栈间手记

开发笔记与排查记录

阿凯把导入结果的字段从 errMsg 改成了 errorMessage,我说命名不统一,让他跟别的接口一样用 message。他改了。今天前端问为什么失败原因没了。旧页面读的是 errMsg,新页面有一处读 errorMessage,没有一处读 message。我让改名的时候没搜调用方。

字段又改回 errorMessage,因为新页面已经发出去了。errMsg 在响应里再留一周,值相同,旧页面不会空。下周的清理要前端确认没有引用再删,我不想再单方面改。

评审时我老看命名。上次还写过「先看失败路径」。这次失败路径其实没问题,导入失败会返回原因,是我把原因所在的字段名改丢了。命名可以提,不该挡合并,更不该在不看调用的情况下让人改公开响应。

就记这一笔。没有别的结论。

今早导入的发票一直停在「排队」。order-api 的健康检查是绿的,页面也能打开。队列长度从 7:12 开始涨,没人收到报警,因为报警看的是接口错误率,错误率是 0。请求只负责把任务丢进 Redis,丢成功就算成功。

工人是同一个进程里的一个循环,不是单独的部署。循环里有个没抓住的异常:处理到一张附件名字为空的发票时,抛了 Cannot read properties of undefined。异常把循环打停了,HTTP 服务还在。进程没退出,systemd 也不会拉起。从停到我重启,大约 43 分钟,积了 200 多条任务。

重启之后工人把积压消费完了。那张附件为空的发票我改成跳过并记 warn,不让它再把循环打死。这只是这一次的数据。下次换一种异常,还是会停。

健康检查不能只打 /health。我加了一个其实很笨的判断:工人每处理完一条就写当前时间到 Redis,键过期 30 秒;健康检查读这个时间,超过 30 秒没有更新就返回 500。另外队列长度超过 100 发报警。这两个今天都上了。

进程内跑工人本来就容易变成这样。拆成单独进程要改部署,这周来不及。先让「人还在、活没干」能被看见。

一批备份脚本是早期用交互方式一点点拼出来的。在终端里一步步跑没问题,放进定时任务之后就开始出现两种事故:失败了但任务显示成功,以及两次任务叠在一起,把同一份文件写坏。

失败就应该是非零退出

脚本开头加上:

1
set -euo pipefail

set -e 让命令失败时脚本停下来。set -u 让使用未定义变量时直接失败,而不是把空字符串拼进路径。pipefail 让管道中间的失败也能传递出来,否则 cmd | tee log 只会看 tee 的退出码。

有一个脚本把备份拷到远端,拷贝失败后仍然去删除本地七天前的文件,因为删除命令成功了,整个脚本的退出码就是 0。定时任务因此显示绿色。把删除放在拷贝成功之后,并在失败时直接退出,任务状态才和实际结果一致。

同一时间只跑一份

备份一旦超过定时周期,下一轮会和上一轮同时写同一个目录。我用文件锁把并发收成排队:

1
2
exec 9>/var/lock/backup.lock
flock -n 9 || exit 0

拿不到锁就退出,并记一条日志说明跳过的原因。这里选择退出而不是等待,是因为重叠通常说明上一轮已经超时,继续排只会越积越长。这种情况应该告警,而不是默默排队。

重试要有上限和间隔

对远端拷贝加重试是合理的,网络抖动不该让整天的备份失败。重试三次,间隔 15 秒、30 秒、60 秒。不要在循环里无限制重试,也不要在失败后立刻重试把对端打满。

重试只包在拷贝这一步。不要把「删除旧文件」也放进重试循环,否则一次逻辑错误会被重复执行。

再跑一次也要安全

脚本可能在拷到一半时被中断。再次执行时,目标位置如果已经有半份文件,应该换一个临时名字写入,完成后再改名。改名是同一文件系统上的原子操作,读的一方不会看到半份内容。

我用日期和进程号组成临时文件名,避免两次运行撞在一起。完成标记单独写一个小文件,恢复时先看标记,而不是靠文件大小猜测是否写完。

这些改动加起来不到四十行,但定时任务的结果从此可以相信:绿色就是写完了,红色就是没写完,而不会是两种状态混在同一次运行里。

账单那次改完,我补了一个测试,断言某条记录的「业务日期」是 8 月 1 日。本地 npm test 过了。合并请求的 CI 红了,断言收到的是 7 月 31 日。

测试里写的是:

1
const at = new Date('2026-08-01 00:05:00')

这不是标准格式。中间是空格,没有时区。Node 在我电脑上按本地时区理解,CI 的容器是 UTC,两种解释差 8 小时,再一切日期就不在同一天。本地没失败,是因为我和用例想的时区碰巧一样。

改成带偏移的字符串:2026-08-01T00:05:00+08:00。需要「这一天」的地方,用明确的时区算,不把 Date 再 toISOString() 之后切前 10 位。那样切出来的是 UTC 的日期。

我把仓库里其他 new Date('YYYY-MM-DD HH:mm:ss') 搜了一遍,还有 4 处,都在测试里,今天一起改了。生产代码里没有这种写法。这次算是运气,红在 CI 上,没有红在客户的报表里。

查重复扣款时,我在创建订单的地方临时打了一整段请求体,想看前端到底传了什么。本地看完,提交的时候把这行一起带上了。昨天发布之后,生产日志里就有客户邮箱和手机号。

不是故意留的。diff 很长,这行夹在两段校验中间,我评审自己的代码时滑过去了。阿凯也没看出来。上线后我去对一笔投诉,搜索订单号,才在日志里看见邮箱。

已经从主干删掉,热修了一版。日志平台里这段时间的原始日志还在,保留周期是 14 天,不能按字段单独删。我在工单里写了范围:9 月 2 日 16:12 到 9 月 3 日 11:02,order-api 的 create order payload 这一条。能看到这个索引的人不多,但该说还是要说。

以后临时日志我放到本地的未跟踪文件里,或者用环境变量包住,生产配置里那个变量不存在。不要再把 console.log(req.body) 写在业务分支旁边。上次写日志那篇的时候我还在说不要记邮箱,结果自己记下了。

仓库的持续集成从八分钟慢慢涨到二十多分钟,没人记得是从哪一次开始的。我没有先去换更大的构建机器,而是把每一步的耗时打出来,看时间到底堆在哪里。

时间不在编译,在反复安装

日志里,安装依赖占了将近一半。缓存看起来是开着的,命中率却很低。缓存键用了锁文件的哈希,这本身没问题。问题是安装脚本在装完之后又执行了一次会改写锁文件的命令,于是下一次构建的键和上一次对不上,缓存等于没开。

把会改写锁文件的步骤挪到本地整理依赖时执行,构建里只做按锁文件安装。缓存命中之后,这一步从九分钟降到一分多钟。

镜像分层把变化频繁的文件放前面了

另一个服务用镜像构建。Dockerfile 里先复制整个仓库,再安装依赖。只要改一行业务代码,依赖层就会失效。正确的顺序是先复制清单和锁文件,安装依赖,最后再复制源码。

1
2
3
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

源码变化时,只有最后一层需要重建。依赖层可以留在缓存里。这个调整不改变构建结果,只改变失效范围。

测试不是都要在同一步跑

有一组端到端测试依赖完整环境,每次都要起数据库。它们只占测试数量的一小部分,却占了大部分时间。我把它们和单元测试拆开。合并请求先跑单元测试和静态检查,端到端测试放到主干和夜间构建。

拆开的前提是单元测试确实覆盖了主要分支。如果单元测试只是空转,拆开只会让问题更晚被发现。我补了几个原来只在端到端里间接覆盖到的分支,再做的拆分。

变慢要有记录

我在构建脚本末尾打印三个数:安装耗时、测试耗时、镜像构建耗时。超过阈值时在日志里标出来。没有这三个数的时候,二十分钟只是一种感觉,没法判断该看哪一层。

机器规格可以以后再加。先确认缓存真的命中,分层没有被频繁失效,这通常比加一倍 CPU 更便宜。

财务说 8 月 1 日零点前后的几张账单被算进了 7 月。不是全部,就 8 张。金额对得上,日期不对。

库存的是 timestamptz。写成库的时候是对的,例如 2026-07-31 16:05:00+00,换成上海时间是 8 月 1 日 00:05。出报表的 SQL 用了 created_at::date。这个转换按会话时区来。报表任务连库时没设 TimeZone,用的是服务器默认 UTC,于是日期变成 7 月 31 日。

白天的账单看不出来,因为上海时间的白天落到 UTC 还是同一天。只有北京时间 0 点到 8 点之间创建的,才会被算到前一天。所以不是「时区全错了」,是每天有 8 个小时的窗口会错。8 张这个数量,跟那天凌晨的开票量也吻合。

报表连接现在会先执行 SET TIME ZONE 'Asia/Shanghai'。我更想把日期截断写进 SQL,不依赖会话:

1
(created_at AT TIME ZONE 'Asia/Shanghai')::date

这次先改了连接参数,SQL 留到下次碰这张报表时一起换。怕一次改两处,对不上的时候不知道是哪边的问题。

历史账单没有批量改。财务只要了那 8 张的更正说明,她们自己在表格里调整。我把查询条件和结果附在工单里了。

服务规模不大的时候,日志就是一个文件,出了问题就上去 grep。这个办法在一天几十兆的时候还能用。等一天超过几个 GB,或者同一种错误一分钟出现上百次,人眼翻日志就只剩下碰运气。

先让一行日志能被机器读

我把应用日志改成每行一个 JSON。固定字段包括时间、级别、服务名、环境、请求编号、消息。业务字段按需加,不要把一整段对象直接塞进去。

请求编号在网关生成,通过请求头传给应用,再写进每一条相关日志。这样从一条 502 可以找回这一次请求在应用里的全部记录,而不用拿时间戳去对。

1
2
3
4
5
6
7
8
{
"time": "2026-07-30T12:04:11Z",
"level": "error",
"msg": "query failed",
"request_id": "8f3c1a",
"duration_ms": 42,
"error": "connection reset"
}

文本日志不是不能用,而是字段一旦靠空格分隔,消息里多一个空格,后面的切割就会错位。JSON 多占一点体积,换来的是后面可以按字段统计。

级别要有含义

info 记录请求完成和状态变化,一条请求一条,不记录循环里的每一步。warn 表示这次处理退化了,但请求还是完成了,比如缓存未命中后回源。error 表示这次请求失败,需要人看。

最常见的问题是把预期内的失败打成 error。用户输错密码是业务结果,不是系统故障。打成 error 之后,告警会被淹没,真正的数据库错误反而不显眼。

告警看比率,也看新增

错误日志可以按消息模板聚合,把变量留在字段里,消息本身保持稳定。告警设在错误率,而不是错误条数。流量涨一倍时,条数涨一倍不一定表示故障。

另外留一个很简单的检查:过去一小时出现、过去七天没出现过的错误模板。新错误往往比总错误率更早说明这次发布带进了问题。

不要记不该记的东西

密码、会话令牌、身份证号、完整的请求体,默认不进日志。调试某个字段时,只打印长度或者是否为空。临时把级别调到 debug 可以,但不要在生产长期打开,体积和敏感信息都会上来。

日志文件按天切分,保留十四天。超过这个时间还需要追查的,应该已经在指标或链路里,而不是在某一台机器的旧文件里。

报警是内存使用率,不是错误率。Redis 从 6 月底的大约 1.1GB 慢慢到了今天的 3.6GB,中间没有发过版本。我一开始在猜会话,因为键前缀里 sess: 看起来最多。MEMORY USAGE 抽了几条,会话单条不大,数量也还在过期。

真正大的是 preview:。发票预览会把渲染后的 PDF 路径和一份 HTML 放进缓存,键是 preview:{invoiceId}:{templateVersion}。模板一改,version 变了,旧键却还在。代码里 SET 的时候没给 TTL。我翻提交,这段是五月为了「预览别每次都渲染」加上的,评论里写了 TODO:上线前补过期。TODO 还在。

当时发票少,看不出来。六月开了批量导入,预览接口被页面自动调用,一份发票能留下三四份旧模板的缓存。没有淘汰,就一直堆着。

我给新写入加了 6 小时过期,并写了个一次性脚本,把 preview:* 里 TTL = -1 的删掉。脚本先 SCAN,不要 KEYS。删之前在从节点上看了数量,大约 18 万条。生产上分了十几批,每批间隔 2 秒。内存回到 1.4GB 附近。

预览缓存值不值得保留,我现在有点犹豫。渲染一次大概 200 毫秒,用户又不是连续打开同一份。6 小时可能还是长。先这样,过两周看命中率,命中很低就把时间再缩短,或者干脆去掉。

评审请求一多,就容易从头到尾跟着 diff 走,看完风格问题,真正会在上线后出事的地方反而一笔带过。我后来把顺序固定下来,先看三件事,其余的留到第二遍。

失败时会怎样

主路径通常写得很完整,失败路径往往是补上去的。我会先找外部调用:数据库、缓存、下游接口、对象存储。每一处看三样东西:超时是多少、失败会不会重试、重试是否可能把一次请求放大成多次。

有一次评审里,下游超时设了 30 秒,网关对这个接口的超时是 8 秒。调用方早已经放弃,服务还在等。用户看到的是失败,后台留下的是一堆还在跑的请求。这种数字对不上,比命名风格重要得多。

也看事务边界。把外部 HTTP 调用放进数据库事务里,事务时间会被网络拖长,锁也会被拖长。能挪到事务外的,就不要放在里面。

数据变更能不能收回来

涉及表结构或数据回填的改动,我会单独看迁移脚本。新增列最好允许为空,或者带默认值,这样旧版本的程序在发布过程中仍然能写。删除列分两步:这次发布停止读写,下一次再删。同一次发布里又改代码又删列,回滚时旧代码会去读已经不存在的列。

回填脚本要能重复执行,也要能中断后继续。按主键分批,每批提交,记录进度。不要一条 UPDATE 打满整张大表。

出了问题能不能看见

新接口至少要有请求量和错误数。关键分支打一条带请求编号的日志,方便从网关追到应用。不要在日志里打印令牌、密码、完整的证件号。调试时临时加上的打印,合并前删掉。

如果这个改动有功能开关,开关的默认值应该是关闭。先在小流量上打开,确认指标,再扩大。评审里我会问一句:开关打开之后,怎样判断它是正常的。答不上来,就先补指标再合并。

第二遍才看别的

命名、注释、测试是否覆盖到边界,放在这三件事之后。它们值得改,但不该占据第一遍的注意力。评审意见我也尽量分成「合并前需要处理」和「可以随后再改」,避免十条评论里分不清哪一条会挡住发布。

0%