Redis
Redis 为什么快
需要后续补充
内存存储
数据都保存在内存中,访问速度极快
单线程架构
单线程事件循环模型,避免锁竞争、上下文切换的开销
注:持久化机制和集群通信是多线程
高效数据结构
哈希表、压缩列表、跳表等
大多操作时间复杂度都是 O(1) 或 O(logN)
零拷贝机制
Redis 在发送响应给客户端时,利用了操作系统的 writev 等系统调用,直接将多个缓冲区内容合并输出,避免不必要的数据拷贝
14高效 I/O 模型
使用 I/O 多路复用中的 epoll 模型,高并发场景下的效率最高
协议简单
采用自定义的 RESP 协议,格式简单、解析快,在客户端与服务器间的通信开销小
异步持久化
持久化交由后台线程,保证主线程的性能
集群和分布式支持
可水平扩展读写性能
数据类型
1 | TODO |
Hash
数据结构
- 如果哈希类型元素小于 512 个,所有值小于 64 字节,以压缩列表作为数据结构
- 不满足上述条件则以哈希表作为数据结构
特点
key, filed, value 结构与对象的对象id,属性,值结构相似
常用命令
存储一个哈希表 key 的键值
HSET key field value
获取 key 对应的 value
HGET key field
删除 key 对应的 field 键值
HDEL key field [field ...]
应用场景
缓存对象
例:HMSET uid:1 name X age 15
存储一个哈希表 uid:1 的键值
购物车
渐进式 rehash
Hash 类型底层由两张哈希表 ht[0]、ht[1] 实现
- 当 ht[0] 的负载因子 > 1.0 时,触发扩容操作,分配一个更大的新哈希表 ht[1],设置 rehashidx = 0
- 增添元素时,会直接插入在 ht[1] 中
- 删除元素时,会同时查两张表,然后将其删掉
- 修改元素时,会先查 key 在那张表中,然后在对应表中更新,并将其迁移到 ht[1] 中
- 查找元素时,由于正在进行数据迁移,需要同时查两张表
- 当 ht[0] 的元素都搬到 ht[1] 后,用 ht[1] 替换 ht[0],设置 rehashidx = -1
rehashidx 表示当前进行到第几个槽位,等于 -1 表示未开启 rehash
按槽位进行迁移,如果一个槽上有多个 key,则将其以链表的形式插入到 ht[1] 中
Redis 底层的字典结构也是采取渐进式 rehash
由于Redis 执行命令是单线程的,如果一次性完成 rehash的话,会导致 Redis 无法响应客户端的命令, 所以将其分散到每次对哈希表的增删改查操作中
为什么 6.0 版本后提出了多线程的概念?
6.0 之前,主线程 = 处理所有客户端请求 + 处理所有命令逻辑 + 处理网络 IO
如果客户端的网速慢、或数据量大,Redis 主线程会卡在网络 IO 的 read() 或 write() 上,成为性能瓶颈,引入多线程的初衷就是为了加速网络 IO
String
数据结构
int 和 SDS(简单动态字符串)
应用场景
缓存对象
常规计数
分布式锁
SET 支持 NX 参数,实现 key 不存在时才插入
共享 session
List
数据结构
双向链表或压缩列表
特点
FIFO 顺序对数据进行存取操作
应用场景
消息队列
Set
数据结构
哈希表或整数集合
特点
支持取差集、并集和交集操作
应用场景
点赞
共同关注
Zset
数据结构
压缩列表或跳表
特点
可以根据元素的权重进行排序
应用场景
排行榜
Bitmap
数据结构
string作为底层结构
应用场景
适用于二值状态统计场景,海量数据下有效节省空间
签到统计
判断用户登录态
持久化机制
AOF日志
作用
宕机或服务器重启后,恢复 Redis 缓存中的数据
如何实现?
当 Redis 写操作执行成功后,将其追加到 AOF 日志中,该文件位于磁盘中,可以实现持久化存储
读操作是否需要存储?
不需要,读操作不会改变数据状态,没有记录的必要
为什么要在写操作执行成功后才追加?
- 保证 AOF 中记录的语法都是正确的,避免语法检查这一不必要的开销
- 避免当前写操作的执行被阻塞,保证 Redis 性能
潜在风险
- 将 Redis 命令 追加到 AOF 这一操作,会阻塞主进程执行下一个 Redis 命令
- 由于写操作执行成功后才做追加,如果此时服务器挂了,还是会造成数据丢失
写回策略
Redis 提供了三种写回硬盘的策略
- Always,每条写操作执行成功后,立即将 AOF 日志写回硬盘
- Everysec,写操作执行成功后,先将其加入 AOF 缓冲区,每隔一秒将缓冲区的内容写回硬盘
- No,写操作执行成功后,将其加入 AOF 缓冲区,由操作系统决定何时写回硬盘
这三种策略是如何实现的?
本质上是 fsync() 函数的调用时机不同
Always 每次写入 AOF 数据后,立即调用 fsync() 函数
Everysec 创建一个异步任务来调用 fsync() 函数
No 永不调用 fsync() 函数
如何选取?
上述风险中提到的两个点,本质上是互斥的,无法同时兼顾
具体的写回策略须根据业务需要进行设置,给出参考
- 追求高性能,采用 No 策略
- 追求高可靠,采用 Always 策略,尽可能减少数据丢失
- 追求性能且允许少量数据丢失,采取折中的 Everysec 策略
重写机制
随着执行的写操作命令越来越多,AOF 文件的大小越来越大
重启 Redis 后,需要读 AOF 文件恢复缓存数据,若 AOF 文件太大,恢复过程就很慢
重写机制就是为了避免这一情况的出现
如何实现?
Redis 以 Key-Value 形式存储数据,对于每个 Key,只保留其最新的 Value,这样即使这个键值对被修改多次,也只会保留最新状态,并在 AOF 文件中用一条命令记录它,降低 AOF 文件大小
具体过程?
先创建一个新的 AOF 文件,在其中进行重写,重写成功后再覆盖旧的 AOF 文件
为什么不直接覆写在旧 AOF 文件?
先写到新的 AOF 中,失败的话,直接删除这个文件即可,避免因重写失败污染AOF 文件
何时触发?
AOF 文件当前大小 >= auto-aof-rewrite-min-size( 默认64MB)
AOF 文件当前大小 >= M * (1 + auto-aof-rewrite-percentage%),其中 M 为上次重写后 AOF 文件的大小
举例:上次重写后 AOF 文件大小为 50MB,百分比设定为100,则当前大小达到 100MB 时触发重写
后台重写
由于此时的 AOF 文件已经很大了,且需要读取所有的键值对,此操作如果在主进程进行,就会阻碍其它命令的操作
如何进行?
fork 一个 bgrewriteaof 子进程在后台完成重写
为什么不用更节省资源的线程?
多线程之间共享内存,如果修改共享内存中的数据,就要加锁保证线程安全,造成额外开销,降低性能
子进程如何访问父进程的数据?
创建子进程时会复制一份父进程的页表,二者可以通过不同的虚拟内存访问相同的物理内存,节约物理内存资源。复制页表的过程会阻塞父进程,页表越大,阻塞时间越长。页表会将物理内存的权限标记为只读
写时复制(COW)
由于物理内存权限被标记为只读,若期间子进程或父进程对该物理内存的数据进行修改,就会触发 Copy On Write 机制
具体流程为:由于违反系统权限,CPU触发写保护中断,操作系统在写保护中断处理函数中进行物理内存复制,修改子父进程的内存读写权限为可读写,最后再对内存中的数据进行写操作
COW 机制的作用?
发生写操作时,才复制物理内存,可以减短复制页表时对主进程的阻塞时长
如果在重写过程中,主进程修改了已存在的键值对,子父进程的数据一致性如何保证?
Redis内置了 AOF 重写缓冲区,其在创建 bgrewriteaof 子进程后启用。重写期间,Redis 执行完的写操作会被同步写到 AOF 缓冲区和 AOF 重写缓冲区
重写期间,主进程执行三个工作:
- 执行客户端发送到命令
- 将执行后的写操作追加到 AOF 缓冲区
- 将执行后的写操作追加到 AOF 重写缓冲区
当子进程完成 AOF 重写后,会发送一个信号给主进程,主进程接收到信号后,调用信号处理函数,此函数的功能
- 将 AOF 重写缓冲区的所有内容追加到新的 AOF 文件中,保证新旧 AOF 文件保持的数据库状态一致
- 将新的 AOF 文件改名,覆盖旧的 AOF 文件
阻塞主进程的操作
- 创建子进程时的页表复制过程
- 写时复制
- 调用信号处理函数
RDB快照
除 AOF 日志外,Redis还提供了 RDB 快照作为持久化方案
二者区别
- AOF 文件的内容为操作命令
- RDB 文件的内容为二进制数据
快照,顾名思义为某一个瞬间的记录。RDB 快照记录的是某一瞬间的内存数据(实际数据);AOF 文件记录的是命令操作的日志(非实际数据)
用 RDB 文件恢复数据时,只需将其读入内存中即可;而使用用 AOF 文件时,Redis 会创建一个无网络连接的伪客户端,专门用于执行 AOF 文件中的命令。因此 RDB 恢复效率更高
如何使用?
- SAVE
在主进程中生成 RDB 文件,若写入 RDB 文件的时间过长,会阻塞主进程
- BGSAVE
fork 一个子进程生成 RDB 文件,避免阻塞主进程
Redis 的快照是全量快照,会将内存中的所有数据记录到磁盘中,因此执行频繁不能太高,否则 Redis 性能会降低,但频率降低,故障时丢失的数据更多(RDB的缺点)
一般设置为 5 分钟保存一次快照,这样最多丢失 5 分钟的数据
BGSAVE 时能修改数据吗?
可以,跟 AOF 同样会发生写时复制,但不会像 AOF 那样追加修改后的数据
混合持久化
4.0 版本提出,其发生在 AOF 日志重写过程
此时 fork 出来的子进程会先将与主线程共享的数据以 RDB 格式写入 AOF 文件,主线程在此阶段处理的操作命令会被记录在重写缓冲区,缓冲区的增量命令会以 AOF 格式写入 AOF 文件,写完后通知主进程将旧的 AOF 文件替换为包含 RDB 格式和 AOF 格式的 AOF 文件
简而言之,若采用混合持久化,AOF 前半段为 RDB 格式的全量数据,后半段为 AOF 格式的增量数据
有什么好处?
前半段 RDB 加载速度快,后半段 AOF 记录主进程处理的操作命令,减少丢失的数据量
内存淘汰策略
LRU
Least Recently Used,即淘汰最近最少使用的数据
如何实现?
传统 LRU
基于链表,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移到头部,触发内存淘汰时,删除尾部元素即可
存在问题:
- 需要引入链表,额外内存开销
- 大量数据访问时,引起很多链表移动操作,耗时太久,降低 Redis 性能
Redis 中是如何实现的?
在 Redis 的对象结构体中加入记录最后一次访问时间的额外字段
内存淘汰时,进行随机采样淘汰,随机取 5 个值(可调整),淘汰其中最久没有使用的那个
缓存污染
应用一次读取了大量数据,但这些数据只被读取这一次,也会将其保存在 Redis 中很长时间
LFU
Least Frequently Used,即淘汰最不经常使用数据
1 | TODO |
高可用
主从复制
1 | TODO |
主节点进行读写,发生写操作时自动将其同步给从节点
从节点一般只读,并执行主节点同步过来的写操作命令
主从复制模式
全量复制
主从第一次同步时,采用全量复制,此时需要生成 RDB 和传输 RDB,为避免过多主节点和从节点进行 全量复制,将一部分从节点升级为经理,让其也有从节点,进而分担主节点的压力
同步完成后,主从节点会维持长连接,当主节点接收到写操作命令后,就通过长连接传播给从节点,保证主从节点的数据一致性
基于长连接的命令传播
增量复制
若主从之间网络断开又恢复正常,就只将断联期间的写操作命令同步给从节点
如何判断节点是否正常工作?
心跳检测:通过 ping 操作,若一半以上的节点去 ping 同一个节点,都未收到 pong 回应,集群就认为该节点挂了,断开它的连接
主从模式下,过期 key 如何处理?
当主节点处理了一个 key 或淘汰算法淘汰了一个 key,主节点就模拟一条 del 命令发送个从节点,从节点收到后就执行删除 key 操作
复制是同步还是异步?
主节点接收到写命令后,先写到内部缓冲区,然后异步发送给从节点
哨兵节点
哨兵用于实现主从节点故障转移,其会监测主节点是否存活,如果发现主节点挂了,就选举一个从节点作新的主节点,并将新主节点的信息通知给从节点和客户端
哨兵一般以集群方式部署,至少需要 3 个哨兵节点,哨兵集群负责监控、选主、通知
集群
概念
对数据进行切片,在每个节点上存储不同的内容,实现 Redis 的分布式存储,解决在线扩容问题
即使面对亿级数据,也只需增加 Redis 实例并在集群上进行流量调度即可
整个 Redis 数据库划分为 16384 个哈希槽,集群中的节点均分这些槽位
为什么设置为 16384 个槽位?
作者原话
The reason is:
- Normal heartbeat packets carry the full configuration of a node, that can be replaced in an idempotent way with the old in order to update an old config. This means they contain the slots configuration for a node, in raw form, that uses 2k of space with16k slots, but would use a prohibitive 8k of space using 65k slots.
- At the same time it is unlikely that Redis Cluster would scale to more than 1000 mater nodes because of other design tradeoffs.
So 16k was in the right range to ensure enough slots per master with a max of 1000 maters, but a small enough number to propagate the slot configuration as a raw bitmap easily. Note that in small clusters the bitmap would be hard to compress because when N is small the bitmap would have slots/N bits set that is a large percentage of bits set.
大致意思为
1.集群模式下,需要通过心跳包机制定期广播槽位信息。这些信息以位图的形式存储和传输
16384 个槽位下,位图大小为 16384 / 8 / 1024 = 2KB
65536 个槽位下,位图大小为 65536 / 8 / 1024 = 8KB
心跳包大小增大了 4 倍,相应网络带宽和内存的消耗也会倍增
2.Redis 设计时预期的主节点上线为 1000 个,在此规模下,每个主节点负责约 16 个槽位,避免槽位过于稀疏的同时保证数据均衡分布
那为什么不设置更少一点,比如 8192 个?
每个槽负责的 key 数量增加,每个节点可分配的槽位减少,槽位迁移时需要移动更多数据
缓存
缓存三兄弟
雪崩
大量缓存数据在同时过期或者Redis宕机,如果此时有大量用户请求,将同时访问数据库,导致数据库压力突增,可能造成数据库宕机,进而导致系统崩溃
解决方案:
大量数据同时过期
均匀设置过期时间
设置过期时间时加上一个随机数,保证数据不会在同一时间过期
互斥锁
如果请求访问的数据不在 Redis 中,则加互斥锁,保证同一时间内只有一个请求用于构建缓存,缓存构建完成后,释放锁;未能获取互斥锁的请求,可以等待锁释放后读缓存,也可以直接返回空值或默认值
互斥锁最好设置超时时间,否则加锁的请求如果意外阻塞,就会导致锁不释放且其它请求也拿不到锁,系统出现无响应现象
后台更新缓存
后台线程定时更新缓存,保证缓存永久有效
- 后台线程频繁检测缓存是否有效,若失效则读数据库更新缓存
- 引入 MQ 让其发送消息告知后台更新缓存
上线前先把数据缓存起来,而非用户访问时才触发缓存构建,此为缓存预热
Redis宕机:
服务熔断
启动服务熔断机制,暂停业务层面对缓存的访问,直接返回错误,无需访问数据库,降低数据库压力,Redis 恢复后,再允许业务层面访问缓存
保证数据库正常运行,但业务都无法正常进行
请求限流机制
允许少量请求到达数据库,剩余请求在入口处就拒绝服务,等 Redis 恢复正常并将缓存预热后,再接除限流机制
高可靠集群
通过主从节点方式构建 Redis 集群,当主节点宕机时,从节点切换为主节点,继续提供缓存服务
击穿
某个热点数据在缓存中过期了,此时大量请求访问这个数据,导致这些请求都击穿缓存直接访问数据库,可能使数据库过载
解决方案:
互斥锁
保证同一时间只有一个业务线程更新缓存,未能获取锁的请求,要么等待锁释放后重新读取缓存,
要么返回空值或默认值
不设置过期时间
后台异步更新缓存;或在热点数据将过期前,通知线程更新缓存并重设过期时间
穿透
查询一个根本不存在的数据,导致每次查询都要去数据库查询,从而使得缓存形同虚设
解决方案:
非法请求
入口处判断请求参数是否合法,若非法直接返回错误
缓存空值或默认值
针对查询的数据,在缓存中设置一个空值或默认值,后续查询就不需要查询数据库
使用布隆过滤器
数据库写入时,用布隆过滤器做标记。当请求到来时,若业务线程确认缓存失效,先通过布隆过滤器判断数据是否存在,不存在就不查数据库
布隆过滤器的工作原理
核心思想:用一组位数组 + 多个哈希函数表示集合
工作原理:
- 初始化
- 创建一个长度为 m 的位数组,所有位初始化位 0
- 选择 k 个独立的哈希函数,分别映射输入到[0, m - 1]范围
- 添加元素 x
- 依次使用 k 个哈希函数处理元素 x,得到 k 个下标值
- 将这 k 个位置的 bit 设为 1
- 查询元素 y 是否存在
- 同样计算出 k 个下标值
- 若其中任何一个下标的 bit 为 0,则 y 一定不在集合中**(无假阴性)**
- 若对应位都为 1,说明 y 可能在集合中**(假阳性)**
m、k 大小如何确定?
与误判率有关,具体要参考相关的对数计算公式
为什么会有假阳性?
因为按位判断
比如说集合中存在 5,就会导致误判 3 和 1也存在集合内
假阳性能否解决?
本质为哈希冲突,故只能缓解
- 加大位数组 m,牺牲一点空间降低假阳性率
- 使用层级布隆过滤器
数据库与缓存的一致性如何保证
旁路缓存策略
先更新数据库,再删缓存
- 写策略步骤
- 更新数据库数据
- 删除缓存中的数据
- 读策略步骤
- 如果读取数据命中缓存,则直接返回数据
- 未命中,则先从数据库中读取数据,然后将其写入缓存并返回数据
潜在问题
短期数据不一致
发生在数据库更新完成但缓存还未删除的这一时间窗口内
长期数据不一致
缓存删除失败(网络故障、Redis 服务异常),保留旧数据直到下次删除
解决方案:
- 引入自动重试机制
- 设置 TTL 兜底
主从延迟放大
读写分离下,若删除缓存后立即有查询请求,可能因主从同步延迟导致缓存回填旧数据
解决方案:
- 关键数据强制读主节点
- 延迟双删,更新数据库后,延迟一定时间(如主从同步时间 + 缓冲)再次删除缓存
延迟双删
删除缓存 -> 更新数据库 -> 等待主从同步 -> 再次删除缓存
总结
- 对一致性要求高:延迟双删 + 重试机制 + 强制读主库
- 对性能要求高:使用 TTL 兜底
- 折中:引入 MQ 异步处理