尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

双 11 分布式锁实战红线:千万不要在分布式事务包裹中持有分布式长锁

双 11 分布式锁实战红线:千万不要在分布式事务包裹中持有分布式长锁 在双 11 大促代码审查Code Review的红线清单上有一条被标为“P0 级最高危代码坏味道”的禁令严禁在Transactional声明式事务方法内部使用分布式锁严禁将长耗时的分布式锁包裹在本地数据库事务之内很多工作三到五年的开发者在初次看到这条禁令时往往觉得小题大做“我为了保证扣减库存的绝对安全在 Service 方法上加个Transactional保证数据库一致性在方法里用lock.lock()锁住库存防止并发冲突双重保险难道不好吗”好心办坏事往往是生产灾难的最初起点。当高并发洪峰撞上“事务与分布式锁的错误嵌套顺序”它不仅起不到双重保险的作用反而是引爆数据库物理连接池耗尽、并发脏写穿透以及级联分布式死锁的罪魁祸首。致命时序拆解一锁提前释放引发的并发脏写我们先看一段在很多业务系统里随处可见的“翻车标准范例”Service public class OrderStockService { Autowired private RedissonClient redissonClient; Autowired private StockMapper stockMapper; // 错误示范Spring 事务切面包裹在分布式锁的外层 Transactional(rollbackFor Exception.class) public boolean deductStock(String skuId, int amount) { RLock lock redissonClient.getLock(lock:stock: skuId); lock.lock(); try { int current stockMapper.queryStock(skuId); if (current amount) { stockMapper.updateStock(skuId, amount); return true; } return false; } finally { // 致命瞬间分布式锁在方法返回前被释放了 lock.unlock(); } } }为什么这段代码在高并发下必出超卖很多开发者以为lock.unlock()执行完后方法就结束了。完全想错了回顾 Spring AOP 的动态代理调用链路外部调用进入 Spring 代理对象OrderStockService$$SpringCGLIBSpring 事务切面先执行从 HikariCP 连接池借出一个数据库连接执行setAutoCommit(false)开启本地事务进入业务目标方法获取分布式锁执行 SQL 更新语句此时数据只写入了数据库的内存 Undo/Redo Log尚未提交业务代码执行完毕走入finally块分布式锁物理释放退回到 Spring 事务切面Spring 代理切面准备向数据库发送COMMIT命令。灾难的时序微隙Time Window注意看步骤 4 和步骤 5 之间的那个微隙分布式锁已经在第 4 步彻底释放了但是数据库事务在第 5 步还没有真正执行COMMIT此时另一个并发线程 B 瞬间拿到刚刚释放的分布式锁冲进业务逻辑去查库存。由于线程 A 的事务尚未提交根据数据库读已提交Read Committed的隔离级别线程 B 读到的依然是线程 A 修改前的旧库存数据线程 B 基于旧数据做完判断再次扣减分布式锁的互斥性被瞬间打破超卖事故当场发生致命时序拆解二事务内长耗时导致的连接池大面积枯竭如果说超卖是正确性问题那么把分布式锁放事务内部还会直接引爆系统的可用性雪崩。一旦方法被打上了TransactionalSpring 在方法进入的第一毫秒就会向 HikariCP 申请一个宝贵的物理数据库连接。如果此时加锁逻辑如下Transactional public void processTrade(String skuId) { // 在持有数据库物理连接的前提下去等待一把可能需要排队的分布式锁 RLock lock redissonClient.getLock(lock:sku: skuId); lock.lock(); // 如果前一个线程执行了 3 秒当前线程就会在此阻塞 3 秒 try { // 业务写入... } finally { lock.unlock(); } }如果在双 11 秒杀峰值期间有 200 个并发线程同时调用该接口这 200 个线程在进入方法的第一瞬间就强行从连接池里借走了 200 个数据库物理连接随后这 200 个线程全挤在lock.lock()门外排队等待那一把锁HikariCP 连接池在 100 毫秒内被迅速榨干池中空闲连接降为 0全站其他所有完全无关的业务如用户登录、查看商品详情只要需要访问数据库全部被阻塞在连接池外超时报错整个微服务集群瞬间瘫痪生产级黄金规范锁在外层事务在内层要根除这一隐患铁律只有一条分布式锁的生命周期必须严格大于并完全包裹本地数据库事务的生命周期必须确保只有当本地事务已经物理执行了COMMIT、数据真正落盘之后分布式锁才被允许释放生产标准推荐方案结合编程式事务 TransactionTemplate坚决抛弃方法级的Transactional全面拥抱细粒度的编程式事务Service public class SafeOrderStockService { private static final Logger log LoggerFactory.getLogger(SafeOrderStockService.class); Autowired private RedissonClient redissonClient; Autowired private TransactionTemplate transactionTemplate; Autowired private StockMapper stockMapper; public boolean deductStockSafely(String skuId, int amount) { RLock lock redissonClient.getLock(lock:stock: skuId); try { // 1. 第一步在数据库连接获取之前先尝试加锁最多等待 3 秒 boolean isLocked lock.tryLock(3000, TimeUnit.MILLISECONDS); if (!isLocked) { throw new BusinessException(系统繁忙请稍后重试); } try { // 2. 第二步在分布式锁内部开启极小粒度的编程式事务 // 仅在事务模板执行的这几毫秒内持有物理数据库连接 return Boolean.TRUE.equals(transactionTemplate.execute(status - { int current stockMapper.queryStock(skuId); if (current amount) { stockMapper.updateStock(skuId, amount); return true; } return false; })); // 3. 事务模板退出时底层数据库已严格完成 COMMIT } finally { // 4. 第四步确保数据落盘后在最外层安全释放分布式锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BusinessException(加锁被中断, e); } } }架构审查总结看清事务切面的执行边界是每个后端架构师必须具备的“透视眼”永远不要让数据库连接等待网络 I/O 或分布式锁只有确定拿到了锁才准许去借数据库连接只有确定事务完成了提交才准许归还分布式锁。守住这条铁律你的双 11 交易链路才真正拥有了抵御高并发洪峰的钢铁骨架。
返回列表