如何用 MySQL 实现一个可重入的锁?

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 中是原子操作,可以保证并发安全。
逻辑步骤:

  1. 尝试插入新记录(获取锁)。
  2. 如果发生主键冲突(说明锁已被占用),则检查 owner_id 是否是自己。
  3. 如果是自己,更新 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. 注意事项与避坑指南

  1. 锁超时与续期(看门狗机制)
    如果业务执行时间超过了 expire_time,锁会自动失效,其他线程就会拿到锁,导致并发问题。如果是长业务,需要在代码层面实现类似 Redisson 的"看门狗",起一个后台线程定期执行:

    sql 复制代码
    UPDATE reentrant_lock SET expire_time = 新的过期时间 
    WHERE lock_key = 'xxx' AND owner_id = 'thread_123';
  2. 死锁预防
    如果客户端在持有锁的情况下崩溃,没有执行释放锁的逻辑,只能依赖 expire_time 过期自动释放。因此,超时时间不能设置得太长,但也不能太短。

  3. 数据库性能瓶颈
    基于数据库的锁性能较低(磁盘IO),适合并发量不高、需要强一致性且没有 Redis/Zookeeper 等分布式组件的场景。如果请求量极大,数据库很容易成为瓶颈。

  4. 锁的清除
    前面的逻辑里,判断锁是否过期是在 UPDATEWHERE 条件里做的。如果锁过期了,步骤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 < 当前时间戳;
  5. 唯一标识 owner_id
    在分布式系统中,owner_id 绝对不能只是线程ID(因为不同机器的线程ID可能重复),必须是 机器IP/实例名 + 线程ID 或者是 UUID

相关推荐
小白勇闯网安圈11 小时前
Django 响应对象、文件上传与类视图
数据库·django·sqlite
努力的小雨12 小时前
同一份 SQL 跑多套环境:Ksql 变量怎么用才安全
数据库
科技绘图12 小时前
快鲸GEO vs 传统AI搜索优化:全链路自动化与高效内容生产在转化闭环上的对比
数据库·人工智能·自动化
ltl13 小时前
数据库作为 LLM 记忆体:语义缓存、RAG 与一致性
数据库
ltl13 小时前
WAL 与崩溃恢复:ARIES 协议怎么跑通
数据库
ltl13 小时前
TEE 数据库:EnclaveDB、Oblivious 原语与机密 SQL
数据库
麻瓜code14 小时前
【Mysql】重新学一遍 SQL 执行顺序
数据库·sql
这个DBA有点耶15 小时前
数据库一体机架构演进:从硬件堆叠到软硬深度耦合
服务器·网络·数据库·硬件架构·运维开发·database·数据库架构
一只fish15 小时前
优化器架构对比:Oracle vs PostgreSQL vs MySQL
mysql·postgresql·oracle
安_16 小时前
RAG的向量数据库:为LLM提供语义搜索能力
数据库