hyapp-server/docs/房间Outbox补偿开发.md
2026-05-27 15:57:01 +08:00

199 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Room Outbox Worker
本文档定义当前 `room-service` outbox 的实际语义。房间写命令只负责提交 Room Cell 状态、command log、snapshot 和 `room_outbox`;腾讯云 IM、activity-service、notice-service、后续 push/inbox 等外部副作用都通过 outbox worker 或 RocketMQ 下游 consumer 异步完成。
## 核心结论
| 项目 | 当前规则 |
| --- | --- |
| 状态 owner | Room Cell 和 MySQL snapshot/command log 是房间状态权威来源 |
| 持久事实 | `room_outbox` 是房间外事件的持久事实源,和房间命令提交链路一起落 MySQL |
| 发布模式 | `outbox_worker.publish_mode` 支持 `direct``mq``dual` |
| MQ 职责 | RocketMQ 只做事件分发和宝箱延迟唤醒,不保存房间核心状态 |
| 下游语义 | 下游服务按 `event_id` 幂等消费,失败由自己的位点、重试和死信处理 |
| 主链路 | HTTP/gRPC 请求不等待 IM REST、activity、notice、push 或 inbox 投递完成 |
不做的事:
| 不做 | 原因 |
| --- | --- |
| 在 Room Cell 中保存投递状态 | 投递状态属于 outbox 或下游消费位点,不属于房间权威状态 |
| 用 Redis 替代 outbox | Redis 只保存热状态和短 TTL lease不能作为恢复事实来源 |
| 用 RocketMQ 替代 `room_outbox` | 房间状态提交成功但 MQ 发布失败时,必须能从 MySQL 补偿发布 |
| 追求精确一次投递 | 外部系统和进程崩溃窗口只能做到 at-least-once消费者必须按 `event_id` 去重 |
## 写命令链路
```text
gateway -> room-service command
-> command_id idempotency check
-> ensure Redis lease and Room Cell
-> mutate cloned RoomState
-> optional wallet debit before room gift state is committed
-> SaveMutation(command log + room_outbox + room meta)
-> snapshot when version hits snapshot_every_n
-> update list/presence projections best effort
-> return response
outbox worker
-> claim pending/retryable rows
-> if RoomTreasureCountdownStarted, schedule treasure open delayed wakeup when configured
-> publish by mode:
direct: Tencent IM publisher + activity gRPC publisher
mq: RocketMQ room_outbox publisher
dual: direct publishers + RocketMQ publisher
-> delivered / retryable / failed
```
主请求成功只表示房间状态已经提交;不表示腾讯云 IM、activity-service、notice-service 或任何后续通知已经投递完成。客户端实时表现应以请求响应、房间 snapshot 和腾讯云 IM 系统消息共同补偿。
## 发布模式
| `publish_mode` | 行为 | 适用场景 |
| --- | --- | --- |
| `direct` | worker 直接调用腾讯云 IM publisher 和 activity gRPC publisher所有 direct publisher 成功后才标记 `delivered` | 本地开发、未接 MQ 的隔离测试 |
| `mq` | worker 只把 `room_outbox_event` 发布到 `hyapp_room_outbox`RocketMQ 接收成功后标记 `delivered` | 线上推荐模式activity、notice、IM bridge 各自 consumer group 解耦消费 |
| `dual` | 同时 direct 投递和 MQ 发布,全部成功后标记 `delivered` | 迁移或灰度期;要求所有消费端都能按 `event_id` 去重 |
`mq` 模式下,`room_outbox.status=delivered` 的含义是“事件已经可靠进入 RocketMQ”不是“所有下游都已处理成功”。下游的成功、重试和死信由各自服务的消费位点负责不能反写 `room_outbox.status`
## 数据状态
`room_outbox.status` 使用:
| 状态 | 含义 |
| --- | --- |
| `pending` | 已落库,尚未被 worker 处理 |
| `delivering` | 已被 worker 抢占,`lock_until_ms` 前其他 worker 不抢 |
| `delivered` | 当前发布模式要求的 publisher 已处理成功MQ 模式下表示 RocketMQ 已接收 |
| `retryable` | 上次发布失败,等待 `next_retry_at_ms` 后重试 |
| `failed` | 达到最大重试次数,进入死信,需要后台工具查看或重放 |
旧数据里可能存在历史 `published` 状态。当前 worker 不再写这个状态,新链路只写上表状态。
## RocketMQ Topics
| Topic | Producer | Consumer Group | 语义 |
| --- | --- | --- | --- |
| `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-activity-room-outbox` | activity-service 消费房间事实,做活动、播报、系统消息等后续处理 |
| `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-notice-room-outbox` | notice-service 消费私有通知相关事实,例如 `RoomUserKicked` |
| `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 |
| `hyapp_room_treasure_open` | `room-service` treasure scheduler | `hyapp-room-treasure-open` | 宝箱倒计时到点唤醒 Room Cell 开箱命令 |
`hyapp_room_treasure_open` 不是业务状态源。消息只携带 `app_code、room_id、box_id、level、open_at_ms、command_id` 等唤醒参数;真正能否开箱仍由 room-service 加载 Room Cell 后校验当前宝箱状态、等级、`box_id``open_at_ms` 和 UTC 重置边界。
## 配置
`services/room-service/configs/*.yaml` 需要同时维护本地、Docker 和腾讯云示例配置:
```yaml
rocketmq:
enabled: false
name_servers: []
name_server_domain: ""
access_key: ""
secret_key: ""
namespace: ""
send_timeout: "3s"
retry: 2
room_outbox:
enabled: false
topic: "hyapp_room_outbox"
producer_group: "hyapp-room-outbox-producer"
tencent_im_consumer_enabled: false
tencent_im_consumer_group: "hyapp-room-im-bridge"
consumer_max_reconsume_times: 16
treasure_open:
enabled: false
topic: "hyapp_room_treasure_open"
producer_group: "hyapp-room-treasure-open-producer"
consumer_group: "hyapp-room-treasure-open"
consumer_max_reconsume_times: 16
outbox_worker:
enabled: true
publish_mode: "direct"
poll_interval: "1s"
batch_size: 100
publish_timeout: "3s"
retry_strategy: "exponential_backoff"
max_retry_count: 10
initial_backoff: "5s"
max_backoff: "5m"
```
配置规则:
| 字段 | 规则 |
| --- | --- |
| `rocketmq.enabled` | 控制 RocketMQ client 是否启用;本地默认关闭,线上示例开启 |
| `rocketmq.room_outbox.enabled` | 控制 room outbox 是否可发布到 MQ`publish_mode=mq/dual` 时必须开启 |
| `rocketmq.room_outbox.tencent_im_consumer_enabled` | 控制 room-service 是否作为 IM bridge consumer 消费 `hyapp_room_outbox` |
| `rocketmq.treasure_open.enabled` | 控制宝箱倒计时是否发布和消费延迟唤醒消息 |
| `outbox_worker.publish_mode` | 本地默认 `direct`,线上建议 `mq`,迁移期可用 `dual` |
| `poll_interval` | worker 空轮询间隔,必须大于 0避免 busy loop |
| `batch_size` | 每轮最大抢占条数,必须大于 0避免全表扫描 |
| `publish_timeout` | 单条 outbox 发布 deadline避免外部系统拖死 worker |
| `max_retry_count` | 达到次数后标记 `failed`,防止 poison event 永久刷日志 |
## 投递对象
| Publisher / Consumer | 行为 |
| --- | --- |
| RocketMQ room outbox publisher | 把 protobuf outbox 包装成统一 `room_outbox_event`,发布到 `hyapp_room_outbox` |
| Tencent IM direct publisher | `RoomCreated` 对已同步创建的房间群执行幂等 EnsureGroup房间系统事件发送腾讯云 IM 群自定义消息 |
| Activity direct publisher | 本地或未接 MQ 时直接调用 activity-service room event 消费入口 |
| Treasure open scheduler | 在 `RoomTreasureCountdownStarted` 后发布到点延迟消息,唤醒 room-service 开箱 |
| Activity MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,按 `event_id` 幂等推进活动/播报逻辑 |
| Notice MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,写 `notice_delivery_events` 后投 C2C 私有通知 |
| Room IM bridge MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,投递房间群系统消息和群成员控制 |
同一个事件允许被多个 consumer group 消费。任何 consumer 失败都不能阻塞 Room Cell 主链路,也不能影响其它 consumer group 的进度。
## 重试和死信
发布失败处理:
```text
publish failed
-> retry_count + 1
-> if retry_count >= max_retry_count: status=failed
-> else: status=retryable, next_retry_at_ms=now+backoff
```
`failed` 不再自动重试。后台应提供人工查看和重放工具,重放时必须保留原 `event_id` 或生成明确的新修复事件,避免消费端无法去重。
RocketMQ consumer 失败时不修改 `room_outbox`。consumer 应依赖 RocketMQ 重投、自己的幂等表和死信处理;对于已经执行业务副作用但 ACK 失败的窗口,下一次消费必须按 `event_id` 安全去重。
## 阶段耗时日志
每条房间写命令都会输出 `room_command_timing`
| 字段 | 含义 |
| --- | --- |
| `idempotency_ms` | command log 幂等检查耗时 |
| `lease_ms` | Redis lease / Room Cell 装载耗时 |
| `wallet_debit_ms` | 送礼扣费耗时;非送礼为 0 |
| `save_mutation_ms` | command log + outbox + 房间 meta 提交耗时 |
| `snapshot_ms` | 快照持久化耗时;未触发为 0 |
| `projection_ms` | 列表和 presence 投影耗时 |
| `activity_ms` | 主链路 activity 同步耗时;当前固定为 0 |
| `tencent_im_ms` | 主链路腾讯 IM 同步耗时CreateRoom 记录同步建群耗时,其余命令当前为 0 |
| `activity_async_outbox` | 本命令是否产生异步 activity/outbox 投递 |
| `tencent_im_async_outbox` | 本命令是否产生异步 IM/outbox 投递 |
排查接口卡顿时先看 `total_ms`,再看各阶段字段。送礼慢通常优先看 `wallet_debit_ms`,普通房间命令慢优先看 `save_mutation_ms``snapshot_ms``projection_ms`
## 验收
| 场景 | 验收 |
| --- | --- |
| IM 不可用,`direct/dual` | 房间写命令仍成功outbox 进入 `retryable` 或最终 `failed` |
| activity 不可用,`direct/dual` | 房间写命令仍成功outbox 进入重试 |
| MQ 不可用,`mq/dual` | 房间写命令仍成功outbox 进入重试MQ 恢复后补发 |
| activity consumer 不可用,`mq` | `room_outbox` 不回退RocketMQ 和 activity 消费位点负责积压与重试 |
| poison event | 达到 `max_retry_count` 后进入 `failed`,不再每秒刷日志 |
| 宝箱倒计时 | `RoomTreasureCountdownStarted` 发布延迟唤醒;到点后 room-service 重新校验状态再开箱 |
| 正常投递 | 同一 `event_id` 可被多个 consumer group 独立消费且重复投递不产生重复副作用 |
| 真实链路 | create/join/mic/sendGift/leave 均有 `room_command_timing` |