Redis 原理

Redis 原理

单线程 | 多线程

Redis与Reactor模式 - 腾讯云开发者社区-腾讯云 (tencent.com)
Redis 多线程网络模型全面揭秘 - 编程札记 - SegmentFault 思否

Redis 核心操作为单线程操作,在辅助模块例如复制,网络 I/O 解包等使用多线程
Redis 程序并不完全是单线程,Redis 启动时会启动后台线程(BIO):

单线程优点:没有并发带来的上下文切换成本和加锁成本
多线程原因:redis 的性能瓶颈主要在于内存大小和网络通信能力

Redis 服务器中有两类事件,文件事件和时间事件。

单线程模型

redis 中将 socket 设置为非阻塞式,使用 epoll 实现 I/O 多路复用来处理 socket 连接

int main(int argc, char **argv) {
	...
	initServer();
	...
	aeMain();
	...
	aeDeleteEventLoop(server.el);
	return 0;
}

|700

Redis 多线程

redis 在 V6.0以后引入多线程以提升网络 I/O 的性能,默认关闭多线程模式,用户可在 redis.conf 中开启

io-threads 1 # 启用线程数,最大为128,改动后仅启用写多线程 
io-threads-do-reads yes # 允许读多线程

Redis 6.0 引入了多线程 I/O,它的总体设计思路是:

  1. 主线程接受客户端并创建连接,注册读事件,读事件就绪时,主线程将读事件放到一个队列中,这个队列的顺序代表收到客户端请求的顺序,所以意味着 redis 也要按这个队列顺序执行命令
  2. 主线程利用 RR 策略,将读事件分配给多个 I/O 线程,然后主线程开始忙等待(等待包括主线程在内的 I/O 多线程读取完成)
    • I/O 线程读取数据到输入缓冲区,并解析命令(不执行)
  3. 主线程忙等待结束,单线程执行解析后的命令,将响应写入输出缓冲区
  4. 返回响应时主线程同样利用 RR 策略,将写事件分配给多个 I/O 线程,然后主线程开始忙等待(等待包括主线程在内的 I/O 多线程写出完成)
    总结一句话就是:多线程读取/写入,单线程执行命令

2016012682-44f6eb72869ece9c_fix732 (732×473) (segmentfault.com)|600

与单线程模型的差异:

过期删除&内存淘汰

过期删除策略

【吊打面试】Redis的过期策略和内存淘汰策略不要搞混淆 - 腾讯云开发者社区-腾讯云 (tencent.com)

过期时间设置

在创建时设置过期时间:

通用方法:

过期判定

Redis 数据库结构中存在一个 hash table 形式的过期字典,当一个 key 设置了过期时间时,在过期字典内存储 {key}-{expire time} 键值对

过期字典数据结构.png (1080×793) (xiaolincoding.com)|600

当查询一个 key 时,Redis 首先查询过期字典判定该 key 是否过期

过期删除

常见的过期删除策略有以下三种:

Redis 同时使用惰性删除和定期删除两种过期策略

定期删除会阻塞命令执行,因此会设置删除执行时间上限,保证不会造成过长时间停顿

由于 Redis 定期删除是随机抽取机制,不可能扫描删除掉所有的过期 Key,因此需要内存淘汰机制

内存淘汰策略

Redis 可通过 redis.conf 中的 maxmemory 参数设置内存最大占用,每次进行读写的时候检查是否触发内存数据淘汰
maxmemory 在 64 位系统中默认为 0,不限制最大内存占用,但当 Redis 内存超出物理内存的限制时,内存的数据会开始和磁盘产生频繁的 swap,让 Redis 的性能急剧下降

Redis 的内存淘汰策略共八种

不淘汰:

仅从过期字典中淘汰:

全局淘汰:

内存淘汰策略修改方式:

Redis-LRU

LRU 优先淘汰最久未使用的 key

常规 LRU 算法为所有数据维护一个顺序链表,带来额外的空间开销,同时大量数据被访问时链表移动操作过多,降低性能

Redis 采用近似 LRU 算法,在现有数据结构的基础上采用随机采样的方式来淘汰元素,当内存不足时,就执行一次近似 LRU 算法

在 LRU 模式,redisObject.lru 字段(24 bytes)存储的是 key 被访问时 Redis 的时钟 server.Lrulock,当 key 被访问的时候,Redis 会更新对应 redisObject 的 lru 字段

注意,Redis 为了保证核心单线程服务性能,缓存了 Unix 操作系统时钟,默认每毫秒更新一次,缓存的值是 Unix 时间戳取模2^24

V3.0 后 redis 维护一个大小默认为 16 的候选池,池中的数据根据访问时间进行排序,首次随机选取的 key 都放入池中,之后只将选取 key 中活性低于池中活性最小值的 key 放入池中,每次放入后删除池中活性最小的 key

LRU 无法解决缓存污染问题:应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染

Redis-LFU

LFU 优先淘汰最近最不常用的 key,V4.0 引入

Redis 在 LFU 策略下复用 redisObject.lru 字段,高 16bit 存储 ldt(上次访问时间戳),低 8bit 存储 logc(访问次数),默认情况下新添加 key 的访问频次为 5,防止 key 过早被删除

/* Update LFU when an object is accessed.
 * Firstly, decrement the counter if the decrement time is reached.
 * Then logarithmically increment the counter, and update the access time. */
void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val);
    counter = LFULogIncr(counter);
    val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}
  1. 按照上次访问距离当前的时长对 logc 进行衰减
  2. 将 logc 按一定概率增加
    • 次数不足 5 次则一定增加
    • 大于 5 次,小于 255 次,会一定概率加 1,原来的次数越大,越困难
    • 最大为 255 次
  3. 更新时间戳

持久化

Redis 在内存中运行,通过将数据保存到存储设备中实现持久化

Redis 提供两种持久化方法:

RDB VS AOF

体积方面:相同数据量下,RDB 体积更小,因为 RDB 记录的是二进制紧凑型数据
恢复速度:RDB 是数据快照,可以直接加载,而 AOF 文件恢复,相当于重放情况,RDB 更快
数据完整性:AOF 记录了每条日志,RDB 间隔一段时间记录一次,用 AOF 恢复数据通常会更为完整

同时开启 RDB 和 AOF 时优先使用 AOF 恢复数据,但 Redis 官方不建议单独开 AOF

RDB

# /etc/redis/redis.conf
save 3600 1 300 100 60 10000

dbfilename dump.rdb # 文件名

dir ./ # 位置

save 参数配置执行 BGSAVE 命令进行 RDB 的阈值
save 3600 1 表示每间隔 3600s,发生至少 1 次写数据操作,就执行 RDB,以此类推

RDB 本质是 Redis 的数据快照,这种方式是最常见、最稳定的数据持久化手段

Redis 中 RDB 的触发方式有四种:

Redis 通过 fork() 创建一个子进程来执行 BGSAVE,配合写时复制技术,相当于异步执行,和主进程互不干扰,将对执行流程的影响降到最低

根据写时复制的性质,Redis 主进程在 RDB 期间进行的新的写操作不被记录在快照中,且会导致被修改数据的物理内存的复制
因此主进程在 RDB 期间对大 key 的写操作会导致内核花费较长时间复制物理内存,阻塞主进程

内存大页影响 redis 性能

Linux 内核从 2.6.38 开始支持内存大页机制,该机制支持 2MB 大小的内存页分配,而常规的内存页分配是按 4KB 的粒度来执行的
采用了内存大页,那么即使客户端请求只修改 100B 的数据,在发生写时复制后,Redis 也需要拷贝 2MB 的大页

AOF

# /etc/redis/redis.conf
appdendonly no # 是否开启AOF
appendenfilename "appendonly.aof"

Redis 每次执行写操作命令后将该命令记录至 AOF 日志

写入步骤:

  1. 将数据写入 sds 结构的 server.aof_buf
  2. 通过 write() 系统调用,将 aof_buf 对应数据写入内核缓冲区 PageCache
  3. 内核将数据写入磁盘

AOF 提供了alwayseverysecno三种写入磁盘的策略:

# appendfsync always # 每次记录命令后立刻写回磁盘
appendfsync everysec # 每秒将缓冲区刷入磁盘
# appendfsync no     # 不主动刷入,一般Linux系统每30秒刷入一次

3 种写回策略都无法完美解决主进程阻塞减少数据丢失的平衡问题

AOF 重写

AOF 日志文件大小随写操作命令增加而增大,Redis 可以在 AOF 日志体积变得过大时,自动地在后台创建一个子进程 bgrewriteaof 重写 AOF
重写机制合并针对相同 Key 的操作,根据键值对当前的最新状态用一条命令记录键值对,生成一个压缩后的 AOF 日志文件替代原文件

子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程
子进程带有主进程的数据副本,且不像线程需要通过加锁来保证共享数据的安全,导致降低性能
采用进程的方式的隔离性更好,如果线程崩溃会带来整个进程的崩溃,而子进程的崩溃对父进程不造成影响,提高在 AOF 日志重写和 RDB 快照生成时的安全性

子进程通过写时复制技术和父进程共享物理内存数据

重写过程中发生的新的写操作记录同时记录在 AOF 缓冲区AOF 重写缓冲区

TODO 新 AOF 文件创建完毕后

  • 以前的版本是主进程直接将 aof 重写缓冲区的数据写到新的 aof 文件
  • 后面用管道优化为主进程将数据通过管道传递给子进程,子进程将其写入磁盘,要是子进程结束后 aof 重写 buf 中还有剩余数据,主进程收尾将其写入新的 aof 文件中

重写缓冲区避免了父子进程写入同一文件而竞争文件系统锁进而对 Redis 主线程的性能造成影响

image.png|600

重写触发条件:

# /etc/redis/redis.conf
# 相比上次重写时候数据增长100%
auto-aof-rewrite-percentage 100
# 文件大小超过指定值
auto-aof-rewrite-min-size 64mb

重写阻塞

MP-AOF

RedisV7.0 采用了新的 AOF 重写方案 MP-AOF(多部件 AOF)
MP-AOF 将 AOF 文件分为 BASE AOFINCR AOF 两个文件
发生重写时 redis 将旧 BASE AOF 以及旧 INCR AOF 合并重写为新的 BASE AOF,同时新建一个 INCR AOF 文件以供 aof_buf 书写
Redis 通过 manifest 文件记录当前有效的 BASE AOF 以及 INCR AOF ,重写完成后通过更新该文件记录更新 AOF

MP-AOF 主要的意义在于减少了 aof rewrite buffer 的内存开销,以及发送和写入 aof rewrite buffer 时的 CPU 与 IO 开销

宕机

数据丢失:
Always 会丢失一个时间循环(epoll wait)里面的所有命令
Everysec 策略下可能会丢失 2s 的数据,因为如果当主线程尝试进行 write() 系统调用时发现 2s 内的上一次子线程的刷盘还未结束,主线程会跳过该次写入以避免阻塞,但如果超过 2s 则主线程会阻塞等待上一子线程刷盘结束

重写宕机:
V7.0 以前宕机恢复后可以使用旧 AOF 文件恢复数据
V7.0 以后使用旧 base aof + 旧 incr aof + 新 incr aof 文件恢复数据

混合持久化

V4.0 提出,开启方式:

aof-use-rdb-preamble yes

混合持久化作用于 AOF 重写阶段,AOF 重写日志时,重写子进程将与父进程共享的内存数据RDB 二进制形式写入 AOF 文件,再将重写缓冲区内容(重写过程中的新写操作命令)追加至 AOF 文件

c1618be1f307ae6abe68549b5831ee7f.png (431×761) (csdnimg.cn)

引入了混合持久化之后,使用 AOF 重建数据集时,会通过文件开头是否为“REDIS”来判断是否为混合持久化

事务

Redis 原生事务包含 MULTIEXECDISCARDWATCH 四个命令
MULTI 开启事务,开始输入事务指令
EXEC 执行事务
DISCARD 在执行前取消事务
WATCH 可以监控一个或多个键,一旦其中有一个键被修改(或删除),阻止之后的事务执行
e.g.

WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC

原理:redis 存储了一个包含命令队列的结构体,顺序执行任务

Redis 事务不具备原子性,仅能通过单线程的特性使得事务执行过程中其他操作无法执行

在 EXEC 命令前的语法错误命令会导致事务不执行直接返回错误,在事务执行时运行错误的命令不影响其他命令的执行

问题:

Lua 事务

V2.6 后 Redis 通过内嵌支持 Lua 环境,通过EVAL命令原子性执行脚本

优点:

原子性保证
Redis 使用相同的 Lua 解释器来运行所有的命令
Redis 保证脚本以原子方式执行:在执行脚本时,不会执行其他脚本或 Redis 命令,从所有其他客户端的角度来看,脚本的效果要么仍然不可见,要么已经完成

Attention

如果 Lua 执行出错,可能出现一部分命令执行,一部分没有执行的情况,因此实际未保证原子性

Lua 事务的优势
相比于原生的 Muti