Java进阶必修课:分布式锁到底怎么用,才不会把业务锁死?
很多 Java 开发第一次用分布式锁,通常都是因为项目上了多实例。
单机时没问题,代码一部署成 2 台、3 台,问题就出来了:
- 同一个订单被处理两次
- 同一批库存被扣了两遍
- 定时任务在两台机器上同时跑
- 同一个用户抢券抢出了多张
这时候最常见的解决思路就是一句话:
“加个分布式锁吧。”
这句话当然没错,但问题是,很多人只知道“要加锁”,却不知道:
- 锁到底该加在哪里
- 锁 key 怎么设计
- 锁多久过期才合理
- 业务执行超时了怎么办
- 解锁时怎么防止把别人的锁删掉
所以分布式锁真正难的地方是:怎么让锁真的锁住业务
这篇文章不讲太虚的理论,直接讲最常见的几个场景,以及对应的正确做法。
一、先说清楚:为什么会需要分布式锁?
你可以把分布式锁理解成一句大白话:
在多台机器同时跑的时候,保证某一段业务同一时刻只能有一个执行者。
注意这里有两个关键词:
- 多台机器
- 同一时刻只有一个执行者
比如下面这些场景就很典型:
- 同一个用户只能提交一次抢购请求
- 同一个订单只能被支付成功处理一次
- 同一个商品扣库存时不能并发超卖
- 同一个定时任务只能有一台机器执行
如果你的服务还是单机,很多问题用 synchronized 或本地锁就够了。
但只要上了集群,本地锁就失效了,因为:
A 机器上的锁,B 机器根本看不见。
这时候才需要分布式锁。
二、什么场景适合分布式锁,什么场景不适合?
这一步特别重要。
很多人一遇到并发问题就想上分布式锁,结果最后系统越来越慢,问题还没完全解决。
适合分布式锁的场景
- 同一业务对象必须串行处理
- 资源竞争范围明确
- 冲突概率较高
- 不方便只靠数据库唯一约束解决
比如:
- userId 级别防重复提交
- orderNo 级别防重复处理
- productId 级别扣库存串行化
- taskName 级别防止重复跑任务
不太适合分布式锁的场景
- 纯查询类接口
- 高频但冲突极低的场景
- 完全可以用数据库唯一索引解决的场景
- 完全可以用状态机或条件更新解决的场景
一句话总结:
能不用分布式锁解决的问题,尽量别上锁。
因为锁本质上是在降低并发,换取一致性。
三、最常见的错误:把 Redis setnx 当成完整分布式锁
很多人会这么写:
Boolean success = redisTemplate.opsForValue().setIfAbsent("lock:order:1001", "1");
if (Boolean.TRUE.equals(success)) {
try {
// do business
} finally {
redisTemplate.delete("lock:order:1001");
}
}
看起来像那么回事,但这里至少有 3 个大问题:
-
没有过期时间
- 如果服务宕机,锁就可能一直不释放
-
value 没有唯一标识
- 你根本不知道这个锁是不是自己加的
-
直接 delete 有误删风险
- 你的业务超时了,锁过期后被别人拿走,你再 delete,就把别人的锁删了
这类代码在线上非常危险。
四、正确的 Redis 分布式锁,至少要满足这 3 个条件
如果你要自己实现 Redis 锁,最少要做到:
- 加锁和设置过期时间必须是原子操作
- 锁的 value 必须是唯一值
- 解锁时要先判断“锁是不是自己的”
五、一个更靠谱的 Redis 锁实现
1. 加锁:SET key value NX EX
public boolean tryLock(String key, String requestId, long expireSeconds) {
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, requestId, Duration.ofSeconds(expireSeconds));
return Boolean.TRUE.equals(success);
}
这里解决了两个问题:
- NX:只有不存在时才能设置成功
- EX:加锁时就带过期时间,避免死锁
2. 解锁:必须判断 value 是否匹配
解锁不能直接 delete,而要用 Lua 保证原子性:
private static final String UNLOCK_SCRIPT = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""";
public boolean unlock(String key, String requestId) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class),
Collections.singletonList(key),
requestId
);
return Long.valueOf(1).equals(result);
}
为什么一定要这样?
因为你必须保证:
只有加锁的人,才能解自己的锁。
否则业务超时后,锁过期了,别人已经拿到了新锁,你的 finally 再 delete,就会误删别人的锁。
六、真实场景 1:防止同一用户重复下单
很多系统都有这种问题:
- 用户连续点了两次提交
- 两个请求几乎同时打到不同实例
- 结果生成了两笔订单
正确思路:按用户或业务请求号加锁
public Long createOrder(CreateOrderRequest request) {
String lockKey = "lock:create:order:user:" + request.getUserId();
String requestId = UUID.randomUUID().toString();
boolean locked = redisLock.tryLock(lockKey, requestId, 5);
if (!locked) {
throw new BusinessException("请求过于频繁,请稍后重试");
}
try {
return doCreateOrder(request);
} finally {
redisLock.unlock(lockKey, requestId);
}
}
这就够了吗?
不完全够。
因为分布式锁更适合控制“并发窗口”,
但真正的数据一致性,还应该有数据库兜底。
比如订单表再加唯一约束:
ALTER TABLE orders ADD UNIQUE INDEX uk_request_id (request_id);
最稳妥的做法是:
- 锁控制并发
- 数据库控制最终一致性
不要把所有希望都押在锁上。
七、真实场景 2:扣库存时,怎么避免超卖?
库存是分布式锁最经典的应用场景之一。
错误写法
public void deductStock(Long productId, int quantity) {
Product product = productMapper.selectById(productId);
if (product.getStock() < quantity) {
throw new BusinessException("库存不足");
}
productMapper.updateStock(productId, product.getStock() - quantity);
}
这段代码在并发下很容易出问题:
- 两个线程同时查到库存都是 10
- 两个线程都判断“够扣”
- 最后库存被多扣
方案一:分布式锁串行化商品扣减
public void deductStock(Long productId, int quantity) {
String lockKey = "lock:stock:product:" + productId;
String requestId = UUID.randomUUID().toString();
boolean locked = redisLock.tryLock(lockKey, requestId, 3);
if (!locked) {
throw new BusinessException("系统繁忙,请稍后重试");
}
try {
Product product = productMapper.selectById(productId);
if (product.getStock() < quantity) {
throw new BusinessException("库存不足");
}
productMapper.updateStock(productId, product.getStock() - quantity);
} finally {
redisLock.unlock(lockKey, requestId);
}
}
但这还不是最优解
库存场景里,很多时候更推荐直接用数据库条件更新:
UPDATE product
SET stock = stock - #{quantity}
WHERE id = #{productId}
AND stock >= #{quantity};
Java 里判断更新条数:
int updated = productMapper.deductStock(productId, quantity);
if (updated == 0) {
throw new BusinessException("库存不足");
}
为什么这个更好?
因为它比“先查再改”更原子,也比“全靠锁”更轻。
所以库存场景的经验是:
能用数据库原子更新解决,就优先别上分布式锁。
八、真实场景 3:定时任务重复执行,怎么防止两台机器一起跑?
这是线上非常常见的分布式锁使用场景。
比如每天凌晨 1 点跑结算任务,部署了 3 台服务。
如果不加控制,3 台都会执行,数据可能被处理三遍。
正确方案:任务入口加分布式锁
@Scheduled(cron = "0 0 1 * * ?")
public void settleTask() {
String lockKey = "lock:task:settle";
String requestId = UUID.randomUUID().toString();
boolean locked = redisLock.tryLock(lockKey, requestId, 300);
if (!locked) {
return;
}
try {
settlementService.doSettle();
} finally {
redisLock.unlock(lockKey, requestId);
}
}
这里要注意什么?
最大的坑是:过期时间不能乱设。
如果任务要跑 3 分钟,你只给 30 秒锁超时,那 30 秒一到,锁自动过期,别的实例就可能又进来执行一次。
所以定时任务场景下,锁超时要么:
- 设置成明显大于任务执行时间
- 要么做续约机制
九、分布式锁最容易踩的 5 个坑
1. 锁没有过期时间
这是最危险的,服务异常退出就会形成死锁。
2. 过期时间太短
业务没执行完,锁先过期了,别人又进来了,结果变成“多个执行者同时工作”。
3. 过期时间太长
虽然不会轻易失效,但一旦业务卡住,其他请求会长时间拿不到锁,用户体验很差。
4. 解锁直接 delete
这会带来误删别人的锁的问题,必须校验 value。
5. 把分布式锁当成万能方案
很多问题本来更适合:
- 唯一索引
- 状态机
- 乐观锁
- 条件更新
结果一上来先加锁,系统复杂度和性能成本都上去了。
十、锁超时时间到底怎么设?
这是最常被问的问题。
没有统一答案,但有一个很实用的原则:
锁超时时间 = 正常业务执行时间的 2~3 倍,再留一点缓冲。
比如:
- 正常业务 200ms 内完成,可以给 3~5 秒
- 普通接口 1 秒内完成,可以给 5~10 秒
- 定时任务 2 分钟跑完,可以给 5 分钟左右
别迷信固定值,关键是结合业务耗时来定。
如果业务耗时波动特别大,建议用成熟组件支持“自动续约”。
十一、自己写 Redis 锁,还是直接用 Redisson?
如果是生产项目,我更建议:
优先用成熟组件,比如 Redisson。
因为它帮你处理了很多细节:
- 可重入锁
- 自动续约
- 超时控制
- 解锁安全性
- 更完整的分布式同步能力
例如:
RLock lock = redissonClient.getLock("lock:order:" + orderNo);
boolean locked = lock.tryLock(0, 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("系统繁忙,请稍后再试");
}
try {
doBusiness();
} finally {
lock.unlock();
}
为什么很多团队最终都选 Redisson?
因为分布式锁看起来代码不多,但真正难的是细节。
自己写一版 demo 不难,写到线上稳定可用就完全是另一回事。
十二、什么时候该用分布式锁,什么时候不该用?
我给你一个最实用的判断口诀:
该用的时候
- 多实例并发处理同一业务对象
- 同一时刻只能允许一个执行者
- 冲突概率高
- 仅靠前端防抖和本地锁不够
不该优先用的时候
- 能用唯一索引解决
- 能用条件更新解决
- 能用状态机解决
- 能用乐观锁解决
十三、我最推荐的落地思路
如果你真要在项目里上分布式锁,我最推荐这套:
- 先判断是不是非锁不可
- 锁 key 设计到具体业务对象
- 加锁必须带过期时间
- value 必须唯一
- 解锁必须校验 value
- 核心数据再用数据库兜底
- 尽量用成熟组件,不要手搓线上基础设施
更多推荐


所有评论(0)