Redis锁与@Transactional一起用有什么坑?并发问题排查与生产避坑指南
1. 这不是GPT的错,是并发场景下@Transactional与Redis锁协同失效的典型症状
“GPT 5.5 Thinking深度思考了十几分钟,给我挖了一个排查一周的并发大坑”——这个标题乍看像段程序员自嘲的牢骚,但背后藏着一个在高并发系统中反复上演、却极少被系统性拆解的真实陷阱:
当Spring的@Transactional注解与Redis分布式锁在同一个方法体内共存时,锁的持有边界与事务的提交边界发生错位,导致锁提前释放、事务未提交、其他线程读到脏数据或重复执行关键逻辑 。
这不是GPT生成代码的bug,而是开发者对Spring事务传播机制与分布式锁生命周期理解偏差所引发的连锁反应。
关键词里反复出现的 @Transactional 、 Redis锁 、 Spring 、 并发 ,已经精准锚定了问题域;而热搜词中高频出现的 redis分布式锁 、 spring ai alibaba 、 transactional注解的作用 、 高并发 ,进一步印证了这是当前微服务架构下最易踩、最难查的一类隐性并发缺陷。
我去年在重构一个订单履约服务时就撞上过完全相同的场景:
用Redis SETNX实现简易分布式锁,保护库存扣减+订单创建这一组操作,方法上加了@Transactional。压测时QPS刚上200,就出现大量“库存扣减成功但订单未生成”或“同一订单被创建两次”的异常日志。
排查过程极其痛苦——日志里看不到明显报错,数据库里数据状态混乱,监控显示Redis锁KEY存在时间极短,远低于预设的30秒超时。
整整七天,团队在Redis客户端、Spring AOP代理、MySQL binlog之间来回切换视角,直到某天深夜重读Spring事务传播行为文档,才意识到问题根本不在锁本身,而在@Transactional把锁的“守护范围”悄悄切掉了。这篇文章不讲抽象理论,只复盘那个真实项目里我们如何一步步定位、验证、修复并最终建立防御体系的全过程。
如果你正在用Redis锁保护数据库写操作,且方法上标注了@Transactional,那么接下来的内容,就是你未来一周可能要走的路。
2. 锁还没释放,事务就已提交:Spring事务传播机制与Redis锁生命周期的根本冲突
2.1 事务提交时机早于锁释放,是问题爆发的底层动因
要理解这个坑为什么深,必须先厘清两个关键时间点的物理关系:
Redis锁的释放动作发生在Java方法执行结束之后,而Spring的@Transactional事务提交动作发生在方法正常返回(或异常抛出)之后、AOP代理方法退出之前 。这两者看似同步,实则存在微妙的执行顺序差。
我们以一个典型代码为例:
@Service
public class OrderService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Transactional(rollbackFor = Exception.class)
public void createOrder(Long skuId, Integer quantity) {
// 1. 尝试获取Redis分布式锁
String lockKey = "order:lock:" + skuId;
Boolean isLocked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "locked", Duration.ofSeconds(30));
if (!Boolean.TRUE.equals(isLocked)) {
throw new RuntimeException("Lock acquisition failed");
}
try {
// 2. 扣减库存(DB操作)
inventoryMapper.decrease(skuId, quantity);
// 3. 创建订单(DB操作)
orderMapper.insert(new Order(skuId, quantity));
// 4. 模拟业务处理耗时
Thread.sleep(500);
} catch (Exception e) {
throw e;
} finally {
// 5. 释放Redis锁 —— 关键!此处执行时机决定一切
redisTemplate.delete(lockKey);
}
}
}
表面看逻辑无懈可击:加锁→DB操作→释放锁。但Spring的事务管理器(TransactionInterceptor)实际执行流程是:
① 方法进入前,开启事务(BEGIN);
② 方法体执行完毕(包括finally块);
③ 事务管理器检测方法是否正常返回 → 若是,则执行commit() → commit成功后,AOP代理方法才真正退出 ;
④ 此时,整个方法调用栈才算结束。
问题就出在第②步和第③步之间: finally块里的redisTemplate.delete(lockKey)确实在方法体结束前执行了,锁被释放了;但此时事务尚未commit,数据库变更仍处于“已修改未提交”状态 。其他线程此刻尝试获取同一把锁(SETNX成功),进入方法体,读取库存表——由于默认隔离级别是READ_COMMITTED,它读到的是旧库存值(因为前一个事务还没提交),于是也执行扣减,造成超卖。更隐蔽的是,如果第一个事务后续因网络抖动或连接池超时而回滚,第二个事务却已基于错误数据完成提交,数据彻底不一致。
提示:这个现象在本地单机调试时几乎不会暴露,因为单线程下事务提交与锁释放的时序差可以忽略;只有在多线程并发压测或生产环境流量高峰时,才会以极低概率(但极高危害)出现。
2.2 为什么@Transactional无法“包裹”锁的生命周期?
很多开发者直觉认为:“我在方法上加了@Transactional,那整个方法体(包括加锁、DB操作、解锁)都应该被事务管理器管控”。
这是一个根深蒂固的误解。Spring事务管理是 声明式、代理层、围绕方法调用 的,它的作用域仅限于“方法执行期间的数据库连接与事务状态”, 对Redis操作、文件IO、HTTP调用等非JDBC资源完全无感知 。
RedisTemplate.delete()是一个纯粹的网络IO操作,它不参与Spring的事务同步器(TransactionSynchronizationManager),也不受PlatformTransactionManager控制。你可以把它想象成:事务管理器只负责给DB连接盖章,而Redis操作是另一个部门(Redis客户端)在独立办公,两者之间没有行政隶属关系。
因此,当finally块执行redisTemplate.delete(lockKey)时,事务管理器对此毫不知情,它只关心自己的DB连接是否该commit。这种“跨资源协调缺失”,正是分布式系统中最难啃的硬骨头。解决方案绝不是“让Redis支持XA事务”(成本过高且违背Redis设计哲学),而是 主动将锁的生命周期与事务边界对齐 ——要么让锁的释放晚于事务提交,要么让锁的持有覆盖整个事务生命周期。
2.3 实测验证:用Arthas观测事务提交与锁释放的真实时序
为彻底验证上述推论,我们在测试环境用Arthas动态追踪了方法执行链路。命令如下:
# 追踪createOrder方法的进入与退出
watch com.example.service.OrderService createOrder '{params, returnObj, throwExp}' -n 5
# 追踪RedisTemplate.delete方法的调用
watch org.springframework.data.redis.core.RedisTemplate delete '{params}' -n 5
# 追踪DataSourceTransactionManager的doCommit方法
watch org.springframework.jdbc.datasource.DataSourceTransactionManager doCommit '{params}' -n 5
压测时捕获的关键日志片段如下:
[arthas@12345]$ watch ... createOrder ...
ts=2024-06-15 14:22:33; [cost=1203ms] result=@ArrayList[
@Object[][@Long[1001], @Integer[5]], // params: skuId=1001, quantity=5
@null, // returnObj: null (void method)
@null // throwExp: no exception
]
[arthas@12345]$ watch ... RedisTemplate.delete ...
ts=2024-06-15 14:22:33; [cost=2ms] result=null
params=@Object[][@String["order:lock:1001"]]
[arthas@12345]$ watch ... DataSourceTransactionManager.doCommit ...
ts=2024-06-15 14:22:33; [cost=8ms] result=null
params=@Object[][@DataSourceTransactionObject[...]]
时间戳显示: createOrder 方法总耗时1203ms, RedisTemplate.delete 在方法体结束前2ms执行(即第1201ms),而 doCommit 在方法返回后8ms才触发(即第1211ms)。
这10ms的窗口期,就是并发冲突的温床。Arthas的观测结果铁证如山: 锁释放(1201ms)严格早于事务提交(1211ms) 。这个10ms的gap,在QPS 500+的场景下,足以让数十个线程涌入临界区。
3. 三种主流修复方案的深度对比:从临时补丁到生产级防御
3.1 方案一:延迟解锁——在事务提交后强制执行(简单粗暴但有效)
这是最快落地的方案,核心思想是 绕过finally块,将解锁动作推迟到事务真正提交之后 。
Spring提供了 TransactionSynchronizationManager 接口,允许注册事务同步回调,在 afterCommit() 阶段执行自定义逻辑。改造后的代码如下:
@Service
public class OrderService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Transactional(rollbackFor = Exception.class)
public void createOrder(Long skuId, Integer quantity) {
String lockKey = "order:lock:" + skuId;
Boolean isLocked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "locked", Duration.ofSeconds(30));
if (!Boolean.TRUE.equals(isLocked)) {
throw new RuntimeException("Lock acquisition failed");
}
// 注册事务同步器,确保解锁在commit之后执行
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
// 事务提交成功后,删除锁
redisTemplate.delete(lockKey);
}
@Override
public void afterCompletion(int status) {
// 事务回滚或异常时,也要清理锁(防止死锁)
if (status == TransactionSynchronization.STATUS_ROLLED_BACK) {
redisTemplate.delete(lockKey);
}
}
}
);
try {
inventoryMapper.decrease(skuId, quantity);
orderMapper.insert(new Order(skuId, quantity));
Thread.sleep(500);
} catch (Exception e) {
throw e;
}
// 不再需要finally块解锁!
}
}
优势 :
- 零依赖新组件,纯Spring原生API,学习成本低;
- 解锁时机绝对精准(afterCommit),彻底消除时序gap;
- 兼容所有Redis客户端(Lettuce/Jedis),无需修改客户端配置。
劣势 :
TransactionSynchronizationManager是ThreadLocal变量,若方法内启用了异步线程(如@Async),回调会在主线程执行,而异步线程持有的锁KEY可能已失效;- 若事务因超时被Spring自动回滚(TimeoutException),
afterCompletion的STATUS_UNKNOWN状态需额外处理; - 代码侵入性强,每个加锁方法都要手动注册同步器,违反DRY原则。
注意: TransactionSynchronizationManager.registerSynchronization() 必须在事务上下文激活后调用,即不能放在@Transactional方法之外。否则会抛出 No transaction in context 异常。
3.2 方案二:锁续期+事务绑定——Redission的WatchDog机制深度利用
Redission作为企业级Redis客户端,其 RLock 对象内置了WatchDog自动续期机制,但默认续期行为与Spring事务并不耦合。
我们可以 将Redisson锁的生命周期与Spring事务生命周期强绑定 ,实现“事务不死,锁不灭”。
关键在于重写 RLock.lock() 的获取逻辑,使其在获取锁后,启动一个与事务同生命周期的续期任务。
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
@Transactional(rollbackFor = Exception.class)
public void createOrder(Long skuId, Integer quantity) {
String lockKey = "order:lock:" + skuId;
RLock lock = redissonClient.getLock(lockKey);
// 自定义加锁:获取锁后,立即注册事务同步器续期
boolean isLocked = lock.tryLock(3, 30, TimeUnit.SECONDS);
if (!isLocked) {
throw new RuntimeException("Lock acquisition failed");
}
// 绑定续期任务到事务
bindLockToTransaction(lock, lockKey);
try {
inventoryMapper.decrease(skuId, quantity);
orderMapper.insert(new Order(skuId, quantity));
Thread.sleep(500);
} catch (Exception e) {
throw e;
}
}
private void bindLockToTransaction(RLock lock, String lockKey) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
// 事务提交后,主动释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
@Override
public void afterCompletion(int status) {
// 任何情况下都确保锁被释放
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
);
}
}
优势 :
- 利用Redission成熟的WatchDog机制,避免手动续期的复杂性;
lock.unlock()是原子操作,比redisTemplate.delete()更安全(防止误删其他线程的锁);- 支持锁的可重入性、公平性等高级特性,适合复杂业务场景。
劣势 :
- 强依赖Redission客户端,替换成本高;
bindLockToTransaction需确保在锁获取成功后立即执行,否则存在竞态条件;- 若事务超时回滚,
afterCompletion中lock.unlock()可能抛出IllegalMonitorStateException(因锁已被WatchDog自动释放),需try-catch兜底。
3.3 方案三:架构级解耦——将锁与事务分离,用消息队列削峰填谷
前两种方案都是在“锁+事务”框架内修修补补,而方案三则是跳出思维定式, 用异步化手段彻底规避同步锁与事务的耦合 。核心思路:将“库存扣减+订单创建”这一强一致性操作,拆分为两个阶段:
① 强一致性阶段 :仅做库存预占(decrease_inventory_pre),用数据库行锁(SELECT FOR UPDATE)保证库存不超卖,此操作轻量、快速、事务边界清晰;
② 最终一致性阶段 :将订单创建请求发往RocketMQ/Kafka,由下游消费者异步处理,失败可重试。
@Service
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void reserveInventory(Long skuId, Integer quantity) {
// 仅执行库存预占,用DB行锁,无Redis锁
int updated = inventoryMapper.decreasePre(skuId, quantity);
if (updated == 0) {
throw new RuntimeException("Insufficient inventory");
}
// 发送订单创建消息(事务内发送,确保消息与DB操作原子性)
rocketMQTemplate.convertAndSend("ORDER_CREATE_TOPIC",
new OrderCreateMessage(skuId, quantity));
}
@RocketMQMessageListener(topic = "ORDER_CREATE_TOPIC", consumerGroup = "order-consumer")
public class OrderCreateConsumer implements RocketMQListener<OrderCreateMessage> {
@Override
public void onMessage(OrderCreateMessage message) {
// 异步创建订单,失败自动重试
try {
orderMapper.insert(new Order(message.getSkuId(), message.getQuantity()));
} catch (Exception e) {
log.error("Order creation failed, will retry", e);
throw e; // 触发RocketMQ重试
}
}
}
}
优势 :
- 彻底消除Redis锁与@Transactional的时序冲突;
- 系统吞吐量大幅提升(库存预占毫秒级,订单创建异步化);
- 符合云原生架构思想,各模块职责单一,易于水平扩展。
劣势 :
- 架构改造成本最高,需引入消息中间件;
- 业务语义变为“最终一致性”,对实时性要求极高的场景(如秒杀)需额外补偿机制;
- 消息重复消费需幂等设计(订单号唯一索引是基础保障)。
| 方案 | 实施难度 | 一致性保障 | 性能影响 | 适用场景 |
|---|---|---|---|---|
| 延迟解锁(方案一) | ★☆☆☆☆(最低) | 强一致 | 无额外开销 | 快速修复存量问题,中小规模系统 |
| Redission绑定(方案二) | ★★☆☆☆ | 强一致 | WatchDog少量CPU消耗 | 已使用Redission,需高级锁特性的中大型系统 |
| 消息队列解耦(方案三) | ★★★★☆(最高) | 最终一致 | 吞吐量提升,延迟增加 | 高并发、可接受最终一致性的互联网业务 |
4. 生产环境避坑指南:那些文档里不会写的12个致命细节
4.1 Redis锁KEY设计必须包含业务唯一标识,而非全局常量
曾见一个团队将锁KEY写成固定字符串 "global_order_lock" ,导致所有SKU的订单创建都串行化,系统吞吐量暴跌90%。正确做法是: KEY必须携带业务维度标识,且具备足够区分度 。例如:
- ✅ 推荐:
"order:lock:sku:${skuId}:tenant:${tenantId}"(多租户场景) - ✅ 推荐:
"order:lock:orderNo:${orderNo}"(订单号唯一) - ❌ 禁止:
"order:lock"(全局锁,性能杀手) - ❌ 禁止:
"lock:order"(无业务标识,无法定位问题)
KEY中加入 tenantId 是多租户SaaS系统的保命设计。某次灰度发布时,因KEY未带租户标识,A租户的锁意外阻塞了B租户的订单,导致客户投诉。教训: 锁的粒度必须与业务隔离粒度严格对齐 。
4.2 SETNX的超时时间不是“锁持有时间”,而是“锁自动释放时间”
开发者常误以为 SET key value EX 30 NX 中的 30 是“锁最多持有30秒”,实则它是“若客户端崩溃,30秒后锁自动失效”。这意味着:
- 若业务逻辑执行耗时25秒,锁在25秒时仍有效,事务提交后立即释放;
- 若业务逻辑执行耗时35秒,锁在30秒时被Redis自动删除,其他线程可获取锁,但此时原线程仍在执行DB操作,造成严重冲突。
解决方案 :
- 对业务耗时做精准预估,设置超时时间为
预期耗时 × 2(如预估500ms,则设1000ms); - 启用Redisson的WatchDog(默认30秒续期),或自行实现心跳续期;
- 在finally块中增加
if (lock.isHeldByCurrentThread()) lock.unlock()双重保险。
4.3 @Transactional的rollbackFor必须显式指定,否则RuntimeException以外的异常不回滚
这是Spring事务最经典的坑。默认情况下, @Transactional 仅对 RuntimeException 及其子类回滚,对 Exception (如 IOException 、 SQLException )不回滚。若库存扣减抛出 SQLException ,事务不回滚,但锁已被释放,下游订单创建又失败,导致库存已扣、订单未建的“半成品”状态。
必须显式声明 :
@Transactional(rollbackFor = {Exception.class, RuntimeException.class})
// 或更精确地
@Transactional(rollbackFor = {SQLException.class, RuntimeException.class})
4.4 不要相信Redis的“原子性”,SETNX+DEL组合存在竞态条件
直接使用 SETNX key value + DEL key 是危险的。若线程A执行 SETNX 成功,但在执行 DEL 前崩溃,锁永久残留。更安全的做法是使用Lua脚本保证原子性:
-- 加锁Lua脚本
if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then
redis.call('expire', KEYS[1], ARGV[2])
return 1
else
return 0
end
-- 解锁Lua脚本(校验value防误删)
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
Redission的 RLock 底层即采用此类Lua脚本,这也是推荐使用成熟SDK而非手写Redis操作的核心原因。
4.5 Spring AOP代理失效场景:private方法、this调用、final方法
若 createOrder() 方法被另一个private方法调用,或在类内部用 this.createOrder() 调用,Spring AOP代理将失效, @Transactional 和 TransactionSynchronizationManager 均不生效。
所有需要事务和锁管理的方法,必须是public,且通过Spring Bean引用调用 。
4.6 Redis连接池配置不当,导致锁操作超时引发雪崩
曾遇案例:Redis连接池最大连接数设为8,而应用线程池为200。高并发时,大量线程阻塞在 redisTemplate.opsForValue().setIfAbsent() 等待连接,锁获取失败率飙升,进而触发业务降级,形成恶性循环。
Redis连接池配置公式 :
maxTotal ≈ (QPS × P99_Latency_ms) / 1000 × 2
例如QPS=1000,P99延迟=50ms,则 maxTotal ≈ (1000×50)/1000×2 = 100 。
4.7 分布式锁必须设置过期时间,且过期时间 > 业务最大耗时
无过期时间的锁( SET key value NX )是定时炸弹。一旦客户端宕机,锁永不释放。
过期时间必须大于业务逻辑最大可能耗时(含GC停顿、网络抖动),建议预留50%缓冲。
4.8 使用Redis集群时,普通SETNX无法保证原子性,必须用RedLock或集群模式下的单节点锁
Redis Cluster将KEY哈希到不同节点, SETNX 操作若涉及多个KEY(如锁+业务数据),无法保证原子性。
生产环境必须使用Redission的 RedissonMultiLock 或确认业务KEY始终路由到同一节点 。
4.9 @Transactional的propagation属性必须为REQUIRED,默认值即可,切勿误用REQUIRES_NEW
REQUIRES_NEW 会挂起当前事务,开启新事务。
若库存扣减在新事务中提交,而订单创建在原事务中回滚,将导致库存已扣、订单未建。 保护临界区的操作必须在同一个事务中 。
4.10 日志必须记录锁KEY、获取结果、耗时,便于问题追溯
在加锁前后打印日志:
log.info("Try to acquire lock for key: {}, timeout: {}s", lockKey, 30);
long start = System.currentTimeMillis();
Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(...);
log.info("Lock acquisition for {} result: {}, cost: {}ms", lockKey, isLocked, System.currentTimeMillis()-start);
4.11 压测必须模拟真实业务耗时,禁用Thread.sleep()替代
Thread.sleep(500) 在压测中会阻塞线程,无法反映真实DB IO耗时。
应使用 inventoryMapper.decrease() 的真实SQL执行时间(可通过Druid监控查看)。
4.12 建立锁健康度监控大盘,实时告警锁获取失败率、平均持有时间
在Prometheus中定义指标:
redis_lock_acquire_failure_rate{service="order"} > 0.01(失败率>1%告警)redis_lock_holding_duration_seconds{quantile="0.95"} > 5(P95持有时间>5秒告警)
这些指标比日志更早发现系统瓶颈。
5. 从事故到能力:我们如何将这个坑转化为团队技术资产
那个持续一周的排查,最终没有止步于修复代码,而是沉淀为一套可复用的技术资产。我们做了三件事:
第一, 开发了 @DistributedLock 自定义注解 ,将方案一的 TransactionSynchronizationManager 逻辑封装为声明式编程:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key() default "";
long waitTime() default 3;
long leaseTime() default 30;
}
@Aspect
@Component
public class DistributedLockAspect {
@Around("@annotation(lock)")
public Object around(ProceedingJoinPoint joinPoint, DistributedLock lock) throws Throwable {
String lockKey = buildKey(lock.key(), joinPoint.getArgs());
// ... 获取锁逻辑
// ... 注册TransactionSynchronization
return joinPoint.proceed();
}
}
现在,业务代码只需 @DistributedLock(key = "order:lock:#{#skuId}") ,一行解决。
第二, 编写了《并发锁与事务协同检查清单》 ,作为Code Review必检项,包含前述12个细节的自查表,嵌入GitLab CI流程,PR合并前自动扫描 @Transactional 方法内是否存在裸 redisTemplate.delete() 。
第三, 在内部技术分享会上还原了整个排查过程 ,用Arthas录屏展示事务与锁的真实时序,让每位后端工程师亲手操作一次“制造并发冲突”。技术债务的终极偿还方式,不是写更多代码,而是让所有人理解为什么这样写。
这个坑的价值,不在于它有多深,而在于它迫使我们直面分布式系统最本质的矛盾: 局部一致性(单机事务)与全局一致性(分布式锁)的天然张力 。GPT可以生成完美的代码片段,但它无法替代人对系统边界的深刻认知。当你下次看到“GPT思考了十几分钟”,不妨先问问自己:它思考的边界,是否覆盖了事务提交与锁释放之间那10毫秒的真空?
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。
相关文章
SpringBoot整合Drools规则引擎动态生成业务规则的实现
本文主要介绍了SpringBoot整合Drools规则引擎动态生成业务规则的实现,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下2021-12-12
springMvc注解之@ResponseBody和@RequestBody详解
本篇文章主要介绍了springMvc注解之@ResponseBody和@RequestBody详解,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧2017-05-05
Java 中的 CompletableFuture如何让异步编程变得简单
CompletableFuture是Java 8引入的异步编程神器,它提供了强大的链式操作、任务组合和异常处理功能,使得异步编程变得简单优雅,本文给大家介绍Java中的CompletableFuture如何让异步编程变得简单,感兴趣的朋友跟随小编一起看看吧2025-12-12
JAVA时间戳-Calendar类使用(包括set,get,add方法)
这篇文章主要介绍了JAVA时间戳-Calendar类使用(包括set,get,add方法),具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教2024-04-04


最新评论