【Redis】集群与分布式缓存
1. 初识 Redis Cluster
之前介绍的 Redis 主从结构,已经提高了系统的可用性,但是实际上全部业务数据依然存储在主从节点上。也就是说,主从结构并不是 Redis 服务的横向扩展,只是缓解了读压力,缓解不了写压力。如果业务继续扩展,需要缓存的数据进一步增加,写压力进一步增加,一个主节点就疲于应付了。此时就会有两种策略:要么为主从节点上更高的硬件配置,要么部署 Redis 切片集群。所谓 Redis 切片集群就是部署多台 Master,同时对外提供读写服务,下文主要介绍的就是这种策略。
2. 数据分片算法
Redis Cluster 的思路是使用多个分片(Sharding)来分别存储全量数据的一部分,每个分片包含一个 Redis 主从结构。因此现在最大的问题是,既然每个分片只存一部分数据,那么读数据的时候我该怎么知道要找哪个 Master 呢?写数据的时候我又怎么知道该向哪个 Master 写呢?对于这个问题,就要靠 Redis Cluster 使用数据分片算法来定义路由规则,实现负载均衡。
在介绍 Redis Cluster 使用的数据分片算法之前,先铺垫另外两种常见的数据分片算法,了解了这两种算法,才能更好地理解 Redis Cluster 算法的优势。
2.1 哈希求余
假设现在有三个分片,编号为 1 2 3。
收到一个 key,先通过一个哈希函数(如 MD5,SHA-1,CRC32,murmurhash)计算它的哈希值,再用这个哈希值 % 3,结果是几就分配在几号分片上。一个设计越好的哈希函数计算的结果分配越均匀,当大量不同的 key 经过哈希取余后,得到的余数也会是近乎均匀分布的。因此,请求会被大致平均地分配到所有服务器上,可以满足负载均衡。
这个方案最大的问题是,当系统需要扩容,需要更多分片时,就破坏了原有的映射规则,大量的 key 都需要重新映射。我们知道,如果增加了一个分片,是一定要向这个分片转移部分数据的,但关键是,这个转移会影响到多少已经部署的分片。在哈希求余中,这个影响是巨大的,因为一个数 %3 和 %4 后的结果大概率不会相同,这表示全部的分片都需要进行海量数据迁移,这样太危险,成本也太高。
2.2 一致性哈希
现在我将哈希函数生成的 32 位整数表示的范围直接分成三段,每台主机负责一段,这样的话,每台主机负责哪些数据和主机数量就没有任何关系了。
扩容时,有两种选择。第一,直接把某台主机负责的段分一半给新主机,简单粗暴,缺点是打破了负载均衡。第二,将每台主机负责的段都分一点给新主机,使得全部主机负责的段大致相等,显然这是更靠谱的做法。为了更清晰地计算和维护扩容后的分段改变,我们需要引入一个中间层,这个中间层可以是虚拟节点,也可以是哈希槽,思想是一样的。
2.3 Redis Cluster 的哈希槽分区算法
Redis Cluster 使用 CRC16 哈希函数将 key 映射为 16 位哈希值(不需要 32 位哈希值)。哈希槽这个中间层将全部哈希值映射到它的 16384 个槽位上(%16384),再以槽位为单位,分配给集群中的节点。
扩容时,Redis Cluster 严格以槽位为单位进行数据迁移,粒度可控。
实际上,16384 这个数字也是经过精心权衡的。
如果是 16 位哈希值,能表示的哈希值范围是 2 的 16 次方也就是 65536,显然这个粒度太低了,根本不需要以这个粒度为主机分配段。实际上 16384 已经绰绰有余,因为 Redis 官方已经提示分片个数不宜超过 1000,16384 这个分配粒度已经够细了。
并且哈希槽也是需要数据结构(位图)维护的,每个节点都要维护这样一个位图(为了明确自己负责的区域),节点之间相互传输的心跳包也会带有这个位图(因为它是节点信息的一部分)。如果槽位太多维护成本也随之上升,16384 个哈希槽需要(16384 / 8)2 kb 左右的空间存储,这个空间还算可以接受。
3. 集群的故障转移
故障判定:
每个节点,每秒钟,都会随机给其他节点发送心跳包(并不是给每个节点都发,避免心跳包占用过多带宽)。若某节点 B 不能及时回复节点 A 的心跳,A 首先会尝试重置和 B 的 TCP 连接,如果仍然失败,A 判定 B 为主观下线。判定主观下线后,A 和其他节点进行沟通,确认 B 的状态,如果认为 B 主观下线的节点超过集群中节点个数的一半,A 标记其为客观下线,并将这个决议同步给其他节点。此时,B 被判定为故障节点。
故障转移:
如果 B 是从节点,不需要故障转移。如果 B 是主节点,由其从节点进行故障转移。从节点会先休眠一段时间(避免所有从节点都立刻同时进行拉票),这个休眠时间由一段随机时间和同步情况共同决定,越接近主节点进度的从节点休眠时间越短,可以率先进行拉票。只有集群中的主节点具有投票资格,当某从节点获得的票数超过现存主节点数目的一半,则晋升为主节点。
4. 集群的扩容
Redis Cluster 在扩容期间可以继续提供服务,这是其核心设计目标。
根据哈希槽分区算法,在扩容时,每个分片都会转移部分槽位到新分片上。对于无需转移的数据,请求不受任何影响。对于正在转移的数据,客户端会收到一个 ASK 重定向指令,收到该指令后,该客户端会使用一个 ASKING 命令向新分片请求数据。
数据迁移结束后,依然使用 MOVED 重定向。
5. 分布式缓存
5.1 名词解释
缓存预热:
在系统上线前后或流量激增前(如大促活动),主动地将提前预测出的热点数据加载到缓存中,而不是等待用户请求来触发缓存写入。避免初期洪峰压垮数据库。
缓存穿透:
用户查询一个根本不存在于数据库中和缓存中的数据。由于缓存中不存在,每次请求都会穿透到数据库去查询。如果有人恶意攻击,用大量不存在的 Key 进行请求,就会给数据库造成巨大压力。
解决:
1. 做好接口校验,对于明显不合法的ID(如负数、非数字字符)直接拦截返回。或使用布隆过滤器。
2. 缓存空对象。即使从数据库没查到,也将空结果进行缓存。这样,短期内再请求同一个不存在的 key,会直接返回空,而不会访问数据库。
缓存雪崩:
在某一时刻,大量的缓存数据同时过期。此时请求全部涌向数据库,导致数据库瞬时压力过大而崩溃。这有可能是因为一部分 key 被设置了相同的过期时间,也有可能是 Redis 服务宕机。
解决:
1. 设置随机过期时间。
2. 使用 Redis 哨兵或集群,避免单点问题,构建高可用 Redis 集群。
缓存击穿:
某个热点 Key 在缓存中过期的瞬间,同时有海量请求对这个 Key 进行访问。这个 Key 的失效,像一颗子弹在缓存上击穿了一个洞,所有请求都从这个洞穿透到数据库,导致数据库瞬间压力激增。
解决:
1. 对于极少数超级热点数据,可以设置为永不过期。
2. 使用分布式锁。当请求发现缓存失效,不能直接查数据库,必须先去获取一个分布式锁,获取到锁的进程去查库,重建缓存。
5.2 缓存淘汰策略
1. 先进先出,淘汰缓存中存在时间最久的数据。
2. 淘汰最长时间没用过的。
3. 淘汰最不常用的。
4. 随机淘汰一个。
Redis 内置的缓存淘汰策略基本沿用上面的场景策略,只不过针对带有过期时间的 key 做了区分,我们可以选择只淘汰有过期时间的 key。默认策略是不自动淘汰数据,当内存不足时,新写入操作会报错。
5.3 缓存读写策略
在旁路缓存模式中,应用程序的读写逻辑如下:
读:先读缓存,缓存命中则返回数据;缓存未命中则从数据库读取,写入缓存,然后返回数据。
写:更新数据库然后将缓存中对应的数据项删除。
为什么不是更新缓存而是删除缓存?
1. 避免并发写问题:
在更新缓存策略下,如果两个写操作并发执行,由于网络延迟等原因,它们更新缓存的操作顺序可能与更新数据库的顺序不一致,从而导致缓存中是旧数据。
比如 A 更新数据库,然后 B 更新数据库,然后 B 更新缓存,最后 A 更新缓存。此时,数据库中的数据是 A 更新后 B 又更新后的数据,而缓存中的数据只是 A 更新后的数据,因为 B 的更新被覆盖了。
如果是删除缓存,则最终一定能保证缓存是被清空的。下一个读请求会因为缓存未命中而从数据库读取最新的值并重新填充缓存,这就保证了最终一致性。
2. 降低写操作负载:
如果采用更新缓存策略,则每次写操作都会更新缓存。更新缓存需要将完整的数据对象序列化并写入缓存,而删除缓存只需要一个简单的 DEL 命令。
3. 避免写入冷门数据:
需要更新的数据并不一定是热点数据,若该数据被更新后很长一段时间都不会再读取,那么该次缓存更新就是浪费的。而删除缓存是惰性的,只有在真正需要时(即下次读取时)才将数据加载到缓存中,这样缓存中保留的都是热点数据。
但更新缓存策略在特定场景下也有它的优势。如果在某场景下,已知该数据将被持续高并发地读取,希望尽可能避免任何一次请求穿透到数据库。使用更新缓存可以确保写之后缓存总是可用的。但此时需要通过分布式锁等手段来解决并发写问题,这会增加系统复杂度。
读写数据不一致:
虽然删除缓存策略能避免写并发问题,但是依然会有读写并发问题。
比如 A 查询缓存未命中,开始查数据库。随后 B 更新数据库中的数据,并删缓存。A 查到的是 B 更新前的数据,将旧数据写入缓存,造成数据不一致。
为了解决这个问题,有下面几种解决方案:
1. 延迟双删:写操作中,第一次删除缓存后,等一小段时间,再删除一次。
2. 为缓存设置较短的过期时间:即使发生上述情况,旧数据也会在一定时间后自动失效,实现最终一致。过期时间设置较短,确实数据一致性会变高,但缓存命中率也会变低,因此还是要根据实际业务做出权衡,设置合理的过期时间。
3.通过分布式锁来保证数据库和缓存强一致,但会严重损害性能。
6. Redis 实现简易分布式锁
只要涉及到多个线程或进程访问同一公共资源,都会涉及到使用锁做同步控制。在分布式系统下,需要使用分布式锁。分布式锁就是使用一个或一组服务器专门用于记录加锁状态。思路就是用一个键值对来标识锁的状态。
例如,对于一个商品下单的接口,通常是先校验库存,若库存为 0,返回下单失败;若库存大于 0,将库存减去下单数量,再返回下单成功。很明显这些操作必须打包成原子操作。
此时,收到请求的服务器必须先向锁服务器申请锁,即在 Redis 上设置一个键值对:
SET key value NX PX(毫秒)|EX(秒) timeout
key -> 资源 ID
value -> 服务器 ID,用于保证只有锁的持有者才能释放它。在代码中,删除 key 之前,加上对该 ID 的校验,只有它的确是当初加锁的服务器才能删除。
NX -> 当且仅当 key 不存在时才能设置成功,这是实现分布式锁的核心字段
PX|EX -> 为了防止客户端崩溃后锁永远无法释放,必须为锁设置一个过期时间。
只有设置该键值对成功,才能访问共享数据,访问之后,在通过服务器 ID 校验后,使用 DEL 指令删除该 key。
"检查锁所有权" 和 "删除锁" 之间的时间窗口内,锁的所有权有极小概率发生变化,如下图。为了确保万无一失,还是要将这两步打包成原子操作(用 lua 脚本执行)。此时,SET 只是一行命令天然保证原子性,删除时也能通过 lua 脚本保证原子性。

实际上,凭感觉设置过期时间是不靠谱的,如果设置太短,锁有可能提前失效,如果设置太长,其他服务器不能及时获取到锁。我们必须能比较精确地控制这个过期时间,也就是监视服务器的任务执行情况。
因此我们可以先为锁设置一个较短的超时时间,随后启动一个后台线程,称为看门狗(Watch Dog),定期去检测服务器的任务执行情况。若检测到其未完成任务,则重置超时时间(续约)。
对于更极端的情况,可能需要集群部署分布式锁。如果当前 Redis 锁服务器是主从结构,从节点对主节点可能存在一定的数据延迟,如果锁信息还未同步给从节点,主节点就宕机了,锁信息就会丢失。因此如果想保证锁的更高可用性,还是要使用多个主节点。加锁时,向每个主节点都要 SET key,判定锁是否加成功依然要遵守半数以上原则,这样就不会因为个别主节点的加锁失败而导致整体锁服务不可用。释放锁的时候,也要把所有主节点都进行解锁,判定解锁是否成功也要遵守半数以上原则。
上面的模式称为 Redlock 算法,其思想就是避免单点问题,不能只写一个主节点,要写多个,加锁成功的结论一定是 “少数服从多数” 的,而不是只听一个节点的。
上面只是基于 Redis 实现分布式锁的一些最基本的原理,实际上我们也不会在业务中自己实现分布式锁,使用现成的工具如 Java 的 Redission 是更好的选择。
