Redis基本原理

数据类型

数据类型与底层数据结构的关系

数据类型底层数据结构
String动态字符串SDS
List双向链表LinkedList、压缩列表ZipList(数据量小)、快速列表QuickList(数据量大)
Hash压缩列表ZipList(数据量小),哈希表HashTable(数据量大)
Set哈希表HashTable,整数集合IntSet(数据量小且只有整数)
Sorted Set压缩列表ZipList,跳跃表SkipList

底层数据结构

SDS

包含sdshdr8/16/32/64等多种类型,以sdshdr8为例:

struct sdshdr8 {
    uint8_t len; 
    uint8_t alloc; 
    unsigned char flags; 
    char buf[]; 
};
  • len : 记录buf数组中已使用的字节数量
  • alloc : 分配的buf数组长度,不包括头和空字符结尾
  • flags : 标志位,标记当前字节数组是 sdshdr8/16/32/64 中的哪一种,占 1 个字节。
  • buf[] : 字符数组,用于存放实际字符串

设计上的好处:

  • 减少修改字符串的内存重新分配次数:C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。而SDS基于len和alloca实现了扩容时空间预分配和缩容时惰性空间释放两种策略,减少内存重新分配次数。
  • 避免缓冲区溢出:在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。
  • 常数时间获取字符串长度:用单独的变量 len 和 alloc,可以方便地获取字符串长度和剩余空间,不需要遍历数组。
双向链表LinkedList

优点

  1. 高效的两端操作:可以在 O(1) 时间复杂度内完成头部和尾部的插入、删除操作。
  2. 有序性与遍历灵活:支持正向和反向遍历(得益于双向指针)。

缺点

  1. 内存占用高:每个节点需要额外存储prev指针、next指针等信息(通常各占8字节,一共占16字节),在存储整数等场景下内存消耗显著。
  2. 访问中间元素效率低:随机访问中间元素需要从头或尾遍历,时间复杂度为 O(n),无法满足快速查询需求。
  3. 碎片化问题:链表节点在内存中是离散存储的,可能导致内存碎片化,影响分配效率和性能。

Redis 3.2之后双向链表已废弃,被快速列表替代。

压缩列表ZipList

压缩表在内存中的存储格式:

  • zlbytes 字段的类型是uint32_t, 存储压缩列表所占用的内存的字节数。
  • zltail 字段的类型是uint32_t, 存储压缩列表中最后一个entry的偏移量. 用于快速定位最后一个entry, 以快速完成pop等操作。
  • zllen 字段的类型是uint16_t, 存储压缩列表中entry的数量,如果entry的数目小于65535(2的16次方),那么该字段中存储的就是实际entry的值。若等于或超过65535,那么该字段的值固定为65535,但实际数量需要一个个entry的去遍历所有entry才能得到。
  • entry 数据节点。
    • prevlen:前一个元素的长度。若前一个元素长度<254字节则prevlen长度为1字节,若前一个元素长度>=254字节(第1字节固定为0xFE,后4字节存储实际长度)则prevlen长度为5字节。
    • encoding:数据编码方式。
      • 整数编码(1/2/5字节):存储小整数(如 int16_tint64_t)。
      • 字符串编码(1/2/5字节):根据字符串长度动态调整编码,例如:
        • 00xxxxxx:字符串长度<64字节,用1字节编码。
        • 01xxxxxx xxxxxxxx:字符串长度<=16383字节,用2字节编码。
        • 10000000 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx:字符串长度<=2^32-1字节,用5字节编码。
    • data:实际存储的数据,字符串或整数,根据encoding字段解析。
  • zlend是一个终止字节,其值为全F,即0xff,任何情况下, 一个entry的首字节都不会是255。

优点:节省内存

  • 相比于双向链表而言,压缩列表的元素节点没有两个8字节的指针,而且存储空间连续,不存在内存碎片的问题,适合存储大量的小元素。
  • 相对于数组而言,压缩列表不需要为每个元素节点预留同样大小的空间,通过encoding对不同的元素类型细化存储大小。

缺点:

  • 没有预留内存空间,每一次扩缩容或者修改prevlen字段都会进行内存分配操作,不适合数据量大的场景。
  • 随机访问需从头遍历,时间复杂度为 O(n)。

压缩列表 vs. 双向链表的内存对比
假设有 100 个元素,每个元素为 8 字节整数:
双向链表:
每个节点需存储:数据(8 字节)+ 前驱指针(8 字节)+ 后继指针(8 字节)+ 节点头(假设 8 字节)= 32 字节 / 节点。
总内存:100节点 × 32字节 = 3200字节
压缩列表:
每个元素需存储:prevlen(1 字节)+ encoding(1 字节)+ 数据(8 字节)= 10 字节 / 元素。
列表头(zlbytes+zltail+zllen)= 10 字节,列表尾(zlend)= 1 字节。
总内存:10字节/元素 × 100元素 + 11字节 = 1011字节

快速列表QuickList

快速列表是双向链表和压缩列表的结合,整体是一个双向链表,每一个节点是一个压缩列表,且支持节点压缩。

快速列表是在内存使用和操作效率之间取得平衡,但实现复杂度高。

哈希表HashTable

哈希表使用开链法解决冲突,即相同hash的节点通过双向链表存储在同一个槽内。

采用渐进式rehash避免一次性扩容时的阻塞:进行渐进式rehash期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。进行 增加操作时在新的哈希表上进行的。

整数集合IntSet

  • encoding表示编码方式,取值有:INTSET_ENC_INT16, INTSET_ENC_INT32, INTSET_ENC_INT64。
  • length表示数组长度。
  • contents是实际保存元素的地方,元素在数组中从小到大排列,没有重复元素。查询时采用二分法查找。

升级:

在一个int16类型的整数集合中插入一个int32类型的值,整个集合的所有元素都会转换成32类型:

  • 根据新元素的类型扩展整数集合底层数组的空间大小,并为新元素分配空间。
  • 将底层数组现有的所有元素都转换成与新元素相同的类型。
  • 改变encoding的值,length+1。

只会升级,不会降级。

跳跃表SkipList

skiplist与平衡树比较:

  • **平衡树的范围查询过程比跳表复杂:**在平衡树上找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点,需要对平衡树进行一定的改造后才能实现中序遍历。在skiplist上进行范围查找非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
  • 平衡树的插入删除可能引发子树调整:平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
  • 平衡树的多余内存占用固定为两个指针,跳表更灵活
  • 平衡树的实现难度比跳表高。

数据类型

String

String数据结构是简单的key-value类型,redis的key和value大小限制都是512M,超过限制会报错

String是最常用的数据结构,几乎可用于所有场景(序列化);计数器,快速实现计数和查询的功能;共享用户Session;限流。

Hash

hash 是一个 string 类型的** field value **的映射表,适合用于存储对象,后续操作的时候,可以仅仅修改这个对象中的某个字段的值。底层实现是字典。

可以 hash 数据结构来存储用户信息,商品信息等等。

List

各种列表如商品列表,消息列表等都可以用 list 实现;可以作为消息队列;可以用lrange 命令,就是从某个元素开始读取多少个元素,实现分页查询(类似微博下拉不断分页)。

Set

底层是字典,字典的值为 null。使用字典作为底层实现。

可以基于 set 实现交集、并集、差集的操作,查找共同好友

Sorted Set(ZSET)

和set相比,sorted set增加了一个权重参数score,使得集合中的元素能够按score进行有序排列。底层实现是字典+跳跃表范围查找和精准查找都很快。

可用于各种排行榜;可以用来实现延时队列,时间戳作为分数。

持久化

RDB

原理是把当前进程数据生成快照保存到磁盘上。

触发方式

手动触发:

  • save命令:阻塞Redis主进程,直到RDB过程完成为止。
  • bgsave命令:Redis进程执行fork操作创建子进程,持久化过程由子进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间很短。

自动触发:

  • 在redis.conf中配置在m秒内有n次修改时,自动触发bgsave生成rdb文件;
  • 主从复制时,从库全量复制同步主库数据,此时主库会执行BGSAVE命令进行快照;
  • 客户端执行数据库清空命令FLUSHALL时候,会触发快照的生成;
  • 客户端执行shutdown关闭redis时,会触发快照的生成。
优点
  • 冷备份。文件紧凑且使用LZF压缩,同样的数据大小,相比AOF会更小,节省磁盘空间。
  • RDB快照恢复起来,比AOF日志要更快。
缺点
  • 生成RDB文件,通常是遍历整个内存数据块,所以即使是fork的子进程去做,也需要一定的资源消耗,时间可能会比较长。
  • 快照生成期间,如果有数据的改动,不再反应到RDB文件中,一旦数据库宕机,就可能丢失几分钟的数据。
  • 可读性差,不能直接打开。

AOF

原理是只记录执行的命令。

如果开启了 AOF,会优先使用 AOF 还原数据库,否则使用 RDB 还原。

写后日志

Redis是“写后”日志,Redis先执行命令,把数据写入内存,然后才记录日志。而大多数的数据库采用的是写前日志(WAL),例如MySQL,通过写前日志和两阶段提交,实现数据和逻辑的一致性。

优点:

  • 避免额外的检查开销:Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。
  • 不会阻塞当前的写操作。

缺点:

  • 如果命令执行完成,写日志之前宕机了,会丢失数据。
  • 主线程写磁盘压力大,导致写盘慢,阻塞后续操作。
写入策略

对每一条命令都会以 AOF 格式写入 AOF 文件中。AOF 日志只记录对内存进行修改的指令,先执行成功命令,再将命令写入到aof_buf 缓冲区中,然后写入AOF文件中。

有三种写入AOF文件的策略:

always,实时刷新,写入并同步到 AOF 文件中。

everysec,默认机制,每一秒同步一次,由单独的线程负责。

no,由操作系统决定何时同步。linux 系统的 fsync 和 fdatasync 函数可以强制操作系统同步。

重写AOF文件:

  1. 主线程fork出子进程重写aof日志,此过程会阻塞主进程。
    1. fork采用操作系统提供的写时复制(copy on write)机制,就是为了避免一次性拷贝大量内存数据给子进程造成阻塞。fork子进程时,子进程时会拷贝父进程的页表,即虚实映射关系(虚拟内存和物理内存的映射索引表),而不会拷贝物理内存。在拷贝完成前会阻塞主线程,阻塞时间取决于内存中的数据量,数据量越大,则内存页表越大。拷贝完成后,父子进程使用相同的内存地址空间。
    2. 在主进程有数据写入时,操作系统会创建写入页面的副本,真实的拷贝当前页的物理数据,并将新页映射到主进程中,而子进程还是使用原来的的页。此过程也会阻塞主进程。
  2. 子进程重写日志完成后,主线程追加aof日志缓冲。
  3. 替换日志文件。
优点
  • 热备份,支持三种持久化策略,基本保证数据不丢失
  • AOF 文件可读性高
缺点

同一份数据来说,AOF日志文件通常比RDB数据快照文件更大

通过AOF日志文件进行数据恢复所需要的时间会更长

混合式持久化

RDB 快照数据和 AOF 日志数据都存在同一个文件中,前面的大部分存储的是 RDB 的快照数据,后一小部分存储的是自 RDB 快照持久化开始到持久化结束的这段时间发生的增量 AOF 日志。

主从结构

主从同步

作用
  • 读写分离,分散请求,缓解主节点压力。
  • 主节点出现故障时快速恢复
  • 主从复制是哨兵和集群实现的基础。
全量复制
  • 建立连接(即准备阶段):从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。
  • 数据同步:连接正常通信后,对于首次建立复制的场景,主节点会把数据全部发送给从节点,这部分操作是耗时最长的步骤,依赖RDB快照。当主节点把当前的数据同步给从节点后,便完成了复制的建立流程。
  • 命令传播:接下来主节点会持续地把写命令发送给从节点,保证主从数据一致性。如果断线,根据从服务器返回的偏移量确定是部分重同步还是完整重同步。
增量复制

如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步。

读写分离的问题
  • 延迟问题
    • 优化主从节点之间的网络环境(如在同机房部署),使用集群同时扩展写负载和读负载等。
    • 监控主从节点延迟(通过offset)判断,如果从节点延迟过大,通知应用不再通过该从节点读取数据。
  • 数据过期问题
    • 在主从复制场景下,为了主从节点的数据一致性,从节点不会主动删除数据,而是由主节点控制从节点中过期数据的删除。由于主节点的惰性删除和定期删除策略,都不能保证主节点及时对过期数据执行删除操作,因此,当客户端通过Redis从节点读取数据时,很容易读取到已经过期的数据。
    • Redis 3.2中,从节点在读取数据时,增加了对数据是否过期的判断:如果该数据已过期,则不返回给客户端。
  • 故障切换问题
    • 使用哨兵模式。

哨兵模式

作用
  • 节点监控:监控 master 和 slave 是否正常工作。
  • 故障转移:当主节点不能正常工作时,将失效主节点的其中一个从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
  • 提供配置:客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址。

由一个或多个 Sentinel 实例组成的 Sentinel 系统可以监视任意多个主服务器以及它们的从服务器,并在主服务器下线的时候让新的主服务器继续执行命令。Sentinel 节点本质上是 redis 节点。

运行机制

每个哨兵节点维护了3****个定时任务。定时任务的功能分别如下:通过向主从节点发送info命令获取最新的主从结构;通过发布订阅功能获取其他哨兵节点的信息;通过向其他节点发送ping命令进行心跳检测,判断是否下线。

心跳检测的定时任务中,如果其他节点超过一定时间没有回复,哨兵节点就会将其进行主观下线。哨兵节点在对主节点进行主观下线后,会询问其他哨兵节点该主节点的状态,如果判断主节点下线的哨兵数量达到一定数值,则对该主节点进行客观下线。当主节点被判断客观下线以后,各个哨兵节点会进行协商,试用raft算法选举出一个领导者哨兵节点,并由该领导者节点对其进行故障转移操作。选举出的领导者哨兵,开始进行故障转移操作。

故障转移
  • 从节点中选择新的主节点
  • 更新主从状态
  • 如果原先的主节点重新上线,将会被设置为从节点。

集群模式

架构

一个集群由多个Redis节点(这里指主节点,每个主节点可能是一个主从结构)构成。集群是水平切分,不同节点组的数据没有交集,也就是每个节点组对应数据sharding的一个分片。

去中心化的架构不存在统一的配置中心,所以各个节点对整个集群状态的认知来自于节点之间的信息交互

集群通过分片来保存数据库中的键值对,整个数据库被分为 16384 个槽,数据库中每个键都属于这些槽中的一个,集群的每个节点可以处理 0 个或最多 16384 个槽。每个节点都记录了所有槽的分配信息

为什么是16384个槽,发送心跳包时需要把所有的槽放到这个心跳包里,以便让节点知道当前集群信息,16384=16k,压缩之后是2k,使用CRC16算法(对key进行CRC16校验后取模分配到槽里)最多可以分配65535个,压缩后是8k,但是不值得,一般master节点不会超过1000个,所以16384是合适的选择。

在集群中执行命令时,接受命令的节点计算出命令要处理的数据应该属于哪个槽,并检查这个槽是否指派给了自己。如果是,直接执行这个命令并返回结果,否则返回一个 MOVED 错误,指引客户端重定向至正确的槽中。节点间的信息交换使用Gossip协议。

常用的数据分片的方法有:范围分片,哈希分片,一致性哈希算法和虚拟哈希槽等,Redis Cluster 采用虚拟哈希槽分区,所有的键根据哈希函数映射到 0 ~ 16383 整数槽内,计算公式:slot = CRC16(key) & 16383。每一个节点负责维护一部分槽以及槽所映射的键值数据。

Gossip协议

原理

在一个有界网络中,每个节点都随机地与其它节点通信,经过一番杂乱无章的通信,最终所有节点的状态都会达成一致。每个节点可能知道所有其它节点,也可能仅知道几个邻居节点,只要这些节点可以通过网络连通,最终它们的状态都是一致的。Gossip主要有两大作用:去中心化,以实现分布式和弹性扩展;失败检测,以实现高可用。

Gossip的缺点是,冗余通信会对网路带宽、CUP 资源造成很大的负载,通信频率越高时集群负载越高,而通信频率越低时算法收敛的速度越低。

Redis 集群内节点通信采用固定频率(定时任务每秒执行10次),随机选择最近没有通信过的节点发送消息。

消息类型

集群中节点发送的消息主要包括:MEET(加入集群)、PING(确认在线)、PONG(响应在线)、FAIL(下线)、PUBLISH(广播)。

  • Meet 消息:用于通知新节点加入。消息发送者通知接收者加入到当前集群,Meet 消息通信正常完成后,接收节点会加入到集群中并进行周期性的 Ping、Pong 消息交换。
  • Ping 消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其它节点发送 Ping 消息,用于检测节点是否在线和交换彼此状态信息。Ping 消息发送封装了自身节点和部分其它节点的状态数据。
  • Pong 消息:当接收到 Ping、Meet 消息时,作为响应消息回复给发送方确认消息正常通信。Pong 消息内部封装了自身状态数据。节点也可以向集群内广播自身的 Pong 消息来通知整个集群对自身状态进行更新。
  • Fail 消息:当节点判定集群内另一个节点下线时,会向集群内广播一个 Fail 消息,其他节点接收到 Fail 消息之后把对应节点更新为下线状态。

故障检测

如果在一个集群里,超过半数的持有 Slot(槽)的主节点都将某个主节点 X 报告为疑似下线,那么,主节点 X 将被标记为下线(Fail),并广播出去,所有收到这条 Fail 消息的节点都会立即将主节点 X 标记为 Fail。

集群中所有正常节点都将感知到某个主节点下线的信息,也包括这个下线主节点的所有从节点。当从节点发现自己复制的主节点状态为已下线时,从节点就会向集群广播一条请求消息,请求所有收到这条消息并且具有投票权的主节点给自己投票。

在一个具有 N 个主节点投票的集群中,理论上每个参与拉票的从节点都可以收到一定数量的主节点投票,但是,在同一轮选举中,只可能有一个从节点收到的票数大于 N/2 + 1,也只有这个从节点可以升级为主节点,并代替已下线的主节点继续工作。

选举方式使用Raft算法。

Codis架构(Proxy架构)

由Proxy负责确定数据保存在哪个Master节点中,路由信息存放依赖第三方存储组件,如 ZooKeeper 或 Etcd。

缓存淘汰

过期键删除

定期删除:默认是每隔 100ms 随机抽取一些设置了过期时间的key,检查其是否过期,如果过期就删除。

惰性删除:定期删除每次只会扫描一部分的过期数据字典,所以有可能会导致一部分的过期数据被遗留下来。惰性删除是作为定期删除不足的补救措施,在获取某个key的时候,先检查一下是否设置了过期时间以及是否过期,如果已经过期将会删除该数据。只有key被操作时,才会被动检查该key是否过期。

如果定时删除留下的过期key过多,而惰性删除又没有触发,此时内存告警,就会走内存淘汰策略。

缓存淘汰

内存淘汰常用三种策略:LRU, LFU, FIFO。

以下均为从随机选出的N个数据执行淘汰,而不是从所有的数据中淘汰最精准的一个。

noeviction:禁止写入数据,允许继续读,保证已有的缓存数据不丢失。这是默认的淘汰策略。

volatile-lru:从设置了过期时间的数据中,淘汰最近最久未使用的(LRU, Least Recently Used),保证无过期时间的数据不丢失。

volatile-ttl:从设置了过期时间的数据中,淘汰过期时间最近的。越快过期的优先淘汰。

volatile-random:从设置了过期时间的数据中,随机淘汰

allkeys-lru:从所有数据中,淘汰最近最久未使用的(LRU)。

allkeys-random:从所有数据中,随机淘汰。

一共6种,Redis 4.0 中又新加入了两个基于 LFU(Least Frequently Used)的淘汰策略:

volatile-lfu:从设置了过期时间的数据中,淘汰(最近)最少使用的。

allkeys-lfu:从所有数据中,淘汰(最近)最少使用的。

应用问题

常见缓存问题

问题概述解决方案
击穿同一时间大量请求同时访问数据库(一般是缓存时间到期),数据库压力瞬间增大。1. 使用互斥锁,只允许一个请求能查询数据库,阻塞其他请求。
2. 缓存预热,提前加载数据;
3. 热点数据永不过期
穿透缓存失效,一般是访问缓存和数据库都没有的数据,恶意攻击1. 完善校验
2. 使用布隆过滤器过滤黑名单用户。
热点大部分甚至所有业务请求都命中同一份缓存数据。1. 复制多份缓存,将请求分散到多台缓存服务器上。
2. 多级缓存,本地缓存。
雪崩和“击穿”的区别是大规模缓存key失效。1. 限流降级:使用限流工具限流,避免 MySQL 被打死;服务降级返回假数据。
2. 分散过期时间:在缓存的过期时间上加上一个随机数,避免大量缓存同时过期。
并发并发写入的时候顺序错了,导致最终的结果不是期待的结果。把并发写入改成串行化,用分布式锁或者加上时间戳,写入数据的时间戳晚于当前时间戳时才写入。

缓存一致问题

Cache Aside Pattern

客户端读取时,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。

客户端写入时,先写数据库,然后再删除缓存(不更新缓存是为了避免并发写入)。

可能存在的问题:

客户端1的读操作没有命中缓存,然后就到数据库中取数据,此时另外客户端2发起写操作,写完数据库后,让缓存失效,之后客户端1的读操作再把老的数据放进去,就产生了脏数据。

保证最终一致性

Read/Write Through Pattern

需要在客户端和存储系统中使用特定的缓存中间件。

客户端读取时,缓存系统主动从数据库读取数据,并将其写入缓存后再返回给客户端。客户端只需与缓存交互,无需关心数据是否在缓存中存在或如何从数据库加载。

客户端写入时,先更新缓存,再立即同步更新数据库,确保两者数据始终一致。写操作完成后,客户端才会收到成功响应,且这个过程不允许其他客户端更新。

数据强一致性,缓存和数据库始终保持同步。

Write Behind Caching Pattern

写入时直接更新缓存,异步批量更新数据库(通常按时间或数量阈值触发)。

数据弱一致性,数据库更新存在延迟;若缓存崩溃,可能丢失未写入数据库的数据。

参考

  1. 《Redis设计与实现》
  2. Redis5种基本数据结构底层实现 - CryFace - 博客园
  3. 分布式一致性协议 Gossip 和 Redis 集群原理解析
  4. Redis进阶 - 数据结构:底层数据结构详解
  5. Redis5种基本数据结构底层实现 - CryFace - 博客园
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值