13 KiB
13 KiB
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
房间命令成功后的语义:
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 语义:
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-serviceready;腾讯云 IM 抖动时 room 状态服务仍应接收房间命令。 request_id仍然只做追踪;outbox 去重键是event_id,房间命令幂等键是command_id。published在本阶段表示腾讯云 IM 补偿通道已处理,不表示 activity/audit 已消费。
Configuration
在 services/room-service/internal/config/config.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 配置:
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 只支持固定间隔重试,失败记录下一轮继续扫描 |
配置文件示例:
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 生命周期字段:
type App struct {
// existing fields...
workerCancel context.CancelFunc
workerWG sync.WaitGroup
}
启动顺序:
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
关闭顺序:
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 增加持续循环方法:
type OutboxWorkerOptions struct {
PollInterval time.Duration
BatchSize int
PublishTimeout time.Duration
RetryStrategy string
}
func (s *Service) RunOutboxWorker(ctx context.Context, options OutboxWorkerOptions)
RunOutboxWorker 只负责循环、节流和退出:
normalize options
process one batch immediately
start ticker
for each tick:
if ctx done: return
ProcessPendingOutbox(ctx, options)
ProcessPendingOutbox 建议改成 options 版本:
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:
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 |
最近一次失败原因,不能包含密钥或完整腾讯响应体 |
指标后续可补:
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 一启动就制造重复消息:
- 扩展 room-service 配置结构和三份 YAML,增加
outbox_worker配置。 - 给
room/service增加OutboxWorkerOptions和RunOutboxWorker。 - 调整
ProcessPendingOutbox,支持batch_size、单条publish_timeout、单条失败不中断批次。 - 调整命令链路:同步
PublishRoomEvent成功后 best-effortMarkOutboxPublished(event_id);失败保持 pending。 - 在
app.Run启动 outbox worker,在app.Closecancel 并等待 worker 退出。 - 增加单测覆盖同步失败补偿、重试失败保留 pending、worker cancel 退出。
- 更新 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
代码完成后至少运行:
go test ./services/room-service/internal/room/service
go test ./services/room-service/internal/config
go test ./services/room-service/internal/app
go test ./...
涉及配置和生命周期后运行:
docker compose config
docker compose build room-service
如果要验证本地启动链路:
docker compose up -d mysql redis wallet-service room-service
docker compose ps
docker compose down
Done Definition
完成标准:
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 的“房间系统消息同步失败后可补投”才算完成。