19 KiB
Voice Room Region Room List Architecture
本文档定义 App 房间列表的区域化架构。目标是让同一区域内的国家看到同一套房间列表,不同区域看到不同列表,同时不破坏现有 Room Cell 单房间状态 owner 模型。
本文的区域隔离始终发生在单个 app_code 内。不同 App 即使使用相同国家、区域和 room_id,房间列表也必须互相不可见。
Scope
| Capability | Owner | Required Result |
|---|---|---|
| 国家和区域主数据存储 | user DB | 国家归属区域由服务端配置和计算 |
| 后台国家/区域管理 | hyapp-admin-server |
创建国家、启用/禁用国家、创建区域、启用/禁用区域属于后台管理后端能力 |
| App 国家/区域解析 | user-service |
App 注册、改国家和用户投影只读 enabled 国家与 active 区域映射 |
| 用户区域归属 | 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 |
同一区域内的国家看到相同列表;不同区域看到不同列表。
用户无区域归属时进入默认列表桶:
region_id = 0
region_code = GLOBAL
GLOBAL 不是地理区域,只是兜底列表桶。后续如果产品要求“未选国家不能看列表”,gateway 可以在入口直接拒绝,不需要改 room-service 列表模型。
Service Boundary
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
边界规则:
hyapp-admin-server是后台创建/启停国家和区域的 owner,不为这些后台动作新增 user-service RPC。user-service是 App 侧国家校验、区域解析和用户region_id投影的 owner。gateway-service只做鉴权、用户区域解析和协议转换。room-service是房间列表和房间状态的 owner。- gateway 调
user-service和room-service时必须透传同一个RequestMeta.app_code;room-service 的房间、列表、快照、命令日志和 outbox 查询都必须带app_code。 Room Cell不保存区域列表,不扫描全局房间。- 腾讯云 IM 只负责实时消息,不参与房间列表排序和区域可见性。
Request Flow
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 + active room_region_pins
S-->>R: RoomListItem[]
R-->>G: ListRoomsResponse
G-->>C: {code,message,request_id,data}
列表只是发现页。用户点击房间后仍然走:
gateway -> room-service.JoinRoom -> Tencent IM join group callback guard
因此列表允许秒级最终一致;真正能否进入房间由 JoinRoom 和 IM 守卫决定。
区域置顶只影响当前 visible_region_id 的公共发现列表排序。room_region_pins 保存 app_code + visible_region_id + room_id 的运营置顶关系;查询时只 join status=active 且 expires_at_ms > now_ms 的记录,排序优先级为:
- 有效区域置顶在普通房间之前。
- 置顶房间按
weight DESC, expires_at_ms DESC, room_id ASC。 - 普通房间继续按
hot的sort_score DESC或new的created_at_ms DESC。
置顶不进入 Room Cell、command log 或 snapshot;它是房间列表读模型的运营控制面。房间改区域后,旧区域置顶不会自动跨区域生效,后台需要在新区域重新建立置顶。
Room Region Ownership
CreateRoom
创建房间时绑定 visible_region_id。
推荐链路:
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(room_name, room_avatar, room_description?)
G->>U: GetUser(actor_user_id)
U-->>G: region_id, display_user_id
G->>G: generate room_id = app_code + uuid
G->>R: CreateRoom(room_id, room_short_id=display_user_id, visible_region_id=region_id, room profile)
R->>R: Create Room Cell + room_list_entries
R-->>G: RoomSnapshot
G-->>C: room
约束:
- 客户端不能提交
visible_region_id。 - 客户端不能提交
room_id;gateway 按当前app_code生成<app_code>_<uuid>作为房间长 ID。 room_short_id首版直接等于创建者当前display_user_id,用于搜索、分享和展示。- 同一个登录用户只能作为 owner 创建一个房间;room-service 按
RequestMeta.actor_user_id判定 owner,不能通过更换room_id创建第二个房间。 - 客户端必须提交
room_name和room_avatar;room_description只进入RoomExt。 visible_region_id来自创建者当前users.region_id。- 创建者没有区域时写
0,进入GLOBAL列表桶。 - 房间创建成功后,创建者改国家不自动修改房间区域。
- 用户
users.region_id允许变化的来源见 User Region Change Sources。无论是用户改国家、区域映射 rebuild,还是后台创建 BD Leader 导致用户区域变化,都不自动迁移已创建房间的visible_region_id。 - 管理端迁移房间区域必须走独立命令,例如
ChangeRoomVisibleRegion,并写审计。
Why Not Dynamic By Owner Region
不建议每次列表查询都按房主当前 region_id 过滤:
- 房主改国家会导致直播中房间突然从原区域消失。
- 列表查询会被迫 join user-service 或用户表,破坏 room-service 的读模型独立性。
- 历史房间的区域口径会随用户资料变化而漂移,不利于运营审计。
所以房间列表区域应该是房间属性,不是房主资料的实时派生字段。
Data Model
rooms
rooms 是房间恢复和基础元数据表。建议增加:
ALTER TABLE rooms
ADD COLUMN room_short_id VARCHAR(32) NOT NULL DEFAULT '' AFTER room_id,
ADD COLUMN visible_region_id BIGINT NOT NULL DEFAULT 0 AFTER status,
ADD UNIQUE KEY uk_rooms_short_id (app_code, room_short_id),
ADD KEY idx_rooms_region_status (visible_region_id, status);
字段语义:
| Field | Rule |
|---|---|
room_id |
对外房间长 ID,gateway 按 <app_code>_<uuid> 生成 |
room_short_id |
房间短 ID,首版等于创建者当前 display_user_id |
visible_region_id |
房间列表可见区域,0 表示 GLOBAL |
status |
房间生命周期状态,列表只展示 active/live 类状态 |
room_list_entries
room_list_entries 是房间列表读模型,避免列表查询反序列化 room_snapshots。
CREATE TABLE IF NOT EXISTS room_list_entries (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
room_id VARCHAR(64) NOT NULL,
room_short_id VARCHAR(32) NOT NULL DEFAULT '',
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,
locked BOOLEAN NOT NULL DEFAULT FALSE,
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,
PRIMARY KEY (app_code, room_id),
KEY idx_room_list_region_hot (app_code, visible_region_id, status, sort_score DESC, room_id),
KEY idx_room_list_region_new (app_code, visible_region_id, status, created_at_ms DESC, room_id),
KEY idx_room_list_owner (app_code, owner_user_id, updated_at_ms),
KEY idx_room_list_short_id (app_code, room_short_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
读模型字段只保存列表卡片需要的内容,不保存完整房间状态:
| Field | Source | Update Trigger |
|---|---|---|
room_id |
CreateRoom | 创建房间 |
room_short_id |
owner display_user_id |
创建房间 |
visible_region_id |
creator region_id |
创建房间或管理端迁移 |
owner_user_id |
RoomMeta | 创建房间 |
host_user_id |
RoomState | 创建房间、TransferRoomHost |
title |
RoomState.RoomExt["title"] |
创建房间 |
cover_url |
RoomState.RoomExt["cover_url"] |
创建房间 |
mode |
RoomMeta/RoomState | 创建房间或模式变更 |
status |
RoomState | 创建、关闭、封禁、恢复 |
locked |
RoomState.RoomPasswordHash != "" |
SetRoomPassword |
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:
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 计算:
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
GET /api/v1/rooms?tab=hot&cursor=&limit=20
GET /api/v1/rooms?tab=new&cursor=&limit=20
响应继续使用 gateway envelope:
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"rooms": [
{
"room_id": "lalu_room_xxx",
"im_group_id": "lalu_room_xxx",
"title": "Live Room",
"cover_url": "https://cdn.example/room.png",
"mode": "voice",
"status": "active",
"heat": 1000,
"online_count": 26,
"seat_count": 15,
"occupied_seat_count": 2
}
],
"next_cursor": ""
}
}
请求规则:
tab默认hot。limit默认20,最大50。cursor使用不透明字符串,客户端不能解析或拼装。- gateway 从 access token 得到
user_id,再查 user-service 获取region_id。 - 如果 user-service 查询失败,gateway 应返回错误,不应该用 IP 国家临时兜底。
im_group_id当前等于room_id,只用于客户端准备腾讯 IM 房间群;真正入群必须等JoinRoom成功后由 IM 回调守卫校验 presence。
gRPC
建议新增查询服务,不混入 RoomCommandService:
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;
string app_code = 13;
string room_short_id = 14;
bool locked = 15;
}
message ListRoomsResponse {
repeated RoomListItem rooms = 1;
string next_cursor = 2;
}
兼容规则:
- 新增 proto 字段只能追加,不能复用字段号。
visible_region_id使用int64,0表示GLOBAL。- 不要把
country传给 room-service 列表接口;国家到区域的映射不属于 room-service。 app_code只从RequestMeta来;客户端不能在列表 query/body 里自报另一个 App。
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 内存。 - 公共列表和带
query的搜索列表都从同一投影读取locked;它只表达是否需要入房密码,不携带密码明文或哈希。
Consistency Model
房间列表采用最终一致。
允许:
- 列表在线人数短暂落后。
- 房间关闭后短时间仍出现在列表。
- 热度和排序秒级延迟。
不允许:
- 用户看到其他区域专属列表。
- 客户端通过伪造
region_id越权获取列表。 - 列表查询直接访问 Room Cell 全量内存。
- Redis 成为唯一数据源。
点击列表进入房间时必须再次校验:
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 拒绝,客户端刷新列表 |
| 用户区域变化 | 包括用户改国家、区域配置 rebuild、后台创建 BD Leader 迁移用户区域;已有房间不自动迁移 |
| 区域配置变更 | 影响新用户和用户重算;已有房间不自动迁移 |
Implementation Plan
Phase 1: MySQL Read Model
room.proto新增RoomQueryService.ListRooms和列表消息。CreateRoomRequest包含visible_region_id和房间展示资料;区域由 gateway 填充,room_name由客户端必填。rooms增加visible_region_id。- 新增
room_list_entries表。 - room-service 新增
room/list或room/query包,封装列表 repository 和 service。 - gateway 新增
GET /api/v1/rooms,先查 user-service,再调 room-service。
Phase 2: Projection Updates
CreateRoom插入列表投影。JoinRoom、LeaveRoom更新在线人数。MicUp、MicDown更新麦位占用。SendGift更新热度和排序分。- 增加投影补偿任务,避免命令成功但列表长期不更新。
Phase 3: Redis Acceleration
- 为
hot/new增加 Redis ZSET。 - 列表查询优先读 Redis room_id,再批量读
room:card。 - Redis miss 或卡片缺失时回源 MySQL。
- 增加后台 reconcile job,把 MySQL 投影定期修复到 Redis。
Phase 4: Admin Controls
- 新增管理端房间区域迁移命令。
- 新增区域迁移审计表。
- 支持按区域隐藏、封禁或置顶房间;置顶由
room_region_pins按区域独立维护。
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 故障不影响创建房间、进入房间和房间状态提交。