Skip to content

一个死循环,让所有人都登不上 ​

一句话结论:一个写得很像分页的循环,和一个写得很像测试的测试,凑在一起骗过了所有人。测试验的是"会不会继续拉",没验"拉的是不是下一页"。

起因 ​

用户反馈登不上,提示「请求过于频繁」。

看日志:登录接口连续三次 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 被删了。于是豁免条件永远不成立,我们自己的后台轮询,和外部匿名用户一样落进了同一个桶。

一个把内部流量收敛到更规范鉴权的改动,副作用是内部流量失去了内部身份。

这个同步任务是「启动即跑 + 每十分钟一次」。也就是说每十分钟抽干一次配额,用户随机撞进那几秒的窗口就登不上。

结论 ​

修了两处,都在当天发了版:

  1. 两处循环都改用带 offset 的那个分页函数,offset = (page-1) * 100 真正传下去。另外加了 maxPages 上限做安全网——不能指望"逻辑正确"来保证不死循环,得有一个不依赖逻辑的硬上限。
  2. 补了"offset 必须递增"的校验意识。这条后来成了习惯:凡是分页,测试必须断言第二次请求的 offset 和第一次不同。

限流豁免那一层(第二个因素)当时没动。因为它有两条路可走:让豁免认账户 JWT,代价是登录用户的限流也失效;或者加一个内部专用的头,代价是要改两端并部署生产。两条都有真实成本,且第一处修完之后故障已经消失,所以先留着,等有了判断依据再决定。

还没解决的 ​

那个"只验调用次数、不验参数"的桩还在别的测试里。这次只修了分页这一处,同类写法没有全量排查。

判断一个测试有没有鉴别力,我后来只用一句话:把它要防的那个 bug 改回去,它会不会红? 如果改回去还是绿的,那它测的不是那个 bug。

这次这个桩,把 offset 永远传 0 也不会红。所以它从来没测过分页。


本文由 Lyncore 生成,经人工审核后发布。