443 lines
14 KiB
Markdown
443 lines
14 KiB
Markdown
# Voice Room Region Room List Architecture
|
||
|
||
本文档定义 App 房间列表的区域化架构。目标是让同一区域内的国家看到同一套房间列表,不同区域看到不同列表,同时不破坏现有 `Room Cell` 单房间状态 owner 模型。
|
||
|
||
## Scope
|
||
|
||
| Capability | Owner | Required Result |
|
||
| --- | --- | --- |
|
||
| 国家和区域主数据 | `user-service` | 国家归属区域由服务端配置和计算 |
|
||
| 用户区域归属 | `user-service` | 注册和改国家后写入 `users.region_id` |
|
||
| 房间可见区域 | `room-service` | 房间创建时绑定 `visible_region_id` |
|
||
| 房间列表读模型 | `room-service` | 按 `visible_region_id` 查询列表卡片 |
|
||
| HTTP 列表入口 | `gateway-service` | 鉴权后解析用户区域并调用 room-service |
|
||
| 实时进房校验 | `room-service` | 列表只负责发现,进入房间仍以 `JoinRoom` 为准 |
|
||
|
||
本阶段不做:
|
||
|
||
| Excluded | Reason |
|
||
| --- | --- |
|
||
| 推荐系统 | 首版先实现区域隔离和稳定排序,不做个性化推荐 |
|
||
| 跨区域混排 | 产品语义要求不同区域列表隔离 |
|
||
| 客户端提交 `region_id` | 区域必须由服务端按用户国家计算,避免伪造 |
|
||
| IP 国家决定房间列表 | `country_by_ip` 只用于审计和风控辅助,不代表用户选择国家 |
|
||
| 把列表塞进 Room Cell | Room Cell 是单房间高频状态 owner,不承担多房间列表查询 |
|
||
| 创建者改国家自动迁移房间 | 房间可见区域是创建时确定的业务属性,避免直播中列表突然漂移 |
|
||
|
||
## Core Rule
|
||
|
||
房间列表按 `region_id` 隔离,而不是按 `country` 隔离。
|
||
|
||
例如:
|
||
|
||
| Country | Region | Visible List |
|
||
| --- | --- | --- |
|
||
| `SG` | `SEA` | `room:list:region:SEA` |
|
||
| `MY` | `SEA` | `room:list:region:SEA` |
|
||
| `SA` | `MENA` | `room:list:region:MENA` |
|
||
|
||
同一区域内的国家看到相同列表;不同区域看到不同列表。
|
||
|
||
用户无区域归属时进入默认列表桶:
|
||
|
||
```text
|
||
region_id = 0
|
||
region_code = GLOBAL
|
||
```
|
||
|
||
`GLOBAL` 不是地理区域,只是兜底列表桶。后续如果产品要求“未选国家不能看列表”,gateway 可以在入口直接拒绝,不需要改 room-service 列表模型。
|
||
|
||
## Service Boundary
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
C["Client"] --> G["gateway-service"]
|
||
G --> U["user-service"]
|
||
G --> R["room-service"]
|
||
R --> DB["MySQL"]
|
||
R --> Redis["Redis"]
|
||
R --> T["Tencent Cloud IM"]
|
||
|
||
U -. "country -> region_id" .-> G
|
||
G -. "ListRooms(region_id)" .-> R
|
||
R -. "room list projection" .-> DB
|
||
R -. "hot/new sorted cache" .-> Redis
|
||
R -. "system room events" .-> T
|
||
```
|
||
|
||
边界规则:
|
||
|
||
- `user-service` 是国家和区域归属的唯一 owner。
|
||
- `gateway-service` 只做鉴权、用户区域解析和协议转换。
|
||
- `room-service` 是房间列表和房间状态的 owner。
|
||
- `Room Cell` 不保存区域列表,不扫描全局房间。
|
||
- 腾讯云 IM 只负责实时消息,不参与房间列表排序和区域可见性。
|
||
|
||
## Request Flow
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant C as Client
|
||
participant G as gateway-service
|
||
participant U as user-service
|
||
participant R as room-service
|
||
participant S as MySQL/Redis
|
||
|
||
C->>G: GET /api/v1/rooms?tab=hot&limit=20
|
||
G->>U: GetUser(user_id)
|
||
U-->>G: region_id
|
||
G->>R: ListRooms(region_id, tab, cursor, limit)
|
||
R->>S: Read room_list_entries or Redis ZSET
|
||
S-->>R: RoomListItem[]
|
||
R-->>G: ListRoomsResponse
|
||
G-->>C: {code,message,request_id,data}
|
||
```
|
||
|
||
列表只是发现页。用户点击房间后仍然走:
|
||
|
||
```text
|
||
gateway -> room-service.JoinRoom -> Tencent IM join group callback guard
|
||
```
|
||
|
||
因此列表允许秒级最终一致;真正能否进入房间由 `JoinRoom` 和 IM 守卫决定。
|
||
|
||
## Room Region Ownership
|
||
|
||
### CreateRoom
|
||
|
||
创建房间时绑定 `visible_region_id`。
|
||
|
||
推荐链路:
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant C as Client
|
||
participant G as gateway-service
|
||
participant U as user-service
|
||
participant R as room-service
|
||
|
||
C->>G: POST /api/v1/rooms/create
|
||
G->>U: GetUser(actor_user_id)
|
||
U-->>G: region_id
|
||
G->>R: CreateRoom(visible_region_id=region_id)
|
||
R->>R: Create Room Cell + room_list_entries
|
||
R-->>G: RoomSnapshot
|
||
G-->>C: room
|
||
```
|
||
|
||
约束:
|
||
|
||
- 客户端不能提交 `visible_region_id`。
|
||
- `visible_region_id` 来自创建者当前 `users.region_id`。
|
||
- 创建者没有区域时写 `0`,进入 `GLOBAL` 列表桶。
|
||
- 房间创建成功后,创建者改国家不自动修改房间区域。
|
||
- 管理端迁移房间区域必须走独立命令,例如 `ChangeRoomVisibleRegion`,并写审计。
|
||
|
||
### Why Not Dynamic By Owner Region
|
||
|
||
不建议每次列表查询都按房主当前 `region_id` 过滤:
|
||
|
||
- 房主改国家会导致直播中房间突然从原区域消失。
|
||
- 列表查询会被迫 join user-service 或用户表,破坏 room-service 的读模型独立性。
|
||
- 历史房间的区域口径会随用户资料变化而漂移,不利于运营审计。
|
||
|
||
所以房间列表区域应该是房间属性,不是房主资料的实时派生字段。
|
||
|
||
## Data Model
|
||
|
||
### `rooms`
|
||
|
||
`rooms` 是房间恢复和基础元数据表。建议增加:
|
||
|
||
```sql
|
||
ALTER TABLE rooms
|
||
ADD COLUMN visible_region_id BIGINT NOT NULL DEFAULT 0 AFTER status,
|
||
ADD KEY idx_rooms_region_status (visible_region_id, status);
|
||
```
|
||
|
||
字段语义:
|
||
|
||
| Field | Rule |
|
||
| --- | --- |
|
||
| `visible_region_id` | 房间列表可见区域,`0` 表示 `GLOBAL` |
|
||
| `status` | 房间生命周期状态,列表只展示 active/live 类状态 |
|
||
|
||
### `room_list_entries`
|
||
|
||
`room_list_entries` 是房间列表读模型,避免列表查询反序列化 `room_snapshots`。
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS room_list_entries (
|
||
room_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
||
owner_user_id BIGINT NOT NULL,
|
||
host_user_id BIGINT NOT NULL,
|
||
title VARCHAR(128) NOT NULL DEFAULT '',
|
||
cover_url VARCHAR(512) NOT NULL DEFAULT '',
|
||
mode VARCHAR(64) NOT NULL,
|
||
status VARCHAR(32) NOT NULL,
|
||
heat BIGINT NOT NULL DEFAULT 0,
|
||
online_count INT NOT NULL DEFAULT 0,
|
||
seat_count INT NOT NULL DEFAULT 0,
|
||
occupied_seat_count INT NOT NULL DEFAULT 0,
|
||
sort_score BIGINT NOT NULL DEFAULT 0,
|
||
created_at_ms BIGINT NOT NULL,
|
||
updated_at_ms BIGINT NOT NULL,
|
||
KEY idx_room_list_region_hot (visible_region_id, status, sort_score DESC, room_id),
|
||
KEY idx_room_list_region_new (visible_region_id, status, created_at_ms DESC, room_id),
|
||
KEY idx_room_list_owner (owner_user_id, updated_at_ms)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||
```
|
||
|
||
读模型字段只保存列表卡片需要的内容,不保存完整房间状态:
|
||
|
||
| Field | Source | Update Trigger |
|
||
| --- | --- | --- |
|
||
| `room_id` | CreateRoom | 创建房间 |
|
||
| `visible_region_id` | creator `region_id` | 创建房间或管理端迁移 |
|
||
| `owner_user_id` | RoomMeta | 创建房间 |
|
||
| `host_user_id` | RoomState | 创建房间、TransferRoomHost |
|
||
| `mode` | RoomMeta/RoomState | 创建房间或模式变更 |
|
||
| `status` | RoomState | 创建、关闭、封禁、恢复 |
|
||
| `heat` | RoomState | SendGift、热度刷新 |
|
||
| `online_count` | RoomState presence | JoinRoom、LeaveRoom、stale cleanup |
|
||
| `occupied_seat_count` | RoomState mic seats | MicUp、MicDown、ChangeMicSeat |
|
||
| `sort_score` | room-service ranking policy | 任意影响排序的事件 |
|
||
|
||
## Redis Cache
|
||
|
||
MySQL 是事实来源,Redis 是列表排序和卡片缓存。
|
||
|
||
推荐 key:
|
||
|
||
```text
|
||
room:list:{visible_region_id}:hot -> ZSET(room_id, sort_score)
|
||
room:list:{visible_region_id}:new -> ZSET(room_id, created_at_ms)
|
||
room:card:{room_id} -> HASH/JSON RoomListItem
|
||
```
|
||
|
||
缓存规则:
|
||
|
||
- `CreateRoom` 写 MySQL 后写 Redis。
|
||
- `JoinRoom`、`LeaveRoom`、`SendGift`、麦位变化更新 MySQL 投影,再更新 Redis。
|
||
- Redis 写失败不回滚 Room Cell 命令。
|
||
- Redis miss 时从 MySQL 回源并修复缓存。
|
||
- Redis 中的列表成员必须定期清理非 active 房间,避免关闭房间长期残留。
|
||
|
||
首版可以先只做 MySQL 查询,等列表 QPS 上来再加 Redis ZSET。不要一开始为了缓存牺牲正确性。
|
||
|
||
## Sort Policy
|
||
|
||
首版只支持两个 tab:
|
||
|
||
| Tab | Sort | Use Case |
|
||
| --- | --- | --- |
|
||
| `hot` | `sort_score DESC, room_id ASC` | 默认热门列表 |
|
||
| `new` | `created_at_ms DESC, room_id ASC` | 最新开播列表 |
|
||
|
||
`sort_score` 建议由 room-service 计算:
|
||
|
||
```text
|
||
sort_score = heat * 1000 + online_count * 100 + occupied_seat_count * 10 + freshness_boost
|
||
```
|
||
|
||
约束:
|
||
|
||
- 排序公式只影响列表展示,不进入 RoomState。
|
||
- 排序公式调整不需要回放 Room Cell command log。
|
||
- 需要大规模重排时使用后台 rebuild job 更新 `room_list_entries.sort_score`。
|
||
|
||
## API Contract
|
||
|
||
### HTTP
|
||
|
||
```text
|
||
GET /api/v1/rooms?tab=hot&cursor=&limit=20
|
||
GET /api/v1/rooms?tab=new&cursor=&limit=20
|
||
```
|
||
|
||
响应继续使用 gateway envelope:
|
||
|
||
```json
|
||
{
|
||
"code": "OK",
|
||
"message": "ok",
|
||
"request_id": "req_xxx",
|
||
"data": {
|
||
"rooms": [],
|
||
"next_cursor": ""
|
||
}
|
||
}
|
||
```
|
||
|
||
请求规则:
|
||
|
||
- `tab` 默认 `hot`。
|
||
- `limit` 默认 `20`,最大 `50`。
|
||
- `cursor` 使用不透明字符串,客户端不能解析或拼装。
|
||
- gateway 从 access token 得到 `user_id`,再查 user-service 获取 `region_id`。
|
||
- 如果 user-service 查询失败,gateway 应返回错误,不应该用 IP 国家临时兜底。
|
||
|
||
### gRPC
|
||
|
||
建议新增查询服务,不混入 `RoomCommandService`:
|
||
|
||
```proto
|
||
service RoomQueryService {
|
||
rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse);
|
||
}
|
||
|
||
message ListRoomsRequest {
|
||
RequestMeta meta = 1;
|
||
int64 viewer_user_id = 2;
|
||
int64 visible_region_id = 3;
|
||
string tab = 4;
|
||
string cursor = 5;
|
||
int32 limit = 6;
|
||
}
|
||
|
||
message RoomListItem {
|
||
string room_id = 1;
|
||
int64 owner_user_id = 2;
|
||
int64 host_user_id = 3;
|
||
string title = 4;
|
||
string cover_url = 5;
|
||
string mode = 6;
|
||
string status = 7;
|
||
int64 heat = 8;
|
||
int32 online_count = 9;
|
||
int32 seat_count = 10;
|
||
int32 occupied_seat_count = 11;
|
||
int64 visible_region_id = 12;
|
||
}
|
||
|
||
message ListRoomsResponse {
|
||
repeated RoomListItem rooms = 1;
|
||
string next_cursor = 2;
|
||
}
|
||
```
|
||
|
||
兼容规则:
|
||
|
||
- 新增 proto 字段只能追加,不能复用字段号。
|
||
- `visible_region_id` 使用 `int64`,`0` 表示 `GLOBAL`。
|
||
- 不要把 `country` 传给 room-service 列表接口;国家到区域的映射不属于 room-service。
|
||
|
||
## Write Path Updates
|
||
|
||
房间命令成功后要同步更新读模型。读模型失败不能破坏 Room Cell 已提交状态,但必须可补偿。
|
||
|
||
| Command | Room Cell | List Projection |
|
||
| --- | --- | --- |
|
||
| `CreateRoom` | 创建状态、快照、outbox | 插入 `room_list_entries` |
|
||
| `JoinRoom` | 增加或刷新 presence | 更新 `online_count`、`sort_score` |
|
||
| `LeaveRoom` | 移除 presence、释放麦位 | 更新 `online_count`、`occupied_seat_count` |
|
||
| `MicUp` | 占用麦位 | 更新 `occupied_seat_count` |
|
||
| `MicDown` | 释放麦位 | 更新 `occupied_seat_count` |
|
||
| `TransferRoomHost` | 修改 host | 更新 `host_user_id` |
|
||
| `SendGift` | 更新 heat、rank | 更新 `heat`、`sort_score` |
|
||
| `CloseRoom` | 修改状态 | 从列表隐藏或标记非 active |
|
||
|
||
推荐实现:
|
||
|
||
- Room Cell 命令仍然是状态变更入口。
|
||
- 命令成功提交后调用 `RoomListProjector` 更新 MySQL 投影。
|
||
- 投影更新失败时写 `room_outbox` 或独立 `room_projection_tasks` 等待补偿。
|
||
- 列表查询只读 `room_list_entries`,不直接读取 Room Cell 内存。
|
||
|
||
## Consistency Model
|
||
|
||
房间列表采用最终一致。
|
||
|
||
允许:
|
||
|
||
- 列表在线人数短暂落后。
|
||
- 房间关闭后短时间仍出现在列表。
|
||
- 热度和排序秒级延迟。
|
||
|
||
不允许:
|
||
|
||
- 用户看到其他区域专属列表。
|
||
- 客户端通过伪造 `region_id` 越权获取列表。
|
||
- 列表查询直接访问 Room Cell 全量内存。
|
||
- Redis 成为唯一数据源。
|
||
|
||
点击列表进入房间时必须再次校验:
|
||
|
||
```text
|
||
JoinRoom(room_id, user_id)
|
||
```
|
||
|
||
如果房间已关闭、用户被封禁或区域策略后续收紧,`JoinRoom` 返回拒绝,客户端刷新列表。
|
||
|
||
## Failure Handling
|
||
|
||
| Failure | Handling |
|
||
| --- | --- |
|
||
| user-service GetUser 失败 | gateway 返回错误,不用 IP 国家兜底 |
|
||
| 用户 `region_id` 为空 | 使用 `GLOBAL` 列表桶,除非产品要求强制选国家 |
|
||
| room-service Redis miss | 回源 MySQL 并修复缓存 |
|
||
| Redis 写失败 | 不回滚房间命令,记录日志或补偿任务 |
|
||
| MySQL 投影写失败 | 房间命令已提交时必须进入补偿队列 |
|
||
| 列表出现关闭房间 | `JoinRoom` 拒绝,客户端刷新列表 |
|
||
| 区域配置变更 | 影响新用户和用户重算;已有房间不自动迁移 |
|
||
|
||
## Implementation Plan
|
||
|
||
### Phase 1: MySQL Read Model
|
||
|
||
1. `room.proto` 新增 `RoomQueryService.ListRooms` 和列表消息。
|
||
2. `CreateRoomRequest` 增加 `visible_region_id`,由 gateway 填充。
|
||
3. `rooms` 增加 `visible_region_id`。
|
||
4. 新增 `room_list_entries` 表。
|
||
5. room-service 新增 `room/list` 或 `room/query` 包,封装列表 repository 和 service。
|
||
6. gateway 新增 `GET /api/v1/rooms`,先查 user-service,再调 room-service。
|
||
|
||
### Phase 2: Projection Updates
|
||
|
||
1. `CreateRoom` 插入列表投影。
|
||
2. `JoinRoom`、`LeaveRoom` 更新在线人数。
|
||
3. `MicUp`、`MicDown` 更新麦位占用。
|
||
4. `SendGift` 更新热度和排序分。
|
||
5. 增加投影补偿任务,避免命令成功但列表长期不更新。
|
||
|
||
### Phase 3: Redis Acceleration
|
||
|
||
1. 为 `hot/new` 增加 Redis ZSET。
|
||
2. 列表查询优先读 Redis room_id,再批量读 `room:card`。
|
||
3. Redis miss 或卡片缺失时回源 MySQL。
|
||
4. 增加后台 reconcile job,把 MySQL 投影定期修复到 Redis。
|
||
|
||
### Phase 4: Admin Controls
|
||
|
||
1. 新增管理端房间区域迁移命令。
|
||
2. 新增区域迁移审计表。
|
||
3. 支持按区域隐藏、封禁或置顶房间。
|
||
|
||
## Tests
|
||
|
||
必须覆盖:
|
||
|
||
| Case | Expected |
|
||
| --- | --- |
|
||
| `SG` 和 `MY` 属于同一区域 | 两个用户看到相同列表 |
|
||
| `SG` 和 `SA` 属于不同区域 | 两个用户看到不同列表 |
|
||
| 用户无区域 | 进入 `GLOBAL` 列表 |
|
||
| 客户端传伪造区域 | gateway 忽略客户端区域,只用 user-service 返回值 |
|
||
| 创建房间 | 写 `rooms.visible_region_id` 和 `room_list_entries` |
|
||
| 房主改国家 | 已创建房间列表区域不变 |
|
||
| Join/Leave | 列表在线人数最终更新 |
|
||
| SendGift | 热度和排序分更新 |
|
||
| Redis miss | MySQL 回源成功 |
|
||
| 列表房间已关闭 | `JoinRoom` 拒绝,客户端刷新 |
|
||
|
||
## Acceptance Criteria
|
||
|
||
- App 房间列表只展示当前用户 `region_id` 对应房间。
|
||
- 同一区域内不同国家用户看到同一套列表。
|
||
- 不同区域用户不会看到彼此区域房间。
|
||
- 房间列表查询不访问 Room Cell 内存集合。
|
||
- Room Cell 状态变更仍只走命令流水线。
|
||
- MySQL 可独立恢复完整房间列表读模型。
|
||
- Redis 故障不影响创建房间、进入房间和房间状态提交。
|