Redis 分布式锁与 MySQL 事务如何对齐:从时间线到原理
Redis 分布式锁主要解决“谁先进入临界区”,MySQL 事务负责“数据库修改何时真正生效”。如果 Redis 锁在 MySQL 事务提交之前就释放,系统仍然可能出现并发问题。
这篇文章重点回答四个问题:
- Redis 分布式锁和 MySQL 事务一起用时,时序到底应该怎么放。
- 为什么“先解锁,后提交”会出问题。
- MySQL 事务底层到底靠什么保证原子性、隔离性和持久性。
- Redis 锁放在哪个事务节点之后释放,会更稳妥。
1. 先看核心结论
先把结论放在前面:
- 如果一个方法还处在 Spring 事务代理包裹之下,就不要简单地在业务方法的
finally里释放 Redis 锁。 - Redis 锁更稳妥的释放时机,是“事务完成之后”,而不是“业务代码执行完成之后”。
- 更准确地说,锁释放应该放在事务的
afterCompletion之后,也就是提交或回滚真正完成以后。 - 如果业务的并发冲突范围只在单库单行,优先考虑 MySQL 自己的锁,例如
SELECT ... FOR UPDATE。 - 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 本身不会直接操作数据库连接。
它表达的是一组规则:
- 传播行为:比如
REQUIRED、REQUIRES_NEW - 隔离级别:比如
READ_COMMITTED、REPEATABLE_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。
常见实现包括:
DataSourceTransactionManagerJdbcTransactionManagerJpaTransactionManager
如果项目使用 JDBC 或 MyBatis,通常走的是 DataSourceTransactionManager 或 JdbcTransactionManager。
它主要负责:
- 从连接池拿到
Connection - 关闭
autoCommit - 设置隔离级别
- 绑定连接到当前线程
- 提交或回滚连接
- 释放连接
也就是说,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 提供了事务同步回调机制。
常见节点包括:
beforeCommitbeforeCompletionafterCommitafterCompletion
它们的大致顺序是:
业务方法返回
-> 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:
- 它覆盖 commit 和 rollback 两种结束方式。
- 不管事务最终成功还是失败,锁都能被释放。
- 这能保证“数据库状态已经稳定”之后,后续线程才允许进入。
时序应当改成这样:
线程 A 获取 Redis 锁
线程 A 进入事务
线程 A 执行 SQL
线程 A 方法返回
Spring 代理 commit / rollback
事务完成
afterCompletion 回调执行
线程 A 释放 Redis 锁
线程 B 才能获取 Redis 锁
这时线程 B 再进入,读到的就是线程 A 事务结束后的稳定状态。
6. 为什么“事务完成后解锁”仍然不是万能方案
这里需要把边界讲清楚。
即使把 Redis 锁释放时机修正为“事务完成后”,也不代表系统就绝对不会出问题。
因为还要考虑以下问题:
- Redis 锁有没有超时。
- 业务执行时间是否超过锁租期。
- 应用宕机时锁如何自动释放。
- Redis 主从切换时锁状态是否一致。
- 业务 SQL 自身是否足够防御并发。
更合理的理解是:
- Redis 锁负责跨实例互斥进入
- MySQL 事务负责数据库内部一致性
- MySQL 自己的约束、唯一索引、行锁、条件更新,仍然应该承担最后一道数据正确性的责任
换句话说:
Redis 锁是入口控制,数据库约束和事务仍然要负责最终兜底。
7. MySQL 事务到底在做什么
很多文章讲事务时会先讲 ACID:
- Atomicity 原子性
- Consistency 一致性
- Isolation 隔离性
- Durability 持久性
但写业务系统时,只知道这四个词还不够,还需要理解它们背后的几个关键机制。
在 MySQL 的 InnoDB 存储引擎里,事务能力主要依赖下面几样东西:
- undo log
- redo log
- MVCC
- 锁
- 两阶段提交
下面按作用拆开讲,不追求覆盖所有数据库内核细节,只抓和业务并发最相关的部分。
8. undo log:回滚和一致性读的基础
它是什么
undo log 可以理解为“旧版本记录”。
当一行数据被更新前,InnoDB 会先把旧值保存下来,形成 undo 记录。
它解决什么问题
主要解决两件事:
- 回滚
- 一致性读
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 的做法是:
- 先修改内存页
- 把对应的 redo log 顺序写盘
- 提交成功
- 后续再慢慢刷脏页到数据文件
这样做的主要收益是:
- 顺序写日志快
- 崩溃恢复时可以根据 redo log 重放已经提交的修改
所以:
- undo log 负责“怎么撤回”
- redo log 负责“怎么恢复已提交结果”
10. MVCC:为什么另一个事务可能读到旧值
它是什么
MVCC 是多版本并发控制。
可以简单理解为:
一行数据在事务系统里不只一个版本,读请求会根据自己的视图选择应该看哪个版本。
它依赖什么
MVCC 主要依赖:
- 数据行上的隐藏字段
- undo log
- Read View
读的时候发生了什么
事务读一行数据时,不是简单地看“当前内存里最新值”,而是要判断:
- 这个版本是谁改的
- 修改它的事务是否对当前读请求可见
如果不可见,就沿着 undo log 去找更老的版本。
这和 Redis 锁问题有什么关系
关系很直接。
Redis 锁提前释放后,后续线程进入数据库时:
- 前一个事务可能还没提交
- 后一个事务根据 MVCC 读到的就是旧的已提交版本
于是两个线程虽然按顺序进入了 Redis 临界区,但它们看到的数据库状态并没有按同一条提交时间线推进。
所以这里的问题不是 Redis 命令本身错了,而是:
Redis 的互斥时间线,和 MySQL 的提交可见性时间线,没有对齐。
11. 什么时候该依赖 MySQL 自己的锁
MySQL 除了 MVCC,还有锁机制。
常见需要关注的有:
- 行锁
- 间隙锁
- Next-Key Lock
- 锁定读
对业务开发来说,最常见的是:
SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE或等价共享锁读
SELECT ... FOR UPDATE 到底做了什么
它不是普通查询,而是锁定读。
它的效果是:
- 读出目标行
- 同时对目标行加排他锁
之后其他事务如果也想更新这些行,就得等。
所以如果业务问题的核心就是:
- 单库
- 单表
- 某一行或某几行的并发更新冲突
那么更直接、语义也更清晰的方案,往往是数据库锁本身。
比如库存扣减,如果所有请求都落在同一个 MySQL 实例上,SELECT ... FOR UPDATE 往往比额外加一层 Redis 锁更直接。
12. Spring 事务为什么容易让人误判时机
常见误判通常有两个:
误判一:方法结束就等于事务结束
严格来说,不是。
在声明式事务里,事务通常由代理控制。
业务方法只是“被代理调用的目标方法”,真正的提交逻辑发生在方法返回之后。
误判二:方法里的 finally 就是最后时刻
也不是。
finally 只是业务方法体内部的最后时刻,不是整个事务生命周期的最后时刻。
事务生命周期还包括:
- 方法返回后
- 代理决定提交还是回滚
- 执行同步回调
所以把 Redis 解锁放在 finally,只是做到了业务方法维度上的“最后”,并不等于事务维度上的“最后”。
13. Redis 锁可以如何接入事务
这里给出一个更稳妥的思路。
写法原则
- 先获取 Redis 锁
- 再进入事务逻辑
- 在事务完成回调里释放 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 的价值主要体现在这几个点:
- 提供可重入分布式锁
- 提供 watchdog 自动续期
- 提供更丰富的锁原语
- 屏蔽原始 Redis 锁实现细节
1. 可重入
同一线程重复获取同一把锁时,不会直接把自己卡住,而是增加重入计数。
2. watchdog
如果没有显式设置固定租期,Redisson 会在持锁线程存活期间自动续约,避免业务执行时间稍长就把锁过期掉。
3. 更丰富的锁原语
包括:
- FairLock
- ReadWriteLock
- MultiLock
- Semaphore
- CountDownLatch
- FencedLock
但需要注意:
Redisson 提供的是更方便、更完整的分布式互斥能力,不是用来替代数据库事务的。
15. 什么时候适合用 Redis 锁
这是落地时最实际的问题。
优先不用 Redis 锁的场景
如果你的问题满足这些条件:
- 所有写流量都落到同一个 MySQL
- 冲突对象可以被某一行或某几行表示
- 业务只需要数据库级串行化
可以优先考虑:
- 唯一索引
- 条件更新
- 乐观锁版本号
SELECT ... FOR UPDATE
这些方案通常更简单,边界更清晰,也更容易验证正确性。
Redis 锁适合的场景
如果你的问题是:
- 多实例部署
- 同一个业务对象会被多个应用节点同时处理
- 临界区不只是一条 SQL,而是一串跨组件操作
- 需要一个跨进程的“入口互斥”
这时 Redis 锁会更有意义。
但即使如此,也建议记住:
Redis 锁主要负责控制入口,数据库规则负责兜住最终结果。
16. 工程实践建议
最后给一个更偏实践的建议清单。
第一层:数据库自身兜底
尽量保留这些兜底手段:
- 唯一索引
- 合法状态判断
- 条件更新
- 必要时用行锁
因为数据库层通常是最后一道防线。
第二层:Redis 锁控制入口并发
用于:
- 减少热点争用
- 防止多个实例同时进入同一业务对象
- 降低数据库冲突概率
第三层:锁释放时机和事务完成对齐
这是本文最核心的建议:
- 不要在事务方法体
finally中直接解锁 - 把解锁放到事务
afterCompletion - 如果事务是跨多个 Service 代理层触发的,确保你放锁的地方确实能拿到真实事务边界
第四层:不要只靠锁证明正确性
设计方案时,至少应该能回答:
- 锁丢了会怎样
- Redis 重启了会怎样
- 续期失败会怎样
- 事务回滚了会怎样
- 重复请求来了会怎样
如果这些问题答不清楚,说明系统正确性还需要继续收敛。
18. 最后总结
把整篇文章压缩成四句话:
- Redis 锁解决的是“谁先做”,MySQL 事务解决的是“什么时候真正生效”。
- Spring 声明式事务的提交时机晚于业务方法体返回。
- 所以 Redis 锁如果在
finally中释放,可能早于数据库事务提交。 - 更合理的做法是:锁在事务
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
更多推荐



所有评论(0)