Best practice-生产环境中加锁的最佳实践
什么是死锁?
场景:图书馆有两个相邻的储物柜(柜子A和柜子B),小明和小红需要同时使用这两个柜子才能完成借书流程。
- 互斥资源
每个柜子只有一把钥匙,且一次只能被一人使用(资源不可共享)。 - 持有并等待
-
- 小明拿到了柜子A的钥匙,但他说:“我要等小红用完柜子B的钥匙,才能继续操作。”
- 小红拿到了柜子B的钥匙,但她说:“我要等小明用完柜子A的钥匙,才能继续操作。”
- 僵局形成
两人都死死攥着已有的钥匙,同时等待对方手里的另一把钥匙。结果两人卡在原地,谁也无法完成借书流程。
死锁的定义:线程A获取到资源1,需要再次获取到资源2被释放以获取到该资源,此时线程B获取到了资源2,等待获取资源1,两线程进入了互相等待的状态形成死锁。
业务中的死锁
让我们以电商系统中的「购物车库存锁定」场景为例,具体分析死锁的触发机制:当用户A尝试同时锁定商品X和Y的库存,而用户B以相反顺序(先锁Y再锁X)发起操作时,这两个并发请求可能因资源竞争进入相互等待状态——此时系统既无法完成库存扣减,也无法释放已占用的资源,形成典型的死锁僵局。
MockData
类初始化了商品的库存,使用ConcurrentHashp
模拟购物车和商品库存信息,并提供了添加至购物车、清空购物车、扣减库存等方法。
public class MockData {
// 模拟购物车:用户ID -> 商品列表 + 总价
public static final ConcurrentHashMap<Long, Cart> Carts = new ConcurrentHashMap<>();
// 模拟库存:商品ID -> 库存数量
public static final ConcurrentHashMap<Long, AtomicInteger> Inventory = new ConcurrentHashMap<>();
static {
// 初始化库存(商品1和2各有1件)
Inventory.put(1L, new AtomicInteger(1));
Inventory.put(2L, new AtomicInteger(1));
}
// 添加商品到购物车
public static void addToCart(Long userId, Long productId, int quantity) {
Carts.computeIfAbsent(userId, k -> new Cart()).addProduct(productId, quantity);
}
// 清空购物车
public static void clearCart(Long userId) {
Carts.remove(userId);
}
// 获取库存数量
public static int getStock(Long productId) {
return Inventory.getOrDefault(productId, new AtomicInteger(0)).get();
}
// 扣减库存(原子操作)
public static boolean decreaseStock(Long productId, int quantity) {
return Inventory.getOrDefault(productId, new AtomicInteger(0))
.compareAndSet(getStock(productId), getStock(productId) - quantity);
}
// 购物车类
static class Cart {
private final ConcurrentHashMap<Long, Integer> items = new ConcurrentHashMap<>();
@Getter
private int totalPrice = 0;
/**
* 购物车添加商品
* @param productId 商品id
* @param quantity 数量
*/
public void addProduct(Long productId, int quantity) {
items.put(productId, items.getOrDefault(productId, 0) + quantity);
totalPrice += quantity;
}
public void removeProduct(Long productId) {
items.remove(productId);
totalPrice -= items.getOrDefault(productId, 0);
}
}
}
以下代码模拟库存不足造成死锁的场景:
@Slf4j
@Service
@EnableAsync
public class OrderService {
// 死锁场景(模拟库存不足)
public void createOrderDeadLock(Long userId, Long productId) {
log.info("用户:{},开始下单商品:{}", userId, productId);
// 模拟购物车添加商品
cartLock.lock();
try {
// 步骤2:检查库存(此时可能有其他线程扣减)
if (MockData.getStock(productId) < 1) {
log.error("用户:{} 库存不足,放弃订单", userId);
return;
}
// 模拟长时间业务操作(人为制造时间差)
try {
Thread.sleep(5000);
} catch (Exception e) {
log.error("异常", e);
}
// 步骤3:扣减库存(实际业务场景需要原子操作)
if (!MockData.decreaseStock(productId, 1)) {
log.error("用户:{} 库存已被抢光,放弃订单", userId);
return;
}
log.info("用户 {},下单成功", userId);
MockData.clearCart(userId);
} catch (Exception e) {
log.error("下单失败", e);
} finally {
cartLock.unlock();
}
}
}
在Controller层调用该方法,同时进行场景分析:
死锁场景分析:
- 核心逻辑:两个用户同时抢购同一商品,库存仅剩1件。
- 死锁原因:
-
- 线程1持有购物车锁,等待库存锁。
- 线程2持有购物车锁,等待库存锁。
- 双方互相等待对方释放锁,形成循环等待。
@GetMapping("/wrong/cert/lock")
public void wrongCertLock() throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(2);
// 用户A尝试购买商品1(库存1)
executor.submit(() -> orderService.createOrderLockBySequence(1L, 1L));
// 用户B尝试购买商品1(库存已不足)
executor.submit(() -> orderService.createOrderLockBySequence(2L, 1L));
// 等待观察结果(死锁表现为长时间无输出)
executor.shutdown();
executor.awaitTermination(20, TimeUnit.SECONDS);
}
}
使用工具调用该接口,并查看接口的输出结果,接口响应时间5.08秒:
在下单时使用购物车的全局锁certLock
时,存在两个问题:
一.单锁阻塞堆积(隐性"假死锁")
当使用全局锁 cartLock
时,所有下单请求必须串行执行。在高并发场景下:
- 第一个线程获得锁后执行5秒休眠
- 后续所有线程在
cartLock.lock()
处排队阻塞 - 线程堆积导致系统吞吐量骤降,最终表现类似"死锁"
数据示例:
- 假设QPS=100,5秒内会堆积500个等待线程
- 实际业务处理能力被压缩到0.2 TPS(每秒处理0.2个请求)
使用Arthas
的thread -b
命令分析服务存在线程“死锁”的情况和线程阻塞情况,同时Arthas
支持查看阻塞位置的源码。
使用jad --jad --source-only
命令查看源码,如例子中展示第40行附近存在线程阻塞的问题,我们可以通过反编译查看源码:
jad --source-only com.codetree.business_error.chapter.chapter02.shop.OrderService createOrderDeadLock
二、锁粒度错位导致的竞态条件
隐患根源:
// 非原子操作
if (MockData.getStock(productId) < 1) {
return;
}
// 非原子操作
MockData.decreaseStock(productId, 1)
即使有全局锁保护:
- 库存检查与扣减分离:其他系统(如支付系统)可能同时修改库存
- 超卖风险:检查时库存充足,但扣减时已被其他通道(API/后台)修改
如何避免死锁?
避免死锁一般有两种方案:
方案 | 实现方式 | 优点 | 缺点 |
---|---|---|---|
一次性获取资源 | 使用全局锁(globalLock ) | 简单粗暴,彻底避免死锁 | 并发性能差,所有请求串行执行 |
按顺序获取资源 | 固定锁顺序(先购物车 → 再库存) | 兼顾并发性能,适用于复杂业务 | 需全局统一锁顺序策略 |
方案一、一次性获取所有资源
一次性获取所有资源可以视为将多个非原子性操作封装成一个大的原子性操作,强制实现线程“串行化”访问,该方案能够彻底消除持有并等待条件,同时保证临界区操作的原子一致性。
public void createOrderLockAllResource(Long userId, Long productId) {
log.info("优化:一次性获取所有的资源,用户:{},开始下单商品:{}", userId, productId);
// 模拟购物车添加商品
globalLock.lock();
try {
// 步骤2:检查库存(此时可能有其他线程扣减)
if (MockData.getStock(productId) < 1) {
log.error("用户:{} 库存不足,放弃订单", userId);
return;
}
// 模拟长时间业务操作(人为制造时间差)
try {
Thread.sleep(10000);
} catch (Exception e) {
log.error("异常", e);
}
// 步骤3:扣减库存(实际业务场景需要原子操作)
if (!MockData.decreaseStock(productId, 1)) {
log.error("用户:{} 库存已被抢光,放弃订单", userId);
return;
}
log.info("用户 {},下单成功", userId);
MockData.clearCart(userId);
} catch (Exception e) {
log.error("下单失败", e);
} finally {
globalLock.unlock();
}
}
未出现阻塞问题,串行化执行成功,接口响应10.10秒。
方案二、按顺序获取资源
顺序化获取资源可以有效规避死锁产生的必要条件(之一)——循环等待条件,同时消除进程间非原子操作的竞争冲突,从而避免竞态条件的发生。
public void createOrderLockBySequence(Long userId, Long productId) {
log.info("优化:按顺序获取锁,用户:{},开始下单商品:{}", userId, productId);
// 模拟购物车添加商品
cartLock.lock();
try {
// 步骤2:获取库存锁
inventoryLock.lock();
try {
// 快速失败
if (MockData.getStock(productId) < 1) {
System.out.println("用户 " + userId + " 库存不足,方案二无效");
return;
}
// 执行所有操作
MockData.addToCart(userId, productId, 1);
MockData.decreaseStock(productId, 1);
System.out.println("用户 " + userId + " 方案二下单成功!");
MockData.clearCart(userId);
} catch (Exception e) {
log.error("异常", e);
throw new RuntimeException(e);
} finally {
inventoryLock.unlock();
}
} catch (Exception e) {
log.error("下单失败", e);
} finally {
cartLock.unlock();
}
}
总结
在并发系统的设计与优化中,死锁预防始终是确保系统稳定性的核心命题,我介绍的两种死锁的处理方式:
- "一刀切"的原子化方案
通过全局锁强制串行化操作,牺牲了并发性能,以最简单的方式彻底消除死锁风险。这种"粗暴但可靠"的设计思路,特别适合对数据一致性要求极高、容错成本较大的业务场景,保证了基本的安全性。 - 精细化控制的顺序化策略
访问资源顺序化,投机取巧的利用了业务场景优势,方案适合于对接口响应时间敏感的业务场景(下单抢购)。
实践启示录:
- 没有银弹的解决方案:两种方案各有利弊,需根据业务特性进行取舍。高频小事务场景宜用原子化方案,长流程多步骤业务则更适合顺序化控制。
- 死锁预防≠完全消除:即使采取最优策略,仍需通过监控(如JVM线程Dump分析)、日志埋点(死锁检测)、压力测试等手段持续验证系统稳定性。
优秀的设计永远是在理论模型与实际需求之间寻找精妙的平衡点。希望本文的分析能为你提供一些新的思路。