MySQL 实现分布式锁:悲观锁、乐观锁、唯一索引锁

一、前言

上一篇我们了解到分布式锁三大主流方案:MySQL、Redis、ZK。 MySQL 分布式锁是门槛最低的一种:不需要额外部署中间件,只要项目已经接入 MySQL,就能快速落地。很多后台管理系统、低并发定时任务防重场景都会优先选择它。

但是很多同学只听过select ... for update,不清楚它的底层限制、事务坑、行锁表锁转化、超时阻塞问题;乐观锁很多人只会写 version,却不知道它不是锁、只是冲突检测;唯一索引锁更是网上资料零散,很少有人完整梳理。

本篇我们分三种实现方案逐个拆解:

  1. 悲观锁:select ... for update(行锁实现)
  2. 乐观锁:版本号 version 机制
  3. 唯一索引锁(插入冲突抢锁)

每个方案包含:原理、完整 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)抛出异常。 生产不能无限阻塞,两种优化:

  1. 设置较短锁等待超时 SET innodb_lock_wait_timeout = 5;
  2. 使用 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。

流程:

  1. 查询数据,拿到当前 version;
  2. 执行业务逻辑;
  3. 更新的时候带上条件:where id=? and version=旧版本号;
  4. 如果影响行数 = 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 分布式锁通用风险总结(高频)

  1. 数据库单点问题:如果只有单主库,数据库挂掉,所有锁全部失效;需要主从架构,但是锁操作只能走主库;
  2. 连接资源耗尽:悲观锁大量阻塞会占用数据库连接池;
  3. 时间同步问题:唯一索引锁依赖 expire_time,服务器时间不一致会导致锁提前 / 延后释放;
  4. 无法支持锁续期:业务执行时间不确定,很难像 Redisson 看门狗一样自动续锁;
  5. 不支持可重入:原生三种方案都不支持同一个线程重复加同一把锁,需要自己加字段记录持有线程标识;
  6. 吞吐量天花板低:数据库本身不适合扛超高并发锁竞争。

七、什么时候选 MySQL 分布式锁?什么时候不要选?

推荐使用:

  • 内部后台系统、低并发;
  • 定时任务防重复执行;
  • 项目不方便引入 Redis/ZK,快速落地;
  • 并发冲突少,一致性优先。

不推荐:

  • 秒杀、高并发库存扣减;
  • 高 QPS 接口防重;
  • 业务执行时间波动很大,需要锁自动续期。

八、思考题

  1. select ... for update 什么时候行锁变表锁?怎么避免?
  2. for update 和 for update skip locked 的区别?适用场景?
  3. 乐观锁解决了什么问题?存在 ABA 问题吗?version 方式能否解决?
  4. 唯一索引锁最大的问题是什么?怎么解决宕机死锁?
  5. MySQL 悲观锁分布式锁,临界区里面能不能调用远程接口?为什么?

九、小结

MySQL 分布式锁三种方案各有定位: 悲观锁是数据库原生锁机制,强一致但容易阻塞;乐观锁偏向业务冲突检测,适合读多写少;唯一索引锁是轻量抢锁方案,但是需要自己做过期兜底。 MySQL 锁最大的短板是性能,生产高并发场景几乎都会转向 Redis 分布式锁。

下一篇,我们进入专栏 03:手写 Redis 分布式锁,一步步迭代修复漏洞,从最简 SETNX 版本到 Lua 解锁,逐个踩坑。

相关推荐
RuoyiOffice1 小时前
达梦 DM8 + openGauss:RuoYi Office 的 MySQL 数据转换与导入实践
数据库·spring boot·python·sql·信创
代码不停1 小时前
MyBatis操作数据库
数据库·mybatis
程序猿乐锅1 小时前
面向对象练习:图书借阅系统里的封装、抽象类与多态
开发语言·前端·数据库·后端·python·学习·1024程序员节
严同学正在努力1 小时前
PostgreSQL 高可用教程(流复制 + Patroni 生产级实战)
运维·数据库·ai·postgresql·架构
牧瀬クリスだ1 小时前
Redis String深度解析:从底层到五大实战场景
数据库·redis·缓存
Random Glow2 小时前
聚簇索引:为什么主键顺序决定插入性能
数据库·mysql·性能优化·innodb·索引
软件技术NINI2 小时前
基于PHP和MySQL的快递代收点管理系统的设计与实现
开发语言·javascript·数据库·mysql·毕业设计·php·毕设
SelectDB技术团队2 小时前
ClickHouse SQL 好学吗?从 MySQL 迁移 Doris 的完整兼容验证
人工智能·sql·mysql·clickhouse·开源·apache doris·增量计算
吹什么轩2 小时前
MySQL 增删改查(CRUD)完全指南:从 INSERT 到 GROUP BY
数据库·mysql