【Redis】分布式锁深度解析:实现、可重入、主从一致性与强一致方案

在分布式系统中,多进程/多节点并发访问共享资源时,必须通过分布式锁保证资源互斥性。Redis凭借高性能、易部署的特性,成为分布式锁最主流的实现方案。本文从原理到实操,从基础实现到生产级优化,完整拆解Redis分布式锁的核心逻辑与避坑点。

一、分布式锁

特性

一个可靠的分布式锁需满足以下特性:

  1. 互斥性:同一时间只能有一个客户端持有锁;
  2. 原子性:加锁/解锁操作必须原子执行,避免并发异常;
  3. 防死锁:客户端宕机后锁能自动释放;
  4. 可重入:同一客户端可重复获取已持有的锁;
  5. 防误删:不能删除其他客户端持有的锁。

synchronized 为什么失效?

分布式秒杀场景 中,synchronized 完全无法解决核心问题 ------ 因为 synchronized本地锁 ,只能控制「单个 JVM 进程内」的并发,而分布式系统中多个服务节点(多 JVM)的并发请求,synchronized 根本无法约束,这也是分布式锁存在的核心意义。

java 复制代码
// 错误示范:用synchronized加锁
@Override
public Result seckillVoucher(Long voucherId) {
    synchronized (this) { // 仅能锁住当前JVM进程内的线程
        // 库存校验、扣减、下单逻辑
    }
}

假设你部署了 3 个秒杀服务节点(Node1、Node2、Node3),每个节点都用 synchronized 加锁:

失效场景:

  1. 用户 A 的请求 1 打到 Node1,synchronized 锁住 Node1 的线程,执行扣库存(库存从 100→99);
  2. 同一时间,用户 A 的请求 2 打到 Node2,Node2 的 synchronized 只能锁住 Node2 的线程,但 Node2 感知不到 Node1 的锁,直接执行库存校验(此时 Redis 库存还是 100),扣库存后库存变为 99;
  3. 最终导致「超卖」:同一用户重复下单,库存被重复扣减。

本质原因:

  • synchronized锁对象是「JVM 进程内的对象」,不同节点的 JVM 内存隔离,锁信息无法共享;
  • **分布式系统的并发是「跨 JVM、跨节点」**的,必须依赖「第三方共享存储(Redis/ZK)」实现锁信息的全局共享。

总结:

  • synchronized 是本地锁,仅适用于单机应用,分布式场景下完全失效;
  • 秒杀场景是典型的分布式并发,必须用 Redis 分布式锁(或 Lua 脚本)实现「全局锁」,约束所有节点的请求;
  • 即使单机部署,Redis Lua 脚本的性能和原子性也远优于 **synchronized**,这也是秒杀场景首选 Redis 的核心原因。

常见分布式锁

常见分布式锁实现:

​ **MySQL:**MySQL本身就带有锁机制,由于业务特性使用MySQL作为分布式锁并不合适,而且性能一般,一般很少使用MySQL来实现分布式锁。

​ **ZooKeeper:**ZooKeeper是企业级开发中较好的一个实现分布式锁的方案,相对于Redis,ZooKeeper的部署和维护复杂一些。此外,ZooKeeper的性能相对较低,适用于对性能要求不高的场景。

​ **Redis:**Redis分布式锁的实现通常使用了SETNX(SET if Not eXists)命令和EXPIRE命令。使用SETNX可以尝试将一个键值对设置到Redis中,只有在该键不存在的情况下才能成功。成功获取锁的客户端可以设置一个过期时间,确保即使在发生故障的情况下,锁也能自动释放。

二、Redis分布式锁基础实现

1. 核心命令:SET NX EX

Redis单线程特性保证SET命令的原子性,结合NX(不存在才设置)和EX(自动过期)参数,是分布式锁的基础:

plain 复制代码
# 加锁:lock_key为锁名,unique_id为客户端唯一标识(UUID+线程ID)
SET lock_key unique_id NX EX 30

# 执行结果:
# 加锁成功 → OK
# 锁已被占用 → (nil)
  • NX:保证互斥性,只有锁不存在时才设置成功;
  • EX 30:设置30秒过期时间,防止客户端宕机导致死锁;
  • unique_id:唯一标识客户端,避免解锁时误删其他客户端的锁。

这样也行,分两步,但是容易死锁(第一步执行完就宕机)

plain 复制代码
127.0.0.1:6379> setnx key1 1
(integer) 1
127.0.0.1:6379> setnx key1 1
(integer) 0
127.0.0.1:6379> expire key1 60
(integer) 1

2. 原子解锁:Lua脚本

Lua 脚本是 Redis 分布式锁最核心、最经典的解锁逻辑 ,目的是保证解锁操作的原子性 + 防止误删其他客户端的锁

解锁时需先校验锁归属,再删除锁,两步操作必须原子执行(避免查锁和删锁之间锁过期):

lua 复制代码
-- 解锁Lua脚本
-- 1. 校验锁的归属:判断锁key的值是否等于当前客户端的唯一标识
if redis.call('get', KEYS[1]) == ARGV[1] then
  -- 删除锁,返回删除结果(成功返回1,失败返回0)
    return redis.call('del', KEYS[1])  -- 归属一致
  
else
    return 0  -- 归属不一致,返回失败
end

执行脚本:

plain 复制代码
EVAL "脚本内容" 1 lock_key unique_id

KEYS[1]:Lua 脚本的「键参数」,对应解锁时传递的锁名 (比如order_lock

ARGV[1]:Lua 脚本的「值参数」,对应解锁时传递的客户端唯一标识 (比如uuid-123456-thread-01

如果**不用 Lua 脚本,**解锁会拆成两步 Redis 命令:

lua 复制代码
# 第一步:查锁的值
GET order_lock
# 第二步:如果值匹配,删除锁
DEL order_lock

这两步操作非原子性,会导致致命问题:

场景:客户端 A 持有锁(过期时间 30 秒),业务执行了 29 秒,此时执行GET order_lock拿到了自己的标识,但还没执行DEL,锁就过期了;客户端 B 立刻加锁成功,此时客户端 A 执行DEL,就会误删客户端 B 的锁。

而 Lua 脚本会把「查值 + 删锁」封装成原子操作(Redis 单线程执行脚本,执行过程中不会被其他命令打断),从根本上避免上述误删问题。

3.Lua 脚本在秒杀场景的实战应用

秒杀场景的核心诉求是「高性能 + 原子性」:

  • 高性能:Redis 内存操作响应速度达毫秒级,远优于数据库直查;
  • **原子性:**将秒杀核心逻辑封装在 Lua 脚本中,Redis 单线程执行脚本,避免多请求并发导致的超卖、重复下单;
  • **异步解耦:**秒杀成功后,通过 Redis Stream 队列异步处理订单入库,同步接口仅返回订单 ID,提升吞吐量。
java 复制代码
<!-- Spring Data Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

1. Lua 脚本加载(Java 层)

首先在 Java 中加载 Lua 脚本文件,通过DefaultRedisScript封装脚本信息,静态代码块保证脚本仅加载一次,避免重复 IO 开销:

java 复制代码
// 定义Redis Lua脚本对象
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

static {
    SECKILL_SCRIPT = new DefaultRedisScript<>();
    // 指定脚本路径(resources目录下的seckill.lua)
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
    // 声明脚本返回值类型(对应Lua脚本返回的0/1/2)
    SECKILL_SCRIPT.setResultType(Long.class);
}

2. 秒杀接口核心逻辑(Java 层)

Java 层负责接收秒杀请求、准备参数、执行 Lua 脚本,并根据脚本返回结果处理业务响应:

java 复制代码
@Override
public Result seckillVoucher(Long voucherId) {
    // 1. 获取当前登录用户ID(从ThreadLocal中获取)
    Long userId = UserHolder.getUser().getId();
    // 2. 生成全局唯一订单ID(基于Redis自增ID生成器)
    long orderId = redisIdWorker.nextId("order");
    // 3. 执行Lua脚本(核心原子操作)
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(), // KEYS[]参数(示例中暂未使用,生产需优化)
            voucherId.toString(),    // ARGV[1]:优惠券ID
            userId.toString(),       // ARGV[2]:用户ID
            String.valueOf(orderId)  // ARGV[3]:订单ID
    );
    int r = result.intValue();
    // 4. 根据脚本返回值处理响应
    if (r != 0) {
        // 1=库存不足,2=重复下单
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }
    // 5. 秒杀成功,返回订单ID(后续异步处理订单入库)
    return Result.ok(orderId);
}

3. 秒杀核心逻辑(Lua 脚本层)

seckill.lua

java 复制代码
-- 1. 接收Java层传递的参数
local voucherId = ARGV[1]  -- 优惠券ID
local userId = ARGV[2]     -- 用户ID
local orderId = ARGV[3]    -- 订单ID

-- 2. 定义Redis业务Key
local stockKey = 'seckill:stock:' .. voucherId  -- 库存Key
local orderKey = 'seckill:order:' .. voucherId  -- 已下单用户集合Key

-- 3. 原子性业务逻辑
-- 3.1 校验库存(空值处理+数值校验)
local stock = redis.call('get', stockKey)
if not stock or tonumber(stock) <= 0 then
    return 1  -- 库存不足,返回1
end
-- 3.2 校验重复下单(Set集合快速判断)
if redis.call('sismember', orderKey, userId) == 1 then
    return 2  -- 重复下单,返回2
end
-- 3.3 扣减库存(原子操作,避免超卖)
redis.call('incrby', stockKey, -1)
-- 3.4 记录已下单用户(防止重复下单)
redis.call('sadd', orderKey, userId)
-- 3.5 发送下单消息到Stream队列(异步处理订单入库)
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
-- 3.6 秒杀成功,返回0
return 0

三、进阶优化:可重入与自动续期

1. 可重入锁实现

你想理解 "可重入" 的核心含义,简单来说:

一个执行体(线程 / 进程)在已经持有某把锁的情况 下,再次请求获取这把锁时不会被自己卡住(死锁),而是能成功拿到锁

反之,"不可重入锁" 会因为自己持有锁又去抢锁,导致自己堵死自己。

基础锁无法支持重入(同一客户端重复加锁会失败),需基于Redis哈希结构实现:

lua 复制代码
-- 加锁Lua脚本(可重入)
if redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
    redis.call('hincrby', KEYS[1], ARGV[1], 1)  -- 重入次数+1
    redis.call('expire', KEYS[1], ARGV[2])
    return 1
elseif redis.call('setnx', KEYS[1], ARGV[1]) == 1 then
    redis.call('expire', KEYS[1], ARGV[2])  -- 首次加锁
    return 1
else
    return 0  -- 锁被其他客户端持有
end

-- 解锁Lua脚本(可重入)
if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then
    return nil
elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then
    return redis.call('del', KEYS[1])  -- 重入次数归0,删除锁
else
    return 1  -- 仅减少重入次数
end
public class DemoService {

    @Resource
    private RedissonClient redissonClient;

    public void add1() {
        // 获取分布式锁对象
        RLock lock = redissonClient.getLock("heimalock");
        // 尝试获取锁,最多等待3秒,锁自动释放时间为30秒
        // boolean isLock = lock.tryLock(3, 30, TimeUnit.SECONDS);
        // 尝试获取锁(非阻塞)
        boolean isLock = lock.tryLock();
        
        try {
            // 执行业务逻辑
            add2();
        } finally {
            // 释放锁(保证无论业务是否异常,锁都会被释放)
            lock.unlock();
        }
    }

    public void add2() {
        // 获取分布式锁对象(与add1使用同一锁名,体现可重入特性)
        RLock lock = redissonClient.getLock("heimalock");
        // 尝试获取锁(非阻塞)
        boolean isLock = lock.tryLock();
        
        try {
            // 执行业务逻辑
        } finally {
            // 释放锁
            lock.unlock();
        }
    }
}

代码说明与问题点:

  1. 可重入特性 :Redisson 的 RLock 是可重入锁,同一线程在 add1() 中获取锁后,调用 add2() 时可以再次获取同一把锁,不会被阻塞。
  2. 锁释放优化 :原代码将 unlock() 直接写在方法末尾,存在业务异常时锁无法释放的风险,因此我将其放入 finally 块,保证锁一定会被释放。
  3. 潜在问题
    • tryLock()非阻塞的,如果获取锁失败会直接执行业务,可能导致并发问题,生产中建议添加获取失败的处理逻辑(如重试或抛出异常)。
    • 未指定锁超时时间,Redisson 会启用看门狗自动续期,适合长时间业务,但需注意避免死锁。

2. 自动续期(WatchDog看门狗机制)

若业务执行时间超过锁过期时间,会导致锁提前释放,需通过「看门狗」自动续期:

  • 原理:客户端持有锁时,启动后台线程,每隔10秒(过期时间的1/3)执行 **EXPIRE**命令重置过期时间
  • 实践:直接使用Redisson框架内置看门狗机制,无需手动实现。

3.Redisson 看门狗机制(实践)

lua 复制代码
<!--redisson-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

代码示例:

lua 复制代码
@Resource
private RedissonClient redissonClient;
//redissonClient  内置看门狗机制
private void createVoucherOrder(VoucherOrder voucherOrder) {
    Long userId = voucherOrder.getUserId();
    Long voucherId = voucherOrder.getVoucherId();
    // 创建锁对象
    RLock redisLock = redissonClient.getLock("lock:order:" + userId);
    // 尝试获取锁
    boolean isLock = redisLock.tryLock();
    // 判断
    if (!isLock) {
        // 获取锁失败,直接返回失败或者重试
        log.error("不允许重复下单!");
        return;
    }

    try {
        // 5.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
            log.error("不允许重复下单!");
            return;
        }

        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
                .update();
        if (!success) {
            // 扣减失败
            log.error("库存不足!");
            return;
        }

        // 7.创建订单
        save(voucherOrder);
    } finally {
        // 释放锁
        redisLock.unlock();
    }
}

1.RLock redisLock = redissonClient.getLock("lock:order:" + userId);

  • 锁名设计lock:order:用户ID(比如 lock:order:1001),核心是「按用户粒度加锁」------ 只限制同一个用户重复下单,不同用户之间不互斥,保证高并发下的性能(如果用全局锁,所有用户秒杀都要排队,性能极低);
  • RLock:Redisson 封装的分布式可重入锁,兼容 Java Lock 接口,使用方式和本地锁(ReentrantLock)几乎一致。

2.boolean isLock = redisLock.tryLock();

  • tryLock()非阻塞加锁 ,立即返回结果(获取锁成功返回 true,失败返回 false);对比 lock()lock() 是阻塞加锁,获取不到锁会一直等待,秒杀场景下会导致请求排队,甚至超时,所以优先用 tryLock()
  • 核心目的:同一用户同时发起多个秒杀请求时,只有一个请求能拿到锁,其他请求直接返回「不允许重复下单」,避免重复创建订单
  • 加锁失败处理:快速失败

3.try里面是业务核心机制

  • 锁内校验重复下单:即使加了分布式锁,仍需查询数据库校验(防止锁过期后,用户重复请求创建订单);
  • 扣减库存的原子性update 语句中 gt("stock", 0) 保证「只有库存 > 0 时才扣减」,避免超卖(数据库层面的原子操作);
  • 所有核心业务在锁内执行,保证同一用户的请求串行化,避免重复下单、超卖。

4.释放锁:finally 块保证必释放

  • **unlock()** 会先校验锁归属(只有当前客户端持有锁才会释放),避免误删其他客户端的锁(Redisson 内部已封装此逻辑)。

Redisson 看门狗机制(核心亮点)

1. 看门狗的作用

代码中没有手动设置锁的过期时间,但不会出现「业务执行时间过长导致锁过期」的问题 ------ 因为 Redisson 内置了看门狗(Watch Dog)

  • 触发条件 :使用 tryLock()/lock() 且未指定过期时间时,自动启用看门狗;
  • 核心逻辑
    1. 加锁成功后,看门狗启动一个后台线程(默认每 10 秒执行一次);
    2. 只要客户端还持有锁,看门狗就会自动将锁的过期时间重置为「默认 30 秒」;
    3. 当客户端执行完业务、调用 unlock() 释放锁后,看门狗线程停止;
  • 解决的问题:避免业务执行时间超过锁过期时间,导致锁提前释放,进而引发重复下单。
2. 手动指定过期时间(可选)

如果想手动控制锁过期时间,可使用 tryLock 重载方法:

java 复制代码
// 尝试获取锁,最多等待1秒,持有锁最多30秒(超过30秒自动释放,看门狗失效)
boolean isLock = redisLock.tryLock(1, 30, TimeUnit.SECONDS);

代码优缺点分析:

优点
  1. 细粒度锁:按用户 ID 加锁,不同用户不互斥,并发性能高;
  2. 防死锁:看门狗自动续期 + finally 块释放锁,双重保障;
  3. 防重复下单:分布式锁 + 数据库查询双重校验,彻底避免重复;
  4. 防超卖 :数据库更新时加 stock > 0 条件,保证库存不会为负;
  5. 易用性:Redisson 封装了分布式锁的底层细节(Lua 脚本、原子操作、续期),开发无需关注底层。
待优化点
  1. 库存校验与扣减分离:锁内先查库存(代码中未显式查库存,直接扣减),高并发下可能出现「库存为 0 但仍扣减」的极端情况(建议扣减前先查库存);
  2. 无重试机制tryLock() 失败后直接返回,可增加「有限重试」(比如重试 3 次,每次间隔 100ms),提升用户体验;
  3. 分布式事务:扣减库存 + 创建订单是两个数据库操作,无分布式事务保障(比如库存扣减成功但订单创建失败,会导致库存少了但订单没创建),可引入 Seata 或「补偿机制」。

核心总结

  1. 这段代码是生产级秒杀订单创建逻辑,核心是 Redisson 分布式锁按用户粒度加锁,解决重复下单问题;
  2. Redisson 看门狗自动续期,避免锁过期导致的并发问题,无需手动处理续期;
  3. 锁内必须做「重复下单校验 + 库存扣减 + 订单创建」,且释放锁必须放在 finally 块;
  4. 设计思路:细粒度锁保证性能 + 双重校验保证正确性 + 看门狗避免

四、生产避坑:主从一致性问题

1. 问题根源

Redis主从架构下,主节点加锁成功后,若未同步锁数据到从节点就宕机从节点升级为主节点 后,其他客户端可重新加锁,导致锁失效。

关键问题:「其他客户端可重新加锁」到底怎么了?

到这一步,核心问题就暴露了:

  1. 客户端 A:以为自己还持有锁(主节点宕机前加锁成功),继续执行「扣库存、创建订单」等核心业务;
  2. 客户端 B:在新主节点上加锁成功,也认为自己持有唯一锁,也执行相同的核心业务;
  3. 最终结果:同一把锁被 A 和 B 同时持有,分布式锁的「互斥性」完全失效,直接导致:
    • 秒杀场景:超卖(A 和 B 都扣减库存,库存为负);
    • 订单场景:重复下单(A 和 B 都创建同一个用户的订单);
    • 转账场景:重复扣款(A 和 B 都扣同一个用户的余额)。

核心根源:异步同步 + 主从切换的数据丢失

  • Redis 主从的数据同步是异步的:主节点执行写操作后,不会等从节点同步完成再返回结果,而是先返回「操作成功」,再异步把数据发给从节点;
  • 主从切换时,未同步的数据会永久丢失:原主节点宕机后,未同步到从节点的 **lock_key**(锁数据)没了,新主节点完全不知道「客户端 A 已经加过锁」,所以会允许客户端 B 重新加锁。

2. 解决方案

方案1:RedLock(红锁)

部署3+个独立Redis节点**(无主从),客户端向所有节点加锁,满足以下条件则加锁成功**:

  • 超过半数节点加锁成功;
  • 总耗时≤锁过期时间的一半。
    解锁时需向所有节点释放锁,保证锁的一致性。
方案2:替换强一致性中间件

若业务要求100%强一致性,放弃Redis,改用:

  • ZooKeeper/etcd:基于CP模型,临时有序节点实现分布式锁,主节点宕机时临时节点自动删除;
  • 数据库行锁 :通过唯一索引+select ... for update实现,完全保证一致性,但性能较低。

五、分布式锁选型对比

方案 一致性 性能 部署难度 死锁风险 适用场景
Redis基础锁 AP 极高 高并发、性能优先
Redis红锁 强一致 高并发+需一致性
ZooKeeper CP 核心业务、强一致性
数据库锁 ACID 极低 低并发、低成本

六、规范

  1. 优先使用Redisson:封装了可重入锁、公平锁、红锁等,内置看门狗,开箱即用;
  2. 锁名规范 :按业务维度命名(如order:lock:1001),避免冲突;
  3. 过期时间合理:根据业务耗时设置,留10%冗余(如业务最多执行20秒,设EX 25);
  4. 集群适配 :将锁key通过KEYS[]传递给Lua脚本,保证集群槽位一致;
  5. 异常处理:加锁失败后重试需设置间隔(如100ms),避免高频重试压垮Redis。

七、总结

Redis分布式锁是高并发场景的首选方案,核心是通过SET NX EX保证原子加锁,Lua脚本保证原子解锁;进阶场景需解决可重入、自动续期、主从一致性问题;强一致性业务可选择RedLock或替换为ZooKeeper。生产中优先使用成熟框架(如Redisson),避免重复造轮子,同时结合业务场景权衡性能与一致性。

相关推荐
霖霖总总2 小时前
[Redis小技巧5]Redis Sorted Set 深度解析:从跳表原理到亿级排行榜架构
redis·架构
IvorySQL2 小时前
IvorySQL 5.3 正式发布:基于 PG 18.3 内核,多特性升级+全场景适配
数据库·postgresql·开源
冰糖拌面2 小时前
mysql 与 pg 的网卡监听参数
数据库·mysql·postgresql
DBA小马哥2 小时前
智能电网调度系统国产化:为什么总卡在数据库替换这一步?
数据库
JAVA学习通2 小时前
InnoDB 存储引擎
java·数据库·mysql
努力学算法的蒟蒻2 小时前
day106(3.7)——leetcode面试经典150
算法·leetcode·面试
Kim Jackson2 小时前
我的世界Java版1.21.4的Fabric模组开发教程(二十三)创建生物(下)实体在游戏中的实现(1)
java·游戏·fabric
温柔一只鬼.2 小时前
GUI学习——day2
java·开发语言·学习