一把年纪了还是这么菜
本文主要学习自:Redis 核心技术与实战
Redis 是一个高性能的 Key-Value 数据库,key 的类型是字符串,value 的类型有:string 字符串、list 列表、set 集合、sortedset(zset) 有序集合、hash 、bitmap 位图等。
相对一般的键值数据库, Redis 的 Value 类型丰富,有 2 种持久化方式并且支持集群。
Reids 的 String 能表达 3 种值的类型:字符串,整数和浮点数。
命令名称 | 命令格式 | 命令描述 |
set | set key value | 赋值 |
get | get key | 取值 |
setnx | setnx key value | 当 key 不存在时才赋值,可用于实现分布式锁。 set key value NX PX 3000 原子操作,px 设置毫秒数 set age 28 NX PX 10000 不存在则赋值,有效期 10 秒 |
incr | incr key | 递增数字,可用于实现乐观锁 |
decr | decr key | 递减数字 |
1 开发一个图片存储系统,要求这个系统能快速地记录图片 ID 和图片在存储系统中保存时的 ID。同时,还要能够根据图片 ID 快速查找到图片存储对象 ID。
因为图片数量巨大,所以我们就用 10 位数来表示图片 ID 和图片存储对象 ID,例如,图片 ID 为 1101000051,它在存储系统中对应的 ID 号是 3301000051。
- set photo_id photo_obj_id
- set 1101000051 3301000051
问题来了,上述例子中保存 1 亿张图片的信息,用了约 6.4GB 的内存,一个图片 ID 和图片存储对象 ID 的记录平均用了 64 字节。而一组图片 ID 及其存储对象 ID 的记录,实际用两个 8 字节的 Long 类型就可以表示,因为 8 字节的 Long 类型最大可以表示 2 的 64 次方的数值。
但是,为什么 String 类型却用了 64 字节呢?其实,除了记录实际数据,String 类型还需要额外的内存空间记录数据长度、空间使用等信息,这些信息也叫作元数据。当实际保存的数据较小时,元数据的空间开销就显得比较大了。
List 列表类型可以存储有序、可重复的元素,按元素进入 List 的顺序进行排序。
命令名称 | 命令格式 | 命令描述 |
lpush | lpush key v1 v2 v3 | 从左侧插入列表 |
lpop | lpop key | 从左侧弹出 |
rpush | rpush key v1 v2 v3 | 从右侧插入列表 |
rpop | rpop key | 从右侧弹出 |
1) 作为栈或队列使用
2) 适用于展示最新评论列表、排行榜等场景
如:每个商品对应一个 List,这个 List 包含了对这个商品的所有评论,而且会按照评论时间保存这些评论,每来一个新评论,就用 LPUSH 命令把它插入 List 的队头。
Redis 的 Set 是 String 类型的无序集合。集合成员是唯一不可重复且无序的。
Redis 中集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。
命令名称 | 命令格式 | 命令描述 |
sadd | sadd key mem1 mem2 .... | 为集合添加新成员 |
spop | spop key | 返回集合中一个随机元素,并将该元素删除 |
srandmember | srandmember key | 返回集合中一个随机元素,不会删除该元素 |
sinter | sinter key1 key2 key3 | 求多集合的交集 |
sdiff | sdiff key1 key2 key3 | 求多集合的差集 |
sunion | sunion key1 key2 key3 | 求多集合的并集 |
1)聚合统计:统计多个集合元素的聚合结果,如统计多个集合的共有元素(交集统计);把两个集合相比,统计其中一个集合独有的元素(差集统计);统计多个集合的所有元素(并集统计)
具体例子:统计手机 App 每天的新增用户数和第二天的留存用户数
用一个集合记录所有登录过 App 的用户 ID,key="user:id",value 是一个 Set 集合,存放用户 ID
再用一个集合存放每天登录的用户 ID,key="user:id:当天日期的时间戳",value 是一个 Set 集合,存放用户 ID
假设 App 在 2020 年 8 月 3 日上线,那么,8 月 3 日前是没有用户的。此时,累计用户 Set 是空集,当天登录的用户 ID 会被记录到 key 为 user:id:20200803 的 Set 中。所以,user:id:20200803 这个 Set 中的用户就是当天的新增用户。然后,我们计算累计用户 Set 和 user:id:20200803 Set 的并集结果,结果保存在 user:id 这个累计用户 Set 中,如下所示:
SUNIONSTORE user:id user:id user:id:20200803
此时,user:id 这个累计用户 Set 中就有了 8 月 3 日的用户 ID。
等到 8 月 4 日再统计时,我们把 8 月 4 日登录的用户 ID 记录到 user:id:20200804 的 Set 中。接下来,我们执行 SDIFFSTORE 命令计算累计用户 Set 和 user:id:20200804 Set 的差集,结果保存在 key 为 user:new 的 Set 中,如下所示:
SDIFFSTORE user:new user:id:20200804 user:id
可以看到,这个差集中的用户 ID 在 user:id:20200804 的 Set 中存在,但是不在累计用户 Set 中。所以,user:new 这个 Set 中记录的就是 8 月 4 日的新增用户。当要计算 8 月 4 日的留存用户时,我们只需要再计算 user:id:20200803 和 user:id:20200804 两个 Set 的交集,就可以得到同时在这两个集合中的用户 ID 了,这些就是在 8 月 3 日登录,并且在 8 月 4 日留存的用户。执行的命令如下:
SINTERSTORE user:id:rem user:id:20200803 user:id:20200804
存在问题:Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致 Redis 实例阻塞。可以从主从集群中选择一个从库,让它专门负责聚合计算,或者是把数据读取到客户端,在客户端来完成聚合统计,这样就可以规避阻塞主库实例和其他从库实例的风险了。
元素有序不可重复
命令名称 | 命令格式 | 命令描述 |
zadd | zadd key score1 member1 score2 member2 ... | 为有序集合添加新成员 |
zcount | zcount key min max | 返回集合中score值在[min,max]区间 的元素数量 |
zrank | zrank key member | 获得集合中member的排名(按分值从 小到大) |
zrange | zrange key start end(-1返回全部) | 获得集合中指定区间成员,按分数递增排序 |
zrevrange | zrevrange key start end(-1返回全部) | 获得集合中指定区间成员,按分数递减排序 |
1)可以按分值排序,适用于各种排行榜,如:点击排行榜、销量排行榜、关注排行榜等
2)多维排序
具体例子:假设有5个app的下载量和最后更新时间分别如下
wechat-下载量:12,最后更新时间:1;其score为:12.1
qq-下载量:12,最后更新时间:2;其score为:12.2
tiktok-下载量:10,最后更新时间:3;其score为:10.3
taobao-下载量:11,最后更新时间:5;其score为:11.5
alipay-下载量:11,最后更新时间:4;其score为:11.4
将上述数据存到redis
- # 参考命令:zadd key score1 member1 score2 member2 ...
- zadd TopApp 12.1 wechat 12.2 qq 10.3 tiktok 11.5 taobao 11.4 alipay
递减排序
- # 参照命令:zrevrange key start end
- zrevrange TopApp 0 -1
如果有三维排序,四维排序呢?可以自定义得分权重计算公式 ,这个公式包含所有影响排序的因子,例如:downloadCount*1000 + updatedTime
Redis hash 是一个String类型的field和value的映射表,插入和查询的复杂度为 O(1),但不支持对数据进行范围查询
命令名称 | 命令格式 | 命令描述 |
hset | hset key field value | 赋值,不区别新增或修改 |
hmset | hset key field1 value1 field2 value2 | 批量赋值 |
hget | hget key field | 获取一个字段值 |
hmget | hmget key field1 field2 | 获取多个字段值 |
hdel | hdel key field1 field2 | 删除一个或多个字段 |
hlen | hlen key | 获取字段数量 |
hgetall | hgetall key | 获取指定 key 的所有字段和值 |
1)适用于对象的存储
具体例子1:下图为用户的余额信息
存储上述用户数据 key = 用户id,field1 = name,field2 = balance
- #参考命令 hmset key field1 value1 field2 value2
- hmset user:1 name wyd balance 1888
- hmset user:2 name hk balance 110
- hmset user:3 name dd balance 800
2)常用于购物车场景
key = 用户id field = 商品id value = 商品数量
购物车操作
- # 添加商品 hset key field value
- hset cart:userid skuid 1
- # 增加数量 hincrby key field increment
- hincrby cart:userid skuid 1
- # 获取商品总数 hlen key
- hlen cart:userid
- # 删除商品 hdel key field1
- hdel cart:userid skuid
- #获取购物车所有商品 hgetall key
- hgetall cart:userid
3)用于存储商品库存信息
传统的库存信息一般在mysql中存储,在云mall的零售场景中,由于大促和秒杀场景频繁出现,并发查询和并发扣减会给mysql带来明显性能问题,所以通过 redis 将库存信息保存起来,便于一次查询出库存的全部数值。
为什么使用hash结构?
hash 结构可以用一条redis命令取出可售库存数据,不需要在程序内进行反序列化操作,性能高,在2w并发下可以提高15%的性能。
bitmap是进行位操作的,通过一个 bit 位来表示某个元素对应的值或者状态,其中的 key 就是对应元素本身。 bitmap本身会极大的节省储存空间。
命令名称 | 命令格式 | 命令描述 |
setbit | setbit key offset value | 设置key在offset处的bit值(只能是0或者1) |
bitcount | bitcount key | 获得key的bit位为1的个数 |
getbit | getbit key offset | 获得key在offset处的bit值 |
1)统计活跃用户, 日期为key,用户id为偏移量 1表示活跃
2)查询用户在线状态, 日期为key,用户id为偏移量 1表示在线
3)用户每月签到,用户id为key , 日期作为偏移量 1表示签到
具体例子:假设要统计 ID 3000 的用户在 2022 年 12 月份的签到情况,就可以按照下面的步骤进行操作。
第一步,执行下面的命令,记录该用户 12 月 3 号已签到。
SETBIT uid:sign:3000:202212 3 1
第二步,检查该用户 12 月 3 日是否签到。
GETBIT uid:sign:3000:202212 3
第三步,统计该用户在 12 月份的签到次数。
BITCOUNT uid:sign:3000:202212
提到 Redis,我们的脑子里马上就会出现一个词:“快”。它接收到一个键值对操作后,能以微秒级别的速度找到数据,并快速完成操作。
为啥 Redis 能有这么突出的表现呢?
1)Redis 是内存数据库,所有操作都在内存上完成,内存的访问速度本身就很快。
2)Redis 底层采用高效的数据结构来存储键值对
底层数据结构一共有 6 种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。
Redis 使用了一个哈希表来保存所有键值对。
哈希桶中的 entry 元素中保存了 *key 和 *value 指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过 *value 指针被查找到。
哈希表能用 O(1) 的时间复杂度来快速查找到键值对——只需要计算键的哈希值,就可以定位到对应的哈希桶位置,访问相应的 entry 元素。
但是,往哈希表中写入的数据越来越多时,不可避免会产生哈希冲突。即两个 key 的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中。如上图所示:entry1、entry2 都需要保存在哈希桶 1 中,导致了哈希冲突。此时,entry1 元素会通过一个*next指针指向 entry2,同样,entry2 也会通过*next指针指向下一个 entry。这就形成了一个链表,也叫作哈希冲突链。
哈希冲突链上的元素只能通过指针逐一查找再操作,时间复杂度降为 O(n)。
对此,Redis 会对哈希表做 rehash 操作,也就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。
Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,哈希表的负载因子(哈希表中的元素/哈希表的大小)达到预设值,Redis 开始执行 rehash,这个过程分为三步:
到此,我们就可以从哈希表 1 切换到哈希表 2,用增大的哈希表 2 保存更多数据,而原来的哈希表 1 留作下一次 rehash 扩容备用。
由于第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。
为了避免这个问题,Redis 采用了渐进式 rehash。即把一次性大量拷贝的开销,分摊到了多次处理请求的过程中。
简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。如下图所示:
同时保留旧数组和新数组,然后在定时任务中以及后续对 hash 的指令操作中渐渐地将旧数组中挂接的元素迁移到新数组上。这意味着要操作处于 rehash 中的字典,需要同时访问新旧两个数组结构。如果在旧数组下面找不到元素,还需要去新数组下面去寻找,然后将结果融合后返回给客户端。
新增值的时候就只需要新增 hash2 中,因为最终的目的就是将所有值同步到 hash2 中。
压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。
整数数组和压缩列表在查找时间复杂度方面并没有很大的优势,那为什么 Redis 还会把它们作为底层数据结构呢?
1)内存利用率,数组和压缩列表都是非常紧凑的数据结构,它比链表占用的内存要更少。Redis是内存数据库,大量数据存到内存中,此时需要做尽可能的优化,提高内存的利用率。
2)数组对CPU高速缓存支持更友好,所以Redis在设计时,集合数据元素较少情况下,默认采用内存紧凑排列的方式存储,同时利用CPU高速缓存不会降低访问速度。当数据元素超过设定阈值后,避免查询时间复杂度太高,转为哈希和跳表数据结构存储,保证查询效率。
跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位,
上图要在链表中查找 33 这个元素,只能从头开始遍历链表,查找 6 次,直到找到 33 为止。此时,复杂度是 O(N),查找效率很低。
为了提高查找速度,增加一级索引:从第一个元素开始,每两个元素选一个出来作为索引。这些索引再通过指针指向原始的链表。此时,只需要 4 次查找就能定位到元素 33 了。
如果还想再快,可以再增加二级索引:从一级索引中,再抽取部分元素作为二级索引。这样,只需要 3 次查找,就能定位到元素 33 了。可以看到,这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是 O(logN)。
平时说的 Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。严格来说,Redis 并不是单线程,但是我们一般把 Redis 称为单线程高性能。
为什么用单线程?为什么单线程能这么快?这就与Redis的IO模型有关了。
多线程确实可以增加系统吞吐率,但是如果没有精细的设计,还会带来性能问题。
1)上下文切换
在实际开发中,线程数往往是大于 CPU 核心数的,比如 CPU 核心数可能是 8 核、16 核,等等,但线程数可能达到成百上千个。
这种情况下,操作系统就会按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。而在进行调度时就会引起上下文切换,上下文切换会挂起当前正在执行的线程并保存当前的状态,然后寻找下一处即将恢复执行的代码,唤醒下一个线程,以此类推,反复执行。
但上下文切换带来的开销是比较大的,假设任务内容非常短,比如只进行简单的计算,那么就有可能发生上下文切换带来的性能开销比执行线程本身内容带来的开销还要大的情况。
2)并发访问控制
并发访问控制一直是多线程开发中的一个难点问题,如果没有精细的设计,比如说,只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果:即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加。
而且,采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis 直接采用了单线程模式。
通常来说,单线程的处理能力要比多线程差很多,但是 Redis 却能使用单线程模型达到每秒数十万级别的处理能力,这是为什么呢?其实,这是 Redis 多方面设计选择的一个综合结果。
一方面,Redis 的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。
另一方面,就是 Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。
Redis 为了处理一个 Get 请求,需要监听客户端请求(bind/listen),和客户端建立连接(accept),从 socket 中读取请求(recv),解析客户端发送请求(parse),根据请求类型读取键值数据(get),最后给客户端返回结果,即向 socket 中写回数据(send)。
既然 Redis 是单线程,那么,最基本的一种实现是在一个线程中依次执行上面说的这些操作。
但是,在这里的网络 IO 操作中,有潜在的阻塞点,分别是 accept() 和 recv()。
当 Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。
类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()。
在 socket 模型中,不同操作调用后会返回不同的套接字类型。socket() 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字。
针对监听套接字,我们可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。但是,你要注意的是,调用 accept() 时,已经存在监听套接字了。
虽然 Redis 线程可以不用继续等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求时通知 Redis。类似的,我们也可以针对已连接套接字设置非阻塞模式:Redis 调用 recv() 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。我们也需要有机制继续监听该已连接套接字,并在有数据达到时通知 Redis。
这样才能保证 Redis 线程,既不会像基本 IO 模型中一直在阻塞点等待,也不会导致 Redis 无法处理实际到达的连接请求或数据。
到此,Linux 中的 IO 多路复用机制就要登场了。
Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。
1)select 机制实现原理
select进程启动时,没有数据到达网卡
select进程有数据到达时,会通过回调函数唤醒进程进行数据的读取
2)epoll 机制实现原理
简单来说,在 Redis 只运行单线程的情况下,Linux的多路复用机制允许内核中可以同时存在多个监听 Socket 和已连接 Socket,一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
下图就是基于多路复用的 Redis IO 模型。图中的多个 FD 就是刚才所说的多个套接字。Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。
为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,一旦监测到 FD 上有请求到达时,就会触发相应的事件。这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。
并发量非常大时,单线程读写客户端IO数据存在性能瓶颈,虽然采用IO多路复用机制,但是读写客户端数据依旧是同步IO,只能单线程依次读取客户端的数据,无法利用到CPU多核。
Redis 6.0 前的版本是用一个线程来读取网络请求并进行解析,并根据请求的具体命令操作进行数据读写的。从 Redis 6.0 开始,网络请求解析也是由专门的线程处理,从而支持快速网络读写。而读写处理仍然由单个主线程执行,这是为了避免多线程协调的开销。
补充拓展reddis6.0
Redis 一般会当作缓存来使用,把后端数据库中的数据存储在内存中,然后直接从内存中读取数据,响应速度会非常快。但是,一旦服务器宕机,内存中的数据将全部丢失。
如果从后端数据库恢复这些数据,会存在两个问题:
1)需要频繁访问数据库,会给数据库带来巨大的压力;
2)这些数据是从慢速数据库中读取出来的,性能肯定比不上从 Redis 中读取,导致使用这些数据的应用程序响应变慢。
所以,对 Redis 来说,实现数据的持久化,避免从后端数据库中进行恢复,是至关重要的。目前,Redis 的持久化主要有两大机制,即 AOF(Append Only File)日志和 RDB 快照。
不同于数据库的写前日志(Write Ahead Log, WAL),即在实际写数据前,先把修改的数据记到日志文件中,以便故障时进行恢复(详见:使我郁郁寡欢的 MySQL 事务和锁)。而AOF 日志正好相反,它是写后日志,即 Redis 是先执行命令,把数据写入内存后,再记录日志,如下图所示:
AOF 里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存的。
以 Redis 收到“set testkey testvalue”命令后记录的日志为例,看看 AOF 日志的内容。其中,“*3”表示当前命令有三个部分,每部分都是由“$+数字”开头,后面紧跟着具体的命令、键或值。这里,“数字”表示这部分中的命令、键或值一共有多少字节。例如,“$3 set”表示这部分有 3 个字节,也就是“set”命令。
为了避免额外的检查开销,Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。
而写后日志这种方式,就是先让系统执行命令,只有命令能执行成功,才会被记录到日志中,否则,系统就会直接向客户端报错。所以,Redis 使用写后日志这一方式的一大好处是,可以避免出现记录错误命令的情况。
除此之外,AOF 还有一个好处:它是在命令执行后才记录日志,所以不会阻塞当前命令的操作。
不过,AOF 也有两个潜在的风险。
1)如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。如果此时 Redis 是用作缓存,还可以从后端数据库重新读入数据进行恢复,但是,如果 Redis 是直接用作数据库的话,此时,因为命令没有记入日志,所以就无法用日志进行恢复了。
2)AOF 虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。这是因为,AOF 日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了。
这两个风险都是和 AOF 写回磁盘的时机相关的。这也就意味着,如果能够控制一个写命令执行完后 AOF 日志写回磁盘的时机,这两个风险就解除了。
想要获得高性能,就选择 No 策略;如果想要得到高可靠性保证,就选择 Always 策略;如果允许数据有一点丢失,又希望性能别受太大影响的话,那么就选择 Everysec 策略。
随着接收的写命令越来越多,AOF 文件会越来越大,会带来性能问题
一是,文件系统本身对文件大小有限制,无法保存过大的文件;
二是,如果文件太大,之后再往里面追加命令记录的话,效率也会变低;
三是,如果发生宕机,AOF 中记录的命令要一个个被重新执行,用于故障恢复,如果日志文件太大,整个恢复过程就会非常缓慢,这就会影响到 Redis 的正常使用。
简单来说,AOF 重写机制就是在重写时,Redis 根据数据库的现状创建一个新的 AOF 文件,也就是说,读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写入。比如说,当读取了键值对“testkey”: “testvalue”之后,重写机制会记录 set testkey testvalue 这条命令。这样,当需要恢复时,可以重新执行该命令,实现“testkey”: “testvalue”的写入。
为什么重写机制可以把日志文件变小呢? 实际上,重写机制具有“多变一”功能。所谓的“多变一”,也就是说,旧日志文件中的多条命令,在重写后的新日志中变成了一条命令。
我们知道,AOF 文件是以追加的方式,逐一记录接收到的写命令的。当一个键值对被多条写命令反复修改时,AOF 文件会记录相应的多条命令。但是,在重写的时候,是根据这个键值对当前的最新状态,为它生成对应的写入命令。这样一来,一个键值对在重写日志中只用一条命令就行了,而且,在日志恢复时,只用执行这条命令,就可以直接完成这个键值对的写入了。
当我们对一个列表先后做了 6 次修改操作后,列表的最后状态是[“D”, “C”, “N”],此时,只用 LPUSH u:list “N”, “C”, "D"这一条命令就能实现该数据的恢复,这就节省了五条命令的空间。对于被修改过成百上千次的键值对来说,重写能节省的空间当然就更大了。
不过,虽然 AOF 重写后,日志文件会缩小,但是,要把整个数据库的最新数据的操作日志都写回磁盘,仍然是一个非常耗时的过程。这时,我们就要继续关注另一个问题了:重写会不会阻塞主线程?
和 AOF 日志由主线程写回不同,重写过程是由后台子进程 bgrewriteaof 来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。
我把重写的过程总结为“一个拷贝,两处日志”。
“一个拷贝”就是指,每次执行重写时,主线程 fork 出后台的 bgrewriteaof 子进程。此时,fork 会把主线程的内存拷贝一份给 bgrewriteaof 子进程,这里面就包含了数据库的最新数据。然后,bgrewriteaof 子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。
“两处日志”又是什么呢?
因为主线程未阻塞,仍然可以处理新来的操作。此时,如果有写操作,第一处日志就是指正在使用的 AOF 日志,Redis 会把这个操作写到它的缓冲区。这样一来,即使宕机了,这个 AOF 日志的操作仍然是齐全的,可以用于恢复。
而第二处日志,就是指新的 AOF 重写日志。这个操作也会被写到重写日志的缓冲区。这样,重写日志也不会丢失最新的操作。等到拷贝数据的所有操作记录重写完成后,重写日志记录的这些最新操作也会写入新的 AOF 文件,以保证数据库最新状态的记录。此时,我们就可以用新的 AOF 文件替代旧文件了。
总结来说,每次 AOF 重写时,Redis 会先执行一个内存拷贝,用于重写;然后,使用两个日志保证在重写过程中,新写入的数据不会丢失。而且,因为 Redis 采用额外的线程进行数据重写,所以,这个过程并不会阻塞主线程。
所谓内存快照,就是指内存中的数据在某一个时刻的状态记录。
对 Redis 来说,它实现类似照片记录效果的方式,就是把某一时刻的状态以文件的形式写到磁盘上,也就是快照。这样一来,即使宕机,快照文件也不会丢失,数据的可靠性也就得到了保证。这个快照文件就称为 RDB 文件,其中,RDB 就是 Redis DataBase 的缩写。
和 AOF 相比,RDB 记录的是某一时刻的数据,并不是操作,所以,在做数据恢复时,我们可以直接把 RDB 文件读入内存,很快地完成恢复。
我们还要考虑两个关键问题:
这么说可能你还不太好理解,我还是拿拍照片来举例子。我们在拍照时,通常要关注两个问题:
Redis 的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是全量快照,也就是说,把内存中的所有数据都记录到磁盘中,这就类似于给 100 个人拍合影,把每一个人都拍进照片里。这样做的好处是,一次性记录了所有数据,一个都不少。
Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave。
在给别人拍照时,一旦对方动了,那么这张照片就拍糊了,我们就需要重拍,所以我们当然希望对方保持不动。对于内存快照而言,我们也不希望数据“动”。
但是,如果快照执行期间数据不能被修改,是会有潜在问题的。对于刚刚的例子来说,在做快照的 20s 时间里,如果这 4GB 的数据都不能被修改,Redis 就不能处理对这些数据的写操作,那无疑就会给业务服务造成巨大的影响。
为了快照而暂停写操作,肯定是不能接受的。所以这个时候,Redis 就会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。
简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。
此时,如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据就会被复制一份,生成该数据的副本(键值对 C’)。然后,主线程在这个数据副本上进行修改。同时,bgsave 子进程可以继续把原来的数据(键值对 C)写入 RDB 文件。
这既保证了快照的完整性,也允许主线程同时对数据进行修改,避免了对正常业务的影响。
如果频繁地执行全量快照,也会带来两方面的开销。
一方面,频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。
另一方面,bgsave 子进程需要通过 fork 操作从主线程创建出来。虽然,子进程在创建后不会再阻塞主线程,但是,fork 这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长。如果频繁 fork 出 bgsave 子进程,这就会频繁阻塞主线程了(所以,在 Redis 中如果有一个 bgsave 在运行,就不会再启动第二个 bgsave 子进程)。那么,有什么其他好方法吗?
此时,我们可以做增量快照,所谓增量快照,就是指,做了一次全量快照后,后续的快照只对修改的数据进行快照记录,这样可以避免每次全量快照的开销。
在第一次做完全量快照后,T1 和 T2 时刻如果再做快照,我们只需要将被修改的数据写入快照文件就行。但是,这么做的前提是,我们需要记住哪些数据被修改了。你可不要小瞧这个“记住”功能,它需要我们使用额外的元数据信息去记录哪些数据被修改了,这会带来额外的空间开销问题。如下图所示:
如果我们对每一个键值对的修改,都做个记录,那么,如果有 1 万个被修改的键值对,我们就需要有 1 万条额外的记录。而且,有的时候,键值对非常小,比如只有 32 字节,而记录它被修改的元数据信息,可能就需要 8 字节,这样的画,为了“记住”修改,引入的额外空间开销比较大。这对于内存资源宝贵的 Redis 来说,有些得不偿失。
到这里,你可以发现,虽然跟 AOF 相比,快照的恢复速度快,但是,快照的频率不好把握,如果频率太低,两次快照间一旦宕机,就可能有比较多的数据丢失。如果频率太高,又会产生额外开销,那么,还有什么方法既能利用 RDB 的快速恢复,又能以较小的开销做到尽量少丢数据呢?
Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。
这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。而且,AOF 日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。
如下图所示,T1 和 T2 时刻的修改,用 AOF 日志记录,等到第二次做全量快照时,就可以清空 AOF 日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。
这个方法既能享受到 RDB 文件快速恢复的好处,又能享受到 AOF 只记录操作命令的简单优势
总结
1)数据不能丢失时,内存快照和 AOF 的混合使用是一个很好的选择;
2)如果允许分钟级别的数据丢失,可以只使用 RDB;
3)如果只用 AOF,优先使用 everysec 的配置选项,因为它在可靠性和性能之间取了一个平衡。