可靠性与限流
消息进出的两个方向各有一道守卫:入站挡重复与刷屏,出站压发送速率并兜住失败重试。它们解决的不是业务逻辑问题,而是生产环境里最容易先把机器人拖垮的两类问题。
| 方向 | 模块 | 默认 |
|---|---|---|
| 入站 | framework/messaging/inbound_guard.py | 去重开、限流关 |
| 出站 | framework/messaging/outbound_guard.py | 节流开(阈值宽松)、重试开(1 次) |
为什么放在内核层
成熟 IM 框架(OneBot 生态、Koishi、各类生产级 bot)的共识是:机器人第一次事故通常不是命令写错了,而是
- 断线重连后同一批消息被重推,机器人对同一条消息回复了两遍,甚至重复扣费、重复建单;
- 某个群刷屏或被脚本打,事件瞬间堆满内存缓冲,进程 OOM 或被平台判为滥用。
这两件事都属于「基础设施先建」的范畴——等出事再补,往往要停机改架构。所以守卫放在 framework/messaging/inbound_guard.py,由内核在 dispatch_event 中调用,插件无需感知。
位置:归一化之后、入队之前
适配器 → dispatch_event()
├─ 1. 事件契约补齐(normalize_event)
├─ 2. 入站守卫(去重 + 限流) ← 在这里
├─ 3. event.before_dispatch 扩展点
└─ 4. 进入分层缓冲(L1 → L2 → L3)→ worker放在第 2 步而不是 worker 里,有两个原因:
- 被拦掉的事件不占用缓冲槽,限流才真的能保护内存;
- 去重依据是
message_id,归一化之后才有稳定值。
守卫自身抛异常时放行事件——宁可漏拦,也不能让消息凭空消失。
配置
reliability:
dedup_window: 300 # 去重窗口(秒),0=禁用
rate_limit:
enabled: false # 默认关闭
window: 60
global_per_min: 0 # 0=不限
group_per_min: 0
user_per_min: 0
action: skip # skip | notify
notify_text: "消息过于频繁,请稍后再试。"默认值是有意为之:
- 去重默认开(300 秒)。它只拦「同一接入端 + 同一
message_id」的重复,正常使用几乎不可能误伤,属于纯收益。 - 限流默认关。阈值带业务属性——大群和小群、私聊和群聊的合理上限差一个数量级,框架没有资格替部署方拍板。
去重语义
- 只对
post_type == 'message'生效。notice/request/meta_event本身幂等,不去重(否则会漏掉真实的重复通知)。 - 键是
(bot_name, message_id)。多接入端同时在线时,不同 bot 的message_id可能撞号,必须区分来源。 - 内存有硬上限(2 万条)。超限时先清过期,仍超限再淘汰最早插入的条目,不会无限增长。
限流语义
三级滑动窗口,从小到大依次判定:global → group → user;命中任一级即丢弃。
global_per_min:整机上限,防全局打爆;group_per_min:单群上限,跨用户累计(刷屏通常是多人一起);user_per_min:单用户上限,跨群累计。
action:
| 值 | 行为 |
|---|---|
skip | 静默丢弃,不回复 |
notify | 丢弃,并在同一窗口内至多回复一条提示(避免提示本身变成刷屏源) |
提示走 reply_text(),由当前接入端翻译成本平台的消息格式。
观测
- 去重丢弃打
debug:重放属于正常现象,不该刷生产日志; - 限流丢弃打
warning,含bot与命中层级(rate_limit:user等),便于排查「消息为什么没回」。
需要长期观察时,配合 可观测性与运维接口 的 /metrics 做日志聚合。
出站闸门:发送节流与失败重试
入站守卫管"进来的会不会重复处理",出站闸门管对称的另一半:"发出去的会不会太快、失败了会不会悄悄丢"。
挂在哪一层
不挂在插件 API 上,而挂在动作调用的统一入口——这样插件无论走 ctx.send_msg、ctx.actions、ctx.onebot.send_group_msg 还是 ctx.api('send_msg'),都会被同一道闸门管住:
插件调用 → ctx.actions / ctx.onebot / ctx.api
↓
ProtocolAdapter.acall / call ← 遵守契约的接入端(qq_official / telegram / discord / http_inject)
onebot_adapter.ApiCaller.acall/call ← OneBot 自建调用器(不走基类,单独接一处)
↓
出站闸门(节流 → 重试)→ 真正发送只有发送类动作被接管(send_msg / send_private_msg / send_group_msg / 转发消息 / send_like / upload_group_file 等)。get_* 查询动作原样直通,既不受节流也不重试——查询被拖慢会连带拖慢整个事件处理。
IPC 双进程架构下,宿主侧转发不重复节流:真实发送发生在核心进程,闸门只在那里计一次数。
配置
outbound:
rate_limit:
enabled: true # 默认开启
window: 60 # 滑动窗口(秒)
global_per_min: 180 # 全局每分钟上限,0=不限
group_per_min: 30 # 单群每分钟上限,0=不限
user_per_min: 20 # 单用户私聊每分钟上限,0=不限
max_wait: 5.0 # 单次最多等多久,超过即放弃并返回失败
retry:
enabled: true
attempts: 1 # 额外重试次数(0=不重试)
backoff: 0.4 # 首次重试前等待(秒),之后翻倍默认阈值有意取得宽松:明显高于正常业务、低于平台惩罚线。正常应答、定时播报不会碰到;只有真正的批量刷屏才会被压下来。该段支持热应用——改完保存即生效,不用重启。
节流是等待,不是丢弃
这是与入站最关键的区别。入站丢弃一条重放消息没有副作用;出站丢弃一条消息则意味着插件以为发出去了,实际用户没收到,这种"看起来成功"比失败更难排查。
所以超限时优先 sleep 等到有名额为止;只有预计等待超过 max_wait 才放弃,并返回一个与协议失败同形的结构:
{'status': 'failed', 'retcode': -4, 'msg': '出站限流:...', 'throttled': True}插件照常判断 status/retcode 即可,不需要改代码。
只重试"确定没发出去"的失败
| 返回码 | 含义 | 是否重试 | 为什么 |
|---|---|---|---|
-1 | 未连接 | 否 | 等一会儿也不会自己好,重试只是拖时间 |
-2 | 请求超时 | 否 | 超时不代表没送达,重试会产生重复消息 |
-3 | 本地通道异常 | 是 | 发送通道断了,重试有意义 |
重复消息比发送失败更难排查——用户看到两条一样的回复,运维却查不到任何错误日志。所以宁可漏一次重试,也不赌超时的那一条。
重试带指数退避与抖动(backoff × 2^n 再叠 0~25% 随机),避免多实例同时撞同一服务端。
平台侧的限流也要按它说的等
出站闸门管的是「我们自己发得太快」;平台侧另有自己的限流,两者不是一回事。
平台明确给了等待时间时,必须按它的值等。自己拍一个更短的间隔去重试,会被继续限, 而且有些平台(Discord、KOOK)的限流窗口是累积的,越试越久。各接入端的处理:
| 平台 | 信号 | 处理 |
|---|---|---|
| Telegram | HTTP 429 + parameters.retry_after | 读 retry_after(缺失时从 description 解析),轮询侧按该值休眠 |
| Discord | HTTP 429 + body retry_after(秒,浮点) | 读 body,缺则读 Retry-After 头,向上取整 |
| KOOK | HTTP 429 + Retry-After 头 | 读头 |
| Matrix | HTTP 429 + errcode=M_LIMIT_EXCEEDED + retry_after_ms | 读毫秒值并换算 |
它们统一返回 retcode=-4 + throttled: true + retry_after(秒), 与出站闸门的「被限流」语义一致,插件不需要按平台分支。
另一个同类问题:凭证错误不该重试。Discord 的 4004/4013/4014、KOOK 的 40101/40103、Matrix 的 M_UNKNOWN_TOKEN、Telegram 的 401,重连一万次也不会好, 还会把配额烧光(Discord 限 1000 次 IDENTIFY / 24 小时,超了直接重置 token)。 这些接入端一律停止重连并打印可执行的修法。
观测
- 节流拒绝打
warning(含动作名、预计等待、上限),重试打warning(含第几次、原因); - 判定异常一律放行并打
debug——闸门自己出问题不能让消息发不出去; /api/runtime/stats的outbound字段与/metrics的zcbot_outbound_*指标同源:
| 指标 | 含义 |
|---|---|
zcbot_outbound_sent_total | 发送成功累计数 |
zcbot_outbound_failed_total | 发送失败累计数(含重试耗尽) |
zcbot_outbound_throttled_total | 因限流被拒累计数 |
zcbot_outbound_retried_total | 重试累计次数 |
zcbot_outbound_waited_seconds_total | 因限流累计等待秒数 |
throttled 持续增长说明阈值偏紧或插件在刷屏;failed 增长而 sent 不增长说明接入端出问题了。
断线重连:指数退避 + 抖动
入站守卫管的是「事件进来之后」,出站闸门管的是「消息发出去时」,中间还剩一段: 连接本身断了,多久重连。
固定间隔重连(原来 ws_client / discord 都是 5 秒)有两个实际后果:
- 对端下线、网络断了,前端仍按固定节奏猛敲,日志被刷满,对端恢复瞬间又要承受一波密集重连;
- 多实例按同一间隔重试,永远同步撞上去,形成周期性尖峰。
所以三个 WebSocket 接入端(ws_client / qq_official / discord)和 Telegram 长轮询 统一改用 framework/messaging/backoff.py 的 Backoff:
| 参数 | 默认 | 说明 |
|---|---|---|
| 起始间隔 | 接入端原有 reconnect_interval(默认 5s) | 第一次失败后等这么久 |
| 上限 | 60s | 逐次翻倍到上限后保持 |
| 抖动 | 25% | 只向上加,不缩短等待;用于错开多实例的重试时刻 |
| 稳定判定 | 连接存活 30s | 达到才清零退避 |
退避序列(无抖动):5 → 10 → 20 → 40 → 60 → 60 …
「连上就清零」是错的
一个容易写错的细节:只在连接存活足够久之后才重置退避,而不是握手成功就重置。 如果连上即清零,那「连上就被踢」的故障(token 失效、被限流、协议不匹配)会退化成 高频重连循环,比固定间隔更糟——日志里全是"已连接",然后立刻断开。
ws_client 可通过 reconnect_max_interval / reconnect_jitter 调整;其余接入端 用默认上限 60s(它们的 reconnect_interval 仍是起始间隔,语义从"固定间隔"变为"初始退避")。
断线期间积压的消息
ws_client 在未连接时会把发送请求放进有界队列(max_queue,默认 256),重连后冲刷。 两个相关行为:
- 调用方仍然拿到
status=failed,但返回体带queued: true—— 明确告诉插件「没发出去, 但已排队」,避免它以为成功而重复补发,也避免它以为彻底丢了; - 冲刷时按 50ms 间隔逐条发送,不会把 256 条一口气推给对端;中途再断则剩余消息回队首。
已知边界
- 去重是内存态:进程重启后窗口清空,重启瞬间的重放拦不住(OneBot 侧一般不会跨进程重推同一批)。
- 只在单进程内生效:双核心架构下 core 与 host 各自持有一份守卫,
message_id在三方之间有碰撞风险时需按bot_name区分,框架已按此取键。 - 出站闸门不含发送队列:节流是"调用线程原地等待",不是异步排队。一个 handler 里连发 100 条会占住自己的执行时间(最长
max_wait× 条数)。要真正削峰应由插件侧做批量合并或走定时任务。 - 节流按单进程计数:多实例部署时每个进程各自一份配额,平台侧看到的是各进程之和。
测试
tests/test_inbound_guard.py(13 组)覆盖:重放拦截、窗口过期、notice 不去重、多接入端区分、三级限流、notify 每窗口只提示一次、容量淘汰、sweep() 释放、异常字段健壮性,以及走真实 dispatch_event 的接线验证(确认守卫确实在入队前执行)。
tests/test_outbound_guard.py(14 组)覆盖:查询动作直通、全局/群/用户三级节流与作用域隔离、窗口过期、超限等待而非丢弃、-3 重试成功、-2/-1 不重试、异常耗尽后原样抛出、关闭即直通、键容量淘汰、闸门自身故障放行、异步路径,以及走真实 ProtocolAdapter.acall 的接线验证(确认闸门在适配器层生效,不是只接在 onebot 调用器上)。
tests/test_backoff.py(10 组)覆盖:退避序列与封顶、抖动只向上加、jitter=0 时确定可预期、存活超阈值才清零、连上即被踢不重置、上限小于起始值时不被压到更小、reset()、describe(),以及三个接入端确实持有统一退避实例与源码里不再有固定间隔重连的守卫断言(防止改回去没人发现)。