本文 的 原文 地址
尼恩说在前面
在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如得物、阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试资格,遇到很多很重要的面试题:
流量暴涨,突然提升100倍QPS, 怎么办?
如果某个业务量突然提升100倍QPS你会怎么做?
最近有小伙伴在面试 阿里,又遇到了相关的面试题。小伙伴懵了,因为没有遇到过,所以支支吾吾的说了几句,面试官不满意,面试挂了。
有些伙伴一听完题目,就不假思索回答,就是 扩容 / 加机器 。
当然,这个不能算错,但是你只得其中一小点的分数,肯定不及格的。
作为一名优秀的后端架构师,我们应当从多个维度去思考这个问题,尽可能回答完整、详细、正确。
所以,尼恩给大家做一下系统化、体系化的梳理,使得大家内力猛增,可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提”。
尼恩 团队给大家做了一个总结出来一个 (“压、分、缓、异” / ” 限、降、扩 、监” / ” 演 ”) 9字 真经 ,每一个维度都不能少哦 :
当然,这道面试题,以及参考答案,也会收入咱们的 《尼恩Java面试宝典PDF》V171版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请关注本公众号【技术自由圈】获取,回复:领电子书
另外,此文的内容, 收入尼恩的《 五大 GC 学习圣经 》PDF , 帮助大家 吊打 面试官。
高并发架构 9字 真经(“压、分、缓、异” / ” 限、降、扩 、监” / ” 演 ”)
1. 压(压力测试)
通过模拟线上真实流量和场景,验证系统在极限负载下的表现。
需关注吞吐量、响应时间、错误率等核心指标,并使用工具如JMeter或WRK进行故障注入测试流量。
2. 分(分而治之 )
- 服务拆分:微服务架构隔离核心功能,提升扩展性。
- 数据分片:如订单号哈希分库分表,解决集中式存储瓶颈。
- 读写分离:降低主库压力,提高查询性能。
3. 缓(多级缓存)
构建客户端/CDN/Redis/数据库多级缓存,减少数据库访问。需处理缓存穿透(布隆过滤器)、雪崩(随机过期时间)等问题。
4. 异(异步化)
- 消息队列:如RocketMQ实现流量削峰和解耦。
- 异步I/O:非阻塞处理提升吞吐量。
5. 限(流量控制)
- 算法:令牌桶、漏桶或动态限流(如Guava RateLimiter)。
- 分级限流:按API优先级差异化控制58。
6. 降(服务降级)
流量暴增时关闭非核心功能(如评论推荐),保障核心链路可用。
7. 扩(弹性扩展)
- 横向扩展:通过容器化/K8s快速扩容实例。
- 资源池化:如数据库连接池复用资源。
8. 监(立体监控)
-
指标监控:Prometheus+Granfa看板
-
链路追踪:Skywalking全链路分析
-
智能预警:基于机器学习异常检测
9. 演(混沌工程)
模拟网络延迟、节点宕机等故障,验证系统容错能力,实现系统在 局部故障/分区故障场景下,整体的可用性能达到 99.99% 甚至 999.99% 。
9字真经 的 实战价值
某云厂商2023年42个崩溃案例显示,未分片数据库锁冲突(占比89%)、缓存穿透(76%)等是高频故障原因。
分片后系统QPS从2.1万提升至14.6万,异步处理吞吐量提升6倍,动态限流使恶意请求拦截率达99.2%,混沌演练将故障恢复时间从136分钟缩短至23分钟。
高并发设计无银弹,需通过“压、分、缓、异” / ” 限、降、扩 、监” / ” 演 ” 9字 真经 构建系统性解决方案,并结合实战持续优化架构,提升系统韧性与可用性。
第一字真金: 压(压力测试)
通过模拟线上真实流量和场景,验证系统在极限负载下的表现。
需关注吞吐量、响应时间、错误率等核心指标,并使用工具如JMeter或WRK进行故障注入测试流量。
设计压测方案
明确压测目标与范围
-
确定目标 :
明确压测的目的,如评估系统在高并发场景下的性能表现、确定系统的最大承载能力、验证系统在特定业务场景下的稳定性等。
例如,目标是确定系统在 100 倍 QPS 下的各项性能指标(如响应时间、吞吐量、错误率等)。
-
划定范围 :
确定压测涉及的业务模块、功能接口、服务器组件等。
如对电商系统的商品详情页接口、下单接口、支付接口等进行压测,以及对应的 Web 服务器、应用服务器、数据库服务器、缓存服务器等。
准备压测环境
-
搭建测试环境 :
尽量模拟生产环境的硬件配置、网络拓扑结构、软件版本等,确保测试结果具有代表性。
如在测试环境中(或者 预生产环境) ,配置与生产环境相同数量和型号的服务器(共 38 台,其中 Web 服务器 CPU 配置为 8 核、内存 16GB,应用服务器 CPU 配置为 16 核、内存 32GB 等),安装相同的操作系统(如 CentOS 7.9)、中间件(如 Tomcat 9.0.68 版本)、数据库(如 MySQL 8.0.30 版本)等。
-
部署压测工具 :
选择合适的压测工具,如 JMeter cluster( 支持模拟 10 万 + 并发用户)、LoadRunner( 能精准模拟不同业务场景下的复杂用户行为)、Gatling( 擅长处理高并发场景下的性能测试)等,并在测试机上进行部署和配置。
测试机的性能应与实际用户设备相当(如配置 CPU 为 4 核、内存 8GB 的测试机 20 台),避免测试机性能不足影响压测结果。
设计压测场景与脚本
-
制定场景 :
根据业务流程和用户行为,设计多种压测场景。如模拟正常业务流量场景、高峰流量场景、突发流量场景等。
例如,在电商促销活动场景中,模拟大量用户同时浏览商品、加入购物车、下单支付的操作。
-
编写脚本 :
使用压测工具编写测试脚本,定义请求的 URL、参数、请求头、请求频率、并发用户数等。
脚本应能够模拟不同类型的用户请求和操作行为,如随机访问不同的商品页面、提交表单数据等。
设置压测指标与监控
-
确定指标 :
明确压测过程中需要监控的性能指标,如系统的响应时间(包括平均响应时间、最大响应时间、最小响应时间)、吞吐量(每秒处理的请求数)、并发用户数、服务器资源利用率(CPU、内存、磁盘 I/O、网络带宽)、错误率等。
-
部署监控工具 :
在服务器端和网络设备上部署监控工具,如 Zabbix、Prometheus、Grafana 等,实时监控服务器的各项资源指标和网络流量情况。
同时,在应用层面添加日志记录和监控点,用于分析业务代码的执行情况和性能瓶颈。
执行压测并收集数据
-
逐步加压 :
按照预定的压测场景和脚本,逐步增加并发用户数或请求频率,模拟流量从低到高的变化过程。
比如,阶梯式增长:5万→10万→15万→20万(每级稳定15分钟)
在每个压力级别下,运行足够长的时间(如每个压力级别运行 30 分钟,确保系统各项指标稳定),使系统达到稳定状态,收集稳定状态下的性能数据(如每 5 分钟收集一次数据,每个压力级别收集 6 组数据,取平均值作为该压力级别的性能表现)
-
数据收集 :
使用压测工具和监控工具收集各项性能指标数据,并记录系统在不同压力下的表现。如在 JMeter 中,可以查看每个请求的响应时间、吞吐量、错误率等统计信息;在监控工具中,可以获取服务器的 CPU、内存等资源使用情况。
分析压测结果与优化调整
-
结果分析 :
对收集到的数据进行分析,绘制性能曲线, 如响应时间 - 并发用户数曲线,吞吐量 - 时间曲线等 。通过性能曲线, 找出系统的性能瓶颈。
如发现当并发用户数达到 4 万时,响应时间从 200 毫秒急剧上升至 600 毫秒,可能是服务器资源不足(此时 CPU 使用率达到 90%)或代码存在性能问题(如某个数据库查询语句未添加索引,导致执行时间过长)。
-
优化调整 :
根据分析结果,对系统进行优化调整,然后重新进行压测。
优化的方式 一:优化代码:对未添加索引的数据库查询语句添加索引,优化前查询时间 500 毫秒,优化后查询时间 50 毫秒;
优化的方式 二:对冗余的业务逻辑进行精简,减少不必要的计算和 I/O 操作
优化的方式 三:增加硬件资源(如增加 2 台应用服务器,CPU 配置为 16 核、内存 32GB,将数据库服务器的内存从 64GB 升级至 128GB)
优化的方式四:调整配置参数(如调整 Tomcat 的线程池大小,从默认的 200 提升至 500,优化数据库连接池配置,最大连接数从 100 增加至 200 等)。
然后重新进行压测(按上述优化措施调整后,重新执行压测,压测时长 2 小时,涵盖正常、高峰、突发三种场景),验证优化效果,直到系统性能满足预期目标(如响应时间控制在 200 毫秒以内,吞吐量达到 10000 请求 / 秒,错误率低于 0.1%)。
第二字真金:分( 分而治之)
分 就是 分而治之。
服务层 分而治之: 服务层的横向扩展
接入层的核心职责: 接收用户请求, 通过多种策略(如缓存策略) 提升系统的读写吞吐量,通过多种策略(如限流策略) 防止系统雪崩, 通过多种策略(如黑名单) 提升系统的访问安全性。
服务层的核心职责: 实现核心业务逻辑, 接收用户请求参数, 完成业务处理之后最终返回HTML或者JSON给用户。
在Spring Cloud分布式应用中, 接入层通过LVS或者Nginx可以将请求路由到服务层多个Spring Cloud Gateway网关服务, 请求顺利从接入层进入服务层。 服务层的横向扩展高并发架构可以分为:
- 微服务网关的高并发横向扩展。
- 微服务Provider(提供者) 的高并发横向扩展。
- 微服务Provider的自动伸缩。
微服务网关 横向扩展
当微服务层网关Spring Cloud Gateway成为瓶颈时, 可以进行横向扩展: 增加服务节点数量, 新增Spring Cloud Gateway服务的部署。
在接入层Nginx配置中配置新的Spring Cloud Gateway的IP和端口就能扩展服务网关的性能, 做到理论上的无限高并发。
微服务 Provider 横向扩展
微服务网关Spring Cloud Gateway, 相对于接入层网关Nginx区别在于,微服务网关能自动发现后端微服务Provider(提供者) , 而接入层网关仅有反向代理、 负载均衡的作用, 并不能进行后端微服务Provider的自动发现。 当微服务Provider有所变化时, 微服务网关,借助注册中心(Nacos,Eureka等),能够对目标Provider进行动态发现、 动态的负载均衡, 而接入层网关却做不到这点。
在生产环境上, 一旦发现Java服务(微服务Provider) 集群的吞吐能力不足, 具体来说就是集群吞吐量不足以支撑线上的用户请求规模, 就需要进行微服务Provider的高并发横向扩展, 开启新的Java服务。
微服务 Provider 的自动伸缩
微服务Provider的自动伸缩也叫作自动扩容、 自动缩容。 自动伸缩是和手动伸缩相对而言的,就是通过运维工具实现资源监控, 然后根据资源的紧张程度自动开启新的Java服务( 微服务Provider) , 或者自动关闭一些空闲的Java服务。
传统的微服务Provider扩容策略是手动的, 由运维人员手动进行。 一旦发现现有的微服务Provider能力不够, 运维人员就会开启新的Java服务并且动态地加入集群; 一旦发现现有的微服务Provider能力有富余, 运维人员就会关闭部分Java服务, 这些Provider会动态地离开集群(自动下线)。
手动伸缩的问题是无法面对突发流量。 一旦出现突发流量, 等到运维人员收到监控系统的资源预警信息后再去进行微服务Provider扩容, 中间会有较大的时间延迟, 在这个时间延迟内, 系统可能已经发生雪崩了。 所以, 对于会出现突发流量的系统需要用到自动扩容、 自动缩容的策略。
常见的微服务Provider的自动伸缩策略有以下两种:
- 通过Kubernetes HPA组件实现自动伸缩。
- 通过微服务Provider自动伸缩伺服组件实现自动伸缩。
数据层的横向扩展
在数据规模量很大的场景下, 数据层数据库涉及数据横向扩展高并发架构, 将原本存储在一台服务器上的数据层数据库拆分到不同服务器上, 以达到扩充系统性能的目的。
数据层的横向扩展高并发架构主要包括两个方面:
- 结构化数据的高并发架构方案: 分库分表。
- 异构数据、 复杂查询的高并发架构方案: NoSQL海量存储(如ElasticSearch、 HBASE、ClickHouse) 。
结构化数据:分库分表
业内对于结构化数据库(主要针对MySQL) 有一些比较共识的参考数据(基线值) :
- 单表的记录参考上限: 500万~1000万
- 单库的TPS上限: 1000~1500 TPS。
在进行架构设计时, 如果没有数据库实际的吞吐量/并发量指标值, 可以按照这些基线值进行架构设计。
为什么分库分表
为什么要分库分表? 随着数据量的增长, 数据库很容易产生性能瓶颈: IO瓶颈、 CPU瓶颈。
分库:就是将一个数据库分为多个数据库。 一个库一般最多支撑并发2000TPS, 较为合理是1500TPS。 如果吞吐量需要达到1万TPS, 则考虑分为8个库, 一个库支撑1250TPS。
分表:就是把一个表的数据放到多个表中, 然后查询的时候只查一个表。
场 景 | 分库分表前 | 分库分表后 |
---|---|---|
并发支撑情况 | 数据库单机部署, 扛不住高并发 | 数据库从单机到多机, 能承受的并发增加了多倍 |
磁盘使用情况 | 数据库单机磁盘容量几乎撑满 | 拆分为多个库, 数据库服务器磁盘使用率大大降低 |
SQL 执行性能 | 单表数据量太大, SQL 执行效率越来越慢 | 单表数据量减少, SQL 执行效率明显提升 |
亿级库表规模分库分表方案
(1)表的数量规划
假设一个系统, 其中某个表每天增长100万记录, 2年内保持稳定增长, 那么表的数据量预估为:两年数据记录总量是7.3亿(每天100万× 730天) 。
假设每个表的标准值为500万, 库中表的数量平均是146个, 若用2的幂次方形式表示, 则比较接近146的是27, 即128。 接下来, 按照128个表进行折算, 单个表存放570万(570万=7.3亿/128) 的
数据, 其数据规模也是可以接受的。
按照上面的算法, 最终按照128个的数量进行表的规划
(2)库的数量规划
假设按照TPS峰值1万的要求进行表库的规划:
相对乐观一点, 假设每个库正常承载的写入并发量是1500TPS。 那么8个库就可以承载8× 1500
= 12000TPS的写并发。
如果每秒写入不超过1万TPS, 8个库是可以胜任的。 如果每秒写入超过1万TPS, 比如5万TPS呢? 那么可以通过RocketMQ削峰+批写入的异步降级策略, 进行高并发异步批量写入。
由于RocketMQ集群的写入吞吐量可以轻松到达10万TPS级别, 所以通过RocketMQ削峰+批写入的异步降级策略, 10万TPS以内的数据写入吞吐量还是比较容易实现的。
异构化数据查询方案
比如对于订单库, 当对其分库分表后, 如果不是按照数据分片键而是按照分片键之外的商家维度或者按照用户维度进行查询, 那么是非常困难的, 性能也是非常低的。 如果需要进行跨库查询或者按照分片键之外的复杂维度去查询, 可以通过异构数据库来解决这个问题。
ES+HBase组合方案的查询过程大致如下:
- 先根据搜索条件去ES相应的索引上查询符合条件的Rowkey值。
- 然后用Rowkey值去HBase查询, 这一步查询速度极快。
ES+HBase组合方案的特点: 将索引与数据存储隔离。HBase的一个高速的特点是根据Rowkey查询速度超快,通过这种以空间换时间的方案, 可以解决根据各种字段条件进行复杂查询、 跨库查询的业务需求。
ES的优势是进行高速的分布式全文检索, 所以那些参与条件检索的字段都会在ES中建一份索引, 例如商家、 商品名称、 订单日期等。 HBase的优势是支持海量存储, 所有全量数据的副本都保存一份到Base中。
第三字真金:缓(多级缓存)
构建客户端/CDN/Redis/数据库多级缓存,减少数据库访问。
同时, 核心的 redis 缓存, 需处理 缓存穿透(布隆过滤器)、缓存雪崩(随机过期时间)、缓存 击穿 (加锁访问DB) 等问题。
在缓存层可以采用多级缓存方案,来应对流量突然激增问题,
Java 本地缓存
Java应用本地缓存简单一点的可以是Map, 复杂一点的可以使是Guava、 Caffeine这样的第三方组件。
Java应用本地缓存类似于寄生虫, 占用的是JVM进程的内存空间。
Guava Cache是Google开源的一款本地缓存工具库, 它的设计灵感来源于ConcurrentHashMap,使用多个Segments方式的细粒度锁, 在保证线程安全的同时, 支持高并发场景需求, 同时支持多种类型的缓存清理策略, 包括基于容量的清理、 基于时间的清理、 基于引用的清理等。
Caffeine是Spring 5默认支持的缓存, Spring抛弃Guava转向了Caffeine, 可见Spring对它的看重。Caffeine因为使用Window TinyLfu 回收策略而提供了一个近乎最佳的命中率。
Caffeine的底层数据存储采用ConcurrentHashMap。
因为Caffeine面向JDK8, 而在JDK8中ConcurrentHashMap增加了红黑树, 所以在Hash冲突严重时Caffeine也能有良好的读性能。
如果要在Java应用中使用本地缓存, 建议使用Caffeine组件
Nginx 本地缓存
Nginx有三类本地缓存:
-
proxy_cache(代理缓存)
-
shared_dict(共享字典)
-
lua-resty-lrucache缓存
多级缓存架构
根据分布式缓存、 本地缓存的特点, 对缓存进行分级。 在整个系统架构的不同系统层级进行数据缓存, 以提升访问的高并发吞吐量。
从Java程序在访问缓存时的距离远近的角度对缓存进行分级, 可以将缓存划分为:
- 一级缓存: JVM本地缓存, 如Guava Cache、 Caffeine等。
- 二级缓存: 经典的分布式缓存, 如Redis Cluster集群。
- 三级缓存: 在接入层的本地缓存, 如Nginx的shared_dict(共享字典) 。
不同热度的数据可以按照不同的层级进行存放:
- 对于访问热度最高的数据, 可以在接入层Nginx的shared_dict(共享字典) 缓存, 此为三级缓存(规模在1GB以内) , 比如秒杀系统中的优惠券详情、 秒杀商品详情信息, 这些信息访问得非常频繁。
- 对于访问热度没有那么高但也访问频繁的数据, 可以在JVM进程内缓存(如Caffeine) ,这部分的数据规模也不能太大, 大概在1GB以内, 作为一级缓存。、
- 对于访问热度比较一般的数据, 存放到Redis Cluster集群, 作为二级缓存, 这部分的数据规模最大, 可以以10GB为节点单位进行横向扩展。
第四字真金: 异(异步化)
- 消息队列:如RocketMQ实现流量削峰和解耦。
- 异步I/O:非阻塞处理提升吞吐量。
异步架构是一种常见的高并发架构, 与之相对的架构模式就是同步架构。
同步架构
什么是同步架构? 以方法调用为例, 同步调用代表调用方要阻塞等待, 一直到被调用方法中的逻辑执行完成, 返回结果。 这种方式下, 如果被调用方法响应时间较长, 会造成调用方长久阻塞,在高并发下会造成整体系统性能下降甚至发生雪崩。
在同步调用的架构模式(简称同步架构) 中, 假设接入层的吞吐量在10万级TPS, 服务层的吞吐量在1万级TPS, 就会出现速率严重不匹配导致接入层大量的请求被阻塞和积压, 严重拖慢了性能。解决方案可以采用异步架构。
异步架构
什么是异步架构? 异步调用与同步调用相反, 调用方不需要等待被调用方法中的逻辑执行完成, 调用方提交请求后就可以返回执行其他的逻辑。 在被调用方法执行完毕后, 调用方通过回调、事件通知、 定时查询等方式获得结果。
比如当我们在12306网站订票时, 页面会显示系统正在排队, 这个提示就代表着系统在异步处理我们的订票请求。 在12306系统中查询余票、 下单和更改余票状态都是比较耗时的操作, 可能涉及多个内部系统的互相调用。 如果是同步架构, 那么12306网站在高峰时期会出现严重拥塞。 而采用异步架构, 上层处理时会把请求写入请求队列, 同时快速响应用户, 告诉用户正在排队处理, 然后释放出资源来处理更多的请求。 订票请求处理完成之后, 再通知用户订票成功或者失败。
采用异步架构后, 请求移到异步处理程序中, Web服务的压力小了, 资源占用得少了, 自然就能接收更多的用户订票请求, 系统承受高并发的能力也就提升了。
异步架构基本方案
方案1:线程池模式
比如订单下单"控制器收到请求后,将下单操作放入到独立的线程池中后,立即返回给前端,而线程池会异步执行下单操作"。
使用线程池模式,需要注意如下几点:
1、线程数不宜过高,避免占用过多的数据库连接 ;
2、需要考虑评估线程池队列的大小,以免出现内存溢出的问题。
方案2:本地内存 + 定时任务
开源中国统计浏览数的方案非常经典。
用户访问过一次文章、新闻、代码详情页面,访问次数字段加 1 , 在 oschina 上这个操作是异步的,访问的时候只是将数据在内存中保存,每隔固定时间将这些数据写入数据库。
示例代码如下:
import java.util.concurrent.ConcurrentHashMap;
import java.util.Timer;
import java.util.TimerTask;
import org.apache.log4j.Log;
import org.apache.log4j.LogFactory;
public class VisitStatService extends TimerTask {
private final static Log log = LogFactory.getLog(VisitStatService.class);
private static boolean start = false;
private static VisitStatService daemon;
private static Timer clickTimer;
private final static long INTERVAL = 60 * 1000;
/**
* 支持统计的对象类型
*/
private final static byte[] TYPES = new byte[]{
0x01, 0x02, 0x03, 0x04, 0x05
};
// 内存队列
private final static ConcurrentHashMap<Byte, ConcurrentHashMap<Long, Integer>> queues =
new ConcurrentHashMap<Byte, ConcurrentHashMap<Long, Integer>>() {{
for (byte type : TYPES) {
put(type, new ConcurrentHashMap<Long, Integer>());
}
}};
/**
* 记录访问统计
* @param type 统计类型
* @param obj_id 统计对象ID
*/
public static void record(byte type, long obj_id) {
ConcurrentHashMap<Long, Integer> queue = queues.get(type);
if (queue != null) {
Integer nCount = queue.get(obj_id);
nCount = (nCount == null) ? 1 : nCount + 1;
queue.put(obj_id, nCount.intValue());
System.out.printf("record (type=%d,id=%d,count=%d)\n", type, obj_id, nCount);
}
}
/**
* 启动统计数据写入定时器
*/
public static void start() {
if (!start) {
daemon = new VisitStatService();
clickTimer = new Timer("VisitStatService", true);
clickTimer.schedule(daemon, INTERVAL, INTERVAL); // 运行间隔1分钟
start = true;
}
log.info("VisitStatService started.");
}
/**
* 释放服务资源
*/
public static void destroy() {
if (start) {
clickTimer.cancel();
start = false;
}
log.info("VisitStatService stopped.");
}
@Override
public void run() {
for (byte type : TYPES) {
ConcurrentHashMap<Long, Integer> queue = queues.remove(type);
queues.put(type, new ConcurrentHashMap<Long, Integer>());
try {
_flush(type, queue);
} catch (Throwable t) {
log.fatal("Failed to flush click stat data.", t);
// 此处可添加异常报警逻辑
} finally {
// 此处可添加数据库连接关闭逻辑
}
}
}
@Override
public boolean cancel() {
boolean b = super.cancel();
// 应用停止时写回剩余数据,避免丢失
this.run();
return b;
}
/**
* 将统计数据刷入数据库
* @param type 统计类型
* @param queue 待刷写的统计数据队列
*/
private void _flush(byte type, ConcurrentHashMap<Long, Integer> queue) {
if (queue.size() == 0) {
return;
}
switch (type) {
// 此处实现具体的数据库写入逻辑(示例:根据type区分表或业务)
// case 0x01: 处理类型1的数据写入...
// case 0x02: 处理类型2的数据写入...
default:
break;
}
System.out.printf("Flush to database: type=%d\n", type);
}
}
可以借鉴开源中国的方案 :
(1) 控制器接收请求后,观看进度信息存储到本地内存 LinkedBlockingQueue 对象里;
(2) 异步线程每隔1分钟从队列里获取数据 ,组装成 List 对象,最后调用 Jdbc batchUpdate 方法批量写入数据库;
(3) 批量写入主要是为了提升系统的整体吞吐量,每次批量写入的 List 大小也不宜过大 。
这种方案优点是:不改动原有业务架构,简单易用,性能也高。该方案同样需要考虑内存溢出的风险。
方案3:MQ模式
MQ 模式 ,消息队列最核心的功能是异步和解耦,MQ 模式架构清晰,易于扩展。
核心流程如下:
(1) 控制器接收写请求;
(2) 写服务发送消息到 MQ ,将写操作成功信息返回给前端 ;
(3) 消费者服务从 MQ 中获取消息 ,批量操作数据库 。
这种方案优点是:
- MQ 本身支持高可用和异步,发送消息效率高 , 也支持批量消费;
- 消息在 MQ 服务端会持久化,可靠性要比保存在本地内存高;
不过 MQ 模式需要引入新的组件,增加额外的复杂度。
方案4:Agent 服务 + MQ 模式
互联网大厂还有一种常见的异步的方案:Agent 服务 + MQ 模式。
高并发写服务 不写入 MQ,而是 接收写请求后,将请求按照固定的格式(比如 JSON )写入到本次磁盘 文件中,然后给前端返回成功信息。
服务器上部署 Agent 服务(独立的进程) , 监听 文件的变动。
Agent 服务会监听文件变动,将文件内容发送到消息队列 , 消费者服务获取观看行为记录,将其存储到 MySQL 数据库中。
还有一种演进,假设 不想在应用中依赖消息队列,不生成本地文件,可以采用如下的方式:
这种方案最大的优点是:架构分层清晰,业务服务不需要引入 MQ 组件。
一般性能监控平台,或者日志分析平台都使用这种模式。
异步架构总结
方案对比与选型建议
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
线程池模式 | 实现简单,低延迟 | 线程资源有限,风险较高 | 快速订单处理(如秒杀) |
本地内存 + 定时任务 | 无需额外组件,吞吐量高 | 内存溢出风险 | 订单日志批量处理 |
MQ模式 | 高可用、解耦、批量处理 | 引入MQ复杂度 | 高并发订单场景 |
Agent + MQ模式 | 完全解耦,架构清晰 | 实现复杂,维护成本高 | 订单分析、监控平台 |
学习需要一层一层递进的思考。
第一层:什么场景下需要异步
- 大量写操作占用了过多的资源,影响了系统的正常运行;
- 写操作异步后,不影响主流程,允许适当延迟;
第二层:异步的外功心法
四种异步方式:
- 线程池模式
- 本地内存 + 定时任务
- MQ 模式
- Agent 服务 + MQ 模式
它们的共同特点是:将写操作命令存储在一个池子后,立刻响应给前端,减少写动作的耗时。任务服务异步从池子里获取任务后执行。
第三层:异步的内功心法
异步本质是更细粒度的使用系统资源的一种方式。
在高并发写场景里,数据库的资源是固定的,但写操作占据大量数据库资源,导致整个系统的阻塞,但写操作并不是最核心的业务流程,它不应该占用那么多的系统资源。
我们使用异步的解决方案时,无论是使用线程池,还是本地内存 + 定时任务 ,亦或是 MQ ,对数据库资源的使用都需要在合理的范围内,只有这样系统才能顺畅的运行。
第五字真金:限(流量控制)
限流部分详细内容参考:阿里面试:10WQPS高并发,怎么限流?这份答案,让我当场拿了offer
什么是限流
限流在很多场景中用来限制并发和请求量,比如说秒杀抢购,保护自身系统和下游系统不被巨型流量冲垮等。
以微博为例,例如某某明星公布了恋情,访问从平时的50万增加到了500万,系统的规划能力,最多可以支撑200万访问,那么就要执行限流规则,保证是一个可用的状态,不至于服务器崩溃,所有请求不可用。
常见限流算法
常见的有四大算法,分别是计数器算法、滑动窗口算法、 漏桶算法、令牌桶算法
算法类型 | 原理与特点 | 适用场景 | 案例 |
---|---|---|---|
计数器 | 固定时间窗口内统计请求数,超阈值拒绝。存在“突刺现象”(窗口切换时瞬时流量高峰)。 | 简单场景(如单机限流)。 | 本地单机限流(如支付接口)。 |
滑动窗口 | 将时间窗口划分为多个子窗口,动态统计总请求数,缓解突刺问题。 | 高精度限流(如微服务调用)。 | Sentinel默认限流模式(滑动窗口算法)。 |
漏桶算法 | 请求以固定速率流出,突发流量直接丢弃。平滑输出,适合保护后端服务。 | Nginx反向代理限流。 | 京东抢购活动使用Redis+Lua实现中心化限流,防止库存超卖。 |
令牌桶算法 | 令牌按固定速率生成,允许突发流量(桶满时拒绝)。灵活应对流量波动。 | 允许突发流量的场景(如秒杀)。 | Sentinel热点参数限流(基于令牌桶)。 |
单机限流
使用Guava RateLimiter
Guava 的 RateLimiter
提供了基于令牌桶算法的限流功能,支持 平滑突发限流(SmoothBursty) 和 平滑预热限流(SmoothWarmingUp)。
Guava 的 RateLimiter
支持两种模式:
- SmoothBursty:允许突发流量(默认模式)。
- SmoothWarmingUp:预热模式,逐步增加令牌生成速度。
核心方法说明:
方法 | 描述 |
---|---|
create(double permitsPerSecond) | 创建限流器,设置每秒生成的令牌数。 |
tryAcquire() | 尝试获取一个令牌,若没有可用令牌则返回 false 。 |
acquire() | 获取一个令牌,若没有可用令牌则阻塞等待。 |
setRate(double permitsPerSecond) | 动态调整每秒生成的令牌数(仅适用于 SmoothBursty)。 |
setWarmupPeriod(Duration duration, double permitsPerSecond) | 设置预热时间及初始速率(适用于 SmoothWarmingUp)。 |
参考实现:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version> <!-- 根据实际版本调整 -->
</dependency>
import com.google.common.util.concurrent.RateLimiter;
import java.util.concurrent.TimeUnit;
public class GuavaRateLimiterDemo {
public static void main(String[] args) {
// 创建每秒生成 5 个令牌的限流器
RateLimiter limiter = RateLimiter.create(5.0);
//limiter.setWarmupPeriod(Duration.ofSeconds(10), 2.0); // 预热时间 10 秒,初始速率 2/s
// 模拟 20 个请求
for (int i = 0; i < 20; i++) {
if (limiter.tryAcquire()) {
System.out.println("Request " + i + " is allowed.");
} else {
System.out.println("Request " + i + " is denied.");
}
// 模拟请求间隔
try {
TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
输出结果
假设每秒生成 5 个令牌,请求间隔为 200ms(即每秒 5 个请求),输出如下:
Request 0 is allowed.
Request 1 is allowed.
Request 2 is allowed.
Request 3 is allowed.
Request 4 is allowed.
Request 5 is denied.
Request 6 is denied.
Request 7 is allowed.
...
注意:Guava 的
RateLimiter
不适用于分布式限流,需结合 Redis 或 Sentinel 等工具实现分布式限流。
Nginx限流
Nginx 是一个高性能的 HTTP 服务器和反向代理服务器,广泛用于负载均衡、API 网关、静态资源服务等场景。在限流方面,Nginx 使用的是 漏桶算法(Leaky Bucket Algorithm),而不是 令牌桶算法(Token Bucket Algorithm)。
Nginx 选择漏桶算法而不是令牌桶算法的原因主要是:平滑流量,避免突发流量对后端服务造成冲击。
Nginx 基于请求速率的限流 (ngx_http_limit_req_module
)模块基于漏桶算法实现请求速率的限制,它可以控制每个客户端 IP 或其他标识的请求频率,防止过多请求对服务器造成压力。
Nginx的限流功能通过ngx_http_limit_req_module
模块实现,主要通过limit_req_zone
和limit_req
指令进行配置。以下是一些常见的限流配置示例和说明,帮助你快速理解和应用Nginx的限流功能。
基本限流配置
以下是一个简单的限流配置示例,限制每个客户端IP地址的请求速率:
http {
# 定义一个限流区域
limit_req_zone $binary_remote_addr zone=my_limit:10m rate=1r/s;
server {
listen 80;
server_name example.com;
location / {
# 应用限流规则
limit_req zone=my_limit;
}
}
}
limit_req_zone 的配置说明**:
- $binary_remote_addr:基于客户端IP地址进行限流。
- zone=my_limit:10m:定义一个名为
my_limit
的共享内存区域,大小为10MB。 - rate=1r/s:每秒最多处理1个请求。
limit_req的配置说明
zone=my_limit:引用limit_req_zone
定义的共享内存区域。
处理突发流量
如果需要允许突发流量,可以使用burst
参数。例如,允许突发5个请求:
location / {
limit_req zone=my_limit burst=5;
}
burst=5:允许在短时间内额外处理5个请求。超出部分的请求会被延迟处理。
延迟处理与立即拒绝
默认情况下,超出速率的请求会被延迟处理。如果希望立即拒绝超出速率的请求,可以使用nodelay
参数:
location / {
limit_req zone=my_limit burst=5 nodelay;
}
nodelay**:取消延迟处理,超出速率的请求会立即返回错误(默认为503)。
自定义拒绝状态码
可以通过limit_req_status
指令自定义拒绝请求时返回的状态码。例如,返回429状态码(Too Many Requests):
location / {
limit_req zone=my_limit burst=5 nodelay;
limit_req_status 429;
}
基于不同键的限流
除了基于客户端IP地址,还可以使用其他键进行限流,例如基于请求路径或用户代理:
http {
# 基于请求路径限流
limit_req_zone $request_uri zone=path_limit:10m rate=2r/s;
# 基于用户代理限流
limit_req_zone $http_user_agent zone=agent_limit:10m rate=1r/s;
server {
listen 80;
server_name example.com;
location / {
limit_req zone=path_limit burst=3;
}
location /api/ {
limit_req zone=agent_limit burst=2 nodelay;
}
}
}
在Nginx中,$http_user_agent
是一个内置变量,用于获取客户端请求中的 User-Agent
头部信息。
假设客户端请求中包含一个自定义头部user-id
,你可以通过以下配置实现基于user-id
的限流:基于 user-id 的header限流
http {
# 定义基于 user-id 的限流区域
limit_req_zone $http_user_id zone=user_id_limit:10m rate=1r/s;
server {
listen 80;
server_name example.com;
location / {
# 应用限流规则
limit_req zone=user_id_limit burst=5 nodelay;
limit_req_status 429; # 返回 429 Too Many Requests
}
}
}
在Nginx中,可以通过$http_header_name
变量结合limit_req_zone
指令实现基于HTTP头部(如user-id
)的限流。
sentinel限流
Sentinel是Alibaba微服务容错框架,详细内容参考Sentinel学习圣经:从入门到精通 Sentinel,最全详解 (40+图文全面总结)
这里简单总结下Sentinel使用的限流算法,Sentinel限流算法常见的有三种实现:滑动时间窗口,令牌桶算法,漏桶算法。
Sentinel 什么场景用漏桶,什么场景令牌桶 ,什么场景滑动窗口?
-
默认限流模式是基于滑动时间窗口算法
针对资源做统计,一个资源队列 + 一个滑动窗口算法,统计的数据较少,内存使用不高。
-
流控效果为排队等待的限流模式基于漏桶算法
需要排队等待效果
-
而流控规则的热点参数限流 是基于令牌桶算法
参数较多,只需要记录参数对应的请求时间信息
分布式限流
- 单体限流:指在一个单一的节点(如单个服务器或进程)上实现的限流逻辑。它主要关注单个节点的负载和性能,防止单点过载。
- 分布式限流:指在分布式系统中,通过多个节点协同实现的限流逻辑。它需要考虑整个系统的负载和性能,确保系统整体不过载。
自研中心化限流组件:redis+lua分布式限流组件
通过一个中心化的限流器来控制所有服务器的请求。实现方式:
(1) 选择一个中心化的组件,例如: Redis。
(2) 定义限流规则,例如:可以设置每秒钟允许的最大请求数(QPS),并将这个值存储在 Redis 中。
(3) 对于每个请求,服务器需要先向 Redis 请求令牌。
(4) 如果获取到令牌,说明请求可以被处理;如果没有获取到令牌,说明请求被限流,可以返回一个错误信息或者稍后重试。
高性能的中心化组件可以使用Redis+Lua来开发,京东的抢购就是使用Redis+Lua完成的中心化 限流。
并且无论是Nginx外部网关还是Zuul内部网关,都可以使用Redis+Lua限流组件。
参考实现:
--- 此脚本的环境: redis 内部,不是运行在 nginx 内部
---方法:申请令牌
--- -1 failed
--- 1 success
--- @param key key 限流关键字
--- @param apply 申请的令牌数量
local function acquire(key, apply)
local times = redis.call('TIME');
-- times[1] 秒数 -- times[2] 微秒数
local curr_mill_second = times[1] * 1000000 + times[2];
curr_mill_second = curr_mill_second / 1000;
local cacheInfo = redis.pcall("HMGET", key, "last_mill_second", "curr_permits", "max_permits", "rate")
--- 局部变量:上次申请的时间
local last_mill_second = cacheInfo[1];
--- 局部变量:之前的令牌数
local curr_permits = tonumber(cacheInfo[2]);
--- 局部变量:桶的容量
local max_permits = tonumber(cacheInfo[3]);
--- 局部变量:令牌的发放速率
local rate = cacheInfo[4];
--- 局部变量:本次的令牌数
local local_curr_permits = 0;
if (type(last_mill_second) ~= 'boolean' and last_mill_second ~= nil) then
-- 计算时间段内的令牌数
local reverse_permits = math.floor(((curr_mill_second - last_mill_second) / 1000) * rate);
-- 令牌总数
local expect_curr_permits = reverse_permits + curr_permits;
-- 可以申请的令牌总数
local_curr_permits = math.min(expect_curr_permits, max_permits);
else
-- 第一次获取令牌
redis.pcall("HSET", key, "last_mill_second", curr_mill_second)
local_curr_permits = max_permits;
end
local result = -1;
-- 有足够的令牌可以申请
if (local_curr_permits - apply >= 0) then
-- 保存剩余的令牌
redis.pcall("HSET", key, "curr_permits", local_curr_permits - apply);
-- 为下次的令牌获取,保存时间
redis.pcall("HSET", key, "last_mill_second", curr_mill_second)
-- 返回令牌获取成功
result = 1;
else
-- 返回令牌获取失败
result = -1;
end
return result
end
--eg
-- /usr/local/redis/bin/redis-cli -a 123456 --eval /vagrant/LuaDemoProject/src/luaScript/redis/rate_limiter.lua key , acquire 1 1
-- 获取 sha编码的命令
-- /usr/local/redis/bin/redis-cli -a 123456 script load "$(cat /vagrant/LuaDemoProject/src/luaScript/redis/rate_limiter.lua)"
-- /usr/local/redis/bin/redis-cli -a 123456 script exists "cf43613f172388c34a1130a760fc699a5ee6f2a9"
-- /usr/local/redis/bin/redis-cli -a 123456 evalsha "cf43613f172388c34a1130a760fc699a5ee6f2a9" 1 "rate_limiter:seckill:1" init 1 1
-- /usr/local/redis/bin/redis-cli -a 123456 evalsha "cf43613f172388c34a1130a760fc699a5ee6f2a9" 1 "rate_limiter:seckill:1" acquire 1
--local rateLimiterSha = "e4e49e4c7b23f0bf7a2bfee73e8a01629e33324b";
---方法:初始化限流 Key
--- 1 success
--- @param key key
--- @param max_permits 桶的容量
--- @param rate 令牌的发放速率
local function init(key, max_permits, rate)
local rate_limit_info = redis.pcall("HMGET", key, "last_mill_second", "curr_permits", "max_permits", "rate")
local org_max_permits = tonumber(rate_limit_info[3])
local org_rate = rate_limit_info[4]
if (org_max_permits == nil) or (rate ~= org_rate or max_permits ~= org_max_permits) then
redis.pcall("HMSET", key, "max_permits", max_permits, "rate", rate, "curr_permits", max_permits)
end
return 1;
end
--eg
-- /usr/local/redis/bin/redis-cli -a 123456 --eval /vagrant/LuaDemoProject/src/luaScript/redis/rate_limiter.lua key , init 1 1
-- /usr/local/redis/bin/redis-cli -a 123456 --eval /vagrant/LuaDemoProject/src/luaScript/redis/rate_limiter.lua "rate_limiter:seckill:1" , init 1 1
---方法:删除限流 Key
local function delete(key)
redis.pcall("DEL", key)
return 1;
end
--eg
-- /usr/local/redis/bin/redis-cli --eval /vagrant/LuaDemoProject/src/luaScript/redis/rate_limiter.lua key , delete
local key = KEYS[1]
local method = ARGV[1]
if method == 'acquire' then
return acquire(key, ARGV[2], ARGV[3])
elseif method == 'init' then
return init(key, ARGV[2], ARGV[3])
elseif method == 'delete' then
return delete(key)
else
--ignore
end
第三方中心化限流组件:Redisson 中心化限流实现
除了自研,还可以使用 成熟的,第三方中心化限流组件。
Redisson 提供了一个高性能的分布式限流组件 RRateLimiter
,基于 Redis 实现,支持令牌桶算法。
RRateLimiter 采用令牌桶思想和固定时间窗口,trySetRate方法设置桶的大小,利用redis key过期机制达到时间窗口目的,控制固定时间窗口内允许通过的请求量。
spring cloud gateway集成redis限流,但属于网关层限流
参考demo实现:
import org.redisson.Redisson; // 导入 Redisson 的核心类,用于创建 Redisson 客户端
import org.redisson.api.RRateLimiter; // 导入 RRateLimiter 接口,用于实现分布式限流
import org.redisson.api.RedissonClient; // 导入 RedissonClient 接口,用于与 Redis 进行交互
import org.redisson.config.Config; // 导入 Redisson 的配置类,用于配置 Redis 连接
import java.util.concurrent.CountDownLatch; // 导入 CountDownLatch 类,用于控制线程同步
public class RateLimiterDemo { // 定义一个公共类 RateLimiterDemo
public static void main(String[] args) throws InterruptedException { // 主方法,程序入口,可能抛出 InterruptedException
RRateLimiter rateLimiter = createRateLimiter(); // 创建一个 RRateLimiter 实例
int totalThreads = 20; // 定义总线程数为 20
CountDownLatch latch = new CountDownLatch(totalThreads); // 创建一个 CountDownLatch 实例,初始计数为 totalThreads
long startTime = System.currentTimeMillis(); // 记录开始时间,用于计算总耗时
for (int i = 0; i < totalThreads; i++) { // 循环创建并启动 20 个线程
new Thread(() -> { // 创建一个新线程
rateLimiter.acquire(1); // 每个线程尝试获取 1 个令牌,若令牌不足则阻塞等待
latch.countDown(); // 线程完成后,调用 countDown() 方法减少计数器
}).start(); // 启动线程
}
latch.await(); // 主线程等待,直到所有子线程完成
System.out.println("Total elapsed time: " + (System.currentTimeMillis() - startTime) + " ms"); // 打印总耗时
}
/**
* 创建并配置 RRateLimiter 的方法
*
* @return 配置好的 RRateLimiter 实例
*/
private static RRateLimiter createRateLimiter() { // 创建并配置 RRateLimiter 的方法
Config config = new Config(); // 创建一个新的 Redisson 配置实例
config.useSingleServer() // 配置使用单一 Redis 服务器
.setAddress("redis://127.0.0.1:6379") // 设置 Redis 服务器地址
.setTimeout(1000000); // 设置连接超时时间(毫秒)
RedissonClient redisson = Redisson.create(config); // 根据配置创建一个 Redisson 客户端实例
RRateLimiter rateLimiter = redisson.getRateLimiter("myRateLimiter"); // 获取名为 "myRateLimiter" 的 RRateLimiter 实例
rateLimiter.trySetRate(RRateLimiter.RateType.OVERALL, 3, 5, RateIntervalUnit.SECONDS); // 初始化限流器,设置全局速率为每秒5个令牌
return rateLimiter; // 返回配置好的限流器实例
}
}
第六字真金:降 (熔断降级)
什么熔断降级
什么是雪崩效应?:微服务系统A调用B,而B调用C,这时如果C出现故障,则此时调用B的大量线程资源阻塞,慢慢的B的线程数量持续增加直到CPU耗尽到100%,整体微服务不可用,这就是雪崩效应,也就是服务过载的存在级联效应
熔断就像是家里的保险丝一样,当电流达到一定条件时,比如保险丝能承受的电流是5A,如果电流达到了6A,因为保险丝承受不了这么高的电流,保险丝就会融化,电路就会断开,起到了保护电器的作用;
在微服务里面也是一样,当下游的服务因为某种原因突然变得不可用或响应过慢,上游服务为了保证自己整体服务的可用性,不再继续调用目标服务,直接返回,快速释放资源。如果目标服务情况好转则恢复调用;
产生熔断的条件大概下面几种:
- 慢调用达到阈值
- 异常数达到阈值
- 异常比例达到阈值
常见实现熔断降级主流微服务治理治理框架:
- Hystrix
- Resilience4j
- Sentinel
熔断机模型
熔断器的工作机制为:统计最近RPC调用发生错误的次数,然后根据统计值中的失败比例等信息,决定是否允许后面的RPC调用继续,或者快速地失败回退。
熔断器的3种状态如下:
1)closed:熔断器关闭状态,这也是熔断器的初始状态,此状态下RPC调用正常放行。
2)open:失败比例到一定的阈值之后,熔断器进入开启状态,此状态下RPC将会快速失败,执行失败回退逻辑。
3)half-open:在打开一定时间之后(睡眠窗口结束),熔断器进入半开启状态,小流量尝试进行RPC调用放行。如果尝试成功则熔断器变为closed状态,RPC调用正常;如果尝试失败则熔断器变为open状态,RPC调用快速失败。
断路器的状态切换图如下:
Hystrix 实现熔断机制
Spring Cloud Hystrix 是一款优秀的服务容错与保护组件,也是 Spring Cloud 中最重要的组件之一。 Spring Cloud Hystrix 是基于 Netflix 公司的开源组件 Hystrix 实现的,它提供了熔断器功能,能够有效地阻止分布式微服务系统中出现联动故障,以提高微服务系统的弹性。
参考配置:
hystrix:
...
command:
...
default: #全局默认配置
circuitBreaker: #熔断器相关配置
enabled: true #是否启动熔断器,默认为true
requestVolumeThreshold: 20 #启用熔断器功能窗口时间内的最小请求数
sleepWindowInMilliseconds: 5000 #指定熔断器打开后多长时间内允许一次请求尝试执行
errorThresholdPercentage: 50 #窗口时间内超过50%的请求失败后就会打开熔断器
metrics:
rollingStats:
timeInMilliseconds: 6000
numBuckets: 10
UserClient#detail(Long): #独立接口配置,格式为: 类名#方法名(参数类型列表)
circuitBreaker: #熔断器相关配置
enabled: true #是否使用熔断器,默认为true
requestVolumeThreshold: 20 #窗口时间内的最小请求数
sleepWindowInMilliseconds: 5000 #打开后允许一次尝试的睡眠时间,默认配置为5秒
errorThresholdPercentage: 50 #窗口时间内熔断器开启的错误比例,默认配置为50
metrics:
rollingStats:
timeInMilliseconds: 10000 #滑动窗口时间
numBuckets: 10 #滑动窗口的时间桶数
Hystrix 会监控微服务间调用的状况,当失败调用到一定比例时(例如 5 秒内失败 20 次),就会启动熔断机制。 Hystrix 实现服务熔断的步骤如下:
(1) 当服务的调用出错率达到或超过 Hystix 规定的比率(默认为 50%)后,熔断器进入熔断开启状态。
(2) 熔断器进入熔断开启状态后,Hystrix 会启动一个休眠时间窗,在这个时间窗内,该服务的降级逻辑会临时充当业务主逻辑,而原来的业务主逻辑不可用。
(3) 当有请求再次调用该服务时,会直接调用降级逻辑快速地返回失败响应,以避免系统雪崩。
(4) 当休眠时间窗到期后,Hystrix 会进入半熔断转态,允许部分请求对服务原来的主业务逻辑进行调用,并监控其调用成功率。
(5) 如果调用成功率达到预期,则说明服务已恢复正常,Hystrix 进入熔断关闭状态,服务原来的主业务逻辑恢复;否则 Hystrix 重新进入熔断开启状态,休眠时间窗口重新计时,继续重复第 2 到第 5 步。
熔断器状态之间相互转换的逻辑关系
涉及到了 4 个与 Hystrix 熔断机制相关的重要参数,这 4 个参数的含义如下表。
参数 | 描述 |
---|---|
metrics.rollingStats.timeInMilliseconds | 统计时间窗。 |
circuitBreaker.sleepWindowInMilliseconds | 休眠时间窗,熔断开启状态持续一段时间后,熔断器会自动进入半熔断状态,这段时间就被称为休眠窗口期。 |
circuitBreaker.requestVolumeThreshold | 请求总数阀值。 在统计时间窗内,请求总数必须到达一定的数量级,Hystrix 才可能会将熔断器打开进入熔断开启转态,而这个请求数量级就是 请求总数阀值。Hystrix 请求总数阈值默认为 20,这就意味着在统计时间窗内,如果服务调用次数不足 20 次,即使所有的请求都调用出错,熔断器也不会打开。 |
circuitBreaker.errorThresholdPercentage | 错误百分比阈值。 当请求总数在统计时间窗内超过了请求总数阀值,且请求调用出错率超过一定的比例,熔断器才会打开进入熔断开启转态,而这个比例就是错误百分比阈值。错误百分比阈值设置为 50,就表示错误百分比为 50%,如果服务发生了 30 次调用,其中有 15 次发生了错误,即超过了 50% 的错误百分比,这时候将熔断器就会打开。 |
Sentinel实现熔断机制
Sentinel 提供以下几种熔断策略:
- 慢调用比例 (
SLOW_REQUEST_RATIO
):选择以慢调用比例作为阈值,需要设置允许的慢调用 RT(即最大的响应时间),请求的响应时间大于该值则统计为慢调用。当单位统计时长(statIntervalMs
)内请求数目大于设置的最小请求数目,并且慢调用的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求响应时间小于设置的慢调用 RT 则结束熔断,若大于设置的慢调用 RT 则会再次被熔断。 - 异常比例 (
ERROR_RATIO
):当单位统计时长(statIntervalMs
)内请求数目大于设置的最小请求数目,并且异常的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求成功完成(没有错误)则结束熔断,否则会再次被熔断。异常比率的阈值范围是[0.0, 1.0]
,代表 0% - 100%。 - 异常数 (
ERROR_COUNT
):当单位统计时长内的异常数目超过阈值之后会自动进行熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求成功完成(没有错误)则结束熔断,否则会再次被熔断。
(1)慢调用比例
慢调用 RT表示最大的响应时间,请求的响应时间大于该值则统计为慢调用。当单位统计时长内请求数目大于设置的最小请求数目,并且慢调用的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求响应时间小于设置的慢调用 RT 则结束熔断,若大于设置的慢调用 RT 则会再次被熔断。具体配置
(2)异常比例
当单位统计时长内请求数目大于设置的最小请求数目,并且异常的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求成功完成,则结束熔断,否则会再次被熔断。异常比率的阈值范围是[0.0, 1.0],代表 0% - 100%。异常比例配置,如图10-16所示。
(3)异常数
异常数 (ERROR_COUNT)是指当单位统计时长内的异常数目超过阈值之后会自动进行熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求成功完成(没有错误)则结束熔断,否则会再次被熔断。异常数配置,如图10-17所示。
Resilience4j实现熔断
断路器配置属性
配置属性 | 默认值 | 描述 |
---|---|---|
failureRateThreshold | 50 | 以百分比配置失败率阈值。当失败率等于或大于阈值时,断路器状态并关闭变为开启,并进行服务降级。 |
slowCallRateThreshold | 100 | 以百分比的方式配置,断路器把调用时间大于slowCallDurationThreshold的调用视为慢调用,当慢调用比例大于等于阈值时,断路器开启,并进行服务降级。 |
slowCallDurationThreshold | 60000 [ms] | 配置调用时间的阈值,高于该阈值的呼叫视为慢调用,并增加慢调用比例。 |
permittedNumberOfCallsInHalfOpenState | 10 | 断路器在半开状态下允许通过的调用次数。 |
maxWaitDurationInHalfOpenState | 0 | 断路器在半开状态下的最长等待时间,超过该配置值的话,断路器会从半开状态恢复为开启状态。配置是0时表示断路器会一直处于半开状态,直到所有允许通过的访问结束。 |
slidingWindowType | COUNT_BASED | 配置滑动窗口的类型,当断路器关闭时,将调用的结果记录在滑动窗口中。滑动窗口的类型可以是count-based或time-based。如果滑动窗口类型是COUNT_BASED,将会统计记录最近slidingWindowSize次调用的结果。如果是TIME_BASED,将会统计记录最近slidingWindowSize秒的调用结果。 |
slidingWindowSize | 100 | 配置滑动窗口的大小。 |
minimumNumberOfCalls | 100 | 断路器计算失败率或慢调用率之前所需的最小调用数(每个滑动窗口周期)。例如,如果minimumNumberOfCalls为10,则必须至少记录10个调用,然后才能计算失败率。如果只记录了9次调用,即使所有9次调用都失败,断路器也不会开启。 |
waitDurationInOpenState | 60000 [ms] | 断路器从开启过渡到半开应等待的时间。 |
automaticTransition FromOpenToHalfOpenEnabled | false | 如果设置为true,则意味着断路器将自动从开启状态过渡到半开状态,并且不需要调用来触发转换。创建一个线程来监视断路器的所有实例,以便在WaitDurationInOpenstate之后将它们转换为半开状态。但是,如果设置为false,则只有在发出调用时才会转换到半开,即使在waitDurationInOpenState之后也是如此。这里的优点是没有线程监视所有断路器的状态。 |
recordExceptions | empty | 记录为失败并因此增加失败率的异常列表。 除非通过ignoreExceptions显式忽略,否则与列表中某个匹配或继承的异常都将被视为失败。 如果指定异常列表,则所有其他异常均视为成功,除非它们被ignoreExceptions显式忽略。 |
ignoreExceptions | empty | 被忽略且既不算失败也不算成功的异常列表。 任何与列表之一匹配或继承的异常都不会被视为失败或成功,即使异常是recordExceptions的一部分。 |
recordException | throwable -> true· By default all exceptions are recored as failures. | 一个自定义断言,用于评估异常是否应记录为失败。 如果异常应计为失败,则断言必须返回true。如果出断言返回false,应算作成功,除非ignoreExceptions显式忽略异常。 |
ignoreException | throwable -> false By default no exception is ignored. | 自定义断言来判断一个异常是否应该被忽略,如果应忽略异常,则谓词必须返回true。 如果异常应算作失败,则断言必须返回false。 |
参考配置:
resilience4j:
circuitbreaker:
configs:
default:
failureRateThreshold: 30 #失败请求百分比,超过这个比例,CircuitBreaker变为OPEN状态
slidingWindowSize: 10 #滑动窗口的大小,配置COUNT_BASED,表示10个请求,配置TIME_BASED表示10秒
minimumNumberOfCalls: 5 #最小请求个数,只有在滑动窗口内,请求个数达到这个个数,才会触发CircuitBreader对于断路器的判断
slidingWindowType: TIME_BASED #滑动窗口的类型
permittedNumberOfCallsInHalfOpenState: 3 #当CircuitBreaker处于HALF_OPEN状态的时候,允许通过的请求个数
automaticTransitionFromOpenToHalfOpenEnabled: true #设置true,表示自动从OPEN变成HALF_OPEN,即使没有请求过来
waitDurationInOpenState: 2s #从OPEN到HALF_OPEN状态需要等待的时间
recordExceptions: #异常名单
- java.lang.Exception
instances:
backendA:
baseConfig: default #熔断器backendA,继承默认配置default
backendB:
failureRateThreshold: 50
slowCallDurationThreshold: 2s #慢调用时间阈值,高于这个阈值的呼叫视为慢调用,并增加慢调用比例。
slowCallRateThreshold: 30 #慢调用百分比阈值,断路器把调用时间大于slowCallDurationThreshold,视为慢调用,当慢调用比例大于阈值,断路器打开,并进行服务降级
slidingWindowSize: 10
slidingWindowType: TIME_BASED
minimumNumberOfCalls: 2
permittedNumberOfCallsInHalfOpenState: 2
waitDurationInOpenState: 2s #从OPEN到HALF_OPEN状态需要等待的时间
上面配置了2个断路器"backendA",和"backendB",其中backendA断路器配置基于default配置,"backendB"断路器配置了慢调用比例熔断,"backendA"熔断器配置了异常比例熔断
修改Controller代码,使用上面配置的熔断策略
@GetMapping("/payment/{id}")
@CircuitBreaker(name = "backendD", fallbackMethod = "fallback")
public ResponseEntity<Payment> getPaymentById(@PathVariable("id") Integer id) throws InterruptedException, ExecutionException {
log.info("now i enter the method!!!");
Thread.sleep(10000L); //阻塞10秒,已测试慢调用比例熔断
String url = "http://cloud-payment-service/payment/" + id;
Payment payment = restTemplate.getForObject(url, Payment.class);
log.info("now i exist the method!!!");
return ResponseEntity.ok(payment);
}
public ResponseEntity<Payment> fallback(Integer id, Throwable e) {
e.printStackTrace();
Payment payment = new Payment();
payment.setId(id);
payment.setMessage("fallback...");
return new ResponseEntity<>(payment, HttpStatus.BAD_REQUEST);
}
注意name="backendA"和name=“backendD"效果相同,当找不到配置的backendD熔断器,使用默认熔断器配置,即为"default”。
第七字真金:弹性扩容
弹性扩容可以从以下几个方面处理
- 中间件
针对有状态中间件(比如 MySQL、Redis),通过读写分离和从库扩展实现弹性。MySQL 主库写、从库读,横向扩展从库数量分担读压力;Redis 通过从节点扩展读能力,结合哨兵或 Cluster 模式自动故障转移。核心目标是缓解数据库读写瓶颈,需注意主从同步延迟和数据一致性。
- 切流量
服务多机房冗余部署,高并发时通过流量调度工具(如 DNS/GSLB、负载均衡器)将流量从高负载机房切换至低负载机房。需提前规划多机房容量、网络延迟及服务灰度切换策略,确保切换过程无感知,适用于容灾和区域性流量突发场景。
- K8s HPA
针对无状态应用(如 Web 服务),通过 Kubernetes HPA 自动扩缩 Pod 副本数。基于 CPU / 内存等资源指标、自定义指标(如 QPS)或外部指标(如云监控),定期计算目标副本数,15 秒内触发调整。需依赖 Metrics Server(资源指标)或适配器(自定义指标),适用于动态负载的弹性扩缩,需配置合理副本上下限和冷却时间。
生产优化建议:结合PDB保障最小可用副本,冷启动时预热扩容,Prometheus Adapter扩展自定义指标。需注意Pod必须配置资源请求,否则HPA不生效。
- 云服务厂商
借助云厂商或中间件厂商的托管服务实现弹性。如使用云数据库(RDS)的自动扩缩容(按 CPU / 连接数)、Redis Cloud 的自动分片;或云厂商提供的弹性伸缩服务(如 AWS Auto Scaling),通过 API 集成业务指标触发扩缩。优势是降低运维成本,依赖厂商能力,需关注费用和厂商锁定风险。
第八字真金:监(立体监控)
8.1 日志监控分析
建议使用 ELK 或 Loki 对日志监控做聚合分析
日志收集与存储
确保系统有完善的日志收集和存储架构。当流量暴增时,传统的日志文件写入方式可能会导致磁盘 I/O 瓶颈,影响系统性能。可以采用分布式日志收集系统(如 Flume、Logstash 等),将各个服务器上的日志实时收集并传输到集中式的存储系统(如 HDFS、Elasticsearch 等)中。
例如,Flume 可以通过多个 agent(数据收集器)从不同的服务器上收集日志数据,然后经过拦截器(interceptor)进行数据过滤和格式转换,最后将数据写入到 HDFS 中,实现高效、可靠的日志收集和存储。
在高流量情况下,为了减轻系统负担,同时又能获取足够的日志信息用于分析,可以采用日志采样策略。例如,对于访问频率很高的接口,可以按照一定比例(如 1%)进行日志采样,记录下关键的请求参数、响应时间、错误信息等内容。
但是,采样比例需要根据业务的重要性和系统的性能状况进行动态调整。对于一些关键的业务接口,可能需要更高的采样率,甚至全量记录;而对于一些非关键的接口,可以适当降低采样率。
日志内容分析
深入分析日志中的请求详情,包括请求的接口名称、请求方法(GET、POST 等)、请求参数、响应状态码等。通过统计各个接口的请求次数占比、平均响应时间、错误率等指标,可以快速定位到性能瓶颈接口。
例如,如果发现某个接口的请求次数占总 QPS 的很大比例,并且其平均响应时间较长,错误率较高,那么这个接口可能是整个系统的性能瓶颈。进一步分析该接口的请求参数和对应的业务逻辑,找出是由于复杂的数据库查询、大量的文件 I/O 操作还是外部服务调用超时等原因导致的性能问题。
对错误日志进行重点分析。错误日志包含了系统运行过程中出现的各种异常信息,如服务器故障、应用程序错误、数据库连接失败等。通过分析错误日志,可以及时发现系统中存在的问题。
例如,如果在错误日志中频繁出现数据库连接超时的错误,在流量暴增的情况下,这可能是由于数据库连接池的大小设置不合理,无法满足高并发的请求需求。此时,可以根据错误日志中的相关信息,对数据库连接池的配置进行优化。
8.2 指标监控分析
建议使用 Prometheus + Grafana进行指标监控
系统资源指标监控
CPU 监控
监控 CPU 的使用率、上下文切换次数、中断次数等指标。当 CPU 使用率过高(例如超过 80%)时,系统可能已经处于高负载状态。上下文切换次数过多可能意味着进程之间频繁切换,导致系统开销增大。
例如,通过操作系统自带的性能监控工具(如 Linux 的 top、vmstat 命令)或者第三方监控工具(如 Prometheus、Zabbix 等),可以实时获取 CPU 的使用情况。如果发现 CPU 使用率长期居高不下,可能是由于某些计算密集型的业务逻辑在占用大量 CPU 资源,如对大量数据进行复杂的算法处理或者频繁的加解密操作等。
内存监控
- 监控内存的使用率、可用内存大小、交换分区(swap)的使用情况等。内存不足会导致系统频繁地进行页面交换操作,严重影响系统性能。同时,还要监控内存中各个进程的内存占用情况,包括应用程序的堆内存、栈内存、共享内存等。
- 例如,对于 Java 应用程序,可以使用 JMX(Java Management Extensions,Java 管理扩展)工具来监控其堆内存的使用情况,包括堆内存的分配、回收、垃圾回收的频率等。如果发现堆内存使用率过高,可能需要优化 Java 应用的代码,减少不必要的对象创建,或者调整 JVM(Java Virtual Machine,Java 虚拟机)的内存参数。
磁盘监控
监控磁盘的 I/O 使用率、读写速度、磁盘空间使用率等指标。当磁盘 I/O 使用率过高时,可能会导致系统响应变慢,尤其是在处理大量文件读写操作或者数据库操作时。
例如,在数据库服务器上,可以通过 iostat 命令来监控磁盘的 I/O 性能。如果发现磁盘的读写速度明显下降,可能是由于磁盘出现故障或者磁盘的 I/O 队列长度过长。此时,可以考虑对磁盘进行优化,如增加磁盘缓存、优化数据库的存储引擎、采用更快的存储设备(如 SSD 固态硬盘)等。
业务指标监控
业务性能指标
监控系统的平均响应时间、吞吐量、并发连接数等指标。平均响应时间反映了系统处理请求的快慢程度,吞吐量表示系统在单位时间内能够处理的请求数量,并发连接数则体现了系统的并发处理能力。
例如,通过压测工具(如 JMeter、LoadRunner 等)在正常情况下对系统进行压测,记录下各个性能指标的基准值。当流量暴增时,可以通过对比实时的性能指标和基准值,来判断系统是否能够正常应对高并发流量。如果平均响应时间大幅增加,吞吐量下降,就说明系统可能存在性能瓶颈。
业务成功率监控
监控业务请求的成功率,包括各个接口的成功率、整个业务流程的成功率等。业务成功率可以直观地反映系统在处理请求时的可靠性。
例如,对于一个在线支付系统,可以监控支付接口的成功率。如果支付成功率突然下降,可能是由于第三方支付通道出现问题、系统内部的支付逻辑故障或者网络问题等原因导致的。此时,需要根据具体的错误信息和监控数据,及时进行排查和修复。
8.3 恶意 监控与 流量分析
通过上面的日志和指标分析流量来源,判断是否存在恶意流量
查看系统入口的访问日志,包括但不限于 Web 服务器(如 Nginx、Apache)日志、API 网关日志等。通过分析日志中的请求来源 IP、User - Agent、Referer 等信息,判断流量是来自正常的用户操作(如移动端应用、网页浏览器等),还是来自某些不明来源或自动化工具。
例如,可以利用日志分析工具(如 ELK Stack、Splunk 等)对访问日志进行实时统计和分析,绘制出流量来源的地理分布图、访问设备类型分布图等。如果发现大量请求来自特定的 IP 地址段、异常的 User - Agent(如大量相似的非主流浏览器标识)或者 Referer 为空的情况,就需要进一步深入调查这些请求是否合理。
结合业务实际情况,分析当前业务是否有可能出现流量暴增的情况。比如,是否正在进行大型促销活动、新产品发布、热点事件引发大量用户关注等。如果是正常的业务高峰流量,那么 QPS 来源是合理的;如果不是,就有可能存在恶意流量。
例如,对于一个电商平台,如果在 “双十一” 期间 QPS 提升是正常的业务现象,因为大量用户会涌入进行商品浏览和购买。但如果是在平常的工作日,没有特殊的营销活动,QPS 突然提升 100 倍,就需要警惕可能的恶意攻击,如 CC(Challenge Collapsar,挑战坍塌)攻击。
恶意流量识别
对请求的访问频率、时间间隔、请求参数等进行分析。正常用户的访问模式通常是比较随机的,而恶意流量往往具有规律性。例如,恶意流量可能会在短时间内发送大量相同类型的请求,并且请求的参数可能具有相似的特征,如相同的接口调用顺序、相同的请求体内容等。
可以利用机器学习算法来构建流量模式模型,将实时流量与正常流量模式进行对比,当偏差超过一定阈值时,就判定为可能存在恶意流量。例如,通过聚类算法对请求的特征向量(如请求路径、参数值、时间戳等)进行聚类,正常流量会集中在某些聚类簇中,而恶意流量可能会形成新的、异常的聚类簇。
与 WAF(Web Application Firewall,Web 应用防火墙)、IDS(Intrusion Detection System,入侵检测系统)等安全防护系统进行协同工作。这些系统通常已经具备了一定的恶意流量检测能力,如对 SQL 注入、XSS(Cross Site Scripting,跨站脚本攻击)、CC 攻击等的检测。
例如,WAF 可以根据预定义的规则和签名,对进入的 HTTP 请求进行检查,当发现符合恶意流量特征的请求时,会进行拦截或报警。此时,系统可以根据 WAF 的反馈,进一步对相关流量进行封禁或限制。
8.4 对比压测指标分析系统瓶颈
根据系统指标,分析对应的系统瓶颈
将实时监控到的系统资源指标和业务指标与之前压测得到的指标进行对比。
在压测过程中,通常会模拟不同负载(如不同 QPS)情况下的系统运行情况,记录下系统在接近极限负载时的各项指标表现。
例如,在压测报告中,当 QPS 为 X 时,CPU 使用率为 Y%,内存使用率为 Z%,平均响应时间为 T 毫秒等。
当实际流量暴增 100 倍后,如果监控到的指标远超压测极限指标,就说明系统已经无法正常处理当前的流量,需要进行紧急扩容或者优化。
根据对比结果,定位系统瓶颈所在。
-
如果是 CPU 瓶颈,可以考虑优化代码中的计算逻辑,采用更高效的算法或者将部分计算任务转移到其他服务器上;
-
如果是内存瓶颈,可以优化内存使用,增加内存或者采用分布式缓存等技术;如果是磁盘瓶颈,可以升级磁盘设备或者优化磁盘 I/O 操作。
例如,如果发现系统在流量暴增后,数据库查询的平均响应时间大幅增加,而数据库服务器的磁盘 I/O 使用率很高,通过对比压测数据发现数据库的磁盘性能已经接近极限。
此时,可以考虑对数据库进行垂直扩容(如更换更快的磁盘、增加磁盘缓存)或者水平扩容(如采用数据库分片技术、读写分离等)来解决磁盘瓶颈问题。
第九字真金:演(混沌工程)
模拟网络延迟、节点宕机等故障,验证系统容错能力,实现系统在 局部故障/分区故障场景下,整体的可用性能达到 99.99% 甚至 999.99% 。