
做过几年后端的人基本都会被分布式锁折腾一遍。服务从一个节点扩展到两三个节点之后像“同一时间只能有一个任务在跑”这种约束单机上的 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里面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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;加上idx_expire_time索引不是为了加锁用的是为了清理过期锁时不让 DELETE 全表扫这是个很容易漏掉的性能细节。3.2 获取锁 SQLINSERT 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 释放锁 SQLDELETE 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) ) ENGINEInnoDB;初始化时插入需要使用的锁编号比如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 个主键 id1001 到 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, timeout30, retry_interval0.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_time3): 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, timeout30) if not lock.acquire(wait_time3): 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 分钟如果真的超过了一定是业务代码出了问题与其让别的请求一直阻塞不如让锁尽快流产让运维人员有时间去排查。数据库分布式锁最大的价值不是追求性能而是让你在现有的架构里用最少的额外组件严谨地解决“多节点互斥”这件事。