345 lines
13 KiB
Markdown
345 lines
13 KiB
Markdown
# Room Outbox Compensation Worker Development Guide
|
||
|
||
本文档定义 `room-service` 的 outbox 补偿 worker 如何接入进程生命周期、如何配置、如何重试,以及如何验证腾讯云 IM 同步失败后可由 `room_outbox` 补投。目标是收口 Phase 1 的房间系统消息可靠性,不扩展活动消费和 MQ 架构。
|
||
|
||
## Scope
|
||
|
||
本阶段只做:
|
||
|
||
| Item | Result |
|
||
| --- | --- |
|
||
| App 生命周期接入 | `room-service` 启动时启动 outbox worker,下线时先停止扫描新批次,再关闭 gRPC/MySQL/Redis |
|
||
| worker 配置 | 支持启停、扫描间隔、批量大小、单条投递超时和固定间隔重试策略 |
|
||
| 腾讯云 IM 补偿 | 同步 `PublishRoomEvent` 失败不回滚房间状态,pending outbox 后续补投到腾讯云 IM |
|
||
| 可验证测试 | 单测覆盖同步失败后状态已提交、outbox pending、worker 成功补投并标记 published |
|
||
|
||
本阶段不做:
|
||
|
||
| Not Included | Reason |
|
||
| --- | --- |
|
||
| activity-service 消费主循环 | 活动消费需要独立消费位点,不能复用 IM 补偿状态 |
|
||
| MQ/Redis Stream | 当前 MySQL outbox 已能满足 Phase 1 补偿 |
|
||
| 精确一次投递 | 腾讯云 IM 和进程崩溃窗口只能保证 at-least-once,客户端按 `event_id` 去重 |
|
||
| 多 sink 状态表 | 先把腾讯云 IM 补偿闭环跑通;activity/audit 后续新增自己的 consumption 表 |
|
||
| 指数退避和 poison 队列 | 当前 `room_outbox` 没有 `next_retry_at_ms` 或 poison 状态,V1 用固定间隔重试 |
|
||
|
||
## Current State
|
||
|
||
当前代码已有基础能力:
|
||
|
||
| Component | Current Fact |
|
||
| --- | --- |
|
||
| `room-service/internal/room/service/pipeline.go` | 命令成功后先写 command log/outbox,再尝试同步 `PublishRoomEvent`;同步成功 best-effort 标记 `published`,同步失败不回滚状态 |
|
||
| `room-service/internal/room/service/outbox_worker.go` | 已有 `RunOutboxWorker(ctx, options)` 持续循环和 `ProcessPendingOutbox(ctx, options)` 单批处理 |
|
||
| `room-service/internal/storage/mysql/repository.go` | 已有 `ListPendingOutbox`、`MarkOutboxPublished`、`MarkOutboxFailed` |
|
||
| `room-service/internal/integration/tencent_im.go` | 已能把 `EventEnvelope` 转换成腾讯云 IM 房间系统事件 |
|
||
| `room-service/internal/app/app.go` | 已在进程生命周期中启动 presence stale worker 和 outbox worker,下线时先停 worker 再关闭 gRPC/MySQL/Redis |
|
||
|
||
当前风险:
|
||
|
||
- 代码接入前已经残留的 `pending` 事件仍会被补投;V1 接受 at-least-once,客户端按 `event_id` 去重。
|
||
- `room_outbox.status` 目前只有 `pending/published`,适合先表达腾讯云 IM 补偿状态,不适合同时表达 activity/audit 消费状态。
|
||
- 多个 room-service 实例同时扫描同一批 pending 时可能重复投递;V1 接受 at-least-once,客户端按 `event_id` 去重。
|
||
|
||
## Target Semantics
|
||
|
||
房间命令成功后的语义:
|
||
|
||
```text
|
||
Room Cell command applied
|
||
-> write command log
|
||
-> write room_outbox status=pending
|
||
-> persist snapshot if needed
|
||
-> replace in-memory RoomState
|
||
-> try synchronous PublishRoomEvent
|
||
-> if sync publish success, best-effort mark that event published
|
||
-> if sync publish fails, keep pending for worker
|
||
```
|
||
|
||
补偿 worker 语义:
|
||
|
||
```text
|
||
scan room_outbox where status=pending
|
||
-> publish one EventEnvelope to configured OutboxPublisher
|
||
-> success: status=published, last_error=NULL
|
||
-> failure: status=pending, retry_count=retry_count+1, last_error=<short error>
|
||
-> next scan retries failed pending records
|
||
```
|
||
|
||
重要边界:
|
||
|
||
- worker 不执行房间命令,不修改 Room Cell,不写 command log,不写 snapshot。
|
||
- worker 只处理已经持久化的 outbox envelope。
|
||
- worker 投递失败不能影响 `room-service` ready;腾讯云 IM 抖动时 room 状态服务仍应接收房间命令。
|
||
- `request_id` 仍然只做追踪;outbox 去重键是 `event_id`,房间命令幂等键是 `command_id`。
|
||
- `published` 在本阶段表示腾讯云 IM 补偿通道已处理,不表示 activity/audit 已消费。
|
||
|
||
## Configuration
|
||
|
||
在 `services/room-service/internal/config/config.go` 增加:
|
||
|
||
```go
|
||
type OutboxWorkerConfig struct {
|
||
Enabled bool `yaml:"enabled"`
|
||
PollInterval time.Duration `yaml:"poll_interval"`
|
||
BatchSize int `yaml:"batch_size"`
|
||
PublishTimeout time.Duration `yaml:"publish_timeout"`
|
||
RetryStrategy string `yaml:"retry_strategy"`
|
||
}
|
||
```
|
||
|
||
挂到 room-service 配置:
|
||
|
||
```go
|
||
type Config struct {
|
||
// existing fields...
|
||
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
|
||
}
|
||
```
|
||
|
||
默认值:
|
||
|
||
| Field | Default | Meaning |
|
||
| --- | ---: | --- |
|
||
| `enabled` | `true` | 本地和生产默认启动补偿 worker;测试可显式关闭 |
|
||
| `poll_interval` | `1s` | 每轮扫描 pending outbox 的间隔 |
|
||
| `batch_size` | `100` | 每轮最多处理的 outbox 记录数 |
|
||
| `publish_timeout` | `3s` | 单条事件调用腾讯云 IM 的最大时间 |
|
||
| `retry_strategy` | `fixed_interval` | V1 只支持固定间隔重试,失败记录下一轮继续扫描 |
|
||
|
||
配置文件示例:
|
||
|
||
```yaml
|
||
outbox_worker:
|
||
enabled: true
|
||
poll_interval: "1s"
|
||
batch_size: 100
|
||
publish_timeout: "3s"
|
||
retry_strategy: "fixed_interval"
|
||
```
|
||
|
||
配置规则:
|
||
|
||
- `poll_interval <= 0` 时使用默认值,不能导致 busy loop。
|
||
- `batch_size <= 0` 时使用默认值,不能全表扫描。
|
||
- `publish_timeout <= 0` 时使用默认值,不能让单条外部调用无限阻塞 worker。
|
||
- `retry_strategy` 只接受空值或 `fixed_interval`;未知值启动失败,避免配置写错后静默改变投递语义。
|
||
- `config.yaml`、`config.docker.yaml`、`config.tencent.example.yaml` 必须同步增加该配置。
|
||
|
||
## App Lifecycle
|
||
|
||
`room-service/internal/app.App` 需要新增 worker 生命周期字段:
|
||
|
||
```go
|
||
type App struct {
|
||
// existing fields...
|
||
workerCancel context.CancelFunc
|
||
workerWG sync.WaitGroup
|
||
}
|
||
```
|
||
|
||
启动顺序:
|
||
|
||
```text
|
||
1. 初始化 MySQL repository、Redis directory、wallet client、Tencent IM publisher
|
||
2. 创建 room service 和 gRPC server
|
||
3. Run 标记 gRPC serving
|
||
4. 创建 worker context
|
||
5. 启动 presence stale worker
|
||
6. 如果 outbox_worker.enabled=true,启动 outbox worker
|
||
7. 进入 grpcServer.Serve
|
||
```
|
||
|
||
关闭顺序:
|
||
|
||
```text
|
||
1. MarkDraining
|
||
2. cancel worker context,停止扫描新 outbox 批次
|
||
3. worker 当前正在处理的单条事件受 publish_timeout 控制,结束后退出
|
||
4. workerWG.Wait 等待后台 worker 停止
|
||
5. GracefulStop gRPC,等待已进入命令完成持久化
|
||
6. 关闭 wallet gRPC、MySQL、Redis
|
||
```
|
||
|
||
关闭规则:
|
||
|
||
- 不能在 worker 退出前关闭 MySQL,否则可能无法标记投递结果。
|
||
- worker 收到 cancel 后不再拉取新批次;已经进入投递的事件可以完成成功标记或失败标记。
|
||
- 如果进程被强杀,未标记成功的记录保持 `pending`,下次启动继续补偿。
|
||
|
||
## Worker API
|
||
|
||
在 `room/service` 增加持续循环方法:
|
||
|
||
```go
|
||
type OutboxWorkerOptions struct {
|
||
PollInterval time.Duration
|
||
BatchSize int
|
||
PublishTimeout time.Duration
|
||
RetryStrategy string
|
||
}
|
||
|
||
func (s *Service) RunOutboxWorker(ctx context.Context, options OutboxWorkerOptions)
|
||
```
|
||
|
||
`RunOutboxWorker` 只负责循环、节流和退出:
|
||
|
||
```text
|
||
normalize options
|
||
process one batch immediately
|
||
start ticker
|
||
for each tick:
|
||
if ctx done: return
|
||
ProcessPendingOutbox(ctx, options)
|
||
```
|
||
|
||
`ProcessPendingOutbox` 建议改成 options 版本:
|
||
|
||
```go
|
||
func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorkerOptions) error
|
||
```
|
||
|
||
处理规则:
|
||
|
||
- 每轮调用 `repository.ListPendingOutbox(ctx, batch_size)`。
|
||
- 每条记录单独创建 `context.WithTimeout(ctx, publish_timeout)`。
|
||
- `PublishOutboxEvent` 成功后调用 `MarkOutboxPublished`。
|
||
- `PublishOutboxEvent` 失败后调用 `MarkOutboxFailed`。
|
||
- 单条失败不阻断同批后续记录。
|
||
- `ListPendingOutbox` 或 `MarkOutboxPublished/Failed` 失败属于本轮 worker 错误,记录后等待下一轮。
|
||
|
||
## Sync Publish Interaction
|
||
|
||
为了避免 worker 对同步成功事件重复补投,命令链路需要在同步成功后 best-effort 标记 outbox:
|
||
|
||
```text
|
||
if result.applied && result.syncEvent != nil:
|
||
err := syncPublisher.PublishRoomEvent(ctx, *result.syncEvent)
|
||
if err == nil:
|
||
repository.MarkOutboxPublished(ctx, result.syncEvent.EventID)
|
||
if err != nil:
|
||
keep room_outbox pending
|
||
```
|
||
|
||
约束:
|
||
|
||
- `MarkOutboxPublished` 失败不能让已经成功提交的房间命令返回失败;最多造成后续重复投递。
|
||
- 同步成功但进程在标记 published 前崩溃时,worker 后续可能重复投递;客户端必须用 `event_id` 去重。
|
||
- 对 `RoomCreated`、`RoomHeatChanged`、`RoomRankChanged` 这类不推 IM 的事件,`PublishOutboxEvent` 返回 no-op success 后可标记 `published`,避免长期 pending。
|
||
|
||
## Retry Strategy
|
||
|
||
V1 采用固定间隔无限重试:
|
||
|
||
| Case | Behavior |
|
||
| --- | --- |
|
||
| 腾讯云 IM 网络错误 | `retry_count + 1`,保留 `pending`,下一轮继续 |
|
||
| 腾讯云 IM 返回可重试错误 | `retry_count + 1`,保留 `pending`,下一轮继续 |
|
||
| envelope 反序列化失败 | worker 本轮返回错误,不删除记录;后续需要人工排查或补工具 |
|
||
| 不需要推 IM 的事件类型 | no-op success,标记 `published` |
|
||
| 进程退出 | 未成功标记的记录保持 `pending` |
|
||
|
||
为什么不加最大重试次数:
|
||
|
||
- 房间系统消息是状态变更的外部表现,不能因为达到次数就丢弃。
|
||
- 当前 schema 没有 poison 状态,贸然新增半成品状态会让恢复和运营查询复杂化。
|
||
- `retry_count` 和 `last_error` 已足够驱动告警;后续需要 poison 队列时再单独设计 `failed/next_retry_at_ms`。
|
||
|
||
## Observability
|
||
|
||
首版至少打结构化日志字段:
|
||
|
||
| Field | Meaning |
|
||
| --- | --- |
|
||
| `worker` | 固定为 `room_outbox` |
|
||
| `node_id` | 当前 room-service 节点 |
|
||
| `event_id` | outbox 事件 ID |
|
||
| `event_type` | RoomUserJoined、RoomGiftSent 等 |
|
||
| `room_id` | 事件所属房间 |
|
||
| `retry_count` | 当前失败次数 |
|
||
| `status` | pending 或 published |
|
||
| `error` | 最近一次失败原因,不能包含密钥或完整腾讯响应体 |
|
||
|
||
指标后续可补:
|
||
|
||
```text
|
||
room_outbox_pending_total
|
||
room_outbox_publish_success_total
|
||
room_outbox_publish_failure_total
|
||
room_outbox_publish_latency_ms
|
||
room_outbox_oldest_pending_age_ms
|
||
```
|
||
|
||
## Implementation Order
|
||
|
||
按以下顺序做,避免 worker 一启动就制造重复消息:
|
||
|
||
1. 扩展 room-service 配置结构和三份 YAML,增加 `outbox_worker` 配置。
|
||
2. 给 `room/service` 增加 `OutboxWorkerOptions` 和 `RunOutboxWorker`。
|
||
3. 调整 `ProcessPendingOutbox`,支持 `batch_size`、单条 `publish_timeout`、单条失败不中断批次。
|
||
4. 调整命令链路:同步 `PublishRoomEvent` 成功后 best-effort `MarkOutboxPublished(event_id)`;失败保持 pending。
|
||
5. 在 `app.Run` 启动 outbox worker,在 `app.Close` cancel 并等待 worker 退出。
|
||
6. 增加单测覆盖同步失败补偿、重试失败保留 pending、worker cancel 退出。
|
||
7. 更新 README 当前限制,说明 outbox worker 已接入生命周期。
|
||
|
||
## Acceptance Tests
|
||
|
||
必须覆盖:
|
||
|
||
| Test | Expected Result |
|
||
| --- | --- |
|
||
| 同步 IM 失败后命令成功 | `JoinRoom` 或 `SendGift` 返回成功,RoomSnapshot 已更新 |
|
||
| 同步 IM 失败后 outbox pending | 对应 `event_id` 仍为 `pending`,`retry_count` 未因同步失败被提前增加 |
|
||
| worker 补投成功 | fake outbox publisher 收到同一 `event_id`,repository 标记 `published` |
|
||
| worker 补投失败 | 记录仍为 `pending`,`retry_count+1`,`last_error` 有值 |
|
||
| 下一轮成功 | 第一次失败、第二次成功后状态变为 `published` |
|
||
| 同步 IM 成功 | 对应 `event_id` best-effort 标记 `published`,worker 不再扫描该事件 |
|
||
| worker cancel | cancel 后不再扫描新批次,当前投递受 `publish_timeout` 约束后退出 |
|
||
| 非 IM 事件 | `RoomCreated/RoomHeatChanged/RoomRankChanged` 不发送腾讯 IM,但会标记 `published` |
|
||
|
||
已有测试 `TestRoomOutboxCompensationWhenSyncPublishFails` 可以作为基础,但需要补齐持续 worker 和同步成功去重场景。
|
||
|
||
## Verification Commands
|
||
|
||
代码完成后至少运行:
|
||
|
||
```bash
|
||
go test ./services/room-service/internal/room/service
|
||
go test ./services/room-service/internal/config
|
||
go test ./services/room-service/internal/app
|
||
go test ./...
|
||
```
|
||
|
||
涉及配置和生命周期后运行:
|
||
|
||
```bash
|
||
docker compose config
|
||
docker compose build room-service
|
||
```
|
||
|
||
如果要验证本地启动链路:
|
||
|
||
```bash
|
||
docker compose up -d mysql redis wallet-service room-service
|
||
docker compose ps
|
||
docker compose down
|
||
```
|
||
|
||
## Done Definition
|
||
|
||
完成标准:
|
||
|
||
```text
|
||
room command committed
|
||
-> outbox persisted
|
||
-> sync Tencent IM publish fails
|
||
-> HTTP/gRPC command response still succeeds
|
||
-> outbox remains pending
|
||
-> app-level worker scans pending
|
||
-> worker publishes same event_id to Tencent IM publisher
|
||
-> outbox status becomes published
|
||
-> process shutdown stops worker before closing MySQL
|
||
```
|
||
|
||
该闭环稳定后,Phase 1 的“房间系统消息同步失败后可补投”才算完成。
|