做后端开发的这些年,被分布式系统折磨的次数太多了。白天还好好的线上集群,半夜突然某个节点心跳超时,一连掉好几个;接口偶发失败,客户端一重试,订单重复扣款;明明上了缓存,一到大促流量上来,缓存服务一重启,整个数据库直接被打满。这些问题表面看千奇百怪,但追到根子上,几乎都绕不开几个共同点:网络并不可靠、节点并不总是可用、时钟并不可信、状态最终需要在多副本之间对齐。这篇文章就把我踩过、也帮同事排查过的高频分布式系统问题逐个拆一遍,讲清楚每个问题背后的成因,以及我实测下来真正有效的解决方案。适合正在做微服务改造、中间件选型,或者被线上分布式 bug 折腾得够呛的同学参考。
1. 网络故障的真相:延迟、分区与超时重试的连锁反应
1.1 你无法区分“节点挂了”和“网络很慢”
分布式系统里最容易踩的第一个坑,是把网络的不可靠当成小概率事件。实际运维中,网络抖动、交换机丢包、机房之间的专线故障我都遇到过,而且发生的时间往往比你预期的更随机。你在设计系统时做的“假设”,比如“调用第三方必然快速返回”“对端处理超时就是死亡”,都是这些事故的温床。
这里最本质的问题是:网络分区与节点故障,在客户端看来没有任何区别。你发一个 RPC 请求,对方可能已经收到、正在处理,也可能根本没收到;对方可能已经处理完并返回,但返回包在途中被丢弃了。你永远无法百分百确定对端的真实状态,只能靠超时和心跳去推断。这就是所谓的部分失败(partial failure)。
我们的做法一般是:把超时设置成两段——连接超时和读超时分开。连接超时短一些,几百毫秒到 1 秒;读超时根据服务的 P99 延迟来定,通常是 P99 的 3~5 倍,而不是拍脑袋设个 30 秒。固定长超时会让线程池被尾部延迟拖垮:假设服务 P99 是 200ms,P999 是 2s,你设 30 秒超时,一旦某个下游服务抖动,所有线程都在等,连接池很快就耗尽,正常请求全部失败。这个坑我见过太多次,每次排查到最后都是同一个结论:不是下游挂了,是我们的等待策略太天真。
举一次真实的排查经历。当时某服务依赖第三方积分服务,配置了 30 秒连接超时,看起来是很宽容的节奏。某个晚上第三方网络出口堵塞,响应时间暴增十几秒,结果我们服务上千个线程全部阻塞在等待积分接口返回上,Web 层资源池耗尽,所有路由全部报错。后面修复不是把超时调小就完了,而是把连接超时和读超时拆开,给依赖接口单独开线程池,并加上熔断逻辑,这才真正恢复。
1.2 超时与重试是把双刃剑,幂等是底线
网络不可靠意味着你几乎必须在合理范围内做重试,但重试最直接的问题是产生重复请求。客户端看到超时,发起重试,而实际上第一个请求在对端已经执行成功了——支付、扣库存、发短信这类有副作用的操作,重复执行就会出大问题。
我的解决方案很简单也很硬核:所有写接口必须做幂等。幂等的落法常见有三种:
- 请求唯一 ID 去重:客户端每一次业务请求生成一个 requestId,服务端在数据库里用 requestId 建唯一索引,插入冲突就返回已有结果。
- 业务键唯一约束:比如订单号、支付流水号,数据库层面 unique key 兜底。
- 状态机校验:只有状态处于“待支付”才能执行支付,已支付状态直接拒绝或者直接幂等返回。
我不止一次在项目里看到,重复扣款的根因就是重试时没有幂等机制。所以我会特意强调:先做幂等,再谈重试。没有幂等,前面的重试策略再优雅都是空谈。很多团队喜欢把精力花在“超时怎么调”上,但真正吃大亏的,都是重试带来的重复请求把下游打穿。
1.3 防止故障扩散:熔断、退避与线程池隔离
重试也不是无限重试,否则下游越慢,你打给它的请求反而越多,把下游直接打死,这叫故障放大。我的习惯是:第一轮超时后,用指数退避加上重试,比如 1s、2s、4s,并加随机抖动,避免多个实例同时重试形成惊群。同时引入熔断器:连续错误率超过阈值,熔断器打开,快速失败,不再发起请求;一段时间后进入半开状态,放少量探测请求,成功后再关闭熔断。
线程池隔离也是被验证过很有效的方法。给每类下游依赖分配独立的线程池或信号量,比如服务 A 依赖 B、C,B 故障了只会占满 B 专用的线程池,不影响 C。这个思路在 Hystrix、Resilience4j、Sentinel 里都有对应实现,原理并不复杂,值得在生产里落地。我见过太多系统没有做隔离,一个下游抖动,整个服务跟着瘫痪,这类事故完全可以通过隔离设计避免掉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 时钟漂移:为什么“时间戳排序”在分布式系统里天生不可靠
2.1 物理时钟的漂移:NTP不是万能药
很多开发者默认服务器时间都是准的,因为它跑着 NTP 服务。但现代分布式系统里许多诡异 bug,恰恰来自物理时钟的漂移。服务器里的石英晶振会因为温度、负载、老化产生漂移,一台机器一天可能跑偏几十甚至几百毫秒。NTP 同步本身也不是实时纠偏,而是周期性校准,且校准过程有网络延迟误差;还有闰秒微调整,某些老系统在闰秒时会卡顿甚至崩溃。
这个误差在单机系统无所谓,但在分布式系统里,一旦涉及“哪个事件先发生”的判断,就麻烦了。常见场景包括:
- 把“最后写入时间戳”大的数据当作最新数据,结果旧节点的时钟快了几十秒,覆盖了新节点上的新数据。
- 用客户端时间做排序,客户端本地时间被用户改了,日志和业务数据顺序完全混乱。
- 用时间戳做消息的先后顺序,跨机房延迟浮动,顺序错乱。
我记过一笔账:某次线上数据修复,脚本按客户端时间戳过滤增量数据,结果几个客户端时间不一致,把一堆还没处理的数据当成旧数据处理掉,最后还得靠全量对账找回来。从那之后,业务里凡是要做“先后”判断的,我几乎不再信任机器时间戳。
2.2 逻辑时钟与混合逻辑时钟:为因果关系而设计
如果只是要描述事件的因果顺序,而不是墙上时间,分布式系统领域很早就给了方案:逻辑时钟。Lamport 时钟用一个单调递增的计数器来给事件打标记,事件发生时会更新本地计数器,消息传递时还会带上发送方的计数器值,从而保证:如果事件 A 因果地影响事件 B,那么 L(A) < L(B)。但 Lamport 时钟的局限是,它无法比较没有因果关系的事件,而且 L(A) < L(B) 并不能倒推出 A 一定影响 B。
向量时钟(Vector Clock)解决了一部分:每个进程维护其他进程的计数器,可以比较出“并发事件”,两个事件互不依赖时就是并发。工程上用起来会比较占存储,能表达因果顺序但维护成本不低。HLC(Hybrid Logical Clock)是实际应用里不错的选择:把物理时钟和逻辑时钟混在一起,既能保留物理时间的直观性,让排序方便,又能捕捉因果依赖,TiDB 等数据库的技术讨论里经常提到。
我的建议其实比较保守:如果你的业务只是要保证“同一份数据的最新值”不被旧值覆盖,用版本号或版本向量比用时间戳靠谱得多。数据库里的 Update ... SET version = version + 1 WHERE version = ? 这种乐观锁,就是最简单可靠的方式。用时间戳做版本控制,只能算是图省事,生产环境里迟早给你挖坑。
2.3 工程实践:雪花ID的时钟回拨与版本号控制
提到时钟,不得不提雪花(Snowflake)ID。雪花 ID 用“时间戳 + 机器 ID + 序号”来生成趋势递增的 ID,分布式环境用得非常多。但它有一个经典问题:如果服务器时钟回拨,NTP 校准导致的回拨很常见,同一时间戳可能会生成重复 ID。
处理时钟回拨,业界一般有几种套路:
- 等待:检测到回拨时,期望周期内等待时钟追平,超过阈值再报错。适用于回拨量很小的场景。
- 预留位/备用时间位:时间位上做偏移,让 ID 生成逻辑单调递增,即使墙钟回拨也能保证生成时间单调。
- 使用独立序号发生器:像美团 Leaf 这类方案,不依赖系统时钟,用数据库发号段来保证趋势递增。
另外一个经验:不要用应用服务器的时间去对比来自不同服务的时间戳。在日志和链路数据里,如果你要按时间排序,最好在收集端做乱序重排,而不是依赖生成时间。当年为了排查“明明 A 先发的消息,怎么处理记录显示 B 在前”,把日志时间戳和业务序列号一起打出来,才发现是两台机器时钟差了几秒。从那以后,凡是业务上的“先后”,一律不碰物理时间戳,除非是审计、监控这类只做展示的场景。
3. 分布式事务的取舍:2PC、Saga、TCC各自的适用边界
3.1 先从本地事务说起:ACID在分布式环境的态度变化
在单体应用里,你可以在一个数据库里同时更新订单表和库存表,靠本地事务的 ACID 保证原子性。一旦拆成微服务,订单在订单库,库存在库存库,甚至订单在服务 A、支付在服务 B,事务的原子性、隔离性就没有那么简单了。这就是分布式事务要解决的问题。
很多团队一听到“分布式事务”就想到强一致方案,但我见过太多反面案例:为了追求强一致引入分布式事务中间件,结果系统可用性下降、运维复杂度飙升,最后业务还是改成最终一致。核心原因是:在分布式系统里,网络分区是常态,你不可能让所有参与者在一个不可靠网络上做到“要么全做、要么全不做”并且不分摊代价。所以选方案之前,先想清楚业务能容忍多长的数据不一致,再选择对应的一致性级别。这个决定做得越早,后面返工的成本越低。
3.2 三种主流方案的原理与边界
我梳理一下最常见的三种方案:2PC、TCC、Saga,另外把本地消息表和 MQ 作为最终一致的补充。
两阶段提交(2PC):协调者先发 prepare,所有参与者准备好资源并返回成功,协调者再发 commit;任何一个参与者失败则全部回滚。它提供强一致、原子性,但问题是:prepare 后协调者单点故障,参与者只能一直阻塞等待;参与者之间通过网络交互,网络分区时可能无法完成提交。2PC 更适合参与者数量少、对一致性要求极高、允许适度阻塞的小范围场景,生产环境大规模使用并不现实。
TCC(Try-Confirm-Cancel):把业务拆成三步——Try 阶段预留资源,Confirm 阶段真正执行,Cancel 阶段释放预留。比如扣减库存时,Try 先冻结库存,Confirm 再扣减,Cancel 释放冻结。TCC 的好处是资源预留让并发冲突变少,适合对可靠性要求高的核心链路;坏处是每个参与方都要实现三组接口,有不少额外开发量。而且 Confirm 和 Cancel 业务逻辑本身要幂等,否则重试出问题。
Saga:把一个长事务拆成多个本地事务,每个本地事务对应一个补偿事务。正向流程执行失败时,按照反方向执行补偿操作。比如下单:创建订单、扣减库存、扣减用户余额;如果扣余额失败,则反向撤销订单和恢复库存。Saga 适合长流程、中间状态可接受短暂存在的场景。实现上有编排式和协同式两种:编排式用 Saga 状态机集中控流,协同式各服务通过事件异步协作。
本地消息表 + 消息队列:生产者开启本地事务,同时写业务表和一条消息表记录,同一个事务保证两者一致;然后后台任务定时扫描消息表发送消息;消费者收到消息执行消费,成功后标记消息已消费。它实现的是最终一致性,优点是方案简单、不依赖专门的事务中间件,缺点是本地消息表会占用数据库资源,消息积压时延迟较高。
3.3 电商订单的一个完整取舍示例
拿一个真实的电商下单链路来把上面的方案连起来。假设流程是:创建订单、扣库存、支付、发货。
- 创建订单和扣库存通常在一个服务内部,用本地事务解决。
- 库存服务是独立服务时,扣库存可以用 TCC,Try 阶段在 Redis 或数据库里冻结库存,Confirm 阶段真正扣减,Cancel 阶段释放冻结。
- 订单创建后,把“订单已创建”的事件写入本地消息表,异步通知给支付服务或库存服务,保证最终一致性。
- 支付回调是一个天然的重试加幂等场景,可以用 Saga 的思想:支付成功后,正向推进订单状态;如果支付失败,执行补偿,取消订单、释放库存。
通常我告诉团队的一句话是:先想清楚业务能否接受最终一致;能接受,优先用消息加本地事务加幂等补偿,别急着上强一致方案。实际项目里,能落地、能运维、出问题能排查的方案,远比“听起来架构很完整但出了问题没人敢碰”的方案更有价值。
| 方案 | 一致性 | 复杂度 | 性能 | 适用场景 |
|---|---|---|---|---|
| 2PC | 强一致 | 高 | 低(阻塞) | 参与者少、允许阻塞的小范围强一致场景 |
| TCC | 强一致(业务层) | 高 | 中 | 核心资源类操作,如库存、余额 |
| Saga | 最终一致 | 中高 | 中高 | 长流程跨服务业务,如订单流转 |
| 本地消息表+MQ | 最终一致 | 中 | 高 | 绝大多数异步化业务场景 |
4. 一致性、分布式锁与脑裂:多副本共识的经典坑
4.1 CAP不是玩具,是权衡工具
讨论分布式数据一致性,CAP 是绕不开的概念:一致性、可用性、分区容错性三者,在网络分区的前提下,只能在 C 和 A 之间做取舍。注意,网络分区是必然发生的,所以 CAP 更准确的说法是:当分区发生时,你是选择返回旧数据,也就是可用性优先,还是拒绝访问以等待数据对齐,也就是一致性优先。
实际业务里,绝大多数互联网场景选择 AP:允许短暂的不一致,换取更长的可用时间,最终通过补偿手段把数据补齐。数据库主从架构、缓存与数据库的一致性,都是最终一致性的体现。但要注意,最终一致性不等于无脑接受旧数据。常见的更强一点的保证有:
- 读己之写:用户提交后,立即读能读到自己提交的数据。
- 单调读:一个用户发起多次读取,不会读到比前一次更旧的数据。
实现思路是:读写都带上分片或路由信息,必要时让用户会话固定到某个副本上,也就是 session affinity,或者在应用层维护“我上次写到的位置”,读取时优先到那个位置拿数据。这些在方案设计阶段就要考虑,别等上线出 bug 再补。很多团队直到数据不一致的投诉堆成山,才回头补“会话亲和”和“路由策略”,成本比一开始设计高好几倍。
4.2 分布式锁的三种实现与它们的坑
分布式锁几乎是分布式系统的刚需:抢单、秒杀、定时任务只允许一台机器执行。常见实现有三种。
基于 Redis 的 SETNX:SET key uniqueId NX EX ttl。流程很简单,但坑也最多:
- 忘记加过期时间,进程崩溃后锁永远不释放。
- 删除锁时没有校验客户端标识,可能误删别人的锁。必须用 value 存客户端唯一 ID,删除时先比较再删,且用 Lua 脚本保证原子。
- 锁过期时间是硬伤:业务执行时间大于过期时间,锁提前释放,另一个线程拿到锁,两个线程同时执行。解决思路:给业务操作加 watchdog 续期,或者设置足够大的过期时间加快执行。
- 主从切换场景:A 在 master 上拿到锁,master 故障切换到 slave,slave 没有锁记录,B 也能拿到锁,导致并发。RedLock 想解决这个问题,但学术界和工程界对其一直有争议,因为它本身依赖多个独立节点和全局时钟。
我的建议是:能用 etcd 或 ZooKeeper,就不要用 Redis 做高可靠性锁;Redis 锁最多适合短临界区、能容忍极小概率并发的内容。
基于 ZooKeeper 的临时顺序节点:客户端在指定路径下创建临时顺序节点,获得序号最小的节点即获得锁。其他节点监听前一个节点,释放后触发获取锁。临时节点的天然好处是客户端会话断开自动删除,避免死锁。但要注意 session 过期可能导致锁被提前释放,以及 ZK 集群本身也存在变更延迟,选型时要评估。
基于 etcd/Raft 的租约锁:etcd 提供 Lease 加事务比较,能实现较理想的分布式锁。原理上是 Raft 共识,主从切换后仍能保证锁的正确性。强一致性让我比较放心。大多数云原生基础设施默认用 etcd 锁,不是没原因的。
| 实现 | 自动释放 | 主/从切换安全性 | 性能 | 适用场景 |
|---|---|---|---|---|
| Redis SETNX | 需自定义过期/续期 | 低 | 极高 | 短临界区、容忍极小概率并发 |
| ZooKeeper 临时节点 | 会话断开自动释放 | 中高 | 中 | 较多写场景需评估 |
| etcd Lease | 租约到期自动释放 | 高 | 中高 | 高可靠性锁、选主、配置 |
4.3 脑裂:fencing token和租约怎么救场
脑裂是分布式系统最怕的故障:由于网络分区,多个节点都以为自己是主节点,同时接受写请求,导致数据被分裂成两份。最常见出现在主从切换、leader 选举、缓存哨兵切换这些场景。
解决脑裂的核心思路是:任何时刻,只有一个节点拥有主人身份,这个身份必须可以被其他节点验证。两个常用机制:quorum 多数派,重要的变更必须得到多数节点确认,例如 Redis Sentinel 切换、Raft 选主都依赖多数派。网络分区时,只有包含多数节点的分区能继续选主,少数分区则无法获得身份;还有 fencing token 隔离令牌,每次选主产生一个递增的 token,旧主是旧 token,新主是新 token。当旧主尝试写数据时,服务端发现它的 token 小于当前最大 token,直接拒绝。ZooKeeper 里的 zxid、etcd 里的 revision 可以用来做这种 token。
这里说一个真实的翻车案例:某次主库切换演练,新库已经完成数据同步并通过哨兵切换为主库,但应用客户端仍然缓存着旧库连接,继续向旧库写入。旧库没有数据同步到新库,新库正常对外服务,结果这 5 分钟里写入的数据全部“失踪”。后来修复:切换时强制旧库下线,拒绝写入,并且客户端在遇到连接异常时要重新向配置中心获取当前主节点地址,而不是无脑重连旧库。从那以后,凡是做切换演练,我都会额外检查客户端的连接池是不是会“顽强地”连回旧库。
5. 故障检测与主节点选举:高可用系统里最容易翻车的细节
5.1 心跳、超时与环境抖动评估
高可用系统里一定要有故障检测机制,最常见的方式是节点之间发送心跳。心跳超时的设定很有讲究:超时太短,网络一抖动就误判节点故障,频繁触发主备切换,反而降低可用性;超时太长,节点真正故障后,业务长时间没有新主,故障时间拉长。
合理的做法是:根据心跳间隔统计 RTT 的延迟分布,把超时设成心跳间隔的 3~5 倍,同时给网络抖动留出足够的冗余。比如心跳每隔 1 秒发一次,超时设 3~5 秒。但这只是经验值,真正要依据你的网络环境去压测和调整。Kubernetes 里的 kubelet 向 APIServer 报告自身状态、etcd 的心跳和选举超时,参数都是经过调优的,可以参考它们的默认值。
另外,故障检测不等同于“检测到就立刻切换”。很多系统引入了租约的概念:节点只有在租约有效期内才允许持有某个角色或锁;租约到期后,哪怕节点还活着,它也必须停止扮演持有者。租约能很好地把“我对全局资源没有争议”的责任放到持有者自己身上,从而降低脑裂概率。比如 master 定期续约,当它无法续约,就自动放弃身份。
5.2 Raft选主的几个理解要点
Raft 是非常流行的共识算法,很多中间件都用它做选主和数据复制。理解 Raft 选主有这几个关键点:
- 节点有三种状态:Leader、Follower、Candidate。
- 每个任期(term)内只有一个 Leader。网络分区后,多数派一侧能选出新 Leader,少数派一侧不能完成多数派选举,也就不能成为 Leader。
- Follower 在超时时间内没收到 Leader 心跳,就变 Candidate 并开始新任期选举,投自己的票并要求其他节点投票。获得多数派投票的节点成为新 Leader。
- 新 Leader 必须拥有全部已提交日志,它在上任后会强制让其他节点与自己的日志对齐。
我之前被问得最多的一个问题是:“怎么保证新 Leader 一定是最新的?”答案在于日志索引和任期比较:投票时,节点只投票给日志更新,也就是任期更大或任期相同但日志索引更大的候选者。这样即使旧主在分区内仍被部分节点认为活着,它也没有能力拉到多数票,选举结果天然偏向数据最新的节点。
5.3 换主之后的故事:日志同步与老主回归
选举成功不等于系统可以继续服务了,真正容易翻车的是换主之后的处理。
第一,老主回归问题。旧主从网络分区恢复后,可能还带着旧日志。它如果继续接受写流量,就会发生脑裂。正确做法是:旧的 Leader 发现自己 term 落后后,立刻降级为 Follower,并主动跟上新主的日志;在协议层面,新主通过 term 比较可以让旧主“辞职”。应用代码里不要给旧主任何特权,甚至要主动拒绝它。
第二,客户端路由。客户端请求必须路由到新主。如果客户端缓存了旧主地址,就必须增加“连接失败时重新拉取集群地址”的逻辑。很多中间件客户端会自动处理,但你自己封装 RPC 的时候,最容易忽略这一点。
第三,数据同步窗口。新主上任后,要确保它已经从多数派同步了所有已提交的数据。Raft 天然保证这点。如果你用的是简化版的选主,比如只检测心跳然后随便挑一个当主,一定要补上日志校验和对账逻辑,否则可能丢数据。
我做过一次实践:用 etcd 加自定义 leader 选举,失败切换后马上做一次数据对账任务,把新主与旧主之间的数据差异找出来并补写。这套兜底策略让系统在高度不可靠的环境下也能保证最后能收敛到一致。虽然对账任务看起来有点“土”,但在生产里屡试不爽,能救回许多因为网络分区造成的隐性数据缺口。
6. 流量洪峰时的经典连环坑:缓存、限流与降级
6.1 穿透、击穿、雪崩:一种一种拆掉
缓存是分布式系统应对高并发的第一道防线,但它带来的坑同样不少。
缓存穿透:请求查询一个数据库中不存在的数据,缓存里自然也没有,每次请求都打到数据库,数据库压力倍增。解决方案:一是布隆过滤器,把可能存在的数据放进过滤器,查不到直接返回;二是缓存空值,给不存在的 key 也缓存一个空结果并设置较短过期时间,比如 30 到 60 秒。
缓存击穿:某个热点 key 过期的一瞬间,大量请求同时穿过缓存打到数据库,造成数据库瞬间压力骤升。解决方案:一是互斥锁,用分布式锁或本地锁,只有一个线程去数据库重建缓存,其他线程等锁后直接复用缓存;二是逻辑过期,热点 key 在缓存里存一个“逻辑过期时间”,业务发现逻辑过期后再异步重建,避免并发穿透。
缓存雪崩:大量 key 在同一时间过期,或缓存服务本身不可用,请求全部打到数据库。解决方案:一是设置过期时间时加随机数,把过期打散;二是多级缓存,本地缓存加 Redis,Redis 故障时本地缓存还能扛住一部分;三是接口层限流、降级,兜底数据库不被冲垮。特别是缓存服务整体挂掉的场景,一定要有“降级到数据库但严格限流”的预案,防止数据库也跟着挂。
还有一个常被忽略的点是缓存一致性:Cache Aside(旁路缓存)模式下,先更新数据库,再删除缓存;删除失败会导致旧数据长期服务。我建议用延迟双删:更新完数据库后删缓存,短暂延迟后再删一次,并配合消息队列确保删除最终成功。同时,缓存和数据库之间要保持最终一致,千万别试图在缓存里维护事务,那会把简单问题复杂化。
6.2 分布式限流与削峰填谷
引入限流是流量洪峰下保护系统的最基本动作。单机限流可以用令牌桶、漏桶、滑动窗口,但分布式下要用共享存储来计数,最常见的是 Redis 加 Lua 脚本。基于 Lua 实现滑动窗口或令牌桶,原子性好、性能高,但注意 Redis 本身的高可用会影响限流的可用性——限流组件挂了,业务宁可拒绝流量,也不要放行所有流量。这个权衡在做架构时就要明确。
- 固定窗口:实现简单,但窗口边界会出现双倍流量尖峰。
- 滑动窗口:把窗口切成更小的时间片,计数更平滑。
- 令牌桶:以恒定速率往桶里放令牌,请求需要拿到令牌才能通过,允许一定的突发流量,是最常用的自适应限流。
- 漏桶:出口速率恒定,适用于保护下游依赖,比如第三方接口只允许每秒 10 次调用。
更好的限流还要结合业务优先级:重要客户请求优先,普通请求降级,甚至直接拒绝。系统保护要的是“关键时刻不死”,而不是“对所有请求一律公平”。我在大促时经常跟团队强调,限流阈值一定要给足余量,并且要有动态调整入口,不能写死,不然活动流量一涨,限流先把自己限死了。
削峰填谷的目的是把突发的流量先缓冲下来,再按下游能力慢慢消费。最经典的载体就是消息队列:秒杀下单先把请求写入 MQ,消费端根据自己的吞吐能力批量处理订单,避免数据库被峰值流量打爆。这会带来消息积压和延迟,但对很多业务是可接受的。消息队列消费者要特别注意重复消费问题——可靠消费一定要配合幂等消费,因为至少一次投递几乎无法避免重复。
6.3 秒杀场景的完整链路设计
把前几节讲的方案串起来,一个经典的秒杀链路可以这样设计:
- 用户请求先接入网关或 API 层,做分布式限流,按用户维度和全局维度双层限流。
- 商品详情等服务用缓存抗住读流量,活动库存预扣也放在 Redis 里,用 Lua 脚本原子扣减库存,防止超卖。
- 扣减成功后,把“抢购成功”消息写入 MQ,响应客户端“排队中”。
- 消费端从 MQ 拉取订单创建消息,批量落库,创建订单、扣账户余额等操作通过本地事务加幂等处理保证一致性。
- 如果消费失败或数据库异常,进入死信队列或人工补偿通道,确保订单不漏单、不超卖。
这套链路在多个促销场景里验证过:Redis 先抗住 90% 以上的热点流量,MQ 把写库压力削平,数据库稳态只吃大约 1/10 的峰值请求。
实际踩过的坑也列几个:
- Redis 扣库存必须用 Lua 保证原子性,不能先 get 再 set,否则并发下必超卖。
- 消息积压要有监控告警,积压超过阈值要能自动扩容消费者实例。
- 秒杀失败的用户要有明确的“已售罄”响应,不能让他们反复重试,加重系统压力。
- 缓存预热和过期随机化要在活动开始前做好,不然大促当天缓存雪崩,系统直接白给。
这些点我已经在不同项目里逐一验证过。对我个人来说,分布式系统的“常见问题”远不止一份清单能穷尽,但只要你把网络不可靠、时钟不可信、状态易分裂、流量有尖峰这四件事想透了,绝大多数线上事故都能在设计阶段就拦下来。真等事故在凌晨三点爆发再救火,代价往往是第二天的复盘会比身体更痛。
