# Outbox 统一治理说明 更新时间:2026-07-11 ## 背景 生产 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 表。 - wallet/room topic 使用可灰度的 typed Tag:consumer 先部署 `legacy || relevant event_type`,producer 再从 `legacy` 切到 `event_type`。 - 同步 MQ publisher 每次只 claim 一条 outbox;`batch_size` 表示单轮最多处理 条数,不再表示一次提前锁住的行数,避免批尾 lease 在真正发送前过期。 ## Typed Tag 无损切换 Typed Tag 只降低无关 consumer group 的投递、反序列化和 handler 调用量,不会 减少 Broker ingress 或存储。礼物消息总量必须在写入 owner outbox 前通过送礼微批 收敛;多人礼物仍按 target 保留各自 `WalletGiftDebited`、`RoomGiftSent` 事实,且 每条事实携带聚合后的 `gift_count`。 切换顺序必须严格执行: 1. 先滚动发布所有 wallet consumer,确认订阅表达式包含 `wallet_outbox_event || <相关 event_type>`,且同一 group 所有副本表达式一致。 2. 确认旧 consumer 实例归零、消费错误率稳定,再仅把 wallet-service `rocketmq.wallet_event_tag_mode` 从 `legacy` 改为 `event_type`。 3. 保留 consumer 的 legacy 分支,直到切换前积压、retry 和计划内 replay 窗口全部 排空;不能在 producer 切换同一批发布中移除 legacy。 4. wallet 稳定后,以同样顺序先发布 activity/statistics/notice/user 等 room consumer,再把 room-service `rocketmq.room_event_tag_mode` 从 `legacy` 改为 `event_type`。 5. 如需回滚 producer,只改回 `legacy`;兼容 consumer 无需回滚。只有确认 Broker 已无 legacy 消息且不再重放旧 outbox 后,才可以另行删除 legacy selector。 推荐验证: ```text Phase A: producer tag=legacy;每个 consumer group 仍能消费原有事实 Phase B: gift typed tag 只进入需要 gift 的 group;首充/累充/邀请等 group lag 不再随礼物增长 Rollback: producer 改回 legacy 后所有兼容 consumer 继续消费,无消息不可见窗口 ``` Topic 拆分不是降低消息总量的手段。完成微批和 typed Tag 后,只有在礼物流量仍 持续挤压非礼物 SLO,或确实需要独立 retention/retry/ACL/顺序策略时才拆高频 Topic; 流量占比只能作为评审信号,不能把固定百分比当作行业标准。 ### 历史礼物消费修复 新代码只阻止新增的 `WalletBalanceChanged(direct_gift_debit)` 与 `RoomGiftSent` 双计;已经落入 `stat_app_day_country.consumed_coin` 的历史窗口不能直接拍脑袋减值。 上线后使用现有 `replay-stat-tz` 从四个 owner outbox 重建受影响窗口,并分别执行 UTC 与 Asia/Shanghai 两套口径。先不带 `--apply` 核对 source_rows,再在停止该 窗口实时写入或只选择已完成自然日时执行 apply: ```bash go run ./services/statistics-service/cmd/replay-stat-tz \ --app lalu --timezone UTC --start-day 2026-07-01 --end-day 2026-07-10 go run ./services/statistics-service/cmd/replay-stat-tz \ --app lalu --timezone Asia/Shanghai --start-day 2026-07-01 --end-day 2026-07-10 ``` 确认四类 owner source_rows 非零且窗口正确后,为同一命令补充 `--apply` 和运行时 DSN 环境变量。每个 App 独立执行;完成后校验 `consumed_coin` 中礼物部分等于 `RoomGiftSent.coin_spent`,钱包礼物扣款只继续影响 `coin_total`。 ## 修复掉的问题 ### 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`。