修复 QQ 网关重连风暴导致「接口调用超过频率限制」 - #2
Open
AnnaofArendelle wants to merge 1 commit into
Open
Conversation
线上现象:断线之后 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 桩回归上述行为,不访问
腾讯任何接口。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
问题
断线之后
penguin-connect会以每秒 20 次以上的频率打GET /gateway,被腾讯限流成{"message":"接口调用超过频率限制","code":100017,"err_code":40023001},并且在重启服务器之前QQ 桥接再也无法恢复。
一台 26.2 生产服的日志(8-16 起,v1.1.1 ~ v1.1.5 都有):
latest.loglogs/合计 392 MB)触发时序(2026-08-24 真实日志)
另一场(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()connectAsync加AtomicBoolean闸门,退避期间持有闸门;watchdog 仅在没有连接尝试且超过握手宽限期时才触发
openSocket先摘掉wsConn再关闭旧连接AtomicInteger,只在 READY / RESUMED 时清零配套(为可测试性,行为不变):
QQClient增加可选的tokenUrl/apiBase构造参数,默认仍是官方地址;
HttpsURLConnection换成其父类HttpURLConnection(原本没有用到任何 HTTPS 专有 API)。测试
新增
QQClientReconnectTest(本地127.0.0.1HTTP 桩,不访问腾讯任何接口、不需要真实AppID/Secret)。
./gradlew build在 mc26.2 分支上实跑:另外把改动前的
QQClient.kt(只加上同样的tokenUrl/apiBase注入,保留全部原始行为)拿同一套用例跑了一遍作为对照:
/gateway返 401 后重新申请 token/gateway/gatewayreason=reconnect掉线日志倒数第三行的 21 : 1 就是线上速率线性增长的直接来源,后两行对应那 11,772 次空转和 250MB 日志。
用本 PR 编出的 26.2 jar 已在上述那台生产服上实跑验证过。注意本 PR 只覆盖了重连状态机的行为,
真实 QQ 网关的 READY / RESUME / op7 握手没有进入自动化测试。
另外两个分支
为了不刷屏,这里只开一个 PR,另外两件事一并写在这儿,需要的话我再单独提。
mc26.2 ——
QQClient.kt在master和mc26.2上原本字节完全相同,本 PR 的提交可以直接cherry-pick,无冲突:
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.name→name()。这个分支只过了编译和上述单元测试,没有在任何真实 1.21.11 服务器上跑过,要不要收、以什么形式
收(新分支 / CI 加一个 job)由你决定。