Redis分布式锁演进过程详解

目录

  1. 分布式锁的基本需求
  2. 第一阶段:SETNX的简单实现
  3. 第二阶段:加入过期时间
  4. 第三阶段:原子性操作
  5. 第四阶段:Lua脚本保证原子性
  6. 第五阶段:看门狗机制
  7. 第六阶段:可重入锁
  8. 第七阶段:红锁算法
  9. 常见问题总结

分布式锁的基本需求

核心要求

  • 互斥性: 任意时刻只有一个客户端能持有锁
  • 安全性: 只有持有锁的客户端才能释放锁
  • 活性: 不会发生死锁,最终一定能获取到锁
  • 容错性: 部分节点宕机后,锁服务依然可用

第一阶段:SETNX的简单实现

实现方式

SETNX lock_key "client_id"

代码示例

public boolean tryLock(String lockKey, String clientId) {
    // 尝试设置锁
    String result = jedis.set(lockKey, clientId, "NX");
    return "OK".equals(result);
}

public void unlock(String lockKey, String clientId) {
    // 简单删除锁
    jedis.del(lockKey);
}

存在的问题

1. 死锁问题

场景:客户端获取锁后宕机,锁永远不会被释放
问题:其他客户端永远无法获取锁
影响:系统完全阻塞

2. 误删锁问题

// 客户端A获取锁
SETNX lock_key "client_A"

// 客户端A业务执行时间过长
// 客户端B在客户端A还在执行时删除了锁
DEL lock_key

// 客户端C获取到锁,与客户端A同时执行
SETNX lock_key "client_C"

第二阶段:加入过期时间

实现方式

SETNX lock_key "client_id"
EXPIRE lock_key 30

代码示例

public boolean tryLock(String lockKey, String clientId, int expireSeconds) {
    // 设置锁
    String result = jedis.set(lockKey, clientId, "NX");
    if ("OK".equals(result)) {
        // 设置过期时间
        jedis.expire(lockKey, expireSeconds);
        return true;
    }
    return false;
}

存在的问题

1. 非原子性操作

时序问题:
1. SETNX 成功
2. 客户端宕机(EXPIRE未执行)
3. 锁永远不会过期
结果:死锁问题依然存在

2. 过期时间设置困难

问题:
- 设置太短:业务未完成锁就过期
- 设置太长:异常情况下锁释放慢
- 业务执行时间不确定

第三阶段:原子性操作

实现方式

SET lock_key "client_id" NX EX 30

代码示例

public boolean tryLock(String lockKey, String clientId, int expireSeconds) {
    // 原子性设置锁和过期时间
    String result = jedis.set(lockKey, clientId, "NX", "EX", expireSeconds);
    return "OK".equals(result);
}

public void unlock(String lockKey, String clientId) {
    // 检查是否是自己的锁
    String value = jedis.get(lockKey);
    if (clientId.equals(value)) {
        jedis.del(lockKey);
    }
}

存在的问题

1. 锁误删问题

// 时序问题
String value = jedis.get(lockKey);        // 1. 获取锁值
if (clientId.equals(value)) {             // 2. 判断是自己的锁
    // 此时锁可能已经过期,被其他客户端获取
    jedis.del(lockKey);                   // 3. 删除锁(可能删除了别人的锁)
}

2. 锁过期问题

场景:业务执行时间 > 锁过期时间
问题:锁在业务执行过程中过期,其他客户端获取到锁
结果:多个客户端同时执行临界区代码

第四阶段:Lua脚本保证原子性

释放锁的Lua脚本

-- 释放锁的Lua脚本
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

代码示例

public class DistributedLock {
    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 tryLock(String lockKey, String clientId, int expireSeconds) {
        String result = jedis.set(lockKey, clientId, "NX", "EX", expireSeconds);
        return "OK".equals(result);
    }
    
    public boolean unlock(String lockKey, String clientId) {
        Object result = jedis.eval(UNLOCK_SCRIPT, 
                                  Collections.singletonList(lockKey), 
                                  Collections.singletonList(clientId));
        return "1".equals(result.toString());
    }
}

解决的问题

  • ✅ 原子性释放锁
  • ✅ 避免误删其他客户端的锁

仍存在的问题

1. 锁续期问题

场景:业务执行时间不确定
问题:
- 锁过期时间设置困难
- 业务执行中锁过期导致并发问题
- 无法动态调整锁的持有时间

第五阶段:看门狗机制

Redisson的看门狗实现原理

1. 自动续期机制

// Redisson内部实现原理
public class RedissonLock {
    private void scheduleExpirationRenewal(long threadId) {
        ExpirationEntry entry = new ExpirationEntry();
        entry.addThreadId(threadId);
        
        // 每10秒续期一次(默认30秒过期时间的1/3)
        Timeout task = commandExecutor.getConnectionManager()
            .newTimeout(new TimerTask() {
                @Override
                public void run(Timeout timeout) {
                    // 续期Lua脚本
                    renewExpiration();
                }
            }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    }
}

2. 续期Lua脚本

-- 续期脚本
if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then 
    return redis.call('pexpire', KEYS[1], ARGV[1])
else 
    return 0
end

看门狗机制特点

优势:
✅ 自动续期,避免业务执行中锁过期
✅ 客户端宕机时,看门狗停止,锁自然过期
✅ 无需预估业务执行时间

工作原理:
1. 获取锁时启动看门狗
2. 定时检查锁是否还被当前线程持有
3. 如果持有,则续期锁的过期时间
4. 释放锁或客户端宕机时,看门狗停止

代码示例

// Redisson使用示例
public void businessLogic() {
    RLock lock = redissonClient.getLock("myLock");
    try {
        // 获取锁,启动看门狗
        boolean acquired = lock.tryLock(3, TimeUnit.SECONDS);
        if (acquired) {
            // 执行业务逻辑,无需担心锁过期
            doBusinessLogic();
        }
    } finally {
        // 释放锁,停止看门狗
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

第六阶段:可重入锁

可重入锁的需求

public void methodA() {
    lock.lock();
    try {
        methodB(); // 需要再次获取同一把锁
    } finally {
        lock.unlock();
    }
}

public void methodB() {
    lock.lock(); // 同一线程再次获取锁
    try {
        // 业务逻辑
    } finally {
        lock.unlock();
    }
}

Redisson可重入锁实现

1. 数据结构

# 使用Hash结构存储锁信息
HSET lock_key thread_id count
# 例如:
HSET myLock "thread_123" 2  # 线程123持有锁,重入次数为2

2. 获取锁的Lua脚本

-- 可重入锁获取脚本
if (redis.call('exists', KEYS[1]) == 0) then 
    redis.call('hset', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil
end

if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then 
    redis.call('hincrby', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil
end

return redis.call('pttl', KEYS[1])

3. 释放锁的Lua脚本

-- 可重入锁释放脚本
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 
    return nil
end

local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1)
if (counter > 0) then 
    redis.call('pexpire', KEYS[1], ARGV[2])
    return 0
else 
    redis.call('del', KEYS[1])
    return 1
end

第七阶段:红锁算法

单点故障问题

问题:Redis主从架构下的锁丢失
场景:
1. 客户端A在Master上获取锁
2. Master宕机,锁信息未同步到Slave
3. Slave提升为Master
4. 客户端B在新Master上获取到同一把锁
结果:两个客户端同时持有锁

红锁算法原理

1. 多实例部署

部署N个独立的Redis实例(通常N=5)
每个实例都是独立的,不存在主从关系

2. 获取锁流程

1. 获取当前时间戳 start_time
2. 依次向N个实例请求锁,设置超时时间
3. 计算获取锁的总耗时
4. 判断是否获取锁成功:
   - 获取锁的实例数 > N/2
   - 总耗时 < 锁的有效时间
5. 如果成功,锁的实际有效时间 = 原有效时间 - 总耗时
6. 如果失败,向所有实例释放锁

3. 代码示例

public class RedLock {
    private List<RedisClient> redisClients;
    
    public boolean tryLock(String lockKey, String clientId, long expireTime) {
        long startTime = System.currentTimeMillis();
        int successCount = 0;
        
        // 向所有实例请求锁
        for (RedisClient client : redisClients) {
            try {
                boolean success = client.set(lockKey, clientId, "NX", "PX", expireTime);
                if (success) {
                    successCount++;
                }
            } catch (Exception e) {
                // 忽略异常,继续下一个实例
            }
        }
        
        long costTime = System.currentTimeMillis() - startTime;
        
        // 判断是否获取锁成功
        if (successCount >= (redisClients.size() / 2 + 1) && 
            costTime < expireTime) {
            return true;
        } else {
            // 释放已获取的锁
            unlock(lockKey, clientId);
            return false;
        }
    }
}

常见问题总结

1. 死锁问题

原因:锁没有过期时间或客户端宕机
解决:设置合理的过期时间 + 看门狗机制

2. 锁误删问题

原因:删除锁时没有验证锁的所有者
解决:使用Lua脚本原子性验证和删除

3. 锁过期问题

原因:业务执行时间超过锁的过期时间
解决:看门狗机制自动续期

4. 可重入问题

原因:同一线程无法多次获取同一把锁
解决:使用Hash结构记录重入次数

5. 主从切换问题

原因:主从异步复制导致锁丢失
解决:红锁算法使用多个独立实例

6. 性能问题

原因:频繁的网络交互和Lua脚本执行
优化:
- 合理设置锁粒度
- 使用连接池
- 批量操作
- 监控锁的获取成功率和耗时

7. 时钟偏移问题

原因:不同服务器时钟不同步
影响:锁的过期时间计算不准确
解决:使用NTP同步时钟,或使用相对时间

最佳实践建议

1. 锁设计原则

  • 锁粒度要合适,避免粗粒度锁
  • 锁的key要有业务含义
  • 设置合理的超时时间

2. 异常处理

  • 必须在finally块中释放锁
  • 处理获取锁超时的情况
  • 记录详细的日志便于排查

3. 监控告警

  • 监控锁的获取成功率
  • 监控锁的持有时间
  • 监控Redis的性能指标

4. 降级策略

  • Redis不可用时的降级方案
  • 锁获取失败时的业务处理
  • 本地锁作为备选方案
#分布式锁##redis#
全部评论

相关推荐

06-28 18:58
已编辑
东北大学 Java
1.自我介绍2.List和Set分别有什么实现了线程安全的集合?ConcurrentHashMap线程安全的执行流程?3.介绍一下CAS的原理,是乐观锁还是悲观锁?4.讲一下任务进入线程池后的执行流程5.有很多线程执行的时间不一样,怎么安排执行?有一个线程依赖于上边这些线程所产生的结果,Java有什么类可以实现这个功能?6.如果一个大型项目,某一个时间所有的CPU的已经被占用了,导致服务不可用,我们开发人员应该如何使服务器尽快恢复正常7.讲一下Java的内存模型8.volatile关键字9.介绍一下单例模式,双重检查锁定的流程10.volatile除了可以保证线程之间的可见性外,还有什么功能?11.有哪些垃圾回收算法?G1用的是什么回收算法?12.老年代无法正常回收,可能是什么原因?13.讲一下Redis的淘汰策略,&nbsp;LRU的底层实现14.你认为,用LRU来进行淘汰,合理吗?如果存放的数量级很大,那么Redis要维护这么大的一个双向链表,这个开销是不是就很大了?应该如何解决这个问题15.讲一下MySQL的索引优化策略,讲一下索引覆盖16.有了解过索引下推吗?17.项目是实验室项目,还是网上找的练手项目?18.微服务是通过什么通信的?19.介绍一下项目用到的&nbsp;Guava,项目里边你认为比较复杂的地方20.项目里Redis的BitMap是如何实现人群标签的21.准备给项目加上一些什么别的内容?22.有没有对AI,大模型的相关知识有学习,有运用吗?平常用什么AI大模型23.&nbsp;手撕,给一个n*m的网格,每个网格都有一个值,求到达右下角终点的路径,并且路径和最小。一面过了
查看23道真题和解析
点赞 评论 收藏
分享
06-15 14:38
已编辑
门头沟学院 Java
一、先写了一份笔试题,难度类似八股(涉及面主要在Javase,jvm,juc)二、自我介绍(这里比较短,因为我没有介绍实习项目,我以为他会问就没有展开说)三、八股先是常见的Javase部分1.两个integer类型==号结果是false,这是为什么2.常见的集合类型有哪些(这里说我们项目中最常用的感觉是map,因为Redis的hash也要传map什么的,所以后面就对map开始发问)3.那map的底层是什么,扩容机制4.那hashmap是线程安全的吗5.你说concurrenthashmap是线程安全,那讲讲concurrenthashmap的底层6.那concurrenthashmap是在哪加锁7.你有了解加的是什么锁吗(只说了CAS,因为不了解juc,这里只扛到这层了)8.知道stream流吗,讲讲什么是stream流,有什么用(这部分没背八股,好在上家公司用的比较多这里用大白话讲了下,就是类似工厂流水线,一般我们用来批处理)9.那stream流可以存储元素吗10.什么样的元素可以使用stream流(完全不知道,技术官老哥人很好,看我半天不知道和我说了句函数式接口,这个也不知道他又给我讲,哭死)11.String类型一个是new出来的一个是””直接赋值,==结果是一样的吗12.了解字符串常量池吗,是干嘛的13.那你有了解juc么(老哥尝试性的问了一个juc的问题,看我完全不会他说那我不问了,哭死老哥,我回去一定恶补juc)14.多线程环境下在hashmap中添加元素会出现什么问题15.那ArrayList是线程安全吗16.如果我这里有100个线程,分别往ArrayList中加元素,会出现什么问题(这个八股没有准备,但是老哥人真的很好,引导我让我仔细想想,这里我只想到了可能会数据覆盖的情况)17.你们项目中用过泛型吗,怎么限定泛型的可用类型(以前Javase学过后再也没接触了,还是得多看源码啊,这里说了个super,extends死活想不起来了)这里实在憋不住了,看他一直在拷打八股没问实习项目,我直接毛遂自荐了,主动出击问他想不想听我讲一下实习项目,老哥笑了笑说可以,这里开始吟唱自己准备的项目八股(有点紧张语速太快了,可能就讲了七八分钟?)18-20.问了几个项目问题21.Redis有哪些过期策略(因为我实习八股主要围绕Redis讲了,所以这里Redis问的就没那么多了)22.Spring中事务的原理知道吗23.&nbsp;那AOP的原理了解吗,spring&nbsp;aop默认使用什么代理24.了解单例模式吗,都有哪几种模式25.那你遇到过单例模式吗26.MySQL的sql语句执行过程有了解吗27.MySQL的innodb引擎默认的数据结构是什么28.B+树的特点有哪些29.你有用过索引吗什么是索引,都有哪些常用的索引类型30.哪些情况下索引会失效31.你知道回表吗32.那什么是覆盖索引33.那你用过MySQL的事务吗,有哪些隔离级别,默认是哪个隔离级别34.最后问了几个不相关的问题,比如你大二这学期怎么去实习的,直接旷课裸考吗
点赞 评论 收藏
分享
评论
2
15
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务