目录

1. 什么是分布式锁?

分布式锁的核心要求

2. 基于Redis的分布式锁实现方案

(1)基础方案:SETNX + EXPIRE

(2)优化方案:SET NX PX(原子性加锁)

(3)进阶方案:RedLock(Redis官方推荐)

3. 实战案例

案例1:防止重复下单

1. 加锁阶段

2. 业务逻辑阶段

3. 释放锁阶段

案例2:秒杀库存扣减

案例3:分布式定时任务调度

4. 常见问题与解决方案

(1)锁过期但业务未执行完?

(2)锁被其他客户端误删?

(3)Redis主从切换导致锁丢失?

5. 总结


1. 什么是分布式锁?

在分布式系统中,多个服务实例可能同时访问共享资源(如数据库、缓存等),为了避免并发问题(如超卖、重复提交等),我们需要一种跨JVM的锁机制——分布式锁。

分布式锁的核心要求

  1. 互斥性:同一时刻只有一个客户端能持有锁。

  2. 防死锁:即使客户端崩溃,锁也能自动释放。

  3. 高可用:锁服务必须高可用(如Redis集群)。

  4. 可重入性(可选):同一个客户端可以多次获取同一把锁。


2. 基于Redis的分布式锁实现方案

Redis因其高性能和原子性操作(如SETNX),成为实现分布式锁的常用方案。

(1)基础方案:SETNX + EXPIRE

// 加锁(错误示范,非原子性)
Boolean locked = redisTemplate.opsForValue().setIfAbsent("lock:order123", "1");
if (locked) {
    redisTemplate.expire("lock:order123", 10, TimeUnit.SECONDS);  // 设置过期时间
    // 执行业务逻辑...
    redisTemplate.delete("lock:order123");  // 释放锁
}

问题:SETNX和EXPIRE不是原子操作,如果加锁后客户端崩溃,锁永远不会释放!


(2)优化方案:SET NX PX(原子性加锁)

Redis 2.6+ 支持SET命令的NX(不存在才设置)和PX(毫秒级过期时间)参数:

// 正确方式:原子性加锁 + 设置过期时间
Boolean locked = redisTemplate.opsForValue().setIfAbsent(
    "lock:order123", 
    "client1", 
    10, 
    TimeUnit.SECONDS
);

if (locked) {
    try {
        // 执行业务逻辑...
    } finally {
        // 释放锁(需判断是否是自己加的锁)
        if ("client1".equals(redisTemplate.opsForValue().get("lock:order123"))) {
            redisTemplate.delete("lock:order123");
        }
    }
}

关键改进:

  • 使用SET NX PX保证原子性。

  • 设置唯一标识(如client1),避免误删其他客户端的锁。


(3)进阶方案:RedLock(Redis官方推荐)

如果单点Redis不可靠,可以使用RedLock算法(需多个独立Redis实例):

// RedLock示例(使用Redisson客户端)
RLock lock1 = redissonClient1.getLock("lock:order123");
RLock lock2 = redissonClient2.getLock("lock:order123");
RLock lock3 = redissonClient3.getLock("lock:order123");

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
    if (redLock.tryLock(10, 30, TimeUnit.SECONDS)) {  // 最多等待10秒,锁30秒后自动过期
        // 执行业务逻辑...
    }
} finally {
    redLock.unlock();
}

适用场景:对一致性要求极高的场景(如金融交易)。


3. 实战案例

案例1:防止重复下单

public String createOrder(String userId, String productId) {
    String lockKey = "lock:order:" + userId + ":" + productId;
    String clientId = UUID.randomUUID().toString();  // 唯一标识
    
    try {
        // 尝试加锁(等待5秒,锁10秒后自动释放)
        Boolean locked = redisTemplate.opsForValue().setIfAbsent(
            lockKey, 
            clientId, 
            10, 
            TimeUnit.SECONDS
        );
        
        if (!locked) {
            throw new RuntimeException("操作太频繁,请稍后再试!");
        }
        
        // 检查是否已下单
        if (orderService.hasOrder(userId, productId)) {
            throw new RuntimeException("请勿重复下单!");
        }
        
        // 创建订单...
        return orderService.create(userId, productId);
    } finally {
        // 释放锁(需校验clientId)
        if (clientId.equals(redisTemplate.opsForValue().get(lockKey))) {
            redisTemplate.delete(lockKey);
        }
    }
}

这段代码实现了一个防并发重复下单的订单创建逻辑,核心是使用Redis分布式锁来保证同一用户对同一商品的订单操作是串行化的。下面逐部分解析:


1. 加锁阶段
String lockKey = "lock:order:" + userId + ":" + productId;
String clientId = UUID.randomUUID().toString();
  • lockKey:锁的键,格式为lock:order:{userId}:{productId},确保不同用户或不同商品的锁互不影响。

  • clientId:生成唯一标识(UUID),用于后续校验锁的归属,防止误删其他客户端的锁。

Boolean locked = redisTemplate.opsForValue().setIfAbsent(
    lockKey, 
    clientId, 
    10, 
    TimeUnit.SECONDS
);
  • setIfAbsent:Redis的SETNX命令(原子性操作),如果lockKey不存在则加锁,并设置:

    • 值:clientId(锁的持有者标识)。

    • 过期时间:10秒(防止死锁)。

  • 返回值:true表示加锁成功,false表示锁已被占用。

if (!locked) {
    throw new RuntimeException("操作太频繁,请稍后再试!");
}
  • 如果加锁失败,直接抛出异常,提示用户"操作太频繁"(类似秒杀场景的限流)。


2. 业务逻辑阶段
if (orderService.hasOrder(userId, productId)) {
    throw new RuntimeException("请勿重复下单!");
}
  • 检查是否已下单:在锁的保护下查询订单系统,防止重复下单(即使通过了前端校验,仍需后端保证幂等性)。

return orderService.create(userId, productId);
  • 创建订单:执行业务逻辑(如扣减库存、生成订单等)。


3. 释放锁阶段
finally {
    if (clientId.equals(redisTemplate.opsForValue().get(lockKey))) {
        redisTemplate.delete(lockKey);
    }
}
  • finally块:确保锁一定会被释放,即使业务逻辑抛出异常。

  • 校验clientId:

    • 只释放自己加的锁(避免误删其他客户端的锁)。

    • 如果锁已自动过期(10秒后),get操作返回null,不会执行删除。

  • 原子性问题:

    • 这里的get和delete是两步操作,非原子性,极端情况下可能误删锁(如锁过期后,其他客户端加锁成功,但当前线程仍执行删除)。

    • 改进方案:使用Lua脚本保证原子性(见下文补充)。


案例2:秒杀库存扣减

public boolean seckill(Long productId, Long userId) {
    String lockKey = "lock:seckill:" + productId;
    String stockKey = "stock:" + productId;
    String clientId = UUID.randomUUID().toString();
    
    try {
        // 加锁(防止超卖)
        Boolean locked = redisTemplate.opsForValue().setIfAbsent(
            lockKey, 
            clientId, 
            3,  // 锁3秒(避免长时间阻塞)
            TimeUnit.SECONDS
        );
        
        if (!locked) {
            return false;  // 抢锁失败
        }
        
        // 检查库存
        Integer stock = Integer.valueOf(redisTemplate.opsForValue().get(stockKey));
        if (stock <= 0) {
            return false;  // 已售罄
        }
        
        // 扣减库存(原子操作)
        redisTemplate.opsForValue().decrement(stockKey);
        
        // 生成订单...
        orderService.createSeckillOrder(userId, productId);
        return true;
    } finally {
        // 释放锁
        if (clientId.equals(redisTemplate.opsForValue().get(lockKey))) {
            redisTemplate.delete(lockKey);
        }
    }
}

案例3:分布式定时任务调度

多个服务实例同时运行定时任务时,需确保只有一个实例执行:

@Scheduled(cron = "0 */5 * * * ?")  // 每5分钟执行一次
public void scheduledTask() {
    String lockKey = "lock:scheduled:report";
    String clientId = "server-" + System.getProperty("server.port");  // 用服务实例标识
    
    try {
        // 尝试加锁(锁5分钟)
        Boolean locked = redisTemplate.opsForValue().setIfAbsent(
            lockKey, 
            clientId, 
            5, 
            TimeUnit.MINUTES
        );
        
        if (!locked) {
            return;  // 其他实例已执行
        }
        
        // 执行业务逻辑(生成报表...)
        reportService.generateDailyReport();
    } finally {
        // 释放锁
        if (clientId.equals(redisTemplate.opsForValue().get(lockKey))) {
            redisTemplate.delete(lockKey);
        }
    }
}

4. 常见问题与解决方案

(1)锁过期但业务未执行完?

  • 问题:锁自动释放后,其他客户端可能获取锁,导致并发问题。

  • 解决方案:使用看门狗机制(如Redisson的lockWatchdogTimeout),自动续期锁。

(2)锁被其他客户端误删?

  • 问题:客户端A释放了客户端B的锁。

  • 解决方案:加锁时设置唯一标识(如UUID),释放时校验。

(3)Redis主从切换导致锁丢失?

  • 问题:主节点加锁后崩溃,从节点晋升但未同步锁数据。

  • 解决方案:使用RedLock(多Redis实例)或ZooKeeper替代。


5. 总结

方案优点缺点适用场景
SETNX + EXPIRE简单高效非原子性,可能死锁低并发场景
SET NX PX原子操作单点故障一般分布式系统
RedLock高可用实现复杂金融级高一致性场景

最佳实践:

  • 优先使用SET NX PX + 唯一标识。

  • 高可用场景选择Redisson的RLock或RedLock。

  • 结合业务设置合理的锁超时时间。

更多推荐