服务端那些没人写进需求文档的事

5 阅读25分钟

凌晨三点被电话吵醒,说线上挂了。爬起来一看,不是业务报错,是发布时重启了一台机器,结果那台上面的服务关到一半就卡住了,新请求还在往这边打,旧连接已经断得七七八八。最后只能强杀进程。第二天大家查日志,发现关的时候数据库连接先被回收了,HTTP 还在处理请求,一查库就崩。谁写的关闭逻辑?好几个人都碰过,没人能说清当时为什么那样关。

这种事我后来见过不少。做后端久了,总会遇到一类问题:平时不显山不露水,一上线、一扩模块、一重启,就冒出来。问题往往不在业务逻辑,而在「谁先起来、谁后起来、谁先关、谁后关」。这些东西很少有人当正式需求写进文档,可一旦乱掉,排错能排到天亮。

这篇文章想聊的,就是这些服务端架构里常见的「难言之隐」——不写进需求,但天天在咬人的那种。我用多个真实里常见的场景串起来说,不推销任何框架,最后会放一个相关开源项目的链接,看不看随你。


先多说两句「为什么没人写进需求」。产品经理不会给你提一个需求叫「请保证关闭进程时先关 HTTP 再关数据库」;业务评审也不会专门讨论「我们的服务启动顺序是否声明清晰」。大家默认这些是「工程实现细节」,是开发自己该搞定的。可实现细节一旦没搞定,线上出的就是业务级故障:用户看到 502、超时、数据异常。所以这些事既重要,又容易在需求阶段被忽略,最后全压在开发自己的「心里有数」上。人少的时候还能兜住,人一多、模块一多,就兜不住了。

下面我分几块说:关错顺序、起错顺序、单例乱飞、多人协作时约定说崩就崩、规模一大就爆雷。每块都会用一些例子,这些例子有的是我亲眼见过的,有的是听同事、网友吐槽的,你很可能也遇到过类似的。名字和细节我都虚化了,重点是把「难言之隐」说清楚。


一、关的时候关错了顺序

第一个坑:关进程的顺序。

很多服务在跑的时候会占着资源:数据库连接池、Redis 连接、HTTP 服务器、消息队列的 consumer、定时任务。关进程的时候,理论上应该「先别接新活了,把手头的活干完,再一样一样把资源还回去」。可代码里经常是各写各的:这里一个 close(),那里一个 disconnect(),还有人在信号处理函数里调 process.exit()。谁先执行谁后执行?没人保证。结果是:HTTP 还在收请求,数据库连接已经被关掉了;或者 consumer 还在处理消息,下面的 Redis 已经断开了。

我见过一个项目,上线半年没事,有一次做容量调整,多上了几台机器,旧机器缩容的时候要关进程。关到一半,监控开始报「数据库连接数异常」「部分请求超时」。查下来才发现,关的时候先调了「关闭数据库连接」的逻辑,后关的 HTTP。当时写代码的人早就离职了,文档里只写了一行「关闭顺序见各模块注释」,各模块的注释又不一致。最后只能翻 git 历史,把当年改关闭顺序的 commit 看了一遍,才把正确的顺序拼出来。拼出来之后呢?写进文档,再在代码里加了一堆「注意:必须在某某之后关闭」的注释。下次换个人改,会不会又乱?谁也不敢说。

还有一次,一个服务里用了消息队列的 consumer。关进程的时候,有人先关了「接收新消息」的入口,再关数据库;有人觉得应该先关数据库,再停 consumer。两派在代码里各改各的,最后合并的时候冲突了,解决冲突的人没搞清两边的意图,随手留了一版。上线后关进程时,consumer 还在跑,下面的 DB 已经关了,消费者逻辑里要写库,直接报错。事后大家定了个规矩:关的时候必须「先停 consumer,再关 HTTP,再关 DB」。规矩是定了,可代码里依然散落着三四个地方在调「关」的逻辑,没有一处总控在排队,全靠大家自觉按规矩写。自觉这东西,有一次没遵守,就前功尽弃。

还有一种情况:服务本身没关错,但关的「时机」错了。比如收到 SIGTERM 之后,理论上应该先停止接收新请求,等正在处理的请求结束,再关数据库、关 Redis。可有的实现是「一收到信号就开始关」,新请求还在进来,这边已经在关连接了。用户侧就看到 502、超时。这类问题查起来特别费劲,因为本地很难复现——你本地关一下进程,关得快,请求少,往往看不出问题;上了线,流量一打,关得慢一点,就全暴露了。

还有「关的时候卡住」:某个关闭回调里做了同步的 I/O,或者等一个锁,等不到就卡死。于是整个进程关不掉,K8s 只能等超时强杀。强杀之后,资源可能没还干净,下次同一台机器再起,端口占用、文件句柄泄漏就来了。所以关的逻辑不仅要「顺序对」,还要「别卡死、别拖太久」。可谁负责检查每个关闭回调会不会卡? again,没有总控的话,又是各写各的,出事了再查。

关的时候还有个问题:超时。有的资源关起来慢,比如数据库连接池要等正在跑的查询结束,消息队列的 consumer 要等当前消息处理完。如果「关」没有超时控制,就会一直等下去;如果设了超时,超时之后是强杀还是跳过、后面的资源还关不关?这些决策如果散落在各处,有的人写「超时了就 throw」,有的人写「超时了打个日志继续」,关一次进程下来,有的关了有的没关,状态不一致。有总控的话,至少可以统一「每个关闭步骤最多等多久、超时了怎么处理」。

所以「关的时候关错顺序」这件事,本质上是:没有人对「关闭顺序」和「关闭时机」负总责。大家都觉得「我把我这块关好就行了」,结果合在一起就乱套。

再说一个变体:有的服务会挂「退出钩子」,在进程退出时执行一段逻辑。可钩子是谁注册的、注册了几个、执行顺序怎样,经常也是一笔糊涂账。有人用 process.on('SIGTERM', ...),有人用某个库提供的 beforeExit,还有人直接在模块里写「import 时就注册」。最后关的时候,有的钩子先跑,把连接关了;有的钩子后跑,还想用连接,就报错。你让谁改?每个人都觉得自己的钩子没问题,问题是别人的钩子跑得太早或太晚。更糟的是,不同运行时、不同部署环境对信号的处理还不一样:有的会等你的钩子跑完再杀,有的给几秒就强杀。你本地跑的时候钩子顺序「碰巧」对,上了 K8s 可能就变成强杀,资源没关干净。所以「关」这件事,光靠每个人自己挂钩子,很难在多种环境下都稳定。


二、谁先起、谁后起,全靠约定

第二个坑:启动顺序。

服务 A 依赖配置先加载,服务 B 依赖数据库先连上,服务 C 又依赖 B 先起来。如果每个服务都是自己写一段「初始化代码」,那谁先执行谁后执行?有的项目会有一个「启动脚本」或「启动文件」,里面按顺序调一遍:先读配置,再连数据库,再起 HTTP,再起定时任务。顺序写死在一个地方,改起来心惊胆战——加一个模块就要插到合适的位置,万一插错了,本地可能还能跑(因为有的依赖碰巧已经在了),上了线就挂。

还有的项目更散:没有统一的启动入口,各个模块各自在「需要的时候」初始化。比如第一次处理 HTTP 请求时才去连数据库,第一次用缓存时才去连 Redis。听起来挺懒加载的,可这样就有个问题:谁依赖谁,在代码里是隐式的。新人看代码,不知道「用 DB 之前必须保证 Redis 已经连上」这种约定;重构的时候也不敢随便动,因为不知道动哪根线会带出一串。更麻烦的是,这种「需要的时候再连」的写法,经常和「关的时候要关」的逻辑对不上——起是懒的,关是集中关的,顺序对不上就又回到第一个坑。

我听过一个更离谱的:一个服务里,数据库连接在 A 文件里初始化,HTTP 在 B 文件里初始化,两边的初始化都依赖一份「全局配置」。可全局配置是在 C 文件里读的。C 是被 A 和 B 的上级模块在启动时 import 的,而 import 的顺序又取决于打包工具和运行时的加载顺序。结果有一回有人改了个无关的依赖,打包顺序变了,C 还没执行完,A 就跑了,配置是空的,连数据库直接崩。你说这是谁的锅?写 A 的人觉得「我按约定用的配置」,写 C 的人觉得「我按约定提供的配置」,约定没人写死,一换环境就碎。

类似的事在「多入口」项目里更常见。比如一个 monorepo,好几个包都要起服务,每个包自己有一个「启动脚本」。整体启动时,是并行起还是串行起?如果串行,顺序是什么?有的团队用脚本一层层调:先起 A,等 A 的 health check 过了再起 B,再起 C。听起来靠谱,可 A 的 health check 过了不代表 A 内部依赖的「配置」「连接池」都就绪了,可能只是 HTTP 端口在 listen。B 一起,去连 A 提供的内部接口,A 那边还没连上 DB,就 500。所以「谁先起谁后起」不能只看「进程起没起」,还得看「进程里的依赖就绪没就绪」。这两件事如果不在一层里统一管,就会变成各种隐式假设,换一个环境就崩。

再比如「懒加载」和「启动顺序」的冲突。有人为了省启动时间,把数据库连接做成「第一次请求时才连」。可定时任务也是同一个进程里的,定时任务跑的时候可能还没任何 HTTP 请求进来,这时候定时任务去用 DB,触发了「第一次连」,连接是建了,可定时任务没声明「我依赖 DB」,将来关的时候,关的顺序里可能先关了 DB 再停定时任务,定时任务还在跑,又去用 DB,就崩。所以「谁依赖谁」如果不在启动阶段说清楚,光靠「用的时候再连」就会和关闭顺序对不上。

所以「谁先起谁后起」的本质是:依赖关系没有在一处声明,顺序靠约定和偶然的加载顺序。人少、模块少的时候还能靠嘴说;人一多、模块一多,约定就靠不住了。

还有一类问题:环境差异。本地是 Mac,跑的是 A 顺序;部署是 Linux,或者用了不同的 Node 版本、不同的打包方式,加载顺序可能不一样。你在本地测得好好的,一上 CI 就挂;或者 CI 过了,一上预发就挂。大家就开始「在我机器上能跑」式排查,最后发现是某处依赖了「谁先被 import」的隐式顺序。这种事修起来特别恶心,因为你要么把顺序写死(容易脆),要么真的引入一层来管依赖(又要改架构)。


三、同一个东西被起了好几遍

第三个坑:本来该是「全进程一个」的东西,被初始化了好几次。

数据库连接池、Redis 客户端、HTTP 服务器、和第三方服务的长连接,这类资源通常整个进程只要一份就够了。多份的话,连接数翻倍,还可能带来状态不一致。可写代码的时候,有人用全局变量存一份,有人在依赖注入的容器里挂一份,有人图省事在某个模块内部 new 了一个自己用。结果:A 模块用自己 new 的池子,B 模块用自己 new 的池子,两边不互通。连接数蹭蹭涨,DBA 跑来找你问为什么这个应用开了这么多连接。你查代码,发现「数据库」这三个字出现在七八个文件里,每个文件都「建了一次」。

单例这件事,说起来简单——「全进程一个实例,大家共用」——可落到代码里,如果没有一层东西明确说「这东西是单例,由我创建、由我关」,就会变成「每个人觉得自己用的是单例,其实各用各的」。更糟的是关的时候:每个 new 过的地方都可能写了一个「关」的逻辑,关的时候谁关谁不关?有的关了两次,有的漏关。漏关的那个连接池一直在那儿,直到进程被强杀。

我见过一个服务,跑一段时间就 OOM。查下来发现是「某个模块自己 new 了一个数据库连接池,从没关过」。那个模块是后来加上的,加的人觉得「我就用一下 DB,用完就完了」,没想过「池子要关」。主流程的关闭逻辑里也没包含这个模块,因为主流程不知道有这么个池子存在。结果每次部署旧进程被强杀,新进程起来又建新池子,旧池子没关干净,连接数、内存一点点涨,最后 OOM。这种「隐式的资源、没人认领关闭责任」的坑,在单例没管好的项目里特别常见。

还有一种变体:不是「多个实例」,而是「以为只有一个,其实有多个」。比如大家以为全公司就一个「应用配置」对象,结果有的模块从环境变量读,有的从远程拉,有的从本地文件读,读出来的还不一样。问题排查起来特别魔幻:你这台机器上是对的,他那台不对;你这次启动是对的,下次加了个模块 import 顺序变了又不对。根因还是:没有一层统一说「配置就这一份,从哪儿来、什么时候关」

再往细里说,有的资源「看起来」是单例,其实背后有多份。比如 HTTP 服务器:理论上一个进程一个 listen 就够了,可有人为了省事在「每个 worker」里都起了一个,或者测试里每次用例都起一个,没关干净,端口占用、连接泄漏就来了。还有定时任务:全进程一份就够了,结果三个模块各自调了「启动定时任务」的 API,同一份逻辑跑了三遍,数据被重复处理。这些都不是「不会写单例」的问题,而是没有一层在说「这类资源全进程只一份,要用的来取,关的时候我关」

单例和「并发安全」也容易搅在一起。比如多处同时「第一次用 DB」,每处都检查「有没有现成的连接池」,没有就建一个。没有锁的话,可能同时建了好几个,最后只留了一个,别的泄漏了;有锁的话,又要小心死锁和启动时的顺序。如果有一层统一管「这类资源只建一次、大家来取」,至少「建几次」这件事不用每个调用方自己操心,也不会出现「A 和 B 各建各的,都以为自己是单例」这种局面。


四、多人一起改,约定说崩就崩

第四个坑:人一多,约定就松了。

上面说的「关闭顺序」「启动顺序」「单例」,在小团队里往往靠口口相传或者一两个核心的人心里有数。文档有,但文档会滞后:代码改了三版,文档还停在「先关 HTTP 再关 DB」,结果早就变成「先关 consumer 再关 HTTP 再关 DB」了,没人更新。新人进来,看文档按文档做,踩坑;老人说「文档过时了,你问某某」,某某又离职了。最后大家都不敢动启动和关闭的逻辑,能跑就行,技术债越堆越厚。

还有一种情况:注释和实际不符。代码里写「此处必须在 DB 关闭之后执行」,结果后来有人把 DB 的关闭挪了个地方,注释没改。下一个改的人信了注释,又挪了一次,顺序就乱了。注释、文档、代码,三处都在说「顺序」,三处可能不一致,信谁?

再就是「谁负责改」的问题。启动和关闭的逻辑,往往分散在好几个文件里。改的时候要动好几处,有的人只改了自己负责的那一块,以为别人会改别的块,结果没人改,线上就炸。事后复盘:「我以为你会改」「我以为那个已经改过了」。没有一处「总控」在说「所有的起和关都在这里排队」,大家就只好靠默契。默契一破,就出事。

还有一种:老人带新人,口口相传「这个地方你别动,动了会挂」。新人不敢动,可业务要加新模块,总得有个地方「接」进去。接在哪里?老人说「你加在某某后面」,某某是两年前写的,现在文件都重构过好几轮了,「后面」是哪个文件的后面?新人猜着加,加错了再回滚。时间一长,启动和关闭的逻辑变成一坨没人敢大动的「禁区」,外面再裹一层又一层补丁,技术债全堆在这儿。

还有 code review。启动和关闭的代码散落在各处,review 的人很难一眼看出「这个 close 和那个 init 是不是一对」「顺序有没有错」。大家往往只 review 自己负责的那几行,整体顺序对不对,没人拍胸脯。所以这类 bug 经常是「合进去没问题,上线一关进程就出问题」,因为 review 阶段没人专门测「关一下看看」。

文档和代码脱节也是个问题。有人好心写了「启动与关闭指南」,放在 wiki 上。过了半年,代码改了好几轮,指南没更新。新人按指南做,踩坑;老人说「别信 wiki,问某某」。某某离职了,大家就靠口口相传。最后要么没人维护文档,要么文档和代码各说各的。如果「谁先起谁后起、谁先关谁后关」能在代码里以声明的方式存在,至少代码本身就是文档,改代码就等于改文档,不会出现「文档说先 A 后 B,代码里已经变成先 B 后 A 了」这种尴尬。


五、规模一大,雷就爆了

第五个坑:小流量、少机器的时候没问题,一上规模就爆。

本地开发时,进程起得快、关得也快,请求少,关的时候几乎不会有「还有请求在处理」的情况。上了线,机器多了,重启、扩缩容、发版,关进程变成常态。关的时候如果顺序或时机不对,立刻在监控上看到:错误率 spike、连接数异常、部分请求超时。可这类问题在本地很难复现,只能靠「线上试一次,看日志」。试错了就回滚,再改代码,再试。成本很高。

再比如「重启单机」和「全量重启」不一样。单机重启时,可能只有那台机器上的连接会断;全量重启时,如果每台机器都是「一收到信号就关」,那所有机器同时开始关,上游负载均衡还在往这边打流量,就会有一大波请求失败。正确的做法往往是:先摘流量,再关进程;或者至少「先别接新请求,等旧请求处理完再关」。可这又回到「谁负责发信号」「谁负责等」——如果没有一层统一的关闭流程,大家又是各关各的,摘流量和关进程的配合就容易出岔子。

还有迁移、扩容:从旧集群迁到新集群,或者从 4 台扩到 8 台。新机器上的服务启动顺序对不对?关旧机器的时候顺序对不对?如果平时没有「声明式」的依赖和关闭顺序,每次迁移都要人工检查一遍,容易漏。多 region、多集群的时候更麻烦:有的 region 网络延迟大,启动慢;有的集群配置不一样,依赖的「配置中心」地址不同。你希望「同一套代码到处能跑」,可起和关的顺序、超时时间、依赖的地址,全在环境里变。如果这些没在一层里统一表达,就会变成「在 A 集群能跑,在 B 集群要改几行」「在 B 集群关得干净,在 C 集群关到一半卡住」。运维和开发都头大。

所以规模一大,启动顺序、关闭顺序、单例、约定,这些「小事」都会变成「一错就大面积故障」的雷。不提前在架构上理清楚,就会在线上一次次交学费。

再扯远一点:配置、环境变量、密钥。很多服务的「起」都依赖「先读到正确的配置」。配置从哪读?环境变量、配置文件、远程配置中心,可能混着用。谁先读?读的时候有没有依赖别的服务?比如「从配置中心拉配置」本身就要网络,网络超时了怎么办?是起不来还是用默认值?如果配置和「谁先起谁后起」搅在一起,就会变成:A 依赖配置里的 DB 地址,配置又依赖「配置服务先起」,可配置服务又依赖「本机某端口已监听」……循环依赖、顺序敏感,全来了。有的项目用「启动时把所有配置拉齐再起业务」来规避,可拉齐的「齐」是谁定义的?加一个新模块又要拉一份新配置,有没有人更新「齐」的清单?又是一笔糊涂账。还有密钥:有的服务起的时候要去拿密钥,拿密钥又要调另一个服务,那个服务又要 DB……链一长,谁先谁后、谁超时了会怎样,又是一团麻。这些和「关错顺序」「起错顺序」本质上是同一类问题:没有一层在说「谁依赖谁、按什么顺序来」,全凭各处自己调,调着调着就乱。所以如果你发现你们项目里「启动慢」「关不干净」「换环境就挂」,别急着加更多超时和重试,先看看是不是依赖和顺序从来没被显式管过。很多时候加一层「谁依赖谁、按序起按序关」就能少掉一大半这类问题。

另外,这类问题在测试里往往复现不了。单元测试不会真起 HTTP、真连 DB,集成测试可能只起一次、关一次,流量也小。真要模拟「关到一半还有请求进来」,要么专门写压测关进程的脚本,要么就得上线试。试错了,用户已经看到错误了。所以很多团队最后的选择是:尽量少重启、少动启动关闭逻辑,能跑就跑。可这样又会导致「没人敢动」,债越堆越多,直到某次不得不大改,风险一次性爆发。有的团队会写「关进程」的混沌测试,随机杀进程、随机关某个依赖,看系统能不能优雅降级。那又是另一套投入,而且只能暴露问题,不能从根上解决「顺序没人管」这件事。所以很多团队的选择是:能少重启就少重启,能不动启动关闭逻辑就不动。可技术债会越堆越多,直到某次不得不大改——比如换语言、换框架、拆微服务——那时候「起」和「关」的债会一次性暴露,改起来特别痛。


六、一种思路:让「谁依赖谁、谁先关谁后关」有处可管

上面这些坑,本质都可以归结成一点:依赖关系和生命周期没有一层统一在管。谁依赖谁、谁先起谁后起、谁先关谁后关,要么靠约定,要么靠注释,要么靠某几个人记在脑子里。代码里散落着各种「起」和「关」,没有一处说「所有的起和关都在这里排队」。

一种常见的应对方式,是引入一层「容器」或「启动框架」:你把「服务」定义出来,声明「我这个服务依赖谁」「我关的时候要调什么」。由这一层来排启动顺序——你声明了「我要用 DB」,它就保证在用你之前先把 DB 起好;关的时候倒过来关一遍,你只负责「我关的时候要关什么」,不负责「我第几个关」。这样,单例也可以交给这一层:DB 只在这一层里建一次,谁要用谁就来取,关的时候由这一层关一次。多人协作时,新人只需要学会「怎么声明服务、怎么声明依赖」,不需要背一整套「先谁后谁」的潜规则。

这种做法很多框架都在做,有的重(一整套依赖注入、配置、生命周期),有的轻(就管「起」和「关」,别的不管)。核心思想是一样的:把「谁依赖谁、谁先关谁后关」从散落的代码里收拢到一层,变成声明式的。你只写「我要用谁」「我关的时候做什么」,不写「我排第几个」。

好处是:新人上手时不需要背一长串「先谁后谁」的潜规则,只要学会「怎么声明一个服务、怎么声明依赖」;重构的时候,动的是「谁依赖谁」的声明,而不是到处散落的 init 和 close;关的时候由这一层统一倒序执行,不会出现「你关你的、我关我的」最后顺序乱掉。单例也可以交给这一层:某种资源只在这一层里建一次,谁要用就向这一层要,关的时候这一层关一次,不会漏也不会重复。而且「谁依赖谁」一旦变成声明,工具可以帮你检查循环依赖、帮你画依赖图,出问题的时候至少有个地方能翻,不用全靠猜和翻 git 历史。

当然,引入任何一层都有成本:要学一套概念、要改现有代码。所以有的人选择继续靠约定和文档,小团队、少模块时也够用;有的人被线上故障教育了几次,就下决心把「起」和「关」收拢到一层。没有绝对的对错,看团队规模和你能接受的复杂度。如果你在选型,可以看看那层是「只管起和关」还是「顺带管配置、路由、中间件」——管得越多,能力越强,但学习和迁移成本也越高。有的项目只需要「把顺序和单例管好」,别的保持原样,那就选轻一点的方案,够用就好。先解决「顺序和单例有人管」这件事,比一上来就全盘重构要现实得多。


上面这些,总结起来就是一句话:依赖谁、谁先起、谁后关,如果从来没在一处说清楚,就会变成各种隐式约定和潜规则,人一多、规模一大,约定说崩就崩,线上就爆雷。 解决思路也不是银弹,无非是:让「服务」和「依赖」变成显式声明,让「起」和「关」有且仅有一个地方在排队。至于用不用框架、用哪家,看你团队的习惯和能接受的复杂度。有的团队用现成的依赖注入或应用框架,有的自己写一层薄薄的「服务注册 + 按依赖排序起关」,都能缓解;关键是把「散落」收拢成「一处」,让顺序和单例有据可查。

写到这里,如果你也老被「关不干净」「启动顺序乱」「单例满天飞」「约定说崩就崩」这类事折腾,可以多一种参考:有一个开源项目就是按上面这种思路做的,把服务的依赖和关闭都交给一层来管,代码量不大,思路也比较直接。仓库地址:github.com/cevio/hile。不吹不黑,就当多一个选项。