凌晨一点零七分,监控大屏突然飘红。订单服务的超时率从0.3%一路飙升到47%,核心下单接口的P99延迟从80ms拉到了4.6秒。我盯着Grafana上那些垂直上升的曲线,后背一阵发凉——这是典型的服务雪崩前兆。更麻烦的是,这不是单体应用那种“挂就挂一个”的故障,而是订单、库存、支付三个服务像多米诺骨牌一样接连倒下。那一刻我突然意识到,很多人把“分布式”和“微服务”混为一谈,以为是换了个部署方式,结果拆完之后才发现,真正的难题才刚刚开始。这篇文章就从这次订单超时雪崩切入,把分布式和微服务的本质区别、故障传播链路、以及分布式场景下必须补的功课一次讲透。
1. 那场订单超时雪崩,到底发生了什么
1.1 故障现场回放:从一条慢SQL到全站不可用
先交代一下背景。这个系统是典型的Spring Cloud微服务架构,核心链路是网关 → 订单服务 → 库存服务 → 支付服务,注册中心用Nacos,配置中心也是它,服务间调用走OpenFeign。数据库用的是MySQL,订单库和库存库分库部署,缓存用的Redis。
故障是从一条慢SQL开始的。当晚有一个运营活动上线,订单表的某个查询条件没走到索引,扫描行数直接到了570万行。这条SQL平时偶尔出现,都以为是偶发问题没在意,但活动流量一冲,它就成了压垮系统的第一根稻草。
慢SQL导致数据库连接池被占满,订单服务发起的数据库操作全部开始排队。这时候库存服务还在正常响应,但订单服务因为拿不到数据库连接,对库存服务的Feign调用开始超时。Feign默认的超时时间是1秒,超时之后会触发重试机制,默认重试5次。这一下,订单服务对库存服务的并发请求量直接翻了6倍。
库存服务本来还能扛住,被这么一冲也开始慢了。它的数据库连接池也有限,线程被大量请求占住不放,慢慢出现线程池队列积压。接着支付服务收到订单服务的回调请求,里面带着重试标志,支付服务以为订单状态没更新,又反过来查订单服务。整个调用链从“订单 → 库存 → 支付”变成了“订单 → 库存 → 订单 → 支付 → 订单”的恶性循环。
从我看到的真实数据来看,最严重的时候,订单服务的Tomcat线程池200个线程全部处于等待状态,没有任何一个线程能处理新请求。网关层还在继续转发流量,请求进来就排队,排队的请求越来越多,最终把内存也拖垮了。整个过程不到15分钟,三个核心服务全部不可用。
1.2 技术债溯源:把单体拆成微服务,麻烦就消失了吗
很多人看到这里会想:这不就是数据库慢SQL的问题吗?把SQL优化一下不就行了?
问题是,这个系统如果还是单体架构,一条慢SQL最多让数据库变慢,但应用层还能撑一阵。因为它所有的请求都在本地JVM内部流转,没有网络调用超时和重试的放大效应。但拆成微服务之后,每一次服务间调用都要经过网络,网络就会带来三个单体架构几乎不用考虑的问题:延迟不可控、超时和重试、部分失败。
这个项目当时拆微服务的初衷很简单——订单、库存、支付三个模块经常因为各自的版本迭代互相影响,上线要一起发,团队之间互相踩脚。拆成微服务之后,每个团队可以独立开发、独立部署、独立伸缩,这确实是很大的进步。但代价是,原本一次本地方法调用变成了一次RPC调用,原本一个数据库事务变成跨库的分布式事务,原来一条链路变成了一个调用图。
更关键的是,当时大家在设计的时候有一个普遍的认知误区:以为微服务就是把原来的功能拆开,用Feign连起来就完事了。没人想过分布式环境下的故障传播问题——服务之间相互依赖,一个服务的成功依赖于链路上一堆服务的成功,只要其中一个变慢,整个链路就会被拖垮。这不是某一个人的锅,而是微服务架构从第一天起就自带的风险,只不过这次被一条慢SQL暴露出来了。
2. 分布式和微服务,先分清楚再谈优化
2.1 分布式是部署形态,微服务是架构风格
先把概念掰扯清楚,因为这两个词被混用得实在太厉害了。
分布式系统,指的是一组通过网络互联、相互协作的计算机节点,对外表现得像一个整体。它的核心特征是“物理上分散、逻辑上统一”。什么叫逻辑上统一?就是你访问任何一个节点,得到的结果是一致的、完整的,用户不需要关心请求具体被哪台机器处理了。典型的例子:Hadoop的HDFS分布式文件系统,数据分散存储在多个DataNode上,但客户端访问的是一个统一命名空间的文件系统;Kubernetes集群,多个Node节点协同工作,对外表现为一个容器编排平台。
微服务架构则是一种软件架构风格,强调将一个大型应用拆分成一组小的、独立的服务,每个服务围绕业务能力构建,独立部署、独立扩展,服务之间通过轻量级通信机制(通常是HTTP/RPC)交互。
简单来说:分布式描述的是系统的物理拓扑和协作方式——“系统跑在多少台机器上、机器之间怎么分工”;微服务描述的是软件的模块划分方式——“应用怎么拆、服务边界怎么定”。一个单体应用可以部署在分布式环境中(比如单机部署集群通过负载均衡对外服务),一个微服务架构也可以跑在单台机器上(比如用Docker Compose把所有服务起在同一台服务器,这在开发环境很常见)。所以微服务不是分布式的同义词,而是分布式系统的一种典型实现架构。
2.2 微服务架构把分布式问题放大了多少倍
搞清楚了概念,接下来要回答一个更实际的问题:为什么单体时代没那么多事,拆成微服务之后各种分布式难题全冒出来了?
我列一张对比表,看得更清楚:
| 维度 | 单体应用 | 分布式系统 | 微服务架构 |
|---|---|---|---|
| 调用方式 | 本地方法调用 | RPC/消息队列 | RPC/消息队列为主 |
| 数据一致性 | 本地事务(ACID) | 数据分片+复制 | 分布式事务(最终一致性为主) |
| 故障影响 | 局部崩溃 | 节点故障可隔离 | 链路级联故障易放大 |
| 部署复杂度 | 单包部署 | 多节点部署 | 服务编排+配置管理 |
| 调试定位 | 日志集中查 | 日志分散 | 链路追踪+日志聚合 |
| 扩缩容 | 整机垂直扩展 | 节点级别水平扩展 | 服务级别独立伸缩 |
| 典型代表 | 传统CRM系统 | HDFS、Kafka、Elasticsearch | Spring Cloud、Dubbo、K8s |
看到没有,微服务架构等于把分布式系统的所有复杂度“人肉”暴露到了业务层面。HDFS这种基础设施级别的分布式系统,它的网络通信、数据复制、故障转移全都是内部屏蔽掉的,你只管调API就行。但微服务的每个服务都是你写的,服务间调用的超时、重试、幂等、分布式事务全都要你自己处理,处理不好就是事故现场。
所以回到订单超时雪崩那次故障,本质上不是某个服务的代码写得不好,而是微服务架构把“数据库慢”这个单点故障,通过网络放大成了全链路故障。单体时代,数据库慢了,应用程序也跟着慢,但所有请求在本地排队,不会对另一个服务产生级联压力。微服务时代,数据库慢传导到服务A,服务A超时重试暴击服务B,服务B又回调服务A,整个系统成了互相拖拽的一团乱麻。
3. 订单超时雪崩的完整链路拆解
3.1 超时是怎么传播的:线程池和连接池的死亡螺旋
要理解雪崩,就得先理解超时是怎么传播的。这里有两个关键的“池”:线程池和连接池。
假设订单服务有200个Tomcat工作线程,数据库连接池有50个连接。正常情况下,这200个线程中的任意一个处理请求时,需要从连接池借一个连接去查询数据库,用完了归还。但如果数据库慢SQL把50个连接全占住了,后续180个线程就都在“等待获取数据库连接”这个步骤上阻塞。
阻塞的线程不会消失,它们一直占着Tomcat线程池的位置。新请求进来,线程池已满,只能进入队列等待。队列默认容量是100,满了之后,Tomcat就开始拒绝请求——但网关不知道啊,网关还在不断转发请求过来。
这就是第一个死亡螺旋:数据库连接池被慢SQL占满 → Tomcat线程池被等待的线程占满 → 新请求被拒绝或排队 → 网关请求堆积 → 网关内存被撑爆。
3.2 雪崩的三个阶段:阻塞、扩散、连锁击穿
我把那次的故障过程复盘为三个阶段,你也可以把它当成分布式链路故障的通用模型来理解:
第一阶段是“阻塞”:单一服务或单一资源出现性能瓶颈,导致依赖方调用超时。这次的源头就是数据库慢SQL导致订单服务处理能力下降,对下游库存服务的调用因为上游处理变慢而开始出现超时。
第二阶段是“扩散”:被阻塞的服务会通过重试、回调、队列积压等手段,把压力传导给下游服务。订单服务对库存服务的超时重试,让库存服务的请求量暴增;库存服务的RT上升之后,它自己的线程池也开始被占满,于是订单服务对它的调用超时越来越多,形成了一个正反馈循环。
第三阶段是“连锁击穿”:部分服务资源耗尽之后,原本还能工作的服务也因为没有线程处理请求,或者因为依赖了已经瘫痪的服务而跟着瘫痪。支付服务就是典型的池鱼之殃——它本身没问题,但订单服务不断给它发查询状态的重试请求,把它也拖垮了。
这里有一个大家容易忽略的点:网关层如果没做防护,也会陷入同样的循环。网关的线程池是有限的,下游服务全挂之后,网关的等待队列会越来越长,最终连登录、商品浏览这种不需要订单链路的请求也会被拖死。这就是为什么雪崩的杀伤力这么大——它不是只影响故障链路,而是可能把无关的流量也全部拖下水。
4. 治标:让系统在故障中活下来的三板斧
雪花崩都崩了,当务之急是先恢复服务,然后才是思考怎么防。这里说的治标三板斧,是指能把“一个服务挂了拖垮一整条链路”的问题解决掉的手段。具体来说就是隔离、熔断、限流降级。
4.1 隔离:线程池隔离与信号量隔离的取舍
隔离的核心思路是“不要把所有鸡蛋放在一个篮子里”。具体到微服务架构里,最常用的是线程池隔离,让每一个下游依赖都有自己独立的线程池池。这样,当库存服务的线程池被占满时,订单服务对库存的调用只会阻塞在“库存专用的线程池”里,不会影响订单服务对支付服务、以及对外提供API的正常处理。
以Sentinel为例,它提供了两种隔离方式:线程池隔离的隔离效果好,但线程切换有开销,会降低接口的RT;信号量隔离则是控制最大并发数,不额外创建线程池,性能更高但隔离粒度较粗。我实操下来的建议是:核心链路、低频调用用线程池隔离更安全;高频、需要极致性能的场景,优先考虑信号量隔离,但一定要给足数量和兜底策略。
4.2 熔断:不让失败无限蔓延
熔断器的原理和电路熔断类似——当某个服务的失败率超过阈值,熔断器就打开,后续的请求直接快速失败,不再实际调用下游服务,给下游一个喘息的机会。等过了一段时间,熔断器进入半开状态,放一小部分请求过去探测一下下游是否恢复,如果成功就关闭熔断器,否则继续熔断。
这里有一个实用的参数配置经验。以Hystrix为例,比较合理的初始配置是:滑动窗口大小10秒,请求阈值20次,失败率阈值50%,熔断打开后的休眠时间5秒。实际调整时,失败率阈值不要设太高,超过30%到50%就要考虑熔断了,因为30%的失败率已经意味着下游服务处于不健康状态,再打下去只会更糟。
4.3 限流与降级:守住最后一个防线
隔离和熔断解决的是“某个下游挂了怎么办”,限流解决的是“大量请求超过系统承载力怎么办”。常用的限流算法有计数器、滑动窗口、令牌桶和漏桶。业务系统里最推荐的是令牌桶,它能平滑突发流量,允许一定量的突发请求,但又不会让系统被瞬时洪峰打垮。
降级则是一种兜底策略:在系统承受不住压力时,主动舍弃一些非核心功能,保证核心功能可用。比如订单列表页里的“推荐商品”模块,如果调用超时就直接返回空,不阻塞主流程;比如支付成功后的“发送短信通知”,可以降级为写入消息队列,异步处理。
注意,降级一定要有业务上的取舍预案,不能一拍脑袋做。哪些功能可以降,降级后用户看到什么,这些都要提前定义清楚。比如订单服务挂了,那“查询订单”就不能随便降级,因为这是核心功能。但“订单详情页的物流轨迹”就可以降级为“提示用户稍后再查看”。
5. 治本:分布式系统设计必须补齐的功课
防雪崩的熔断、限流、隔离,是分布式微服务系统的“表面功夫”,能让系统在故障中活下来。但真正让这个系统能跑得久、跑得稳,还必须补齐一套更底层的分布式能力。这次订单超时雪崩故障,也把我们团队在分布式设计上的各种欠账全部暴露了出来。
5.1 分布式锁:定时任务重复执行的问题
先讲一个我们在故障恢复后遇到的高频问题:定时任务重复执行。
微服务架构下,如果我们启动多个订单服务实例(通常至少2个,保证高可用),Spring的@Scheduled注解会在每个实例上都执行一遍。比如“每分钟跑一次订单超时关闭”的任务,如果部署了3个实例,就可能同时有3个任务在跑,造成重复关闭订单、重复发送通知等问题。
解决思路是用分布式锁,保证同一时间只有一个实例能执行这个任务。我推荐用Redis分布式锁实现,因为实现简单、性能好,但有几个关键细节必须处理好。
第一步是加锁,必须保证原子性。用Redis的SET命令,一次性设置key和过期时间,避免先SETNX再EXPIRE两步操作之间进程崩溃导致锁永不过期:
String lockKey = "lock:order:close";
String requestId = UUID.randomUUID().toString();
// 原子性地加锁,过期时间5秒
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 5, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 执行定时任务逻辑
closeExpiredOrders();
} finally {
// 释放锁时必须校验是不是自己的锁,防止误删别人的锁
if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) {
redisTemplate.delete(lockKey);
}
}
}
这段代码里有几个细节值得细说。第一,加锁时必须用setIfAbsent带过期时间,不能拆成两步。第二,为什么释放锁前要判断requestId?因为如果任务执行时间超过了5秒,锁自动过期了,另一个实例可能已经拿到了锁,这时候如果你直接delete,就把别人的锁删掉了,导致两个实例同时执行任务。第三,判断和删除这两个操作也应该用Lua脚本保证原子性,否则判断完还没删的时候锁又过期了。
当然,Redisson提供了更完善的分布式锁实现,底层通过看门狗机制自动续期,就不用担心业务逻辑执行时间超过锁过期时间的问题。我的建议是:简单场景用RedisTemplate手写,生产环境优先用Redisson,它能处理各种边界情况。
5.2 分布式事务:订单和库存的最终一致性
微服务拆开之后,订单和库存的数据分散在两个库中,单库事务已经覆盖不到了。订单创建成功之后要扣减库存,如果扣库存失败,订单状态怎么处理?订单支付成功之后,需要通知库存发货、通知积分服务加积分,任何一个环节失败都会导致数据不一致。
分布式事务的主流方案有几种:
| 方案 | 核心思路 | 适用场景 | 注意点 |
|---|---|---|---|
| 两阶段提交(2PC/XA) | 准备 + 提交/回滚 | 强一致性要求极高、并发量低 | 数据库锁时间太长,性能差 |
| TCC(Try-Confirm-Cancel) | 预留资源 → 确认 → 取消 | 强一致性要求较高、有资损风险 | 代码侵入性强,每个业务要写三个方法 |
| 本地消息表 | 业务操作 + 消息记录在同一本地事务 | 最终一致性要求、并发量适中 | 消息表需要清理机制 |
| 事务消息(RocketMQ) | 先发半消息 → 本地事务 → 确认发送 | 最终一致性、高并发 | 事务消息的“不可见”阶段要设计好 |
| Saga | 正向操作 + 逆向补偿 | 长事务、链路长 | 补偿逻辑要幂等 |
我个人的实战经验是:能异步就异步,能用最终一致性解决就不追求强一致。比如订单创建和扣库存,正确的做法是把“创建订单”和“发送扣减库存消息”放到同一个本地事务里,然后由库存服务监听消息异步扣减;如果扣减失败,通过死信队列重试。绝大多数业务场景的“分布式事务”本质上是“处理失败重试和补偿”的问题,而不是“让多个库同时提交”的问题。
5.3 分布式存储与状态管理
微服务都是无状态设计这句话说了无数遍,但真正落地时总是出幺蛾子。订单系统的会话状态早期就存在本地Session里,负载均衡把请求分发到不同实例时,用户登录状态就丢了。后来把这些状态全部挪到了Redis,才算解决了问题。
除此之外,分布式文件存储也需要统一规划。订单系统有导出报表、上传凭证附件等需求,这些文件如果存本地磁盘,服务实例扩容后文件不在同一台机器上,用户下载就会404。我们后来接入了MinIO做分布式对象存储,和本地文件系统的使用体验类似,但数据是冗余存储在多节点上的,单点故障不影响数据访问。HDFS也能做这事,但对中小团队来说运维重了些,MinIO的轻量和S3兼容API更合适。
5.4 分布式链路追踪和可观测性
这里必须补一个特别容易被忽视的模块:可观测性。一次请求在微服务架构中会横跨订单、库存、支付好几个服务,出了问题如果没有链路追踪,光靠日志一个个服务翻,效率极低。我们早期排查这次故障时,就是因为日志分散在各个节点上,定位一条完整链路花了快半小时。
后来接入了OpenTelemetry + SkyWalking的方案,在Feign调用中自动透传TraceId,一次请求的完整链路从入口到出口一目了然。再把SkyWalking和告警对接,服务之间的调用延迟、错误率、饱和度全部可视化。有了这套东西,以后再出超时类问题,直接按TraceId拉全链路排查,10分钟就能定位到瓶颈服务。
6. 重构后的架构设计与关键参数
熔断、隔离、限流、分布式锁、分布式事务、可观测性,这一套补丁打完之后,系统已经能在“故障发生”时稳稳活着,也能在“故障之后”快速定位问题。最后说一下这次事故后我们重构出来的架构模型和几个关键配置,给你们一个可以直接抄作业的参考。
6.1 调用链拓扑与治理策略
重构后的订单系统调用拓扑大致是这样的:
- 接入层:Nginx(限流 + 静态资源缓存 + SSL卸载)→ Spring Cloud Gateway(动态路由 + 全局限流 + 认证鉴权)
- 业务服务层:订单服务、库存服务、支付服务、会员服务、消息服务
- 中间件层:Nacos(注册中心/配置中心)、Redis(分布式缓存/分布式锁)、RocketMQ(异步消息/事务消息)、MySQL(业务数据分库)、OpenSearch(订单搜索)
- 基础设施层:Kubernetes(容器编排/弹性伸缩)、MinIO(对象存储)、SkyWalking(链路追踪)
关键的治理策略是这样配置的:
| 治理策略 | 采用方案 | 具体参数/实现 |
|---|---|---|
| 服务发现 | Nacos | 心跳5秒、摘除阈值3次 |
| 负载均衡 | Spring Cloud LoadBalancer | 默认轮询,核心服务改为加权响应时间 |
| 接口超时 | Feign + Resilience4j | 连接超时3秒、读超时5秒 |
| 熔断 | Sentinel | 10秒窗口、失败率40%熔断、休眠5秒后半开探测 |
| 限流 | Sentinel(令牌桶) | 核心接口单机QPS 300,超出排队等待 |
| 隔离 | Sentinel信号量隔离 | 信号量数量10,排队超时500ms |
| 分布式配置 | Nacos Config | 配置变更即时生效 |
6.2 防止雪崩的实战参数配置
我再把几个最关键的参数拿出来单独讲,这些都是会直接影响线上稳定性的。
Feign的链路里,超时和时间配置尤其要谨慎:连接超时设短一点,因为连接的建立一般几百毫秒就够,设3秒很充足;读超时设长一点,因为下游服务业务处理时间可能确实需要几秒。千万不要为了追求“首屏快”把读超时设成1秒,那只会在业务高峰期制造大量不必要的超时重试。
Sentinel熔断的阈值需要综合考虑服务的RT和错误率。我们实际用下来,比较稳妥的初始值是:RT超过800ms的请求占比达到40%,或者错误率超过40%,就触发熔断。注意这里用的是“占比”,是因为流量有高峰有低谷,只看瞬时RT容易被短时波动误触熔断。熔断后让系统半开探测的时间建议设5到10秒,太短的话下游没恢复过来就被再次打崩,陷入反复熔断的振荡。
限流的QPS阈值要基于压测数据来定,不能拍脑袋。我们当时用JMeter做了阶梯加压测试,发现订单服务的单实例稳定承载QPS是400,就留了25%的余量,限流值设在300。这样既能保证日常流量不受影响,也能在峰值流量来临时保住系统不被打垮。
还有一点容易被忽略,那就是线程池和连接池的数值也要配套调整。Tomcat线程池默认是200,如果信号量隔离只设置了10个并发,就会造成大量请求在入口排队。我的建议是:线程池数量设为核心业务实际并发量的1.5倍左右,数据库连接池设为线程池数量的一半左右,留出足够的缓冲空间。
7. 常见问题与排查技巧实录
这部分我把分布式微服务系统最常见的故障场景和排查方法整理成一个速查表,也把这次事故中我踩过的坑和教训写出来。以后你们再遇到类似问题,直接按表抄作业。
7.1 分布式故障排查速查表
| 故障现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 接口超时集中在某个时间段 | 慢SQL、垃圾回收停顿、下游服务RT升高 | 拉链路追踪看瓶颈节点;查数据库监控;看GC日志 |
| 某个服务实例CPU飙高 | 代码死循环、频繁GC、流量热点 | 先看GC日志;再抓线程栈;最后才是看代码 |
| 定时任务重复执行 | 分布式锁配置有问题 | 检查Redis锁加锁/释放逻辑;看锁过期时间;看Redis主从切换后的锁一致性 |
| 接口重试导致下游压力翻倍 | 超时重试配置不合理 | 检查Feign的重试次数;加上幂等控制;重试用退避策略 |
| 数据库连接池被占满 | 慢SQL、连接泄漏、并发过高 | 查慢查询日志;查连接池监控;全链路排查慢节点 |
| 网关请求堆积内存升高 | 下游服务全部不可用、网关无限等待 | 给网关加超时和主动熔断;对下游做快速失败 |
| 分布式事务数据不一致 | 本地事务提交后消息发送失败、消息消费失败 | 消息表记录状态;配合定时任务对账补偿 |
| 数据节点间状态不一致 | 节点间同步延迟、脑裂 | Zookeeper保证选主;Refis主从切换要配置好哨兵 |
7.2 排查经验与个人体会
有一次排查定时任务重复执行问题,我在开发环境怎么也复现不了,因为只有一个实例导致根本不存在锁竞争。后来直接到预发环境起三个实例实测,才定位出锁过期时间设置太短,任务执行没结束锁就自动释放了,另一个实例马上获得锁又跑了一遍。
这里有一个重要经验:分布式问题最大的特点就是“测试环境不容易暴露,生产环境突然爆发”。不是说开发环境不用管,而是你要特别重视“并发场景下的逻辑正确性问题”——单实例单线程能跑通的逻辑,不代表3个实例同时竞争时也是对的。
另外,排查分布式故障,工具链一定要提前备齐。链路追踪(TraceId串联全链路)、日志聚合(grep一条TraceId能拉到所有相关日志)、指标监控(Grafana看CPU/内存/QPS/RT/RROR率)、告警(超过阈值自动报警),这四样缺一不可,别等到出故障了才想起来搭。
最后一个技巧,也是我踩过很多次坑才总结出来的:线上故障排查时,在链路追踪里看到“某个服务调用超时”,先别急着怀疑这个服务有问题。超时是“结果”不是“原因”——它可能是因为这个服务调用更下游的服务变慢了,也可能是因为它自身的线程池被其他请求占满了。所以排查要从调用链开始,一级一级往下看,找到最终那个“第一个变慢的节点”,才真正找到了问题所在。很多时候,最底层的元凶就是一条不走索引的SQL、一个Redis大Key、或者一次全表的慢查询。
1036



被折叠的 条评论
为什么被折叠?



