12 KiB
12 KiB
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匹配索引。 - 所有
ORclaim 分支拆成独立查询。 delivered/done历史 outbox 建立归档机制。
修改前
运行方式
wallet-service扫wallet_outbox发布hyapp_wallet_outboxMQ。room-service扫room_outbox发布hyapp_room_outboxMQ。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_outboxroom-service发布hyapp_room_outboxactivity-service发布 activity / broadcast 相关 MQgame-service发布 game 相关 MQuser-service发布hyapp_user_outbox
notice-service不再启动直扫wallet_outbox/room_outbox的 worker,改为消费 MQ。wallet-service内部礼物墙和 badge projection 不再轮询扫描wallet_outbox,改为消费hyapp_wallet_outboxMQ。- claim 查询拆成多段:
pendingretryabledelivering/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-serviceruntime 配置禁止直扫 wallet/room outbox,只消费 MQ。wallet-service的礼物墙和 badge projection 改为消费hyapp_wallet_outboxMQ。- 生产验证中,
wallet_projection_events / idx_wallet_outbox_event_created / idx_wallet_outbox_asset_event_created相关直扫进程数为0。
实现位置:
services/notice-service/internal/config/config.goservices/notice-service/internal/app/app.goservices/notice-service/internal/modules/walletnotice/service.goservices/notice-service/internal/modules/walletnotice/mysql_repository.goservices/wallet-service/internal/app/app.goservices/wallet-service/internal/service/wallet/service.goservices/wallet-service/internal/storage/mysql/gift_wall_projection.goservices/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.goservices/room-service/internal/storage/mysql/repository.goservices/activity-service/internal/storage/mysql/broadcast_repository.goservices/game-service/internal/storage/mysql/repository.goservices/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都命中指定索引。
关键索引形态:
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_msnotice_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.goservices/user-service/internal/storage/mysql/user_outbox.goservices/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_archivehyapp_room.room_outbox_archivehyapp_activity.activity_outbox_archivehyapp_activity.im_broadcast_outbox_archivehyapp_game.game_outbox_archivehyapp_game.game_level_event_outbox_archivehyapp_user.user_outbox_archivehyapp_notice.notice_delivery_events_archive
归档方式
归档脚本逻辑:
- 默认归档 30 天以前的数据:
SET @outbox_archive_cutoff_ms := IFNULL(
@outbox_archive_cutoff_ms,
UNIX_TIMESTAMP(DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 30 DAY)) * 1000
);
- 每张表先创建同结构 archive 表:
CREATE TABLE IF NOT EXISTS hyapp_wallet.wallet_outbox_archive LIKE hyapp_wallet.wallet_outbox;
- 先
INSERT IGNORE到 archive 表:
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;
- 确认 archive 表存在对应记录后,再删除源表:
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 archivingbd0ed78 fix: add game outbox retention index0aef70e fix: consume wallet projections from mq
生产部署:
wallet-service:main-0aef70e-outbox-governanceroom-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。