这篇不是拿来背名词的。
这是事故训练场。
很多人说自己懂架构,其实只是会背几个词:Redis、MQ、限流、熔断、降级、分布式锁。真到线上爆炸的时候,脑子还是空的。
所以我想把这些高频灾难场景都写成更接近事故现场的样子。以后随便翻到一个,都要先问自己:
- 如果这是我的系统,它会先死在哪?
- 我平时最爱用的那几件武器,这次还管用吗?
- 如果网络断了、机器慢了、流量翻十倍了,我的方案是不是立刻露馅?
场景 #1 —— ThreadLocal 内存泄漏
【噩梦描述】
你有一套老系统,历史包袱很重,用户上下文、租户信息、TraceID 全都塞在 ThreadLocal 里。平时压测也过,功能也没出过大问题,于是大家都默认“这套写法虽然老,但还能跑”。
直到某天,线上开始出现特别诡异的问题。A 用户的请求日志里混进了 B 用户的租户 ID,偶发地串数据,偶发地鉴权错乱。与此同时,JVM 内存缓慢上涨,Full GC 越来越频繁,但你翻 heap dump 又看不出那种特别大的单一对象。
最后你才意识到,线程池里的线程根本不会死,ThreadLocal 又没有及时清。值留在线程里,下一次换了用户进来还在用。你不是“上下文透传”,你是在做“上下文遗传”。
【拷打环节】
你以为在线程池里塞个 ThreadLocal 就像在单线程里写个局部变量一样安全吗?
如果线程复用十万次,这些脏上下文谁来擦屁股?
如果用户信息串了,你怎么证明这是 ThreadLocal 泄漏,不是缓存脏读、不是日志错位、不是网关乱传?
别装了,你的系统现在不是慢,是开始认错人了。
场景 #2 —— 上下文切换致死
【噩梦描述】
业务方天天催,说系统并发扛不住。你们团队一拍脑袋:线程不够就加线程。于是线程池从 200 调到 500,再调到 1000,最后直接拉满。
表面看上去机器很忙,CPU 一直很高,监控曲线甚至给人一种“资源利用率很好”的错觉。可用户侧感受到的却是另一回事:响应时间越来越差,吞吐量没有提升,反而开始掉。
你最后才发现,CPU 根本没在做业务,而是在忙着线程调度。上下文切换像一群人在一扇门口互相让路,谁都很忙,谁都没进去。
【拷打环节】
CPU 高就是系统在干活?你到底是懂机器,还是只会看面板颜色?
线程加到几千以后,你怎么判断瓶颈在业务逻辑,还是在调度器自己?
如果这个时候 GC 也凑热闹,你的系统是不是直接从“慢”升级成“看起来没挂,但已经没用”?
场景 #3 —— 无界队列 OOM
【噩梦描述】
你做了个异步处理服务,入口流量很大,下游又偶尔抖动。你想的是先把请求收下来,于是在内存里搞了个很大的队列,甚至干脆用无界队列,想着“先堆着,总能慢慢消费完”。
开始几分钟一切正常。请求都接进来了,调用方也觉得你服务很稳。然后下游开始慢,消费速度跟不上,队列长度一路飙升。对象越堆越多,老年代被顶满,GC 越来越重。
等你看到报警的时候,已经不是“处理慢一点”的问题,而是整个 JVM 直接 OOM。最讽刺的是,调用方那边可能还觉得你接口很稳,因为你前面一直在笑着接单。
【拷打环节】
你以为把请求收下来就是高可用?
无界队列不是缓冲区,是延迟爆炸装置。
如果上游永远比下游快,你准备把请求堆到哪一年?
别再骗自己说“慢慢会消化掉”。你的内存已经替你接单接到猝死了。
场景 #4 —— 死锁
【噩梦描述】
你有两个核心资源,两个线程都要拿。平时线上跑得飞快,偶尔一次并发时序刚好撞上,一个线程先拿 A 等 B,另一个线程先拿 B 等 A。两边都没报错,就是安静地互相等着。
更恶心的是,这种问题并不总发生。它像鬼一样,压测时不来,凌晨高峰时来,来了之后不是整站挂,而是某一类请求开始无限卡住。
你去看线程栈,一堆线程都活着,也没抛异常。老板问你系统是不是挂了,你说“也不算挂,就是卡住了”。这句话本身就已经说明你的系统死得很难看。
【拷打环节】
你以为死锁一定会伴随惊天动地的报错?
如果只是 3% 的请求被卡死,你打算多久才意识到这是锁顺序问题?
主线程等子线程、子线程又依赖主线程,这种自己绞死自己的写法,你平时 code review 看出来过吗?
现在不是“偶发卡顿”,是你的系统学会了自杀。
场景 #5 —— ABA 问题
【噩梦描述】
你为了追求性能,上了 CAS 乐观锁。值从 A 改成 B,又从 B 改回 A。第二个线程一看,诶,还是 A,那我 CAS 成功,没毛病。
问题在于,值虽然回到了 A,世界已经不是原来的世界了。中间那次变化已经发生过,资源已经被别人借走又还回,状态机已经跳了一轮,业务含义完全变了。
日志里你看不到报错,监控里你也看不到很大波动,但数据会慢慢变得不可信。最阴险的 bug 就是这种:代码以为自己没错,系统也以为自己没错,只有业务语义已经烂掉了。
【拷打环节】
你以为 CAS 成功就代表没有并发问题?
值一样,不代表历史一样,这么简单的道理你在状态机里想过吗?
如果中间那次变化附带资源消费、资金冻结、库存占用,你还敢说“反正又变回来了”?
场景 #6 —— 多线程事务失效
【噩梦描述】
你在主线程里开了事务,又为了加快速度把一些子任务丢到线程池里。你以为所有逻辑都包在一个业务流程里,自然也应该在一个事务里。
结果主线程最后回滚了,子线程却已经各自把数据写进数据库。主流程失败,子流程成功,系统里留下了一堆“半成品状态”。
用户看到的是一次失败操作,数据库里留下的却是一半成功的痕迹。你再回头补偿,就会发现自己根本说不清哪些写入该撤,哪些写入又被后续流程依赖了。
【拷打环节】
你以为 @Transactional 能跨线程生效?
子线程根本不在那个事务上下文里,这事你真不知道,还是装不知道?
如果一个子线程先成功写库,另一个子线程又失败,你准备靠什么把这些碎尸拼回去?
场景 #7 —— BigKey 阻塞
【噩梦描述】
你把一个超级大的列表、用户画像或者活动配置整个塞进一个 Redis Key,心想 Redis 很快,读一次就全有了。
平时请求不多还好,一到高峰,这个大 Key 一被读,网络带宽就像被人掐住一样。Redis 不是挂了,而是被这个巨无霸 Key 抢占了大量 IO,其他本来只需要取几个小字段的请求也跟着一起慢。
你会看到一种很诡异的现象:Redis CPU 不一定特别高,但整体延迟已经开始飘,网络也开始炸。所有人都在问“为什么只是查个缓存能把全站拖慢”。
【拷打环节】
你以为缓存是免费的?
一个 Key 大到可以拖慢整条链路,你平时到底有没有做过体检?
如果这个 Key 又恰好是热点,你还准备继续把它当成“优化成果”往外吹吗?
场景 #8 —— 热点 Key 击穿
【噩梦描述】
你正在为一场顶级流量的抢购活动做技术支撑。你给那个超级热门商品做了 Redis 缓存,过期时间 30 分钟。听起来很标准,也很合理。
然后最可怕的巧合发生了。活动开始前一秒,这个 Key 正好过期。十万并发请求几乎同时发现 Redis 里空了,于是像发疯一样涌向数据库。
数据库本来平时扛得住,可它扛的是均匀流量,不是这种极端瞬时冲击。连接池先被打满,磁盘 IO 再飙升,查询开始排队。第一个查库的人迟迟写不回缓存,后面的请求就继续前赴后继地冲库。
这不是“一个 Key 失效”,这是你整条链路一起跟着倒。
【拷打环节】
别跟我说“把过期时间设长一点”。万一活动时间改了呢?万一节点重启了呢?
你打算怎么保证十万个请求里,只有极少数能去查库,其他人都别冲进去添乱?
如果那个负责回源的人自己挂了,其他线程要一起陪葬吗?
除了锁,你有没有想过让热点数据逻辑过期、后台续命,而不是在流量高峰时玩俄罗斯轮盘?
场景 #9 —— 缓存穿透
【噩梦描述】
攻击者或者异常请求专门查不存在的数据,比如一堆压根没有的商品 ID、用户 ID。Redis 每次都查不到,数据库每次都要真实查询一次。
如果这种请求量很小,你根本感觉不到。但一旦有人持续打,你会看到缓存命中率下降,数据库 QPS 升高,最后真正的正常请求也被拖慢。
最致命的是,这类流量看起来很像“正常业务查询”。你表面上觉得是系统压力变大,实际上是有人在专门绕过你的缓存保护层。
【拷打环节】
你以为有缓存,数据库就安全了?
不存在的数据本来就是缓存最脆弱的洞。
如果所有请求都挑不存在的 key 打,你拿什么挡?布隆过滤器、空值缓存、网关限流,你到底准备在哪层拦?
场景 #10 —— Redis 脑裂
【噩梦描述】
你搞了个 Redis 高可用集群,节点一多,图一画,觉得自己已经很专业。结果某天网络分区,主节点和哨兵、从节点之间互相看不见,开始各自选主。
最可怕的不是全挂,而是都没全挂。不同分区里的客户端都还能写入,而且都收到“成功”响应。业务看起来没停,数据其实已经开始分叉。
等网络恢复之后,系统开始同步、覆盖、回放。你再去看某些用户的数据,会发现一部分写入无声无息地蒸发了。
【拷打环节】
你以为高可用就是“别挂”,对吗?
如果两个脑袋都觉得自己是主,你到底信谁?
网络一断,写成功就等于数据安全?你这理解也太天真了。
场景 #11 —— 序列化惊魂
【噩梦描述】
Redis 命中率很高,理论上应该很快,可应用服务器 CPU 却持续飙升。你一查火焰图,发现时间全耗在对象序列化、反序列化上。
问题出在你缓存的对象太重了。字段多、层次深、还夹杂一堆没必要的历史属性。Redis 本身并没有拖你后腿,是应用层在把自己榨干。
用户看到的是“查缓存也慢”。而你团队里有人还在说“是不是 Redis 该扩容了”。这就跟发高烧时怪温度计一样荒唐。
【拷打环节】
你以为命中缓存就代表性能没问题?
对象越大、结构越深,序列化越像在做苦力。
如果每个请求都要搬运一头大象,你还准备继续在 Redis 扩容上自我感动多久?
场景 #12 —— 缓存与数据库不一致
【噩梦描述】
用户改了昵称、更新了库存、修改了价格,数据库已经提交成功。然后缓存删除失败,或者删了又被并发旧请求回填。结果接下来十几分钟,用户看到的还是老数据。
最烦的是,这种问题经常不是一直错,而是偶发地错。你本地复现不了,线上只能靠零散日志拼故事。
对业务来说,这不是一个“技术小问题”,而是信任问题。用户会直接觉得:我明明改了,系统为什么骗我?
【拷打环节】
别跟我说“缓存最终会一致”。用户关心的是现在。
如果删除缓存失败,谁来补?如果旧请求又把老数据写回去了,你怎么防回填污染?
你以为“双删”喊两遍就能镇住这个鬼?
场景 #13 —— 消息积压雪崩
【噩梦描述】
下游消费者因为一个 bug 挂了两个小时,MQ 里堆了一大坨消息。你修好了之后很高兴,觉得“恢复消费就完了”。
结果消费者一恢复,开始疯狂回放历史消息。原本已经很脆弱的下游数据库、搜索引擎、库存服务,再次被这一波“旧流量海啸”狠狠干翻。
于是你得到一个特别黑色幽默的结论:
故障恢复本身,成了第二次故障的起点。
【拷打环节】
消息积压不是存着就没事,它只是把洪水憋在了上游。
恢复后如果不限速、不分级、不做削峰,你等于亲手放闸。
你以为 MQ 帮你扛住了流量,实际上它只是帮你攒了个更大的雷。
场景 #14 —— 重复消费
【噩梦描述】
你做了扣款、发券、发货这类业务。消息消费逻辑本身写得没问题,可 ACK 在网络里丢了,于是 MQ 认为你没消费成功,又给你来了一次。
如果业务幂等没做好,第一次扣款成功,第二次又扣一次,事故就直接发生了。更糟糕的是,你去查代码时会发现“业务逻辑只写了一次”,一切都像没有问题。
这种事故最容易让人恼羞成怒,因为代码看起来没毛病,出错的是“消息语义”和“系统时序”。
【拷打环节】
你以为消息中间件承诺了“只消费一次”?谁给你的幻觉?
如果它只能保证至少一次投递,你的幂等到底落在订单表、流水表,还是业务唯一键上?
别说“概率很低”,钱被多扣一次就够你上首页了。
场景 #15 —— 顺序错乱
【噩梦描述】
一笔订单先下单,再支付,再取消。你把这些事件都丢进 MQ,觉得解耦做得很漂亮。
然后某次分区重平衡、消费者扩缩容或者网络抖动,消息顺序乱了。取消先到,下单后到,库存回补和扣减也跟着错位。
日志里每条消息看起来都合法,单独看谁都没错,但放在业务时间线上就是一场灾难。
【拷打环节】
你以为“最终一致性”可以包治百病?
状态机一旦乱序,不是最终一致,是最终发疯。
同一实体的事件你到底怎么保序?保不了的时候怎么兜底?
场景 #16 —— 消息丢失
【噩梦描述】
某条关键消息在 broker 还没刷盘时机器掉电,或者消费端自动 ACK 太早,结果消息就这么没了。
业务表现是偶发少单、偶发状态不推进、偶发补偿永远触发不了。
最痛苦的是,这种事故不像重复消费那样会留下明显痕迹。它是“什么都没发生”,于是你连尸体都找不到。
你最后只能靠旁证去推断:这步逻辑应该发生,但没有发生;消息理论上存在,但系统里找不到。
【拷打环节】
你拿什么证明它真的丢了,而不是只是处理慢?
刷盘时机、ACK 时机、重试策略,你到底有没有完整想过?
“MQ 很可靠”这句话是谁告诉你的?他赔过线上事故吗?
场景 #17 —— 毒丸消息
【噩梦描述】
某条消息格式有问题,或者内容触发了消费者里的边界 bug。消费者一拿到它就报错,然后重试,再报错,再重试。
如果你没做隔离,这条毒丸消息会像卡在喉咙里的一根骨头,让整个消费分区都动不了。后面的正常消息明明没问题,也只能排队等死。
随着重试次数上升,线程池、日志、告警、重试队列全被带炸。系统像被一颗很小的子弹打穿了动脉。
【拷打环节】
一条坏消息能卡死一整条链路,这设计你自己听着不害臊吗?
你打算无限重试到宇宙尽头,还是把脏消息隔离出来?
“先重试看看”这种习惯,在这里就是慢性自杀。
场景 #18 —— 分布式事务悬挂
【噩梦描述】
本地事务提交了,消息没发出去;或者消息发出去了,本地事务最后回滚了。
订单系统说成功了,库存系统说没看见,支付系统又说已经开始处理。
每个子系统都只对自己的局部真相负责,拼在一起却是一团烂账。用户看到的可能是一笔“既成功又失败”的操作。
等你想补偿时,会发现补偿本身也需要依据一致的真相,可现在根本没有统一真相。
【拷打环节】
你到底信数据库提交,还是信消息发送?
如果两个结果分裂,你怎么定义业务真相?
别再轻飘飘地说“最终会补偿”,补偿本身就是另一套系统,懂吗?
场景 #19 —— 主从延迟
【噩梦描述】
用户刚下单成功,页面刷新却显示“查无此单”。他以为没成功,又点了一次。
原来写请求落在主库,读请求打到从库,而从库还没追上。
你表面上做了读写分离,实际上是在把一致性成本转嫁给用户。
对数据库来说这是正常的复制延迟,对用户来说这是系统在撒谎。
等重复订单、重复支付、重复扣库存出现时,大家才开始后知后觉地问:刚写完为什么读不到?
【拷打环节】
读写分离不是白赚的优化,它用一致性做了交换。
你有没有明确定义哪些请求可以读旧,哪些绝对不能?
如果没有,那你就是在让用户替你做复制延迟实验。
场景 #20 —— 深分页
【噩梦描述】
后台列表翻前几页都很快,越往后越慢。到第几万页时,数据库像在泥里爬。
原因很简单:limit offset 这种写法,偏移越大,数据库就得先扫掉前面那一大坨数据,再把你要的那几十条吐出来。
用户只看到了 20 条,数据库却已经干了十万条的苦力。
更要命的是,这类请求经常来自运营、报表或者内部管理后台,所以很多团队一开始根本不重视。
【拷打环节】
你以为“只是翻个页”,数据库也这么觉得吗?
为什么用户只要第 100000 页的 20 条,你却逼数据库先跑完前面 99980 条?
没有游标、没有范围分页、没有业务约束,你这就是让数据库做无意义体力活。
场景 #21 —— 隐形全表扫描
【噩梦描述】
一条 SQL 在测试环境飞快,线上数据量一大突然慢成狗。
你明明建了索引,执行计划却没按你想的走,或者数据分布变化后优化器选了条极烂的路。
最讨厌的是,这种问题往往不是一开始就爆,而是系统跑着跑着慢慢长出来的。
今天还好好的,明天一到高峰就把连接池压满。
代码没改,业务说没改,结果系统就是变慢了。很多人到这一步才第一次承认:数据库不是许愿池。
【拷打环节】
你建过索引,就以为查询一定走索引?
线上数据分布、统计信息、回表成本,你真的盯过吗?
别把“我以为会走索引”当成性能方案,那只是自我安慰。
场景 #22 —— 间隙锁死锁
【噩梦描述】
你有一段看起来普通的插入逻辑,高并发时偶发死锁。不是每次都来,但只要高峰一到,就开始时不时炸一下。
问题在于数据库为了保证隔离级别,会对范围做锁定。两个事务都以为自己只是插一行,实际上却在同一段索引区间上互相顶住。
应用层看起来没有显式锁,数据库里却已经打成一锅粥。你不理解底层锁语义,就会一直把它当成“玄学偶发”。
【拷打环节】
你以为只写了 insert,数据库就只锁一行?
如果索引区间被锁住,后面的并发插入会不会互相咬死?
别遇到死锁就只会无脑重试,重试能掩盖一时,掩盖不了结构性问题。
场景 #23 —— 连接池风暴
【噩梦描述】
服务重启、故障恢复或者节点扩容时,所有实例同时开始抢数据库连接。
数据库本来就在恢复期,结果一大批连接像洪水一样冲上来,直接把最大连接数吃满。
这时候就会出现一幕非常荒诞的画面:
应用和数据库明明都没完全挂,但它们在恢复的第一秒,就被自己人一起踩死了。
这不是普通压力问题,这是系统恢复路径没设计好。
【拷打环节】
你的连接池是在保护数据库,还是在恢复时带头冲锋把数据库踩死?
有没有冷启动限速?有没有连接预热节奏?有没有实例级别的错峰?
如果没有,那你这叫集体自杀,不叫弹性扩容。
场景 #24 —— 分布式 ID 重复
【噩梦描述】
你以为发号器是小事,结果某天线上突然爆出主键冲突。
一查,可能是时钟回拨,可能是 worker id 配错,可能是某个节点复制环境时把配置一起抄过去了。
问题不只是“插入报错”。更严重的是,一旦重复 ID 漏进去了,整条业务链都会被污染。订单号重复、日志串台、下游关联错误,全是一连串次生灾害。
这种事故很少发生,但只要发生一次,就足够让你怀疑人生。
【拷打环节】
你平时是不是把发号器当工具类随手用?
时钟回拨、节点复制、机房切换这些极端情况你考虑过吗?
ID 一旦不唯一,你所有“以 ID 为中心”的设计都会开始腐烂。
场景 #25 —— 级联故障
【噩梦描述】
A 调 B,B 调 C,C 再调 D。某个最底层的服务慢了几秒,结果上层线程一个个被堵住,调用链上的每一层都开始超时、重试、堆积。
很快,原本只是一个小点慢,变成全链路一起雪崩。
最糟糕的是,很多系统在这种时候还会自动重试,于是故障被自己放大得更快。
到最后你会发现,真正拖死系统的,可能不是最初那个坏节点,而是你整条依赖链毫无节制的互相索命。
【拷打环节】
你以为“我这个服务本身没问题”就能独善其身?
没有熔断、没有隔离、没有超时边界,依赖一慢你就跟着陪葬。
别再说“重试一下看看”,有时候重试就是补刀。
场景 #26 —— 分布式锁失效
【噩梦描述】
你用 Redis 做分布式锁,觉得很稳。结果业务执行时间比锁超时还长,锁自动释放了,第二个请求进来又拿到锁,把同一资源操作了一遍。
两个业务都觉得自己是合法执行者,数据冲突就在你眼皮底下发生。
更讽刺的是,监控里你还能看到“锁获取成功”,仿佛一切都很健康。
可惜锁不是看有没有加,而是看在最坏情况下还守不守得住。
【拷打环节】
你以为“加了锁”就等于“互斥一定成立”?
业务执行超时、GC 暂停、网络抖动,这些都会让你的锁先死。
如果锁过期了任务还在跑,你的系统根本不是并发安全,而是并发幻觉。
场景 #27 —— 配置漂移
【噩梦描述】
十台机器里有两台没更新配置,或者环境变量不一致。
结果线上出现一堆完全不规律的问题:有时候失败,有时候成功;换个节点又好了;重启完又随机复现。
你查代码查半天,越查越觉得自己要疯,因为同一个版本在不同机器上表现完全不一样。
最终才发现,根本不是代码问题,是配置已经悄悄分叉了。
这种事故最折磨人,因为它直接摧毁你对系统稳定性的直觉。
【拷打环节】
配置管理没做统一,还敢说自己在做微服务?
一部分节点活在旧世界,一部分节点活在新世界,你准备靠运气把它们拼起来?
别再只盯代码了,系统很多时候死在配置上。
场景 #28 —— 网络分区
【噩梦描述】
最可怕的不是机房全断,而是只断一半。
部分节点之间通信中断,但每一边都还活着、还在对外提供服务、还觉得自己是正常集群的一部分。
于是双写开始发生,选主开始分裂,状态开始各自演化。
对用户来说,两个分区里的系统都“能用”;对你来说,这意味着同一份业务现实被写成了两份不同的历史。
等网络恢复时,问题才真正爆炸:
你得决定谁覆盖谁、谁丢弃谁、谁补偿谁。这个时候再说“当时系统看起来没挂”,已经没有意义了。
【拷打环节】
你以为高可用就是节点多?如果网线被挖断了呢?
两个分区都能写、都觉得自己没错,这时候你准备信哪边?
别把“局部可用”误判成“系统健康”,那是最危险的错觉。
场景 #29 —— 全链路追踪断层
【噩梦描述】
同步调用那一截你还能靠 TraceID 串起来,一进线程池、异步任务或者 MQ,链路上下文就断了。
结果你只能看到一堆局部日志,却再也拼不回完整事故现场。
线上一出问题,A 服务说请求来了,B 服务说没收到,C 服务说处理过类似请求但不确定是不是同一个。
整个排障过程像在看被撕碎的监控录像。
你会发现,系统不是不能排,而是你根本没有保存“可排”的能力。
【拷打环节】
没有统一上下文传递,你做什么全链路追踪?
异步一断、消息一跳、线程一切换,TraceID 丢了你还想靠感觉破案?
系统不是只有运行时要设计,排障路径也得设计。
场景 #30 —— 时钟不同步
【噩梦描述】
有些机器时间快几秒,有些慢几秒,平时看起来没啥。
一到 Token 校验、签名过期、延迟队列、定时任务这些依赖时间窗口的地方,就开始莫名其妙地错。
最烦的是,这种错不是全错,而是部分节点错、部分请求错。
用户会说“我刚登录成功,怎么下一秒就说我过期了?”
而你如果没往时间同步那条线去想,可能会在鉴权、网关、缓存层绕一整天。
【拷打环节】
你到底有多信时间这件事?
如果不同节点对“现在”理解不一样,你的签名、Token、延迟任务是不是全都不可靠?
别把时间当背景板,它经常是系统里最隐蔽的状态变量。
场景 #31 —— 僵尸进程 / 优雅停机失败
【噩梦描述】
K8s 做滚动更新,本来你以为只是平滑替换。
结果旧 Pod 还没把连接处理完就被杀,新 Pod 也还没完全 ready,长连接用户开始掉线,部分请求直接中断。
业务方问你为什么每次发布都有人投诉。你说“已经做了滚动更新”。
问题是,滚动更新不等于优雅停机。系统会不会好好收尾,得看你有没有把退出路径设计清楚。
【拷打环节】
Pod 能停,不代表业务能停得漂亮。
如果旧连接没 drain 完,新流量又进不来,你这叫发布还是叫断电?
别再把 “SIGTERM 收到就退出” 当成优雅停机了。
场景 #32 —— 吵闹的邻居
【噩梦描述】
你的服务平时很稳,某段时间却开始偶发抖动。
代码没变,流量没变,数据库也没变,偏偏 RT 就是在某些时间窗里忽高忽低。
最后一查,不是你自己的锅,是宿主机上的别的容器在疯狂吃 CPU、打磁盘、抢缓存。
你在共享环境里住着,邻居半夜蹦迪,你再怎么整理自己房间也没用。
这种问题最容易误导工程师,因为它看起来特别像“业务逻辑偶发慢”。
【拷打环节】
你以为性能问题一定出在自己代码里?
资源隔离没做清楚,别人打喷嚏你都发烧。
如果环境层面的噪声不识别出来,你所有业务优化都只是白费力气。
场景 #33 —— DNS 解析剧变
【噩梦描述】
服务都活着,节点也健康,可调用突然开始一大片一大片地超时。
你查应用日志查不出原因,查数据库也查不出原因,最后发现是 DNS 解析开始失败或者抖动。
这时候最致命的地方在于,应用层经常会自动重试。
于是一个解析变慢的问题,很快被重试风暴放大成全链路卡顿。
明明不是业务服务本身挂了,可系统照样表现得像全站出事。
【拷打环节】
你以为名字解析这种基础设施问题和业务没关系?
如果 DNS 慢了、挂了、缓存失效了,你的服务发现、连接重建、重试逻辑还能撑住吗?
别只会盯 Java 线程栈,有时候问题在更下边。
场景 #34 —— 磁盘 IOPS 耗尽
【噩梦描述】
某次高峰期,日志级别没收住,应用开始海量打印。
日志、数据库、消息刷盘全在抢同一块云盘,结果 IOPS 很快被打满。
这时候系统不会立刻报一个特别清晰的错。你看到的只是:数据库写慢了、消息落盘慢了、应用偶发卡顿、告警开始乱飞。
每个症状都像一点小病,合起来却是整台机器的呼吸系统堵住了。
最后你才明白,不是数据库忽然变差了,是你把盘打废了。
【拷打环节】
你以为写日志只是顺手一打?
如果日志和核心存储共用一块盘,你就是在拿数据库的命换排障爽感。
IOPS 打满时,系统不是“有点慢”,而是开始集体缺氧。
场景 #35 —— 分摊不均
【噩梦描述】
一笔订单要分摊优惠券、积分、手续费、退款,金额一拆就出现除不尽。
你前端四舍五入一次,后端又四舍五入一次,财务系统再按另一套规则来一次,最后大家的账都对不上。
起初看起来只是几分钱的问题。
可一旦订单量大了,这几分钱会稳定累积,最终变成一笔谁都说不清的系统性误差。
技术同学喜欢说“小数点问题”,财务不会这么客气。财务只会说:你们账是错的。
【拷打环节】
钱分不均,不是小 bug,是根本性的账务问题。
最后那一分钱归谁?规则在哪层定义?能不能重放?
如果这些都没写死,你的系统不是做支付,是在做许愿。
场景 #36 —— 浮点数陷阱
【噩梦描述】
你拿 double 算金额、折扣或者费率,测试时都看不出问题。
一上线,各种边界值开始飘:0.1 加 0.2 不等于 0.3,金额比较偶尔失真,阈值判断莫名翻车。
最恐怖的是,这种错误通常不是整笔账明显错,而是边边角角地错。
一开始没人察觉,等察觉时已经进了账务链、对账链、报表链。
到那时你再说“机器就是这么算的”,没人会听。
【拷打环节】
你居然还想用浮点数算钱?
系统一旦涉及金额、费率、分摊,你还敢偷懒用二进制浮点,那就是主动挖坑。
别问为什么财务不信任你,是你先不尊重精度。
场景 #37 —— 并发扣款负余额
【噩梦描述】
两个请求几乎同时过来,都先查余额,都发现“还够”,然后都去扣款。
最后两边都成功,账户直接扣成负数。
这种问题特别常见,因为很多人会把“检查余额”和“执行扣款”写成两个看起来很自然的步骤。
单线程没问题,一并发就露底。
最离谱的是,很多团队事后第一反应还是“加个分布式锁”。你加锁当然可能暂时压住,但你到底是在解决账户模型,还是在补表面裂缝?
【拷打环节】
检查和扣减不是原子操作时,负余额就是迟早的事。
你打算怎么保证“看到的余额”和“扣掉后的余额”属于同一个事实时刻?
你以为加个锁就完事了?如果锁失效了呢?如果跨机房了呢?
场景 #38 —— 夏令时回拨
【噩梦描述】
凌晨两点过后,本地时间突然往回拨了一小时。
你的定时任务按“本地时间表达式”触发,于是有些任务执行了两次,有些任务又错过了一次。
用户不会跟你讨论什么时区政策、什么 DST。
他们只会告诉你:昨天那批任务为什么重复跑了?为什么报表多了一次结算?
你如果平时根本没把时间制度当回事,这种事故会把你打得毫无防备。
【拷打环节】
你到底是按物理时间调度,还是按墙上时钟调度?
时间一回拨,你的任务是否有幂等保护?
别总把时间当常量,时间本身就是事故来源。
场景 #39 —— 跨时区错乱
【噩梦描述】
数据库存 UTC,后端部分逻辑按服务器时区算,前端按用户本地时区展示,报表系统又按业务地区切天。
于是同一条数据,在不同页面、不同系统里显示成了不同的“日期”。
用户会说:我明明是今天下的单,为什么报表里算昨天?
运营会说:活动明明到今晚结束,为什么有些地区已经显示结束了?
这类问题不是技术实现难,而是没人统一定义“时间语义”。
【拷打环节】
你到底在哪一层定义“今天”?
如果存储、计算、展示用的不是同一套时间标准,系统不乱才怪。
别再说“前端自己转一下就行”,这是架构层问题,不是页面小修小补。
场景 #40 —— 闰秒问题
【噩梦描述】
某些操作系统在处理闰秒时,会让时钟停顿、回拨或者以奇怪方式平滑。
这对大多数业务平时像不存在,可一旦你依赖高频调度、精确计时或时间排序,就可能突然出现极怪的系统行为。
CPU 可能短时间挂高,定时任务可能失序,监控时间序列甚至都会出现难以解释的跳变。
这种问题平时极少见,但也因此更难排查。
你如果只盯应用代码,可能永远不会想到锅在系统时间处理方式上。
【拷打环节】
你以为“极少见”就等于“不用管”?
真正难排的事故,往往就是这种一年见不到几次但一来就把所有人打蒙的。
系统越底层,越不能用“应该没事吧”这种心态。
场景 #41 —— 不兼容的 DB 变更
【噩梦描述】
你做了数据库字段变更,新代码已经适配,旧代码还没全下线。
结果部署中间那几分钟,旧代码开始读取新结构,或者新代码依赖的新字段在旧节点看来根本不存在。
发布过程看起来很短,可这几分钟恰恰是最危险的窗口。
因为它不是“旧世界”也不是“新世界”,而是两个世界硬生生叠在一起。
用户不一定会立刻报错,但系统已经开始生成不兼容的数据。
【拷打环节】
你以为上线只是发布代码?
数据库结构演进从来都得允许新旧版本共存,这点都没考虑,你发什么版?
先扩再缩、前后兼容、双读双写,这些词你到底是真懂还是背过?
场景 #42 —— 灰度路由逃逸
【噩梦描述】
你本来只想让 1% 用户试试新逻辑,于是做了灰度。
结果灰度请求和正式请求共用了缓存、共用了消息通道、共用了下游副作用,污染很快扩散到全量。
最后你会发现,所谓灰度根本不是隔离实验,而是在生产环境里向所有人投放不确定性。
尤其当灰度流量携带了新格式数据时,正式环境很可能根本接不住。
灰度本来是为了降低风险,你却用它把风险扩大得更隐蔽。
【拷打环节】
你到底有没有把灰度和全量隔开?
缓存命名空间、消息主题、数据格式、回调路径,哪一层共用了?
别把“只放 1% 用户”当成安全策略,边界没隔离干净,1% 也能把全站拖下去。
场景 #43 —— 数据迁移脏读
【噩梦描述】
你要从老库迁到新库,数据还在持续写入。
你先全量拷一遍,再做增量追平。听起来很经典,也很合理。
问题是,追平过程里数据还在变,旧库和新库在一段时间内同时都不是完整真相。
如果写入顺序、延迟、补偿没处理好,最终你会得到两边都“差一点对”的状态。
这种事故不会立刻炸,它会慢慢表现为某些用户数据缺字段、某些订单状态对不上、某些历史记录永远追不齐。
【拷打环节】
数据迁移不是搬家,是不停有人在屋里走动时给你换地基。
你准备怎么定义迁移窗口内的唯一真相?
没有校验、没有重放、没有追平策略,你迁的不是数据,是事故。
场景 #44 —— 第三方服务“半死不活”
【噩梦描述】
第三方接口没有完全挂掉,只是变得特别慢。
每个请求都在等待,等待又不超时,调用方线程池很快被耗尽。
最糟糕的是,这种情况比直接失败更恶心。
直接失败你还能快速降级,慢吞吞地卡住则会一点点把你的资源抽干,让你看着系统慢慢失血。
很多团队直到线程池全满、服务 RT 飙升,才意识到问题根本不在自己,而是在那个“还活着但快死了”的外部依赖。
【拷打环节】
你以为第三方不报错就算正常?
慢就是另一种形式的故障。
没有超时、没有熔断、没有隔离舱,你就是在拿自己的线程池给别人陪葬。
场景 #45 —— 重放攻击
【噩梦描述】
攻击者截获一次合法请求,然后反复重放。
如果你的接口只校验参数格式、不校验唯一性和时效性,系统就会把每次重放都当成一次新的真实业务。
支付会重复、回调会重复、状态机会重复推进。
而你日志里看到的还全是“合法请求”,因为请求本身确实曾经合法过。
这种问题特别适合欺负那些“只关注业务 happy path”的系统。
【拷打环节】
没有 nonce、没有签名时效、没有幂等约束,你到底防了什么?
如果一条请求能被复制粘贴无限次,你的接口和裸奔有什么区别?
别总说安全是边缘需求,很多业务逻辑本身就是安全问题。
场景 #46 —— WebHook 轰炸
【噩梦描述】
第三方回调一直失败,于是它开始重试。
如果你没做好幂等和削峰,这些重复回调会一波一波打过来,最后把接收服务压得喘不过气。
更糟糕的是,WebHook 经常带着强业务语义,比如支付结果、订单通知、物流变更。
你不能随便丢,也不能无脑硬接。
于是系统陷入一种很难看的状态:既要接住不稳定的外部回调,又不能让它拖死主业务。
【拷打环节】
你打算直接在主业务接口上硬扛外部重试风暴?
没有验签、没有幂等、没有隔离队列、没有限流,你靠什么活下来?
第三方的重试策略不受你控制,这种外部不确定性你不先隔开,系统迟早被打穿。
如果以后真拿这篇来练,最重要的不是立刻背出“标准答案”。
最重要的是先承认一个事实:
很多架构事故并不是因为你完全不知道这个词,
而是因为你知道这个词,却从来没把它放进一个足够真实、足够难看的现场里想过。
等真到了线上,事故不会以“知识点”的形式出现。
它只会以一句更直接的话出现:
你的系统已经死了。