MySQL 实现分布式锁:悲观锁、乐观锁、唯一索引锁 一、前言上一篇我们了解到分布式锁三大主流方案MySQL、Redis、ZK。 MySQL 分布式锁是门槛最低的一种不需要额外部署中间件只要项目已经接入 MySQL就能快速落地。很多后台管理系统、低并发定时任务防重场景都会优先选择它。但是很多同学只听过select ... for update不清楚它的底层限制、事务坑、行锁表锁转化、超时阻塞问题乐观锁很多人只会写 version却不知道它不是锁、只是冲突检测唯一索引锁更是网上资料零散很少有人完整梳理。本篇我们分三种实现方案逐个拆解悲观锁select ... for update行锁实现乐观锁版本号 version 机制唯一索引锁插入冲突抢锁每个方案包含原理、完整 Java 代码、适用场景、坑点、失效场景、优缺点对比。最后会汇总三种方案怎么选以及 MySQL 分布式锁通用风险。重要前提悲观锁依赖 InnoDB 引擎 事务MyISAM 不支持事务和行锁无法实现悲观锁分布式锁。二、方案一悲观锁 select ... for update2.1 核心原理悲观锁思想默认并发一定会产生冲突提前把资源锁住别人来了就阻塞等待直到锁释放。 InnoDB 在事务中执行select ... for update会给命中的数据行加上排他行锁。排他锁规则其他事务不能再加排他锁、共享锁其他事务的select ... for update会阻塞普通 select 快照读不加锁不受影响MVCC事务提交 / 事务回滚 / 连接断开行锁自动释放天然避免永久死锁。注意select ... lock in share mode是共享锁读共享写互斥一般不拿来做分布式互斥锁。2.2 准备测试表CREATE TABLE dist_lock ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, lock_key VARCHAR(128) NOT NULL COMMENT 锁标识业务唯一key比如stock_1001、task_2026, lock_desc VARCHAR(255) DEFAULT NULL COMMENT 锁描述, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_lock_key (lock_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT分布式锁表; -- 提前插入一条锁记录我们要抢占 lock_key stock_1001 INSERT INTO dist_lock(lock_key, lock_desc) VALUES (stock_1001,商品库存锁);要点lock_key 代表一把锁不同业务资源使用不同 lock_key。2.3 Java 代码实现SpringBoot MyBatisMapper 接口Mapper public interface DistLockMapper { /** * 悲观锁抢占for update */ Select(SELECT id,lock_key FROM dist_lock WHERE lock_key #{lockKey} FOR UPDATE) DistLock getLockForUpdate(Param(lockKey) String lockKey); }业务 Service核心Service public class MysqlPessimisticLockService { Autowired private DistLockMapper distLockMapper; /** * 抢锁事务开启执行for update * 注意Transactional 必须加行锁在事务范围内生效 */ Transactional(rollbackFor Exception.class) public void doBusinessWithLock(String lockKey){ // 1. 抢占锁获取行排他锁 DistLock lock distLockMapper.getLockForUpdate(lockKey); if(lock null){ throw new RuntimeException(锁不存在无法抢占); } try{ // 临界区只有一个事务能进入 System.out.println(成功获取悲观锁执行业务扣减库存/处理任务); Thread.sleep(3000); // }catch (InterruptedException e){ Thread.currentThread().interrupt(); } // 事务正常提交行锁自动释放 } }2.4 关键坑点高频坑 1查询条件不走索引 → 行锁升级为表锁lock_key如果没有索引MySQL 无法定位行会扫描全表退化成表锁。一旦表锁所有锁全部阻塞整个 dist_lock 表都被锁住所有业务锁互相影响。解决方案锁字段必须建立索引上面我们建了唯一索引 uk_lock_key。坑 2for update 没有在事务里写锁直接失效单独一条select ... for update自动提交事务拿到锁立刻释放等于没锁。必须包裹在事务中。坑 3阻塞无限等待拿不到锁的线程会一直阻塞直到拿到锁或者 innodb_lock_wait_timeout 超时默认 50s抛出异常。 生产不能无限阻塞两种优化设置较短锁等待超时SET innodb_lock_wait_timeout 5使用select ... for update skip locked跳过已经被锁住的行不阻塞抢不到直接返回非阻塞模式MySQL8.0 支持SELECT id,lock_key FROM dist_lock WHERE lock_key #{lockKey} FOR UPDATE SKIP LOCKED;skip locked非常适合定时任务防重抢不到直接放弃不会阻塞。坑 4长事务灾难如果拿到锁之后业务逻辑执行很久事务迟迟不提交行锁一直持有其他请求全部阻塞堆积数据库连接耗尽。悲观锁临界区代码一定要短禁止临界区里面调用远程接口、MQ 发送、复杂 IO。坑 5主从架构下不能在从库执行 for update从库默认只读而且从库数据同步存在延迟锁必须在主库操作。2.5 悲观锁小结优点实现简单、自动释放锁、强一致性缺点阻塞、容易表锁、长事务风险、数据库压力大适用并发低、允许阻塞、一致性要求高的内部业务。三、方案二乐观锁版本号 version 方案3.1 核心原理乐观锁思想默认并发冲突很少不上锁提交更新的时候校验有没有被别人修改过如果被修改则更新失败业务重试。 实现方式增加版本号字段 version。流程查询数据拿到当前 version执行业务逻辑更新的时候带上条件where id? and version旧版本号如果影响行数 0说明期间被其他线程修改冲突抢锁失败行数 0更新成功抢到锁。重点区分乐观锁不是数据库锁它是业务层的冲突校验机制底层没有行锁MVCC 快照读实现。3.2 建表语句CREATE TABLE stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL COMMENT 商品id, stock_num INT NOT NULL DEFAULT 0 COMMENT 库存数量, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_goods_id(goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO stock(goods_id,stock_num,version) VALUES (1001,1,0);3.3 Java 代码MapperMapper public interface StockMapper { Select(SELECT id,goods_id,stock_num,version FROM stock WHERE goods_id #{goodsId}) Stock getStock(Param(goodsId) Long goodsId); Update(UPDATE stock SET stock_num stock_num - 1, version version 1 WHERE goods_id #{goodsId} AND version #{oldVersion}) int deductStock(Param(goodsId) Long goodsId, Param(oldVersion) Integer oldVersion); }ServiceService public class OptimisticLockService { Autowired private StockMapper stockMapper; public void deduct(Long goodsId){ // 1. 查询版本号 Stock stock stockMapper.getStock(goodsId); if(stock.getStockNum() 0){ System.out.println(库存不足); return; } int oldVersion stock.getVersion(); // 2. 业务逻辑 // 3. 更新携带版本号校验 int rows stockMapper.deductStock(goodsId, oldVersion); if(rows 0){ System.out.println(扣减成功); }else{ System.out.println(乐观锁冲突扣减失败需要重试); } } }3.4 乐观锁经典坑点坑 1ABA 问题线程 A 读取 version0线程 B 修改 version 到 1又改回 version0。A 更新的时候 version 匹配成功感知不到中间发生过修改。乐观锁 version 自增方案可以规避 ABA因为版本号只会递增不会回退如果使用时间戳做版本同样可以规避。 ABA 在乐观锁场景危害远小于 CAS 的 ABA绝大多数业务可以接受。坑 2高并发下大量更新失败、大量重试并发量大的时候大部分请求都会更新失败不断重试CPU 和数据库压力暴涨。乐观锁不适合超高并发场景。坑 3只能控制更新冲突不能实现 “锁等待”冲突直接失败不会阻塞排队需要自己写重试逻辑重试次数、间隔控制防止死循环。3.5 扩展时间戳替代 version也可以用 update_time 时间戳字段代替 version原理一致更新时校验时间戳和读取时一致。但是 version 自增可读性更好生产更常用。优点无锁阻塞、性能比悲观锁好缺点高并发冲突严重、需要自己实现重试、只能用于更新场景适用并发不高、读多写少允许失败重试的场景。补充乐观锁一般不单独作为通用分布式锁更多是和业务数据绑定库存、订单。如果你想要一个独立的、和业务解耦的乐观锁会很麻烦。四、方案三唯一索引锁插入冲突锁4.1 原理利用 MySQL 唯一索引约束同一个唯一键不能插入多条记录。 抢锁往锁表插入一条记录插入成功 拿到锁插入报唯一索引冲突异常 锁被别人占用抢锁失败。 释放锁业务执行完成delete 这条记录。这是一种轻量的分布式锁很多定时任务框架底层会用这个思路。4.2 锁表复用前面 dist_lock 表lock_key 是唯一索引。 抢锁 SQLINSERT INTO dist_lock(lock_key,lock_desc) VALUES (stock_1001,库存锁);解锁 SQLDELETE FROM dist_lock WHERE lock_key stock_1001;4.3 Java 代码示例Mapper public interface DistLockMapper { Insert(INSERT INTO dist_lock(lock_key, lock_desc) VALUES(#{lockKey}, #{desc})) int tryInsertLock(Param(lockKey) String lockKey,Param(desc) String desc); Delete(DELETE FROM dist_lock WHERE lock_key #{lockKey}) int releaseLock(Param(lockKey) String lockKey); }Service public class UniqueIndexLockService { Autowired private DistLockMapper distLockMapper; public boolean tryLock(String lockKey){ try { distLockMapper.tryInsertLock(lockKey,唯一索引锁); return true; }catch (DuplicateKeyException e){ // 唯一冲突锁被占用 return false; } } public void unLock(String lockKey){ distLockMapper.releaseLock(lockKey); } }调用示例boolean lock tryLock(stock_1001); if(lock){ try{ // 业务代码 }finally { unLock(stock_1001); } }else{ System.out.println(抢锁失败); }4.4 致命坑点非常重要坑 1拿到锁之后程序宕机没有执行 delete → 锁永久存在死锁插入成功之后服务挂了这条记录一直在表里其他线程永远抢不到锁。解决方案增加过期时间字段 定时清理过期锁记录。改造表ALTER TABLE dist_lock ADD COLUMN expire_time DATETIME COMMENT 锁过期时间;抢锁的时候存入 expire_time后台定时任务清理expire_time now()的锁。坑 2删除锁的时候可能误删别人已经拿到的锁锁过期自动被定时任务清掉B 线程拿到锁此时 A 线程执行完业务执行 delete删掉 B 的锁。优化锁记录存入客户端唯一 ID删除的时候同时校验 ID。坑 3并发插入、删除频繁产生大量碎片需要定期清理优点非阻塞、抢不到直接返回、实现简单缺点需要自己处理过期清理、容易死锁、delete 失败风险适用短周期任务、非阻塞抢锁场景。五、三种 MySQL 分布式锁方案横向对比表格方案底层是否阻塞是否需要事务死锁风险性能悲观锁 for updateInnoDB 行排他锁阻塞可 skip locked 改成非阻塞必须低事务释放较差乐观锁 version版本校验无数据库锁不阻塞可选无中等唯一索引锁唯一索引约束不阻塞不需要高宕机不删锁中等六、MySQL 分布式锁通用风险总结高频数据库单点问题如果只有单主库数据库挂掉所有锁全部失效需要主从架构但是锁操作只能走主库连接资源耗尽悲观锁大量阻塞会占用数据库连接池时间同步问题唯一索引锁依赖 expire_time服务器时间不一致会导致锁提前 / 延后释放无法支持锁续期业务执行时间不确定很难像 Redisson 看门狗一样自动续锁不支持可重入原生三种方案都不支持同一个线程重复加同一把锁需要自己加字段记录持有线程标识吞吐量天花板低数据库本身不适合扛超高并发锁竞争。七、什么时候选 MySQL 分布式锁什么时候不要选推荐使用内部后台系统、低并发定时任务防重复执行项目不方便引入 Redis/ZK快速落地并发冲突少一致性优先。不推荐秒杀、高并发库存扣减高 QPS 接口防重业务执行时间波动很大需要锁自动续期。八、思考题select ... for update什么时候行锁变表锁怎么避免for update 和 for update skip locked 的区别适用场景乐观锁解决了什么问题存在 ABA 问题吗version 方式能否解决唯一索引锁最大的问题是什么怎么解决宕机死锁MySQL 悲观锁分布式锁临界区里面能不能调用远程接口为什么九、小结MySQL 分布式锁三种方案各有定位 悲观锁是数据库原生锁机制强一致但容易阻塞乐观锁偏向业务冲突检测适合读多写少唯一索引锁是轻量抢锁方案但是需要自己做过期兜底。 MySQL 锁最大的短板是性能生产高并发场景几乎都会转向 Redis 分布式锁。下一篇我们进入专栏 03手写 Redis 分布式锁一步步迭代修复漏洞从最简 SETNX 版本到 Lua 解锁逐个踩坑。