Redis:分布式锁,Redisson以及看门狗机制解析

redis集群实现原理

主要由三部分组成:分片,Gossip协议,去中心化

  • 数据怎么存

Redis集群引入了哈希槽的概念,将整个数据集群划分为16384个槽。集群中每一个主节点复制维护一部分槽。存一个key时,redis先对key计算一个CRC16值,然后对16384取模,算出属于哪个槽,再把数据放到槽对应的节点。

  • 节点之间怎么联系

    集群中的节点是去中心化的。他们之间通过Gossip协议连接。通俗理解就像八卦协议,各个节点之间通过交流来保证数据的一致。

  • 客户端怎么找数据

    客户端随机连接一个节点。如果数据真好再这个节点,直接返回结果。如果不在,这个节点会返回MOVED错误(包含了所需数据所在在节点的IP地址)

拓展

跨界点请求示例

如果客户端连接的是Node1,但需要访问存储在Node3 的键user:1001,查询过程如下:

1)客户端使用CRC16 算法计算 user:1001的哈希值12345,计算哈希槽:12345 %16384=12345

2)因为客户端连接的是 Node1,所以发送GETuser:1001到Node1

3)Node1检测到这个键属于 Node3,返回一个MOVED 错误,里面带着Node3 的IP 和端口4)客户端根据返回的目标节点信息,建立与Node3 的连接

5)客户端向Node3发送GETuser:10016)Node3 查询到值并返回结果

Gossip协议

核心思想就是:向人传闲话一样,随机找几个节点聊天,把消息扩散出去。

有三种传播模式:

  • push模式:节点a知道了新消息,随便选几个节点将消息推送,被推送的节点重复上述过程,强调主动推送。
  • pull模式:节点之间定期互相打招呼。强调节点之间定期交换消息。
  • push+pull模式:既主动推也定期拉取消息,最常用的模式。

Redis集群出现的脑裂问题

我们来讲讲出现的原因:

当发生网络问题时,主节点与从节点之间的网路断了,但是主节点没有挂,哨兵检测到主节点失联会选一个从节点升级为主节点。当网络恢复后,旧的主节点会被降级为从节点,他会从新的主节点同步数据,之前客户端写到旧主节点的数据就全丢了。

如何解决的呢?

redis提供了两个参数来解决脑裂:

  • min-slaves-to-write:主节点必须有至少N个从节点确认才能执行写操作
  • min-slaves-max-lag:从节点最大延迟秒数,超过这个值就不算有效从节点
    比如配置 min-slaves-to-write=2 和 min-slaves-max-lag=10,主节点只有在至少2个从节点延迟不超过10秒是才能接收写入。发生脑裂时,就会拒绝写入。

Redis中如何实现分布式锁

先来了解一下什么业务的场景需要分布式锁:

在多台服务器同时访问共享资源时,为了保证一次只有一个实例操作,避免冲突和数据不一致。

这就诞生了分布式锁的出现。

也就是说,分布式锁的作用是:在分布式系统中保证多个节点对共享资源的互斥访问。

如何实现的呢?

在Redis中使用SET key value EX seconds NX命令配合Lua脚本。加锁时用NX保证互斥,EX设置过期防止死锁;解锁时必须使用Lua脚本先校验再删除,保证原子性。

加锁流程

  • 客户端执行SET lock_key uuid EX 30 NX
  • reids检查lock_key是否存在
  • 不存在则设置成功,返回ok
  • 存在则返回nil,加锁失败

解锁流程

  • 客户端执行Lua脚本
  • GET lock_key获取当前值
  • 判断是否等于自己的UUID
  • 相等,DEL lock_key,解锁成功
  • 不等,返回0

拓展

为什么解锁要用Lua脚本

我们分析lua脚本的特征:在redis中执行Lua脚本是原子性的。

我们再来分析原因:解锁过程分为了两步 ,1.先判断锁是不是自己的 2.删除

如果这两部分开执行,中间可能会被插一脚。

下图为锁被误删的常见场景:

所以每个客户端加锁时要带上唯一标识,像UUID。解锁时要先判断是否是自己的,才能del。

单点故障问题

在用单机redis做分布式锁,会出现一个致命问题:redis挂了锁就没了

用主从结构也不保险。客户端在主节点加锁成功后,还没将数据同步给从节点就宕机了,从节点晋升为主节点,但是锁的数据还没同步过来。另一个客户端来新主节点加锁,也能成功,这就乱套了。

如何解决的:Redlock算法(红锁)
Redlock红锁

Redlock的思路是用多个独立的Redis实例,通常部署5个。客户端哟啊在大多数实例上加锁成功才算拿到锁。
加锁流程:

1)客户端记录当前时间戳

2)依次向5个Redis 实例发送加锁请求,每个请求设置较短的超时时间,比如 5-50ms3)统计成功加锁的实例数量,同时计算加锁总耗时

4)如果成功数量>=3个,且总耗时<锁的过期时间,加锁成功5)否则向所有实例发送解锁请求,释放已经加锁的实例

解锁就简单了,直接向所有实例发送DEL命令。
Redlock的问题也不少:

1)部署复杂,要维护5个独立 Redis 实例,成本高

2)依赖系统时钟,如果某个节点时钟跳变,锁的有效期计算就不准了3)高并发场景下要访问多个实例,延迟会变高

4)长时间任务需要续期,多实例续期逻辑更复杂

生产环境如果用Java,推荐直接用 Redisson 框架,它封装好了分布式锁的各种实现,包括可重入锁、公平锁、读写锁,还有看门狗机制自动续期。

接下来我们就来了解一下什么是Redission。

Redissson分布式锁的原理

核心就一句话:用Redis的Hash结构+Lua脚本保证了原子性,再加上一个后台线程做自动续期。
加锁流程

分为三步

  • 锁不存在,直接创建Hash结构,field是线程标识,value为1,然后设置过期时间,加锁成功
  • 锁存在且field匹配当前线程,说明是可重入锁,value+1,刷新过期时间,加锁 成功
  • 锁村子啊但是field不匹配,说明被别人占着 ,返回锁的剩余时间,加锁失败
    用Hash而不是String的好处是天然支持可重入 ,同一个线程多次加锁只需要累加计数。
    自动续期(看门狗机制)

加锁成功后(不手动设置过期时间),Redisson会启动一个后台定时任务,默认每隔10秒把锁的过期时间重置为 30 秒。只要业务线程还活着、还持有锁,这个续期任务就会一直跑。业务执行完调用unlock或者线程挂了,续期任务才停止,锁自然过期。

这套机制叫WatchDog,解决了"业务还没跑完锁就过期"的难问题。

解锁流程

解锁同样和reids原生解锁一样,都用Lua脚本:

  • 锁不存在或者field不匹配当前线程,直接返回。防止误删
  • field匹配,计数-1,如果减完还大于0,说明还有重入没退出,刷新过期时间
  • 计数减到0,删除key,同时publish一条消息通知其他等待的线程

拓展

Redisson支持的锁类型

刚刚讲了在redisson中锁的实现原理。现在我们来补充一下redisson支持哪些锁的类型呢:

Redis实现分布式锁可能遇到的问题

开门见山:

  • 锁过期但是业务每跑完:锁设置了30秒过期时间,但是业务需要35秒,锁自动释放之后,被别的线程修改,数据就乱 了
  • 误删别人的锁:A拿到锁执行超时,锁自动过期;B拿到新锁,A执行完去删锁讲B的锁给删了
  • 主从同步延迟:主节点刚写入锁就挂了,数据还没同步给从节点,新主节点晋升后就把锁丢了,其他客户端照样能加锁。
  • 单点故障:Redis单机部署,挂了整个锁服务就瘫痪了
  • 时钟漂移:多节点的系统时间不一致,TTL判断不准确,锁可能提前失效或者不释放。
  • 锁不可重入:同一个线程无法再次获取同一把锁

Redisson看门狗(watch dog)机制

刚刚在了解Redisson的时候提到了看门狗机制,鉴于这个知识点很重要,我们来详细讲讲并且掌握它。

我们先来看看门狗解决了什么问题:

场景假设:当一个业务还没有执行完,锁的超时时间就到了,锁被自动释放,两个线程同时操作临界资源,数据就乱了。

通过场景我们了解到:看门狗就是用来解决分布式锁过期问题的


原理讲解:

当我们没有给锁设置leaseTime时,Redisson会启动一个定时任务,默认每10秒往Redis 发一次请求,把锁的过期时间重置为 30 秒。只要业务还在跑,锁就一直续着。


,当业务逻辑执行完成后(释放锁的时候),看门狗定时任务就被取消。如果客户端直接宕机了,定时任务自然就没了,不会再续期,等30秒超时一到自动释放,不会死锁。

拓展

看门狗源码分析

续期机制主要涉及两个方法:scheduleExpirationRenewal 负责在获取锁后启动续期任务,renewExpiration 负责定期刷新过期时间。
scheduleExpirationRenewal

该方法在客户端拿到锁之后被调用,干的事情就是把当前锁注册到续期map中,然后启动定时任务。

1)先创建一个ExpirationEntry对象来存锁的过期信息

2)用putIfAbsent往EXPIRATION_RENEWAL_MAP里塞。如果已经有了说明别的线程正在续期这把锁那就把当前线程 ID 加进去就行

3)如果是第一次创建这个条目,调用renewExpiration()启动定时任务

4)如果当前线程被中断了,调用cancelExpirationRenewal(threadId)取消续期

renewExpiration方法

它通过 Netty 的时间轮创建一个定时任务,每隔internalLockLeaseTime/3 毫秒执行一次。默认 leaseTime 是 30 秒,所以默认每 10 秒续一次。

定时任务执行的时候:

1)先从map 里重新拿一次ExpirationEntry,拿不到就说明锁已经释放了,直接返回

2)检查里面有没有线程ID,没有说明没人持有锁,直接返回

3)调用renewExpirationAsync(threadId)异步续期

4)续期成功就重新调度自己,相当于递归续下去;续期失败就取消续期,清掉map里的条目

中文注释版源码如下:

java 复制代码
// 续期锁的过期时间
private void renewExpiration() {
    // 从过期续期映射中获取锁的过期条目
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
        return; // 如果找不到条目,说明没有锁需要续期
    }

    // 创建一个定时任务,用于定期续期锁的过期时间
    Timeout task = commandExecutor.getServiceManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            // 重新获取过期条目
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return; // 如果条目已被移除,结束任务
            }
            // 获取持有锁的线程 ID
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return; // 如果没有线程 ID,说明没有线程持有该锁,结束任务
            }

            // 异步续期锁的过期时间
            CompletionStage<Boolean> future = renewExpirationAsync(threadId);
            future.whenComplete((res, e) -> {
                if (e != null) {
                    // 如果续期过程中发生错误,记录日志并移除续期条目
                    log.error("Can't update lock {} expiration", getRawName(), e);
                    EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                    return;
                }

                if (res) {
                    // 如果续期成功,重新调度续期任务
                    renewExpiration();
                } else {
                    // 如果续期失败,取消续期操作
                    cancelExpirationRenewal(null);
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); // 定时任务每 internalLockLeaseTime / 3 毫秒执行一次

    // 设置定时任务到过期条目中
    ee.setTimeout(task);
}

// 启动续期操作,首次获取锁时会调用此方法
protected void scheduleExpirationRenewal(long threadId) {
    // 创建新的过期条目
    ExpirationEntry entry = new ExpirationEntry();
    // 尝试将新的条目加入到续期映射中
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    if (oldEntry != null) {
        // 如果条目已存在,说明已有其他线程在续期,添加当前线程 ID 到条目中
        oldEntry.addThreadId(threadId);
    } else {
        // 如果是首次添加,开始进行续期操作
        entry.addThreadId(threadId);
        try {
            // 启动锁过期续期任务
            renewExpiration();
        } finally {
            // 如果当前线程被中断,取消续期操作
            if (Thread.currentThread().isInterrupted()) {
                cancelExpirationRenewal(threadId);
            }
        }
    }
}

Redis如何保证缓存与数据库的一致性

缓存和数据库存在双写一致性的问题,本质是分布式系统下两个数据源同步的问题,无法完全解决,只能优化缓解。

我们在这里只讲三种实际工程中使用的方法:

  1. 先更新数据库,再删除缓存:事实一致性高要求首选,可能极端环境下出现不一致,但概率很低。
  2. 缓存双删:先删缓存,更新数据库,延迟再删一次
  3. Binlog异步更新:用Canal监听MySQL Binlog,同消息队列更新缓存,最终一致性好
    方法的实际抉择:对强一致性要求高选择:先更新数据库再删缓存。对最终一致性要求选择:Binlog方案。

缓存双删

刚刚提到了缓存双删,我们再这里详细介绍一下:

缓存双删意为:先删除缓存,再写数据库,然后过一段时间再删除缓存

删除两次缓存的目的,主要是解决先删缓存再更新数据库的并发问题。再更新数据库后延迟删除一次缓存。即使有请求再中间把旧数据回写了,延迟删除也能把它清楚

先更新数据库再删除缓存

优选情况,但是再极端环境也会出情况

  1. 缓存刚好过期
  2. 请求A查询,缓存没命中,去数据库读到旧值
  3. 请求B更新数据库,删除缓存
  4. 请求A把旧值回写缓存
    但这个场景发生的概率极低,因为它要求写操作在读操作"查数据库"和"写缓存"之间完成,而写数据库通常比回写缓存慢很多。实际业务中几乎不会遇到,所以这个方案是性价比最高的。

先写数据库,通过Binlog,异步更新缓存

用Canal伪装成MySQL的从节点,监听到主节点数据变更,解析出数据变更事件,向消息队列发送,消费者负责更新或删除Redis缓存。

优点:业务代码不需要管缓存更新,数据库概率缓存自动变更。而且消息队列有重试机制,能保证最终一致性。

需要注意的是:

  • 消息必须顺序消费,不然先改成10后改成20,但消费顺序反了,缓存旧乱了
  • 要有幂等处理,同一条消息可能重复投递
  • 有一定延迟,一般在百毫秒到秒级

拓展

为什么选择删缓存而不是更新

更新缓存有两个缺陷:

  • 缓存可能需要复杂计算才能得到最终值,每次更新都要计算,浪费资源。直接删掉让下次查询时重新计算更划算。
  • 并发更新顺序不可控。A把数据库改成10,B把数据库改成20,但网络抖动导致B的缓存更新先到达,A的后到达,最后数据库时20,缓存时10。
相关推荐
qq_392690662 小时前
JavaScript中Symbol类型的唯一性特征与创建规范
jvm·数据库·python
gbase_lmax2 小时前
gbase8s数据库权限分类及基础使用
数据库·oracle
u0110225122 小时前
Go语言如何处理multipart_Go语言multipart表单教程【精通】
jvm·数据库·python
运气好好的2 小时前
HTML怎么创建灵感标签智能推荐_HTML输入自动联想标签【技巧】
jvm·数据库·python
qq_349317482 小时前
CSS如何实现动态间距调整_通过CSS变量控制padding与margin值
jvm·数据库·python
无忧智库2 小时前
计量先行:虚拟电厂分布式资源接入的底层逻辑与工程实践深度解析(PPT)
分布式
m0_640309302 小时前
HarmonyOS 5.0 IoT开发实战:构建分布式智能设备控制中枢与边缘计算网关
分布式·物联网·harmonyos
djjdjdjdjjdj2 小时前
Redis怎样追踪系统执行的缓慢操作.txt
jvm·数据库·python