Redis是一款快速、开源、高效的缓存和数据存储系统,已被广泛应用于各种应用场景。但是,要真正理解Redis的性能和可靠性,需要了解其底层实现原理。本文将深入探讨Redis的数据结构、持久化和网络通信技术,从底层到上层逐一剖析其实现原理和工作机制,为读者提供深度了解Redis的机会。此外,本文还将介绍一些常见的Redis应用场景和优化技巧,帮助读者更好地利用Redis提升应用性能。
基本数据结构
- 所有的底层数据结构都被包装成一个redisObject
1
2
3
4
5
6
7typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS;
int refcount;
void *ptr; // 指向真正的数据
} robj;
支持的数据结构
支持的数据结构 | 底层原理 | 备注 | 常用场景 |
---|---|---|---|
string | int raw embstr | 二机制安全 | kv缓存,分布式锁,计数器,限流器等 |
list | linklist (废弃)ziplist (废弃)quicklist listpack | redis7.0中list的底层是quicklist | 队列,栈,mq |
set | intset lishpack hashtable | 集合 | 求交差并集,去重 |
sortset | skiplist+hashtable listpack | 有序集合 | 排行榜,关系链,分层排序 |
hash | listpack hashtable | hash表 | 去重,保存对象信息 |
bitmap | string | 利用strign对象实现的 | 去重,统计,签到 |
hyperloglog | string | 利用strign对象实现的 | 统计uv,计算去重的个数 |
geospatial | sortset | geohash编码地理位置,zset存储最后编码的数据,相邻分数距离更近 | 统计附近的人等 |
string的三种编码方式
- 为了节省内存,redis的string对象有三种编码方式
int
ptr
字段直接保存成int
1 | ┌─────────────┐ |
raw
ptr
指向一个sds结构体
1 | ┌─────────────┐ |
embstr
ptr
指向一个EMBSTR,区别在与sds的buf是指向另外一个内存地址,还是直接紧凑保存的数据- 紧凑的数据结构能带来更好的处理器cache命中率(缓存局部性原理)
1 | ┌─────────────┐ |
list的底层数据的演变
ziplist
- ziplist记录了总字节数,最后一个元素的偏移量,总元素个数
- 单个节点,保存了前一个节点的长度,当前节点的长度
- 根据最后一个节点的偏移量,和前一个节点的长度,就能从后往前遍历
- 根据第一个节点,和当前节点的长度,就能从前往后遍历
- prelen长度字段编码后的,编码规则是第一个字节是否为FF来区分是1字节还是5字节
- 查找需要遍历,复杂度O(n)
- 连锁更新,插入元素后,会导致prelen长度改变,有可能连锁导致所有的长度发生变化,会发生多次内存的重新分配
1 |
|
quicklist
- 双向链表组织多个ziplist
- 让连锁更新发生在局部
- 分片思维
- 链表使得原本紧凑的数据结构又分散了
listpack(list还未采用)
- 目前底层替换的很少
- 没有连锁更新的问题
- 连锁更新发生的原因?
- 在ziplist中节点会引用前一个节点的长度,并且长度是编码后的,不定长,所以当前一个节点长度变更可能导致后一个节点跟着变化,
- 连锁更新发生的原因?
- listpack如何解决的?
- listpack的节点不再保存前一个节点的长度,只关注自己的长度
- 只关注自己的长度,如何拥有往前往后遍历的能力?
- 数据编码信息encoding,保存当前节点长度,并且是变长的
- len字段也是变长的,
- len如果第一字节的最高位为1,说明还有一个字节继续表示其长度,直到遇到的字节最高位为0为止
- 从前往后遍历
- 只需要根据encoding里面的长度计算偏移
- 从后往前遍历
- 只需要先定位到最后一个节点,根据len计算前一个节点的偏移位置
1 | ┌─────────────┬──────────┬───────┬────┐ |
set的底层实现
intset(整数集合)
1 | typedef struct intset { |
- contents 并不是表示只能保存int8,这里只是占位,实际保存的整数的长度是可变的,根据encoding决定
- 实际上是根据encoding计算偏移量,然后按照16位,32位或者64位来解释内存
- 添加删除元素都是O(N)
- 有序保存,所以查找是O(logN)
hashtable
- 拉链式解决hash冲突
- 渐进式rehash
- 负载因子,节点总数/桶的数量 > 1,可以进行扩容
- rdb和aof进行的时候,会阻止扩容,因为hashtable扩容会导致大量的cow
- 负载因子大于5,表示需要立马扩容
- 渐进式rehash最小单位是桶
- 防止cpu空转,会控制最大扫描的空桶个数
1 | ┌──────┐ ┌─────┐ ┌─────┐ |
sortset 有序集合
skiplist 跳表
- 第一层就是双向链表
- 再对数据进行多层索引
- 每一层会有指向前一个节点的指针
1 | ┌─────┐ |
hyperloglog
- 近似算法,用来近似统计集合元素去重个数
- N次伯努利过程
- 本质上就是数所有记录的key中,按位表示时连续0出现的次数,根据出现的个数可以反推出key的大概个数
- 有一定的误差,redis的实现误差是0.81%
geo
- geohash算法可以对经纬度进行编码,把两个数值转换成一个,保存在zset中,相邻的数据表示地理位置更近
- z字填充
- 有突变,查询周围八个点消除突变
缓存应用
缓存一致性
- 异常
- 考虑异常情况下的表现,主要是写超时的情况,无法感知到底是成功还是失败
- 并发
- 考虑写写,读写并发情况下的表现
先写缓存,再写db or 先写db,再写缓存
异常
sequenceDiagram 线程1 ->> 线程1 : 写缓存 线程1 ->> 线程1 : 写db
- 无论是先写缓存还是先写db,如果其中一个写失败了,就会导致数据不一致
并发
sequenceDiagram 线程1 ->> 线程1 : 写缓存 线程2 ->> 线程2 : 写缓存 线程2 ->> 线程2 : 写db 线程1 ->> 线程1 : 写db
- 最终缓存是线程2写的值,但是db是线程1的值
sequenceDiagram 线程1 ->> 线程1 : 写db 线程2 ->> 线程2 : 写db 线程2 ->> 线程2 : 写缓存 线程1 ->> 线程1 : 写缓存
- 最终缓存是线程1写的值,但是db是线程2的值
可见并发情况下,无论是先写哪个最终都会导致数据不一致
先删除缓存,后写db
sequenceDiagram 线程1 ->> 线程1 : 删除缓存 线程2 ->> 线程2 : 重建缓存 线程1 ->> 线程1 : 写db
- 最终db是线程1的值,但是缓存是线程2读到的旧值
先写db,后删缓存
并发下
sequenceDiagram 线程2 ->> 线程2 : 读取db 线程1 ->> 线程1 : 写db 线程1 ->> 线程1 : 删除缓存 线程2 ->> 线程2 : 写入缓存
- 极端情况下,还是会出现不一致
- 由于线程2读取到旧值,没有及时更新到缓存,导致最终写入缓存的还是旧值
- 这种情况比较极端
缓存雪崩
- key集中过期,导致请求大量穿透存储
解决
- 设置随机过期,或者不过期
- 预热,解决服务启动的时候,缓存大量穿透的问题
缓存穿透
- key不存在,恶意请求导致大量请求穿透到存储
解决
- 对不存在的key,也设置缓存,并且设置过期
- 利用布隆过滤器过滤(有一定概率)
- 使用布隆过滤器,需要保存存在的key,因为布隆过滤器的特性是有可能会把不在布隆过滤器中的key判断为在里面,但是不会把存在的判断为不存在
- 所以有一定概率让一些不存在的key穿透到db(因为在布隆过滤器的才是存在的key,才会穿透到db)
- 反过来,如果保存的是不存在的key,那么可能误伤一些本来存在的,被判断为不存在的了
缓存击穿
- 热点key过期,导致请求大量穿透到后端
解决
- 读后端存储,加锁
redis的缓存淘汰策略
- 淘汰策略
- no-enviction(不进行淘汰)
- 需要淘汰
- volatile(在设置了过期时间的key中)
- volatile-random
- volatile-ttl
- volatile-lru
- volatile-lfu
- allkeys(所有key中)
- allkeys-lru
- allkeys-random
- allkeys-lfu
- volatile(在设置了过期时间的key中)
lru(least recently used)最近最少使用
常见实现方式
- 链表记录所有访问的key
- 表头是mru(最近最多使用),表尾是lru
- key被访问会被放到表头,淘汰的时候从表尾进行淘汰
- 由于链表操作复杂度高,因此,通常是使用
map+list
来实现
LRU-K
- 最近k次访问记录
- 选择最近第k次访问时间距离现在的时间为淘汰依据
redis中的实现
- 如果redis去维护这样的链表,那会带来很多麻烦,频繁操作链表带来额外开销
- 在事件循环的时候,检查是否达到内存阈值,然后选择待淘汰的key放到待淘汰的集合,进行删除,达到类似的lru效果
- redisObject中有一个字段保存lur时钟,24bit
- 可以同步,异步淘汰
全局lru时钟
- 精度是秒
- 为什么不每次都直接获取时间?
- 因为获取时间的操作,是一个系统调用,内核态的切换可能影响redis的性能。这里相当于是缓存了时间
lfu(最不频繁使用)
- 根据访问频次进行淘汰
redis实现
- 记录时间和次数(频率是一定时间内的访问次数)
- 比如15分钟访问15次,和5分访问10次,虽然绝对值10次小,但是实际上5分钟的频率更高,数据更热
- 复用lru的字段
- 因为服务启动不同同时存在多种淘汰策略
- 高16为记录时间,低7位记录次数
- 时间粒度是分钟
- 先衰减次数,再增加次数
1
2
3
4
5void updateLFU(robj *val) {
unsigned long counter = LFUDecrAndReturn(val);
counter = LFULogIncr(counter);
val->lru = (LFUGetTimeInMinutes()<<8) | counter;
} - 保存的并不是真正的次数,8bit最大次数才255,显然不够用
- 因此保存的是概率性的次数,越大越难涨,难易程度是可配置的
惰性删除
- lru和lfu在删除key的时候都是可以配置是否启用惰性删除的
- 针对一些大key的场景,同步删除可能导致redis突然延时增加,影响其他客户端
启用惰性删除的场景配置
场景 | 配置 |
---|---|
缓存淘汰时的数据删除场景 | lazyfree-lazy-evictio |
过期 key 的删除场景 | lazyfree-lazy-expire |
隐式进行删除操作的 server 命令执行场景 | lazyfree-lazy-server-del |
从节点完成全量同步后,删除原有旧数据的场景 | replica-lazy-flush |
惰性删除流程
删除操作
- 全局dict表删除key
- 释放空间(同步或者异步)
- 不同对象,有不同的删除代价,list,set,zset,hash的成员数量就是删除的的代价
- 内存紧凑的对象,直接释放空间代价并不高
- 后台线程和主线程使用条件变量和互斥锁实现同步
redis为什么这么快
事件循环
- 事件反应堆
- fd读写回调的时候,会再次产生读写事件,把读写事件继续加入事件循环器,这样就形成事件反应堆了
- 优先使用epoll
- 单线程也能处理多fd
- select监听fd数量有限,需要轮询fd
- poll也需要轮询fd
- epoll,不需要轮询,效率更高
- 一般还要把fd设置为非阻塞的,这样read,write不会阻塞
- 单线程,实现简单,操作数据不需要加锁,cpu不是redis的瓶颈
- 所以大key对redis的影响很大,会阻塞其他客户端
- 耗时操作会使用其他线程,比如惰性删除的释放内存,aof刷盘等
单 Reactor 单线程
- accept,read,write,处理业务逻辑都在一个线程
- 缺点
- redis6.0都是这种模型,缺点是无法利用多核提高效率
- 单个事件的处理,可能影响其他请求
- 并发请求,read和write可能出现瓶颈
- redis6.0后,采用的是读写fd用线程池(不是一定用,看配置和其他条件),处理业务逻辑还是单线程,因为是内存操作,这里单线程也不会是瓶颈,而且单线程减少了锁的使用
单 Reactor 多线程
- accept,read,write 在一个线程
- 业务处理在线程池
主从Reactor多线程模型
- 主Reactor处理监听套接字,负责和客户端建立连接,然后把连接套接字给从Reactor处理
- 从Reactor和客户端进行读写,处理业务逻辑,通常从Reactor和cpu个数相等
- Nginx的实现就是这样,不过nginx的slave进程都是可以进程accept的
事件类型
文件事件
- 和客户端的连接事件,读写事件
- 每次写fd,如果大于NET_MAX_WRITES_PER_EVENT(64k),会分成多次写,这样不会因为单次写耗时太大,从节点除外
时间事件
- 定时任务
- redis这里实现是链表,正常应该是堆或者红黑树来实现,因为redis这里只有2个时间事件
- 事件循环wait的时间就是最近的时间事件到来的时间,这样每次事件循环阻塞结束后,如果没有网络请求,可以找到时间事件来执行,常见的定时器调度也是这样实现的
redis线程模型
三个后台线程
1 |
|
- 互斥锁和条件变量实现的生产者-消费者模型
1
2
3
4
5
6void bioSubmitJob(int type, bio_job *job) {
pthread_mutex_lock(&bio_mutex[type]);
listAddNodeTail(bio_jobs[type],job);
pthread_cond_signal(&bio_newjob_cond[type]);
pthread_mutex_unlock(&bio_mutex[type]);
}
sequenceDiagram 线程1 ->> 线程1 : pthread_mutex_lock 线程1 ->> 线程1 : pthread_cond_wait,unlock,等待,lock 线程1 ->> 线程1 : 消费任务 线程1 ->> 线程1 : pthread_mutex_unlock 线程2 ->> 线程2 : pthread_mutex_lock 线程2 ->> 线程2 : 生产任务 线程2 ->> 线程2 : pthread_mutex_unlock 线程2 ->> 线程2 : pthread_cond_signal
io线程池
1 | pthread_t io_threads[IO_THREADS_MAX_NUM];// io线程描述符 |
- io线程数量大于1的时候开启
- 初始化后没有立马启用
- 使用原子变量进行同步
io_threads_mutex
用于主线程控制暂停线程池(防止空闲时空转)
io线程池读
- 默认不开启,需要配置文件显示指定
- io线程的读和普通直接读都是同一个回调函数
readQueryFromClient
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15int postponeClientRead(client *c) {
if (server.io_threads_active && // io线程开启(启动不等于开启)
server.io_threads_do_reads && // 读操作开启使用io线程池
!ProcessingEventsWhileBlocked && // processEventsWhileBlocked 没有执行
!(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_BLOCKED)) && // master,slave,已经阻塞的客户端不使用
io_threads_op == IO_THREADS_OP_IDLE) // io线程池空闲
{
// 没有设置单个客户端的状态了,直接根据io_threads_op判断
listAddNodeHead(server.clients_pending_read,c);
c->pending_read_list_node = listFirst(server.clients_pending_read);
return 1;
} else {
return 0;
}
} readQueryFromClient
会将阻塞客户端添加任务到阻塞,读事件的回调- 事件循环wait前,都会检查
clients_pending_read
是否需要用io线程读 - 主线程也会参与io读
- 主线程把自己任务处理完后,会等待io线程结束,使用原子变量看任务是否已经处理完
- 读取完成后,主线程进行命令的执行
- 每次最大读16M数据,epoll默认采用水平触发(有未消费的数据就会触发epoll_wait返回),redis采用的水平触发
io线程池写
- 事件循环wait前,会检查写的阻塞链表```clients_pending_write``是否有数据,有的话就进行处理,和读的类似
- 写操作如果回包过大,会暂时break写操作,下次事件循环仔写,这样不会阻塞其他客户端,比如执行
keys *
的这种操作keys *
这种回包大的处理的流程是,先把key都读到一个缓存链表中(每个16k),再使用io线程在多个事件循环中分批回给客户端- 在处理的流程中,读所有的key是阻塞的,可能影响整个server
可靠性
rdb
- 二进制数据
- fork子进程写入(COW)
- fork是阻塞的系统调用,可能 会给系统带来阻塞
生成的时机
- 手动触发
- save命令
- bgsave命令
- 被动触发
- 主从复制(不会生成文件,直接传输到socket)
- 定时触发
- serverCron执行频率自动检查触发
- flushall
- 关闭server
aof
- 记录逻辑操作
- 追加写
- 策略
- always:每次都写,最多丢失一个事件循环的数据
- 因为redis的aof刷盘是在beforSleep中执行的,是下一次事件循环的开始,所以此时,上一次的aof是还没有fsync的,如果此时重启,会丢失一个事件循环的操作
- 和mysql不同的是,mysql是WAL(Write-Ahead Logging),并且通过两阶段提交,来保证了事务不丢失
- everyec:每秒执行
- no:操作系统自动刷盘
- always:每次都写,最多丢失一个事件循环的数据
aof重写
- 防止aof文件过大,因为是记录的操作,aof重写可以把多个操作合并为一个
- aof重写有一个标记
aof_rewrite_scheduled
,用来防止重入 - 全量+增量
aof重写演进
redis2.8以及以前
sequenceDiagram 父进程 ->> 子进程 : fork 子进程 ->> 子进程 : 写全量数据 父进程 ->> 父进程 : 暂存增量命令到aof buffer(老aof文件使用)和rewrite buffer(新aof使用) 子进程 ->> 子进程 : 全量数据写结束 父进程 ->> 父进程 : 把aof buffer一次性写到aof文件(可能阻塞)
- 一次性写buffer到磁盘会导致redis阻塞
redis3.0
sequenceDiagram 父进程 ->> 子进程 : fork 子进程 ->> 子进程 : 写全量数据 父进程 ->> 父进程 : 暂存增量命令到aof buffer和rewrite buffer 父进程 ->> 子进程 : 通过管道把rewrite buffer发送给子进程 子进程 ->> 子进程 : 全量数据写结束
- 同一份数据需要写两份buffer
- 需要使用6个管道进行通信
redis7.0的multi part aof
sequenceDiagram 父进程 ->> 子进程 : fork 子进程 ->> 子进程 : 写全量数据到base.rdb(混合持久化) 父进程 ->> 父进程 : 暂存增量命令到aof buffer 父进程 ->> 父进程 : 把aof buffer写到incr.aof 子进程 ->> 子进程 : 全量数据写结束 父进程 ->> 父进程 : 把base和incr合并成mainfest目录,mainfest目录中老的记录标记为history,后续清理
- aof文件由全量数据文件+增量文件构成
- base.rdb是rdb文件,这样空间更小(混合持久化)
主从复制
redis2.8以前
sequenceDiagram slave ->> master : sync master ->> master : fork生成快照 master ->> slave : 发送快照 master ->> master : 暂存增量命令 master ->> slave : 发送缓存命令 master ->> slave : 命令传播
- slave断开连接重新连接需要再次全量同步
redis2.8
graph TB start[开始] --> isFirst{是否首次复制} isFirst -- Yes --> psyncAll[PSYNC ? -1] isFirst -- No --> psyncPart[PSYNC master offset] psyncPart --> isOk{主节点检查} isOk -- Yes --> run[执行部分同步] isOk -- No --> runAll[执行全量同步] psyncAll --> runAll[执行全量同步]
- 需要从节点记录master的信息,但是重启也会丢失
- 如果offset不在master的缓存中,那么只能进行全量同步
- 如果master发生主从切换,新主id不同,那么所有的slave都要重新全量同步
redis4.0
- 引入replid,相当于之前依赖master的runid,现在依赖replid
- 主从断开的时间是有限的,取决于backlog的大小,如果offset不在buffer中,只能全量同步
redis7.0(百度贡献)
- 共享复制缓冲区
- 从库的复制缓冲区从私有变为共享,多个slave共享buffer,通过引用计数来组织
- 链表把所有的buffer串起来,如果引用计数为0,就释放
哨兵机制
目的
- 监控主从节点
- 故障转移(automatic failover)
- 配置提供(客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址)
- 通知(哨兵可以将故障转移的结果发送给客户端)
哨兵集群的发现和建立
- master节点会有一个```sentinel:hello``的频道,哨兵通过订阅发布进行感知
- 哨兵通过info命令感知从库
感知节点故障
- 主观下线:哨兵通过自己和节点的链接,主观判断节点是否下线(可能误判)
- 客观下线:配置数量的哨兵共同判断节点下线,则为客观下线
故障切换
- 哨兵选举领导者(raft选举,超半数同意,成为leader)
- 由领导者发起故障转移,进行主从切换
- 选择新主
- 过滤掉不健康的(下线或断线),没有回复过哨兵ping响应的从节点
- 选择salve-priority从节点优先级最高(redis.conf)的
- 选择复制偏移量最大,只复制最完整的从节点
- 把其中一个升级为master
- 让其他节点成为新主的从节点
- 通知客户端新主
- 主从切换期间,可能有命令还没同步到从库,导致从库的数据慢于主库,此时切换后回出现丢数据的情况
常见使用场景
cas
cas set
,带版本号的set- 利用lua实现,保证原子,可以把版本号拼接在数据的前面,这样只需要一个key就能保存数据和版本号
- 在lua中使用
struct.unpack
可以解出版本号,进行判断