Redis 学习笔记
Redis 是非常优秀的非关系型数据库,它的优点如下:
1,丰富的数据结构,支持更复杂的应用场景,Memcached 只支持简单 KV 2,有持久化机制(将数据保存在磁盘中) 3,以前是单线程(加 IO 多路复用),现在也支持多线程,Memcached 是多线程加锁机制 4,支持惰性删除与定时删除 5,可以用来做分布式锁(分布式情况下保证并发安全,使用 setnx 上锁、del 解锁、expire 设置过期时间)
在开始之前,先来看看 redis 的主要问题是什么

# 事务
MySQL 的事务是非常标准的事务,满足 ACID 特性,redis 的事务和 MySQL 的不太一样,我们一般使用 lua 脚本来代替它
# 基础使用与实现
redis 因为是单线程执行,所以它的事务会将一些操作加入队列,然后串行的执行事务,每个事务执行过程中不会被其他操作打断
事务的基本操作有四大命令,multi、discard、exec 与 watch:
multi 命令用来将一些命令加入队列,redis 在入队时会有检查,如果这些中有一个出错,那么所有的命令都不会执行成功。但是入队时的检查非常有限,就和编译时检查一样,它只能检查到某些命令不存在之类的错误,并且拒绝执行所有的命令
事务指令是分阶段提交的,不是一次性全部发送的。客户端在事务开始时发送 MULTI,之后的所有命令会逐个发送给 Redis,但 Redis 不会立即执行这些命令,而是将它们缓存在一个队列中。当客户端发送 EXEC 时,Redis 会一次性按顺序执行队列中的所有命令,并返回所有结果
discard 用来取消这个队列
exec 命令用来让队列中的命令执行
我们知道 redis 的实现大部分功能并不复杂,因此功能实现的重点都是关于数据的储存操作,比如这三个命令在 redis 底层的实现,事实上非常简单
multi 命令会将客户端数据结构中的某个标识打开,即 client.flags = REDIS_MULTI。在该标识打开后,redis 只会执行四大命令,其他的所有命令都会被存放在一个队列中。discard 就是取消这个标识,exec 则是遍历执行队列中的内容,并且执行一些清除标记、清除队列这样的收尾工作
反正 redis 遇到大部分需求,都是用数据结构解决的,就像我们不管遇到什么需求,都是用数据库解决的一样
# 关于 ACID 特性
简单分析一下 redis 的 ACID 特性
redis 的事务满足隔离性,事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断,得益于 redis 是单线程的原因,满足隔离性并不困难
不满足原子性指,执行事务过程中,如果有错误的命令 radis 也会继续执行之后的命令,并且不支持回滚和重试。如果此时队列中有命令出错,其他正确的命令也会执行成功,因此 redis 不满足原子性
关于持久性,redis 是内存数据库,就算有 RDB、AOF 这些落库的机制,也不能实时的保证数据的持久性
关于一致性,redis 有很多验错机制,在事务执行之前会进行语法判断,在事务执行时则会排除错误的语句。但是 redis 中没有补偿和回滚,因此如果事务执行一半机器挂了,恢复的时候还是会恢复前一半事务的数据,此时不保证一致性
# 监视命令以及实现
如果在某个事务执行之前,另外一个客户端将某个事务需要修改的键删除了,这时出现了意料之外的多线程错误,我们如何解决这种错误呢?
redis 只能使用乐观锁的方式来保证多线程情况下事务的执行不会出现修改丢失的情况,不可以直接使用悲观锁,因为不提供悲观锁
watch 命令用于监视一个或多个 key ,如果在事务执行中这些 key 被其他命令所改动,那么事务将被打断
底层实现是每个 redis 的数据库都有一个 watched_keys 字典,该字典就是用来实现 watch 功能的。字典的键是被监视的数据库键,而字典的值是要一串记录了所有监视该键的客户端列表
typedef struct redisdb {
dict *watched_keys;
}
2
3
在数据库被修改的时候,redis 会额外在该字典中判断一次,是否有键被监视了,如果被监视了,将对应列表中的客户端中的某个标记置为 true。而在客户端想要执行事务时,会先判断该标记
# 为什么不支持回滚
redis 没有原子性的原因,就是因为没有一种合理的手段进行回滚,MySQL 是使用 redo 日志来实现回滚操作的
上面也说过了,redis 没有事务回滚功能,因此不保证数据库原子性,为什么这么设计呢,redis 官方对此是这么解释的:
redis 一般来说,发现错误会有两种情况:
1,运行时异常,在事务编译中执行错误命令,redis 不会开始这个事务,Redis 命令只会因为错误的语法而失败,或是命令中用在了错误类型的键上面。关于这个问题如何解决:
this means that in practical terms a failing command is the result of a programming errors, and a kind of error that is very likely to be detected during development, and not in production
就是说这个错误在开发过程中很可能检测到,而不是在生产环境中
2,事务执行一半,Redis 宕机,这个可以通过 AOF 日志文件解决,不需要回滚
还有一方面原因是:
An argument against Redis point of view is that bugs happen, however it should be noted that in general the roll back does not save you from programming errors. For instance if a query increments a key by 2 instead of 1, or increments the wrong key, there is no way for a rollback mechanism to help.
这段话大致是说回滚无法解决编程错误,不过说实话,缓存里的数据可以失效删除,也没有数据回滚的必要
同时还有一部分理由,不支持回滚是因为回滚这种复杂的功能和 redis 追求简单高效的设计主旨不相符
# 秒杀
在这个案例中需要特别关注两点,商品库存的减少和用户的增加
需要考虑的其他问题如下:
1,使用连接池进行 redis 连接 2,用户的 ID 以及商品的 ID 是否合法 3,判断用户是否重复秒杀
如果以上条件都通过的话,进行秒杀的多线程同步操作,使用乐观锁对商品进行监视,进行事务操作时需要额外进行两步判断:
- 秒杀是否开始,当 redis 中商品为 null 时没有开始
- 秒杀是否结束,当 redis 中商品为0时秒杀结束
判断通过后执行商品库存的减少和用户链表增加的操作
在商品数量较多的情况下可能会出现库存遗留的情况,这个问题是因为乐观锁的特性导致的,使用 lua(洛)脚本解决这个问题
# Pipeline 和原生批处理命令
Pipeline 是把多个命令打包发送,减少网络往返。这种管道机制传输数据会比较快
import redis
import time
r = redis.Redis()
start = time.time()
pipe = r.pipeline() ## 创建管道
for i in range(1000):
pipe.set(f'key{i}', f'value{i}')
pipe.execute() ## 一次发送所有命令
end = time.time()
print(f"Pipeline方式: {end-start:.3f}秒") ## 约 0.05秒,快40倍!
2
3
4
5
6
7
8
9
10
11
12
13
原生批处理命令会一个命令更改多个 key,执行的更快
import redis
import time
r = redis.Redis()
## MSET 是原生批处理命令
start = time.time()
data = {}
for i in range(1000):
data[f'key{i}'] = f'value{i}'
r.mset(data) ## 一个命令,设置1000个键值对
end = time.time()
print(f"MSET方式: {end-start:.3f}秒") ## 约 0.01秒,最快!
2
3
4
5
6
7
8
9
10
11
12
13
14
# 单线程
redis 的主要功能是通过 IO 多路复用的单线程完成的,因为 Redis 的瓶颈不是 CPU 的运行速度,而往往是网络带宽和机器的内存大小。并且,单线程切换开销小,容易实现,没有并发问题。既然单线程容易实现,那就顺理成章地采用单线程的方案
# 文件事件处理器
Radis 使用 Reactor(反应器)模式开发了文件事件处理器,用来处理文件事件。文件事件处理器使用 I/O 多路复用来同时监听多个套接字(客户端连接),它的实现是基于事件驱动的非阻塞 I/O 模型。这意味着它可以在不等待 I/O 操作完成的情况下处理其他请求
当被监听的套接字准备好执行连接操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件,这是大致的流程
客户端发起操作数据库请求,通过 TCP 连接发送命令,redis 按照顺序处理请求,接收请求参数,接收完成后解析请求协议,得到相应的事件,然后将事件塞入一个事件处理队列中。这里的解析包含接受完整请求,网卡将数据接受到接受缓冲区,然后通过 epoll 将缓冲区中的数据复制到内核空间中(可能也会复制到用户空间中,和用没用 mmap 有关)
这么做是为了解耦,让主线程只关心最重要的处理逻辑,redis 的主线程会不断的轮询这个事件处理队列,每次读到事件,都会根据事件去找到对应的事件处理函数,在队列中的数据是可以直接处理的,然后执行请求命令,即操作 redis 数据库,之后将结果返回给客户端,这就是 redis 单线程处理数据的过程
这里的流程相对底层,涉及 Linux IO 相关知识
# 为什么 Redis 是单线程的
redis 的主要逻辑处理是单线程的,但是 IO 操作还是有其他的线程去实现的
1,使用 IO 多路复用来提高 IO 效率 2,主要限制是 IO 的速度 3,多线程会增加 CPU 负担,比如上下文切换的损耗,还会出现线程安全问题,比如死锁
同时,redis 在6之后支持了多线程的功能,不过默认是关闭的,需要手动开启。引入多线程的目的是为了处理 IO 和后台任务处理,即读取命令和将文件拷贝到套接字的时候会用到多线程
在 redis 6之前,一些消耗时间的 IO 操作也不是主线程来完成的,比如持久化或者大 key 删除操作
# 文件事件和时间事件
redis 的 main 函数不停的轮询,等待事件的到达并且执行事件,Redis 执行的事件分两种:
1,文件事件:指对网络请求数据读取以及解析,数据的回复 2,时间事件:又分为让一段程序在指定的时间之后执行一次的定时事件,以及每隔一段时间执行一次的周期事件。比如键过期检查、持久化、统计更新等
如何区分定时与周期:如果方法的返回值为 ae.h/AE_NOMORE(-1),则这是定时事件,如果返回值为一个正整数 x,则代表每隔 x 毫秒执行一次的周期事件
#define AE_NOMORE -1
时间事件的执行如下:
服务器将所有时间都放在一个单向链表中,每当时间事件执行器运行时,它遍历整个链表查找所有已到达的时间事件,并调用相应的时间处理器
无序指的是不按照执行先后顺序进行排序,这就是为什么需要遍历这个链表,redis 敢这么实现就是因为这个链表很短,redis 会按照 id 排序,由于新创建的时间事件是插入到链表的表头,所以链表中时间事件是按照 id 属性降序排序的
每一次轮询事实上是执行一次 aeProcessEvents 函数,该函数就是轮询的最小单位。该函数的过程如下:
先拿到从现在开始到最近需要执行的任务的开始时间,时长定位 T,这段时间就是属于文件事件的处理时间,时间事件会一直阻塞。而等到需要执行时,一般会有部分文件事件没有执行完,这时会让这些文件事件执行完之后再进行时间事件
int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
int processed = 0, numevents;
/* Nothing to do? return ASAP */
if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;
/* 获取离当前时间最近的时间事件,计算还有多少毫秒到达 */
/* 如果毫秒为负数,说明已经到达,将其设置为0,并且不等待文件事件产生 */
if (eventLoop->maxfd != -1 ||
((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
......
}
/* 等待文件事件产生,最大的阻塞时间为最近的时间事件到达时间 */
numevents = aeApiPoll(eventLoop, tvp);
/* 执行所有的文件事件以及时间事件 */
if (eventLoop->aftersleep != NULL && flags & AE_CALL_AFTER_SLEEP)
eventLoop->aftersleep(eventLoop);
for (j = 0; j < numevents; j++) {
......
}
if (flags & AE_TIME_EVENTS)
processed += processTimeEvents(eventLoop);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
由于执行完文件事件才处理时间事件的这个特性,时间事件的实际处理时间会比设定时间稍微晚一点
在多线程之中,时间事件会将非常耗时的持久化操作放到子线程或子进程中执行,例如主从复制、rehash 等比较重要的时间事件还是会在主线程中处理的
# 内存管理
内存管理是指数据库如何删除管理已经分配的内存空间
# 为什么要设置过期策略
1,数据储存在内存中,不及时清理内存就会满 2,内存过期这个特性也有很多应用场景,比如验证码
# 如何储存数据的过期时间
有一个过期字典,过期字典就是 redisDb 结构中的 expires
该字典是一个键值对,键指向数据存放的地址,因此不会造成内存浪费,值指向一个 longlong 型的数,表示距离过期还有多长时间,具体来说,它是一个毫秒精度的 UNIX 时间戳
正常的 UNIX 时间戳是从1970年1月1日(UTC/GMT 的午夜)开始所经过的秒数,不过 redis 用的是毫秒数,比如2022-01-02 22:45:02过期则 value 为1641134702000
当然,引入这个过期时间肯定对 redis 的其他功能有影响,比如在持久化与磁盘输出到内存时对过期数据的判断,在集群复制时如何处理的等等
# 过期删除策略
既然有了判断数据过期时间的方法,那过期删除策略又是怎么样的呢,这个实现与时间相关,即到达一定时间后会发生什么事情,一般来说有以下三个思路:
1,定时删除:对于每个数据都生成一个定时器,在时间到了的时候执行删除语句 2,惰性删除:只有使用到这个数据时,才判断是否过期 3,定时定量删除:定时定量判断一些数据是否过期,这个定量是使用随机函数选择出一些时间进行判断
在 redis 中,使用的是单线程处理,并且自己实现了时间事件的处理方式——无序列表,遍历起来特别消耗时间,因此定时删除肯定是不行的了,在 redis 中删除策略是由两种组成的:
1,惰性删除 2,定期删除:redis 在执行这个操作时会消耗 cpu,导致请求卡顿或超时,因此执行定期删除的函数需要考虑到执行多长时间。同时如果发现过期 key 的比例超过 25%,Redis 会继续抽取新的批次,直到比例低于 25% 或达到时间限制
# 内存淘汰机制
不过只使用过期删除清理内存肯定是跟不上内存消耗速度的,很快 redis 就会被充满。应该设置更多操作来保证内存不会 OOM,这就引出了内存淘汰机制
内存淘汰(过期删除远远不能满足大量的数据删除): Radis 提供了8种内存淘汰机制 (使用最多) 1,allkeys-lru:从整个内存中选择最近最久未使用使用的淘汰
(设置了过期时间) 2,volatile-ttl:从已设置过期时间的数据集中选出将要过期的淘汰 3,volatile-random:从已设置过期时间的数据集中随机淘汰部分 4,volatile-lru:从已设置过期时间的数据集中选出最近最少使用的淘汰 5,volatile-lfu:从已设置过期时间的数据集中挑选最不经常使用的数据淘汰
(奇奇怪怪) 6,allkeys-random:随机淘汰任意 7,no-eviction:禁止驱逐数据,超过内存会抛出异常 8,allkeys-lfu:选择最不经常使用的数据淘汰
LFU 算法,最不经常使用淘汰算法,有一个时效性问题。LFU 根据使用次数来淘汰数据,次数越多的为热数据。次数越少的越容易淘汰
但是 LFU 算法有个很明显的缺陷,就是只考虑了次数,没有考虑时间。那假如之前有个很热的数据,访问频率都是之前访问增加的,但是随着时间过去,这个热数据没人访问了,不再热了,但是因为之前的次数很高,就会一直占有内存而不被淘汰
Redis如何解决这个问题的? redis 为了去记录每个对象的访问次数,在 RedisObject 对象 (server.h文件) ,也就是我们的数据对象里面有个 LRU 的字段。最后肯定会基于次数来淘汰,但是会基于时间去做衰减次数的操作,假如,这个对象的最后访问时间是5min前,就可以根据 lfu-decay-time 配置去衰减一定的访问次数。lfu-decay-time 代表多少分钟没操作就去衰减1次次数
# 内存碎片
内存碎片是指被 redis 申请的内存空间中,没有使用的一块区域。内存碎片不会对 redis 的使用产生什么性能问题,就是会浪费系统资源,导致 Redis 服务器内存不够用。通常以下几种方式都会产生内存碎片:
1,redis 向操作系统申请内存时,申请的内存空间事实上比实际使用的内存空间要大,这些多余的内存空间本来是为了应付某些特殊情况,不过也因此产生了内存碎片
2,频繁修改 Redis 中的数据会产生内存碎片,当 Redis 中的某个数据删除时,Redis 通常不会轻易释放内存给操作系统。或者当 String 的变长字符串,在字符变长之后不会因为把存放的字符串修改短了而将这个变长字符串也变短
有一个公式可以计算内存碎片
mem_fragmentation_ratio (内存碎片率)= used_memory_rss(操作系统实际分配给 Redis 的物理内存空间大小)/ used_memory(Redis 内存分配器为了存储数据实际申请使用的内存空间大小)
注意,used_memory 是 Redis 内存分配器申请使用的内存空间大小,而不是实际使用的内存空间大小。因此这个值可能过大也可能过小
说了这么多,如何去管理内存碎片呢(重启后内存重新分配,碎片归零),redis 内部又如何处理内存碎片呢:
1, jemalloc(redis 的内存分配器)会尝试将相邻的空闲内存块合并成更大的空闲块
可以使用 info memory 命令查看 Redis 内存相关的信息,其中 mem_fragmentation_ratio 就是内存碎片率。同时,管理内存碎片只要一些简单的配置就可以了。自动碎片清理,只要设置了如下的配置,内存就会自动清理了。Redis 主线程会逐步移动数据,合并空闲内存块,注意可能增加 CPU 开销
config set activedefrag yes
具体的做法是 Redis 的后台碎片整理线程会周期性地扫描内存,寻找存储了数据且可以移动的键值对,以及它们周围的空闲内存块,如果碎片程度超过阈值,Redis 会为这个键值对分配一块新的、连续的内存空间,并且进行复制迁移
如果想把 Redis 的配置,写到配置文件中去
config rewrite
2,如果你对自动清理的效果不满意,可以使用如下命令,直接试下手动碎片清理
memory purge
# bigkey
bigkey 指的是大小或者元素超过一定数量的 value 所对应的 key,它会占用很多内存空间,但是它的危害不止于此,bigkey 对性能也会有比较大的影响
String 类型 value > 100KB
Hash/Set/ZSet 成员数 > 5000
List 类型 元素数 > 5000
由于单线程的特性,大 key 在操作的时候比较慢,比如读取 string、取 list 中从100到1000的数据什么的
在读取时,序列化时间大大增加,持久化时间也大大增加。尤其时 rdb 使用写时复制技术,fork 子进程时需要复制100MB内存导致延迟飙升
一般来说有大 key 运维同学会给我们报警,让我们及时感知到。同时我们在程序开发时应该注意这个问题。不要使用时间复杂度为 n 的命令,读取或者删除 bigkey 的时候,可以一部分一部分的读或者一部分一部分的删,比如一个大 map,我们先读部分 key,直到把 map 读完;或者说看看能不能将这些数据拆分的更细一些
# 持久化
持久化指按一定方法或者频率将数据写到内存中,这么做的目的是为了解决 redis 故障后恢复数据
# RDB
也就是全量快照,创建某一时刻的数据库副本复制到磁盘中以此来完成持久化操作,在 redis 重启或者启动的时候会读取快照内容,这个 RDB 也可以用来做集群操作
rdb 之后会生成一个快照文件,该文件是经过压缩的二进制文件(Redis 默认采用 LZF 算法对 RDB 文件进行压缩)。通常有两种命令可以让数据库生成快照文件。一个是 save 命令,主线程执行,创建快照时会阻塞主线程。另一个是 bgsave 子线程执行,不会阻塞主线程,触发时机如下,下面的参数可以动态配置
## redis.conf 配置示例(默认)
save 900 1 ## 900秒(15分钟)内至少1个key被修改
save 300 10 ## 300秒(5分钟)内至少10个key被修改
save 60 10000 ## 60秒内至少10000个key被修改
2
3
4
在执行 RDB 时操作采用写时复制技术,是为了解决子线程进行 rdb 复制时有数据修改的问题,过程是:
1,主线程 fork() 出一个子进程(子线程),子进程负责将内存数据写入 RDB 文件。fork() 后,子进程获得主进程内存的快照(逻辑上独立,物理上共享内存页) 2,如果主线程修改了某块内存数据(如执行 SET、DEL),操作系统会先复制该内存页,确保子进程看到的是 fork() 时的数据快照,而主线程在复制的内存页上修改。子进程写入的 RDB 数据是 fork() 瞬间的一致性快照,不受后续写操作影响
# 自动间隔保存
Redis 的自动间隔快照功能是判断在 XXX 时间间隔内发生了 XX 次数据变化就进行快照操作,一般使用的是 bgsave(自动快照),也可以手动快照,不过不推荐使用手动,因为手动会阻塞主线程的工作,当快照比较大时对性能影响是非常大的,会间断性暂停服务。而自动则是生成一个子线程来做这个事情
可以向数据库提供配置:save 900 10
该命令的意思是在900秒内对数据库做了10次修改就进行落库操作。这两个属性就是自动快照的主要属性,redis 使用 saveparams 数组来保存它们
struct redisServer {
// ...
struct saveparam *saveparams;//记录了save保存条件的数组
// ...
};
struct saveparam {
time_t seconds;//秒数
int changes;//修改数
};
2
3
4
5
6
7
8
9
10
数据库应该记录下上一次持久化的时间以及上一次持久化之后做了多少次修改操作才可以遍历数组,判断是不是应该进行自动持久化,因此 redis 中的 redisService 存放了这两个数据,并且100毫秒判断一次是不是应该持久化
struct redisServer {
// ...
long long dirty;//修改计数器
time_t lastsave;//上一次执行保存的时间
// ...
};
2
3
4
5
6
# RDB 文件结构
将文件放在磁盘或者在网络上传输就需要考虑读取的问题,计算机只储存01二进制文件,因此需要做的操作不过是那么几种,头标、尾标、计数、校验。rdb 文件也不例外
文件开头魔数五字节标记这是一个 redis 文件,db_version 四字节是指 redis 的版本
databases 就是储存数据库的地方,EOF 是一个特殊标记,1字节,标志 RDB 文件正文结束,check_sum 是一个8字节长的无符号整数,保留一个校验和。这样除了计数全都有了,计数一般出现在不定长的数据中,而数据库储存的数据一般都是不定长的,因此 databases 部分一定会出现计数
selectdb 表示接下来的数据属于哪个数据库的,pairs 文件中存放着各种数据
在 pairs 中,可以存放带过期时间的数据以及不带过期时间的数据,用一个特殊标识 EXPIRETIME_MS 来标记该数据是否带过期,ms 则表示过期时间。TYPE 表示数据类型,比如字符串键、哈希键等,KV 表示键值对,当然还包含一些编码方式等等
再读取 RDB 文件的时候不会全部读取到内存中,在读取带有过期时间的数据时,会先判断该数据是否已经过期,如果已经过期则直接删除该数据
# AOF
开启 AOF 后会将更改 Redis 中的数据的命令写入硬盘中的 AOF 文件,也就是增量保存,一般推荐每秒写入磁盘一次。解决了数据持久化实时性的问题
# 载入与读取的实现
AOF 即 append(追加)only file,当服务器执行完一个写命令时,会以协议的格式将被执行的命令追加到 redisService 结构体的 aof_buf 末尾
struct redisService{
sds aof_buf;
}
2
3
这里的 sds,就是 AOP 缓冲区了(如果每次写命令都追加写硬盘的操作,那么 Redis 的响应速度还要取决于硬盘的 IO 效率,显然不现实,所以 Redis 将写命令先写到 AOF 缓冲区),记录这些东西,可以阅读但是不清晰
*2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n
*3\r\n$3\r\nSET\r\n$3\r\nmsg\r\n$5\r\nhello\r\n
*5\r\n$4\r\nSADD\r\n$6\r\nfruits\r\n$5\r\napple\r\n$6\r\nbanana\r\n$6\r\ncherry\r\n
2
3
可以使用 appendfsync 来配置 aof 写入时机,可以配置为按秒写入磁盘,也可以配置每次有数据修改发生时都会写入 AOF 文件。默认为按秒写入磁盘
以上是 AOF 写入的实现,redis 的载入是通过一个虚拟的伪客户端来执行的。载入文件时,会生成一个不带网络连接的伪客户端,然后将 AOF 文件中的命令一条条的发生给服务器执行
# AOF 重写
AOF 由于记录了所有的命令操作,它会比 RDB 文件大不少,甚至有可能装满磁盘,文件的体积越大,读取文件时消耗的时间就越多,同时很多很早之前写入的数据,他们的 key 都过期了没用了,重写就是为了解决体积过大的问题
AOF 重写是通过读取数据库中的键值对来完成的,根据现在数据库内容生成需要写入的键值对,即生成新的 AOF 文件,新的 AOF 还是记录逻辑日志。同时,这个文件的生成需要大量的写入,调用这个方法的线程会执行很长一段时间,因此 redis 会生成子进程来进行重写操作。自动触发机制如下
auto-aof-rewrite-percentage 100 ## AOF文件比上次重写后增长100%时触发
auto-aof-rewrite-min-size 64mb ## AOF文件至少64MB才触发
2
重写在执行的时候会遇到一个问题,此时用户会修改数据库内容,如果不对重写的文件进行更新,会造成数据不一致。因此 redis 在重写时,会额外维持一个重写缓冲区,在修改数据库之后不仅会将记录添加到缓冲区中,还会添加到重写缓冲区中。在子进程完成自己的工作以后会发送消息给父进程
父进程会将重写缓冲区中的内容附加到 AOF 文件末尾,并对该 AOF 文件改名,原子的覆盖以前的 AOF 文件
# RDB AOF 混用
Redis 目前已经支持 AOF 和 RDB 两种方式一起使用,如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头,通过配置 aof-use-rdb-preamble yes 来完成
因为 RDB 记录的是内存快照,其大小比 AOF 小的多
这样恢复时,先加载 RDB 部分(快速恢复基础数据),再重放少量 AOF 命令(保证最新状态)
# redis 提供的其他功能
# 异步机制
这个是 redis 的一个重要优化,我们通常说,Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的,因为这些功能比较花费时间
举个例子来说,redis 的异步删除机制是主线程通过一个链表形式的任务队列和子线程进行交互
当 Redis 实例收到 key-value 删除和清空数据库的操作时,主线程会把这个操作封装成一个任务,放入到任务队列中,然后给客户端返回一个完成信息,表明删除已经完成
这个时候删除操作还没有执行,等到后台子线程从任务队列中读取任务后,才开始实际删除键值对,并释放相应的内存空间。因此,我们把这种异步删除也称为惰性删除(lazy free)
和惰性删除类似,当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到任务队列中。后台子线程读取任务后,开始自行写入 AOF 日志,这样主线程就不用一直等待 AOF 日志写完了
# 发布与订阅
redis 提供了发布订阅模式,该模式指的是多个客户端在同一台主机上可以订阅多个频道。当其中一个客户端向某个频道发消息的时候,所有订阅该频道的客户端都可以收到信息,可以使用命令退订频道,其底层实现比较简单
向服务器发送 subscribe "news.it" 命令即可订阅 news.it 频道,如果在 redis 里没有该频道,则会创建该频道。PUBLISH 命令,此命令是用来发布消息。底层使用一个 dict 字典来存放所有的频道,值为 redisClient 列表表示所有的订阅者
struct redisClient{
// 字典的键为频道,值为列表,存放客户端状态指针的列表
dict *pubsub_channels;
// 列表,存放客户端订阅模式的列表
list *pubsub_patterns;
......
}
2
3
4
5
6
7
redis 除了支持订阅频道以外还支持订阅模式,模式可以包含多个频道,比如 index.io 与 index.ht 都是频道,而 index.* 是模式。使用命令可以订阅模式,底层实现是将客户端与模式的对应关系存放进列表,每次向频道发送消息的时候都会额外遍历模式列表,向满足条件的模式发送消息
这项功能经常由 mq 来完成,因此发布订阅不是很重要
# 键空间通知
这个不是 redis 实现乐观锁处理事务并发问题的监视器,而是将 redis 的客户端当成一个整体去监视一个服务器,该功能的作用是,当某个 redis 的服务器执行任何命令时,会将命令发送给监视该服务器的每一个客户端
先服务器发送 montor 请求会让该客户端成为监视者
该命令的底层实现非常简单,首先在该客户端对应的 redisClient 中设置监视者标识,然后将该客户端指针放进服务器数据结构中的监视者列表。每次执行命令的时候,都会先遍历监视者列表,向每个客户端发送命令
redis 的这种实现在这个设计中至少有3个,即将指针放入列表,发送什么的时候遍历链表。比如发布与订阅,比如事务的乐观锁,它们的实现都差不多
当订阅事件的客户端断线时, 它会丢失所有在断线期间分发给它的事件。Redis 的键空间通知提供了类似 Watcher 的基础功能,但 缺乏 ZooKeeper 的强一致性和可靠性
# 慢查询日志
redis 提供慢查询日志来为我们查找需要优化的命令,其实现非常简单,但是所提供的功能却无比重要
我们可以修改 Redis 为慢查询对应提供的两个参数:slowlog-log-slower-than 和 slowlog-max-len 来更改慢查询规则,slowlog-log-slower-than 用于表示命令多久时间将该命令记录到日志中,而 slowlog-max-len 表示最多存放多少条日志,如果日志数目超过了这个数字,redis 自动将最久的一条除去。我们可以直接发送 config set 命令更改这两个参数
config set slowlog-log-slower-than 1000
config set slowlog-max-len 1200
2
也可以直接修改配置文件
slowlog-log-slower-than 1000
slowlog-max-len 1200
2
可以使用 slowlog get 命令获取慢查询日志,在 slowlog get 后面还可以加一个数字,用于指定获取慢查询日志的条数
每一条慢查询日志都有4个属性组成:
- 唯一标识 ID
- 命令执行的时间戳
- 命令执行时长
- 执行的命名和参数
因此,redis 在底层对慢查询的实现是列表方式,每个列表都是包含这四个属性的结构体。redis 在命令执行的前后做 AOP 操作,在命令开始时,会记录当前的时间戳,命令执行结束时,将结束时的时间戳减去开始的时间戳,就可以得到命令执行时长。用该值判断是否超过了用户配置的 slowlog-log-slower-than,并且决定是否将其记录在数组中
# 查出 reids 中以某个固定的已知的前缀开头的所有数据
1,我们可以使用 keys 命令,但是一般禁止使用 KEYS 命令,因为 KEYS prefix:* 会阻塞集群,导致所有节点线性扫描所有 Key,引发性能雪崩 2,SCAN 是增量式迭代器,不会阻塞集群,适合生产环境。SCAN 用于遍历当前数据库中的所有键,不过是以窗口形式遍历的 3,可以使用 RedisSearch(官方模块)或外部数据库(如 Elasticsearch)建立二级索引。这样可以支持复杂查询(如前缀、全文搜索),性能极高