怎样使用数据库实现分布式锁
面试里可以这样答:数据库实现分布式锁,核心就两种思路:基于唯一索引的 INSERT/DELETE,和基于 SELECT ... FOR UPDATE 的行锁。另外还有乐观锁版本号方式。实际面试重点讲第一种,因为最常用、也最能体现细节。
1. 总述
数据库分布式锁的本质:
利用数据库的唯一约束或行锁,让同一时刻只有一个事务/线程能拿到锁。
但必须解决:锁超时、误删、可重入、续期、主从、性能这些问题。
2. 方案一:唯一索引 + INSERT/DELETE(重点)
表设计
CREATE TABLE distributed_lock (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
lock_name VARCHAR(64) NOT NULL,
holder VARCHAR(128) NOT NULL, -- 持有者,建议 UUID + 线程ID
reentrant_count INT DEFAULT 1, -- 重入次数
expire_time DATETIME NOT NULL, -- 过期时间,防死锁
create_time DATETIME DEFAULT NOW(),
UNIQUE KEY uk_lock_name (lock_name)
);
加锁
public boolean tryLock(String lockName, String holder, long expireMs) {
try {
jdbcTemplate.update(
"INSERT INTO distributed_lock(lock_name, holder, expire_time) VALUES (?, ?, ?)",
lockName, holder, new Date(System.currentTimeMillis() + expireMs)
);
return true;
} catch (DuplicateKeyException e) {
// 插入失败,说明锁已存在。可尝试清理过期锁后重试
int deleted = jdbcTemplate.update(
"DELETE FROM distributed_lock WHERE lock_name = ? AND expire_time < NOW()",
lockName
);
if (deleted > 0) {
return tryLock(lockName, holder, expireMs);
}
return false;
}
}
关键点:
- 加锁操作要独立事务,插入后立刻提交,否则其他事务插入相同唯一键会一直阻塞等待。
- 如果放在业务大事务里,锁记录迟迟不提交,会拖死其他线程。
释放锁
public void unlock(String lockName, String holder) {
jdbcTemplate.update(
"DELETE FROM distributed_lock WHERE lock_name = ? AND holder = ?",
lockName, holder
);
}
必须带 holder,防止误删别人的锁。
可重入
加锁时如果唯一键冲突,不要直接失败,先查:
SELECT holder, reentrant_count, expire_time FROM distributed_lock WHERE lock_name = ?
如果 holder 是自己且未过期,则:
UPDATE distributed_lock SET reentrant_count = reentrant_count + 1 WHERE lock_name = ? AND holder = ?
释放时:
UPDATE distributed_lock SET reentrant_count = reentrant_count - 1 WHERE lock_name = ? AND holder = ?
减到 0 再 DELETE。
锁续期
后台定时线程,每隔一段时间:
UPDATE distributed_lock SET expire_time = ? WHERE lock_name = ? AND holder = ?
防止业务执行时间超过 expire_time,锁被误删。
过期清理
定时任务:
DELETE FROM distributed_lock WHERE expire_time < NOW()
但要注意:如果业务还在执行但没续期,可能误删。所以续期机制必须配合。
3. 方案二:SELECT ... FOR UPDATE 行锁
思路:表中先有一行锁记录,加锁时用 SELECT ... FOR UPDATE 锁住这行,事务提交后自动释放。
@Transactional
public void doWithLock(String lockName) {
// 确保行存在
jdbcTemplate.update("INSERT IGNORE INTO distributed_lock(lock_name) VALUES (?)", lockName);
// 加锁,其他事务会阻塞在这里
jdbcTemplate.queryForObject(
"SELECT * FROM distributed_lock WHERE lock_name = ? FOR UPDATE",
new Object[]{lockName}, (rs, rowNum) -> rs.getString("lock_name")
);
// 执行业务
// ...
// 事务提交,行锁自动释放
}
优点:
- 不需要显式删除锁。
- 天然阻塞等待,适合需要排队场景。
- 事务结束自动释放,不会因为忘记 unlock 死锁。
缺点:
- 锁持有时间等于整个事务时长,长事务会占用数据库连接。
- 高并发下大量线程阻塞在数据库,容易连接池耗尽。
- 必须操作主库,读写分离下不能读从库。
- 需要先确保行存在,否则
FOR UPDATE可能锁不住。
4. 方案三:乐观锁版本号
表里加 version 或 locked 字段,用 UPDATE 影响行数判断是否拿到锁。
UPDATE distributed_lock
SET holder = ?, expire_time = ?, version = version + 1
WHERE lock_name = ?
AND version = ?
AND (holder IS NULL OR expire_time < NOW());
影响行数为 1,说明抢锁成功。释放时:
UPDATE distributed_lock
SET holder = NULL, version = version + 1
WHERE lock_name = ? AND holder = ? AND version = ?;
适合竞争不激烈、冲突少的场景。
5. 必须注意的坑
-
锁超时
宕机后锁必须能自动释放,所以一定要有expire_time。 -
误删
释放锁必须校验holder,不能直接DELETE WHERE lock_name=?。 -
可重入
用holder + reentrant_count实现,释放时减到 0 才删除。 -
续期
业务执行时间可能超过过期时间,需要后台定时续期,类似 Redisson 看门狗。 -
独立事务
基于唯一索引加锁时,INSERT 要独立事务提交,不能和业务大事务混在一起。 -
主从问题
分布式锁必须走主库,不能走读写分离的从库,否则复制延迟会导致锁状态不一致。 -
性能问题
数据库锁并发能力差,连接池有限,大量轮询会拖垮数据库。
一般只适合并发不高、已有数据库、不想引入 Redis/ZooKeeper 的场景。
6. 与 Redis、ZooKeeper 对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| 数据库 | 简单、强一致、无需额外组件 | 性能差、占连接、主从复杂 |
| Redis | 性能高、实现成熟(Redisson) | 需处理续期、主从切换、Redlock 争议 |
| ZooKeeper | 一致性强、临时顺序节点 | 性能一般、需维护 ZK 集群 |
7. 面试收尾总结
可以这样答:
数据库实现分布式锁,最常用的是基于唯一索引:加锁时 INSERT 一条唯一记录,成功即拿到锁;释放时校验 holder 后 DELETE。必须加 expire_time 防死锁,配合定时续期防误过期,holder 防误删,reentrant_count 支持重入。另一种是 SELECT ... FOR UPDATE 行锁,事务提交自动释放,但长事务会占连接。数据库锁适合并发不高、不想引入中间件的场景;高并发更推荐 Redis 或 ZooKeeper。
常见追问:
唯一索引冲突时会发生什么?锁超时怎么续期?如何防止误删?怎么实现可重入?主从切换锁会丢吗? 这些都可以继续展开。
浙公网安备 33010602011771号