可用unique索引+expireAfterSeconds=0实现分布式锁:插入带唯一_id和expireAt字段的文档,成功即获锁,TTL自动清理作兜底;续期需条件更新确保owner匹配且未过期。

用 unique 索引 + expireAfterSeconds 实现基础分布式锁
MongoDB 本身不提供原生分布式锁命令,但可以用文档唯一性约束配合 TTL 自动清理,模拟“抢锁—持有—自动释放”流程。核心是:插入一条带 _id(或唯一字段)和 expireAt 的文档,成功即获锁;失败说明已被占用。
常见错误是只建 unique 索引却不设 TTL,导致锁永不释放;或设了 TTL 却没在写入时显式指定过期时间,让 TTL 索引失效。
- 必须在锁文档中存一个精确的
expireAt字段(Date类型),不能依赖服务器时间推算 -
expireAfterSeconds索引要建在expireAt字段上,且值设为0(表示按字段值自动删除) - 插入时用
upsert: true+writeConcern: { w: "majority" }避免主从延迟导致重复抢锁
为什么不能只靠 findAndModify 做原子抢锁
findAndModify 看似能原子判断+写入,但它无法同时满足“检查过期”和“插入新锁”两个动作——因为过期状态由 TTL 后台线程异步清理,文档可能还存在但逻辑已失效。直接查 expireAt > new Date() 再 insert,中间存在竞态窗口。
真正可靠的模式是:用 insertOne 尝试写入唯一键文档,靠索引冲突判定是否抢锁成功。TTL 只负责兜底清理,不参与抢锁逻辑。
- 唯一键建议用业务标识 + 时间戳拼接,比如
"order_123456_1717021234",避免纯业务 ID 被复用时误判 - 不要用
$setOnInsert配合findAndModify模拟插入,它不阻止并发写入同一条记录 - 驱动版本需 ≥ 4.0,低版本
insertOne在唯一冲突时不抛出可识别的MongoError.code === 11000
锁续期(renew)必须用 findOneAndUpdate + 条件更新
业务执行时间不确定,需要延长锁有效期。不能简单删旧写新——这会暴露空窗期;也不能只改 expireAt 字段——得确保当前持有者仍是自己。
续期本质是条件更新:只有文档存在、且 ownerId 匹配、且未过期,才允许刷新 expireAt。
- 更新语句必须包含
{ ownerId: "xxx", expireAt: { $gt: new Date() } }双条件 - 使用
returnDocument: "before"检查返回结果是否非空,空则说明锁已丢失或被抢占 - 避免用
save()或全量替换,会清掉其他字段(如调试用的acquiredAt)
客户端异常退出时,仅靠 TTL 不够可靠
TTL 删除有延迟(默认 60 秒扫描周期),且依赖 MongoDB 后台线程。若进程崩溃前没来得及写入锁文档,或写入后立刻宕机,TTL 根本不生效——这时锁根本没产生,自然也无需清理。
真正危险的是:锁文档已写入,但业务逻辑卡死、没调续期也没主动释放,此时只能等 TTL 触发。60 秒等待对多数服务太长。
- 务必设置合理的初始 TTL,比如 30 秒,而不是盲目设成 1 小时
- 在业务入口加超时控制(如 Node.js 的
AbortController),确保锁操作本身不无限阻塞 - 生产环境建议搭配外部看门狗(如 Redis 记录锁心跳),TTL 只作保底,不作主要释放机制


















