hyapp-server/docs/Outbox统一治理说明.md
2026-06-01 15:06:46 +08:00

309 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Outbox 统一治理说明
更新时间2026-06-01
## 背景
生产 MySQL CPU 打满的直接触发点是多个 worker 高频轮询 outbox 表,尤其是 `wallet_outbox` 数据量已经到百万级后,部分查询没有完整匹配索引、带 `OR` 分支、缺少 `app_code` 过滤或由下游服务直扫 owner 表,导致 MySQL 需要持续扫描大量历史 outbox 行。
这次治理目标是:
- owner service 只允许自己扫描自己的 outbox并发布 MQ。
- 下游 projection 不再直接扫描 owner outbox。
- 所有 outbox claim 查询必须带 `app_code + status/event_type + retry/lock + created_at` 匹配索引。
- 所有 `OR` claim 分支拆成独立查询。
- `delivered/done` 历史 outbox 建立归档机制。
## 修改前
### 运行方式
- `wallet-service``wallet_outbox` 发布 `hyapp_wallet_outbox` MQ。
- `room-service``room_outbox` 发布 `hyapp_room_outbox` MQ。
- `activity-service``game-service``user-service` 也各自有 outbox worker。
- `notice-service` 仍保留直扫 `wallet_outbox``room_outbox` 的历史 worker 能力。
- `wallet-service` 内部的礼物墙和 badge projection worker 也在轮询扫描 `wallet_outbox`
- 部分 claim SQL 把 `pending/retryable/locked expired` 写在同一个 `OR` 条件里。
- 部分 outbox 索引缺少 `app_code``next_retry_at_ms``lock_until_ms/locked_until_ms``event_id` 这样的匹配列。
- `game_outbox``user_outbox` 缺少完整的 worker lock/retry 字段。
- 历史 `delivered/done` 数据没有统一归档脚本。
### 主要风险
- outbox 表越大,高频轮询越容易退化成大范围扫描。
- 下游服务直接读 owner outbox会把 owner 表变成跨服务共享查询热点。
- `OR` 查询很容易让优化器放弃最优索引路径。
- delivered 历史数据留在热表中,会持续抬高索引体积和扫描成本。
- projection、通知、MQ 发布混用 outbox 状态,后续排障边界不清晰。
## 修改后
### 运行方式
- owner service 继续负责自己的 outbox claim 和 MQ 发布:
- `wallet-service` 发布 `hyapp_wallet_outbox`
- `room-service` 发布 `hyapp_room_outbox`
- `activity-service` 发布 activity / broadcast 相关 MQ
- `game-service` 发布 game 相关 MQ
- `user-service` 发布 `hyapp_user_outbox`
- `notice-service` 不再启动直扫 `wallet_outbox` / `room_outbox` 的 worker改为消费 MQ。
- `wallet-service` 内部礼物墙和 badge projection 不再轮询扫描 `wallet_outbox`,改为消费 `hyapp_wallet_outbox` MQ。
- claim 查询拆成多段:
- `pending`
- `retryable`
- `delivering/running expired lock`
- claim 查询全部显式带 `app_code`,并匹配对应索引。
- delivered/done 历史 outbox 通过归档脚本搬到 archive 表。
## 修复掉的问题
### 1. 下游直扫 owner outbox
之前:
- `notice-service` 可以直接扫描 `wallet_outbox``room_outbox`
- `wallet-service` 内部 projection worker 轮询扫描 `wallet_outbox` 做礼物墙和 badge 投影。
现在:
- `notice-service` runtime 配置禁止直扫 wallet/room outbox只消费 MQ。
- `wallet-service` 的礼物墙和 badge projection 改为消费 `hyapp_wallet_outbox` MQ。
- 生产验证中,`wallet_projection_events / idx_wallet_outbox_event_created / idx_wallet_outbox_asset_event_created` 相关直扫进程数为 `0`
实现位置:
- `services/notice-service/internal/config/config.go`
- `services/notice-service/internal/app/app.go`
- `services/notice-service/internal/modules/walletnotice/service.go`
- `services/notice-service/internal/modules/walletnotice/mysql_repository.go`
- `services/wallet-service/internal/app/app.go`
- `services/wallet-service/internal/service/wallet/service.go`
- `services/wallet-service/internal/storage/mysql/gift_wall_projection.go`
- `services/wallet-service/internal/storage/mysql/badge_projection_relay.go`
### 2. claim 查询中 `OR` 导致索引不可控
之前:
- 多个 outbox claim SQL 用一个查询覆盖 pending、retryable、锁过期重试。
- SQL 里有 `OR` 分支,优化器在大表上不稳定,容易扫描过多行。
现在:
- claim 查询按状态拆成独立分支。
- 每个分支只走自己的索引条件。
- 必要位置使用 `FORCE INDEX` 固定索引路径。
实现位置:
- `services/wallet-service/internal/storage/mysql/repository.go`
- `services/room-service/internal/storage/mysql/repository.go`
- `services/activity-service/internal/storage/mysql/broadcast_repository.go`
- `services/game-service/internal/storage/mysql/repository.go`
- `services/user-service/internal/storage/mysql/user_outbox.go`
### 3. 缺少 app_code 和匹配索引
之前:
- 部分 claim 查询没有完整 `app_code + status + retry/lock + created_at` 索引。
- 部分索引没有 `event_id` 尾列,排序和稳定分页不够完整。
现在:
- wallet、room、activity、game、user、notice 相关 outbox 都补了 claim/retention 索引。
- 生产库已验证目标索引存在,关键 `EXPLAIN` 都命中指定索引。
关键索引形态:
```sql
app_code, status, next_retry_at_ms, created_at_ms, event_id
app_code, status, lock_until_ms, created_at_ms, event_id
app_code, status, updated_at_ms, event_id
```
部分表字段名不同:
- `im_broadcast_outbox` 使用 `locked_until_ms`
- `notice_delivery_events` 使用 `source_name, app_code, channel, status, next_retry_at_ms/lock_until_ms, updated_at_ms`
### 4. game/user outbox 缺少标准锁字段
之前:
- `game_outbox``user_outbox` 没有完整的 `worker_id``lock_until_ms``retry_count``next_retry_at_ms``last_error` 字段。
- worker 失败和过期接管能力不统一。
现在:
- 两张表都补齐标准 worker claim 字段。
- claim 逻辑统一为 pending/retryable/running expired 三段。
实现位置:
- `services/game-service/internal/storage/mysql/repository.go`
- `services/user-service/internal/storage/mysql/user_outbox.go`
- `services/game-service/internal/storage/mysql/repository.go` 的 schema/migrate 逻辑
- `services/user-service/deploy/mysql/initdb/001_user_service.sql`
### 5. delivered/done 热表历史数据无归档
之前:
- delivered/done 历史 outbox 一直留在业务热表。
- 表越大,索引越大,后续即使查询优化了也会逐步增加维护成本。
现在:
- 新增统一归档脚本:
`scripts/mysql/archive_delivered_outbox.sql`
- 归档目标是同库 archive 表:
- `hyapp_wallet.wallet_outbox_archive`
- `hyapp_room.room_outbox_archive`
- `hyapp_activity.activity_outbox_archive`
- `hyapp_activity.im_broadcast_outbox_archive`
- `hyapp_game.game_outbox_archive`
- `hyapp_game.game_level_event_outbox_archive`
- `hyapp_user.user_outbox_archive`
- `hyapp_notice.notice_delivery_events_archive`
## 归档方式
归档脚本逻辑:
1. 默认归档 30 天以前的数据:
```sql
SET @outbox_archive_cutoff_ms := IFNULL(
@outbox_archive_cutoff_ms,
UNIX_TIMESTAMP(DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 30 DAY)) * 1000
);
```
2. 每张表先创建同结构 archive 表:
```sql
CREATE TABLE IF NOT EXISTS hyapp_wallet.wallet_outbox_archive LIKE hyapp_wallet.wallet_outbox;
```
3.`INSERT IGNORE` 到 archive 表:
```sql
INSERT IGNORE INTO hyapp_wallet.wallet_outbox_archive
SELECT * FROM hyapp_wallet.wallet_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
ORDER BY updated_at_ms ASC
LIMIT 10000;
```
4. 确认 archive 表存在对应记录后,再删除源表:
```sql
DELETE FROM hyapp_wallet.wallet_outbox
WHERE status IN ('delivered', 'done') AND updated_at_ms < @outbox_archive_cutoff_ms
AND EXISTS (
SELECT 1 FROM hyapp_wallet.wallet_outbox_archive a
WHERE a.app_code = wallet_outbox.app_code AND a.event_id = wallet_outbox.event_id
)
ORDER BY updated_at_ms ASC
LIMIT 10000;
```
每张表每次最多处理 `10000` 行,避免一次归档造成长事务和复制压力。
生产执行结果:
- 脚本已执行。
- 本次 30 天前 `delivered/done` 候选行为 `0`
- archive 表已通过脚本创建。
## 本次提交
- `60b7277 fix: govern outbox polling and archiving`
- `bd0ed78 fix: add game outbox retention index`
- `0aef70e fix: consume wallet projections from mq`
生产部署:
- `wallet-service`: `main-0aef70e-outbox-governance`
- `room-service/activity-service/game-service/user-service/notice-service`: `main-bd0ed78-outbox-governance`
## 当前方案的优点
- 明确服务边界owner outbox 只由 owner service 扫描和发布 MQ。
- 降低 MySQL CPU 风险:下游 projection 不再对 owner 大表做高频轮询。
- 查询路径稳定claim SQL 拆分后,索引选择更可控。
- 支持多实例并发:继续使用 lock 字段和 `FOR UPDATE SKIP LOCKED`
- 支持失败重试:通过 `retryable``next_retry_at_ms``lock_until_ms` 做指数退避和过期接管。
- 历史数据可控delivered/done 可以按批归档,热表不会无限增长。
- 保留幂等语义:下游消费通过各自投影表或 delivery 表做去重,不依赖 MQ exactly-once。
## 当前方案的缺点
- MQ 成为关键依赖MQ 故障时,下游 projection 会延迟。
- 归档现在是脚本,不是定时任务;需要接入 cron 或运维调度。
- 仍保留部分离线/测试用直扫方法,虽然 runtime 不启动,但后续要避免误开配置。
- range 查询因为 `next_retry_at_ms <= now``lock_until_ms <= now``EXPLAIN` 里仍可能出现 `Using filesort`;目前扫描行数已被前缀索引压住,但极端积压下仍要关注。
- MQ message 只覆盖被成功发布后的事件;如果历史 projection 已有缺口,需要单独跑一次离线修复或重放。
- archive 表目前与源表同库同实例,能降低热表成本,但不能降低整个 MySQL 实例总存储压力。
## 还需要补足
### 1. 固化归档调度
建议把 `scripts/mysql/archive_delivered_outbox.sql` 接入定时任务:
- 每天或每小时执行一次。
- 每次每表限制 `10000` 行。
- 归档窗口默认 30 天,可通过 `@outbox_archive_cutoff_ms` 覆盖。
- 执行前后记录每张表归档行数。
### 2. 增加 outbox 指标和告警
建议增加以下指标:
- 每张 outbox 各状态数量:`pending/retryable/delivering/running/delivered/dead`
- 最老 pending 时间
- retryable 积压数量
- dead 数量
- MQ 发布失败数
- MQ 消费失败数
- projection duplicate / skipped / failed 数量
### 3. 增加配置保护
现在 notice 已经禁止 runtime 直扫 wallet/room outbox。后续建议继续收紧
- 对所有下游服务保留的 legacy direct scan worker 增加启动保护。
- 生产配置中直接删除或禁用直扫配置。
- CI 增加 SQL lint检查 outbox claim 是否缺 `app_code` 或含 `OR`
### 4. 历史 projection 缺口修复
对于已经 `delivered` 但下游 projection 没处理到的历史事件,建议单独做一次离线 backfill
- 从 owner outbox 按时间窗口导出缺口事件。
- 写入对应 projection/delivery 幂等表。
- 或重新投递 MQ但要控制速率。
### 5. 更彻底的数据生命周期治理
中长期可以考虑:
- outbox 热表只保留未完成和近期完成数据。
- delivered/done 进入 archive 库或对象存储,而不是同库 archive 表。
-`created_at_ms``updated_at_ms` 做分区表,归档时按分区迁移或 drop partition。
- 大流量 outbox 拆成 pending 表和 history 表,避免热冷数据混在同一张表。
## 最终状态
当前生产已验证:
- 目标服务均健康。
- outbox claim 索引已存在且 `EXPLAIN` 命中指定索引。
- notice 不再直扫 wallet/room outbox。
- wallet 内部 projection 不再轮询扫描 `wallet_outbox`
- MySQL processlist 中 wallet projection 相关 outbox 直扫检查结果为 `0`