一、为什么需要分布式锁
在分布式系统中,多个服务实例可能同时操作共享资源,比如修改数据库的同一条记录。如果没有并发控制,就可能出现数据不一致的问题。举个常见的例子:电商系统中的库存扣减。如果两个用户同时下单购买最后一件商品,系统可能会错误地认为库存充足,导致超卖。
这时候就需要分布式锁来协调多个节点的操作,确保同一时间只有一个服务实例能执行关键操作。传统单机锁(比如Java的synchronized)在分布式环境下会失效,因为锁状态无法跨进程共享。
二、MongoDB实现分布式锁的核心思路
MongoDB实现分布式锁主要依赖三个特性:
- 原子操作:使用
findAndModify或事务保证操作的原子性 - TTL索引:通过自动过期机制防止死锁
- 唯一索引:确保锁的唯一性
基本流程是这样的:
- 尝试向特定集合插入一条包含唯一标识的文档(相当于获取锁)
- 如果插入成功就获得锁
- 操作完成后删除文档(释放锁)
- 如果插入失败则等待或重试
三、完整实现示例(基于Node.js)
下面是一个可直接在生产环境使用的实现(需要MongoDB 4.0+支持事务):
const { MongoClient } = require('mongodb');
class MongoDBLock {
/**
* 构造函数
* @param {string} mongoUri - MongoDB连接字符串
* @param {string} dbName - 数据库名
* @param {string} collectionName - 集合名(默认'locks')
* @param {number} ttlSeconds - 锁自动释放时间(秒)
*/
constructor(mongoUri, dbName, collectionName = 'locks', ttlSeconds = 30) {
this.client = new MongoClient(mongoUri);
this.dbName = dbName;
this.collectionName = collectionName;
this.ttlSeconds = ttlSeconds;
this.lockCollection = null;
}
// 初始化集合(创建TTL索引)
async init() {
await this.client.connect();
const db = this.client.db(this.dbName);
this.lockCollection = db.collection(this.collectionName);
// 创建TTL索引(自动删除过期文档)
await this.lockCollection.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: this.ttlSeconds }
);
// 创建唯一索引确保资源唯一性
await this.lockCollection.createIndex(
{ resourceId: 1 },
{ unique: true }
);
}
/**
* 尝试获取锁
* @param {string} resourceId - 要锁定的资源ID
* @param {number} retryDelay - 重试间隔(毫秒)
* @param {number} maxRetry - 最大重试次数
* @returns {Promise<boolean>} - 是否成功获取锁
*/
async acquire(resourceId, retryDelay = 100, maxRetry = 10) {
for (let i = 0; i < maxRetry; i++) {
const session = this.client.startSession();
try {
session.startTransaction();
// 尝试插入锁文档
const result = await this.lockCollection.insertOne(
{
resourceId,
createdAt: new Date(),
clientId: this.generateClientId()
},
{ session }
);
await session.commitTransaction();
return true;
} catch (err) {
await session.abortTransaction();
if (err.code === 11000) { // 唯一键冲突(锁已被占用)
await new Promise(resolve => setTimeout(resolve, retryDelay));
continue;
}
throw err;
} finally {
session.endSession();
}
}
return false;
}
/**
* 释放锁
* @param {string} resourceId - 要释放的资源ID
*/
async release(resourceId) {
await this.lockCollection.deleteOne({ resourceId });
}
// 生成客户端唯一标识
generateClientId() {
return `${process.pid}-${Date.now()}-${Math.random().toString(36).substr(2, 8)}`;
}
}
// 使用示例
(async () => {
const lock = new MongoDBLock('mongodb://localhost:27017', 'testDB');
await lock.init();
const resourceId = 'order_12345';
const acquired = await lock.acquire(resourceId);
if (acquired) {
try {
console.log('成功获取锁,执行业务逻辑...');
// 这里执行需要加锁的操作
await new Promise(resolve => setTimeout(resolve, 2000));
} finally {
await lock.release(resourceId);
console.log('锁已释放');
}
} else {
console.log('获取锁失败');
}
await lock.client.close();
})();
四、关键技术细节分析
1. 事务保障原子性
示例中使用了MongoDB事务,确保插入操作和后续业务操作要么全部成功,要么全部回滚。这是实现可靠锁的关键。
2. 自动过期机制
通过TTL索引,即使客户端崩溃没有正常释放锁,系统也会自动清理过期锁,避免死锁情况。这个时间需要根据业务操作的最长时间合理设置。
3. 客户端标识
每个锁文档包含唯一的clientId,这样可以实现更复杂的锁机制,比如:
- 可重入锁(同一客户端可重复获取)
- 锁续期机制(心跳保持)
五、与其他方案的对比
对比Redis分布式锁
| 特性 | MongoDB实现 | Redis实现 |
|---|---|---|
| 持久化 | 原生支持 | 需配置 |
| 原子性保障 | 事务支持 | Lua脚本 |
| 自动释放 | TTL索引 | EXPIRE |
| 监控能力 | 查询集合即可 | 需要额外实现 |
对比Zookeeper分布式锁
MongoDB方案更适合已经使用MongoDB作为主要存储的系统,避免引入新的中间件。Zookeeper的临时节点虽然更优雅,但增加了架构复杂度。
六、生产环境注意事项
时钟同步问题
各节点的系统时间必须同步,否则TTL机制会失效。建议部署NTP服务。连接池配置
MongoDB连接需要合理配置池大小,避免锁竞争导致连接耗尽。锁粒度控制
过细的锁粒度会增加MongoDB压力,过粗则降低并发度。需要根据业务特点平衡。监控告警
建议监控锁集合的文档数量,异常增长可能表明有未释放的锁。
七、适用场景推荐
这种方案特别适合以下场景:
- 已经使用MongoDB作为主要数据库的系统
- 需要中等并发控制(非极端高并发)
- 希望避免引入Redis等额外组件
- 需要可视化监控锁状态(直接查集合即可)
不适用场景:
- 超高并发(每秒万次以上锁操作)
- 对延迟极其敏感(MongoDB网络IO有开销)
- 需要复杂的锁语义(如读写锁)
八、总结
MongoDB实现分布式锁是一个实用且可靠的方案,特别适合MongoDB技术栈的项目。它平衡了实现的复杂度和功能完整性,通过合理利用MongoDB的特性,可以满足大多数分布式锁的需求。关键是要理解事务、TTL和唯一索引这三个核心机制,并根据业务特点调整参数。
评论