第一章 · 结算组¶
值班手机响的时候,陈稳正梦见自己在填一张表。
表格很长,一直往下延伸,每一栏都要填"影响范围"。他填不出来,笔尖悬在纸上,纸开始渗水。
铃声第三遍响起,他才把手伸出被子。
02:47。
屏幕上是一条自动告警:
他坐起来,右手已经在摸床头的眼镜,左手同时按亮了笔记本。动作没有一丝多余,像做过几百遍——确实做过几百遍。
何晚在旁边翻了个身,把被子往自己那边拽了拽,没有醒。她已经习惯了。头两个月她还会问"怎么了",后来就不问了。人对什么都能习惯,包括凌晨三点床边亮起的一块光。
陈稳走到客厅,没有开灯。
笔记本的光打在他脸上。他登进跳板机,手指在键盘上敲得很轻,怕吵到卧室。
第一步不是看日志。第一步是看大盘。
监控大盘一共十六个格子,他闭着眼都知道每一格的位置。第三行第二格——结算成功率——一条平直的绿线在 02:35 处断掉,垂直坠落,然后横着走,贴在底部。
不是抖动。是断了。
他把时间轴拉宽到一天,绿线在断点之前平稳得像一把尺子。他又拉宽到一周,一样。
这个系统他接了十一个月。前三个月他每天最后一件事就是看这条线,看它平不平。它平了三百多天。今天不平了。
他打开日志。
2026-07-14 02:35:12 ERROR [batch-settle-worker-3]
o.h.s.core.SettleTaskExecutor - task interrupted
java.sql.SQLException: Lock wait timeout exceeded; try restarting transaction
锁等待超时。
这不是原因,这是症状。他知道。锁超时说明有人在长时间持有一把锁不放,真正要找的是那个"有人"。
他开了第二个窗口,连上从库,查活跃事务。
第一行的 trx_started 是 02:31:44。到现在,十六分钟。
十六分钟的事务。
陈稳盯着那一行看了两秒。然后往右滑,看它在跑什么语句。
一条 UPDATE。更新 settle_merchant_balance 表。where 条件里有个字段——biz_line_code。
他不认识这个字段。
他在这个库里待了十一个月,这张表的每一列他都能背下来,一共十九列,没有 biz_line_code 这一列。
他打开 IDEA,全局搜索。
搜到了。三个文件,都在 marketing-settle-adapter 模块下。提交时间:昨天 18:20。提交人:蒋辉。
commit message 是一行:
陈稳把那三个文件挨个打开。
代码写得不难看,注释也齐。逻辑是营销侧要做一个新的返利结算,需要往商户余额表里写一笔账。为了区分业务来源,加了 biz_line_code 字段。
问题在第二个文件的第 87 行。
那是一个循环。循环里面,每一次迭代都发起一次余额更新。而整个循环,套在同一个事务里。
商户数是变量。今天营销侧圈的商户,陈稳查了一下——
11,438 个。
一万一千多次更新,一个事务,一把锁,锁着整张余额表的一大片行。而每天凌晨 02:30,结算的日终对账任务要扫这张表。
两件事撞在了一起。
陈稳往后靠了一下,椅背发出一声轻响。
他不生气。他也说不上是什么感觉,更像是一种确认:果然是这样。这种事每隔一两个月就发生一次,形式不同,本质一样——有人在他不知道的时候,往这个系统里塞了一个东西,然后走了。
他看了一眼时间。02:59。
现在有两条路。
第一条:把那个长事务 kill 掉。三秒钟的事,锁立刻释放,对账任务重新跑,五分钟内一切恢复正常。但营销侧那批已经写进去的返利数据会回滚一半,一万多个商户里,有多少已经落了账、多少没落,天亮之后要人工去对。
第二条:等。等那个事务自己跑完。但它已经跑了十六分钟,还没跑完,说明它可能被别的锁也堵住了——他查了一下,果然,它自己也在等锁。互相等。
死锁的变体。谁都不动。
那就只有第一条。
陈稳的手指放在键盘上,停了一下。
他打开企业微信,找到蒋辉的名字。对话框里最后一条消息是三个月前,蒋辉问他一个接口的字段含义。
他打字:
你的营销结算任务把余额表锁死了,日终对账挂了。我需要 kill 掉你的事务,会有部分数据回滚。
删掉。
重打:
蒋辉,在吗
看了两秒,也删掉。
凌晨三点,问"在吗"是没有意义的。他会看不到,明天早上十点才看到,然后回一句"啊?怎么会"。而对账任务现在每一分钟都在往后压,压到六点半,商户就要开始查前一天的账了。
陈稳关掉企业微信。
他新建了一个文本文件,把当前的事务 ID、执行语句、锁等待链、影响的商户 ID 段全部记下来,存成 20260714_0300_lockwait.txt。然后回到终端。
回车。
监控大盘上,结算成功率那条线在 03:02 抬起来。不是一下子回到顶上,是先跳到 40%,再到 78%,再往上爬。任务在重跑。
他看着那条线爬。
爬了六分钟,回到 99.98%。剩下的 0.02% 是正常的失败率,一直都在,是那些自己账户有问题的商户。
他没有立刻关电脑。
他把营销侧那批数据的落账情况查了一遍。11,438 个商户,成功落账 6,102 个,剩下 5,336 个回滚了。他把这两批 ID 分别导出来,存成两个文件。
然后他做了一件当时没有人要求他做的事。
他写了一个补偿脚本。
脚本很短,一百二十行,读取那 5,336 个 ID,分批重放,每批 200 个,每批一个独立事务,中间 sleep 50 毫秒,避开日终任务的时间窗。他自己跑了一遍 dry-run,把预期结果打到日志里,看了三遍,没有问题。
但他没有执行。
那是营销侧的数据,他不能替蒋辉决定要不要补。他把脚本提交到自己的分支上,起了个名字:
commit message 他写了七行:
fix: 营销结算长事务锁表的补偿脚本(未执行)
背景:0714 02:35 日终对账因锁等待超时中断,
根因为营销结算适配层在单事务内循环更新 11438 条
商户余额记录,与日终任务争锁。
已 kill 长事务恢复对账,营销侧 5336 条返利数据回滚。
本脚本用于分批重放这 5336 条,分批 200/事务,
避开 02:00-04:00 任务窗口。
需营销侧确认后执行。ID 清单见 attachment。
他看了一遍这段话。
写得很清楚。每一句都是事实,没有一句在指责谁。
他提交了。
03:41。
天还没亮。他去厨房倒了半杯温水,从抽屉里拿出胃药,两粒,就着水咽下去。药片有点大,他仰头咽了两次。
抽屉里那盒药还剩十一粒。他记得是上个月买的,一盒二十粒。
回到笔记本前,他开始写复盘邮件。
这是他自己给自己定的规矩:任何一次 P1 以上,二十四小时内出复盘。没人要求他这么做。老梁知道,从来没说什么,只有一次在他工位后面站了一会儿,说了句"写这么细啊"。
他写得很慢。
不是因为想不出来,是因为要想清楚哪些能写、哪些不能写。
比如"根因是蒋辉的代码在单事务内循环更新一万一千条记录",这是事实,但写进去就变成了追责。他改成"营销结算适配层的批量更新事务粒度过大"。
比如"该模块上线前未与结算组同步",也是事实。他想了很久,最后留下了,因为如果不留下,就没有下一条改进项。
邮件写了四段。
第一段:时间线。02:35 告警,02:47 值班响应,02:59 定位根因,03:02 处置,03:08 恢复。每一行后面标了具体动作。
第二段:根因分析。他画了一张锁等待链的图——用纯文本画的,因为他不会用画图工具,Visio 那种东西他打开过一次,找不到箭头在哪。他用 ASCII 画了三个方框和两条箭头。
第三段:影响面。6102 落账 / 5336 回滚,附了两个 ID 清单文件。补偿脚本的分支地址。
第四段:改进项。他列了四条:
1. 结算核心表的写入需通过统一接入层,禁止外部模块直连
2. 单事务更新行数硬限制(建议 500),CI 阶段静态检查
3. 涉及结算核心表的变更,评审需结算组参与
4. 日终任务窗口(02:00-04:00)设为写入禁入期,网关层拦截
第四条他多写了一句:
写完,他从头读了一遍。
三千一百字。
他在收件人里填了老梁,抄送蒋辉、蒋辉的组长、结算组全员。手指在"纪衡"三个字上停了一下。
纪衡是上周才来的。技术总监,直管中台三个组。全员会上讲了四十分钟,讲了很多陈稳没听过的词——技术资产、价值可视化、能力沉淀。会后大家在群里没怎么说话,只有蒋辉发了一句"纪总的思路很清晰"。
陈稳把纪衡加进了抄送。
发送。
04:26。
窗外开始有一点灰。不是亮,是黑得没那么彻底。对面楼有一户人家的窗亮着,昨天也是亮的,前天也是。他不知道那户人家是干什么的。
他合上笔记本。
回到卧室的时候,何晚翻了个身,含糊地问了一句:
"几点了。"
"四点多。"
"处理好了?"
"嗯。"
她往里挪了挪,给他让出一块位置。被子那一侧是凉的。
他躺下,睁着眼看天花板。
"不是你的活吧。"何晚忽然说。她的声音清醒了一点,说明她其实早就醒了。
"不是。"
"那你起来干什么。"
"我值班。"
"值班就是替别人擦屁股?"
陈稳没说话。
黑暗里她也没有再问。过了一会儿,她说:"我上周家长会,一个家长跟我说,他儿子回家跟他讲,长大要当程序员,因为程序员挣钱多。"
"嗯。"
"我当时想说点什么,但我没说。"
"说什么。"
"说不出来。"何晚翻过身,背对着他,"我要是说了,就是我这个当老师的,在教小孩别有梦想。"
天花板上有一道很细的裂缝,从灯座那里斜出来,走了大概三十公分。交房的时候就有,他跟物业说过,物业说是墙面漆的正常收缩,不影响结构。
他每次躺着都看见它。
脑子里还在跑那个事务。11,438 条。如果分成 200 一批,是 57 批。57 批乘以 50 毫秒的间隔,加上执行时间,大概两分钟能跑完。两分钟。原本要两分钟的事,做成了一个十六分钟的死事务,然后炸了一个凌晨。
不是难。就是没人告诉谁。
他翻了个身。
再过三个小时要起床。九点半有个需求评审,十点半有个双周会。桌上还有一个从上个月拖到现在的技术优化——历史结算单据的归档方案,他写了一半,没人催,所以他也没有很急地写完。
他想起明天要跟老梁说一下这个事,第 4 条得排个人。
然后他想到,也许不用说。老梁会看邮件的,老梁看邮件很仔细。
他闭上眼。
床头的值班手机还在充电,屏幕暗着,但没有关机。它从来不关机。
08:20。
陈稳到工位的时候,工区里已经坐了三分之一的人。
他的位置在最里面靠窗,桌上东西不多:两块外接屏、一个键盘、一个保温杯、一盒胃药、一小袋没开封的挂耳咖啡——是去年团建发的,他一直没喝。
他坐下,先开的不是邮件,是监控大盘。
一切正常。结算成功率 99.98%。那条绿线从早上四点到现在,平得像一把尺子。
昨晚那个坑,在这条线上已经看不出来了。要看出来,得把时间轴拉回昨天凌晨,才能看见那个 V 字形的深口子。
他把时间轴拉回去,看了一眼那个口子。
然后拉回来。
他打开邮箱。
四个小时,一封回复都没有。
他刷新了一下。
还是没有。
他关掉邮箱,打开 IDEA,找到那个拖了一个月的归档方案,从上次停下的地方接着写。
工区渐渐满了。有人在讨论周末去哪,有人在抢会议室,运营那边有人在打电话,声音很大。九点十分,蒋辉从他身后过去,去接水,路过的时候看了一眼他的屏幕,没有停。
陈稳的手在键盘上没停。
归档方案写到第三节,他卡住了。不是技术上卡住,是他忽然想不明白自己在给谁写。这个方案做完,历史单据的存储成本能降四成,一年大概省七十万。但这件事没有人催,没有排期,不在任何一个 OKR 里。
他停下来,回头看了一眼工区。
隔着两排工位,老梁的位置是空的。椅子推进去了,桌上那个用了很多年的搪瓷杯还在,杯口有一块磕掉的瓷。老梁今天不在,昨天下午就不在,说是被叫去开会。
陈稳转回来,又刷新了一次邮箱。
零。
他把邮件列表按时间排了一下序。昨天到今天,工区里一共发生了四十七封邮件往来:三封关于会议室调整,六封关于新的报销流程,十一封是某个营销活动的效果汇报——那个活动的数据他扫了一眼,参与人数一万一千多。
跟昨晚锁死在余额表里的那个数,是同一批商户。
他把邮箱关掉。
十一点,双周会。会上纪衡第一次来听结算组的会。他坐在长桌尽头,笔记本立起来,看不见屏幕。老梁不在,会由一个资深工程师主持,念了一遍进度。
念到"上周日终对账有一次超时中断,已恢复"的时候,纪衡抬了一下头。
"影响多少钱?"
主持的人卡了一下,看向陈稳。
陈稳把手从桌子下面拿上来。
"没有资损。"他说。"只是延迟。"
"延迟多久。"
"三十三分钟。"
纪衡点了一下头,在笔记本上敲了两下,没有再问。
会开了二十五分钟。散会的时候,纪衡走在最前面,走到门口停了一下,回头看了一眼会议室里的人,像是在确认什么。
然后他走了。
陈稳最后一个出来。他手里拿着一个笔记本,笔记本上什么都没记。
回工位的路上,蒋辉从后面赶上来,拍了他一下。
"哎,看到你邮件了。"蒋辉手里端着咖啡,语气很轻快,"辛苦啊,凌晨爬起来。"
"嗯。"
"那个锁的事我看了一下,"蒋辉喝了一口,"其实还好吧?也没资损。回滚那五千多条我让实习生手工补一下就行了,不用你那个脚本,太重了。"
陈稳停下来。
"手工补五千三百三十六条。"
"分几天嘛。"蒋辉笑了一下,"你别太紧张,这不是什么大事。"
"是不是大事不是你说的。"
话出口的时候陈稳自己也意外。声音不大,但那句话的落点很硬,把走廊里两个路过的人的头转了过来。
蒋辉的笑停了半秒。
"我不是那意思。"他说,"我就说别搞得那么复杂。"
"你循环里套了一万一千次更新。"陈稳听见自己在说,语速比平时快,"你上线之前有没有看过那张表谁在用?那张表凌晨两点半有个日终任务,跑了三年了,全公司的钱都从那儿过。你要是提前问我一句,一句就行——"
他忽然停住。
不是因为想到了什么策略。是因为他发现自己的手在抖。不是很明显,就是拿笔记本的那只手,指节那里在轻轻地跳。
他把手换到另一边。
"……算了。"他说。"你让实习生补吧。ID 清单在附件里,分批的话别一次超过两百。"
蒋辉看着他,表情有点复杂,说不上是尴尬还是别的什么。
"行。"蒋辉说。"那我照你说的来。"
他走了两步,又回头:"陈稳。"
"嗯。"
"你邮件写太长了。"蒋辉说,语气是真诚的,"三千多字,纪总不会看的。你就写一句'已恢复,无资损',比那三千字有用。"
然后他就走了,咖啡在手里晃了一下。
陈稳站在走廊上,站了大概十秒。
他没有回工位。他推开了旁边的消防通道门,走进楼梯间。
楼梯间里没人。声控灯亮了一下,又灭了。他在黑暗里站着,靠着墙。那面墙是水泥的,很凉。
他抬起手,看了一下。
不抖了。
他把手放下,靠在墙上,什么也没做。
楼上有人下来,两个人在说话,声音由远及近,说的是某个活动的排期。声控灯一层一层亮起来。经过他这一层时,两个人看了他一眼,没认出来是谁,继续往下走。
灯又灭了。
他低头看了看自己的脚。皮鞋。这双鞋是何晚去年买的,四百八,他一直穿着,鞋尖已经有点起皱。
他忽然很想踹一脚那个栏杆。
他没有踹。
不是因为他想通了什么,是因为他知道,如果他踹了,声音会在楼梯井里往上滚三四层,会有人开门出来看。然后他就得解释。
而他不会解释。他从来不会。
站了大概一分钟,他掏出手机,打开备忘录,新建了一条,打了几个字:
邮件写短一点
看了两秒。
他把这条删了,重新打:
三千字没人看。改进项那四条要单独发。
然后他把手机放回兜里,推门出去。
走廊上的灯很亮,亮得有点刺眼。
回到工位,他先开的还是监控大盘。
绿线,平的。
从昨天早上四点到现在,一共二十九个小时,平得像一把尺子。
他看着那条线,忽然冒出一个很清楚的念头,清楚得让他自己愣了一下——
这条线越平,就越像什么都没发生过。
而什么都没发生过,就等于他昨天晚上没有起来。
目录 · 第二章 有人在看 →