title: Redis核心技术
date: 2022-02-22 09:40:32
tags: Redis
categories: Redis
1. 数据结构
提及Redis,脑海中的第一反应就是"快",那么Redis有多快呢?
通常Redis接收到一个键值对的操作后,会以微秒级别 的速度找到数据,并快速完成操作.
Redis都支持哪些数据结构呢?
-
简单动态字符串: String
-
双向链表,整形数组: List
-
压缩列表: List+Hash + SortedList
-
哈希表: Hash + Set
-
跳表: Sorted Set
-
集合: Set
Redis如何保存数据呢?
Redis通过对key进行hash,保存了一个全局哈希表,你可以理解成Map<String,Entry>,String是key的hashcode,Entry是一个二元封装(*key,
*value),又被称为哈希桶,保存着key和value的指针.
哈希表按key找值的算法复杂度是O(1),只要对键进行Hash操作,可以立即取到对应的值,所以Redis取值操作很快.
为什么哈希表的操作会变慢
由于全局哈希表的大小是有限的,但是key是无限增长的,所以就会有哈希冲突的存在.哈希冲突时,不同的key的hashcode相同,全局哈希表一个key对应的多个entry以链表形式保存,此时哈希桶中的每个entry都是一个三元封装(*key, *value, *next),除了存放本身的键值指针以外,还存放着指向下一个entry的next指针.形成一个链表,又叫哈希冲突链.
这个时候按照key查找值的操作变成了(用java伪代码表达一下方便理解,redis本身是c语言写的)
String keyHash = hash(key) // 计算key的hash值
entry = globalHash.get(keyHash) //按照key查找哈希桶
while entry.key != key:
entry = entry.next
return entry.value
可以看到这里的取值复杂程度跟哈希冲突链的长度有关.哈希冲突越大,操作越慢.
这个时候Redis会怎么解决呢? Rehash.
顾名思义,Rehash就是对现有的全局哈希表扩容,假设原来的全局hash表容量是一万,hash算法为按key取模,将hash表的容量扩大到两万,就能将每个哈希桶里的哈希冲突链缩短到一半.说明Rehash是一个行之有效的操作:
如何落地Rehash呢?
redis采用了类似CopyOnWrite的设计,默认采用两个全局哈希表,hashTable1和hashTable2,一开始数据量较少的时候只用hashTable1,hashTable2没有被分配空间,随着数据逐渐增多,Redis开始进行Rehash操作,分为以下3步:
- 给hashTable2分配空间,假设是hashTable1的两倍
- 将HashTable1的数据重新映射(暂且称为reIndex),存入hashTable2;
- 释放hashTable1的空间
至此,就可以切换到另一个全局hash表,原来hashTable1的空间轮换休息,作下一次扩容备用.
但是上述做法有一个问题就是第2步涉及大量的reIndex和copy,假如这个rehash的时机选择跟ArrayList一样在到达某个阈值的瞬间进行扩容,这个操作的等待时间就会被加上rehash的时间,显然对于到达阈值这次的操作来说,用户体验太差.
为了避免这个问题,Redis的解决方案是渐进式rehash.
所谓渐进式rehash,就是一旦为hashTable2分配空间后,每次对hashTable1的取值,只对该值指向的Entry进行rehash.
这样就巧妙的避免了一次性大量拷贝的开销,将一次替换分摊到了每个请求上.除了在查询时根据key搬运,redis本身也有一个定时rehash.
各种操作的平均时间复杂度
名称 | 查询 | 下标查找 |
---|---|---|
哈希表 | O(1) | O(1) |
跳表 | O(logN) | - |
双向链表 | O(N) | O(N) |
压缩列表 | O(N) | O(1) |
集合 | O(N) | O(1) |
2. IO模型
redis中存在大量全局变量,如全局哈希表等,多线程使用共享资源的并发控制难以实现,而且多线程操作对于共享变量很难设计一个粒度合适的锁,这种情况即使使用多线程,难免大部分情况下有很多线程处于等待状态,多线程并发的开发成本和维护成本也要明显高于单线程,为了避免这些情况.Redis直接采用了单线程.
当然这里所说的单线程,仅仅指的是与客户端交互的时候,网络IO和数据读写是由一个线程完成的,而数据清理,主从复制都用了其他线程.
Redis的IO模型借用了Unix内核的多路复用技术,多个套接字借用epoll机制,让CPU来监听,不同事件触发不同回调机制,这些回调机制就是把相应事件放到一个待处理的队列中,再由Redis单核不断处理队列中的任务.因为Redis一直在对队列事件进行处理,所以能及时响应客户端请求.
多路复用机制,即使在不同操作系统上也有不同的实现,在Linux中是select和epoll实现,基于FreeBSD的kqueue和Solaris的evport,还有类似libuv中windows系统使用IOCP.
3. 持久化:AOF和RDB
Redis大多数的使用场景在于内存缓存, 提高后端的相应速度.Redis是一款高性能内存数据库,但是如果Redis本身只用内存,便会存在一个不可忽视的问题,一旦服务宕机,内存中的数据将全部丢失.
为了避免从后端load到内存的网络压力和处理速度,Redis自身也实现了一套缓存逻辑.Redis 的持久化主要有两大机制,即 AOF(Append Only File)日志和 RDB 快照。
AOF(Append Only File)
不同于数据库的"写前"日志(Write Ahead Log,WAL),Redis的AOF是"写后"日志,Redis先执行命令把数据写到内存,然后才会记录日志.
为什么Redis要在写后才记录日志呢?
- 避免记录错误命令
- 不会阻塞当前操作
数据库的Redo log记录的是命令修改后的数据,而AOF记录的是Redis收到的每一条命令.
因为先写内存后记录日志的设计,Redis AOF的风险也就在于写完内存之后还没记录日志就宕机,会造成命令和数据丢失,
如果Redis是缓存的话还能从后端数据库重新load这期间的数据,但是如果是直接控制数据库,就无法恢复了,而且Redis虽然不会阻塞当前命令,但是会阻塞下一条命令(因为AOF也是由主线程完成的)
为了让AOF写入变得可控,Redis提供了三种AOF写回策略,即 appendfsync配置项的三个可选值.
- Always: 同步写回,每条命令执行完,立马同步写回磁盘
- EverySec: 每秒写回,每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘
- No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。
与全局哈希表类似,AOF日志也有两份,正在使用的AOF由主线程写入,另一份由bgrewriteaof子线程来完成.
为什么要有另一份重写日志呢?
- 重写日志会做操作合并,解决日志太大的问题(同一个key多次set只保留最终的值)
- 提高容错性.也可用作宕机恢复
RDB(Redis Database)
与AOF不同的是RDB记录的是某一时刻的数据,而不是操作.所以在做Redis宕机恢复的时候可以直接将RDB的数据读入内存,就能快速恢复
RDB的保存的命令分别喂save和bgsave, 二者的区别是save在主线程中执行,而bgsave为专门用于生成RDB的子线程执行,bgsave是Redis保存快照的默认配置.
快照保存的是瞬间数据,为了不影响主线程正常工作(快照时不暂停),且不被主线程的修改影响(快照生成期间的改动不录入),Redis会借助操作系统提供的写时复制(Copy-On-Write,COW)技术,子线程由主线程fork生成,bgsave运行后读取主线程内存数据,如果此时主线程修改内存数据,这块数据就会复制一份,主线程修改复制处的数据,等到读取结束后再同步回去.
RDB越多越好吗?
理论上来说,快照越频繁,越能减少数据丢失的概率,但是快照间隔时间过短,也会带来两方面的压力:
- 磁盘被频繁写入
- bgsave线程出自主线程,过多fork会阻塞主线程.
有没有办法只做增量快照呢?
增量快照需要有大量元数据记录两次快照期间有哪些key有变动,如果有1万个key被修改,就要有1w条记录,对于内存本就宝贵的redis来说有些得不偿失.
Redis 4.0提出了RDB + AOF混用的方式,也就是RDB周期执行,在周期期间的变动使用AOF记录,这种方案不仅避免了AOF过大,还允许RDB不用太频繁.
4. 数据同步
一个优秀的分布式组件的高可用性保障除了保证数据少丢失外(Redis使用AOF+RDB实现),还要有某种机制保证服务尽量少中断.
Redis保证服务的机制是:
Redis提供了主从库模式,为了保证主从库数据一致,采用的是读写分离的方式.
读: 主从库都可以读
写: 首先到主库执行,再由主库将操作同步给从库.
如何给某个redis加从库?
例如,现在有实例 1(ip:172.16.19.3)和实例 2(ip:172.16.19.5),我们在实例 2 上执行以下这个命令后,实例 2 就变成了实例 1 的从库,并从实例 1 上复制数据:
replicaof 172.16.19.3 6379
主从库的同步过程:
Redis主从同步分为以下几步,
- 从库给主库发送psync命令,请求与主库同步数据.请求参数{runID : ?,offset: -1}(由于第一次同步不知道主库runID,所以设为"?",offset表示复制进度,第一次置为-1)
- 主库回复FULLRESYNC命令,表示第一次采用全量复制,参数{runID:{主库runID},offset:当前复制进度}
- 主库将所有数据进行快照RDB,发送RDB文件至从库
- 从库接受到RDB文件,清空自身数据并加载RDB文件到内存
- 在RDB同步期间,主库不会中断服务,并且用replication buffer记录RDB文件至最新的所有写操作.
- 把步骤5中的replication buffer操作发给从库,从库执行这些操作.
如果有多个从库,主库压力太大怎么办?
上述同步过程可以看出来同步期间主库需要完成两个耗时的操作: 生成和传输RDB文件.如果从库数量过多,会让主库忙于fork子进程生成bgsave.这个时候就可以采用"主-从-从"的模式了:比如A是主库,B和C是A的从库,D和E是B的从库.
主从同步期间网络中断了怎么办?
Redis 2.8之前如果网络中断需要重新同步,Redis 2.8实现了增量同步, 只用同步网络断开期间主库的变更.
那么它是如何实现的呢?
主库在同步期间维护了一个replication buffer用于RDB生成期间的增量变化,照这个思路,主库同样维护了一个repl_backlog_buffer的环形缓冲区,在网络断开期间收到的命令会写入replication buffer,也会写入repl_backlog_buffer缓冲区.
repl_backlog_buffer这个缓冲区除了主库会记录当前写入的位置(master_repl_offset),从库也会写入自己读到的位置(slave_repl_offset),连接恢复后从库通过psync命令同步断开期间的命令直到这两个偏移量相等.
5. 哨兵机制
在这个设计中主库的角色至关重要,从库看起来只是为读操作提供了高可用的备选,那如果主库挂了呢?
答案是Redis的哨兵机制,哨兵的主要职责就是: 监控,选主和通知
监控:
哨兵通过周期性的PING主从库,检测他们是否正常运行,如果在规定的时间内没有响应,就会标记下线.哨兵集群针对主库的标记称为"主观下线(+sdown)“主观下线是哨兵对主库的主观判断,并不一定说明集群真的下线了,有可能网络波动或者繁忙.为了避免误判,多个哨兵的"主动下线"通过少数服从多数的票选,由哨兵Leader标记为"客观下线”(+odown).
选主
由哨兵leader标记主库客观下线之后,将开启哨兵的下一轮职责,选主.哨兵选主的过程为"筛选+打分"
筛选: 从从库中筛选出网络状态良好的从库(不仅当前网络状态良好,历史网络波动也在考量范围内)
打分: 打分分为从库优先级,从库复制进度,从库ID号三轮打分,上一轮未决出优胜者才会进行下一轮打分.
- 从库优先级: slave-priority配置项,优先级高的从库胜出
- 从库复制进度: 复制进度最接近旧主库的从库胜出
- 从库ID: ID小的从库胜出
通知
哨兵的通知机制基于客户端的pub/sub机制,订阅了同一频道的应用可以通过发布的消息进行信息交换.哨兵之间通过__sentinel__:hello 频道互相发现,完成投票选举leader,投票判断主库是否挂掉等工作.客户端也通过订阅哨兵的消息频道接收哨兵的通知,当新的从库被选举成主库,哨兵会通知客户端进行主库切换.和从库重新配置.
6. 切片集群
同众多分布式组件一样,Redis也提供了切片集群的机制来保证数据规模的横向扩展.
但是横向扩展带来的问题就是:
- 数据分布: 数据切片之后如何在多个实例之间分布?
- 访问路由: 客户端访问的时候如何确定要访问的数据在哪个实例上?
数据分布
从Redis3.0开始,Redis Cluster实现了切片集群.具体来说就是提供了214 (16384)个哈希槽(Hash Slot)来处理数据和实例之间的关系.
数据和Hash Slot的映射是 将key通过CRC16算法计算16bit的hash值,然后对16384取模,这样可以让key分布到16384个哈希槽上
伪代码
slot = Crc16(key) % (1 <<14)
Hash Slot 和实例的对应映射,在使用cluster create
命令创建集群的时候会自动平均到各个实例上同样是取模算法,每个实例对应的就是Hash Slot个数就是16384/N个.
在使用cluster meet
手动建立实例连接形成集群并使用cluster addslots
命令的时候可以手动指定每个实例上的slot,就像下边这样:
redis-cli -h 172.16.19.3 –p 6379 cluster addslots 0,1
redis-cli -h 172.16.19.4 –p 6379 cluster addslots 2,3
redis-cli -h 172.16.19.5 –p 6379 cluster addslots 4
使用手动指定slot的方式有两个注意点:
- addslots 添加的slots number 应该介于 0~16383之间
- 需要把这16384个槽都分配完集群才能正常工作