1. 事故背景与典型场景还原
去年双十一大促期间,我们电商平台的优惠券发放服务出现了一次严重的超发事故。原本每人限领3张的满减券,在高并发请求下出现了单用户领取10+张的异常情况。事后排查发现,问题出在分布式锁与事务日志的配合使用上——当两个线程同时检测到库存充足时,由于锁释放时机不当,导致事务提交前其他线程已经获取到"虚假"的库存信息。
这种分布式环境下的事务隔离失效,在Java开发中绝非个例。根据我的事故复盘统计,90%的同类问题都源于以下7个致命陷阱:
- 锁过期时间小于事务执行时间(典型症状:锁提前释放)
- 事务内嵌套远程调用(导致事务边界模糊)
- 锁与事务管理器未统一(如Redis锁+MySQL事务)
- 日志记录与业务操作未原子化(先记日志后操作)
- 锁重入机制缺失(同一线程多次加锁失败)
- 异常处理未考虑锁状态(finally块中直接释放)
- 监控缺失导致问题滞后(无锁等待时间告警)
2. 分布式锁的四大死亡陷阱与破解之道
2.1 锁过期时间与事务执行的生死竞速
这是最隐蔽的坑点之一。假设我们设置Redis分布式锁过期时间为3秒,但事务中包含一个耗时5秒的第三方API调用:
@Transactional public void deductStock(Long itemId) { String lockKey = "lock:item:" + itemId; try { // 获取锁(设置3秒过期) boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (!locked) throw new RuntimeException("操作太频繁"); // 耗时操作(超过3秒) callExternalAPI(); // 执行业务逻辑 stockService.reduce(itemId); } finally { redisTemplate.delete(lockKey); // 此时锁可能已自动过期 } }问题本质:当锁自动过期时,其他线程可能已经获取到锁并读取到未提交的中间状态数据。解决方案有:
- 自动续期机制:使用Redisson的watchDog或自定义守护线程
- 事务压缩:将远程调用移出事务边界
- 双重检查:提交前再次验证数据状态
2.2 锁与事务管理器的割裂之痛
MySQL的RR隔离级别无法感知Redis锁的存在,这会导致如下诡异现象:
-- 线程1获取Redis锁 BEGIN; SELECT stock FROM items WHERE id=1; -- 返回100 -- 此时线程2也获取Redis锁并执行相同查询(仍看到100) UPDATE items SET stock=99 WHERE id=1; COMMIT; -- 最终stock=99而非预期的98破解方案:
- 使用SELECT FOR UPDATE统一锁来源
- 或引入XA事务协调器
- 最简方案:在事务内添加防重表校验
2.3 锁释放的异常处理黑洞
以下代码在异常处理时存在严重缺陷:
try { lock.lock(); doBusiness(); } catch (Exception e) { log.error("业务异常", e); // 忘记释放锁! } finally { if (lock.isHeldByCurrentThread()) { // 必须判断 lock.unlock(); } }经验法则:
- 在finally中释放锁
- 释放前检查持有状态
- 考虑锁的租约时间(leaseTime)
2.4 重入锁的认知误区
很多开发者误以为Redis的setIfAbsent天然支持可重入。实际上需要额外处理:
// 错误示范 public void reentrantMethod() { lock(); innerMethod(); // 内部也需要锁 unlock(); } // 正确实现(使用Redisson或自定义计数器) private Map<Thread, Integer> reentrantCount = new ConcurrentHashMap<>(); void lock() { Thread current = Thread.currentThread(); if (reentrantCount.get(current) != null) { reentrantCount.put(current, reentrantCount.get(current) + 1); return; } // ...实际加锁逻辑 }3. 事务日志的三大致命陷阱
3.1 日志与业务操作的顺序陷阱
考虑以下资金转账场景:
@Transactional public void transfer(Account from, Account to, BigDecimal amount) { // 先记日志 logService.record(from.getId(), to.getId(), amount); // 后执行业务 accountService.debit(from, amount); accountService.credit(to, amount); // 可能失败 }当credit操作失败时,事务回滚但日志已持久化。正确做法应该是:
- 使用事务日志表(与业务同库同事务)
- 或采用最终一致性模式(先业务后日志+补偿)
3.2 大事务导致的日志膨胀
某次促销活动后,我们发现undo日志体积暴涨导致磁盘写满。根本原因是:
@Transactional public void batchProcess(List<Order> orders) { for (Order order : orders) { // 循环1000次 orderService.update(order); logService.record(order); // 每次生成日志 } }优化方案:
- 分批次提交(每100条一个事务)
- 改用批量插入日志
- 关键指标:单个事务日志不超过1MB
3.3 异步日志的内存泄漏风险
使用内存队列异步写日志时,曾发生过OOM:
// 伪代码 Queue<LogEntry> logQueue = new LinkedBlockingQueue<>(); @Async public void recordLog(LogEntry entry) { logQueue.put(entry); // 无界队列风险 } // 消费者速度跟不上生产者时...防御措施:
- 使用有界队列+拒绝策略
- 监控队列积压情况
- 重要日志采用同步双写
4. 分布式锁+事务的黄金组合方案
4.1 Redisson + Spring事务模板
经过多次踩坑后,我们提炼出以下最佳实践:
public void safeOperation(Long id) { RLock lock = redissonClient.getLock("resource:" + id); try { // 尝试加锁(等待10秒,持有30秒) boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS); if (!acquired) throw new BusinessException("操作冲突"); // 在事务模板内执行 transactionTemplate.execute(status -> { // 业务逻辑 return doBusiness(); }); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }关键设计点:
- 锁等待时间(10秒) < 锁持有时间(30秒)
- 事务模板显式控制事务边界
- 完整的中断处理
4.2 事务内锁检查的二次验证
对于特别敏感的操作,我们增加事务内的二次校验:
-- 在@Transactional方法内执行 SELECT COUNT(*) FROM distributed_lock WHERE resource_id = ? FOR UPDATE这虽然会降低性能,但能保证绝对安全。适用于金融级场景。
5. 监控体系的搭建要点
没有监控的分布式锁就像蒙眼走钢丝。我们建议监控这些核心指标:
锁竞争热力图(按资源ID统计等待时间)
sum(rate(redisson_lock_wait_time[1m])) by (resource)事务执行时间分布(按方法名分类)
@Around("@annotation(transactional)") public Object monitorTransaction(ProceedingJoinPoint pjp) { long start = System.currentTimeMillis(); try { return pjp.proceed(); } finally { metrics.record(pjp.getSignature().getName(), System.currentTimeMillis() - start); } }锁释放失败告警(通过Redis的pub/sub监听)
6. 真实案例:优惠券超发事故全复盘
回到开头的案例,我们的最终解决方案包含以下改进:
锁设计升级:
- 使用Redisson的可重入锁
- 锁过期时间设置为平均事务时间的3倍(30秒)
- 增加看门狗自动续期
事务优化:
@Transactional(propagation = Propagation.REQUIRES_NEW) public void grantCoupon(Long userId) { // 短小精悍的事务 couponDao.checkAndInsert(userId); }补偿机制:
- 通过定时任务校验发放数量
- 建立异常发放的自动回收流程
这套方案上线后,在大促期间成功抵御了每秒3000+的并发请求,未再出现超发情况。
7. 开发 Checklist:你的代码安全吗?
在提交任何涉及分布式锁和事务的代码前,请对照此清单检查:
- [ ] 锁过期时间 > 最大可能的事务执行时间
- [ ] finally块中正确释放锁(带持有判断)
- [ ] 事务内不包含远程调用等不确定操作
- [ ] 日志记录与业务操作在同一个事务中
- [ ] 对可重入场景有正确处理
- [ ] 有锁竞争监控和超时告警
- [ ] 压力测试验证过锁有效性
我在实际项目中总结出一条铁律:分布式锁的可靠性不是由代码正确性保证的,而是由完整的失败处理预案决定的。建议每个团队都建立自己的"锁事务规范",这比任何技术方案都重要。