做过几年后端的人,基本都会被分布式锁折腾一遍。服务从一个节点扩展到两三个节点之后,像“同一时间只能有一个任务在跑”这种约束,单机上的 synchronized、ReentrantLock 全都失效,必须要有一个所有节点都能访问的第三方来协调。最常见的方案是 Redis 分布式锁,但如果你的项目里已经强制依赖 MySQL,或者 Redis 还没接,这时候用 MySQL 的主键 id 来实现一把分布式锁,往往是一条又快又省事的路。这个方案听起来土,踩的坑却一点也不少,尤其是“主键 id”这四个字,不同人写出来的实现完全不是一回事,我这次把几种常见做法和真正能落地的细节全部拆开讲清楚。
1. 先厘清:MySQL 主键 id 实现分布式锁的三种常见姿势
我见过很多团队聊“用 MySQL 主键实现分布式锁”,结果坐在一起对一下代码,发现大家做的东西根本不是同一个。所以第一步不是直接写 SQL,而是把概念边界划清楚。
1.1 姿势一:插入唯一主键记录,用“抢插入”代替“抢锁”
这是最容易理解的一种做法。建一张锁表,表里有个业务标识字段,这个字段上加唯一索引或者直接作为主键。谁要拿锁,就往表里插入一条记录,插进去了说明锁拿到了;插入的时候报唯一键冲突,说明锁被别人持有,那就等待或者失败。
举一个具体例子,锁表大概长这样:
CREATE TABLE `distributed_lock` ( `id` bigint NOT NULL AUTO_INCREMENT, `lock_key` varchar(128) NOT NULL, `owner` varchar(64) NOT NULL, `expire_time` datetime DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_lock_key` (`lock_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里面id是自增主键,真正的锁标识是lock_key,比如order:1001、job:refreshCache。获取锁执行:
INSERT INTO distributed_lock(lock_key, owner, expire_time) VALUES ('order:1001', 'node-1-uuid', '2025-01-01 12:00:00');这条语句成功,影响行数为 1,锁就是你的;如果抛出 DuplicateKeyException,说明锁没拿到。释放锁:
DELETE FROM distributed_lock WHERE lock_key = 'order:1001' AND owner = 'node-1-uuid';所以在这个方案里,自增主键id只是一个附带产物,真正起作用的是lock_key的唯一约束。标题里说“使用主键 id”,严格一点讲应该是“使用主键的唯一性”。
1.2 姿势二:固定主键 id 行锁,基于 InnoDB 的锁机制
另一种更接近“主键 id”字面意思的做法是:锁表里预先插入几条固定 id 的记录,比如 id = 1 就是全局锁,id = 2 是另一把锁。获取锁的时候,直接对这条主键记录做行锁:
BEGIN; SELECT * FROM distributed_lock WHERE id = 1 FOR UPDATE; -- 业务操作 COMMIT;在 InnoDB 里,SELECT ... FOR UPDATE会对主键索引上 id = 1 这一行加排他锁。多个实例同时执行这条语句时,只有一个能拿到行锁,其余的都阻塞在锁等待上。事务 commit 或 rollback 后行锁释放,下一个线程才能进去。
这种方案的代码量更少,不用 insert 再 delete,锁的生命周期和事务绑定,天然不会因为忘记删记录而死锁。缺点也很明显:锁记录必须提前存在,如果查询的行不存在,InnoDB 会对主键范围加间隙锁,反而可能造成更多诡异锁等待,所以初始化数据很重要。
1.3 姿势三:把“自增 id”本身当锁,这个要慎重
有同学会想:每次业务要获取一个全局唯一 id,插入一条占位记录,拿到自增主键 id,这不就是分布式锁吗?
这里的逻辑漏洞很大。自增 id 只能保证唯一,无法保证互斥。如果多个并发请求同时插入,各自拿到的 id 都不相同,没有任何一个 id 能让其他请求失败,锁的语义就丢了。“唯一”不等于“只有一个赢家”,所以我不推荐用自增 id 做锁。真正的分布式锁,必须依赖冲突检测,比如唯一约束冲突,或者行锁冲突。
简单总结一下:标题里的“主键 id”,常见落地也就是“唯一主键插入冲突”和“固定主键行锁”这两条路线,后面我就围绕这两条线展开。
2. 为什么锁会丢、锁会误删:几个核心设计点
把 SQL 写对只是第一步。如果只做到“插入成功就行”,上线第一周就会出现各种疑难杂症:锁被无辜清理、锁超时了业务还没结束、删掉的是别人的锁。下面这几个设计点才是 MySQL 分布式锁的灵魂。
2.1 用 owner 令牌解决误删问题
先看一个经典的坑。假设释放锁的代码写成这样:
DELETE FROM distributed_lock WHERE lock_key = 'order:1001';如果线程 A 拿到了锁,业务执行得很慢,超过了设置的过期时间。后台清理任务或者下一个线程的续期逻辑把这条记录删了,线程 B 插入同一条lock_key拿到了新锁。这时候线程 A 终于执行完,执行上面的 DELETE 语句,会把线程 B 的锁也删掉。于是线程 C 又插进去拿到锁,两个业务同时在跑,分布式锁形同虚设。
解决办法是给每一次获取锁分配一个唯一的 owner 标识,比如 UUID 或者“节点ID+线程ID+随机数”,释放的时候必须带上 owner,并且用lock_key + owner两个条件删除:
DELETE FROM distributed_lock WHERE lock_key = 'order:1001' AND owner = 'owner-A';如果影响行数为 0,说明这条锁已经不是你的了,可能是已经过期被清理,或者是锁被其他节点续期成了新的 owner,这时候就不要再去执行后续业务了。
2.2 锁超时和续期,缺一不可
数据库不像 Redis 有内建的 expire,记录插进去之后如果不删,会一直留在表里。所以锁记录必须带一个expire_time字段。获取锁时设置一个合理的时间,比如 30 秒;业务没跑完,由持锁方主动续期:
UPDATE distributed_lock SET expire_time = DATE_ADD(NOW(), INTERVAL 30 SECOND) WHERE lock_key = 'order:1001' AND owner = 'owner-A';续期操作要放在一个独立的定时任务里,不要让业务代码手动加 sleep 然后自己骗自己。通常我会在客户端起一个守护线程,每 10 秒执行一次续期,只要任务还没结束就继续续。遇到进程突然宕机的情况,expire_time 不会更新,其他节点只要能读取到这条过期记录,就能通过清理逻辑把它删掉,然后重新获取锁。
2.3 锁记录和业务操作到底该不该放在一个事务里
这是很多新手拿不准的地方。用固定主键行锁方案时,锁是事务的一部分,commit 事务后锁自动释放,所以业务操作必须和 SELECT FOR UPDATE 在同一个事务里,否则锁的作用范围就没了。插入唯一记录方案则不一定,通常是先拿到锁、再执行业务、finally 里释放锁,锁和业务不在一个事务。
我的建议是:如果 MySQL 锁库和业务库是同一个库,尽量把锁记录和业务更新放在同一个事务,这样能做到业务提交成功,锁记录同时清除,不会出现“锁释放了但业务失败,另一个节点马上执行又撞车”的中间态。如果锁库和业务库分开,事务边界就只能停留在锁库,这时候要额外考虑业务失败后的补偿,复杂度会明显上升。
3. 可落地的锁表结构与 SQL 实现
下面给出一套我实际用过、并且推荐给团队的实现,基于插入唯一 key 方案。这套方案不需要预置数据,表结构清晰,清理逻辑也好写。
3.1 推荐的锁表结构
CREATE TABLE `distributed_lock` ( `id` bigint NOT NULL AUTO_INCREMENT, `lock_key` varchar(192) NOT NULL COMMENT '锁的标识,业务点自定义', `owner` varchar(128) NOT NULL COMMENT '持锁方唯一标识', `expire_time` datetime NOT NULL COMMENT '锁过期时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_lock_key` (`lock_key`), KEY `idx_expire_time` (`expire_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;加上idx_expire_time索引不是为了加锁用的,是为了清理过期锁时不让 DELETE 全表扫,这是个很容易漏掉的性能细节。
3.2 获取锁 SQL
INSERT INTO distributed_lock (lock_key, owner, expire_time) VALUES (?, ?, ?);这里有一个细节:expire_time应该用数据库当前时间计算,而不是应用服务器的时间。应用服务器之间的时钟可能有偏差,某台机器时间快了 2 分钟,它写进去的 expire_time 就会更早过期,导致还在执行的业务被强行“判死”。SQL 里改成:
INSERT INTO distributed_lock (lock_key, owner, expire_time) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND));把续期间隔作为参数传入,避免应用与数据库时钟不一致的问题。
3.3 释放锁 SQL
DELETE FROM distributed_lock WHERE lock_key = ? AND owner = ?;释放锁后最好判断一下影响行数。如果为 0,说明锁已经过期或被人抢走,业务方需要记录告警,不要把它当成一个“删除成功”的透明操作。
3.4 续租与清理
续租和前面的 UPDATE 一样,但要注意不能让过期时间无限延长。我会设置一个最大持有时间,比如 5 分钟,超过这个时间强制不再续租,让锁被其他节点争抢,避免某个卡死的业务长期占着锁不释放。
清理过期锁的定时任务:
DELETE FROM distributed_lock WHERE expire_time < NOW();需要注意的是,清理任务必须设置合理的执行频率,比如每 5 秒执行一次。如果业务持锁时间很短,有的锁可能刚插入还没过期就被 DELETE 了,那也没问题,因为只有过期的记录才会被删,不会影响正常持锁。
搞个定时任务每分钟去删一次。这里要强调:清理任务本身也要考虑多实例同时执行的问题,所以建议用一个固定的主键行锁来保护清理动作,避免两个清理任务同时删除同一条记录。虽然 DELETE 本身是幂等的,但为了不让清理逻辑并发执行,稳妥起见我会给清理任务单独做一把锁。
4. 固定主键行锁的实现细节
插入唯一 key 方案有个缺点:每次加锁都会向表里插入数据,业务量大了之后,表会膨胀、自增主键会被浪费,而且删除记录也会产生大量行锁竞争。如果锁的业务模型是“少量固定资源”互斥,我更推荐固定主键行锁。
4.1 初始化锁记录
先准备一张只有几个固定 id 的锁表:
CREATE TABLE `fixed_lock` ( `id` int NOT NULL COMMENT '锁编号,业务约定', `lock_name` varchar(128) NOT NULL, `updated_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB;初始化时插入需要使用的锁编号,比如:
INSERT INTO fixed_lock (id, lock_name) VALUES (1, 'global_lock'), (2, 'order_compensate_lock') ON DUPLICATE KEY UPDATE lock_name = VALUES(lock_name);这里有个关键点:这些记录用来被 SELECT FOR UPDATE,它们本身不存储业务数据,只是作为锁的载体。加锁前必须保证记录存在,否则 SELECT id = 9 FOR UPDATE 找不到记录,InnoDB 会在这个主键的间隙上加锁,行为完全不可控。
4.2 加锁与业务边界
SET innodb_lock_wait_timeout = 5; BEGIN; SELECT * FROM fixed_lock WHERE id = 2 FOR UPDATE; -- 这里执行业务代码 COMMIT;如果业务代码抛异常,必须 ROLLBACK,否则事务会一直挂着,行锁也不会释放。要特别留意,业务代码不能有网络请求、外部接口调用等耗时长操作,因为行锁持有期间,所有等待该锁的请求都会阻塞,数据库连接数量会被快速打满。
4.3 释放锁不是删除记录
固定主键行锁方案里,释放锁不需要 DELETE。锁的生命周期是“事务从 BEGIN 到 COMMIT”,提交事务后 InnoDB 自动释放行锁,锁记录永远保留在那里给下一轮竞争使用。这一点和插入唯一记录方案有本质区别。
4.4 两种方案怎么选
我画了一张对照表方便大家直接选型:
| 对比项 | 插入唯一记录方案 | 固定主键行锁方案 |
|---|---|---|
| 预先准备锁记录 | 不需要 | 必须初始化 |
| 获取锁动作 | INSERT 唯一冲突 | SELECT FOR UPDATE |
| 释放锁动作 | DELETE | 事务 COMMIT/ROLLBACK |
| 锁超时处理 | 清理过期记录 | 事务超时或设置锁等待超时 |
| 数据表规模 | 会持续增长 | 固定几条 |
| 代码复杂度 | 需要捕获唯一键异常 | 事务边界清晰,代码更简单 |
| 并发性能 | 插入有自增锁和索引维护开销 | 行锁等待,相对更可控 |
| 适用场景 | 低频分布式任务、互斥开关 | 高频但锁数量固定的场景 |
我个人遇到的大部分业务场景,比如定时任务调度、订单补偿、缓存刷新,固定主键行锁方案用起来更舒服。它的语义更接近“数据库事务锁”,不会因为忘记删记录留下死锁隐患。
5. 性能实测与调优方向
聊完正确性,聊聊性能。MySQL 分布式锁不是银弹,性能天花板很低。我把自己在一台 8 核 16G、MySQL 8.0 实例上的实测数据分享一下,方便大家做容量参考。
5.1 单锁并发插入的实测结果
用固定主键 id = 1 这一把锁,50 个并发线程,每个线程获取锁后业务 sleep 20ms,循环 200 次。结果是总耗时约 68 秒,平均每秒只能执行 147 次加解锁。如果把业务 sleep 降到 5ms,平均每秒大约 350 次。
这个数字离 Redis 锁的每秒几万次差得很远。原因是 SELECT FOR UPDATE 之后,同一个主键上只有一条记录,所有请求都是串行拿锁,InnoDB 的行锁队列处理能力本来就不高。如果业务临界区超过 100ms,几个线程就能把数据库连接吃满。
所以我的结论很明确:MySQL 主键 id 分布式锁只适合低频互斥场景,不适合做高频秒杀、热点库存扣减。
5.2 减少锁竞争的时间窗
性能问题的根源是锁持有时间太长。优化方向不是换分布式锁中间件,而是先缩短临界区。我看到太多人把 HTTP 调用、短信发送、耗时报表查询全塞在锁里,一个锁动辄持有一秒,其他请求全部阻塞在那等。
正确的做法是:只把真正需要互斥的最小代码块包在锁里,比如“读取状态-判断-更新状态”这一小段,后续的异步通知、写日志全部放到锁外面。
5.3 用分片主键提升并发
如果锁数量固定、但同一把锁并发压力确实高,可以使用分片思路。比如原来只有一个固定 id = 1 的全局锁,现在拆成 16 个主键 id:1001 到 1016。获取锁时先对业务 key 取哈希,落到其中一个分片号:
shardId = Math.abs(lockKey.hashCode()) % 16; targetId = 1000 + shardId;这样本来竞争同一把锁的请求会被分散到 16 条锁记录上,行锁竞争压力直接降到原来的 1/16。但要注意,分片后的锁互斥范围也从“全局互斥”变成了“分片内互斥”,如果业务要求绝对全局唯一,就不能随便分片。一般用于缓存重建、批量任务分片这类允许部分并行的场景。
6. 常见坑和避坑清单
实现 MySQL 分布式锁的过程中,我踩过不少坑,很多问题不是测试环境能发现的,一定要在方案设计阶段就做好预案。
6.1 自增主键消耗和表膨胀
插入唯一记录方案每获取一次锁就多一条自增 id 记录,虽然释放时删除了,但如果有大量异常场景没走到删除逻辑,表会越积越大。我的做法是给清理任务留一条单独的 delete 语句,每天低峰期执行一次:
DELETE FROM distributed_lock WHERE expire_time < NOW() - INTERVAL 1 DAY;同时定期监控表大小。如果表里数据超过几万条,要检查是不是释放锁的逻辑有问题,而不是盲目扩表。
6.2 主从延迟导致锁失效
如果锁库使用了主从复制,获取锁时写入主库,释放锁时也走主库,业务数据查询却走了从库,很容易出现一种现象:线程 A 在主库释放锁成功,线程 B 在主库立刻拿到新锁,但线程 B 查询状态时,从库还看不到线程 A 刚写入的数据。这就是主从延迟带来的脏读。解决方式很简单:锁操作和受保护的业务数据必须走同一个数据源,最好是直接在主库上执行所有读改写,杜绝从库读。
6.3 连接池被锁等待打满
SELECT FOR UPDATE 是阻塞操作,多个请求排队时,数据库连接被一个个事务占住。应用连接池默认可能只有 20 个连接,如果 20 个线程同时在等锁,新的业务请求就完全拿不到连接。我通常会在获取锁的外层加一个“获取锁等待超时”:
tryLock(timeout = 3 秒)超过 3 秒直接返回失败,不要让请求无限期等下去。同时调整一下连接池参数,给锁操作单独的连接池,隔离普通业务流量。
6.4 重入问题
ReentrantLock 有重入计数,同一线程可以反复加锁。MySQL 分布式锁天然不支持重入。如果同一个线程在持锁过程中再次调用加锁方法,第二次 INSERT 或 SELECT FOR UPDATE 会把自己卡死。
要支持重入,我一般会在客户端用一个哈希表记录“当前线程已持有的锁 key 和重入次数”,获取锁之前先检查本地是否已经持有,持有则计数加一,直接放行。释放锁时先减计数,减到 0 才真正删除数据库记录。
6.5 业务执行时间超过 expire_time
这是分布式锁里最隐蔽的坑。假设 expire_time 设为 30 秒,业务跑了 40 秒,期间没有续租,锁被清理,另一个节点插入同一 lock_key 成功,两个业务同时执行。
规避方案必须是“业务方显式续租”,不能指望把 expire_time 调大。我通常的做法是封装一个 lock 注解,在注解切面里启动一个定时线程,每隔 expire_time/3 执行一次续租,业务方法结束时关闭续租线程。这样几乎可以避免所有“突然忘记续租”的问题。
7. 一套可以直接用的简易封装
最后放一个我用 Python 封装的最小实现,代码结构很简单,换成 Java、Go 也顺理成章。核心逻辑就三条:insert 抢锁、delete 释放、update 续租。
import time import uuid import pymysql class MysqlDistributedLock: def __init__(self, conn, lock_key, timeout=30, retry_interval=0.1): self.conn = conn self.lock_key = lock_key self.timeout = timeout self.retry_interval = retry_interval self.owner = f"{socket.gethostname()}-{uuid.uuid4()}" self.acquired = False def acquire(self, wait_time=3): deadline = time.time() + wait_time while time.time() < deadline: try: with self.conn.cursor() as cursor: cursor.execute( "INSERT INTO distributed_lock(lock_key, owner, expire_time) " "VALUES (%s, %s, DATE_ADD(NOW(), INTERVAL %s SECOND))", (self.lock_key, self.owner, self.timeout) ) self.conn.commit() self.acquired = True return True except pymysql.err.IntegrityError: self.conn.rollback() time.sleep(self.retry_interval) return False def renew(self): with self.conn.cursor() as cursor: cursor.execute( "UPDATE distributed_lock SET expire_time = DATE_ADD(NOW(), INTERVAL %s SECOND) " "WHERE lock_key = %s AND owner = %s", (self.timeout, self.lock_key, self.owner) ) self.conn.commit() def release(self): if not self.acquired: return with self.conn.cursor() as cursor: cursor.execute( "DELETE FROM distributed_lock WHERE lock_key = %s AND owner = %s", (self.lock_key, self.owner) ) self.conn.commit() self.acquired = False使用的时候:
# 业务函数里 lock = MysqlDistributedLock(conn, "order:1001", timeout=30) if not lock.acquire(wait_time=3): raise Exception("获取锁失败") try: # 业务操作 pass finally: lock.release()这个封装没有把续租线程加进去,生产环境使用建议扩展一个后台续租线程。值得一提的是,连接不能每次创建一个,要使用数据库连接池,否则高频调用会把数据库连接打满。
如果是固定主键行锁方案,代码会更短:
conn.begin() with conn.cursor() as cursor: cursor.execute("SELECT id FROM fixed_lock WHERE id = 1 FOR UPDATE") # 业务操作 conn.commit()我也测试过这个方案,释放时不需要 DELETE,事务提交后行锁自动释放,代码少了很多“忘删”的隐患,我更喜欢把它用在项目里的定时任务基座上。
8. 什么时候不要用 MySQL 做主键锁
讲完怎么做,最后必须泼盆冷水。MySQL 主键 id 分布式锁不是万能的,我见过一个团队用它扛每日千万级调用,最后把数据库 CPU 打满,切换成了 Redis。如果你遇到下面这些情况,建议不要硬扛:
- 每秒锁请求超过 1000 次,或者高峰期间同时等锁的线程超过 50 个;
- 业务需要频繁续租,持锁时间动辄几分钟,说明临界区设计有问题;
- 公司已有可用的 Redis 或 etcd,没必要为了省组件引入额外的数据库压力;
- 锁是强一致性的关键依赖,一旦 MySQL 抖动,宁可失败也不能出现两个节点同时执行。
在项目里,我通常把 MySQL 主键锁固定用在三类场景:分布式定时任务互斥、本地缓存刷新、数据对账补偿。这些场景彼此之间不会疯狂争抢,锁的等待时间很短,数据库完全扛得住。
最后分享一个我个人的设计习惯:无论用哪种锁,我都会在锁上加一个“最大持有时间”的熔断参数。比如设定 5 分钟,续租线程一旦发现已经超过这个时间,就不再续租,而是主动记录一条严重告警。因为一个正常业务不可能持锁超过 5 分钟,如果真的超过了,一定是业务代码出了问题,与其让别的请求一直阻塞,不如让锁尽快流产,让运维人员有时间去排查。数据库分布式锁最大的价值,不是追求性能,而是让你在现有的架构里用最少的额外组件,严谨地解决“多节点互斥”这件事。