如何使用“_bulk_docs”+“_rev”实现乐观锁避免 Lost Update?

解读

国内互联网、金融、电商场景下面试官常把“并发写冲突”作为区分候选人对 NoSQL 深度理解的试金石。CouchDB 默认采用多版本并发控制(MVCC),每次写入都会生成新的 _rev,旧版本立即不可见。若两个客户端同时拿到同一文档的旧 _rev,后提交者会因 _rev 不匹配而被拒绝,天然具备乐观锁能力。_bulk_docs 是官方推荐的批量写接口,一次 HTTP 请求可携带 N 个文档,网络 RTT 少、吞吐高,正适合高并发场景。把两者结合起来,就能在一次往返内完成“检测-更新”闭环,既保证性能,又杜绝 Lost Update。

知识点

  1. MVCC 与 _rev_rev 格式为 <整数>-<哈希>,整数部分即版本号,每次成功写入自动 +1;哈希部分保证全局唯一。
  2. 乐观锁三要素:读时取 _rev → 改时带 _rev → 写时校验 _rev
  3. _bulk_docs 语义
    • all_or_nothing=false(默认):逐条校验 _rev,冲突文档返回 error: "conflict",其余正常写入。
    • new_edits=true(默认):CouchDB 自动生成新 _rev;若置为 false,则必须显式给出目标 _rev,用于复制场景。
  4. Lost Update 定义:T1 读 → T2 读 → T2 写成功 → T1 写成功,导致 T2 的更新被“覆盖”掉;借助 _rev 校验可让 T1 的第二次写失败,从而阻止覆盖。
  5. 国内高频误区
    • _rev 当业务字段手动维护,结果出现“幽灵冲突”;
    • 在微服务里把 _bulk_docs 当“事务”,期望回滚,但 CouchDB 无跨文档 ACID,只能保证单文档原子性;
    • 忽略 all_or_nothing=false 的局部成功特性,未对返回数组做二次校验,导致“部分更新”上线。

答案

  1. 读阶段:通过 GET /db/docId 拿到最新 _rev,例如 2-abc
  2. 改阶段:在本地内存修改文档内容,必须保留原 _rev
  3. 写阶段:组装 _bulk_docs 请求体
    POST /db/_bulk_docs
    Content-Type: application/json
    {
      "docs": [
        {
          "_id": "order123",
          "_rev": "2-abc",
          "status": "paid",
          "amount": 9999
        }
      ]
    }
    
  4. 解析返回:
    • 若返回数组中对应元素含 "ok": true 且给出新 _rev(如 3-def),说明乐观锁成功,无 Lost Update。
    • 若返回 "error": "conflict",说明其他客户端已抢先提交,当前更新被拒绝;此时应重读最新 _rev 再重试,或按业务策略合并差异。
  5. 批量场景:把多个文档的 _rev 一并带上,CouchDB 会逐条校验;部分冲突不影响其他文档,应用层需循环检查返回数组,对冲突项单独处理,保证最终一致性。

拓展思考

  1. 重试风暴:国内大促场景下 QPS 瞬间暴涨,冲突重试可能放大负载。可引入指数退避 + 随机抖动,或在网关层做令牌桶限流,把重试率压到 1% 以下。
  2. 业务级合并:对库存、优惠券等“可累加”字段,冲突后不必简单重试,而是读最新值做算术补偿(如库存扣减差额),再带新 _rev 二次提交,减少空转。
  3. 分层锁:若一次修改跨多个文档,且必须“要么全改要么不改”,可在顶层维护一条“聚合锁”文档,用其 _rev分布式信号量;各子文档更新时顺带带上该锁文档的 _rev,实现轻量级跨文档乐观锁
  4. 监控告警:在返回数组里统计 conflict 比例,接入 Prometheus + Grafana,冲突率 > 5% 即触发告警,提前发现热点行写倾斜。
  5. 与 PouchDB 离线同步:移动端先本地写,再同步到 CouchDB;同步过程同样依赖 _rev 冲突检测,面试时可延伸讨论“如何在离线优先架构里保持因果一致性”,展示对多端协同的深入理解。