不能直接用expireafterseconds做分布式锁过期,因其依赖mongodb后台ttl扫描,不保证实时删除,易导致锁已过期却仍被误判占用;且findandmodify等操作无法原子性校验并获取锁,引发竞态。

为什么不能直接用 expireAfterSeconds 做分布式锁过期
因为 expireAfterSeconds 依赖 MongoDB 的 TTL 索引,它只在后台定期扫描过期文档并删除,不保证实时性——锁可能已过期但还存在,其他进程误判为“被占用”。更危险的是,TTL 删除是异步的,findAndModify 或 updateOne 无法原子性地“检查是否过期 + 占用锁”,导致竞态。
lockedAt 和 ttlSeconds 字段组合才是可靠方案
把过期逻辑从索引移到应用层判断,用两个字段协同:一个记录加锁时间(lockedAt),一个声明预期有效期(ttlSeconds)。每次争抢锁时,用原子操作同时校验和更新:
db.locks.updateOne(
{
_id: "payment:order_123",
$or: [
{ lockedAt: { $exists: false } },
{ lockedAt: { $lt: { $subtract: [new Date(), { $multiply: ["$ttlSeconds", 1000]} ] } } }
]
},
{
$set: {
lockedAt: new Date(),
ttlSeconds: 30,
ownerId: "worker-abc"
}
},
{ upsert: true }
)
-
lockedAt必须是Date类型,不能存字符串或秒级时间戳,否则无法用$subtract计算过期点 -
ttlSeconds存整数(如30),避免浮点误差;单位统一为秒,便于读写理解 - 查询条件里用
$subtract和$multiply是为了在服务端完成“当前时间 − 过期时长”计算,避免客户端时间偏差引入漏洞
加锁失败时必须检查 matchedCount 而非仅看返回结果
MongoDB 的 updateOne 返回对象里 matchedCount 才真实反映是否命中了可覆盖的旧锁。常见错误是只看 modifiedCount === 1 就认为加锁成功——但如果原锁已过期,matchedCount 是 1、modifiedCount 也是 1,这没问题;但如果 matchedCount === 0,说明锁被别人占着且未过期,此时必须退让。
- Node.js 驱动中,检查
result.matchedCount > 0才算抢锁成功 - 不要依赖
upsertedId判断:TTL 过期后文档被删,再次 upsert 会生成新_id,但锁语义上这是同一把锁,ID 变化不该影响业务逻辑 - 如果需要诊断,可在
$set中加updatedAt: new Date(),方便查锁最后活跃时间
释放锁必须用 owner 校验,不能无条件 deleteOne
分布式环境下,锁持有者可能崩溃,但其他进程不能擅自清除“看起来过期”的锁——因为时钟不同步或网络延迟,可能导致误删仍在工作的锁。正确做法是释放时带上 ownerId 精确匹配:
db.locks.deleteOne({
_id: "payment:order_123",
ownerId: "worker-abc",
lockedAt: { $gt: { $subtract: [new Date(), { $multiply: ["$ttlSeconds", 1000]} ] } }
})
- 这个条件确保只删“自己持有着且尚未自然过期”的锁,避免 A 进程删掉 B 进程刚续租成功的锁
- 不要省略
lockedAt时间检查:防止 ownerId 碰撞(比如两个 worker 恰好用了相同 ID) - 如果业务允许“强制抢占”,那应走另一套带版本号或租约递增的机制,而不是复用这套释放逻辑
MongoDB 分布式锁的可靠性不取决于字段名有多酷,而在于每次读写是否都在服务端完成原子判断。最容易被忽略的是时间计算必须在数据库内完成——任何把 new Date() - ttl * 1000 放在客户端算好再传进去的做法,都会因服务器与客户端时钟偏差而失效。











