数据类型
数据类型与底层数据结构的关系
数据类型 | 底层数据结构 |
---|---|
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
优点
- 高效的两端操作:可以在 O(1) 时间复杂度内完成头部和尾部的插入、删除操作。
- 有序性与遍历灵活:支持正向和反向遍历(得益于双向指针)。
缺点
- 内存占用高:每个节点需要额外存储prev指针、next指针等信息(通常各占8字节,一共占16字节),在存储整数等场景下内存消耗显著。
- 访问中间元素效率低:随机访问中间元素需要从头或尾遍历,时间复杂度为 O(n),无法满足快速查询需求。
- 碎片化问题:链表节点在内存中是离散存储的,可能导致内存碎片化,影响分配效率和性能。
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_t
、int64_t
)。 - 字符串编码(1/2/5字节):根据字符串长度动态调整编码,例如:
00xxxxxx
:字符串长度<64字节,用1字节编码。01xxxxxx xxxxxxxx
:字符串长度<=16383字节,用2字节编码。10000000 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
:字符串长度<=2^32-1字节,用5字节编码。
- 整数编码(1/2/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文件:
- 主线程fork出子进程重写aof日志,此过程会阻塞主进程。
- fork采用操作系统提供的写时复制(copy on write)机制,就是为了避免一次性拷贝大量内存数据给子进程造成阻塞。fork子进程时,子进程时会拷贝父进程的页表,即虚实映射关系(虚拟内存和物理内存的映射索引表),而不会拷贝物理内存。在拷贝完成前会阻塞主线程,阻塞时间取决于内存中的数据量,数据量越大,则内存页表越大。拷贝完成后,父子进程使用相同的内存地址空间。
- 在主进程有数据写入时,操作系统会创建写入页面的副本,真实的拷贝当前页的物理数据,并将新页映射到主进程中,而子进程还是使用原来的的页。此过程也会阻塞主进程。
- 子进程重写日志完成后,主线程追加aof日志缓冲。
- 替换日志文件。
优点
- 热备份,支持三种持久化策略,基本保证数据不丢失。
- 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
写入时直接更新缓存,异步批量更新数据库(通常按时间或数量阈值触发)。
数据弱一致性,数据库更新存在延迟;若缓存崩溃,可能丢失未写入数据库的数据。