如何使用“_bulk_docs”+“_rev”实现乐观锁避免 Lost Update?
解读
国内互联网、金融、电商场景下面试官常把“并发写冲突”作为区分候选人对 NoSQL 深度理解的试金石。CouchDB 默认采用多版本并发控制(MVCC),每次写入都会生成新的 _rev,旧版本立即不可见。若两个客户端同时拿到同一文档的旧 _rev,后提交者会因 _rev 不匹配而被拒绝,天然具备乐观锁能力。_bulk_docs 是官方推荐的批量写接口,一次 HTTP 请求可携带 N 个文档,网络 RTT 少、吞吐高,正适合高并发场景。把两者结合起来,就能在一次往返内完成“检测-更新”闭环,既保证性能,又杜绝 Lost Update。
知识点
- MVCC 与 _rev:
_rev格式为<整数>-<哈希>,整数部分即版本号,每次成功写入自动 +1;哈希部分保证全局唯一。 - 乐观锁三要素:读时取
_rev→ 改时带_rev→ 写时校验_rev。 - _bulk_docs 语义:
all_or_nothing=false(默认):逐条校验_rev,冲突文档返回error: "conflict",其余正常写入。new_edits=true(默认):CouchDB 自动生成新_rev;若置为false,则必须显式给出目标_rev,用于复制场景。
- Lost Update 定义:T1 读 → T2 读 → T2 写成功 → T1 写成功,导致 T2 的更新被“覆盖”掉;借助
_rev校验可让 T1 的第二次写失败,从而阻止覆盖。 - 国内高频误区:
- 把
_rev当业务字段手动维护,结果出现“幽灵冲突”; - 在微服务里把
_bulk_docs当“事务”,期望回滚,但 CouchDB 无跨文档 ACID,只能保证单文档原子性; - 忽略
all_or_nothing=false的局部成功特性,未对返回数组做二次校验,导致“部分更新”上线。
- 把
答案
- 读阶段:通过
GET /db/docId拿到最新_rev,例如2-abc。 - 改阶段:在本地内存修改文档内容,必须保留原
_rev。 - 写阶段:组装
_bulk_docs请求体POST /db/_bulk_docs Content-Type: application/json { "docs": [ { "_id": "order123", "_rev": "2-abc", "status": "paid", "amount": 9999 } ] } - 解析返回:
- 若返回数组中对应元素含
"ok": true且给出新_rev(如3-def),说明乐观锁成功,无 Lost Update。 - 若返回
"error": "conflict",说明其他客户端已抢先提交,当前更新被拒绝;此时应重读最新_rev再重试,或按业务策略合并差异。
- 若返回数组中对应元素含
- 批量场景:把多个文档的
_rev一并带上,CouchDB 会逐条校验;部分冲突不影响其他文档,应用层需循环检查返回数组,对冲突项单独处理,保证最终一致性。
拓展思考
- 重试风暴:国内大促场景下 QPS 瞬间暴涨,冲突重试可能放大负载。可引入指数退避 + 随机抖动,或在网关层做令牌桶限流,把重试率压到 1% 以下。
- 业务级合并:对库存、优惠券等“可累加”字段,冲突后不必简单重试,而是读最新值做算术补偿(如库存扣减差额),再带新
_rev二次提交,减少空转。 - 分层锁:若一次修改跨多个文档,且必须“要么全改要么不改”,可在顶层维护一条“聚合锁”文档,用其
_rev做分布式信号量;各子文档更新时顺带带上该锁文档的_rev,实现轻量级跨文档乐观锁。 - 监控告警:在返回数组里统计
conflict比例,接入 Prometheus + Grafana,冲突率 > 5% 即触发告警,提前发现热点行写倾斜。 - 与 PouchDB 离线同步:移动端先本地写,再同步到 CouchDB;同步过程同样依赖
_rev冲突检测,面试时可延伸讨论“如何在离线优先架构里保持因果一致性”,展示对多端协同的深入理解。