一个死循环,让所有人都登不上
一句话结论:一个写得很像分页的循环,和一个写得很像测试的测试,凑在一起骗过了所有人。测试验的是"会不会继续拉",没验"拉的是不是下一页"。
起因
用户反馈登不上,提示「请求过于频繁」。
看日志:登录接口连续三次 429,紧接着查询用户信息、查询状态、查询余额、积分接口,全部 429。同一时间,外部模型市场的同步也挂了。
第一件要确认的事是:这个 429 是我们本地的限流,还是上游返回的?
本地限流器有一个独有的日志,搜了一遍,命中 0 次。所以 429 是从上游原样透传回来的——我们只是在转发别人的拒绝。
排查过程
顺着"谁在疯狂请求上游"查下去,很快落到一个后台同步任务上。它的日志长这样:
sync page 74 synced: 0第 74 页,同步了 0 条。
一个正常的分页循环不可能走到 74 页还是 0 条。 说明它一直在拿同一页。
去看代码,问题一眼就清楚了:循环里确实有 page++,但调用的那个接口函数签名里根本没有 offset 或 page 参数——它的地址永远拼成 ?account_id=X&limit=100。
page 只在本地自增,从来没传下去。每一轮拿回同一批 100 条,终止条件是"返回不足 100 条就结束",于是永远不成立。
它只能靠上游把它限流打断为止。这次断在第 74 次。
更气人的是旁边就有一个对的函数:同一个文件里,紧挨着就有一个带 limit, offset 参数的分页版本。调用方挑错了那一个。
为什么今天才爆? 因为这个循环有个前提条件:账户的交易记录必须够满一页(100 条)。这个账户今天刚好破 100。在此之前,第一页返回 99 条,循环正常退出。
那单测为什么没拦住? 这是整件事里我最想记住的一段。单测是有的,测的也是"分页会不会继续拉"——但它的断言只检查了"函数被调用了两次",没有检查两次调用的 offset 是不是不同。
用桩数据喂进去,桩对任何参数都返回同样的一页,于是"会继续拉"成立、测试变绿。真实接口按 offset 返回不同页,行为完全不同。
测试通过的是"代码的形状",不是"代码的语义"。
还有第二个因素,让这件事从小毛病放大成全员故障。
上游限流是每分钟每 IP 一百次,但留了一个豁免:带内部 API Key 的请求不算在内,目的很合理——避免内部轮询和外部匿名用户抢同一个桶。
而前一天,我们刚好把客户端凭证收敛成"只用账户 JWT 单轨",那个 API Key 被删了。于是豁免条件永远不成立,我们自己的后台轮询,和外部匿名用户一样落进了同一个桶。
一个把内部流量收敛到更规范鉴权的改动,副作用是内部流量失去了内部身份。
这个同步任务是「启动即跑 + 每十分钟一次」。也就是说每十分钟抽干一次配额,用户随机撞进那几秒的窗口就登不上。
结论
修了两处,都在当天发了版:
- 两处循环都改用带
offset的那个分页函数,offset = (page-1) * 100真正传下去。另外加了maxPages上限做安全网——不能指望"逻辑正确"来保证不死循环,得有一个不依赖逻辑的硬上限。 - 补了"offset 必须递增"的校验意识。这条后来成了习惯:凡是分页,测试必须断言第二次请求的 offset 和第一次不同。
限流豁免那一层(第二个因素)当时没动。因为它有两条路可走:让豁免认账户 JWT,代价是登录用户的限流也失效;或者加一个内部专用的头,代价是要改两端并部署生产。两条都有真实成本,且第一处修完之后故障已经消失,所以先留着,等有了判断依据再决定。
还没解决的
那个"只验调用次数、不验参数"的桩还在别的测试里。这次只修了分页这一处,同类写法没有全量排查。
判断一个测试有没有鉴别力,我后来只用一句话:把它要防的那个 bug 改回去,它会不会红? 如果改回去还是绿的,那它测的不是那个 bug。
这次这个桩,把 offset 永远传 0 也不会红。所以它从来没测过分页。
本文由 Lyncore 生成,经人工审核后发布。