后端系统工程 04:会话、成员与消息的持久化边界

本批导航:00 技术方案怎样接受验证|01 API 契约与业务不变量|02 负载模型与容量预算|03 单库基线与故障实验工具|04 持久化边界|05 长连接接入|06 发送确认|07 排序与增量同步|08 群消息扇出|09 多设备已读|10 网关故障与路由收敛|11 撤回与集成验收

从本篇开始,第 00–03 篇建立的方法落到一个逐步演进的最小即时通信工程上(代码在 examples/im/)。第一个问题是持久化边界:哪些状态必须在同一个事务里写入,哪些可以分开;哪个状态是权威来源,哪个只是派生。边界划错的后果往往要到故障时才显现——例如消息写入了,但请求键的记录在另一个存储里没写成功,客户端重发后就多出一条消息。

本篇确定会话、成员、消息三者的表结构,说明会话内序号在哪个锁下分配、请求键为何与消息放在同一行,以及成员资格为何用序号区间而不是一个“当前成员”标志表示。所有实验直接调用存储层,连接真实的 PostgreSQL 16,不经过网关。

1. 需求与不变量

本篇负责需求卡中的 R1(部分)、R2、R3(数据模型部分),以及第 01 篇契约中的 C1、C3、C4。

编号 不变量 违反时的表现
P1 同一会话内,已提交事件的序号从 min_seq 起连续,没有空洞,没有重复 客户端按“连续序号”判断是否漏消息(第 07 篇),空洞会让它永远等待一条不存在的消息
P2 同一请求键 (conv_id, sender_user, client_msg_id) 至多对应一个事件 重发产生重复消息
P3 成员区间互不重叠,同一用户在同一会话中至多有一个开放区间 可见性判定出现歧义
P4 成员变化本身是会话日志中的事件,与普通消息共用序号 无法回答“某条消息发出时,谁是成员”
P5 conversations.next_seq = 已提交事件的最大序号 + 1 下一次分配与已有序号冲突或留空

P4 是本篇最重要的设计决定,第 08 篇的群成员变更和第 11 篇的访问时校验都建立在它之上。

2. 规模假设

按第 02 篇的基线:峰值写入约 1 250 条/s,分布在大量会话上;单个会话的写入速率通常很低,但大群可能出现短时集中写入。保留期 30 天,单个会话的事件数可达数十万。

由此得到两个约束:序号分配的串行化范围只能是单个会话,不能是全局;历史查询必须能按 (会话, 序号) 范围走索引,不能扫描整表。

3. 数据模型

1
2
3
4
5
6
7
8
9
10
11
12
图 4-1  会话、成员区间与事件日志的关系
conversations (conv_id PK)
next_seq, min_seq, epoch, delivery_mode, recall_window_ms, retention_ms
│ 1
│
├──────────────< membership_periods (conv_id, user_id, from_seq) PK
│ to_seq NULL 表示开放;部分唯一索引保证每人至多一个开放区间
│
└──────────────< events (conv_id, seq) PK
kind ∈ {msg, recall, join, leave}
sender_user, client_msg_id, request_hash ← 请求键与指纹
部分唯一索引 (conv_id, sender_user, client_msg_id)

核心定义摘自 examples/im/sql/schema.sql:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
CREATE TABLE conversations (
conv_id text PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('direct', 'group')),
delivery_mode text NOT NULL DEFAULT 'push' CHECK (delivery_mode IN ('push', 'pull')),
next_seq bigint NOT NULL DEFAULT 1 CHECK (next_seq >= 1),
min_seq bigint NOT NULL DEFAULT 1 CHECK (min_seq >= 1),
epoch integer NOT NULL DEFAULT 1,
...
CHECK (min_seq <= next_seq)
);

CREATE TABLE membership_periods (
conv_id text NOT NULL REFERENCES conversations,
user_id text NOT NULL REFERENCES users,
from_seq bigint NOT NULL,
to_seq bigint,
PRIMARY KEY (conv_id, user_id, from_seq),
CHECK (to_seq IS NULL OR to_seq >= from_seq)
);
CREATE UNIQUE INDEX one_open_period ON membership_periods (conv_id, user_id) WHERE to_seq IS NULL;

CREATE TABLE events (
conv_id text NOT NULL REFERENCES conversations,
seq bigint NOT NULL,
kind text NOT NULL CHECK (kind IN ('msg', 'recall', 'join', 'leave')),
message_id uuid,
sender_user text,
sender_device text,
client_msg_id text,
request_hash text,
body text,
...
PRIMARY KEY (conv_id, seq)
);
CREATE UNIQUE INDEX events_idempotency ON events (conv_id, sender_user, client_msg_id)
WHERE client_msg_id IS NOT NULL;

各字段的含义:

  • next_seq:下一个可分配的序号。会话的“最大序号”(下文称 head)等于 next_seq - 1。
  • min_seq:保留策略清理后仍存在的最小序号(第 11 篇)。初始为 1。
  • epoch:会话纪元。只有在会话日志被整体重建(例如从备份恢复并重新编号)时才增加,用来让所有客户端游标失效(第 07 篇)。
  • message_id:服务端生成的全局消息标识(UUID),供跨会话引用使用。会话内的定位使用 (conv_id, seq)。
  • request_hash:请求指纹,用于区分合法重放与键冲突(第 01 篇)。

成员事件 join/leave 没有 client_msg_id,部分唯一索引不对它们生效。

4. 正常路径

4.1 创建会话

创建会话时,每个初始成员写入一个 join 事件。因此一个两人单聊创建后 head 为 2:join@1(u-ana)、join@2(u-ben)。两人的成员区间分别从 1 和 2 开始。这意味着 u-ben 看不到 join@1。这在语义上没有问题:u-ana 入群发生在 u-ben 入群之前。对单聊来说这显得多余,但它让单聊和群聊共用同一套可见性规则,不需要特殊分支。

4.2 发送

1
2
3
4
5
6
7
8
9
10
图 4-2  send 事务内的步骤与锁持有范围
BEGIN (READ COMMITTED)
├─ SELECT next_seq, epoch FROM conversations WHERE conv_id=? FOR UPDATE ← 会话行锁,持有到提交
├─ SELECT seq, message_id, request_hash FROM events WHERE (请求键) ← 重放?
├─ SELECT 1 FROM idem_tombstones WHERE (请求键) ← 已清理?(第 11 篇)
├─ SELECT 1 FROM membership_periods WHERE ... AND to_seq IS NULL ← 当前成员?
├─ INSERT INTO events(conv_id, seq=next_seq, ...)
├─ UPDATE conversations SET next_seq = next_seq + 1
├─ SELECT pg_notify('im_events', 'conv:seq') ← 提交后才投递(第 05 篇)
COMMIT

三项检查(重放、墓碑、成员)都在会话行锁之下执行。因此对同一会话的并发发送,它们观察到的是一个确定的顺序:后到的事务要等先到的提交,才能拿到锁;拿到锁后再读 events,在 READ COMMITTED 下每条语句看到的是语句开始时已提交的数据,所以先到者提交的行可见。

成员变更(join/leave)走同一把锁:先锁会话行,再分配序号、修改成员区间、写成员事件。所以“发送检查成员资格”与“成员被移除”在同一会话上是串行的:要么发送在前,消息序号小于 leave 的序号,属于该成员可见范围;要么移除在前,发送检查失败。不会出现消息序号大于 leave 序号、却由已离开的成员发出的情况。

4.3 读取历史

历史读取(sync,第 07 篇展开)按 seq > after 的范围查询,并用成员区间过滤:

1
2
3
4
5
6
SELECT ... FROM events e
WHERE e.conv_id=? AND e.seq>? AND e.seq<=?
AND EXISTS (SELECT 1 FROM membership_periods m
WHERE m.conv_id=e.conv_id AND m.user_id=?
AND e.seq>=m.from_seq AND (m.to_seq IS NULL OR e.seq<=m.to_seq))
ORDER BY e.seq LIMIT ?

检查 04-history-uses-pk 记录了同类范围查询的执行计划:

1
2
3
Limit  (cost=0.14..32.30 rows=50 width=8)
-> Index Only Scan using events_pkey on events (cost=0.14..67.04 rows=104 width=8)
Index Cond: ((conv_id = 'c-ab'::text) AND (seq > 100))

这个计划只说明在实验数据量(约 200 行)下优化器选择了主键范围扫描。在大表上计划可能不同,需要在代表性数据上重新确认。

5. 方案比较

5.1 序号怎样分配

方案 做法 连续性 串行化范围 结论
A. 全局数据库序列 nextval('seq') 有空洞:回滚不归还序号 无 违反 P1
B. 每会话一个序列 每个会话 CREATE SEQUENCE 同样有空洞,且序列对象数量随会话数增长 无 违反 P1
C. max(seq)+1 查询 插入前查最大值 并发下重复,依赖主键冲突重试 无锁时不确定 冲突率随并发上升
D. 会话行计数器 + 行锁 SELECT ... FOR UPDATE 后使用并递增 next_seq 连续:计数器与插入在同一事务,回滚一起回滚 单个会话 采用
E. 时间戳或雪花式标识 本地生成 只能排序,不能判断是否有遗漏 无 不满足第 07 篇的缺口检测

方案 A 的问题在 PostgreSQL 文档中有明确说明:为避免阻塞并发事务,nextval 获取的值即使事务回滚也不会归还,因此序列不能用来生成“无空洞”的编号。负对照 04-control-sequence-gaps 在 seq_events 表上复现了这一点:10 个事务各取一个序列值并插入,其中 3 个故意回滚,结果是 7 行,最大序号为 10。

方案 D 的代价是同一会话的写入被串行化,每个事务持有会话行锁直到提交。在第 02 篇的假设下,单个会话的写入速率很低,这个代价可以接受。对于写入极为集中的大群,锁持有时间直接决定该会话的写入上限;这个上限在本批中没有测量(NOT_RUN),因为单机实验的提交延迟不能代表目标环境的磁盘与复制延迟。

5.2 请求键存放在哪里

第 01 篇比较了内存缓存、全局唯一键和会话内唯一键。在持久化层面需要补充的一点是:请求键必须与消息行在同一个事务中提交。本实现更进一步,把请求键作为消息行的列,而不是另建一张表。好处是不存在“键表有记录但消息表没有”的中间状态;代价是请求键随消息一起被保留策略删除,因此需要墓碑表(第 11 篇)来覆盖清理之后的时段。

5.3 成员资格怎样表示

方案 做法 能回答的问题 不能回答的问题
当前成员表 (conv_id, user_id) 存在即为成员 现在谁是成员 某条历史消息发出时谁是成员;退群后能看哪些历史
成员表 + 加入时间 记录 joined_at 大致的历史可见范围 时间与序号不在同一时钟上,边界处有歧义
成员区间(按序号) [from_seq, to_seq],变更是日志事件 任意序号上谁是成员 —

用时间戳表示成员资格的问题在于:消息的 created_at 和成员变更的时间戳来自不同的事务,在同一毫秒内谁先谁后无法判定;即使使用数据库时钟,事务的开始时间与提交顺序也不一致。用序号表示则没有歧义,因为成员事件与消息在同一把锁下分配序号,序号顺序就是提交顺序。

6. 关键实现

序号分配和成员变更的公共部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// examples/im/src/im/Store.java(节选)
private long appendMembership(Connection c, String convId, String userId, String kind) throws SQLException {
long seq = allocate(c, convId); // 调用方已持有会话行锁
boolean open = exists(c, "SELECT 1 FROM membership_periods WHERE conv_id=? AND user_id=? AND to_seq IS NULL",
convId, userId);
if (kind.equals("join")) {
if (open) throw new ImException("ALREADY_MEMBER", "already a member");
exec(c, "INSERT INTO membership_periods(conv_id,user_id,from_seq) VALUES (?,?,?)", convId, userId, seq);
} else {
if (!open) throw new ImException("NOT_MEMBER", "not a member");
exec(c, "UPDATE membership_periods SET to_seq=? WHERE conv_id=? AND user_id=? AND to_seq IS NULL",
seq, convId, userId);
}
exec(c, "INSERT INTO events(conv_id,seq,kind,subject_user) VALUES (?,?,?,?)", convId, seq, kind, userId);
notifyEvent(c, convId, seq);
return seq;
}

leave 事件的序号被写入 to_seq,所以离开的成员能看到自己的 leave 事件,看不到之后的任何事件(第 08 篇的 08-leave-closes-interval)。重新加入时开一个新区间,两个区间之间的事件保持不可见。

发送路径的关键在于锁的顺序:先锁会话行,再做全部检查。如果把幂等检查放在加锁之前,两个并发的相同请求可能都查不到记录,然后依次拿锁、依次插入,第二次插入会撞上唯一索引,得到数据库错误而不是 dup=true。唯一索引保证了 P2 不被破坏,加锁顺序保证了回复正确。

7. 验证

全部检查在 evidence/im-04/result.json,运行方式为真实 PostgreSQL 16、测试进程内直接调用 JDBC,不经过网关。

类型 检查 预期 观测
正常 04-normal-history 首条消息 seq=3;u-ana 看到 join@1, join@2, msg@3,u-ben 看到 join@2, msg@3 PASS
边界 04-non-member-rejected 非成员 u-cai 发送得到 NOT_MEMBER,head 仍为 3 PASS
边界 04-unknown-conv-same-error 不存在的会话也返回 NOT_MEMBER PASS
边界 04-idempotency-mismatch 同一 cmid 配不同正文得到 IDEMPOTENCY_MISMATCH PASS
失败 04-concurrent-replay-one-row 32 个线程同时发送同一请求:1 行、1 个 mid、1 个 seq、恰好 1 个 dup=false PASS(rows=1, distinct_mid=1, distinct_seq=1, fresh=1)
负对照 04-control-check-then-insert-duplicates 在无唯一约束的表上“先查再插” 27 行(应为 1 行)
不变量 04-seq-gapless 200 个不同请求并发发送后 count = max(seq),next_seq = max + 1 PASS(204 / 204 / 205)
负对照 04-control-sequence-gaps 用数据库序列分配并故意回滚 3 次 7 行,最大序号 10
观测 04-history-uses-pk 范围查询走主键 Index Only Scan using events_pkey

两组负对照的作用各不相同。“先查再插”对照证明 32 个线程确实形成了竞争:同样的并发条件下,没有唯一约束的表写入了 27 行(查询与插入之间插入了 5 ms 的暂停来放大窗口;归档的上一次运行为 29 行,具体数值随线程调度变化,断言只要求大于 1);因此主检查中的“1 行”来自约束和锁,而不是来自偶然的串行执行。序列对照证明 P1 需要的不只是“有一个递增的数”,而是计数器与插入在同一事务内。

04-seq-gapless 中 204 的构成:2 个 join、1 条首发消息、1 条并发重放产生的消息,加上 200 条突发消息。

8. 故障与恢复

故障 持久状态 恢复
事务在插入前失败 无变化;会话行锁随回滚释放 客户端重发(第 06 篇)
事务在插入后、提交前失败 events 行与 next_seq 递增一起回滚,序号不留空洞 同上;03 篇冒烟的失败用例验证了回滚后 next_seq 不变
提交后进程崩溃 行已持久 重发得到 dup=true
两个相同请求并发 后到者在锁上等待,随后读到已提交行 返回 dup=true
持锁事务长时间不提交(例如网关在事务中被挂起) 该会话所有写入阻塞 依赖数据库连接断开时的回滚;可配置 idle_in_transaction_session_timeout,本批未配置也未测试(NOT_RUN)

最后一行是会话行锁方案的主要风险:一个卡住的事务会阻塞整个会话。网关进程被杀时,内核关闭套接字,PostgreSQL 检测到连接断开后回滚事务并释放锁;但如果网关进程存活而事务所在线程卡住(例如等待一个外部调用),锁会一直持有。本实现在事务内部不做任何网络调用(pg_notify 是数据库内部操作),就是为了缩小这个窗口。

9. 限制

  • 单个会话的写入吞吐受锁持有时间限制,目标环境上的数值未测量。
  • 执行计划只在小数据量下观察,未在保留期满的数据量下验证。
  • epoch 字段在本批中只由实验直接修改(第 07 篇),没有实现完整的“日志重建”流程。
  • 所有观测属于真实组件级(真实 PostgreSQL,单进程调用),不包含网络与多进程。

下一篇把存储层放到网关后面:设备怎样通过 WebSocket 接入,连接归属记录在哪里,以及旧连接的清理为什么不能删除新连接的路由。

参考资料