赞
踩
前期内容导读:
- Java开源RSA/AES/SHA1/PGP/SM2/SM3/SM4加密算法介绍
- Java开源AES/SM4/3DES对称加密算法介绍及其实现
- Java开源AES/SM4/3DES对称加密算法的验证说明
- Java开源RSA/SM2非对称加密算法对比介绍
- Java开源RSA非对称加密算法实现
- Java开源SM2非对称加密算法实现
- Java开源接口微服务代码框架
- Json在开源SpringBoot/SpringCloud微服务框架中的最佳实践
- 加解密在开源SpringBoot/SpringCloud微服务框架的最佳实践
- 链路追踪在开源SpringBoot/SpringCloud微服务框架的最简实践
- OAuth2在开源SpringBoot/SpringCloud微服务框架的最佳实践
+------------+ | bq-log | | | +------------+ Based on SpringBoot | | v +------------+ +------------+ +------------+ +-------------------+ |bq-encryptor| +-----> | bq-base | +-----> |bq-boot-root| +-----> | bq-service-gateway| | | | | | | | | +------------+ +------------+ +------------+ +-------------------+ Based on BouncyCastle Based on Spring Based on SpringBoot Based on SpringBoot-WebFlux + | v +------------+ +-------------------+ |bq-boot-base| +-----> | bq-service-auth | | | | | | +------------+ | +-------------------+ ased on SpringBoot-Web | Based on SpringSecurity-Authorization-Server | | | | +-------------------+ +-> | bq-service-biz | | | +-------------------+
说明:
bq-encryptor
:基于BouncyCastle
安全框架,已开源 ,加解密介绍
,支持RSA
/AES
/PGP
/SM2
/SM3
/SM4
/SHA-1
/HMAC-SHA256
/SHA-256
/SHA-512
/MD5
等常用加解密算法,并封装好了多种使用场景、做好了为SpringBoot所用的准备;bq-base
:基于Spring框架的基础代码框架,已开源 ,支持json
/redis
/DataSource
/guava
/http
/tcp
/thread
/jasypt
等常用工具API;bq-log
:基于SpringBoot框架的基础日志代码,已开源 ,支持接口Access日志、调用日志、业务操作日志等日志文件持久化,可根据实际情况扩展;bq-boot-root
:基于SpringBoot,已开源 ,但是不包含spring-boot-starter-web
,也不包含spring-boot-starter-webflux
,可通用于servlet
和netty
web容器场景,封装了redis
/http
/定时器
/加密机
/安全管理器
等的自动注入;bq-boot-base
:基于spring-boot-starter-web
(servlet,BIO),已开源 ,提供常规的业务服务基础能力,支持PostgreSQL
/限流
/bq-log
/Web框架
/业务数据加密机加密
等可配置自动注入;bq-service-gateway
:基于spring-boot-starter-webflux
(Netty,NIO),已开源 ,提供了Jwt Token安全校验能力,包括接口完整性校验
/接口数据加密
/Jwt Token合法性校验等;bq-service-auth
:基于spring-security-oauth2-authorization-server
,已开源 ,提供了JwtToken生成和刷新的能力;bq-service-biz
:业务微服务参考样例,已开源 ;
+-------------------+ | Web/App Client | | | +-------------------+ | | v +--------------------------------------------------------------------+ | | Based On K8S | | |1 | | v | | +-------------------+ 2 +-------------------+ | | | bq-service-gateway| +-------> | bq-service-auth | | | | | | | | | +-------------------+ +-------------------+ | | |3 | | +-------------------------------+ | | v v | | +-------------------+ +-------------------+ | | | bq-service-biz1 | | bq-service-biz2 | | | | | | | | | +-------------------+ +-------------------+ | | | +--------------------------------------------------------------------+
说明:
bq-service-gateway
:基于SpringCloud-Gateway
(底层是基于spring-boot-starter-webflux
),用作JwtToken鉴权,并提供了接口、数据加解密的安全保障能力;bq-service-auth
:基于spring-security-oauth2-authorization-server
,提供了JwtToken生成和刷新的能力;bq-service-biz
:基于spring-boot-starter-web
,业务微服务参考样例;k8s
在上述微服务架构中,承担起了服务注册和服务发现的作用,鉴于k8s
云原生环境构造较为复杂,实际开源的代码时,以Nacos
(为主)/Eureka
做服务注册和服务发现中间件;- 以上所有服务都以docker容器作为载体,确保服务有较好地集群迁移和弹性能力,并能够逐步平滑迁移至k8s的终极目标;
- 逻辑架构不等同于物理架构(部署架构),实际业务部署时,还有DMZ区和内网区,本逻辑架构做了简化处理;
概念 | 处理终端 | 处理措施 | 具体指标 | 恢复措施 |
---|---|---|---|---|
熔断 | 客户端 | 当客户端发起请求时,一旦服务方响应比较慢或者发生了异常,其数值超过了阈值, 则客户端在后续的请求中就直接跳过请求服务端,直接响应预设的失败结果 | 1.按照一定的响应时限熔断; 2.按照异常的比例或者类型熔断; | 一般支持半熔断状态,可自动恢复 |
降级 | 服务端/客户端 | 1.作为服务端,当资源紧张时,主动停掉部分不重要的服务,直接响应预设的异常数据给客户端; 2.作为客户端,作用类似于熔断,但是比熔断的作用范围要小。比如:熔断前会先变成半熔断状态,就可以认为是服务降级; | 1.按照业务重要程度来区分 | 自动或者手动恢复 |
限流 | 服务端/客户端 | 1.作为服务端,当客户在特定时间内的请求达到一定阈值时,直接响应超限的异常结果; 2.作为客户端,在特定时间内,达到服务端允许的调用阈值时,直接响应超限异常或者更换调用其他服务方; | 1.服务端约定时间内的最大调用量限流,如:允许客户A每天调用/xx接口100万次; 2.服务端约定QPS限流,如:允许客户A最大的QPS为100; 3.客户端被约定时间内的最大调用量限流,如:每天被允许调用/xx接口500万次; 4.客户端被约定QPS限流,如:被允许的最大QPS为200; | 自动恢复 |
基于此分析,后面就不单独区分熔断和降级了。在不少开源实现上,熔断降级也是共代码共配置,如:nacos。
bq-service-gateway
)、认证服务(bq-service-auth
)、业务服务(bq-service-biz
)三类,今天就好好梳理下微服务熔断降级、限流的设计初衷;接口 | 调用频率 | 调用链路 | 接口分析 | 是否熔断 | 熔断所在服务 | 是否限流 | 限流所在服务 |
---|---|---|---|---|---|---|---|
/oauth/enc/token | 低 | bq-service-gateway 解密 -> bq-service-auth 生成会话 | auth服务生成JwtToken和刷新JwtToken | ✔ | bq-service-gateway | ✔ | bq-service-gateway |
/oauth/token | 低 | bq-service-gateway 透传 -> bq-service-auth 生成会话 | auth服务生成JwtToken和刷新JwtToken | ✔ | bq-service-gateway | ✔ | bq-service-gateway |
/auth/user/get | 高 | bq-service-gateway JwtToken校验 -> bq-service-auth 获取用户信息 | auth服务提供基础的用户获取数据 | ✔ | bq-service-gateway | ✔ | bq-service-gateway |
/demo/enc/qr | 高 | bq-service-gateway JwtToken校验和解密 -> bq-service-biz 生成二维码数据 | biz服务提供的1个业务能力 | ✔ | bq-service-gateway | ✔ | bq-service-gateway /bq-service-biz |
/demo/qr | 高 | bq-service-gateway JwtToken校验 -> bq-service-biz 生成二维码数据 | biz服务提供的1个业务能力 | ✔ | bq-service-gateway | ✔ | bq-service-gateway /bq-service-biz |
- 表格是按照个人在金融场景下的项目经验做了简化处理,每个微服务的职责非常清晰,这也是我对整个系统架构的强制约束,可能和互联网电商等场景下的微服务之间的调用关系偏差比较大。做此表的目的,是希望大家能够从中学会分析场景,建立自己的思维体系,不要一开始什么都想做,要多思考为什么要做;
- 本微服务解决方案产生的根本原因是:先根据业务诉求,设计出了网关、认证服务和业务服务,先满足了业务诉求;在此基础上,为了系统的稳健和合理,才去思考的哪些服务要做熔断降级,哪些服务要做限流。本文也是照着这个顺序来记录的;
- 熔断降级是系统稳定性需求,限流则要分2个场景来看。限流作为系统稳定性需求(一般叫非功能需求,或者DFX),应该由公共模块来承担,保证系统不被破坏;限流作为业务需求(一般叫做功能性需求,比如限制客户群体中的A、B客户的QPS分别为100、200),则应该由业务服务来承担。不是每个服务都有限流需求;
- 如果对这个表格的每个服务是否要做熔断和降级、限流还不是很理解,可以结合下章的技术栈规划来理解;
服务 | 提供能力 | 依赖中间件情况 |
---|---|---|
bq-service-gateway | 1.提供JwtToken/JwtToken刷新校验; 2.提供接口数据解密/加密能力; | 1.Nacos(开发业务功能时选型的服务注册/发现组件); 2.网关在前期设计就是无状态的轻量级应用,不使用DB; 3.熔断降级限流组件; 4.限流组件; |
bq-service-auth | 1.提供生成JwtToken的能力; 2.提供获取用户信息的能力; | 1.Nacos(开发业务功能时选型的服务注册/发现组件); 2.按照上述场景分析,不需要熔断降级限流组件; |
bq-service-biz | 1.提供获取业务二维码生成的能力; | 1.Nacos(开发业务功能时选型的服务注册/发现组件); 2.按照上述场景分析,不依赖熔断降级组件; 3.按照上述场景分析,依赖限流组件; |
- 以上是微服务解决方案新增熔断及限流能力初期的设计,实际上,考虑到后续业务场景的扩展,还是会把熔断降级和限流代码写在微服务的公共基础代码包中,以后就算是要扩展,微服务从没做限流到支持限流的改造也会非常简单,此处先不展开叙述了;
优劣势对比 | Hystrix | resilience4j | Sentinel |
---|---|---|---|
优势 | 活跃度非常高,成熟度高 | Spring推荐取代Hystrix的熔断组件,轻量级,功能强大 | 中文社区活跃度非常高,成熟度高; 鉴于系统已经使用了Nacos,再使用alibaba出品的Sentinel的成本相对较低; 考虑到以后微服务可能会切换到Dubbo框架,也会更丝滑 |
缺点 | 已经停止维护,在大公司是禁用状态,此处也不选 | 新组件,国内使用还不算多; 本人专门写的预研总结如下: resilience4j使用指南 | 为了支持复杂的业务场景,导致熔断规则较为复杂 |
结论 | ✖ | ✖ | ✔ |
场景支持情况对比 | Hystrix | Resilience4j | Sentinel | Redis | Guava |
---|---|---|---|---|---|
作为服务端,接口被调用的天调用量限流 | ✖ | ✖ | ✖ | ✔ | ✔ |
作为服务端,接口被调用的QPS限流 | ✔ | ✔ | ✔ | ✔ | ✔ |
作为服务端,接口被客户ID调用的天调用量限流 | ✖ | ✖ | ✖ | ✔ | ✖ |
作为服务端,接口被客户ID调用的QPS限流 | ✖ | ✖ | ✖ | ✔ | ✖ |
作为客户端,调用第三方接口的天调用量限流 | ✖ | ✖ | ✖ | ✔ | ✖ |
作为客户端,调用第三方接口的QPS限流 | ✖ | ✖ | ✖ | ✔ | ✖ |
集群限流 | ✖ | ✖ | ✔ | ✔ | ✖ |
结论 | ✖ | ✖ | ✔ | ✔ | ✖ |
综上以上诉求:
- 非功能需求的限流组件选择Sentinel,理由是支持集群限流,同时正好反向印证了上面的熔断降级组件选择Sentinel是合理的
- 功能需求的限流组件选择redis,理由如下:
- redis可以充当数据库的限流阈值缓存(限流阈值放在数据库是为了方便管理,但是限流使用阈值又非常频繁,不能一直查数据库,但是又要求阈值一直是最新的,否则就起不到快速流控的效果了。所以一般限流阈值是放在数据库,只有第一次从数据库查入缓存,后面就一直从缓存获取;当阈值更新时,更新数据库后,立即更新redis,则限流阈值也随之生效了。能起到同等效果的是Guava+分布式消息通知组件);
- redis对限流场景非常友好,API非常简洁;
- redis还支持lua脚本编写限流逻辑,以后要加新的限流逻辑时,只需要往代码框架添加新的lua脚本即可,代码本身只需要做很小的改动,甚至没有改动;
服务 | 提供能力 | 依赖中间件情况 |
---|---|---|
bq-service-gateway | 1.提供JwtToken/JwtToken刷新校验; 2.提供接口数据解密/加密能力; | 依赖Sentinel熔断降级及做非功能需求的限流 |
bq-service-auth | 1.提供生成JwtToken的能力; 2.提供获取用户信息的能力; | - |
bq-service-biz | 1.提供获取业务二维码生成的能力; | 依赖Redis做业务限流; |
考虑以后的扩展,实际上
bq-service-biz
也有添加熔断降级组件Sentinel,bq-service-auth
也引入了Sentinel和Redis,只是没有启用相应的规则而已;
java -Dserver.port=8080 -Dcsp.sentinel.dashboard.server=localhost:8080 -Dproject.name=sentinel-dashboard -jar ~/opensource/sentinel-dashboard*.jar
sh ~/opensource/nacos-2.2.1/distribution/target/nacos-server-2.2.1/nacos/bin/startup.sh -m standalone
java -jar ~/opensource/zipkin-server/target/zipkin-server-*exec.jar
redis-server
以上所有启动命名均是基于本人的Mac电脑验证的,系统不同时命令可能有差异,请参考其文字说明。
<!--sentinel熔断降级--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId> <version>2021.0.5.0</version> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId> <version>2021.0.5.0</version> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-sentinel-datasource</artifactId> <version>2021.0.5.0</version> </dependency> <dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-datasource-nacos</artifactId> <version>1.8.6</version> </dependency>
bq-service-gateway
:基于spring-cloud-gateway
,已开源 ,其引入Sentinel的yaml配置 如下:spring: cloud: sentinel: transport: #sentinel服务地址 dashboard: localhost:8080 #默认8719,假如被占用了会自动从8719开始依次+1扫描。直至找到未被占用的端口 port: 8719 #规则持久化配置 datasource: ds1: nacos: #nacos服务地址 server-addr: localhost:8848 #nacos配置文件的名称 dataId: ${spring.application.name} groupId: DEFAULT_GROUP #持久化为json文件 data_type: json rule_type: flow
bq-service-gateway
编写限流的异常响应服务SentinelConfigurer(本例是参考官方例子):@Slf4j @Configuration public class SentinelConfigurer { /** * 定义网关异常时的处理器(使用自定义的错误码) * * @return 熔断降级异常时的处理器 */ @Bean public BlockRequestHandler blockRequestHandler() { return (exchange, e) -> { log.error("happened block exception.", e); ResultCode<?> resultCode = ResultCode.error(ErrCodeEnum.SERVER_ERROR.getCode()); int httpCode = HttpStatus.INTERNAL_SERVER_ERROR.value(); ServerResponse.BodyBuilder bodyBuilder = ServerResponse.status(httpCode); bodyBuilder.contentType(MediaType.APPLICATION_JSON); return bodyBuilder.body(BodyInserters.fromValue(resultCode)); }; } }
各位注意:网上有各种Sentinel版本的初始化配置,截止目前我使用的版本,仅需要如此配置即可。不需要定义
SentinelGatewayFilter
/SentinelGatewayBlockExceptionHandler
等,一定不要盲从。
bq-service-auth
和bq-service-gateway
微服务。curl --location --request POST 'http://localhost:9992/oauth/enc/token?scope=read&grant_type=client_credentials' \
--header 'bq-integrity: ef5b4373e5c24c2c0ccd6700a3e9b70f2b3a80268f9d4b1cc5bf8740e5dd81ca' \
--header 'bq-enc: app001' \
--header 'Authorization: 04d726fd8806b1c0763fead3a90592e592d8abb8290a6b638208c19977ae2d52e3553e507ad72f7e62b33a70fdaca4d8416ec091a59fe7eb8246745b5b6c88c15db580847186e895b29b47a569d2fc4e70e1e63e44aa529b396db710663d9a0c0c0f3a171885f74e0d8eec10469cb757d02b57a850547dc03bfa23'
{ "code": "100001", "msg": "通过", "data": { "access_token": "eyJraWQiOiI2ZTNjNmYzMWI2ODk0MjU0YWUwY2Q4ODdkZWFmMzMxOCIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiJhcHAwMDEiLCJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3Q6OTk5MSIsInJlc291cmNlcyI6WyJcL2F1dGhcL3d4Il0sInNvdXJjZV90eXBlIjoiU0RLIiwidG9rZW5fdHlwZSI6IkJlYXJlciIsImF1ZCI6ImFwcDAwMSIsIm5iZiI6MTY4ODA4NzA5Nywic2NvcGUiOlsicmVhZCJdLCJqd3RfdHlwZSI6InRva2VuIiwiZXhwIjoxNjg4MDg4ODk3LCJpYXQiOjE2ODgwODcwOTcsImp0aSI6IjE3ZGQwZTUxZDExNDRiOGJiNzczNmI0YjgyM2RkNDI4In0.ju7NxhV8HmLct3afTrRxtj2KhUBLBekFAZ5bMq-K2yAW-2R8hJr6cKLy9F3WvMS_RsBRfoPx9e3Kn5glG6FzGBjwGJ08IFfGa3ufurFG5wQZZ39MIBu0hwuSuHZa6FFym51ZxY6QM0AMdgVdJOQL8gKVTH-Ui5lNtGDmqsuc89b1HEwORs-Or7jch1BrLJwNijTzrQN3lcdOXuuOuCh4claNxopuxswz3N2p-DTDWZJVnBh4TvtzJN-ycH5Xsy93a64iJCcrgXczoOi-FCUi6UFsly1WBnYkju09iJvrrJ1AkosZ9L-md4xSK2XrA2VEGUroaXITUlrzKoFRSiC2fA", "token_type": "Bearer", "scope": "read", "refresh_token": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhcHAwMDEiLCJhdWQiOiJhcHAwMDEiLCJuYmYiOjE2ODgwODcwOTcsInNjb3BlIjpbInJlYWQiXSwiand0X3R5cGUiOiJyZWZyZXNoIiwiaXNzIjoiaHR0cDpcL1wvbG9jYWxob3N0Ojk5OTEiLCJzb3VyY2VfdHlwZSI6IlNESyIsInRva2VuX3R5cGUiOiJCZWFyZXIiLCJleHAiOjE2ODgwOTA2OTcsImlhdCI6MTY4ODA4NzA5NywianRpIjoiM2QyMzY0MzljOTNiNDhhNjgyYzViMGY5ZmY0MTk0ODcifQ.Cpz0O3UGpGNkirTkcHqOaJgfyTK8HNaM0PybS3SIylwM98gFtf8pyx2geOMc4UDj4qIfCFwhbigGr8Vq3qvbO7BXNDZENyG17Y65TmHoc2SfedBbpsnJZJ9wcAPoCfR-4pD1_5cX6VvZEjm6NXv_2BCKbaP11Z7qMNCB_iI0gA61HV6_13MzWVSNVd8ukYHdyy3LbbmgkwTcAiw16VbTDmgrcCEHX4vdXXDd8OzmT5QpnsPFLKi3y0nHN4R73U7Sdv3gRCCStmZyBYj4EYpXf__5WIU-2emcYgK-7mtiZx09ZJXJThw47QDG1QtAMy_7r4zTRsTiPahIULBJu2XISA", "client_id": "app001", "jti": "17dd0e51d1144b8bb7736b4b823dd428", "resources": [ "/auth/wx" ], "expires_in": 1800 }, "cost": 0 }
curl --location 'http://localhost:9992/auth/user/get' \
--header 'Authorization: Bearer eyJraWQiOiI2ZTNjNmYzMWI2ODk0MjU0YWUwY2Q4ODdkZWFmMzMxOCIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiJhcHAwMDEiLCJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3Q6OTk5MSIsInJlc291cmNlcyI6WyJcL2F1dGhcL3d4Il0sInNvdXJjZV90eXBlIjoiU0RLIiwidG9rZW5fdHlwZSI6IkJlYXJlciIsImF1ZCI6ImFwcDAwMSIsIm5iZiI6MTY4ODA4NzA5Nywic2NvcGUiOlsicmVhZCJdLCJqd3RfdHlwZSI6InRva2VuIiwiZXhwIjoxNjg4MDg4ODk3LCJpYXQiOjE2ODgwODcwOTcsImp0aSI6IjE3ZGQwZTUxZDExNDRiOGJiNzczNmI0YjgyM2RkNDI4In0.ju7NxhV8HmLct3afTrRxtj2KhUBLBekFAZ5bMq-K2yAW-2R8hJr6cKLy9F3WvMS_RsBRfoPx9e3Kn5glG6FzGBjwGJ08IFfGa3ufurFG5wQZZ39MIBu0hwuSuHZa6FFym51ZxY6QM0AMdgVdJOQL8gKVTH-Ui5lNtGDmqsuc89b1HEwORs-Or7jch1BrLJwNijTzrQN3lcdOXuuOuCh4claNxopuxswz3N2p-DTDWZJVnBh4TvtzJN-ycH5Xsy93a64iJCcrgXczoOi-FCUi6UFsly1WBnYkju09iJvrrJ1AkosZ9L-md4xSK2XrA2VEGUroaXITUlrzKoFRSiC2fA' \
--header 'Content-Type: application/json' \
--data '{
"app_id":"app001"
}'
{
"code": "100001",
"msg": "通过",
"data": {
"start": 0,
"id": "a4b42fa45e6f4dd8a942c34c62a6bf57",
"app_id": "app001",
"app_key": "hao123",
"app_name": "bq-app",
"expire_time": 1715348560590,
"create_time": 1683812560590,
"status": 1
},
"cost": 0
}
{
"code": "100098",
"msg": "内部错误",
"cost": 0
}
SentinelConfigurer#blockRequestHandler
)的日志已打印:23-06-30 21:16:40.800[bq-gateway][Tid:,Sid:][ERROR][c.b.b.s.g.c.SentinelConfigurer_lambda$blockRequestHandler$0] - happened block exception.
com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowException: $D
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Error has been observed at the following site(s):
*__checkpoint ⇢ org.springframework.cloud.gateway.filter.WeightCalculatorWebFilter [DefaultWebFilterChain]
*__checkpoint ⇢ org.springframework.cloud.sleuth.instrument.web.TraceWebFilter [DefaultWebFilterChain]
*__checkpoint ⇢ org.springframework.boot.actuate.metrics.web.reactive.server.MetricsWebFilter [DefaultWebFilterChain]
*__checkpoint ⇢ HTTP POST "/auth/user/get" [ExceptionHandlingWebHandler]
Original Stack Trace:
总结下sentinel限流的验证过程:
- 只需要简单的引入sentinel依赖,并编写自定义的异常响应结果(该异常结果也可以配置,本解决方案是为了统一错误码,所以通过代码来控制)。
- 在通过Sentinel管理界面来做流控时,需要注意一旦重启了网关,其流控配置规则就丢失了,需要重新在Sentinel面板刷新确认下规则在不在,如果不在就需要重新创建,否则限流就不会生效。也相信聪明的你,很快就能想到怎么做规则的持久化。
bq-service-auth
的/auth/user/get
rest接口代码:@Slf4j @RestController public class ClientResourceController { @PostMapping("/auth/user/get") public ResultCode<ClientResource> get(@RequestBody ClientResource client) { long simulatorMills = RandomUtils.nextInt(8, 15) * 100; log.info("current user:{},with simulator:{}ms", JsonUtil.toJson(client), simulatorMills); try { Thread.sleep(simulatorMills); } catch (InterruptedException e) { log.error("InterruptedException:", e); } ClientResource result = dao.get(client); log.info("from db user:{}", JsonUtil.toJson(result)); return ResultCode.ok(result); } /** * dao操作 */ @Autowired private BizDao<ClientResource> dao; }
http://localhost:9992/auth/user/get
,会返回异常错误码,观测日志异常结果如下:23-06-30 21:00:10.023[bq-gateway][Tid:,Sid:][ERROR][c.b.b.s.g.c.SentinelConfigurer_lambda$blockRequestHandler$0] - happened block exception.
com.alibaba.csp.sentinel.slots.block.degrade.DegradeException: null
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Error has been observed at the following site(s):
*__checkpoint ⇢ org.springframework.cloud.gateway.filter.WeightCalculatorWebFilter [DefaultWebFilterChain]
*__checkpoint ⇢ org.springframework.cloud.sleuth.instrument.web.TraceWebFilter [DefaultWebFilterChain]
*__checkpoint ⇢ org.springframework.boot.actuate.metrics.web.reactive.server.MetricsWebFilter [DefaultWebFilterChain]
*__checkpoint ⇢ HTTP POST "/auth/user/get" [ExceptionHandlingWebHandler]
Original Stack Trace:
总结下sentinel熔断降级的验证过程:
- 除了配置规则有区别外,其他的方面基本上都是相同的。
- 在通过Sentinel管理界面来做熔断时,需要注意一旦重启了网关,其熔断配置规则就丢失了,需要重新在Sentinel面板刷新确认下规则在不在,如果不在就需要重新创建,否则熔断就不会生效。也相信聪明的你,很快就能想到怎么做规则的持久化。
bq-service-gateway
需要熔断降级和限流外,就只有bq-service-biz
做功能性限流了,不涉及熔断降级。但是为了验证下Sentinel在常规服务的熔断降级用法,特意又新增了一个/demo/jwk
接口(bq-service-gateway
->bq-service-biz
->bq-service-auth
),在bq-service-biz
服务上做熔断降级和限流;bq-service-biz
服务中引入Sentinel Pom依赖配置:<!--sentinel熔断降级--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId> <version>2021.0.5.0</version> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-sentinel-datasource</artifactId> <version>2021.0.5.0</version> </dependency> <dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-datasource-nacos</artifactId> <version>1.8.6</version> </dependency>
bq-service-biz
服务中引入限流的异常响应服务SentinelWebConfigurer:@Slf4j @Configuration public class SentinelWebConfigurer { /** * 定义异常时的处理器(使用自定义的错误码) * * @return 熔断降级异常时的处理器 */ @Bean public BlockExceptionHandler blockSentinelHandler() { return (request, response, e) -> { log.error("limit block happened.", e); ResultCode<?> resultCode = ResultCode.error(ErrCodeEnum.LIMIT_ERROR.getCode()); ResponseUtil.writeErrorBody(response, JsonUtil.toJson(resultCode, snakeCase)); }; } /** * 是否驼峰式json(默认支持) */ @Value("${bq.json.snake-case:true}") private boolean snakeCase; }
curl --location --request POST 'http://localhost:9992/oauth/enc/token?scope=read&grant_type=client_credentials' \
--header 'bq-integrity: ef5b4373e5c24c2c0ccd6700a3e9b70f2b3a80268f9d4b1cc5bf8740e5dd81ca' \
--header 'bq-enc: app001' \
--header 'Authorization: 04d726fd8806b1c0763fead3a90592e592d8abb8290a6b638208c19977ae2d52e3553e507ad72f7e62b33a70fdaca4d8416ec091a59fe7eb8246745b5b6c88c15db580847186e895b29b47a569d2fc4e70e1e63e44aa529b396db710663d9a0c0c0f3a171885f74e0d8eec10469cb757d02b57a850547dc03bfa23'
/demo/jwk
:curl --location 'http://localhost:9992/demo/jwk' \
--header 'Authorization: Bearer eyJraWQiOiI2ZTNjNmYzMWI2ODk0MjU0YWUwY2Q4ODdkZWFmMzMxOCIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiJhcHAwMDEiLCJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3Q6OTk5MSIsInJlc291cmNlcyI6WyJcL2F1dGhcL3d4Il0sInNvdXJjZV90eXBlIjoiU0RLIiwidG9rZW5fdHlwZSI6IkJlYXJlciIsImF1ZCI6ImFwcDAwMSIsIm5iZiI6MTY4ODE2Njg3NSwic2NvcGUiOlsicmVhZCJdLCJqd3RfdHlwZSI6InRva2VuIiwiZXhwIjoxNjg4MTY4Njc1LCJpYXQiOjE2ODgxNjY4NzUsImp0aSI6ImViZjZjNTBiYTk0YjRjM2FiNjQ4MGY5Yjk3YmM4ODIyIn0.SsFSmUHVUjPi-Xdu8jYl4VV3epZoGeyQSsQPsRGE4nyEjiHoME01j9JF8J3iwvdy9sJhQhol8eV2-_t69doQhTT-Faj_Q6kS4Bjr4A5UZknZ5LPHa-gUX_wyxwpZzkto2ynxvyyGIm2sXhsZJapNdDNpM5-skF_ts8aC3BcNx4qk18e9ZEG6-3V1Oc9IDJJ2G3kGqFRXh52Ot0cS61clXk-2BzYHZ6PDBKhB8pjA7_cGgqzqYBF6IXObyH-XfKIROtnl5gHKpUq69Re-ffjInY4hOEGXssb8bzuzt8nAOLSt15_Td_wKYI1MiVuUtx3fqJcH4mJC7dcPqqojjpY4wg' \
--header 'Content-Type: application/json' \
--data '{
"code":"test123"
}'
/demo/jwk
接口,会看到如下返回结果:{
"code": "100003",
"msg": "流量超限",
"cost": 0
}
[bq-biz][Tid:2e534ec2f199a69b,Sid:c64a17f11048a522][ERROR][c.b.b.c.SentinelWebConfigurer_lambda$blockSentinelHandler$0] - limit block happened.
com.alibaba.csp.sentinel.slots.block.flow.FlowException: null
总结:Sentinel对单个接口的限流已经演示完毕。但是Sentinel熔断降级和限流并不仅限于接口,还可以对里面的部分资源进行熔断降级和限流。比如接口中,有查询数据库/请求其他服务时,可以在其Service方法上添加
@SentinelResource
注解。
@SentinelResource
注解对资源做限流(参见文档 )。简单总结下:@SentinelResource
使用场景比较苛刻,要求方法的参数名和接口的入参一致,没法做全局统一的异常管控,也就是每个限流的@SentinelResource
资源都得写个异常处理逻辑,非常不优雅。代码如下:@Slf4j @RestController public class QrCodeController extends BaseBizController<QrCodeResult, QrCode, QrCodeInner> { @SentinelResource(value = "demo_jwk", blockHandler = "blockHandler") @PostMapping("/demo/jwk") @Override public ResultCode<QrCodeResult> execute(@RequestBody QrCodeInner inner) { log.info("current inner:{}", JsonUtil.toJson(inner)); return restService.execute(inner.toModel()); } protected ResultCode<QrCodeResult> blockHandler(QrCodeInner inner, BlockException e) { log.error("current inner:{},block exception.", JsonUtil.toJson(inner), e); return ResultCode.error(ErrCodeEnum.LIMIT_ERROR.getCode()); } /** * 注入自定义的Rest服务 */ @Resource(name = DemoConst.DEMO_REST_SERVICE) private RestService<QrCodeResult, QrCode> restService; }
/demo/jwk
接口,会看到如下返回结果:{
"code": "100003",
"msg": "流量超限",
"cost": 0
}
[ERROR][c.b.b.w.d.QrCodeController_blockHandler] - current inner:{"code":"test123"},block exception.
com.alibaba.csp.sentinel.slots.block.flow.FlowException: null
@SentinelResource
都要写一个异常处理逻辑实在难以接受,下面介绍另外一种解决方案。@SentinelResource
资源,但是不给对应的异常实现,代码如下:@Slf4j @RestController public class QrCodeController extends BaseBizController<QrCodeResult, QrCode, QrCodeInner> { @SentinelResource(value = "demo_jwk", blockHandler = "blockHandler") @PostMapping("/demo/jwk") @Override public ResultCode<QrCodeResult> execute(@RequestBody QrCodeInner inner) { log.info("current inner:{}", JsonUtil.toJson(inner)); return restService.execute(inner.toModel()); } /** * 注入自定义的Rest服务 */ @Resource(name = DemoConst.DEMO_REST_SERVICE) private RestService<QrCodeResult, QrCode> restService; }
blockHandler
逻辑。配置了资源限流规则,阈值达到时,异常就会抛到SpringBoot框架中。GlobalExceptionHandler
中,新增熔断降级与限流的中断异常BlockException处理逻辑:@Slf4j @ControllerAdvice public class GlobalExceptionHandler extends BaseExceptionHandler { @ExceptionHandler({CommonException.class, NoHandlerFoundException.class, Exception.class}) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ResponseBody public ResultCode<?> handleErr(HttpServletRequest req, Exception e) { Throwable ex = e; if (e instanceof UndeclaredThrowableException) { UndeclaredThrowableException realEx = (UndeclaredThrowableException)e; ex = realEx.getUndeclaredThrowable(); } if (ex instanceof BlockException) { log.error("sentinel block happened.", ex); return ResultCode.error(ErrCodeEnum.LIMIT_ERROR.getCode()); } return handle(req.getRequestURI(), e); } }
[ERROR][c.b.b.h.GlobalExceptionHandler_handleErr] - sentinel block happened.
com.alibaba.csp.sentinel.slots.block.flow.FlowException: null
总结下
4.2章节
的主要内容:
- 本章节本来在场景分析中,是不需要做非功能熔断降级与限流的,但是考虑到后续业务的复杂性,还是把相关的能力给补充上了,只要Sentinel面板中不配置相关的规则,也不会有什么影响;
- 接口(Rest)的非功能熔断降级与限流使用非常简单,且全局配置一个异常Handler(参见本开源项目中的SentinelWebConfigurer类),再在Sentinel面板中配置规则即可;
- 资源(
@SentinelResource
注解标注)的非功能熔断降级与限流使用则较为复杂与苛刻,本意是接口中可能会涉及查询数据库、查询缓存、调用第三方接口等,可以分别定义多个资源的熔断降级与限流资源(一般是在对应的Service上添加@SentinelResource
注解)来实现,再在Sentinel面板配置对应规则,同时还要求Service的方法入参和异常Handler的参数一致,这样就很难做到像上面的接口那样由一个异常Handler统一处理。本方案目前采取了通过全局异常来做统一处理,但是在某些降级场景下,服务的某几个资源降级了,整体服务还要求是成功时,全局异常的方案就不适用了。
先讲下客户限流的核心设计思路:
通过一个表结构设计就搞定了数据存储,查询优先级时略微有点复杂;
核心代码如下;
WebMvcConfigurer
的代码如下:@Slf4j @Configuration public class WebMvcConfigurer extends BaseWebConfigurer { @Override protected void addInterceptors(InterceptorRegistry registry) { super.addInterceptors(registry); //默认关闭限流,只有业务微服务才需要打开 if (!limitEnabled) { log.info("disabled limit access."); return; } //添加一个限流器,仅拦截特定的url InterceptorRegistration registration = registry.addInterceptor(limitHandler); GlobalDict param = new GlobalDict().toDict(); List<GlobalDict> batchDict = dictService.getBatch(param); Set<String> urls = Sets.newHashSet(); if (!CollectionUtils.isEmpty(batchDict)) { for (GlobalDict dict : batchDict) { urls.add(dict.getValue()); } } registration.addPathPatterns(Lists.newArrayList(urls)); } /** * 字典服务 */ @Resource(name = BootConst.GLOBAL_DICT_SVC) private BaseBizService<GlobalDict> dictService; /** * 限流handler */ @Resource(name = BootConst.CLIENT_LIMIT_SVC) private HandlerInterceptor limitHandler; /** * 直接拦截的url */ @Value("${bq.limit.enabled:false}") private boolean limitEnabled; }
- 可支持每个服务配置是否开启限流;
- 限流的接口在字典表中配置;
ConfigBizServiceImpl
代码为:@Service(BootConst.GLOBAL_CONF_SVC) public class ConfigBizServiceImpl extends BaseBizService<GlobalConfig> { @Override public GlobalConfig get(GlobalConfig model) { List<GlobalConfig> batch = this.getBatch(model); return getBest(batch); } @Override protected List<GlobalConfig> queryBatchByKey(String key) { return dao.getBatch(GlobalConfig.toBean(key)); } @Override protected List<GlobalConfig> queryBatchByKeys(Iterable<? extends String> keys) { List<GlobalConfig> configs = Lists.newArrayList(); for (Iterator<? extends String> iterator = keys.iterator(); iterator.hasNext(); ) { String key = iterator.next(); configs.add(GlobalConfig.toBean(key)); } return dao.batchGet(configs); } @Override protected List<GlobalConfig> bestChoose(List<GlobalConfig> batch) { List<GlobalConfig> bestResults = Lists.newArrayList(); GlobalConfig best = getBest(batch); if (!best.isEmpty()) { bestResults.add(best); } return super.bestChoose(batch); } /** * 从匹配的4种组合数据下获取最佳的配置 * <p> * 4种配列组合及其优选顺序(从高到低): * 1.clientId和urlId都存在 * 2.clientId存在,urlId不存在 * 3.clientId不存在,urlId存在 * 4.clientId和urlId都不存在 * * @param batch 批量结果 * @return 最佳结果 */ private GlobalConfig getBest(List<GlobalConfig> batch) { if (CollectionUtils.isEmpty(batch)) { //没数据时返回空对象,避免数据库被击穿(此服务因为涉及接口限流,执行频率太高) return new GlobalConfig(); } for (GlobalConfig config : batch) { if (!StringUtils.isEmpty(config.getClientId()) && !StringUtils.isEmpty(config.getUrlId())) { return config; } } for (GlobalConfig config : batch) { if (!StringUtils.isEmpty(config.getClientId()) && StringUtils.isEmpty(config.getUrlId())) { return config; } } for (GlobalConfig config : batch) { if (StringUtils.isEmpty(config.getClientId()) && !StringUtils.isEmpty(config.getUrlId())) { return config; } } return batch.get(0); } /** * 全局配置dao */ @Autowired private BizDao<GlobalConfig> dao; }
LimitServiceImpl
代码如下:@Service public class LimitServiceImpl implements LimitService { @Override public boolean qpsLimit(LimitConfig config) { List<String> keys = Lists.newArrayList(config.toKey()); Object[] params = new Object[Const.THREE]; int i = 0; //限流大小 params[i++] = MathUtil.getLong(config.getSvcValue(), qps); //限流单位时间(默认为1000,即秒,也可以支持更大时间粒度) params[i++] = MathUtil.getLong(config.getUnit(), qpsUnit); //当前时间 params[i] = System.currentTimeMillis(); Boolean result = redis.execute(CommonBootConst.QPS_REDIS_SCRIPT_SVC, Boolean.class, keys, params); return Boolean.TRUE.equals(result); } @Override public boolean maxLimit(LimitConfig config) { List<String> keys = Lists.newArrayList(config.toKey()); Object[] params = new Object[Const.TWO]; int i = 0; params[i++] = MathUtil.getLong(config.getSvcValue(), max); //计算过期时间为当天的00:00:00对应的毫秒+配置的阈值(默认支持天,也可以支持更大粒度,比如月) params[i] = TimeUtil.getTodayUtcMills() + MathUtil.getLong(config.getUnit(), maxUnit); Boolean result = redis.execute(CommonBootConst.MAX_REDIS_SCRIPT_SVC, Boolean.class, keys, params); return Boolean.TRUE.equals(result); } }
access_limit.lua
脚本代码如下:-- 获取限流key local limitKey = KEYS[1] --redis.log(redis.LOG_WARNING,'access limit key is:',limitKey) -- 调用脚本传入的限流大小 local limitNum = tonumber(ARGV[1]) -- 传入数据的有效期(ms,比如1秒) local expireMills = tonumber(ARGV[2]) -- 传入当前时间(ms) local nowMills = tonumber(ARGV[3]) --清除过期数据 local expiredTimeMills = nowMills-expireMills; redis.call('zremrangebyscore',limitKey,0,expiredTimeMills); local count = redis.call('zcard',limitKey); -- 获取当前流量大小 local countNum = tonumber(count or "0") --redis.log(redis.LOG_WARNING,'access countNum=',countNum) --是否超出限流值 if countNum + 1 > limitNum then -- 拒绝访问 return true else -- 没有超过阈值,设置当前访问数量+1 redis.call('zadd',limitKey,nowMills,nowMills) -- 设置过期时间(ms,相当于给这个zset key自动续期) redis.call('pexpire',limitKey,expireMills) -- 放行 return false end
max_limit.lua
脚本代码如下:-- 获取限流key local limitKey = KEYS[1] --redis.log(redis.LOG_WARNING,'max limit key is:',limitKey) -- 调用脚本传入的限流大小 local limitNum = tonumber(ARGV[1]) --redis.log(redis.LOG_WARNING,'max limit num is:',limitNum) -- 传入过期时间(ms) local expireMills = tonumber(ARGV[2]) --redis.log(redis.LOG_WARNING,'max limit expire is:',expireMills) local count = redis.call('get',limitKey); --redis.log(redis.LOG_WARNING,'max limit count is:',count) if count then -- 获取当前流量大小 local countNum = tonumber(count or "0") -- redis.log(redis.LOG_WARNING,'max countNum=',countNum) --是否超出限流值 if countNum + 1 > limitNum then -- 拒绝访问 return true else -- 没有超过阈值,设置当前访问数量+1 redis.call('incrby',limitKey,1) -- 放行 return false end else -- 没有超过阈值,设置当前访问数量+1 redis.call('set',limitKey,1) -- 设置过期时间(ms) redis.call('pexpire',limitKey,expireMills) -- 放行 return false end
bq-service-biz
的/demo/jwk
接口为例进行验证;select * from bq_global_dict where value='/demo/jwk';
[
{
"id": "d211",
"key": "DEMO_QR_API",
"value": "/demo/jwk",
"type": "ClientUrl"
}
]
select * from bq_global_config where url_id='DEMO_QR_API' or client_id='DEMO_QR_API';
[ { "id": "svc301", "svc_id": "client.to.channel", "client_id": "app001", "url_id": "DEMO_QR_API", "svc_value": "DEMO_CHANNEL_JWK_API", "create_time": 1566382443412 }, { "id": "svc431", "svc_id": "client.limit.qps", "client_id": "app001", "url_id": "DEMO_QR_API", "svc_value": "20", "create_time": 1566382443412 }, { "id": "svc432", "svc_id": "client.limit.max", "client_id": "app001", "url_id": "DEMO_QR_API", "svc_value": "20", "create_time": 1566382443412 }, { "id": "svc434", "svc_id": "channel.limit.qps", "client_id": "DEMO_QR_API", "url_id": "DEMO_CHANNEL_JWK_API", "svc_value": "20", "create_time": 1566382443412 }, { "id": "svc435", "svc_id": "channel.limit.max", "client_id": "DEMO_QR_API", "url_id": "DEMO_CHANNEL_JWK_API", "svc_value": "20", "create_time": 1566382443412 } ]
/demo/jwk
接口的curl命令,会看到如下返回结果:{
"code": "100003",
"msg": "流量超限",
"cost": 0
}
[bq-biz][Tid:21ebec3d3b78cbbd,Sid:00c5db8e37856b77][ERROR][c.b.b.h.i.LimitHandlerImpl_limit] - [{"accessId":"app001","urlId":"DEMO_QR_API","config":{"start":0,"clientId":"app001","urlId":"DEMO_QR_API","svcId":"client.limit.max.unit","createTime":0}}]reach max limit:{"start":0,"clientId":"app001","urlId":"DEMO_QR_API","svcId":"client.limit.max","svcValue":"20","createTime":0,"unit":"86400000"}.
总结下客户限流的特点:
- 客户限流可能存在较多维度,需要考虑比较灵活的方案才能较好满足后续的场景扩展;
- 客户限流跟前面的熔断降级思考方式和实现完全不同,前者是要熟悉相关的框架,后者则需要找到合适的框架,还有有业务解决方案的设计和整合能力;
ChannelLimitAop
代码:@Component @Aspect public class ChannelLimitAop extends BaseAop { @Before("execution (* com.biuqu.boot.remote.RemoteService+.*invoke*(..))") @Override public void before(JoinPoint joinPoint) { super.before(joinPoint); } @Override protected void doBefore(Method method, Object[] args) { boolean isLimit = limitHandler.limit(method, args); if (isLimit) { throw new CommonException(ErrCodeEnum.LIMIT_ERROR.getCode()); } } /** * 注入渠道限流 */ @Autowired private ChannelLimitHandler limitHandler; }
ChannelLimitHandler
代码如下:@Component public class ChannelLimitHandler { /** * 限流执行方法 * * @param method 切面拦截的方法对象 * @param args 方法参数 * @return true表示限流, false表示不限流 */ public boolean limit(Method method, Object[] args) { if (null == args || args.length <= 0) { return false; } Object element = args[0]; if (element instanceof BaseBiz) { BaseBiz biz = (BaseBiz)element; if (StringUtils.isEmpty(biz.getChannelId()) || StringUtils.isEmpty(biz.getUrlId())) { return false; } AccessLimit model = new AccessLimit(); model.setAccessId(biz.getUrlId()); model.setUrlId(biz.getChannelId()); model.setConfig(LimitConfig.channelConf()); return limitHandler.limit(model); } return false; } /** * 注入接入限流服务(封装了qps限流和最大调用量限流) */ @Autowired private LimitHandler limitHandler; }
细心的朋友会发现,其服务引入的
LimitHandler
和客户限流的LimitHandler
是同一个服务。
@Slf4j @Service(DemoConst.DEMO_REMOTE_SERVICE) public class QrRemoteServiceImpl extends BaseRemoteService<QrCodeResult, QrCode> { @Override protected String call(QrCode model, boolean snake) { log.info("current param1:{},snake:{}", JsonUtil.toJson(model), snake); return callRemote(model, snake); } protected String callRemote(QrCode model, boolean snake) { log.info("current param2:{},snake:{}", JsonUtil.toJson(model), snake); String channelUrl = this.getChannelUrl(model); if (StringUtils.isEmpty(channelUrl)) { log.error("[{}]no channel[{}] url found.", model.getUrlId(), model.getChannelId()); return null; } ResponseEntity<String> jwkJson = restTemplate.getForEntity(channelUrl, String.class); log.info("remote result:{}", jwkJson.getBody()); return jwkJson.getBody(); } @Override protected ResultCode<QrCodeResult> toModel(String json, TypeReference<ResultCode<QrCodeResult>> typeRef, boolean snake) { if (null == json) { return ResultCode.error(ErrCodeEnum.SERVER_ERROR.getCode()); } QrCodeResult result = new QrCodeResult(); result.setOpenId(Hex.toHexString(json.getBytes(StandardCharsets.UTF_8))); return ResultCode.ok(result); } /** * 注入远程服务 */ @Autowired private CommonRestTemplate restTemplate; }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。