
兄弟们,最近有个电商朋友跟我哭诉,他们搞了个茅台抢购活动,结果系统直接炸锅了。用户疯狂下单,库存直接被扣成了负数,客服电话被打爆,技术团队连夜抢修。我问他用了什么防超卖方案,他说:"我用了分布式锁啊!在扣库存的方法上加了 @Transactional 注解,然后用 Redisson 的 RLock 锁住商品 ID,应该万无一失啊!"
我心里咯噔一下,这场景我太熟悉了。就像你在超市推了个购物车去排队结账,结果购物车太大卡在通道里,后面的人都走不动。锁加在事务里,就像把购物车(锁)和结账流程(事务)绑在一起,一旦事务执行时间长,锁就成了性能瓶颈。
一、锁在事务里:穿着棉袄游泳的痛苦
1. 锁的持有时间过长
假设你的事务里有三个操作:查库存、扣库存、发消息。每个操作都需要 100ms,事务总时长 300ms。而锁的超时时间设置为 500ms,看起来没问题。但如果数据库突然慢了,事务执行了 800ms,锁就会自动释放。这时候另一个线程拿到锁,继续扣库存,就会导致超卖。
这就像你租了个充电宝,租期 2 小时,但你用了 3 小时才还。中间第二个小时的时候,充电宝被别人借走了,你还的时候发现已经被别人还了,结果你被扣了双倍租金。
2. 事务回滚导致锁无法释放
如果事务执行过程中抛出异常回滚,锁会被释放吗?答案是不一定。比如你用 Redis 的 SETNX 加锁,没有设置过期时间,事务回滚时忘记手动释放锁,这个锁就会一直存在,导致其他线程永远无法获取锁。
这就像你在酒店退房时,把房卡忘在房间里,后面的客人就无法入住了。
3. 数据库隔离级别的坑
如果你用的是 MySQL 的可重复读隔离级别,在事务内查询库存时,其他事务的修改是不可见的。但如果锁在事务内,其他事务可能在锁释放后修改库存,导致数据不一致。
这就像你在餐厅吃饭,点了一份牛排,结果服务员告诉你已经卖完了。你刚要走,另一个服务员又端来一份牛排,说刚才查错了。
二、正确的姿势:锁在事务外,事务在锁内
1. 先锁后事务
正确的做法是先获取锁,再开启事务。这样锁的持有时间只包括事务内的操作,而不是整个方法的执行时间。
复制
RLock lock = redisson.getLock("product_123");
try {
lock.lock(); // 先获取锁
// 开启事务
Product product = productRepository.findById(123).orElseThrow();
if (product.getStock() > 0) {
product.setStock(product.getStock() - 1);
productRepository.save(product);
}
} finally {
lock.unlock(); // 释放锁
}1.2.3.4.5.6.7.8.9.10.11.12.
这样,即使事务执行时间长,锁也会在事务结束后立即释放,不会影响其他线程。
2. 锁的粒度要细
不要锁整个商品 ID,而是锁具体的库存项。比如按库存批次加锁,或者按 SKU 加锁。这样可以提高并发度,减少锁竞争。
这就像你去银行取钱,不是锁整个银行,而是锁具体的 ATM 机。
3. 数据库层面加唯一索引
为库存表的商品 ID 加唯一索引,防止重复扣库存。即使锁被释放,数据库也能保证数据一致性。
复制
ALTER TABLE product_stock ADD UNIQUE INDEX uk_product_id (product_id);1.
这样,当多个线程同时扣库存时,只有一个线程能成功插入或更新记录,其他线程会收到唯一约束冲突的错误。
三、分布式锁的选型:别用锤子钉钉子
1. Redis 分布式锁:性能王者
Redis 的 SETNX+EXPIRE 命令可以实现分布式锁,性能高,适合高并发场景。但要注意以下几点:
使用 Lua 脚本保证加锁和设置超时时间的原子性锁的 value 要设置为唯一标识,防止误释放集群模式下使用 Redlock 算法,避免脑裂问题
复制
String luaScript = "if redis.call(setnx, KEYS[1], ARGV[1]) == 1 then " +
"redis.call(expire, KEYS[1], ARGV[2]) return 1 else return 0 end";
List<String> keys = Collections.singletonList("lock_key");
List<String> argv = Arrays.asList("unique_value", "30");
Long result = jedis.eval(luaScript, keys, argv);1.2.3.4.5.
2. ZooKeeper 分布式锁:可靠性之选
ZooKeeper 的临时顺序节点可以实现公平锁,适合对可靠性要求高的场景。但性能较低,适合中低并发。
复制
InterProcessMutex lock = new InterProcessMutex(client, "/locks/lock1");
try {
lock.acquire();
// 执行业务逻辑
} finally {
lock.release();
}1.2.3.4.5.6.7.
ZooKeeper 会自动处理节点的创建和删除,即使客户端宕机,临时节点也会自动消失,避免死锁。
3. 数据库分布式锁:简单但不推荐
通过数据库的唯一索引和行锁实现分布式锁,简单易懂,但性能差,适合小型系统。
复制
INSERT INTO lock_table (resource_id, lock_time) VALUES (product_123, NOW())
ON DUPLICATE KEY UPDATE lock_time = NOW();1.2.
这种方法在高并发下会导致大量的锁竞争,数据库压力大,不建议在生产环境中使用。
四、分布式事务的正确打开方式:别把锁当万能钥匙
1. 避免分布式事务
能不用分布式事务就不用,尽量通过本地事务和消息队列实现最终一致性。比如订单服务扣库存后,发送一条消息给库存服务,库存服务异步更新库存。
这就像你在淘宝下单后,支付宝异步通知商家发货。
2. 使用 TCC 事务
TCC(Try-Confirm-Cancel)事务模型适合长事务场景。比如支付服务先冻结资金(Try),订单服务扣库存(Try),然后支付服务确认支付(Confirm),订单服务确认发货(Confirm)。如果任何一步失败,都需要回滚(Cancel)。
复制
// 支付服务
public void tryPay(String orderId, BigDecimal amount) {
// 冻结资金
}
public void confirmPay(String orderId) {
// 扣除资金
}
public void cancelPay(String orderId) {
// 解冻资金
}
// 订单服务
public void tryDeductStock(String orderId, String productId, int quantity) {
// 锁定库存
}
public void confirmDeductStock(String orderId) {
// 扣减库存
}
public void cancelDeductStock(String orderId) {
// 释放库存
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.
3. 结合 Saga 模式
Saga 模式将长事务拆分为多个短事务,每个短事务都有对应的补偿操作。如果某个短事务失败,回滚之前的所有短事务。
比如用户注册流程:发送验证码(短事务 1)→ 创建用户(短事务 2)→ 发送欢迎邮件(短事务 3)。如果创建用户失败,回滚发送验证码的操作。
五、常见误区:这些坑你踩过吗?
1. 锁的超时时间设置不合理
超时时间太短,会导致锁频繁释放,增加重试次数;太长,会影响并发度。应该根据业务逻辑的平均执行时间来设置,比如平均执行时间的 1.5 倍。
这就像你设置自动关机时间,太短会导致工作没保存,太长会浪费电。
2. 锁的可重入性问题
如果同一个线程多次获取同一把锁,会导致死锁。使用支持可重入的锁,如 Redisson 的 RLock,或者在数据库锁表中记录线程 ID。
复制
// Redisson可重入锁
RLock lock = redisson.getLock("product_123");
lock.lock();
try {
// 执行业务逻辑
lock.lock(); // 可重入
try {
// 嵌套业务逻辑
} finally {
lock.unlock();
}
} finally {
lock.unlock();
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.
3. 忽略网络延迟的影响
在分布式系统中,网络延迟是不可避免的。锁的获取和释放可能会因为网络问题失败,需要设置重试机制。
复制
int retryCount = 3;
int retryInterval = 1000;
for (int i = 0; i < retryCount; i++) {
try {
if (lock.tryLock(10, TimeUnit.SECONDS)) {
// 执行业务逻辑
break;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
if (i < retryCount - 1) {
try {
Thread.sleep(retryInterval);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.
六、性能优化:让系统飞起来
1. 异步处理非核心逻辑
将发消息、写日志等非核心操作放到锁外面异步执行,减少锁的持有时间。
复制
RLock lock = redisson.getLock("product_123");
try {
lock.lock();
// 核心业务逻辑
} finally {
lock.unlock();
}
// 异步发送消息
CompletableFuture.runAsync(() -> messageService.send("库存已扣减"));1.2.3.4.5.6.7.8.9.
2. 使用本地缓存
将高频访问的库存数据缓存到本地,减少对数据库的访问次数。比如使用 Caffeine 或 Guava Cache。
复制
LoadingCache<Long, Integer> stockCache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.build(key -> productRepository.findStockByProductId(key));
int stock = stockCache.get(123);1.2.3.4.5.
3. 限流和熔断
在高并发场景下,使用限流组件(如 Sentinel)限制请求流量,避免系统过载。同时,使用熔断机制(如 Hystrix)在服务不可用时快速失败,防止级联故障。
复制
// Sentinel限流
@SentinelResource(value = "deductStock", blockHandler = "handleBlock")
public void deductStock(Long productId, Integer quantity) {
// 扣库存逻辑
}
public void handleBlock(Long productId, Integer quantity, BlockException e) {
// 限流处理
throw new RuntimeException("系统繁忙,请稍后再试");
}1.2.3.4.5.6.7.8.9.10.
七、总结:锁与事务的正确关系
分布式锁和事务就像一对欢喜冤家,既相互依赖,又相互排斥。锁可以保证数据的一致性,但如果用错了地方,就会成为性能瓶颈。正确的做法是:
锁在事务外,事务在锁内锁的粒度要细,避免大锁结合数据库唯一索引和重试机制选择合适的分布式锁方案避免分布式事务,使用最终一致性
锁不是万能的,不要把所有问题都归咎于锁。在设计系统时,要从架构层面考虑性能和可用性,而不是依赖锁来解决所有问题。