# 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`。
