PostgreSQL 事务:MVCC、锁与幂等预留
本轮仅做正文和源码静态审阅,以下命令、SQL、集成测试及恢复流程均未执行。过去真实运行记录保留在
migration/knowledge-rebuild/m4-next/postgres/verification.md及其原始日志;历史结果不证明修改后的示例运行通过。
MVCC 与隔离级别
前置:表约束。事务是一组一起提交或回滚的操作;MVCC 使用多个行版本让读取者根据快照判断可见性,普通读通常不阻塞写,写写冲突仍会等待。MVCC 不是“不需要锁”,也不是“同一事务必定看到不变数据”。
默认 Read Committed 为每条语句取快照。事务A读库存5,B更新为4并提交,A下一条SELECT可读到4。Repeatable Read 在事务首条非事务控制语句建立快照,A两次普通SELECT仍读到5,事务结束后才能看新值。PostgreSQL 的 Read Uncommitted 等同 Read Committed;Repeatable Read 防止幻读,但仍可能有序列化异常。Serializable 用于要求结果等同某个串行顺序的场景,应用必须准备重试整个事务。官方隔离说明
下面是分配到两个 psql 窗口的操作时间线,不能把整个代码块粘贴到同一窗口。先 verify 重置数据(book=3),两个窗口均连接 postgres_lab;B 必须处于默认自动提交状态。A 先 BEGIN 和第一次 SELECT,B 再 UPDATE,最后 A 第二次 SELECT。
-- A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT stock FROM products WHERE sku='book';
-- B(单独窗口,默认自动提交)
UPDATE products SET stock=stock+1 WHERE sku='book';
-- A
SELECT stock FROM products WHERE sku='book';
COMMIT;A 的两次结果均为3,B 更新为4。A 提交后再执行 SELECT 得4。重新 verify 重置后将 A 改成 READ COMMITTED,第二次读为4。自动化测试用两个独立连接按这个时间线运行,不用 sleep 猜竞争先后。序列取号的增长不会随事务回滚,主键有空洞不等于数据丢失。
行锁、有条件 UPDATE 与超时
“先 SELECT 库存,再用程序计算新库存 UPDATE”可能丢更新。使用单条 UPDATE products SET stock=stock-%s WHERE sku=%s AND stock >= %s RETURNING stock 把判定和扣减放一起。并发更新同一行时等待前一事务,Read Committed 会对更新后的行重新检查 WHERE;库存不够时返回0行。返回0行是业务拒绝,不是连接失败。
需要先读后做多个决策时可用 SELECT ... FOR UPDATE 锁定目标行,并保持事务短小。所有事务按一致的商品顺序加锁有助减少死锁。历史测试中 A 持有book行锁,B设置 SET lock_timeout='150ms' 后写同一行,真实得到 55P03。这个超时适合实验,生产值应根据业务时延选择。
40P01 是死锁,40001 是序列化失败,可在应用边界有限次数、带退避地重试整个事务;不要仅重放事务中最后一条语句。55P03 要区分资源繁忙、NOWAIT或锁超时,先判断业务是否允许重试。不要在事务内等待用户输入、外部HTTP或长计算。
ON CONFLICT 与幂等参数
幂等键通过 reservations.request_id 主键实现。INSERT ... ON CONFLICT (request_id) DO NOTHING RETURNING request_id 首次返回一行;重复键返回0行,再读原请求的 sku和quantity。相同参数返回 replayed=true,不再次扣库存;相同键换参数必须报错,不能把另一次购买误认为已处理。INSERT 官方文档
Read Committed 下并发唯一键冲突可能等待另一事务结果,本条 INSERT 看不到的提交可能在下一条SELECT看到。因此本实现依赖默认 Read Committed;调用者若改为 Repeatable Read/Serializable,必须接住序列化失败并从完整业务边界重试。测试规格要求8个同键请求只有1次扣减、7次重放;8个不同请求抢5件库存只有5次成功,最终库存0。历史测试有这两项结果;本轮给同键请求也增加屏障,便于同时起跑,但线程调度仍不能保证所有数据库语句在同一时刻发生。
先插入预留,再扣库存;若扣减失败,两者必须一起回滚。反例移除事务后,会留下pen的预留记录但没有成功扣库存,历史回滚测试真实报 1 != 0 并退出1。通过数据库约束替代不了多语句事务。
事务所有权与 SAVEPOINT
接收已有连接的库函数不能随意 conn.rollback(),否则会撤销调用者之前的修改。Psycopg conn.transaction() 在空闲连接开始事务,在调用者已开启事务时创建 SAVEPOINT;函数失败只回滚本次保存点,调用者可以捕获错误后继续。函数成功也不能替调用者提交外层事务。Psycopg 事务管理
本项目使用 autocommit=True 避免普通查询悄悄留下长事务,reserve 显式开事务。测试先写note='caller',再让内部预留失败,捕获后 note仍在且外层可提交;另一个测试让内部预留成功后外层抛异常,确认库存和预留一起撤销。事务上下文的异常必须传播出当前保存点,再由外层捕获,不要在失败的SQL状态中继续查询。
练习:如果在 reserve 的 conn.transaction() 块内加入 conn.commit(),会怎样?提示:先区分“驱动拒绝破坏事务上下文”和“手动管理事务时越权提交”,再对照外层回滚测试的异常类型。解答见复习。中文参考核对自postgres-cn 文档翻译项目,以本页链接的英文18手册为语义依据,翻译不作为本轮技术结论的唯一来源。
继续阅读
- 知识专题
- 应用实践
- 应用实践