
数据库专题16乐观锁与版本号——让文章编辑可合并、可追溯上一篇用隔离级别展示了并发冲突但仍没有解决用户体验编辑器到底应该提示什么恢复历史如何避免覆盖本篇把articles.version变成可验证的乐观锁设计 ETag、冲突响应和“恢复为新版本”的流程并把版本事件写入 outbox给后面的 Mongo 快照做好准备。上一篇练习讲解上一篇的 ETag 练习要用where id:id and version:expected_version更新成功才递增第二个并发请求得到 409/412不应该覆盖正文。for update适合短事务的强串行场景Serializable 冲突只有限重试重试必须建立全新事务。1. 版本号不只是计数器版本号表达“客户端编辑时看到的快照”与“数据库当前快照”的关系。它必须在每次正文或标题改变时递增标签、评论等关联数据是否影响文章版本要在产品规则中明确。本文只把正文元数据纳入版本点赞不会改变文章版本。altertablearticlesaddcolumnifnotexistsversionintegernotnulldefault1;altertablearticlesaddconstraintarticle_version_positivecheck(version0);如果旧表已有 NULL先回填再加非空迁移不能假设所有环境都是空数据库。2. 乐观更新函数fromsqlalchemyimporttextdefupdate_article(engine,article_id:int,user_id:int,expected:int,title:str,content:str)-int:基于期望版本更新返回新版本冲突时抛出 Conflict。withengine.begin()asconn:rowconn.execute(text( update articles set title:title,content:content,versionversion1,updated_atnow() where id:id and author_id:user_id and statusdraft and version:expected returning version ),{id:article_id,user_id:user_id,expected:expected,title:title.strip(),content:content}).first()ifrowisNone:currentconn.execute(text(select author_id,status,version from articles where id:id),{id:article_id}).mappings().first()ifnotcurrentorcurrent[author_id]!user_id:raisePermissionError(无权编辑)raiseRuntimeError(f版本冲突当前 v{current[version]}客户端 v{expected})new_versionrow.version conn.execute(text( insert into outbox_events(event_id,event_type,aggregate_id,payload) values (gen_random_uuid(),article.updated,:id, jsonb_build_object(article_id,:id,version,:version)) ),{id:article_id,version:new_version})returnnew_version数据库扩展pgcrypto提供gen_random_uuid()如果环境不允许扩展先在 Python 生成 UUID。冲突查询只用于区分 403、404 和 409不能在发现冲突后强行覆盖。3. ETag 和冲突响应defarticle_etag(article_id:int,version:int)-str:returnfarticle-{article_id}-v{version}defparse_if_match(value:str)-int:try:returnint(value.rsplit(-v,1)[1].strip())except(IndexError,ValueError):raiseValueError(If-Match 格式错误)HTTP 层检查If-Match与响应中的 ETag缺失时返回 428要求条件请求冲突返回 412。响应可以附带当前摘要让前端做三方合并不能返回另一个用户的草稿正文而造成信息泄露。4. 恢复历史为何生成新版本恢复流程读取 Mongoarticle_versions的目标快照 → PostgreSQL 事务检查作者和当前 version → 把快照正文写回并version1→ 写article.restoredoutbox。绝不把articles.version直接改回 3否则版本号会倒退之前的恢复和审计无法排序。第21篇会实现 Mongo 读取和幂等快照写入。5. 并发测试fromconcurrent.futuresimportThreadPoolExecutordefedit_once(expected):try:returnupdate_article(engine,1,1,expected,title,body)exceptRuntimeErrorasexc:returnstr(exc)withThreadPoolExecutor(max_workers2)aspool:resultslist(pool.map(edit_once,[2,2]))print(results)一个结果应为3另一个包含“版本冲突”。若两个都成功检查 SQL 是否遗漏version:expected若两个都失败确认测试文章确实是 draft 且 author_id1。验收与排错客户端 v2 更新 - 当前 v3ETagarticle-1-v3 旧 ETag 重试 - 412正文保持第一次更新 恢复历史 v1 - 新增 v4不覆盖 Mongo v1/v2/v3 非法 If-Match - 400/428不执行 UPDATE课后练习为article.updated事件增加标题/正文摘要正文过大时只传版本引用实现一个三方冲突提示结构测试恢复和普通编辑同时发生时只有一个更新成功。下一篇实战 SQLite 到 PostgreSQL 的迁移。