第十七章 · 凌晨三点¶
九月十七日,星期四,上午九点十二分。
值班交接单上有一条没关闭的项:结算库连接数余量告警,昨夜二十三点零四分触发,一分四十秒后自愈,处理人一栏空着。触发值四百一十二,阈值四百五十,库端上限五百。
四百一十二这个数陈稳算得出来。连接池上限是他上周一从一百改到一百五十的,两台新机器每台多分二十五个。易文说的数是两百。他在 commit message 括号里写的理由是库端余量不足,那半句话他删过一次,又写了回去。
按两百算,库端会到四百四十。营销组下个月那个活动接口还要申请六十个。
记事本第一行:0917 值班 24h。库端连接 412/500。
九点三十,值班手机响了一次。不是告警,是排班提醒:九月十七日,值班二十四小时。签到,剩余二十三小时五十七分。
上午十点,值班群里进来一条消息,运维组的张磊。
「昨晚那条连接数告警你看到没。」
「看到了。四百一十二,一分四十秒自愈。」
「谁家的连接。」
「结算三百八十不动,多出来的三十二不是我们的。」
「营销测试环境昨天二十一点以后有人在跑东西。」
「跑什么。」
「不知道。他们那边不走我们的工单。」
十分钟后张磊又来一条:「你今天值班?池子上限多少。」
「一百五十。」
「调两百。峰值你扛不住。」
「库端只剩九十个余量。两百加上营销那六十个,就是五百整。」
「行,你自己拿主意。出事你叫我。」
营销组那封资源邮件的抄送里没有易文。这件事陈稳知道,他没有说。
下午两点,三楼会议室,数据组和运维组联合评审。纪衡不在,易文主持。压测数据没有人提问题。运维组的人问了一个。
「双十一当天新服务器扛不住,回退方案是什么。」
「两台互为备份,负载均衡权重三十秒内可以切光。」
「切光之后呢。老机器一台,池子一百。」
「限流。只保结算主链路,非核心查询全拒。」
「写进方案里,写清楚谁按开关。」
「好。」
散会前易文停在门口说了一句。
「连接池那一栏,你填的是一百五十。」
「是。」
「记着这个数。」
下午四点十分,记事本上「九月十七日 值班」后面画了一个勾。何晚的消息。
「明天晚上有没有空。」
「值班。明天早上才下班。」
何晚没有回复。
对账模块那条 TODO 还在原处:当前实现基于逐笔比对,时间窗口 T+1,目标 T+0 实时对账。他没有动它,注释日期是七月十一日。
晚上八点,食堂的面。汤是清的,没有味道。
结算成功率百分之九十九点九九。连接池使用率百分之五十八,八十七个活跃连接。慢查询日志今天新增七条,六条落在二十一点到二十一点半,对账任务的固定窗口,最慢一千九百四十毫秒。
晚上十点。窗外星期四的停车场空了大半,路灯橘黄色,没有人。
settle_reconciliation 的索引状态他核了一遍:四个索引都在,最近一次 DDL 是八月二十九日凌晨零点十七分的重建。上一次这张表的索引被人为删除,binlog 第九万三千七百行留着那条 DROP INDEX,执行账号 root@localhost,来源是一个 IP,不是一个人名。那条记录他截过图,存在本地,没有发给任何人。
配置变更检测脚本今天零告警。这个脚本是他八月底自己写的,五十四行,比对生产配置和仓库配置的差异,每十分钟跑一次,只写日志,不发通知。发通知要过运维组的告警接入评审,最近的窗口排在十月八号。
晚上十一点,一杯水,凉的,胃没有反应。今天第三片胃药,一盒十二片,这是九月的第二盒。
十一点到凌晨一点,一百二十行代码,对账模块的数据校验:字段类型、空值率、主键唯一性。commit message 写的是:feat: 对账模块数据校验——校验字段类型、空值率、主键唯一性。推上去,CI 四分十九秒通过。
凌晨一点十分,连接池使用率百分之五十二,七十八个连接。当天日志第二行写完,保存。椅背,闭眼。
没有做梦。醒来时值班手机的屏幕亮着:凌晨两点五十七分。没有告警。洗手间的水是冷的。
凌晨三点零一分。
值班手机震了一下:结算系统——结算成功率下降至百分之九十九点一零,阈值百分之九十九点九零,持续三分钟。
成功率曲线两分钟内从九十九点九九掉到九十九点一零。连接池使用率从百分之五十二直接顶到百分之百。CPU 从百分之十二到百分之四十七。
一百五十个连接全部占满。sleep 一百三十一个,执行中十九个,等待队列十四个。max_wait 三千毫秒,超时的请求已经开始回落成结算失败。十九个执行中的查询扫的是同一张表:settle_transaction_log。
这张表他记得。结算交易日志,二零二三年一月建,没有分区,没有归档,从二零二三年一月累到现在,三年八个月,约两亿行。全表扫描一次,能把一百五十个连接吃干。
如果上限是两百,还剩五十个,主链路不会断。这个数上周一从他手里过。
他没有先查根因。max_pool_size 从 150 改成 200,热加载,三秒生效。库端连接数四百四十一,离五百还有五十九。营销组那六十个还没上。
第二条:settle_transaction_log 的查询加拦截,不带时间范围条件的一律拒绝,返回码 429。执行,确认。
值班手机响了,张磊。
「我这边看到库端四百四十一。你调了。」
「调了两百。事故。」
「什么打的。」
「settle_transaction_log 全表扫描。两亿行,没分区。」
「你现在余量五十九,营销那六十个下周就上。」
「知道。分区方案我今晚写完。」
「成功率回来了吗。」
「六十一了,还在降。」
「盯十分钟,别急着关。」
「好。」
三点零九分:连接池使用率百分之六十一,CPU 百分之十九,成功率九十九点九零。三点十四分回到九十九点九九。等待队列零,失败请求累计四百七十三笔,全部可重试。
三点二十一分,根因查到。两点四十八分零六秒,一个定时任务发了一条不带时间范围、不带索引条件的全量查询,单条语句执行了八分十二秒才被拦截器掐掉,期间并发派生出十九个子查询。这个任务不是他写的,也不是蒋辉写的,是结算系统上线时的一个数据归档脚本。
蒋辉上个月在双月会上讲过一页 PPT,标题是「结算系统历史负债盘点」,列了七项,第四项写的是「日志表体量偏大,建议后续优化」。没有写行数,没有写表名,没有写谁做。这一页当时过了。
Git 历史:创建于二零二三年一月,创建者梁栋。commit message 写的是:feat: 结算交易日志归档脚本——按月归档,定时执行。归档目标表没建,定时任务没配,脚本在仓库里躺了三年八个月,一次没跑过。
今天有人手动跑了它。执行日志第二百四十一行记着来源 IP:10.14.1.88。
DHCP 分配记录:营销技术部测试环境池,最近一次分配是九月十七日二十一点三十分,二十二点整回收。三十分钟窗口,日志里没有主机名,没有登录账号。
昨夜二十三点零四分那条余量告警,触发值四百一十二。张磊说营销测试环境二十一点以后有人在跑东西。
他把 IP 抄进记事本,抄在上一次那条下面。两条隔了二十六天。
易文的号码在通话记录第三条,上一次通话是九月十四日晚上,八分钟。他没有拨。凌晨三点五十分拨过去,接了她也只会问一句「你处理完了吗」,处理完了,就没有拨的必要。
settle_transaction_log 的表结构:无分区,主键自增,索引两个——一个在 order_id,一个在 merchant_id,都不覆盖时间范围查询。表大小一百二十 GB,日增约一百一十万行、一百六十 MB,按这个速度明年这时候两百 GB。
建表语句的最后一行还留着一条注释:-- TODO: 上线后一个月内补分区。 提交时间二零二三年一月十一日。三年八个月,这条 TODO 没有人认领过。
三点五十分到四点二十分,分区方案写完:按月分区,保留最近十二个月,旧数据归档到历史表,历史表落冷盘。记事本上一行:分区方案已写完,周一发给纪衡。
紧急修复推上去的时候是四点三十八分。加一个联合索引,覆盖最常见的查询模式,配套禁止不带时间范围的全表扫描。commit message 写的是:fix: settle_transaction_log 全表扫描导致连接池打满——加索引,禁止不带时间范围的全表扫描。
索引创建语句执行时间十一秒,锁等待零毫秒,这个点库上没有别的写入。执行计划从 type: ALL 变成 type: range,rows 从两亿降到七万六千,Extra 里那个 Using where 后面多了 Using index condition。
同样的查询他重跑了一次:原来二十三点四秒,现在零点一九秒。
四点零二分那条消息是易文发的,他四点四十才看到。
「醒了。看到监控数据了。」
「嗯。」
「什么原因。」
「settle_transaction_log 全表扫描。一个归档脚本,老梁写的,三年八个月,今天第一次被调。」
「谁调的。」
「同一个 IP。」
易文没有回复。过了一会儿,又来一条。
「你打算怎么办。」
「加了索引,禁了全表扫描。分区方案写完了,周一提交。」
「池子你调到多少。」
「两百。」
「上周一我说的就是两百。」
「是。」
「那个 IP 呢。」
「不查了。」
「为什么。」
「查了也没用。测试环境的地址,谁都能用,DHCP 不记主机名。」
「你胆子真大。」
四点五十三分,纪衡的消息进来。凌晨。
「成功率恢复了?」
「三点十四分恢复。失败四百七十三笔,可重试。」
「谁的问题。」
「表没分区,归档脚本没启用。三年八个月的历史负债。」
「上线时谁写的。」
「梁栋。」
纪衡三分钟后回:「早上给我一份复盘。四百字。」
「好。」
第一份是技术版,写给自己的。标题:settle_transaction_log 全表扫描事故复盘(内部)。根因、时间线到秒、应急两步、索引变更前后的执行计划、分区方案、连接池上限一百五十与两百的差额、二十六天前同一个 IP。一千二百字,存成私有文档,不发送。
第二份是汇报版。标题:九月十七日凌晨事故复盘——结算交易日志表归档缺失导致连接池打满。事故时间、影响范围、根因、应急措施、长期方案、预期效果。四百字。
预期效果那一栏他先写的是:该修复可覆盖每年两次以上的凌晨事故,预期减少资损约三十万元。三十万这个数删了。
改成:预计将此类凌晨事故的发生概率降至零。
连接池那一节他写的是「已按备战方案上调至两百」。上周一那条 commit 的括号没有写进去。
技术版里那句被他删掉的话,他在记事本上重新写了一遍:这个改动能让每年两次以上的凌晨事故变成零次。这一遍没有删。
凌晨五点零三分。镜子里眼睛有血丝,脸上没有表情。
连接池使用率百分之四十七。归档脚本的代码他读完了一遍:老梁写的,一百一十行 Python,注释比代码长,逻辑干净,唯一的问题是它从来没有被人跑过。
早上六点,天从黑变深蓝,再变灰白,云层很低,今天大概是阴天。
当天日志第三行:凌晨三点零一分事故告警,三点十四分恢复,历时十三分钟。根因:三年八个月前的归档脚本从未启用,settle_transaction_log 两亿行全表扫描。已加索引,已禁全表扫描。连接池一百五十→两百。
早上七点,一碗粥,一个凉鸡蛋。七点零六分汇报版发出,正文一行:九月十七日凌晨三点零一分,结算系统事故复盘——根因、应急措施、修复方案,详见附件。
七点四十,纪衡的回复:「收到。方案可以。分区方案周一前提交。」
他回了一个字:好。
分区方案他又整理了一遍,加了时间线、风险评估、回退预案,两千字。保存,没有发。纪衡说周一前,今天周四。
记事本上「九月十七日 值班」后面第二个勾,后面一行:分区方案已写完,下周一发。
七点五十到八点五十,归档脚本改完。分区表创建逻辑、归档目标表创建逻辑、定时任务注册、异常处理、日志输出。老梁的一百一十行变成三百行,单元测试十四个全过。commit message 写的是:feat: 结算交易日志归档脚本重构——按月分区,自动归档,定时执行。
推上去,记事本上加一行:老梁的脚本,三年八个月,第一次被调用。不是定时任务调的,是事故调的。
早上九点,值班手机震了一下:签到已完成,剩余时间归零,值班结束。二十四小时里一条告警,处理时长十三分钟。
窗外星期四的早上,灰天,停车场的车开始多起来,有人从地铁站快步过来,低头看手机,没有人抬头看天。
结算成功率百分之九十九点九九,连接池使用率百分之四十五,六十七个连接。库端总数三百九十八。
易文的消息。
「分区方案写完了发我看看。」
「好。」
十分钟后她的回复来了。
「方案可以。但有一个问题。」
「什么。」
「你那个归档脚本,跑在什么机器上。」
陈稳愣了一下。他写的时候没有考虑执行环境。老梁的脚本也没有,它只是躺在仓库里。
「你写分区方案,写了执行环境吗。」
「没有。」
「补上。谁跑,几点跑,跑挂了谁收告警。」
方案里加了一行:归档脚本执行环境——二号楼数据库服务器,定时任务每天凌晨两点执行,失败告警发结算组值班群。
改完重发。
「可以了。以后每份方案都带这一行。」
「好。」
他写的代码,老梁的代码,都是写给自己看的。三年八个月,没有人知道那个脚本怎么跑,直到今天凌晨两点四十八分有人替它跑了一次。
记事本上一行:写方案的时候,要写别人能执行的。
电梯门上的倒影里眼睛有血丝。一楼,停车场,何晚的消息。
「值班结束了?」
「嗯。」
「回去睡一觉。」
「好。」
「你上次说的胃药,还剩几片。」
「四片。」
地铁上闭着眼睛没有睡着。老梁那一百一十行,注释比代码长。他为了加分区表创建逻辑,把老梁的代码抄了一遍,再把每一行都改了一遍。二零二三年一月写这些的人,那时候还不知道自己三年后会离职。
小区,客厅。对面那栋楼的窗户一格一格亮着,他看了十一个月的那扇窗还是黑的。
记事本上是昨天三行日志、今天两千字的分区方案、凌晨的两版复盘。技术版一千二百字,在私有文档里。汇报版四百字,在纪衡的收件箱里。
卧室。老梁写的那行注释还在眼睛里:# 按月归档,保留最近十二个月。 三个月前电梯里那个纸箱上写着「梁栋」,笔画末尾拖了一下,收不住。
过了一会儿,他睡着了。
← 第十六章 备战 · 目录 · 第十八章 这个人是谁 →