06.07 事务与并发异常:成员退出时还能发送吗
返回第六卷 · 四表前置:06.01 · SQL 前置:06.02 · Go 访问前置:06.10
06.07 事务与并发异常:成员退出时还能发送吗
DeepTutor 初稿经技术与教学审阅后的静态课程。事务时序、SQL、错误和结果均是虚构纸上推演;本次没有运行数据库、Go、IM 服务或站点。PostgreSQL 与 MongoDB 语义按所列官方资料核对,教学四表不代表 OpenIM 的实际存储实现。
一、先规定业务顺序,再问数据库能否守住
虚构的 u-a 原是 c-a 成员,准备提交消息 m-a;同时另一请求让 A 退出 c-a。06.01 的 members 当前关系、06.02 的 SQL 和 06.10 的 Go 查询都能分别完成,但两条请求并发时,先查到“仍是成员”并不等于后来写消息时仍满足规则。先写业务合同:本章要求发送决定与退群决定有明确先后顺序。若发送先被接受,随后退群,消息可以保留;若退群先完成,发送应拒绝。这个顺序必须由共同的受控边界建立,不能从两个请求的客户端发起时间猜测。
还有三个结果必须分开:数据库事务提交消息;A 客户端收到服务端回答;B 的某台设备收到消息。事务最多控制其覆盖的数据库读写,不会自动把 HTTP 响应和设备推送装进同一原子步骤。若数据库提交成功但响应在返回途中丢失,A 仍会看到结果未知;若数据库提交成功但 B 离线,设备交付仍未完成。
| 第一遍 | 第二遍 | 交付物 |
|---|---|---|
| 事务边界、ACID、成员退出交错 | 隔离异常、Go Tx、MongoDB 多文档与提交未知 | 两条时间线、一张隔离/重试选择表、一份已知/未知/下一证据 |
二、ACID 是事务性质,不是四个自动业务承诺
事务把一组相关数据操作作为一个数据库工作单元。假设未来教学方案在同一数据库里要“验证成员→写 messages→更新会话摘要”,途中第二步失败时,不能留下看似完整的摘要。四个常见字母是:
| 性质 | 初学者可把它理解为 | 本题不能直接推出 |
|---|---|---|
| A:Atomicity 原子性 | 事务内成功提交的变更作为整体生效;失败回滚不留下半个事务的已提交状态 | 外部 WebSocket 推送、邮件或 HTTP 回应也跟着回滚 |
| C:Consistency 一致性 | 提交后应满足已定义并正确实现的约束与业务不变量 | 数据库自动懂得“退群者不能发言”“B 已读” |
| I:Isolation 隔离性 | 并发事务互相可见和干扰到什么程度由隔离级别及实现决定 | 只要写了 BeginTx,所有竞态都消失 |
| D:Durability 持久性 | 数据库按其配置和故障模型承诺已成功提交结果可恢复 | 跨节点、跨设备或客户端已经收到同一消息 |
“一致性”尤其容易误读。06.01 的外键能证明 sender_id 指向已存在用户,不能证明该用户在提交时有会话成员资格;若规则没有通过事务读写与约束真正守住,数据库可以原子地提交一条业务上不该出现的消息。D 也不是“调用过 INSERT”就成立:至少要区分语句执行、事务提交和在特定数据库配置下对崩溃的承诺,06.09 再深入日志/刷盘/恢复。
Go 的 database/sql 用 db.BeginTx(ctx, ...) 建立 *sql.Tx,相关查询和写入都要用这个 tx,最后 Commit 或 Rollback。若在事务中途改用 db.ExecContext,那条语句可能走另一连接,不受同一 Tx 控制。若 Commit 返回成功,才把这组数据库变更按本次事务视为已提交;对外部设备结果仍需另查。
三、读已提交下,一次成员检查怎样变旧
先采用 PostgreSQL 默认的 Read Committed(读已提交) 作纸上模型:每条普通 SELECT 看该语句开始前已提交的数据;同一事务的下一条语句可以看到后来别的事务刚提交的变化。四表中 messages.sender_id 外键只连到 users,所以消息插入本身不会自动与当前 members 行冲突。
| 时间 | 发送事务 T1 | 退群事务 T2 | 纸上状态 |
|---|---|---|---|
| t0 | — | — | (c-a,u-a) 当前有效 |
| t1 | BEGIN,查询当前成员:有效 | — | T1 得到旧的许可证据 |
| t2 | — | UPDATE members SET left_at=... | T2 改同一成员行 |
| t3 | — | COMMIT | 退群已提交 |
| t4 | INSERT messages(m-a,c-a,u-a,...) | — | 用户与会话外键仍可满足 |
| t5 | COMMIT | — | 消息可能已写入,虽退群先提交 |
这个结果不是“数据库事务坏了”;T1 只读了成员行,T2 更新它,T1 后续插入另一张表。若业务合同把“是否可发”定义为 t1 的检查时刻,可能允许;本章选的合同要求发送/退群形成受控顺序,单纯把 T1 的读写放进一个 Read Committed 事务不足以建立该顺序。
一种 PostgreSQL 教学策略是在 T1 检查活跃成员时锁住同一成员行,T2 退出也更新该行;若 T1 先取得锁,T2 等 T1 提交后才能退出,顺序为“发→退”;若 T2 先完成,T1 重新看状态后应拒绝。SELECT ... FOR UPDATE 是后续 06.08要细讲的手段,所有相关退出路径也必须参与同一规则。锁一行不自动解决“成员行尚不存在”“两个管理员不能同时退出”等跨行条件。
Serializable(可串行化)能让成功提交的数据库事务等效某种串行顺序,冲突时可能要求整笔重试;它并不替产品定义“客户端点了按钮的绝对时间”或“谁先收到回应”,也不能单凭设置级别就保证你想要的提交时间顺序。先写业务顺序,再选冲突数据、锁/约束与隔离方案。
四、四种并发异常,用同一套时间线理解
丢失更新: 假设会话摘要里有 message_count=4。T1、T2 都先读到 4,各自在应用里算 4+1=5,随后分别写常量 5;最后可能是 5,而两次不同消息的预期是 6。把更新表达为数据库中的 SET message_count = message_count + 1 可避免这个单行常量覆盖例子;若计数涉及多行或其他条件,仍要分析事务和冲突。不要把“有锁/有事务”当作无需检查的结论。
不可重复读: T1 在一笔事务里第一次读 members.left_at=NULL;T2 更新并提交退出;T1 第二次读同一行,看见 left_at 有值。读已提交允许同一事务两次普通 SELECT 看不同版本。幻读: T1 第一次查 c-a 的当前成员集合有两行;T2 新增一个成员并提交;T1 用同一条件再查,得到三行。变的是满足条件的行集合,而不是原有一行的某个值。
写偏斜: 另设会话规则“始终至少有一位管理员”。A、B 都是管理员;T1 读到 B 仍是管理员,决定 A 退出;T2 读到 A 仍是管理员,决定 B 退出。两笔事务改的是不同的行,最终可能一个管理员也不剩。单纯让每笔事务看到稳定快照,仍可能让它们都基于旧的“还有另一位”判断而提交;要使不变量可守住,需进一步序列化冲突、锁定共同资源或设计可约束的表示。06.08将拆解锁与 MVCC。
脏读是读到别的事务尚未提交的修改。它是一种标准术语,但PostgreSQL 不允许脏读:即使请求 Read Uncommitted,PostgreSQL 也按 Read Committed 执行。不要背一张不分数据库的隔离表后声称本例可在 PostgreSQL 读到未提交的退群。
五、隔离级别:标准术语与 PostgreSQL 实际行为
隔离级别描述并发事务可见性和允许的异常;不同数据库即使同名也可能实现不同。按 PostgreSQL 当前文档,本章只取最必要的边界:
| 名称 | PostgreSQL 中本题要记住的行为 | 仍需处理 |
|---|---|---|
| Read Uncommitted | 实际按 Read Committed 处理;不发生脏读 | 不等于“允许看未提交行” |
| Read Committed | 默认级别;每条语句读取开始前已提交的数据 | 两次 SELECT 可变;成员检查可能过时 |
| Repeatable Read | 同一事务读稳定快照;PostgreSQL 实现也不出现标准所称幻读 | 仍可能有无法等效串行执行的异常;更新冲突可要求重试 |
| Serializable | 成功提交的事务整体等效某个串行顺序 | 可能报串行化失败;应用须整笔重算并有界重试 |
不要把更强级别理解成“只是更慢、一定不报错”:PostgreSQL 为阻止不允许的结果,可能中止一笔事务并要求从事务开始重新执行所有相关读写。也不能将一个事务的旧判断结果直接拿来重试最后一条 INSERT;重试时成员资格、管理员人数和消息序号都可能已变。隔离级别对数据库内操作起作用,不能原子化数据库之外的 WebSocket 发送或客户端 HTTP 接收。
上表是 PostgreSQL 的特定行为;本章 MongoDB 对照也不能照搬这个四级表。学习者以后在隔离环境实现时,应核对实际数据库、驱动版本、配置及各查询/更新方式。这里没有做任何运行时隔离实验。
六、Go Tx、整笔重试与提交结果未知
06.10 已认识 *sql.DB 与 *sql.Tx 的职责。纸上 Go 流程是:BeginTx(ctx, opts) 得到 Tx;立即安排失败路径的 Rollback;所有相关成员检查、序号分配、消息写入和摘要更新都通过同一 Tx;业务检查不通过则返回并回滚;全部满足才调用 Commit。Go 官方文档也明确提示:不要在同一用例里混用 Tx 方法与 db 的非事务方法,或绕过 database/sql 的 Tx API 自己拼 SQL BEGIN/COMMIT。
若数据库给出明确的可重试并发冲突,应在有限次数和总体期限内重试整笔事务,从新的成员/会话状态重新决策。回调或重试循环里不应直接向 B 的设备推送、发送邮件、打印“已送达”或生成每次都不同的业务消息 ID;否则数据库回滚/重试无法撤销这些外部动作。更深的可靠交付在 S5 单独设计。
另一类风险是提交响应丢失:调用 Commit 时连接断开,Go 看到错误,但数据库可能已经提交,也可能没有。只有“明确被数据库拒绝”的错误与“提交结果不明”的错误不能合并为“没保存”。本章的纸上策略是沿同一稳定 message_id=m-a 或受控操作标识查询权威状态,在仍未知时向客户端明确返回未知并按后续幂等合同处理;不要盲目换新 ID 重发。唯一键只是建立可查询的身份,完整重试语义仍要设计。
七、MongoDB:单文档原子性与多文档事务
MongoDB 对单个文档的写操作有原子边界;这不意味着“一次操作更新 members 文档、插入 messages 文档、更新会话摘要”天然是一个原子事务。若业务必须把多文档改动一起提交,可使用官方支持的多文档事务,Go 驱动通过 Client 创建 Session,在会话中执行并提交;具体读/写关注、集群配置和故障语义按所用版本核对。长期保留无界 IM 历史仍需要独立消息文档或其他有界结构,不因为事务可用就全部嵌入一个会话文档。
MongoDB Go 驱动的 WithTransaction 便于组织事务,但回调在某些可重试错误下可能重新执行;因此回调内也不能无条件做设备推送等外部副作用。官方文档还区分 UnknownTransactionCommitResult:提交可能成功却返回错误,重试提交动作与重新执行业务事务不是一回事。一次数据库提交也不等于 A 收到 200 或 B 设备送达,仍须分别保留证据。这里不套用 PostgreSQL 的 Read Committed/Repeatable Read 表来描述 MongoDB。
| 纸上路径 | 最小原子边界 | 尚需业务设计 |
|---|---|---|
| 单文档更新 | 该文档的一次原子修改 | 其他成员/消息文档与设备状态 |
| 多文档事务 | 被正确纳入同一会话事务的文档改动 | 事务回调重试、提交未知、外部副作用 |
| SQL 教学 Tx | 正确纳入同一数据库 Tx 的语句 | 隔离冲突、身份/序号约束、提交未知 |
八、交付时间线与分层练习
学习者交付:T1 发送/T2 退群的 t0–t5 表,标出读写和提交;message_count=4 的丢失更新反例;“两位管理员同时退出”的写偏斜反例;一份 PostgreSQL Tx 控制流和 MongoDB 单/多文档边界对照。每一项都写“当前已知、当前未知、下一份证据”,尤其不能把“事务已经调用 Commit”写成“客户端和设备都确认”。本章未运行任何数据库或 Go 程序。
分层练习:先答,再展开反馈
1. 数据库事务能把 B 的设备推送一起回滚吗?
不能。普通数据库事务只控制纳入其中的数据库操作。
2. Atomicity 在本题主要要求什么?
同一事务内的数据库变更不应只提交一半。
3. Consistency 会自动知道“退群者不能发送”吗?
不会。业务不变量需要被明确定义并用约束和事务流程守住。
4. Isolation 与“写了 BeginTx”是同一件事吗?
不是。隔离行为还取决于级别、数据库实现和具体读写。
5. Durability 能证明 B 已读吗?
不能。数据库恢复与设备展示/用户阅读是不同确认点。
6. T1 先查成员有效,T2 后退群提交,T1 再写消息,这个交错在读已提交下可能吗?
可能。T1 的旧读结果不会自动阻止 T2 修改成员行。
7. 若合同按“发送决定先于退群决定”排序,还需要什么?
需要两条路径共享的受控冲突边界,例如对同一成员行协调读写顺序。
8. 两事务都读 count=4 并各写常量 5,最终可能怎样?
最终为 5,丢失一次应得到 6 的更新。
9. 单行 `SET count=count+1` 为什么比应用先读再写常量好?
增量在数据库当前行上执行,可避免该特定常量覆盖竞态。
10. 同一行在一笔事务两次 SELECT 中变值叫什么?
不可重复读。
11. 同一成员谓词第二次多出一行叫什么?
幻读,变化的是满足条件的行集合。
12. 两位管理员各看见另一位,随后各自退出导致零管理员是什么?
写偏斜;两笔事务分别改不同的行却一起破坏跨行不变量。
13. PostgreSQL 的 Read Uncommitted 会读到别人的未提交退群吗?
不会。PostgreSQL 实际按 Read Committed 处理。
14. PostgreSQL 默认的 Read Committed 两次普通 SELECT 一定相同吗?
不一定。各语句可读取不同的已提交快照。
15. PostgreSQL Repeatable Read 还有标准意义的幻读吗?
该实现不出现,但仍可能发生串行化异常。
16. Serializable 冲突失败后只重试最后一条 INSERT 合适吗?
不合适。要从事务开始重新读取并判断,且有期限与次数边界。
17. Tx 中途调用 `db.ExecContext` 有什么问题?
它可能走另一连接,相关语句不在这笔 Tx 中。
18. 数据库事务重试回调里能直接推送 WebSocket 吗?
不应如此。回调可能重跑,数据库回滚也无法撤销设备推送。
19. Commit 时网络断开能直接断言消息没保存吗?
不能。结果可能未知,需按稳定消息/操作身份查询权威状态。
20. MongoDB 单文档原子修改会自动覆盖另一个消息文档吗?
不会。跨文档变化需另行设计,可在适用条件下使用多文档事务。
21. `UnknownTransactionCommitResult` 表示一定失败吗?
不表示。提交可能已成功;要区分重试提交与重执行业务事务。
22. 事务提交成功但 A 超时,B 离线,分别该怎样表述?
数据库提交有其证据;A 是否收到回应未知;B 设备交付尚未确认。
来源与下一步
- Go:执行事务:
BeginTx、同一 Tx 的读写、Commit/Rollback与资源责任。 - PostgreSQL:事务教程与隔离级别:读已提交、稳定快照、可串行化与重试。
- MongoDB:单文档原子性、Go 驱动事务与应用中的提交重试:单/多文档边界和未知提交结果。
按学习路线,下一步进入09.05 数据访问与迁移,把事务判断接回应用用例;06.08 锁与 MVCC与06.09 日志与崩溃恢复在 S4 深化;06.04–06.06 已完成页、索引与查询计划前置。离开本章前,应能用时间线与业务顺序解释一条消息为何可写、为何可能被拒绝,以及提交后还有哪些结果未知。