1. 表结构设计
sql
CREATE TABLE `reentrant_lock` (
`lock_key` varchar(128) NOT NULL COMMENT '锁的唯一标识',
`owner_id` varchar(128) NOT NULL COMMENT '锁的持有者标识(如线程ID/UUID)',
`reentrant_count` int NOT NULL DEFAULT '0' COMMENT '重入次数计数器',
`expire_time` bigint NOT NULL COMMENT '锁的过期时间戳(毫秒),防止死锁',
PRIMARY KEY (`lock_key`),
UNIQUE KEY `uk_lock_key` (`lock_key`) -- 确保锁的唯一性
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='可重入锁表';
字段解释:
lock_key:主键,保证锁的名称全局唯一。owner_id:标识锁的持有者。可重入的关键在于判断当前请求者是否是锁的持有者。reentrant_count:重入计数器。加锁+1,解锁-1。expire_time:绝对过期时间戳。为了防止客户端宕机导致锁永远不释放,必须设置超时机制。
2. 加锁逻辑
加锁时,使用 INSERT ... ON DUPLICATE KEY UPDATE 语法,这在 MySQL 中是原子操作,可以保证并发安全。
逻辑步骤:
- 尝试插入新记录(获取锁)。
- 如果发生主键冲突(说明锁已被占用),则检查
owner_id是否是自己。 - 如果是自己,更新
reentrant_count + 1并续期;如果不是自己,则加锁失败。
SQL 语句:
sql
INSERT INTO `reentrant_lock` (`lock_key`, `owner_id`, `reentrant_count`, `expire_time`)
VALUES ('my_lock_key', 'thread_123', 1, UNIX_TIMESTAMP(NOW()) * 1000 + 30000)
ON DUPLICATE KEY UPDATE
`reentrant_count` = IF(`owner_id` = VALUES(`owner_id`) AND `expire_time` > UNIX_TIMESTAMP(NOW()) * 1000,
`reentrant_count` + 1,
`reentrant_count`),
`owner_id` = IF(`owner_id` = VALUES(`owner_id`) AND `expire_time` > UNIX_TIMESTAMP(NOW()) * 1000,
`owner_id`,
VALUES(`owner_id`)),
`expire_time` = IF(`owner_id` = VALUES(`owner_id`) AND `expire_time` > UNIX_TIMESTAMP(NOW()) * 1000,
VALUES(`expire_time`),
VALUES(`expire_time`)),
`reentrant_count`= IF(`owner_id` = VALUES(`owner_id`) AND `expire_time` > UNIX_TIMESTAMP(NOW()) * 1000,
`reentrant_count` + 1,
1);
上面的 SQL 比较复杂,为了更清晰地表达逻辑,在实际开发中通常会结合代码来判断,更推荐的代码+SQL结合逻辑如下:
推荐实现(伪代码 + SQL):
java
// 1. 尝试直接获取锁(无冲突时)
try {
INSERT INTO reentrant_lock (lock_key, owner_id, reentrant_count, expire_time)
VALUES ('my_lock_key', 'thread_123', 1, 当前时间戳+超时时间);
return true; // 获取锁成功
} catch (DuplicateKeyException e) {
// 锁已存在,进入下一步判断
}
// 2. 锁已存在,判断是否是自己持有且未过期
UPDATE reentrant_lock
SET reentrant_count = reentrant_count + 1,
expire_time = 当前时间戳+超时时间
WHERE lock_key = 'my_lock_key'
AND owner_id = 'thread_123'
AND expire_time > 当前时间戳;
// 如果 update 影响行数 == 1,说明重入加锁成功
// 如果影响行数 == 0,说明锁被别人持有,或者锁已过期,加锁失败(自旋重试)
3. 释放锁逻辑
释放锁时,不能直接 DELETE,必须先将 reentrant_count 减 1。只有当计数器减到 0 时,才真正删除这条记录。
SQL 语句:
sql
-- 步骤1:减少重入次数,并判断是否归零
UPDATE `reentrant_lock`
SET `reentrant_count` = `reentrant_count` - 1
WHERE `lock_key` = 'my_lock_key'
AND `owner_id` = 'thread_123';
在代码中判断上面 SQL 的影响行数:
- 如果
affected rows == 0,说明这不是你的锁,或者锁不存在(非法操作)。 - 如果
affected rows == 1,说明减1成功,接下来需要查询当前计数器是否为 0。
sql
-- 步骤2:查询当前计数器
SELECT `reentrant_count` FROM `reentrant_lock`
WHERE `lock_key` = 'my_lock_key' AND `owner_id` = 'thread_123';
- 如果
reentrant_count == 0,执行真正的删除:
sql
-- 步骤3:彻底释放锁
DELETE FROM `reentrant_lock`
WHERE `lock_key` = 'my_lock_key'
AND `owner_id` = 'thread_123'
AND `reentrant_count` = 0;
4. 优化方案:利用 MySQL 8.0 的窗口函数(一步到位的释放锁)
如果你使用的是 MySQL 8.0+,可以利用 WITH 语法将减少计数和删除合并为一条原子 SQL,避免代码中的多次交互:
sql
WITH cte AS (
UPDATE `reentrant_lock`
SET `reentrant_count` = `reentrant_count` - 1
WHERE `lock_key` = 'my_lock_key'
AND `owner_id` = 'thread_123'
RETURNING `reentrant_count`
)
DELETE FROM `reentrant_lock`
WHERE `lock_key` = 'my_lock_key'
AND `owner_id` = 'thread_123'
AND (SELECT `reentrant_count` FROM cte) = 0;
5. 注意事项与避坑指南
-
锁超时与续期(看门狗机制) :
如果业务执行时间超过了expire_time,锁会自动失效,其他线程就会拿到锁,导致并发问题。如果是长业务,需要在代码层面实现类似 Redisson 的"看门狗",起一个后台线程定期执行:sqlUPDATE reentrant_lock SET expire_time = 新的过期时间 WHERE lock_key = 'xxx' AND owner_id = 'thread_123'; -
死锁预防 :
如果客户端在持有锁的情况下崩溃,没有执行释放锁的逻辑,只能依赖expire_time过期自动释放。因此,超时时间不能设置得太长,但也不能太短。 -
数据库性能瓶颈 :
基于数据库的锁性能较低(磁盘IO),适合并发量不高、需要强一致性且没有 Redis/Zookeeper 等分布式组件的场景。如果请求量极大,数据库很容易成为瓶颈。 -
锁的清除 :
前面的逻辑里,判断锁是否过期是在UPDATE的WHERE条件里做的。如果锁过期了,步骤2中的UPDATE语句由于expire_time > 当前时间戳不满足,影响行数为0。此时你可以在代码里执行强行抢占(覆盖别人的过期锁):sql-- 抢占过期锁 UPDATE reentrant_lock SET owner_id = 'thread_123', reentrant_count = 1, expire_time = 新的过期时间 WHERE lock_key = 'my_lock_key' AND expire_time < 当前时间戳; -
唯一标识
owner_id:
在分布式系统中,owner_id绝对不能只是线程ID(因为不同机器的线程ID可能重复),必须是机器IP/实例名 + 线程ID或者是UUID。