一、前言
上一篇我们了解到分布式锁三大主流方案:MySQL、Redis、ZK。 MySQL 分布式锁是门槛最低的一种:不需要额外部署中间件,只要项目已经接入 MySQL,就能快速落地。很多后台管理系统、低并发定时任务防重场景都会优先选择它。
但是很多同学只听过select ... for update,不清楚它的底层限制、事务坑、行锁表锁转化、超时阻塞问题;乐观锁很多人只会写 version,却不知道它不是锁、只是冲突检测;唯一索引锁更是网上资料零散,很少有人完整梳理。
本篇我们分三种实现方案逐个拆解:
- 悲观锁:
select ... for update(行锁实现) - 乐观锁:版本号 version 机制
- 唯一索引锁(插入冲突抢锁)
每个方案包含:原理、完整 Java 代码、适用场景、坑点、失效场景、优缺点对比。最后会汇总三种方案怎么选,以及 MySQL 分布式锁通用风险。
重要前提:悲观锁依赖 InnoDB 引擎 + 事务;MyISAM 不支持事务和行锁,无法实现悲观锁分布式锁。
二、方案一:悲观锁 select ... for update
2.1 核心原理
悲观锁思想:默认并发一定会产生冲突,提前把资源锁住,别人来了就阻塞等待,直到锁释放 。 InnoDB 在事务中执行 select ... for update,会给命中的数据行加上排他行锁。


排他锁规则:
- 其他事务不能再加排他锁、共享锁;
- 其他事务的
select ... for update会阻塞; - 普通 select 快照读(不加锁)不受影响(MVCC);
- 事务提交 / 事务回滚 / 连接断开,行锁自动释放,天然避免永久死锁。
注意:
select ... lock in share mode是共享锁,读共享写互斥,一般不拿来做分布式互斥锁。
2.2 准备测试表
sql
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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分布式锁表';
-- 提前插入一条锁记录,我们要抢占 lock_key = "stock_1001"
INSERT INTO dist_lock(lock_key, lock_desc) VALUES ('stock_1001','商品库存锁');
要点:lock_key 代表一把锁,不同业务资源使用不同 lock_key。
2.3 Java 代码实现(SpringBoot + MyBatis)
Mapper 接口
java
@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(核心)
java
@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)。
坑 2:for update 没有在事务里写,锁直接失效
单独一条select ... for update自动提交事务,拿到锁立刻释放,等于没锁。必须包裹在事务中。
坑 3:阻塞无限等待
拿不到锁的线程会一直阻塞,直到拿到锁,或者 innodb_lock_wait_timeout 超时(默认 50s)抛出异常。 生产不能无限阻塞,两种优化:
- 设置较短锁等待超时
SET innodb_lock_wait_timeout = 5; - 使用
select ... for update skip locked跳过已经被锁住的行,不阻塞,抢不到直接返回,非阻塞模式(MySQL8.0 + 支持)
sql
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 建表语句
sql
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO stock(goods_id,stock_num,version) VALUES (1001,1,0);
3.3 Java 代码
Mapper
java
@Mapper
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);
}
Service
java
@Service
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 乐观锁经典坑点
坑 1:ABA 问题
线程 A 读取 version=0;线程 B 修改 version 到 1,又改回 version=0。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 是唯一索引。 抢锁 SQL:
sql
INSERT INTO dist_lock(lock_key,lock_desc) VALUES ('stock_1001','库存锁');
解锁 SQL:
sql
DELETE FROM dist_lock WHERE lock_key = 'stock_1001';
4.3 Java 代码示例
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);
}
java
@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);
}
}
调用示例:
java
boolean lock = tryLock("stock_1001");
if(lock){
try{
// 业务代码
}finally {
unLock("stock_1001");
}
}else{
System.out.println("抢锁失败");
}
4.4 致命坑点(非常重要)
坑 1:拿到锁之后程序宕机,没有执行 delete → 锁永久存在!死锁!
插入成功之后服务挂了,这条记录一直在表里,其他线程永远抢不到锁。
解决方案:增加过期时间字段 + 定时清理过期锁记录。
改造表:
sql
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 update | InnoDB 行排他锁 | 阻塞(可 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 解锁,逐个踩坑。