很多 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 个大问题:

  1. 没有过期时间

    • 如果服务宕机,锁就可能一直不释放
  2. value 没有唯一标识

    • 你根本不知道这个锁是不是自己加的
  3. 直接 delete 有误删风险

    • 你的业务超时了,锁过期后被别人拿走,你再 delete,就把别人的锁删了

这类代码在线上非常危险。


四、正确的 Redis 分布式锁,至少要满足这 3 个条件

如果你要自己实现 Redis 锁,最少要做到:

  1. 加锁和设置过期时间必须是原子操作
  2. 锁的 value 必须是唯一值
  3. 解锁时要先判断“锁是不是自己的”

五、一个更靠谱的 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 不难,写到线上稳定可用就完全是另一回事。


十二、什么时候该用分布式锁,什么时候不该用?

我给你一个最实用的判断口诀:

该用的时候

  • 多实例并发处理同一业务对象
  • 同一时刻只能允许一个执行者
  • 冲突概率高
  • 仅靠前端防抖和本地锁不够

不该优先用的时候

  • 能用唯一索引解决
  • 能用条件更新解决
  • 能用状态机解决
  • 能用乐观锁解决

十三、我最推荐的落地思路

如果你真要在项目里上分布式锁,我最推荐这套:

  1. 先判断是不是非锁不可
  2. 锁 key 设计到具体业务对象
  3. 加锁必须带过期时间
  4. value 必须唯一
  5. 解锁必须校验 value
  6. 核心数据再用数据库兜底
  7. 尽量用成熟组件,不要手搓线上基础设施

更多推荐