Skip to content

修复 QQ 网关重连风暴导致「接口调用超过频率限制」 - #2

Open
AnnaofArendelle wants to merge 1 commit into
HuHoBot:masterfrom
AnnaofArendelle:fix/qq-gateway-reconnect-storm
Open

修复 QQ 网关重连风暴导致「接口调用超过频率限制」#2
AnnaofArendelle wants to merge 1 commit into
HuHoBot:masterfrom
AnnaofArendelle:fix/qq-gateway-reconnect-storm

Conversation

@AnnaofArendelle

Copy link
Copy Markdown

问题

断线之后 penguin-connect 会以每秒 20 次以上的频率打 GET /gateway,被腾讯限流成
{"message":"接口调用超过频率限制","code":100017,"err_code":40023001},并且在重启服务器之前
QQ 桥接再也无法恢复

一台 26.2 生产服的日志(8-16 起,v1.1.1 ~ v1.1.5 都有):

指标 数值
累计失败请求 8,246,713(400 限流 755 万 + 401 token 失效 70 万)
单个 latest.log 255 MB(logs/ 合计 392 MB)
8-28 单日 socket 重建 11,772 次
8-28 单日 READY 0 次
请求速率 00:00 的 16.3/s 稳定涨到 18:00 的 21.2/s

触发时序(2026-08-24 真实日志)

08:29:41  QQ 网关启动,取得 access_token
08:29:42  QQ 机器人已连接(READY)
08:59:42  服务端要求重连(op7)           ← QQ 每 30 分钟推一次
          → Resume 成功
09:29:44  服务端要求重连(op7)
09:29:45  连接失败:HTTP 401 AccessToken无效或过期   ← 风暴起点
          → 此后 59 分钟一直拿同一个死 token 重试,速率线性上升
10:28:42  本地 token 缓存到期(= 08:29:41 + 7200 - 60)
          → 换到新 token,401 消失,改为 400 接口调用超过频率限制
          → 直到关服再没出现过 READY

另一场(8-22)完全同构:token 于 04:17:59 取得,05:18:03 起 401,06:17:00 整
(= 04:17:59 + 7200 - 60)401 瞬间切成 400。两次都精确对上 TOKEN_REFRESH_LEAD 的算式。

根因

四个缺陷互相放大,缺一条都不会形成风暴。

1. getJson() 收到 401 不作废 token 缓存 —— 无法自愈的直接原因

fetchGatewayUrl()getJson(),但只有 postJson() 处理 401。QQ 提前作废 token 后,
getAccessTokenSync() 按本地时钟认为「还没过期」,于是每次重连都把同一个死 token 发出去,
只能等本地 7200s TTL 自然到期。

2. 重连没有单飞闸门 —— 速率线性增长的原因

connectAsync() 每次新起线程,失败再排一条,watchdog 每 30s 还会无条件再插一条。重试链只增
不减。实测起量斜率 +4.3 次/分钟 每分钟,按每链 30s 退避换算正好是「每 30 秒新增一条链」,
即 watchdog 周期。

3. openSocket() 把自己的主动关闭当成意外掉线

wsConn?.close(1000,"reconnect") 再改写 wsConn,导致 onSocketClose() 里的
conn !== wsConn 守卫失效 → 打一条 WARN 并再排一次重连。线上一天因此空转 11,772 次。

4. 退避被反复重置

doConnect() 一拿到网关地址就 reconnectAttempt = 0,握手始终失败时指数退避被反复重置回 1 秒。

改动

  • getJson / postJson 统一在 401 时调用新增的 invalidateToken()
  • connectAsyncAtomicBoolean 闸门,退避期间持有闸门;watchdog 仅在没有连接尝试
    且超过握手宽限期时才触发
  • openSocket 先摘掉 wsConn 再关闭旧连接
  • 退避计数改 AtomicInteger,只在 READY / RESUMED 时清零
  • 命中「频率限制」后额外静默 60s,避免继续给限流器加压
  • 同类连接失败按分钟折叠日志(trace_id 归一化后比较),避免刷满磁盘
  • op9 不再额外排一次重连(关闭连接本身就会排);4900~4913 关闭码丢弃 session 重新 Identify

配套(为可测试性,行为不变):QQClient 增加可选的 tokenUrl / apiBase 构造参数,默认仍是
官方地址;HttpsURLConnection 换成其父类 HttpURLConnection(原本没有用到任何 HTTPS 专有 API)。

测试

新增 QQClientReconnectTest(本地 127.0.0.1 HTTP 桩,不访问腾讯任何接口、不需要真实
AppID/Secret
)。./gradlew build 在 mc26.2 分支上实跑:

QQClientReconnectTest > gateway 401 invalidates cached token                     PASSED   1.173s
QQClientReconnectTest > rate limited gateway enters cooldown                      PASSED  12.067s
QQClientReconnectTest > concurrent reconnect requests collapse into one attempt   PASSED   1.526s
QQClientReconnectTest > recovers after transient gateway 401                      PASSED   4.520s
QQClientReconnectTest > valid token is reused across reconnects                   PASSED   1.219s

tests=5 failures=0 errors=0 skipped=0     BUILD SUCCESSFUL

另外把改动前的 QQClient.kt(只加上同样的 tokenUrl/apiBase 注入,保留全部原始行为)拿同一套
用例跑了一遍作为对照:

用例 修复前 修复后
/gateway 返 401 后重新申请 token ❌ token 请求数恒为 1 ✅ 换到新 token
限流后 12s 冷却窗口内不再打 /gateway ❌ 又打了 3 次 ✅ 保持 1 次
并发敲 20 次重连入口 21/gateway 1
伪造的 reason=reconnect 掉线日志 ❌ 出现 ✅ 0 次
全程 ERROR 日志行数 16 5
合计 6 通过 / 4 失败 10 通过 / 0 失败

倒数第三行的 21 : 1 就是线上速率线性增长的直接来源,后两行对应那 11,772 次空转和 250MB 日志。

用本 PR 编出的 26.2 jar 已在上述那台生产服上实跑验证过。注意本 PR 只覆盖了重连状态机的行为,
真实 QQ 网关的 READY / RESUME / op7 握手没有进入自动化测试。

另外两个分支

为了不刷屏,这里只开一个 PR,另外两件事一并写在这儿,需要的话我再单独提。

mc26.2 —— QQClient.ktmastermc26.2 上原本字节完全相同,本 PR 的提交可以直接
cherry-pick,无冲突:

git cherry-pick <本 PR 的 commit>   # 分支:AnnaofArendelle:fix/qq-gateway-reconnect-storm-mc26.2

1.21.11 —— Release 说明里 -mc1.20.1.jar 标注「适用 1.20.1 ~ 1.21.x」,但 1.21.9 起
Minecraft 重构了命令权限:用 1.21.11 的 Yarn 映射(1.21.11+build.6)核对后,
ServerCommandSource.hasPermissionLevel(int) 已被 PermissionSource 体系取代、
GameVersion.getName() 变成了 record 风格的 name(),两个成员在 1.21.11 中都不存在
—— 也就是说现在的 1.20.1 jar 在 1.21.11 上注册命令时会抛 NoSuchMethodError

我在 AnnaofArendelle:mc1.21.11 上按 mc26.2 分支的思路建了一个 1.21.11 构建目标
(MC 1.21.11 + yarn build.6 / Loom 1.18.0-alpha.16 / Kotlin 2.4.10 / fabric-api 0.141.6,
产物仍是 Java 21 字节码)。源码适配只有两处:hasPermissionLevel(4)
permissions.hasPermission(DefaultPermissions.OWNERS)(OWNERS 对应原来的 op 等级 4,没有像
mc26.2 分支那样把权限检查注释掉)、以及两处 GameVersion.namename()

这个分支只过了编译和上述单元测试,没有在任何真实 1.21.11 服务器上跑过,要不要收、以什么形式
收(新分支 / CI 加一个 job)由你决定。

线上现象:断线之后 penguin-connect 每秒向 api.bot.qq.com/gateway 发起 20 次以上
请求,被腾讯限流成 {"message":"接口调用超过频率限制","code":100017,"err_code":
40023001},单个 latest.log 一天涨到 250MB,且在重启服务器之前 QQ 桥接再也无法恢复
(整天 11772 次 socket 重建、0 次 READY)。

根因是四个互相放大的缺陷:

1. getJson() 收到 401 不作废 token 缓存,只有 postJson() 会。fetchGatewayUrl()
   走的正是 getJson,于是 QQ 提前作废 access_token 之后,每次重连都拿同一个死
   token 去换网关地址,只能等本地 7200s TTL 自然到期才可能自愈。
2. 重连没有单飞闸门:connectAsync() 每次新起一条线程,失败后再排一条,而
   watchdog 每 30s 还会无条件再插一条。重试链只增不减,请求速率随在线时长线性
   增长(实测 +4.3 次/分钟 每分钟,正好等于 watchdog 周期)。
3. openSocket() 先 close 旧连接、后改写 wsConn,导致 onSocketClose() 里的
   `conn !== wsConn` 守卫失效,把自己的主动关闭当成意外掉线,又排一次重连。
4. doConnect() 一拿到网关地址就把 reconnectAttempt 清零,握手始终失败时指数退避
   会被反复重置回 1 秒,形同虚设。

修复:
- getJson/postJson 统一在 401 时调用 invalidateToken(),让重连能自愈
- connectAsync 增加 AtomicBoolean 闸门,退避期间持有闸门;watchdog 仅在没有连接
  尝试、且超过握手宽限期时才触发
- openSocket 先摘掉 wsConn 再关闭旧连接
- 退避计数改 AtomicInteger,只在 READY / RESUMED 时清零
- 命中「频率限制」后额外静默 60s,避免继续给限流器加压
- 同类连接失败按分钟折叠日志,避免把磁盘刷满
- op9 不再额外排一次重连(关闭连接本身就会排);4900~4913 关闭码丢弃 session
  重新 Identify

配套改动:为可测试性给 QQClient 增加可选的 tokenUrl / apiBase 构造参数(默认仍是
官方地址),并把 HttpsURLConnection 换成其父类 HttpURLConnection(原本没有用到任何
HTTPS 专有 API)。新增 QQClientReconnectTest,用本地 HTTP 桩回归上述行为,不访问
腾讯任何接口。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant