Redis 分布式锁主要解决“谁先进入临界区”,MySQL 事务负责“数据库修改何时真正生效”。如果 Redis 锁在 MySQL 事务提交之前就释放,系统仍然可能出现并发问题。

这篇文章重点回答四个问题:

  1. Redis 分布式锁和 MySQL 事务一起用时,时序到底应该怎么放。
  2. 为什么“先解锁,后提交”会出问题。
  3. MySQL 事务底层到底靠什么保证原子性、隔离性和持久性。
  4. Redis 锁放在哪个事务节点之后释放,会更稳妥。

1. 先看核心结论

先把结论放在前面:

  1. 如果一个方法还处在 Spring 事务代理包裹之下,就不要简单地在业务方法的 finally 里释放 Redis 锁。
  2. Redis 锁更稳妥的释放时机,是“事务完成之后”,而不是“业务代码执行完成之后”。
  3. 更准确地说,锁释放应该放在事务的 afterCompletion 之后,也就是提交或回滚真正完成以后。
  4. 如果业务的并发冲突范围只在单库单行,优先考虑 MySQL 自己的锁,例如 SELECT ... FOR UPDATE
  5. Redis 锁更适合解决跨 JVM、跨实例、跨服务的互斥进入问题,但它不是数据库事务的替代品。

可以先记住这句话:

锁控制进入顺序,事务控制生效时刻,二者的边界需要对齐。


2. 问题通常从哪里来

在实际项目里,经常会看到类似下面的代码:

@Transactional
public void createOrder() {
    RLock lock = redissonClient.getLock("lock:order");
    lock.lock();
    try {
        // 1. 查库存
        // 2. 扣库存
        // 3. 写订单
    } finally {
        lock.unlock();
    }
}

这段代码第一眼看起来很合理:

  • 进入方法先加 Redis 锁
  • 业务执行完再解锁
  • 方法上还有 @Transactional

但这里有一个容易被忽略的点:

业务方法返回,不等于事务已经提交。

在 Spring 默认的声明式事务模型里,事务提交动作发生在代理层,而不是发生在你的业务代码方法体内部。

也就是说,finally 执行时,很多情况下:

  • 你的业务代码确实跑完了
  • 但是数据库事务还没有真正 commit

这就是“锁释放时机早于事务提交时机”的关键错位。


3. 正确理解 Spring 事务的真实时序

先把 Spring 事务的主链路画出来:

HTTP 请求
  -> Controller
  -> Service 代理对象
  -> TransactionInterceptor 开启事务
  -> 调用真正的业务方法
  -> 业务方法返回
  -> TransactionInterceptor 决定 commit / rollback
  -> afterCompletion 回调

关键点不在“方法执行”,而在“代理何时提交”。

如果把 Redis 锁放进去,有风险的时序通常长这样:

线程 A 获取 Redis 锁
线程 A 进入 @Transactional 方法
线程 A 执行业务 SQL
线程 A 在 finally 中释放 Redis 锁
线程 A 方法返回
Spring 代理此时才开始提交事务
线程 B 获取 Redis 锁
线程 B 读取到旧的已提交数据
线程 B 继续执行业务

这里最关键的一点是:

线程 B 看到的是“数据库里已经提交的数据”,而不是线程 A 在事务里尚未提交的数据。

MySQL 默认不会把其他事务未提交的数据暴露出来,所以线程 B 此时很可能读到旧值。

于是就会出现:

  • 重复下单
  • 超卖
  • 误判幂等状态
  • 重复发券
  • 重复执行某个本应串行的状态迁移

这就是“Redis 锁看起来加了,但并发问题仍然可能发生”的主要原因。


3.1 Spring 事务实现原理:从 @Transactional 到真正提交

第一次接触 Spring 事务时,很容易把 @Transactional 理解成“这个注解会直接开启事务”。更准确地说,注解本身只是一个入口标记。

真正完成事务工作的,是 Spring 在运行时组装出来的一整条链路:

@Transactional 注解
  -> Spring 扫描事务元数据
  -> 创建代理对象
  -> 方法调用进入 TransactionInterceptor
  -> TransactionManager 开启事务
  -> JDBC Connection 绑定到当前线程
  -> 执行业务方法
  -> 根据异常类型决定提交或回滚
  -> 触发事务同步回调
  -> 清理线程绑定资源

把这条链路拆开看,就能理解为什么 Redis 锁最好放到 afterCompletion 之后释放。

1. @Transactional 只是事务元数据

@Transactional 本身不会直接操作数据库连接。

它表达的是一组规则:

  • 传播行为:比如 REQUIREDREQUIRES_NEW
  • 隔离级别:比如 READ_COMMITTEDREPEATABLE_READ
  • 超时时间
  • 是否只读
  • 什么异常触发回滚

Spring 启动时会读取这些规则,并把它们保存成事务属性。后续方法调用经过代理时,Spring 再根据这些属性决定如何开启、提交或回滚事务。

所以更准确的说法是:

@Transactional 更像是一份事务配置,而不是事务执行器。

2. Spring 通过代理拦截方法调用

Spring 默认通过代理模式实现声明式事务。

代理可以理解成“包在目标对象外面的一层对象”。调用方先调用代理,代理再决定是否开启事务,然后再调用真正的业务对象。

你调用的看起来是 Service,实际调用路径是:

Controller
  -> Service 代理对象
  -> TransactionInterceptor
  -> 真正的 Service 对象

这也是为什么 self invocation,也就是类内部自己调用自己的方法,会让事务失效:

public void outer() {
    this.inner();
}

@Transactional
public void inner() {
    // ...
}

this.inner() 是普通 Java 对象内部调用,没有经过代理对象,因此 TransactionInterceptor 无法介入。

这说明一个很重要的点:

事务不是跟着方法声明走的,而是跟着代理调用路径走的。

3. TransactionInterceptor 是事务的入口

当调用进入代理对象后,真正负责事务处理的是 TransactionInterceptor

它的核心逻辑可以简化成下面这样:

public Object invoke(MethodInvocation invocation) {
    TransactionInfo txInfo = createTransactionIfNecessary();
    try {
        Object result = invocation.proceed();
        commitTransactionAfterReturning(txInfo);
        return result;
    } catch (Throwable ex) {
        completeTransactionAfterThrowing(txInfo, ex);
        throw ex;
    } finally {
        cleanupTransactionInfo(txInfo);
    }
}

这段伪代码里最值得注意的是顺序:

开启事务
  -> 执行业务方法
  -> 业务方法返回
  -> 提交事务

所以,业务方法内部的 finally 会早于 commitTransactionAfterReturning

如果 Redis 锁在业务方法的 finally 中释放,它自然会早于后面的事务提交动作。这就是提前解锁问题的来源。

4. PlatformTransactionManager 负责真正管理事务

TransactionInterceptor 自己不直接操作数据库连接。

它会委托给 PlatformTransactionManager

常见实现包括:

  • DataSourceTransactionManager
  • JdbcTransactionManager
  • JpaTransactionManager

如果项目使用 JDBC 或 MyBatis,通常走的是 DataSourceTransactionManagerJdbcTransactionManager

它主要负责:

  1. 从连接池拿到 Connection
  2. 关闭 autoCommit
  3. 设置隔离级别
  4. 绑定连接到当前线程
  5. 提交或回滚连接
  6. 释放连接

也就是说,Spring 事务最终还是要落到数据库连接上,本质上是在控制 JDBC Connection 的提交和回滚。

5. Connection 会被绑定到当前线程

Spring 事务里一个很重要的组件是 TransactionSynchronizationManager

它的作用之一是把当前事务资源绑定到当前线程。

可以理解成:

当前线程
  -> 绑定 DataSource
  -> 绑定 Connection
  -> 绑定事务同步回调

为什么要绑定到线程?

因为业务代码里可能会多次调用 Repository 或 Mapper。

Spring 要保证这些 SQL 使用同一个数据库连接,才能保证它们属于同一个事务。

例如:

service 方法开始
  -> SQL 1 使用 connection-1
  -> SQL 2 使用 connection-1
  -> SQL 3 使用 connection-1
service 方法返回
  -> connection-1 commit

如果每次 SQL 都拿到不同连接,它们就不再属于同一个数据库事务。

所以:

Spring 事务的关键不是让 Java 方法自动变得特殊,而是把同一个 JDBC Connection 贯穿到整个调用链。

6. 回滚规则发生在代理层

Spring 默认回滚规则是:

  • RuntimeException 回滚
  • Error 回滚
  • 受检异常默认不回滚

如果你配置:

@Transactional(rollbackFor = DemoCheckedException.class)

Spring 才会把这个受检异常也纳入回滚规则。

注意:这个判断也发生在 TransactionInterceptor 里,而不是业务方法内部。

7. TransactionSynchronization 是事务生命周期回调

Spring 提供了事务同步回调机制。

常见节点包括:

  • beforeCommit
  • beforeCompletion
  • afterCommit
  • afterCompletion

它们的大致顺序是:

业务方法返回
  -> beforeCommit
  -> beforeCompletion
  -> 数据库 commit / rollback
  -> afterCommit
  -> afterCompletion
  -> 清理线程资源

严格说,afterCommit 只在提交成功后执行。

afterCompletion 在提交或回滚后都会执行,并且会带上事务最终状态。

因此释放 Redis 锁更适合放在 afterCompletion

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override
    public void afterCompletion(int status) {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
});

原因很直接:

  • 提交成功,要释放锁
  • 回滚完成,也要释放锁
  • 释放前,事务已经结束

8. Redis 锁为什么不能只看 Java 方法作用域

有风险的写法关注的是 Java 方法作用域:

进入方法
  -> 加锁
  -> 执行业务
  -> finally 解锁
  -> 方法返回

更稳妥的写法关注的是事务生命周期:

进入事务代理
  -> 开启事务
  -> 执行业务方法
  -> 方法返回
  -> 提交或回滚事务
  -> afterCompletion
  -> 解锁

这两个作用域不是一回事。业务方法体结束,只能说明 Java 代码执行完了,不能说明数据库事务已经结束。

只要关键业务依赖 @Transactional,就应该以事务生命周期为准,而不是只看方法体生命周期。

9. 和本文代码的对应关系

在示例项目里,有风险的场景对应:

@Transactional
public OperationRecord purchaseWithRedissonUnlockBeforeCommit(...) {
    RLock lock = redissonClient.getLock(...);
    lock.tryLock(...);
    try {
        // SQL
    } finally {
        lock.unlock();
    }
}

问题在于这个顺序:

finally 解锁
  -> 方法返回
  -> Spring 代理提交事务

修正后的场景对应:

@Transactional
public OperationRecord purchaseWithRedissonUnlockAfterCommit(...) {
    RLock lock = redissonClient.getLock(...);
    lock.tryLock(...);

    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCompletion(int status) {
            lock.unlock();
        }
    });

    // SQL
}

核心变化只有一个:

解锁动作从业务方法 finally,移动到了事务完成回调。

这就是 Spring 事务实现原理对 Redis 锁接入位置的直接影响:要对齐的不是方法结束点,而是事务结束点。


4. 用库存扣减看一次完整时序

假设库存初始值是 1

下面是一种有风险的时序:

时间点 T1:
线程 A 获取 Redis 锁

时间点 T2:
线程 A 开启事务
线程 A 读取库存 = 1
线程 A 更新库存 = 0
线程 A 插入订单

时间点 T3:
线程 A 在 finally 中释放 Redis 锁

时间点 T4:
线程 B 获取 Redis 锁
线程 B 开启事务
线程 B 读取库存 = 1
原因:线程 A 还没真正 commit,线程 B 只能看到旧的已提交值

时间点 T5:
线程 A commit

时间点 T6:
线程 B 扣库存,插订单

最后结果可能变成:

  • 数据库里出现两条订单
  • 最终库存还是 0

为什么库存不是 -1

因为这类问题常常不是“数值直接减到负数”,而是“两个事务基于同一个旧值做覆盖写”,也就是常说的 lost update,中文通常叫“丢失更新”。

这类问题比较隐蔽。只看库存最终值,可能觉得结果没问题;但订单数、流水数或状态流转次数已经不对了。


5. Redis 锁应该在哪个事务节点之后释放

比较稳妥的答案是:

Redis 锁应该在事务真正完成之后释放。

在 Spring 里,更准确的落点是:

  • afterCommit 之后,或者
  • 更稳妥地说,afterCompletion 里释放

为什么更推荐 afterCompletion

  1. 它覆盖 commit 和 rollback 两种结束方式。
  2. 不管事务最终成功还是失败,锁都能被释放。
  3. 这能保证“数据库状态已经稳定”之后,后续线程才允许进入。

时序应当改成这样:

线程 A 获取 Redis 锁
线程 A 进入事务
线程 A 执行 SQL
线程 A 方法返回
Spring 代理 commit / rollback
事务完成
afterCompletion 回调执行
线程 A 释放 Redis 锁
线程 B 才能获取 Redis 锁

这时线程 B 再进入,读到的就是线程 A 事务结束后的稳定状态。


6. 为什么“事务完成后解锁”仍然不是万能方案

这里需要把边界讲清楚。

即使把 Redis 锁释放时机修正为“事务完成后”,也不代表系统就绝对不会出问题。

因为还要考虑以下问题:

  1. Redis 锁有没有超时。
  2. 业务执行时间是否超过锁租期。
  3. 应用宕机时锁如何自动释放。
  4. Redis 主从切换时锁状态是否一致。
  5. 业务 SQL 自身是否足够防御并发。

更合理的理解是:

  • Redis 锁负责跨实例互斥进入
  • MySQL 事务负责数据库内部一致性
  • MySQL 自己的约束、唯一索引、行锁、条件更新,仍然应该承担最后一道数据正确性的责任

换句话说:

Redis 锁是入口控制,数据库约束和事务仍然要负责最终兜底。


7. MySQL 事务到底在做什么

很多文章讲事务时会先讲 ACID:

  • Atomicity 原子性
  • Consistency 一致性
  • Isolation 隔离性
  • Durability 持久性

但写业务系统时,只知道这四个词还不够,还需要理解它们背后的几个关键机制。

在 MySQL 的 InnoDB 存储引擎里,事务能力主要依赖下面几样东西:

  1. undo log
  2. redo log
  3. MVCC
  4. 两阶段提交

下面按作用拆开讲,不追求覆盖所有数据库内核细节,只抓和业务并发最相关的部分。


8. undo log:回滚和一致性读的基础

它是什么

undo log 可以理解为“旧版本记录”。

当一行数据被更新前,InnoDB 会先把旧值保存下来,形成 undo 记录。

它解决什么问题

主要解决两件事:

  1. 回滚
  2. 一致性读

1. 回滚

如果事务失败,需要撤销之前做过的更新,就可以根据 undo log 把数据恢复回去。

例如:

原值 stock = 1
事务中改成 stock = 0
如果事务回滚,就根据 undo log 恢复成 stock = 1

2. 一致性读

另一个关键用途是支撑 MVCC。

当事务 B 读取某行时,如果事务 A 修改过这行但还未提交,事务 B 不会直接看到未提交的新值,而是可以沿着 undo log 找到更早的可见版本。

这也是为什么前面那个 Redis 提前解锁的场景里,线程 B 很可能看到旧库存。

因为:

  • 线程 A 的新值还没提交
  • 线程 B 根据隔离级别和可见性规则,会回到旧版本读取

所以线程 B 读到旧值并不是偶然,它和 MySQL 的一致性读机制有关。


9. redo log:保证提交后的修改不会丢

它是什么

redo log 可以理解为“重做日志”,记录的是数据页修改后的物理变化。

它解决什么问题

它解决持久性问题。

也就是:

事务已经提交了,哪怕数据库突然崩溃,重启后也应该能把已经提交的修改恢复出来。

为什么需要它

因为如果每次提交都直接把所有脏页刷回磁盘,成本太高。

所以 InnoDB 的做法是:

  1. 先修改内存页
  2. 把对应的 redo log 顺序写盘
  3. 提交成功
  4. 后续再慢慢刷脏页到数据文件

这样做的主要收益是:

  • 顺序写日志快
  • 崩溃恢复时可以根据 redo log 重放已经提交的修改

所以:

  • undo log 负责“怎么撤回”
  • redo log 负责“怎么恢复已提交结果”

10. MVCC:为什么另一个事务可能读到旧值

它是什么

MVCC 是多版本并发控制。

可以简单理解为:

一行数据在事务系统里不只一个版本,读请求会根据自己的视图选择应该看哪个版本。

它依赖什么

MVCC 主要依赖:

  1. 数据行上的隐藏字段
  2. undo log
  3. Read View

读的时候发生了什么

事务读一行数据时,不是简单地看“当前内存里最新值”,而是要判断:

  • 这个版本是谁改的
  • 修改它的事务是否对当前读请求可见

如果不可见,就沿着 undo log 去找更老的版本。

这和 Redis 锁问题有什么关系

关系很直接。

Redis 锁提前释放后,后续线程进入数据库时:

  • 前一个事务可能还没提交
  • 后一个事务根据 MVCC 读到的就是旧的已提交版本

于是两个线程虽然按顺序进入了 Redis 临界区,但它们看到的数据库状态并没有按同一条提交时间线推进。

所以这里的问题不是 Redis 命令本身错了,而是:

Redis 的互斥时间线,和 MySQL 的提交可见性时间线,没有对齐。


11. 什么时候该依赖 MySQL 自己的锁

MySQL 除了 MVCC,还有锁机制。

常见需要关注的有:

  1. 行锁
  2. 间隙锁
  3. Next-Key Lock
  4. 锁定读

对业务开发来说,最常见的是:

  • SELECT ... FOR UPDATE
  • SELECT ... LOCK IN SHARE MODE 或等价共享锁读

SELECT ... FOR UPDATE 到底做了什么

它不是普通查询,而是锁定读。

它的效果是:

  • 读出目标行
  • 同时对目标行加排他锁

之后其他事务如果也想更新这些行,就得等。

所以如果业务问题的核心就是:

  • 单库
  • 单表
  • 某一行或某几行的并发更新冲突

那么更直接、语义也更清晰的方案,往往是数据库锁本身。

比如库存扣减,如果所有请求都落在同一个 MySQL 实例上,SELECT ... FOR UPDATE 往往比额外加一层 Redis 锁更直接。


12. Spring 事务为什么容易让人误判时机

常见误判通常有两个:

误判一:方法结束就等于事务结束

严格来说,不是。

在声明式事务里,事务通常由代理控制。

业务方法只是“被代理调用的目标方法”,真正的提交逻辑发生在方法返回之后。

误判二:方法里的 finally 就是最后时刻

也不是。

finally 只是业务方法体内部的最后时刻,不是整个事务生命周期的最后时刻。

事务生命周期还包括:

  • 方法返回后
  • 代理决定提交还是回滚
  • 执行同步回调

所以把 Redis 解锁放在 finally,只是做到了业务方法维度上的“最后”,并不等于事务维度上的“最后”。


13. Redis 锁可以如何接入事务

这里给出一个更稳妥的思路。

写法原则

  1. 先获取 Redis 锁
  2. 再进入事务逻辑
  3. 在事务完成回调里释放 Redis 锁

伪代码如下:

public void execute() {
    RLock lock = redissonClient.getLock(lockKey);
    lock.lock();
    try {
        transactionalService.doInTransaction(lock);
    } catch (Exception e) {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
        throw e;
    }
}

@Transactional
public void doInTransaction(RLock lock) {
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCompletion(int status) {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    });

    // 业务 SQL
}

这段代码只是示意,核心不是具体写法,而是这个约束:

锁的释放时机应当晚于事务完成时机。


14. Redisson 在这里起什么作用

Redisson 的价值主要体现在这几个点:

  1. 提供可重入分布式锁
  2. 提供 watchdog 自动续期
  3. 提供更丰富的锁原语
  4. 屏蔽原始 Redis 锁实现细节

1. 可重入

同一线程重复获取同一把锁时,不会直接把自己卡住,而是增加重入计数。

2. watchdog

如果没有显式设置固定租期,Redisson 会在持锁线程存活期间自动续约,避免业务执行时间稍长就把锁过期掉。

3. 更丰富的锁原语

包括:

  • FairLock
  • ReadWriteLock
  • MultiLock
  • Semaphore
  • CountDownLatch
  • FencedLock

但需要注意:

Redisson 提供的是更方便、更完整的分布式互斥能力,不是用来替代数据库事务的。


15. 什么时候适合用 Redis 锁

这是落地时最实际的问题。

优先不用 Redis 锁的场景

如果你的问题满足这些条件:

  • 所有写流量都落到同一个 MySQL
  • 冲突对象可以被某一行或某几行表示
  • 业务只需要数据库级串行化

可以优先考虑:

  1. 唯一索引
  2. 条件更新
  3. 乐观锁版本号
  4. SELECT ... FOR UPDATE

这些方案通常更简单,边界更清晰,也更容易验证正确性。

Redis 锁适合的场景

如果你的问题是:

  • 多实例部署
  • 同一个业务对象会被多个应用节点同时处理
  • 临界区不只是一条 SQL,而是一串跨组件操作
  • 需要一个跨进程的“入口互斥”

这时 Redis 锁会更有意义。

但即使如此,也建议记住:

Redis 锁主要负责控制入口,数据库规则负责兜住最终结果。


16. 工程实践建议

最后给一个更偏实践的建议清单。

第一层:数据库自身兜底

尽量保留这些兜底手段:

  1. 唯一索引
  2. 合法状态判断
  3. 条件更新
  4. 必要时用行锁

因为数据库层通常是最后一道防线。

第二层:Redis 锁控制入口并发

用于:

  • 减少热点争用
  • 防止多个实例同时进入同一业务对象
  • 降低数据库冲突概率

第三层:锁释放时机和事务完成对齐

这是本文最核心的建议:

  1. 不要在事务方法体 finally 中直接解锁
  2. 把解锁放到事务 afterCompletion
  3. 如果事务是跨多个 Service 代理层触发的,确保你放锁的地方确实能拿到真实事务边界

第四层:不要只靠锁证明正确性

设计方案时,至少应该能回答:

  1. 锁丢了会怎样
  2. Redis 重启了会怎样
  3. 续期失败会怎样
  4. 事务回滚了会怎样
  5. 重复请求来了会怎样

如果这些问题答不清楚,说明系统正确性还需要继续收敛。


18. 最后总结

把整篇文章压缩成四句话:

  1. Redis 锁解决的是“谁先做”,MySQL 事务解决的是“什么时候真正生效”。
  2. Spring 声明式事务的提交时机晚于业务方法体返回。
  3. 所以 Redis 锁如果在 finally 中释放,可能早于数据库事务提交。
  4. 更合理的做法是:锁在事务 afterCompletion 之后释放,数据库继续承担最终正确性约束。

如果只记一句,可以记这句:

不要把业务方法结束,当成事务结束。


参考资料

  • Spring 声明式事务管理
    https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative.html

  • Spring @Transactional 与事务拦截机制
    https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/tx-decl-explained.html

  • Spring @Transactional 与代理 / self-invocation
    https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/annotations.html

  • Redisson 官方文档
    https://redisson.pro/docs/getting-started/

  • Redisson Locks and Synchronizers
    https://redisson.pro/docs/data-and-services/locks-and-synchronizers/index.html

  • MySQL 5.7 InnoDB Transaction Isolation Levels
    https://dev.mysql.com/doc/refman/5.7/en/innodb-transaction-isolation-levels.html

  • MySQL 5.7 Locking Reads
    https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.htmlqq

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐