栈间手记

开发笔记与排查记录

上次把空闲回收改短、定时任务的连接也还回去之后,5 秒超时没再出现。我以为这件事结束了。上周四晚上又有一批慢,不过只有 1 秒出头,而且只打在 GET /reports/monthly 上。

等待连接的时间这次是正常的,慢在查询本身。月报 SQL 扫了 invoices 全表,条件是月份字符串拼接,索引用不上。连接池改好之后,排队消失了,原来被超时挡住的慢查询就露出来了。之前不是没有这条慢查询,是请求在拿到连接之前就已经被网关掐掉,慢查询日志里看不到。

EXPLAIN 里实际走的是 created_at 上的索引,但月份是按上海时区切的,条件写成了 to_char(created_at, 'YYYY-MM') = $1,函数套在列上,索引失效。改成范围:

1
created_at >= $start AND created_at < $end

$start 和 $end 在应用里用 Asia/Shanghai 算好再传进去。五月的月报从 1.4 秒降到 70 毫秒左右。这是今天中午在从库上跑的,生产还没发,准备跟明天的窗口一起上。

所以连接池那次只解决了「拿不到连接」。拿到连接之后查询照样可以慢。两件事情长得像,日志不分开的话还会再误判一次。

有一个查询接口,平时一百多毫秒能返回,每隔几天会在晚高峰出现一批 5 秒超时。重试一次往往又好了。监控上错误率不高,所以一直被当成「网络抖动」。直到同一个小时里连续出现三次,我才把这条链路从头对了一遍。

先分开看时间花在哪

网关日志里,超时请求的上游耗时接近 5 秒,应用自己的业务日志却几乎没有慢查询。这说明时间不是耗在 SQL 执行上,而是耗在拿到连接之前。应用侧把数据库调用包在一个统一的计时里,连接等待和查询执行被算成了一段,所以慢查询面板是干净的。

我把计时拆成两段:从池里取连接的等待时间,以及拿到连接之后的查询时间。再次出现超时时,等待时间顶满,查询时间仍然是几十毫秒。

池的大小和空闲策略对不上

这个服务的连接池上限是 10。晚高峰并发大概在三十上下,大部分请求只做一次很短的查询,池子理论上够用。问题出在空闲回收。

数据库侧把空闲连接在 60 秒后断开。应用侧的池仍把这些连接当作可用,下一次借出时才发现对方已经关了,于是重连。重连本身不慢,但失败的借出会占着池的名额重试,高峰时新请求就排在后面,直到网关的 5 秒超时。

池的空闲超时需要短于数据库的断开时间。我把应用侧的空闲回收改成 30 秒,并打开借出前的探活。探活多一次往返,但避免把已经断开的连接交给业务。

还有一个隐藏的占用

修完池之后,等待时间下来了,却发现有一个定时任务会在每小时的第 50 分钟连续做二十多次查询,而且没有把连接还回去。任务的代码用了手动获取连接,异常分支里漏了释放。偶发超时的节奏和这个定时任务对得上。

改成用完即还,并在测试里断言:任务结束后池的借出数回到 0。

这次之后留下的习惯

超时不要只看查询耗时。连接等待、序列化、写响应,至少分开打点。池的上限、空闲时间、对端的断开时间写在同一段配置注释里,避免以后只改一边。

错误率不高不代表可以先放着。能自己恢复的超时,往往是容量或生命周期的问题,流量再高一点就会从偶发变成持续。

早上财务对账,说有一笔金额一样、客户一样、相差 1 秒的订单。订单号不同。我第一反应是发号器时钟回拨,把 order_no 的生成日志拉出来看了,两号都是正常递增的,没有回头。

客户端超时设的是 3 秒。网关到 order-api 那次实际花了 4.1 秒,客户端重试了。两次请求体一样,但没有带幂等键。接口的态度是「来一次就建一单」,所以它做对了,产品期望做错了。

用户侧的按钮也有问题。请求没返回时按钮能再点,本地还做了自动重试。两个重试叠在一起。我只先堵了服务端:请求头 Idempotency-Key 必填,用客户编号加客户端生成的 UUID。同一把钥匙、同样的请求体,返回第一次的订单;请求体不一样就 409,避免有人拿旧钥匙提交另一笔。

库上加了唯一索引 (tenant_id, idempotency_key)。老数据这个字段是空的,索引建成部分索引,只约束非空值,免得迁移时跟历史行打架。

1
2
3
CREATE UNIQUE INDEX CONCURRENTLY orders_idem_uidx
ON orders (tenant_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;

那笔重复的订单没有自动删。财务已经把其中一笔记成收款,另一笔改成作废,作废原因写的是「客户端重试」。按钮防重复阿凯说这周跟前端一起改,我今天没跟进,明天问他。

新同事把仓库拉下来之后,第一天往往不是在写功能,而是在问「这个服务到底怎么跑起来」。文档里写着「先装依赖」,但没写 Node 版本、数据库要不要初始化、环境变量有哪几个是必填的。等人问到第三遍,我就把启动过程收成了一份可以照着做的说明。

版本先钉死

语言运行时和包管理器不要只写「安装 Node」。我现在会在仓库根目录放一份 .node-version,README 开头写明当前使用的主版本。依赖锁文件必须进仓库。有人用 npm install 装出一套,有人用 npm update 装出另一套,后面排出来的问题经常不是代码问题。

容器只负责不好在本机安装的依赖,比如数据库和缓存。应用本身还是在宿主机上跑,改一行代码不用重新构建镜像。

启动命令只留一条主路径

我给仓库补了一个很短的 Makefile,目标只保留三个:

1
2
3
4
5
6
7
8
dev:
npm run dev

test:
npm test

lint:
npm run lint

README 里对应写成三行:准备环境变量、启动依赖、执行 make dev。分支说明放到后面,不要在第一屏就出现「如果你用的是 Colima 请看这里」。第一屏的任务是让人把服务跑起来。

环境变量要有样例,也要有校验

.env.example 里只放键名和没有敏感信息的示例值。真正会让进程起不来的变量,在启动时检查,缺了就退出,并打印缺的是哪一个。不要等到第一个请求进来才抛一个含糊的连接错误。

我踩过的坑是:示例文件里有 REDIS_URL,代码里读的是 CACHE_URL。样例和代码对不上,比没有样例更耽误时间。改名的时候两处一起改。

数据怎么来

空库能启动,不代表能演示。我留了一个 make seed,写入少量固定的测试数据:一个管理员、两个普通用户、几条业务记录。种子数据要可重复执行,跑第二次不应该因为主键冲突失败。

不把生产数据的脱敏副本放进仓库。体积大、更新频繁,也容易把不该出现的字段带进来。

说明本身也要能过期

这份说明我放在 docs/dev.md,并在季度整理依赖时顺手走一遍。走不通就改文档,而不是在群里补一句「哦对,现在还要先执行这个脚本」。口头补充活不过一周。

昨晚 21:40 左右发布,21:51 回滚。中间客户那边有人问「草稿发票怎么已经显示已开具」。发布群里没人先说话,是客服转过来的截图。

开关 invoice.v2 我在配置样例里写成了 true。代码里的默认值也是 true,注释写着「联调时打开」。这条注释是三月留下的。昨天合并的人(我)没看默认值,只看了 diff 里业务计算那几行。

旧逻辑是草稿不进开具列表。新逻辑在开关打开时,会把「已生成号码」当成已开具。测试环境的开关本来就是开的,所以测试是绿的。生产配置里没有这个键,于是落到了代码默认值。

回滚用的是上一版镜像,没有动数据库。那十几分钟里新写入的发票号码还在,状态被新代码改过的有 7 条。今天手工对回草稿的是其中 4 条,另外 3 条财务说号码已经发出去了,保持开具,在备注里写了原因。

配置样例我改成了 false,并在启动时把最终生效的开关打一条 info。不是为了好看。下次再出现「测试是好的,生产走了另一条」,至少日志里能直接看到值,不用再去猜默认值。

评审清单里我想加一句:新增的开关,默认必须是关。加了还没人看。先记在这里。

下午阿凯跟我说,他本地的订单列表少了两条。我看了一眼,是我中午跑种子数据时把 status = draft 的记录清掉了。我们俩连的是同一台测试库,库名还叫 app_dev,谁都觉得那是「自己的」。

这种事这个月第三次。前两次是有人把密码改成了 123456,还有一次是迁移跑到一半,另一边的接口开始 500。当时都在群里说了句「我弄好了」,过两天又忘。

今天把本地默认库改成带系统用户名的,app_dev_zhou 这种。测试脚本里也不再写死主机。共用的那台还留着,只给要联调的时候用,用完把库删掉,不把个人数据堆在里面。

README 里补了一行:默认不要连 10.0.0.18 上的 app_dev。再有人连上去改数据,就算他自己的事。

0%