饱和降级:慢下来、排队、告警
状态: 已在 v0.1.76 发布(2026-09-27)—— 设计与落地记录见 StandMeet 仓库的 docs/design/event-bus-outbox-webhooks.md。
饱和时慢下来、排队、告警,不丢数据、不崩进程、不拖垮访客请求。每种饱和一条 UT,用注入的故障模拟,断言降级行为。已实现的饱和套件覆盖:连接池耗尽、磁盘满且 relay 退避、端点 429 冷却、Meili 挂掉后排空、积压暴增告警、等待者上限、队列饥饿、任务超时。
饱和状态
stateDiagram-v2
[*] --> normal
normal --> saturated : pool / workers / rate limit / disk / downstream full
state saturated {
[*] --> slow_down
slow_down --> queue : new jobs deferred, never dropped
queue --> alert : backlog or oldest age over threshold
}
saturated --> normal : pressure eases, backlog drains by itself
saturated --> writes_refused : disk full, transaction fails
writes_refused --> normal : space recovered
note right of writes_refused : business write and event fail together, readable error, never a half-write
场景、行为与 UT 断言
| 饱和场景 | 期望行为 | UT 断言 |
|---|---|---|
| 连接池耗尽 | worker 等连接有超时,到点记为 retryable;访客请求的连接余量始终保留 | 把池占满后投任务:任务不崩、转 retryable;预留的请求连接仍可用 |
| worker 全忙 | 新任务排队,不开新 goroutine;各队列互不饥饿 | webhook 队列全堵时 index 队列任务仍按时完成 |
| 下游限额打满(端点 429) | snooze 到 Retry-After,不消耗次数;端点记上 failing_since,新投递等过 5 分钟冷却 |
连续 429 不会把任务耗到 discarded;冷却期内没有额外请求打出 |
| 我们自己的邮件限流命中 | snooze 到窗口结束,不丢弃 | 超过限额的邮件在下个窗口全部发出,数量一致 |
| 磁盘满 / Postgres 拒绝写 | 业务写入和事件一起失败,返回可读错误;relay 退避(2 秒起翻倍,封顶 1 分钟)而不是忙转 | 模拟写失败:没有“改了没事件”;relay 重试间隔递增 |
| Meili 整个挂了 | 索引任务退避重试(Meili 客户端自带重试已关);写入不受影响;恢复后积压自动排空 | 恢复后所有积压任务 completed,没有 discarded(在重试窗口内) |
| 积压暴增(批量导入) | 同一批内同主体的索引任务合并(webhook 不合并);未扇出超 1000 条或超 5 分钟触发 events_backlog |
任务数 ≤ 上限;告警字段被置位 |
| 请求内等待者满了 | 超过上限的请求直接返回回执,不排队 | 第 65 个等待者立刻得到 indexed: false |
| CPU 饱和 / 任务超时 | 任务硬超时取消、转 retryable;不影响其它队列 | 超时任务被取消且可救回 |
每一行背后的机制在兄弟节点里:snooze、冷却与退避见 retry-has-one-owner;队列上限、连接池预算、超时和等待者上限见 concurrency-control;合并与告警阈值见 storage-bounds;业务写入和事件同一事务见 two-sources-of-events。这组 UT 计入 events-test-plan。