一、介绍一下Redis的常用数据结构?
首先Redis是一种key-value结构的非关系型数据库,key通常为String类型,value的类型有很多,不同的类型代表着Redis不同的数据结构。
1.String类型,它是二进制安全的,里面能存储各种对象,如图片、序列化后的对象,一个键最大能存储512MB。一般用于常规缓存(Token、Session)、分布式锁(SETNX),计数器(INCR,如文章浏览量)还有全局发号器。底层是SDS(简单动态字符串)。
ps:Redis是使用C语言编写的,对于String类型,C语言要求必须以空字符\0结尾,相当一遇到\0它就会以为字符串结束了然后做一个截断 。但是对于序列化后的对象或者是多媒体文件之类的,\0是经常存在的,这就会导致这些对象被阉割无法正常存储 ,即非二进制安全。二进制安全指的是它原本是什么二进制数据,最后要原封不动的取出来。于是Redis采用的自定义类SDS(简单动态字符串)作为自己的默认字符串,len属性记录二进制数据的原始长度,取代C语言原本凭借识别\0来确认数据长度。数据写入buf数组时,Redis中的API也会按照二进制的方式处理这些数据,这样Redis的字符串不仅能存储简单的文本数据,还能存储序列化后的对象或者各种多媒体文件。Redis 3.2 之后对 SDS 进行了优化,根据字符串的长度,划分了**
sdshdr5,sdshdr8,sdshdr16,sdshdr32,sdshdr64** 五种结构体,使用不同大小的整数来记录长度,并通过**__attribute__ ((__packed__))**取消内存对齐,进一步极致地节省了内存。
javastruct sdshdr { // 记录 buf 数组中已使用字节的数量 // 等于 SDS 所保存字符串的长度 int len; // 记录 buf 数组中未使用字节的数量 int free; // 字节数组,用于保存真正的二进制数据(不仅仅是字符) char buf[]; };2.Hash类型,它是一个键值对集合,集合内每个元素都是一个String类型的field-value映射表,非常适合存储对象,比如说商品信息、用户信息,相较于序列化后的JSON字符串,它可以更加方便地取出需要的字段,减少网络开销。数据少时使用 ZipList(压缩列表)/ ListPack(紧凑列表,Redis 7.0+) ,数据多时转为 HashTable(哈希表)。
3.List类型,它类似一个String类型的双向链表,可以从头部或者尾部插入或移除元素。可以使用LPUSH和BRPOP命令实现简单的消息队列,或者是时间线和Feed流,如用户历史足迹、最新微博列表。早期是 ZipList 或 LinkedList,Redis 3.2 之后统一升级为 QuickList(快速列表)。
4.Set类型,是一种String类型元素的集合,集合内各个元素是唯一的,它是基于Hash实现的,因此查找、添加、移除操作的时间复杂度均为O(1)。元素全是整数且数量少时使用 IntSet(整数集合) ,否则使用 HashTable。
5.ZSet类型,它跟Set一样,集合内各个元素是唯一的且为String类型,但是区别在于每个元素都有一个double类型的分数,而ZSet就是根据这个分数对内部元素进行排序的,分数可以不唯一。数据少时使用 ZipList / ListPack ,数据多时底层是 SkipList(跳表)+ HashTable。
6.除了基础的5种,Redis还提供了针对特定场景的扩展结构,比如用于基数统计的 HyperLogLog 、用于位运算的 BitMap 、用于地理位置的 GEO ,以及Redis 5.0新增的用于完整消息队列的 Stream。
追问 1:你刚才提到 ZSet 底层用到了跳表(SkipList),为什么 Redis 选择跳表而不是红黑树或 B+ 树来实现有序集合?
参考答案:
实现难度与可读性:红黑树的插入和删除操作会导致树的重平衡(左旋/右旋),逻辑非常复杂,而跳表的实现在代码上更简单直观,容易维护。
范围查询效率 :ZSet 经常需要进行范围查找(如
ZRANGE获取 Top 10)。跳表底层的叶子节点是由链表连接的,找到范围起点后,顺着链表遍历即可;而红黑树进行范围查询时需要进行中序遍历,效率不如跳表。并发友好(虽然Redis单线程核心没利用这点,但设计上跳表更新影响的节点更少,锁粒度可以更细,而红黑树可能引发大范围重排)。
不选 B+ 树的原因:B+ 树是为磁盘 IO 设计的,它的矮胖结构是为了减少磁盘寻道时间。Redis 是纯内存数据库,不需要考虑磁盘 IO,跳表的层级虽然可能比 B+ 树高,但在内存中指针跳转的代价极小。
追问 2:你提到 Hash 和 ZSet 在数据量小的时候会使用 ZipList(压缩列表),能说说压缩列表是个什么东西吗?为什么数据量大了要转为哈希表/跳表?
参考答案:
什么是 ZipList :ZipList 是一块连续的内存空间 ,它不像传统链表那样每个节点都需要维护
prev和next指针(这两个指针在64位系统要占16字节)。ZipList 通过记录每个节点的长度信息,在内存中紧凑排列,核心目的就是为了极致地节省内存。为什么变大后要转换 :因为 ZipList 是连续内存,当进行插入或修改操作时,如果空间不够,可能需要重新分配整个内存块并拷贝数据;而且如果前一个节点长度变化,可能会引发后续节点的连锁更新问题。数据量大时,查询和更新的时间复杂度会退化为 O(N),所以数据量大了必须转换为查找效率为 O(1) 或 O(logN) 的 HashTable 或 SkipList(空间换时间)。
追问 3:你在项目里用到过 BitMap 吗?如果我要统计 1 亿个用户的日活(DAU),用 BitMap 还是用 HyperLogLog?
参考答案:
BitMap 场景 :BitMap 底层还是 String,每一位记录 0 或 1。适合记录精确的二元状态 ,比如我在"黑马点评"项目中用它来做用户签到统计。
1 亿用户 DAU 选型:
如果业务要求 100% 精确 ,且需要知道具体是哪些用户活跃了,用 BitMap。1 亿个 bit 大约占用 12MB 内存,也是可以接受的。
如果业务只关心总数,允许有极小误差(标准误差 0.81%) ,且不需要查具体活跃的用户明细,应该选择 HyperLogLog。无论数据量多大,一个 HyperLogLog 键最多只占用 12KB 内存,在亿级流量的 UV/DAU 统计中能极大地节省内存。
追问 4:既然 String 类型的底层 SDS 可以动态扩容,那它是怎么扩容的?会频繁申请内存吗?
参考答案:
SDS 采用了空间预分配 和惰性空间释放两种策略来避免频繁的内存分配:
空间预分配 :当对字符串进行拼接修改时,如果修改后的长度小于 1MB,Redis 会额外分配与
len相同大小的free空间(即容量加倍);如果修改后的长度大于 1MB,Redis 会每次固定多分配 1MB 的未使用空间。惰性空间释放 :当字符串缩短时,Redis 不会立即回收多出来的内存,而是将其记录在
free属性中,等待将来再次拼接字符串时直接使用。如果确实需要释放,Redis 也提供了相应的 API 进行手动释放。
二、介绍Redis的持久化机制?
Redis通常分为RDB和AOF两种持久化方式,**RDB是将Redis在内存中的数据压缩为二进制快照文件,写入到硬盘中,是Redis默认存储方式,保存了Redis在某个时间点的数据库状态。**可以手动执行SAVE或者BGSAVE命令生成快照文件,也可以配置自动生成快照文件,SAVE N M表示在N秒内数据集至少有M个波动就生成快照文件,在Redis内部维护了dirty计数器和lastsave时间戳,用来作为自动生成的标准,同时通过调用周期性函数serverCron,每隔100毫秒检查一次SAVE配置的条件是否满足,遍历所有条件,只要有条件满足就执行BGSAVE。RDB中的BGSAVE是父进程调用fork函数生成一个子进程生成RDB文件,此时父进程可以同时接收读和写的命令,内部是Redis采用了操作系统的COW(copy on write)技术,Linux系统中是使用写时复制的页表实现,即复制父进程的地址空间到子进程中,复制的是指针,而不是数据,所以速度很快。缺点是在两次快照文件生成的间隙中Redis宕机,就会导致第一次生成快照文件之后存入内存的数据全部丢失。
AOF持久化则是按照Redis写入命令的顺序,将命令一一追加到硬盘文件的末尾,是一种基于日志方式的持久化方式,一旦Redis服务器重启就把文件中存储的Redis命令重新执行恢复服务器状态 。Redis把命令先写到AOF缓冲区 ,再同步至AOF文件,后面这一过程可以减小IO次数提升性能,而这一过程是由这一过程由名为
flushAppendonlyFile的函数完成,这一行为有always、everysec、no三种配置。AOF文件因为存在不断膨胀的问题,所以有AOF重写来压缩内存,AOF重写关注原AOF文件的数据库状态,而不是命令本身,通过使用一行命令达成多行命令效果,去除冗余命令,压缩AOF文件大小,这就是AOF重写。AOF重写也会遇到数据不一致问题,所以有AOF重写缓冲区,实际上Redis先把写入的命令写入AOF重写缓冲区,等到子进程完成了AOF文件重写向父进程发送信号后,再把缓冲区的内容写入到新的AOF文件(重写后的)。RDB 持久化能够快速地储存和恢复数据,但是在服务器停机时可能会丢失大量数据。AOF 持久化能够有效地提高数据的安全性,但是在储存和恢复数据方面却要耗费大量的时间。PS:
问:为什么不能用AOF缓冲区代替AOF重写缓冲区?
答:
"这是因为它们两者的生命周期和清理机制完全不同。
AOF缓冲区 的服务对象是当前的旧AOF文件 。它的机制是周期性(比如每秒)将数据刷入磁盘上的旧AOF文件,一旦刷盘完成,缓冲区里的数据就会被清空或覆盖。
而AOF重写通常需要耗费较长时间(几十秒甚至更久)。在这段期间产生的所有新命令,最终都需要追加到子进程生成的新AOF文件中。
如果我们只用AOF缓冲区,当子进程完成重写准备合并数据时,AOF缓冲区里早就没有刚开始重写时的那些命令了(已经被清空了)。这就必然导致新AOF文件丢失这段时间内的增量数据。
因此,Redis设计了AOF重写缓冲区 ,它专门用来在重写期间持续累积、不清空所有的增量命令,直到子进程重写完成,再一次性追加到新文件中,从而保证了数据的绝对完整。"
而第三种方式是RDB-AOF 混合持久化。这种持久化能够通过 AOF 重写操作创建出一个同时包含 RDB 数据和 AOF 数据的 AOF 文件, 其中 RDB 数据位于 AOF 文件的开头, 它们储存了服务器开始执行重写操作时的数据库状态。至于那些在重写操作执行之后执行的 Redis 命令, 则会继续以 AOF 格式追加到 AOF 文件的末尾, 也即是 RDB 数据之后。
三、Redis的数据如何和mysql进行同步?
首先引入Redis是为了提高读性能,这往往和强一致性要求相悖(加分布式读写锁,导致性能大打折扣),我们通常是追求最终一致性。我们的读操作一般都是先查看是否命中缓存,命中直接返回,未命中查询数据库返回并写入Redis并设置过期时间兜底。而写操作一般都是更新数据库,删除缓存,之所以不选择更新缓存是因为其代价太大了,多次缓存更新可能却一次都用不上。而分歧点主要在更新数据库和删除缓存的先后顺序上。
第一种情况,先删除缓存再更新数据库 ,A线程删除Redis中的缓存,正在更新数据库,此时B线程读操作先读缓存没读到,然后去数据库查询到了旧数据并写入到Redis,然后A线程才完成更新,这就导致了缓存和数据库数据不一致,解决方案是延迟双删 ,即A线程执行写操作更新完数据库后,休眠一段时间再一次执行删除缓存操作,但这会强行降低吞吐量而且休眠时间不好控制。
第二种情况,先更新数据库再删除缓存 ,A线程查数据库查到了旧数据,正在写入Redis缓存,此时B线程更新了数据库同时删除了缓存,然后A线程才完成写入旧数据到缓存,这又造成了数据库与缓存数据不一致。但这种情况发现概率极小,因为它要求数据库的写操作时间低于读操作,实际上恰恰相反。然而即便是先更新数据库再删除缓存也存在弊端,那就是更新完数据库后,在执行删除缓存时突然网络波动导致删除缓存操作失效。对于上述弊端有以下两种方案。
进阶方案一:利用 MQ 消息队列异步重试(业务耦合度稍高)
更新 MySQL 成功后,发送一条"删除缓存"的消息到 MQ(比如 RabbitMQ/RocketMQ)。
消费端监听这条消息,去执行删除 Redis 的操作。
如果删除失败了,利用 MQ 的重试机制,不断重试,直到删除成功。
优点: 保证了最终一定能删除成功。
缺点: 业务代码里需要强行侵入发送 MQ 的逻辑。
进阶方案二:终极优雅方案------Canal + Binlog(大厂主流做法)
这是我非常推荐的方案。我们完全不需要在业务代码里去管删除 Redis 的事情,实现业务彻底解耦。
业务代码只管更新 MySQL,其余什么都不做。
MySQL 更新后,会产生 Binlog。
我们部署一个 Canal 组件,它把自己伪装成 MySQL 的从节点(Slave),实时向 MySQL 主节点拉取 Binlog。
Canal 解析出到底更改了哪些表、哪些数据,然后把这些变更信息投递到 MQ 中。
我们的缓存更新服务监听 MQ,一旦收到数据变更的消息,精准地去删除对应的 Redis 缓存。
- 优点: 对业务代码 0 侵入,解耦完美,可靠性极高。哪怕应用服务器宕机,只要 MySQL 变更了,服务重启后依然能从 MQ 消费到 Binlog,保证缓存被清理。
四、缓存穿透的概念以及解决方案?
缓存穿透是指查询的数据在缓存和数据库中都不存在,若有人恶意使用工具高并发访问不存在的数据,就会对数据库造成巨大的压力。解决方案主要有四个。
第一个解决方案是在缓存中存储查询的空值,面对同一空值的第二次查询,就会直接命中缓存,不会对数据库造成压力。该方案的优势在于思路简单,代码实现轻松。缺点主要是比较占用缓存空间,但我们可以通过给空值设置TTL缓解内存空间压力,另一个缺点是当我们给某个空值设置缓存值后,又恰好向数据库插入了这个空值,这个时候就会面临数据库和缓存数据短期不一致的情况(设置了TTL),不过我们也可以在每次新增操作后手动向缓存写入新数据覆盖之前可能存在的空值来解决这一问题。
第二个解决方案是布隆过滤器 ,它是一种space efficient的概率性数据结构,用于判断一个元素是否在一个集合中,Hash虽然也有同样作用,但布隆过滤器所耗空间复杂度仅仅为Hash的四分之一甚至八分之一。其核心思想是通过多个哈希函数将元素映射到bit数组的不同位置,通过"位是否全为1"来判断元素是否存在 ,其关键特性有空间效率高,查询速度快,绝不漏判,允许误判,不准删除,其存在误判的原因在于存在哈希碰撞问题,可能两个不同的元素多次哈希的结果完全相同,元素越多误判率越高,但绝不可能漏判。不准删除的原因也跟误判原因类似,因为删除意味着你要把该元素的哈希结果位置都置为0,但可能这些位置也是其他元素哈希后的结果位置,因此 remove 会引入 false negative,这是绝对不被允许的。
第三个解决方案ID格式校验,主要通过做正则和范围检查来防非法ID,缺点是没法防合法但是不存在的ID。
第四个解决方案是做接口限流,通过限制QPS来保DB稳定,缺点是影响用户体验。
五、缓存雪崩的概念以及解决方案?
**缓存雪崩是指在同一时段大量的缓存key过期或者Redis宕机导致大量请求直接打到数据库上,带来巨大压力。**针对这两种不同的场景也有不同的解决方案。
对于场景一,我会通过错峰设置TTL,即对于同一批次写入的缓存,设置过期时间时是在一个基础的过期时间上加上一个随机值,这样就可以将多个缓存key的过期时段打散,避免它们在同一秒的集体失效。与此同时我们也可以给核心业务数据或者热点数据不设置物理过期时间TTL,而是设置逻辑过期时间,把过期时间戳存入到Value的JSON字段里,业务线程每次查出来这种数据就比对一下时间戳看是否过期,如果发现逻辑上过期了照常返回旧数据,并异步派发一个任务(或者发MQ)去数据库重新查询数据,更新缓存。这样即便有缓存过期了也不会有请求直接打到数据库上。
对于场景二,可以构建高可用的Redis架构 ,避免使用单节点Redis,而是采用Redis的哨兵模式或者是分片集群,最大限度地缩短缓存层不可用的时间。
如果Redis真的挂了,可以在微服务网关或者业务层引入限流降级组件 (如Spring Cloud Alibaba Sentinel),一旦发现检测到Redis超时或者大量报错,立即触发服务熔断降级,对于非核心业务直接返回默认值,只允许少数核心请求打到数据库,以此保全数据库不挂。
另外我们可以升级架构,引入多级缓存,最外层使用Nginx缓存,业务应用层使用本地缓存(Caffeine),最后才是Redis分布式缓存,这样即使 Redis 这一层发生了雪崩宕机,应用服务器上的本地缓存依然能抗住绝大部分的读请求,为 Redis 重启或故障转移争取宝贵的时间。
六、缓存击穿的概念以及解决方案?
**缓存击穿问题又叫热点key问题,就是一个被高并发访问或者缓存重建比较复杂的key突然失效了,导致大量请求打到数据库,带来巨大压力。**常见的解决方案有两种。
第一种方案是互斥锁,当线程A查询热点key缓存未命中时,在它查询数据库打算对缓存重建前,先获取互斥锁,然后再去进行缓存重建,此时如果线程B也来查询缓存未命中,也想尝试获取这个互斥锁进行缓存重建,但是发现这把锁已经被占用了,于是让它进行休眠一段时间再进行重试,直到线程A重建完缓存释放互斥锁,线程B获取锁成功命中缓存为止。该方案的优点在于实现起来简单,且不需要占用过多的空间,缺点是其他线程阻塞重试导致性能低下。
第二种方案是设置逻辑过期 ,即这种热点key缓存不设置物理过期时间(TTL),而是在Value中设置逻辑过期时间戳,每次命中这种缓存就把它的逻辑过期时间戳拿出来比对一下,如果过期了,主线程照样返回旧缓存,**获取互斥锁的同时开启异步任务(另一个子线程)又或者发MQ去数据库重新查询数据,更新缓存。**此时其他线程也发现缓存过期了,但是获取互斥锁的时候失败了,就知道已经有一个子线程在进行缓存更新了,所以其他线程也照常返回旧缓存,直到子线程更新完缓存。
七、Redis如何判断一个KEY是否过期?
判断Redis中的Key是否过期通常有两种方案。
第一种方案是使用TTL(秒)或者PTTL(毫秒)命令,返回该Key的剩余过期时间,如果返回值 > 0 就说明Key还没有过期,返回值就是它剩余的过期时间。如果返回值 = - 1 就说明这个Key存在,但是压根没有设置过期时间。如果返回值 = - 2 就说明这个Key已经不存在了,可能是过期了也可能从未存在。
第二种方案是使用EXISTS命令,如果返回值 = 1 说明该Key还存在,如果返回值 = 0 就说明该Key已经不存在了。
PS:Redis不会主动立即删除过期的Key,而是采用惰性删除 + 定期删除策略,惰性删除是指只有在访问过期Key时才会删除,定期删除是指后台定期随机抽查部分Key并删除过期的,因此如果你不访问某个过期的Key,它可能暂存于内存中,但你一旦使用TTL或者PTTL或者EXISTS命令访问这个Key,就会发现它过期了。
八、Redis的数据过期策略有哪些?
针对设置了TTL的Key,Redis内部并没有采取极其消耗CPU资源的定时器强制删除机制,而是综合采用了惰性删除 + 定期删除两种策略保证CPU和内存的平衡。
惰性删除就是指Redis从不会主动去检查某个Key是否过期,只有在客户端向一个过期的Key发起访问请求的时候,Redis才会去把这个Key删除并给客户端返回null。该方案的优点在于不浪费CPU额外资源去进行扫描过期Key,缺点在于内存里面可能存储了大量过期Key,导致内存泄漏。
因此辅助方案采用了定期删除,就是指Redis在后台会开启一个定时任务,每隔一定时间就随机抽取一部分设置了过期时间的Key,如果发现了过期的Key就立即删除,并且如果本次抽查的样本中,过期Key超过了一定比例,Redis就会认为过期Key太多了,就会立即再执行一次抽查和删除操作。这样既避免了长期占用CPU,又清除了大部分过期的Key。
九、Redis的内存淘汰策略有哪些?
当 Redis 所在的服务器内存使用达到了
maxmemory配置的上限时,如果此时还有新的写入命令过来,Redis 就会触发内存淘汰机制。Redis 4.0 之后,一共提供了 8 种淘汰策略,我们可以分为三大类:第一类:不淘汰(兜底策略)
noeviction(默认策略): 根本不淘汰任何数据。如果内存满了,对于所有引起内存增加的写命令(如 SET、LPUSH 等),直接返回错误报错;但读操作可以正常进行。一般如果把 Redis 当作纯数据库(不能丢数据)而不是缓存时使用。第二类:在"所有 Key"的范围内进行淘汰 (
allkeys-打头)
(如果业务场景里的数据没有设置 TTL,或者全都是热点缓存,通常选这类)
allkeys-lru: 淘汰整个字典中最久未被使用的 Key(LRU 算法,也是日常工作中最常用的策略 )。
allkeys-lfu(Redis 4.0 新增): 淘汰整个字典中最少被使用的 Key(使用频率最低)。
allkeys-random: 从所有的 Key 中随机挑一个淘汰。第三类:在"设置了过期时间的 Key"范围内淘汰 (
volatile-打头)
(如果业务场景中 Redis 既做缓存(有 TTL),又做持久化存储(无 TTL),为了保护永久存储的数据不被踢掉,必须选这类)
volatile-lru: 在设置了 TTL 的 Key 中,淘汰最久未被使用的。
volatile-lfu(Redis 4.0 新增): 在设置了 TTL 的 Key 中,淘汰使用频率最低的。
volatile-random: 在设置了 TTL 的 Key 中,随机淘汰。
volatile-ttl: 在设置了 TTL 的 Key 中,挑选剩余存活时间(TTL)最短的优先淘汰。
追问 1:LRU 和 LFU 的区别是什么?为什么 Redis 4.0 要引入 LFU?"LRU 看重的是时间 (最近没用过就干掉),它有个明显的漏洞:如果有一次偶发性的全量扫表或者批量拉取历史数据的操作,大量冷数据被短暂读取,它们会瞬间顶替掉真正的高频热点数据,导致缓存污染 。
而 LFU(Least Frequently Used)看重的是访问频率。它记录了 Key 被访问的次数(结合衰减机制),偶尔被扫一次的冷数据频率很低,依然会被优先淘汰,从而完美保护了真正的热点数据。"
防追问 2:Redis 内部真的是用双向链表来实现严格的 LRU 吗?
"不是的。严格的 LRU 需要维护一个庞大的双向链表,每次访问数据都要移动节点,这在 Redis 这种极度追求高性能且内存寸土寸金的系统里代价太大了。
Redis 实现的是一种近似 LRU (Approximate LRU)。它会在对象的结构体(
redisObject)里额外用 24 个 bit 记录最后一次被访问的时间戳。触发淘汰时,Redis 只是随机抽出 N 个 Key(默认 5 个),比较它们的时间戳,把其中最老的那个淘汰掉。这种抽样近似法,不仅几乎达到了严谨 LRU 的效果,还极大地节省了内存和系统消耗。"
十、Redis集群有哪些方案?
Redis集群主要有三种方案。
第一种方案是主从复制模式 ,其核心是一主多从,**主节点负责写入数据,从节点负责读取数据,从节点内的数据都是从主节点那里复制同步而来的。**该方案的优势在于大大提升了读取数据的并发能力,而且从节点做了主节点的数据备份。缺点在于该模式不具备自动修复功能,当主节点宕机了意味着无法进行写数据操作了,必须人工介入SLAVE NO ONE命令指定一个从节点成为新的主节点,当原主节点修复后则需要SLAVE OF命令让其成为当前新主节点的从节点。同时写操作只能在单节点Master上执行,整个服务不论是主节点还是从节点它们存储的东西都是一样的,因此内存空间大小仍然受限于单台机器的内存空间大小。
第二种方案是哨兵模式 ,它是在主从复制架构的基础上引入了哨兵集群组件,哨兵具备监控、通知和自动故障转移的功能 ,它通过心跳检测Master和Slave是否正常运行,如果超半数的哨兵认为Master下线(客观下线),那么哨兵集群将会基于Raft算法选举出哨兵中的Leader,并由它自动将某一个健康的Slave替代成为新的Master,并通知其他Slave指向新的Master以及通知客户端新Master的地址。该模式优势在于它可以完成自动修复,无需人工介入,但是单节点Master执行写操作导致所有节点依然存储全量数据,内存空间受限的弊端仍存在。以及新出现的脑裂问题,及由于网络分区导致旧Master与Slave失联,但客户端仍然能连接旧Master,就会出现新旧两个Master都在执行写操作,网络恢复后旧Master降级为Slave,那刚刚写入旧Master的数据就会消失。可通过配置
min-replicas-to-write缓解)。第三种方案是分片集群 ,该集群**由多个Master组成,且每个Master都可以带有自己多个Slave,形成多主多从。Redis引入了哈希槽的概念进行数据分片,将整个集群划分为16384 个槽。**当用户写入数据时,会将该数据运用CRC16算法进行计算,将计算结果跟16384取模来决定该数据落在哪个哈希槽中,每个Master负责管理一部分的哈希槽。并且该集群的内部集成了类似 Sentinel 的机制,如果某个 Master 挂了,它的 Slave 会自动选举成为新的 Master,接管它负责的哈希槽。该方案的优势在于它自带高可用,而且由于数据被分散在了各个哈希槽中,一定程度上解决了单机存储和写并发的物理瓶颈。缺点在于该架构较为复杂,维护困难,而且当两个Key分散在不同的哈希槽中,就无法对它们使用MSET和MGET这种批量处理命令。
除了官方方案,业界早期为了实现分片,开发了一些基于代理层的集群方案,大厂面试时提一嘴会显得你见多识广:
代表作: Twitter 开源的 Twemproxy ,以及豌豆荚开源的 Codis。
原理: 客户端不直接连接底层的 Redis 节点,而是连接一个 Proxy(代理)。Proxy 负责接收请求,并在内部进行哈希计算,将请求路由转发到后端的某个 Redis 实例上,再将结果返回给客户端。
特点: 对客户端极度友好(客户端就像在用单机 Redis 一样),非常容易横向扩容 Proxy 层。但缺点是多了一层网络转发,性能有一定损耗,且目前官方的 Cluster 方案已经足够成熟,这类代理方案正在逐渐退出历史舞台。
PS:在做架构选型时,会根据业务体量来决定方案。
如果业务量不大,只是为了防止单点故障和提升读性能,会选择 哨兵模式(Sentinel) ,运维简单且稳定。
但如果是类似大促、百亿级消息流这种需要海量数据存储(大于单机内存限制)和极高写并发 的场景,哪怕运维再复杂,也必须采用官方的 Redis Cluster 分片集群,利用哈希槽机制将数据打散到多个主节点上,这也是目前主流互联网大厂的终极解决方案。