第 19 章:绞肉机里的重试风暴与死锁重启 (The Thundering Herd and the Deadlock Restart)
2016年初,西雅图。全美最大的超级碗前夜。
创世软件已经彻底完成了云原生(Cloud Native)的转型。上千个微服务在 Kubernetes (K8s) 的调度下如同呼吸般自然收缩。
为了确保明天的超级碗流量洪峰万无一失,运维总监戴夫在 K8s 中给核心“数据库代理服务(DB-Proxy)”配置了最高级别的自动保护策略: “看,李!我在 Liveness Probe(存活探针)里写了,只要代理服务 3 秒内没有响应,K8s 就会自动判定它为假死,然后毫不留情地把它杀掉重启!”戴夫自豪地展示着 YAML 配置。
李思看着那行代码,眉头微皱。“3 秒是不是太短了?”
“不短了,李。”戴夫说,“如果它 3 秒都没反应,说明系统已经僵死了。尽早杀掉重启(Fail Fast / Restart),是我们云原生架构的圣经。”
这种粗暴但被业界推崇的“重启包治百病”理论,确实在大多数时候有效。但那只是因为,他们还没遇到真正的深水区泥潭(Thundering Herd,惊群效应)。
次日,超级碗中场秀。流量如海啸般砸来。
李思站在战情室里,他的通感视界(Synesthesia)已经被全息网络发出的剧烈轰鸣声填满。全网 QPS 瞬间飙升了 10 倍。
一切都在按照预想的运转。K8s 的自动扩容(HPA)疯狂地拉起新的容器应对流量。
但在机房最深处的那个名为“主数据库(MySQL Master)”的单点瓶颈上排起了长队。它前面的 50 个“数据库代理服务(DB-Proxy)”容器,正试图把数以万计的查询塞进数据库。
由于排队人太多,主数据库处理变慢了。
在 14:01 的那一秒。 代理服务 A,向数据库发出了一个查询,然后开始等待。 1秒……2秒……3秒……数据库还没有返回数据。代理服务 A 的线程被卡住了。
这时候,位于上层的 K8s 巡逻员(Kubelet)带着它的“存活探针(Liveness Probe)”走过来了。它向代理服务 A 敲了敲门:“兄弟,你活着吗?” 代理服务 A 的嘴巴被刚才那个排队的线程堵死了,来不及回答。
“哦,3 秒没反应。你死了。”K8s 巡逻员冷酷无情地拔出了枪。
砰!
代理服务 A 被强行击杀(SIGKILL)。
在李思的通感中,这就是灾难的第一声枪响。
“戴夫!出事了!”李思猛地越过控制台,“K8s 把代理服务杀掉了!”
“别慌,李。它死了,K8s 马上就会拉起一个新的。”戴夫不以为然。
没错,K8s 确实极其尽责地在 1 秒后拉起了一个全新的代理服务 A。 但这可是一个全新的容器。它脑子里没有任何缓存,它的建立连接池(Connection Pool)是空的!
当这个新生的代理服务站起来的那一刻。面对外面排山倒海压过来的超级碗流量,它为了活命,做的第一件事就是: “我要立刻向主数据库同时发起 100 个急促的物理连接(TCP Handshake)!”
主数据库本来就已经很慢了,突然被这样一个像饿死鬼投胎一样的新生儿猛地一头撞过来。 主数据库处理这 100 个连接握手,又花费了 2 秒钟。
这导致站在旁边的、本来就快超时的代理服务 B,也熬过了 3 秒钟。
K8s 巡逻员走到了代理服务 B 面前:“3秒没反应。你死了。” 砰!
代理服务 B 被击杀!然后 K8s 再次拉起一个新的服务 B。新生儿 B 立刻又向数据库发起了 100 个连接!
这就是著名的“惊群效应(Thundering Herd)”与“连环探针击杀”在云原生时代的最恐怖结合。
“不!不!” 戴夫看着大屏幕上的节点监控图,眼神里充满了极度的恐惧。
在短短十秒钟内。那 50 个原本虽然慢、但还在艰辛干活的代理服务,全被 K8s 以“超时”的罪名屠杀了! 紧接着,50 个新生儿同时被拉起! 然后这 50 个新生儿,整齐划一地同时向主数据库发起了5000 次高强度的并发 TCP 连接!
数据库就像是被五十辆满载的重型卡车同时撞击! “轰!” 原本只是缓慢的数据库,在这波“新建连接风暴”的冲击下,它的 CPU 直接被打到了 100%,彻底死锁了!
数据库死锁了……也就意味着,这新站起来的 50 个代理服务,连一次查询结果也等不到。
3 秒钟后。 K8s 巡逻员再次走入广场,看着这 50 个彻底僵死的代理服务。
“3 秒没反应。你们全死了。”
砰!砰!砰!砰!砰!
全军覆没!然后 K8s 极其死板地,再次拉起 50 个新的婴儿,继续向已经死去的数据库发起冲击!
启动、堵死、被杀、重启…… 整个集群变成了一台绞肉机!
“关掉探针!立刻把 K8s 的存活探针(Liveness Probe)给我删掉!”李思指着戴夫大吼道,他的额头上青筋暴起。 通感世界里,那些不断复活又被瞬间斩首的代码,发出了震耳欲聋的惨叫声。
戴夫浑身发抖地在键盘上敲击着,在 K8s 里强行取消了这 50 个代理的“自杀开关”。
绞肉机停下了斩首。 代理服务不再被杀死了。但是由于数据库已经被重启带来的海量脏连接彻底锁死,整个前台页面一片空白。全美几千万人在超级碗中场休息时,面对着一个永远在转圈的 Hello World 页面。
“李!数据库依然是死锁状态!外部流量全卡在网关了!网关也快 OOM 了!”有工程师绝望地喊道。
“因为我们在第 13 章设置了重试风暴的熔断啊!为了让服务不死,它就会在超时后退回去重试。”戴夫抓着头发,“可是现在数据库卡死了,他们所有的重试都在排队!”
李思盯着屏幕中心那个黑洞。 “这就是微服务的反噬!当你把代码拆得太碎,即使有重试,即使有熔断……当排队发生时,底层的排队效应会像泥石流一样逆流而上,把上层所有的节点全部淹没。”
李思猛地推开戴夫,坐在了那台最高权限制的终端前。
他知道,要打破这种分布式的全局死锁,唯一的方法,就是放弃那些温柔的“等待”机制。他必须在网络的最外层,立下一堵绝对的时间墙(Time Wall)。
“准备修改全局边缘网关(Edge Gateway)的逻辑配置文件。”李思的语气冷静得像一块冰。
“李,你要做什么?!”
“我要在这个庞大系统的最入口,增加一个全局请求最后期限(Global Context Deadline / Timeout)。”
李思双手在键盘上飞舞,用 Go 语言的核心包 context,将一个倒计时秒表,强行绑在了每一个外部请求的头上。
“从这一刻起,不论是重试,还是新加的请求。每一个从手机发到网关的请求,我只给它整整 5 秒 的存活寿命!”
李思敲击着回车,“一旦 5 秒倒计时结束,不论这请求现在传递到了第几层微服务,不论它是不是正在排队,甚至不论它是不是正在跟数据库握手……全网各个节点上的这个请求相关的所有线程,必须在同一微秒,就地自杀!同时中断!”
这是在大型微服务网中极度高阶的自救手段:上下文超时传递 (Context Cancel Propagation)。
当这行配置下发后。
奇迹发生了。 在通感的视界里,那些在微服务链路中排起万里长队、像丧尸一样死死扒着通道不放的废弃请求线程,它们的头顶上突然出现了一个血红的数字:0!
轰! 伴随着网关发出的一道冰冷的“超时”信号。 顺着整个微服务的网络拓扑图,像多米诺骨牌一样。那些卡在用户服务、代理服务、甚至是数据库入口的排队线程,全部主动松开了手,如尘埃般消散在内存中。
通道,瞬间空了!
主数据库的 CPU 占用率从 100% 笔直砸到了 10%。它在这个窒息的五分钟里,终于喘过了具有生命意义的第一口气。
随着旧通道被强行清理干净,真正新鲜的、带有新 5 秒寿命的请求开始了丝滑的穿梭。
页面恢复了。超级碗的流量平稳地流过了数据中心。
西拉斯看着图表上的心跳恢复,长长地吐出一口浊气。“李,你刚才救了我们所有人。”
李思没有笑。 他静静地站在屏幕前,通感的直觉告诉他,他只是暂缓了这种由无边际微服务网造成的死亡。
“西拉斯,你还没看透吗?”李思转过身,声音里带着一种超脱世俗的疲惫,“我们拆了几百个微服务,我们用了 K8s,我们配了熔断、重试、上下文超时。但结果呢?”
“结果就是,我们在用更加复杂的网络拓扑,去给前面犯下的错误擦屁股。”
“K8s 固然能管理容器的生死。但是在业务的深水区里,只要系统是一张无限蔓延的‘网’,只要一个上海的数据节点可以向纽约的数据库发起疯狂重试。这种惊群效应和雪崩,就永远会在你最意想不到的时刻摧毁你!”
“所以……”西拉斯迟疑地说。
“所以,卷二该结束了。”
李思走到白板前,将那张画满了三百个微服务连接的、无比丑陋如同蜘蛛网般乱飞的巨型架构图……狠狠地用黑板擦擦得一干二净! 那些代表着 K8s、Kafka、熔断器的名字,也全被他擦去。
白板上空空如也。
随后,李思在最中间,画下了一个小小的、四方四正的方块。他非常仔细地把它画成了一个绝对封闭的胶囊(Cell)。
这是在本书第一章中那一行 Hello World 曾经最纯粹的样子,也是经历了五十年的流浪与撕裂后,高维算法所指引的最终归宿。
“我们要抛弃这张蔓延全球的网。” 李思看着这颗极其微小的胶囊,眼中闪烁着一种近乎于朝圣的狂热。 “在卷三之中,我要把你们引以为傲的上百个微服务、连同数据库的切片,全部塞进这一个绝对真空的胶囊里。”
“它不向外请求,也不允许外部横向打扰。” “我们将把系统从‘一网打尽的分布式深渊’,变成‘一万个互不干涉的小单体帝国’。”
这就是架构史上的终极形态,也是阻断一切爆炸半径的绝对防御——基于独立隔离舱的单元化架构(Cell-Based Architecture)。
至此,卷二《分布式的沼泽》在无尽的重试与死亡重启中落下了帷幕。 高维算法停止了低频的震动。 它在等待。等待那个能在行星表面建立起一万个真空单元的最终时刻。
架构决策记录 (ADR) & 事故复盘 (Post-Mortem)
文档编号:PM-2016-02-07 事故等级:SEV-0 (超级碗大促期间全站死锁,服务群陷入无尽的被杀与重启循环) 主导人:李思 (Principal Engineer)
1. 事故现象 (What happened?) 超级碗流量洪峰时,数据库查询变慢导致前端请求拥堵。K8s 基于极度敏感的Liveness Probe(存活探针 3 秒超时),将因为拥堵而暂时失去响应的 50 个 DB-Proxy 容器判定为死亡并强行击杀(SIGKILL)。 容器随后立刻由 K8s 重启。新启动的 50 个容器在完全冷启动状态下,同时向数据库发起了巨量并发连接请求(惊群效应)。这直接将本就负荷极高的主库资源彻底抽干并物理死锁。数据库死锁反过来导致第二波探针再次大规模超时杀人,系统陷入了“拥堵 -> 被杀 -> 重发海量重连 -> 拥堵加剧 -> 再次被杀”的“绞肉机(连环重启)重启风暴”中。
2. 5 Whys 根本原因分析 (Root Cause)
- Why 1:为什么代理容器会被全部杀光? 因为 K8s 的 Liveness 探针阈值设置过于严苛且没有退避。将“业务阻塞/慢响应”错误地等同于“进程死机僵死”。
- Why 2:为什么重启会让系统死得更透? 因为重建节点丢失了所有的预热(Warm-up)状态和连接池。新节点强行建立物理连接的高昂代价引发了著名的惊群效应 (Thundering Herd Problem)。
- Why 3:为什么探针机制会被关闭后,系统依然无法恢复? 即使不再杀进程,大量已经超时并且被废弃的“脏请求”仍然留在深层网路(排队等待计算),阻挡了正常新请求的入场通道。
- Why 4:为什么深层请求没有及时取消? 因为微服务调用链路中,层与层之间缺乏全局的超时状态透传机制。网关虽然判定用户超时了,但底层依然在傻傻地花费 CPU 执行着“用户早已不在乎”的废操作。
- Why 5:为什么问题会像泥石滚雪球一样放大? 分布式网络缺乏绝对的边界隔离,牵一发而动全身。
3. 解决方案与架构决策 (Action Items & ADR)
- 临时止血 (Workaround):紧急关闭 Liveness Probe 防止无限重启。通过下发全局的边界强行掐断并下放到边缘网关,熔除了所有被挤压的历史死锁请求。
- 架构重构 (Long-term Fix):
- ADR-019A:严肃区分 Liveness(存活)与 Readiness(就绪)探针的意义。 禁止在由于业务拥挤能导致的慢接口上挂载 Liveness 重启杀器。Liveness 必须且只能用于“内存泄漏死锁且必须物理断电”的情况。如果是正常拥堵,只能用 Readiness 探针将其临时移流(让 K8s 不再派发新请求),绝对不能动辄击杀节点引发连接风暴。
- ADR-019B:全面贯彻全局上下文控制与级联取消 (Context Cancel Propagation)。 引入诸如 Go 的
Context。规定任意入口请求必须在 Header 中携带自身的绝对死亡时间戳(Deadline)。如果到达 DeadLine,链路上的全部 150 个微服务必须无条件原地终断其相关的查询代码和对下游发起的 RPC 并释放线程。切断僵尸计算资源的白白浪费。
4. 爆炸半径与代价反思 (Blast Radius & Trade-offs) 盲目迷信云原生工具(重启包治百病),而不深入理解底层的网络吞吐机制,往往会人为制造出威力惊人的雪崩。系统在庞大的网络里不再是单纯的死与活,而是深陷泥潭。 卷二的教训已足够残酷:我们无论如何依靠重试、限流、上下文隔离去修补一张宏大的分布式网状图,但在墨菲定律面前,爆炸半径依然会通过重试风暴波及全局。要彻底解决大规模系统的稳定性,我们必须跳出网状思维。在接下来的卷三中,我们将转向物理上绝对切断牵连的究极形态——“细胞单元化(Cellular Architecture)”。
架构师科普:连接过去与现在的系统设计 (Architect's Note)
1. K8s 最凶险的绞肉机:被滥用的 Liveness 探针 很多小白工程师接触到 K8s 后,觉得给所有程序加上“只要不返回 200 就杀掉重启”是一件极酷的事,反正 K8s 重启不花钱。 这是极端致命的架构惨案诱因。因为在大型流量来袭时(比如李思面临的超级碗或者淘宝双十一),服务器会变满,响应慢,但它依然在艰苦地工作。如果你因为嫌它慢就一枪毙了它,再拉起一个毫无缓存建树的新人,这个新生儿给数据库带来的建连压力(极耗 CPU)是老年人的十倍。结果反而加速了整个链路的彻底灭绝。这个经典的反脆弱教训让后来的 SRE 都只敢偷偷用“Readiness”将满载机器剔出流量池让他消化,而极少在深水区使用击杀重启。
2. 废计算的幽灵粉碎机:上下文取消 (Context Cancellation) 当用户打开一张网页一直白屏时,他早就没有耐心按下了“X”关闭了。但是你的分布式大网深处,还有整整六个微服务在这哥们发起的任务上疯狂苦读排队,甚至还要跑到硬盘里去取数据给这个不会再回来的死人。 在海量高并发中,这种被称为“死胎计算”的东西如果不掐断,服务器内存分分钟被挤爆。现代大型高阶如 Golang 的底座设计里,最引以为豪的特性就是 ctx context.Context。如果边缘网关判定超时或者人走了,直接发出一个 Cancel 信号,这信号沿着网线能瞬间闪电般打透背后那几十个服务,不论他们在哪里排队,立马强制扔下手头的活自杀。这个如同红外线清理废墟的高级设计,正是防雪崩的神兵利器。
卷二至此终结。大泥球被强行隔离,敬请期待下一篇章。