如何用 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

相关推荐
我爱cope4 小时前
【Agent智能体4 | 智能体AI的应用】
数据库·人工智能·职场和发展
知识分享小能手5 小时前
Flask入门学习教程,从入门到精通,数据库操作 — 知识点详解与案例代码(4)
数据库·学习·flask
我是一颗柠檬6 小时前
【MySQL全面教学】MySQL基础SQL语句Day3(2026年)
数据库·后端·sql·mysql·oracle
XS0301066 小时前
MyBatis动态SQL
数据库·sql·mybatis
MandalaO_O6 小时前
MyBatis 与 MySQL 执行流程
数据库·mysql·mybatis
l1t7 小时前
DeepSeek总结的将 Rust Delta Kernel 集成到 ClickHouse
数据库·clickhouse·rust
qq_283720057 小时前
万字深度:Chroma 向量数据库全解析 — 核心原理、实战操作、性能优化与工程最佳实践
数据库·性能优化
黄筱筱筱筱筱筱筱7 小时前
二进制包安装MySql服务
数据库
初心未改HD7 小时前
LLM应用开发之向量数据库详解
数据库·人工智能
键盘上的猫头鹰7 小时前
【从零学MySQL(三)】数据增删改(DML)及 SELECT 查询详解
数据库·mysql·数据分析