后端系统工程 04:会话、成员与消息的持久化边界
后端系统工程 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 | |
核心定义摘自 examples/im/sql/schema.sql:
1 | |
各字段的含义:
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 | |
三项检查(重放、墓碑、成员)都在会话行锁之下执行。因此对同一会话的并发发送,它们观察到的是一个确定的顺序:后到的事务要等先到的提交,才能拿到锁;拿到锁后再读 events,在 READ COMMITTED 下每条语句看到的是语句开始时已提交的数据,所以先到者提交的行可见。
成员变更(join/leave)走同一把锁:先锁会话行,再分配序号、修改成员区间、写成员事件。所以“发送检查成员资格”与“成员被移除”在同一会话上是串行的:要么发送在前,消息序号小于 leave 的序号,属于该成员可见范围;要么移除在前,发送检查失败。不会出现消息序号大于 leave 序号、却由已离开的成员发出的情况。
4.3 读取历史
历史读取(sync,第 07 篇展开)按 seq > after 的范围查询,并用成员区间过滤:
1 | |
检查 04-history-uses-pk 记录了同类范围查询的执行计划:
1 | |
这个计划只说明在实验数据量(约 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 | |
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 接入,连接归属记录在哪里,以及旧连接的清理为什么不能删除新连接的路由。
参考资料
- PostgreSQL 16 文档,9.17 Sequence Manipulation Functions(
nextval的值在事务回滚后不归还,序列不能产生无空洞编号)。https://www.postgresql.org/docs/16/functions-sequence.html - PostgreSQL 16 文档,13.2.1 Read Committed Isolation Level(每条语句看到语句开始时已提交的数据;
FOR UPDATE等待后的重新求值)。https://www.postgresql.org/docs/16/transaction-iso.html - PostgreSQL 16 文档,13.3.2 Row-Level Locks。https://www.postgresql.org/docs/16/explicit-locking.html
- PostgreSQL 16 文档,11.8 Partial Indexes。https://www.postgresql.org/docs/16/indexes-partial.html
- PostgreSQL 16 文档,NOTIFY(通知在事务提交后才投递)。https://www.postgresql.org/docs/16/sql-notify.html






