Loading

Redis核心技术与实战

《Redis核心技术与实战》

高性能主线,包括线程模型、数据结构、持久化、网络框架;

高可靠主线,包括主从复制、哨兵机制;

高可扩展主线,包括数据分片、负载均衡。

数据结构

02 Redis底层数据结构

Redis 键值对中值的数据类型,也就是数据的保存形式:

String(字符串)、List(列表)、Hash(哈希)、Set(集合)和 Sorted Set(有序集合)

底层数据结构一共有 6 种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组

String 类型的底层实现只有一种数据结构,也就是简单动态字符串。而 List、Hash、Set 和 Sorted Set 这四种数据类型,都有两种底层实现结构。通常情况下,我们会把这四种类型称为集合类型,它们的特点是一个键对应了一个集合的数据

键和值用什么结构组织?

Redis 使用了一个哈希表来保存所有键值对

一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。

哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。

因为这个哈希表保存了所有的键值对,所以,我也把它称为全局哈希表

哈希表的最大好处很明显,就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对——我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素。

为什么哈希表操作变慢了?

答:哈希表的冲突问题和 rehash 可能带来的操作阻塞

这里的哈希冲突,也就是指,两个 key 的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中。毕竟,哈希桶的个数通常要少于 key 的数量

Redis 解决哈希冲突的方式,就是链式哈希。链式哈希就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。

这里依然存在一个问题,哈希冲突链上的元素只能通过指针逐一查找再操作。如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。所以,Redis 会对哈希表做 rehash 操作。

rehash 也就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。

为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:

给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;释放哈希表 1 的空间。

但是第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞

为了避免这个问题,Redis 采用了渐进式 rehash

简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。这样就巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。

对于 String 类型来说,找到哈希桶就能直接增删改查了,所以,哈希表的 O(1) 操作复杂度也就是它的复杂度了。

集合数据操作效率

集合类型的底层数据结构和操作复杂度:

集合类型的底层数据结构主要有 5 种:整数数组、双向链表、哈希表、压缩列表和跳表

整数数组和双向链表很常见,它们的操作特征都是顺序读写,也就是通过数组下标或者链表的指针逐个元素访问,操作复杂度基本是 O(N),操作效率比较低;

压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。

跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位

这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是 O(logN)

集合类型的不同操作的复杂度

单元素操作是基础;范围操作非常耗时;统计操作通常高效;例外情况只有几个。

(Redis 从 2.8 版本开始提供了 SCAN 系列操作(包括 HSCAN,SSCAN 和 ZSCAN),这类操作实现了渐进式遍历,每次只返回有限数量的数据。这样一来,相比于 HGETALL、SMEMBERS 这类操作来说,就避免了一次性返回所有元素而导致的 Redis 阻塞)

11 保存单值键值对不一定String最好用

String 类型并不是适用于所有场合的,它有一个明显的短板,就是它保存数据时所消耗的内存空间较多

String 类型还需要额外的内存空间记录数据长度、空间使用等信息,这些信息也叫作元数据。当实际保存的数据长度较小时,元数据的空间开销就显得比较大了,有点“喧宾夺主”的意思。

如何用集合类型保存单值的键值对?

在保存单值的键值对时,可以采用基于 Hash 类型的二级编码方法。这里说的二级编码,就是把一个单值的数据拆分成两部分,前一部分作为 Hash 集合的 key,后一部分作为 Hash 集合的 value

以图片 ID 1101000060 和图片存储对象 ID 3302000080 为例,我们可以把图片 ID 的前 7 位(1101000)作为 Hash 类型的键,把图片 ID 的最后 3 位(060)和图片存储对象 ID 分别作为 Hash 类型值中的 key 和 value。

其实,二级编码方法中采用的 ID 长度是有讲究的:

Redis Hash 类型的两种底层实现结构,分别是压缩列表和哈希表。Hash 类型设置了用压缩列表保存数据时的两个阈值,一旦超过了阈值,Hash 类型就会用哈希表来保存数据了。

hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。

hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。

为了能充分使用压缩列表的精简内存布局,我们一般要控制保存在 Hash 集合中的元素个数。所以,在刚才的二级编码中,我们只用图片 ID 最后 3 位作为 Hash 集合的 key,也就保证了 Hash 集合的元素个数不超过 1000,同时,我们把 hash-max-ziplist-entries 设置为 1000,这样一来,Hash 集合就可以一直使用压缩列表来节省内存空间了。

Redis容量预估工具

http://www.redis.cn/redis_memory/

12 集合类型常见的四种统计模式

集合类型常见的四种统计模式,包括聚合统计、排序统计、二值状态统计和基数统计。

聚合统计

,就是指统计多个集合元素的聚合结果,包括:统计多个集合的共有元素(交集统计);把两个集合相比,统计其中一个集合独有的元素(差集统计);统计多个集合的所有元素(并集统计)

比如统计手机 App 每天的新增用户数和第二天的留存用户数?正好对应了聚合统计:

要完成这个统计任务,我们可以用一个集合记录所有登录过 App 的用户 ID,同时,用另一个集合记录每一天登录过 App 的用户 ID。然后,再对这两个集合做聚合统计。

记录所有登录过 App 的用户 ID 还是比较简单的,我们可以直接使用 Set 类型,把 key 设置为 user:id,表示记录的是用户 ID,value 就是一个 Set 集合,里面是所有登录过 App 的用户 ID,我们可以把这个 Set 叫作累计用户 Set

我们还需要把每一天登录的用户 ID,记录到一个新集合中,我们把这个集合叫作每日用户 Set,它有两个特点:key 是 user:id 以及当天日期,例如 user:id:20200803;value 是 Set 集合,记录当天登录的用户 ID。

例如:

假设我们的手机 App 在 2020 年 8 月 3 日上线,当天登录的用户 ID 会被记录到 key 为 user:id:20200803 的 Set 中,我们计算累计用户 Set 和 user:id:20200803 Set 的并集结果,结果保存在 user:id 这个累计用户 Set 中:

SUNIONSTORE  user:id  user:id  user:id:20200803 

等到 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  

user:new 这个 Set 中记录的就是 8 月 4 日的新增用户

当要计算 8 月 4 日的留存用户时,我们只需要再计算 user:id:20200803 和 user:id:20200804 两个 Set 的交集:

SINTERSTORE user:id:rem user:id:20200803 user:id:20200804

注意;这3个命令都会在Redis中生成一个新key,而从库默认是readonly不可写的,所以这些命令只能在主库使用。想在从库上操作,可以使用SUNION、SDIFF、SINTER,这些命令可以计算出结果,但不会生成新key。

排序统计

在 Redis 常用的 4 个集合类型中(List、Hash、Set、Sorted Set),List 和 Sorted Set 就属于有序集合。

List 是按照元素进入 List 的顺序进行排序的,而 Sorted Set 可以根据元素的权重来排序

在电商网站上提供最新评论列表的场景为例,进行讲解:

先说说用 List 的情况。每个商品对应一个 List,这个 List 包含了对这个商品的所有评论,而且会按照评论时间保存这些评论,每来一个新评论,就用 LPUSH 命令把它插入 List 的队头

在实际应用中,网站一般会分页显示最新的评论列表,一旦涉及到分页操作,List 就可能会出现问题了。

在展示第一页的 3 个评论时,我们可以用下面的命令,得到最新的三条评论 A、B、C:

LRANGE product1 0 2

但是,如果在展示第二页前,又产生了一个新评论 G,评论 G 就会被 LPUSH 命令插入到评论 List 的队头,评论 List 就变成了{G, A, B, C, D, E, F}。此时,再用刚才的命令获取第二页评论时,就会发现,评论 C 又被展示出来了,也就是 C、D、E。所以,对比新元素插入前后,List 相同位置上的元素就会发生变化,用 LRANGE 读取时,就会读到旧元素。

Sorted Set 就不存在这个问题: Sorted Set 的 ZRANGEBYSCORE 命令就可以按权重排序后返回元素。

假设越新的评论权重越大,目前最新评论的权重是 N,我们执行下面的命令时,就可以获得最新的 10 条评论:

ZRANGEBYSCORE comments N-9 N

所以,在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,建议你优先考虑使用 Sorted Set。

二值状态统计

这里的二值状态就是指集合元素的取值就只有 0 和 1 两种。在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态

这个时候,我们就可以选择 Bitmap。这是 Redis 提供的扩展数据类型。

Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。

Bitmap 提供了 GETBIT/SETBIT 操作,使用一个偏移值 offset 对 bit 数组的某一个 bit 位进行读和写。不过,需要注意的是,Bitmap 的偏移量是从 0 开始算的,也就是说 offset 的最小值是 0。

假设我们要统计 ID 3000 的用户在 2020 年 8 月份的签到情况,就可以按照下面的步骤进行操作。

第一步,执行下面的命令,记录该用户 8 月 3 号已签到。

SETBIT uid:sign:3000:202008 2 1

第二步,检查该用户 8 月 3 日是否签到。

GETBIT uid:sign:3000:202008 2

第三步,统计该用户在 8 月份的签到次数

BITCOUNT uid:sign:3000:202008

如果记录了 1 亿个用户 10 天的签到情况,你有办法统计出这 10 天连续签到的用户总数吗?

你可以把每天的日期作为 key,每个 key 对应一个 1 亿位的 Bitmap,每一个 bit 对应一个用户当天的签到情况。我们对 10 个 Bitmap 做“与”操作,得到的结果也是一个 Bitmap。在这个 Bitmap 中,只有 10 天都签到的用户对应的 bit 位上的值才会是 1。最后,我们可以用 BITCOUNT 统计下 Bitmap 中的 1 的个数,这就是连续签到 10 天的用户总数了。在记录海量数据时,Bitmap 能够有效地节省内存空间。

基数统计

基数统计就是指统计一个集合中不重复的元素个数。比如统计网页的 UV

网页 UV 的统计有个独特的地方,就是需要去重,一个用户一天内的多次访问只能算作一次。在 Redis 的集合类型中,Set 类型默认支持去重,所以看到有去重需求时,我们可能第一时间就会想到用 Set 类型。

有一个用户 user1 访问 page1 时,你把这个信息加到 Set 中:

SADD page1:uv user1

对于一个搞大促的电商网站而言,这样的页面可能有成千上万个,如果每个页面都用这样的一个 Set,就会消耗很大的内存空间

这时候,就要用到 Redis 提供的 HyperLogLog 了。

HyperLogLog 是一种用于统计基数的数据集合类型,它的最大优势就在于,当集合元素数量非常多时,它计算基数所需的空间总是固定的,而且还很小。

在统计 UV 时,你可以用 PFADD 命令(用于向 HyperLogLog 中添加新元素)把访问页面的每个用户都添加到 HyperLogLog 中。

PFADD page1:uv user1 user2 user3 user4 user5

接下来,就可以用 PFCOUNT 命令直接获得 page1 的 UV 值了,这个命令的作用就是返回 HyperLogLog 的统计结果。

PFCOUNT page1:uv

HyperLogLog 的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是 0.81%。这也就意味着,你使用 HyperLogLog 统计的 UV 是 100 万,但实际的 UV 可能是 101 万。虽然误差率不算大,但是,如果你需要精确统计结果的话,最好还是继续用 Set 或 Hash 类型。

13 GEO 位置信息处理

GEO

Redis 的 5 大基本数据类型:String、List、Hash、Set 和 Sorted Set,它们可以满足大多数的数据存储需求,但是在面对海量数据统计时,它们的内存开销很大,而且对于一些特殊的场景,它们是无法支持的。所以,Redis 还提供了 3 种扩展数据类型,分别是 Bitmap、HyperLogLog 和 GEO。

在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service,LBS)的应用。LBS 应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO 就非常适合应用在 LBS 服务的场景中

GEO 类型是把经纬度所在的区间编码作为 Sorted Set 中元素的权重分数,把和经纬度相关的车辆 ID 作为 Sorted Set 中元素本身的值保存下来,这样相邻经纬度的查询就可以通过编码值的大小范围查询来实现了。

如何操作 GEO 类型

GEOADD 命令:用于把一组经纬度信息和相对应的一个 ID 记录到 GEO 类型集合中;

GEORADIUS 命令:会根据输入的经纬度位置,查找以这个经纬度为中心的一定范围内的其他元素。

以叫车应用的车辆匹配场景为例,

假设车辆 ID 是 33,经纬度位置是(116.034579,39.030452),我们可以用一个 GEO 集合保存所有车辆的经纬度,集合 key 是 cars:locations。

GEOADD cars:locations 116.034579 39.030452 33

LBS 应用执行下面的命令时,Redis 会根据输入的用户的经纬度信息(116.054579,39.030452 ),查找以这个经纬度为中心的 5 公里内的车辆信息,并返回给 LBS 应用。当然, 你可以修改“5”这个参数,来返回更大或更小范围内的车辆信息。

GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10

使用 ASC 选项,让返回的车辆信息按照距离这个中心位置从近到远的方式来排序,以方便选择最近的车辆;还可以使用 COUNT 选项,指定返回的车辆信息的数量。毕竟,5 公里范围内的车辆可能有很多,如果返回全部信息,会占用比较多的数据带宽,这个选项可以帮助控制返回的数据量,节省带宽。

14 如何在Redis中保存时间序列数据?

时间序列数据的读写特点

这种数据的写入特点很简单,就是插入数据快,这就要求我们选择的数据类型,在进行数据插入时,复杂度要低,尽量不要阻塞。

在查询时间序列数据时,支持单点查询、范围查询和聚合计算

基于 Hash 和 Sorted Set 保存时间序列数据

用 Hash 类型来实现单键的查询很简单。但是,Hash 类型有个短板:它并不支持对数据进行范围查询。

为了能同时支持按时间戳范围的查询,可以用 Sorted Set 来保存时间序列数据,因为它能够根据元素的权重分数来排序。我们可以把时间戳作为 Sorted Set 集合的元素分数,把时间点上记录的数据作为元素本身。

使用 Sorted Set 保存数据后,我们就可以使用 ZRANGEBYSCORE 命令,按照输入的最大时间戳和最小时间戳来查询这个时间范围内的温度值了。如下所示,我们来查询一下在 2020 年 8 月 3 日 9 点 7 分到 9 点 10 分间的所有温度值:

ZRANGEBYSCORE device:temperature 202008030907 202008030910

第二个问题:如何保证写入 Hash 和 Sorted Set 是一个原子性的操作呢

Redis 用来实现简单的事务的 MULTI 和 EXEC 命令。

MULTI 命令:表示一系列原子性操作的开始。收到这个命令后,Redis 就知道,接下来再收到的命令需要放到一个内部队列中,后续一起执行,保证原子性。

EXEC 命令:表示一系列原子性操作的结束。一旦 Redis 收到了这个命令,就表示所有要保证原子性的命令操作都已经发送完成了。此时,Redis 开始执行刚才放到内部队列中的所有命令操作。

以保存设备状态信息的需求为例,我们执行下面的代码,把设备在 2020 年 8 月 3 日 9 时 5 分的温度,分别用 HSET 命令和 ZADD 命令写入 Hash 集合和 Sorted Set 集合

127.0.0.1:6379> MULTI
OK

127.0.0.1:6379> HSET device:temperature 202008030911 26.8
QUEUED

127.0.0.1:6379> ZADD device:temperature 202008030911 26.8
QUEUED

127.0.0.1:6379> EXEC
1) (integer) 1
2) (integer) 1

事务中前一命令执行失败,后一命令也会正常执行吧?redis事务只能保证不被事务外命令打断,而不能保证要么都成功,要么都失败吧: 是的

如果redis使用集群部署,还能保证原子性吗: 不能了,不支持分布式事务。

第三个问题:如何对时间序列数据进行聚合计算

Sorted Set 只支持范围查询,无法直接进行聚合计算,所以,我们只能先把时间范围内的数据取回到客户端,然后在客户端自行完成聚合计算。

但是,如果我们需要进行大量的聚合计算,同时网络带宽条件不是太好时,Hash 和 Sorted Set 的组合就不太适合了。此时,使用 RedisTimeSeries 就更加合适一些。

基于 RedisTimeSeries 模块保存时间序列数据

RedisTimeSeries 是 Redis 的一个扩展模块。它专门面向时间序列数据提供了数据类型和访问接口,并且支持在 Redis 实例上直接对数据进行按时间范围的聚合计算。

因为 RedisTimeSeries 不属于 Redis 的内建功能模块,在使用时,我们需要先把它的源码单独编译成动态链接库 redistimeseries.so,再使用 loadmodule 命令进行加载,如下所示:

loadmodule redistimeseries.so

当用于时间序列数据存取时,RedisTimeSeries 的操作主要有 5 个:

用 TS.CREATE 命令创建时间序列数据集合;用 TS.ADD 命令插入数据;用 TS.GET 命令读取最新数据;用 TS.MGET 命令按标签过滤查询数据集合;用 TS.RANGE 支持聚合计算的范围查询。

如果你是Redis的开发维护者,你会把聚合计算也设计为Sorted Set的内在功能吗?

不会。因为聚合计算是CPU密集型任务,Redis在处理请求时是单线程的,也就是它在做聚合计算时无法利用到多核CPU来提升计算速度,如果计算量太大,这也会导致Redis的响应延迟变长,影响Redis的性能。Redis的定位就是高性能的内存数据库,要求访问速度极快。所以对于时序数据的存储和聚合计算,我觉得更好的方式是交给时序数据库去做,时序数据库会针对这些存储和计算的场景做针对性优化。

缓存策略与常用架构

23 Redis作为旁路缓存

Redis 作为旁路缓存,就意味着需要在应用程序中新增缓存逻辑处理的代码

Redis 做缓存时,还有两种模式,分别是只读缓存和读写缓存

只读缓存

假设业务应用要修改数据 A,此时,数据 A 在 Redis 中也缓存了,那么,应用会先直接在数据库里修改 A,并把 Redis 中的 A 删除。等到应用需要读取数据 A 时,会发生缓存缺失,此时,应用从数据库中读取 A,并写入 Redis,以便后续请求从缓存中直接读取

读写缓存

读写缓存还提供了同步直写和异步写回这两种模式,同步直写模式侧重于保证数据可靠性,而异步写回模式则侧重于提供低延迟访问

24 缓存数据淘汰策略

8种策略:

我们使用 EXPIRE 命令对一批键值对设置了过期时间后,无论是这些键值对的过期时间是快到了,还是 Redis 的内存使用量达到了 maxmemory 阈值,Redis 都会进一步按照 volatile-ttl、volatile-random、volatile-lru、volatile-lfu 这四种策略的具体筛选规则进行淘汰:

volatile-ttl 在筛选时,针对设置了过期时间的键值对,根据过期时间先后进行删除,越早过期越先被删除;

volatile-random 就像它的名称一样,在设置了过期时间的键值对中,进行随机删除;

volatile-lru 会使用 LRU 算法筛选设置了过期时间的键值对;

volatile-lfu 会使用 LFU 算法选择设置了过期时间的键值对;

allkeys-lru、allkeys-random、allkeys-lfu 这三种淘汰策略的备选淘汰数据范围,就扩大到了所有键值对,无论这些键值对是否设置了过期时间:

allkeys-random 策略,从所有键值对中随机选择并删除数据;

allkeys-lru 策略,使用 LRU 算法在所有数据中进行筛选;

allkeys-lfu 策略,使用 LFU 算法在所有数据中进行筛选;

LRU 算法的全称是 Least Recently Used,从名字上就可以看出,这是按照最近最少使用的原则来筛选数据,最不常用的数据会被筛选出来,而最近频繁使用的数据会留在缓存中

LFU在LRU 算法的基础上,同时考虑了数据的访问时效性和数据的访问次数,可以看作是对淘汰策略的优化

建议

优先使用 allkeys-lru 策略。这样,可以充分利用 LRU 这一经典缓存算法的优势,把最近最常访问的数据留在缓存中,提升应用的访问性能。如果你的业务数据中有明显的冷热数据区分,我建议你使用 allkeys-lru 策略。

如果业务应用中的数据访问频率相差不大,没有明显的冷热数据区分,建议使用 allkeys-random 策略,随机选择淘汰的数据就行。

如果你的业务中有置顶的需求,比如置顶新闻、置顶视频,那么,可以使用 volatile-lru 策略,同时不给这些置顶数据设置过期时间。这样一来,这些需要置顶的数据一直不会被删除,而其他数据会在过期时根据 LRU 规则进行筛选。

25 缓存数据和数据库不一致

删除缓存值或更新数据库失败而导致数据不一致,你可以使用重试机制确保删除或更新操作成功;

在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值,应对方案是延迟双删

26 缓存雪崩、缓存击穿和缓存穿透

缓存雪崩

缓存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增

原因:

  1. 缓存中有大量数据同时过期,导致大量请求无法得到处理;

处理:

微调过期时间;

服务降级,当业务应用访问的是非核心数据(例如电商商品属性)时,暂时停止从缓存中查询这些数据,而是直接返回预定义信息、空值或是错误信息,当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取

  1. Redis 缓存实例发生故障宕机了,无法处理请求;

处理:

在业务系统中实现服务熔断机制:就是业务应用调用缓存接口时,缓存客户端并不把请求发给 Redis 缓存实例,而是直接返回,等到 Redis 缓存实例重新恢复服务后,再允许应用请求发送到缓存系统;

请求限流机制:在业务系统的请求入口前端控制每秒进入系统的请求数,避免过多的请求被发送到数据库;

构建 Redis 缓存集群;

缓存击穿

缓存击穿是指,针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,紧接着,访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,会影响数据库处理其他请求。

解决:

解决方法也比较直接,对于访问特别频繁的热点数据,我们就不设置过期时间了

缓存穿透

缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中。如果应用持续有大量请求访问数据,就会同时给缓存和数据库带来巨大压力

原因:

  1. 业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据;
  2. 恶意攻击:专门访问数据库中没有的数据

解决:

第一种方案是,缓存空值或缺省值;

第二种方案是,使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力;

布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在。N个哈希函数分别计算某个数据哈希值,N个值对bit数组长度取模,对应位置bit位设为1,查询时N个位置都为1才说明数据存在。

最后一种方案是,在请求入口的前端进行请求检测,把恶意的请求过滤掉;

27 缓存污染

缓存污染问题指的是留存在缓存中的数据,实际不会被再次访问了,但是又占据了缓存空间

在实际业务应用中,LRU 和 LFU 两个策略都有应用。LRU 和 LFU 两个策略关注的数据访问特征各有侧重,LRU 策略更加关注数据的时效性,而 LFU 策略更加关注数据的访问频次。通常情况下,实际应用的负载具有较好的时间局部性,所以 LRU 策略的应用会更加广泛。但是,在扫描式查询的应用场景中,LFU 策略就可以很好地应对缓存污染问题了,建议你优先使用。

33 脑裂:两个主节点

所谓的脑裂,就是指在主从集群中,同时有两个主节点,它们都能接收写请求。而脑裂最直接的影响,就是客户端不知道应该往哪个主节点写入数据,结果就是不同的客户端会往不同的主节点上写入数据。而且,严重的话,脑裂会进一步导致数据丢失。

是不是数据同步出现了问题

在主从集群中发生数据丢失,最常见的原因就是主库的数据还没有同步到从库,结果主库发生了故障,等从库升级为主库后,未同步的数据就丢失了。

脑裂发生的原因

和主库部署在同一台服务器上的其他程序临时占用了大量资源(例如 CPU 资源),导致主库资源使用受限,短时间内无法响应心跳。其它程序不再使用资源时,主库又恢复正常。

主库自身遇到了阻塞的情况,例如,处理 bigkey 或是发生内存 swap,短时间内无法响应心跳,等主库阻塞解除后,又恢复正常的请求处理了。

为什么脑裂会导致数据丢失

在主从切换的过程中,如果原主库只是“假故障”,它会触发哨兵启动主从切换,一旦等它从假故障中恢复后,又开始处理请求,这样一来,就会和新主库同时存在,形成脑裂。等到哨兵让原主库和新主库做全量同步后,原主库在切换期间保存的数据就丢失了。

避免脑裂带来数据丢失

假设从库有 K 个,可以将 min-slaves-to-write 设置为 K/2+1(如果 K 等于 1,就设为 1),将 min-slaves-max-lag 设置为十几秒(例如 10~20s),在这个配置下,如果有一半以上的从库和主库进行的 ACK 消息延迟超过十几秒,我们就禁止主库接收客户端写请求。

这样一来,我们可以避免脑裂带来数据丢失的情况,而且,也不会因为只有少数几个从库因为网络阻塞连不上主库,就禁止主库接收请求,增加了系统的鲁棒性。

消息队列

15 Redis 提供的消息队列方案

消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息可靠性。

Redis 的 List 和 Streams 两种数据类型,就可以满足消息队列的这三个需求

基于 List 的消息队列解决方案

保序

生产者可以使用 LPUSH 命令把要发送的消息依次写入 List,而消费者则可以使用 RPOP 命令,从 List 的另一端按照消息的写入顺序,依次读取消息并进行处理。

但是,即使没有新消息写入 List,消费者也要不停地调用 RPOP 命令,这就会导致消费者程序的 CPU 一直消耗在执行 RPOP 命令上,带来不必要的性能损失。

Redis 提供了 BRPOP 命令。BRPOP 命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。和消费者程序自己不停地调用 RPOP 命令相比,这种方式能节省 CPU 开销。

处理重复消息

消费者程序本身能对重复消息进行判断,消息的全局唯一 ID 号就需要生产者程序在发送消息前自行生成。生成之后,我们在用 LPUSH 命令把消息插入 List 时,需要在消息中包含这个全局唯一 ID:

LPUSH mq "101030001:stock:5"
(integer) 1

可靠性

为了留存消息,List 类型提供了 BRPOPLPUSH 命令,这个命令的作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存。这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List 中重新读取消息并进行处理了

基于 Streams 的消息队列解决方案

Streams 是 Redis 5.0 专门为消息队列设计的数据类型,它提供了丰富的消息队列操作命令。

XADD:插入消息,保证有序,可以自动生成全局唯一 ID;

XREAD:用于读取消息,可以按 ID 读取数据;

XREADGROUP:按消费组形式读取消息;

XPENDING 和 XACK:XPENDING 命令可以用来查询每个消费组内所有消费者已读取但尚未确认的消息,而 XACK 命令用于向消息队列确认消息处理已完成。

比如说,我们执行下面的命令,就可以往名称为 mqstream 的消息队列中插入一条消息,消息的键是 repo,值是 5。其中,消息队列名称后面的*,表示让 Redis 为插入的数据自动生成一个全局唯一的 ID,例如“1599203861727-0”。

XADD mqstream * repo 5
"1599203861727-0"

我们可以执行下面的命令,从 ID 号为 1599203861727-0 的消息开始,读取后续的所有消息(示例中一共 3 条)

XREAD BLOCK 100 STREAMS  mqstream 1599203861727-0
1) 1) "mqstream"
   2) 1) 1) "1599274912765-0"
         2) 1) "repo"
            2) "3"
      2) 1) "1599274925823-0"
         2) 1) "repo"
            2) "2"
      3) 1) "1599274927910-0"
         2) 1) "repo"
            2) "1"

设置了 block 100 的配置项,10000 的单位是毫秒,表明 XREAD 在读取最新消息时,如果没有消息到来,XREAD 将阻塞 100 毫秒(即 10 秒),然后再返回。

Streams 本身可以使用 XGROUP 创建消费组,创建消费组之后,Streams 可以使用 XREADGROUP 命令让消费组内的消费者读取消息,

例如,我们执行下面的命令,创建一个名为 group1 的消费组,这个消费组消费的消息队列是 mqstream。

XGROUP create mqstream group1 0
OK

我们再执行一段命令,让 group1 消费组里的消费者 consumer1 从 mqstream 中读取所有消息,其中,命令最后的参数“>”,表示从第一条尚未被消费的消息开始读取。

XREADGROUP group group1 consumer1 streams mqstream >
1) 1) "mqstream"
   2) 1) 1) "1599203861727-0"
         2) 1) "repo"
            2) "5"
      2) 1) "1599274912765-0"
         2) 1) "repo"
            2) "3"
      3) 1) "1599274925823-0"
         2) 1) "repo"
            2) "2"
      4) 1) "1599274927910-0"
         2) 1) "repo"
            2) "1"

使用消费组的目的是让组内的多个消费者共同分担读取消息,所以,我们通常会让每个消费者读取部分消息,从而实现消息读取负载在多个消费者间是均衡分布的。例如,我们执行下列命令,让 group2 中的 consumer1、2、3 各自读取一条消息。

XREADGROUP group group2 consumer1 count 1 streams mqstream >
1) 1) "mqstream"
   2) 1) 1) "1599203861727-0"
         2) 1) "repo"
            2) "5"

XREADGROUP group group2 consumer2 count 1 streams mqstream >
1) 1) "mqstream"
   2) 1) 1) "1599274912765-0"
         2) 1) "repo"
            2) "3"

XREADGROUP group group2 consumer3 count 1 streams mqstream >
1) 1) "mqstream"
   2) 1) 1) "1599274925823-0"
         2) 1) "repo"
            2) "2"

相比 Redis 来说,Kafka 和 RabbitMQ 一般被认为是重量级的消息队列。

如果一个生产者发送给消息队列的消息,需要被多个消费者进行读取和处理,你会使用Redis的什么数据类型来解决这个问题?

这种情况下,只能使用Streams数据类型来解决。使用Streams数据类型,创建多个消费者组,就可以实现同时消费生产者的数据。每个消费者组内可以再挂多个消费者分担读取消息进行消费,消费完成后,各自向Redis发送XACK,标记自己的消费组已经消费到了哪个位置,而且消费组之间互不影响。

高性能

03 高性能IO模型

我们通常说,Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。

Redis 为什么用单线程?

多线程会引入复杂的并发控制问题

对于一个多线程的系统来说,可以增加系统中处理请求操作的资源实体,进而提升系统能够同时处理的请求数,即吞吐率。

但是,多线程编程模式面临的共享资源的并发访问控制问题。

并发访问控制一直是多线程开发中的一个难点问题,如果没有精细的设计,比如说,只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果:即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加。

单线程 Redis 为什么那么快?

通常来说,单线程的处理能力要比多线程差很多,但是 Redis 却能使用单线程模型达到每秒数十万级别的处理能力,这是为什么呢?

一方面,Redis 的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。

另一方面,就是 Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。

Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

16 异步机制:避免单线程模型的阻塞

Redis 实例5个阻塞点

与客户端交互

网络 IO

不是:因为使用了 IO 多路复用机制

键值对增删改查操作

是:1.集合全量查询和聚合操作;2.bigkey 删除操作

数据库操作

是:3.清空数据库

与磁盘交互

生成 RDB 快照

不是:用的子进程

记录 AOF 日志

是:4.AOF 日志同步写

AOF 日志重写

不是:用的子进程

主从节点交互

主库生成 RDB 文件,并传输给从库

不是:用的子进程

 

从库接收 RDB 文件、清空数据库、加载 RDB 文件

是:5.从库加载 RDB 文件到内存

切片集群实例交互

向其他实例传输哈希槽信息

不是:哈希槽的信息量不大

 

数据迁移

不是:数据迁移是渐进式执行的

在这 5 大阻塞点中,bigkey 删除、清空数据库、AOF 日志同步写不属于关键路径操作,可以使用异步子线程机制来完成。

Redis 在运行时会创建三个子线程,主线程会通过一个任务队列和三个子线程进行交互。子线程会根据任务的具体类型,来执行相应的异步操作。

键值对删除:当你的集合类型中有大量元素(例如有百万级别或千万级别元素)需要删除时,我建议你使用 UNLINK 命令。

清空数据库:可以在 FLUSHDB 和 FLUSHALL 命令后加上 ASYNC 选项,这样就可以让后台子线程异步地清空数据库

集合全量查询和聚合操作、从库加载 RDB 文件是在关键路径上,无法使用异步操作来完成。对于这两个阻塞点

集合全量查询和聚合操作:可以使用 SCAN 命令,分批读取数据,再在客户端进行聚合计算;
从库加载 RDB 文件:把主库的数据量大小控制在 2~4GB 左右,以保证 RDB 文件能以较快的速度加载。

17 CPU绑核优化 Redis 性能

在 CPU 多核的场景下,用 taskset 命令把 Redis 实例和一个核绑定,可以减少 Redis 实例在不同核上被来回调度执行的开销,避免较高的尾延迟;在多 CPU 的 NUMA 架构下,如果你对网络中断程序做了绑核操作,建议你同时把 Redis 实例和网络中断程序绑在同一个 CPU Socket 的不同核上,这样可以避免 Redis 跨 Socket 访问内存中的网络数据的时间开销。

18 19 如何应对变慢的Redis?

如何确定Redis变慢

查看 Redis 的响应延迟;

redis-cli 命令提供了–intrinsic-latency 选项,可以用来监测和统计测试期间内的最大延迟:

./redis-cli --intrinsic-latency 120
Max latency so far: 17 microseconds.
Max latency so far: 44 microseconds.
Max latency so far: 94 microseconds.
Max latency so far: 110 microseconds.
Max latency so far: 119 microseconds.

36481658 total runs (avg latency: 3.2893 microseconds / 3289.32 nanoseconds per run).
Worst run took 36x longer than the average latency.

一般来说,你要把运行时延迟和基线性能进行对比,如果你观察到的 Redis 运行时延迟是其基线性能的 2 倍及以上,就可以认定 Redis 变慢了

Redis 自身的操作特性、文件系统和操作系统,它们是影响 Redis 性能的三大要素

Redis 自身操作特性的影响

1. 慢查询命令

当你发现 Redis 性能变慢时,可以通过 Redis 日志,或者是 latency monitor 工具,查询变慢的请求

如果的确有大量的慢查询命令,有两种处理方式:

1) 用其他高效命令代替。比如说,如果你需要返回一个 SET 中的所有成员时,不要使用 SMEMBERS 命令,而是要使用 SSCAN 多次迭代返回,避免一次返回大量数据,造成线程阻塞。

2) 当你需要执行排序、交集、并集操作时,可以在客户端完成,而不要用 SORT、SUNION、SINTER 这些命令,以免拖慢 Redis 实例。

还有一个比较容易忽略的慢查询命令,就是 KEYS。因为 KEYS 命令需要遍历存储的键值对,所以操作延时高。所以,KEYS 命令一般不被建议用于生产环境中

2. 过期 key 操作

删除操作是阻塞的(Redis 4.0 后可以用异步线程机制来减少阻塞影响)。所以,一旦该条件触发,Redis 的线程就会一直执行删除

如果一批 key 的确是同时过期,你还可以在 EXPIREAT 和 EXPIRE 的过期时间参数上,加上一个一定大小范围内的随机数,这样,既保证了 key 在一个邻近时间范围内被删除,又避免了同时过期造成的压力。

文件系统:AOF 模式

Redis AOF 日志提供了三种日志写回策略:no、everysec、always。这三种写回策略依赖文件系统的两个系统调用完成,也就是 write 和 fsync

write 只要把日志记录写到内核缓冲区,就可以返回了,并不需要等待日志实际写回到磁盘;而 fsync 需要把日志记录写回到磁盘后才能返回,时间较长。

Redis AOF 配置级别是什么?业务层面是否的确需要这一可靠性级别?如果我们需要高性能,同时也允许数据丢失,可以将配置项 no-appendfsync-on-rewrite 设置为 yes,避免 AOF 重写和 fsync 竞争磁盘 IO 资源,导致 Redis 延迟增加。当然, 如果既需要高性能又需要高可靠性,最好使用高速固态盘作为 AOF 日志的写入盘。

操作系统:swap

内存 swap 是操作系统里将内存数据在内存和磁盘间来回换入和换出的机制,涉及到磁盘的读写,所以,一旦触发 swap,无论是被换入数据的进程,还是被换出数据的进程,其性能都会受到慢速磁盘读写的影响

通常,触发 swap 的原因主要是物理机器内存不足

解决思路:增加机器的内存或者使用 Redis 集群

操作系统本身会在后台记录每个进程的 swap 使用情况,即有多少数据量发生了 swap。你可以先通过下面的命令查看 Redis 的进程号,这里是 5332。

$ redis-cli info | grep process_id
process_id: 5332

然后,进入 Redis 所在机器的 /proc 目录下的该进程目录中:

$ cd /proc/5332

最后,运行下面的命令,查看该 Redis 进程的使用情况。在这儿,我只截取了部分结果:

$cat smaps | egrep '^(Swap|Size)'
Size: 584 kB
Swap: 0 kB
Size: 4 kB
Swap: 4 kB
Size: 4 kB
Swap: 0 kB
Size: 462044 kB
Swap: 462008 kB
Size: 21392 kB
Swap: 0 kB

每一行 Size 表示的是 Redis 实例所用的一块内存大小,而 Size 下方的 Swap 和它相对应,表示这块 Size 大小的内存区域有多少已经被换出到磁盘上了。如果这两个值相等,就表示这块内存区域已经完全被换出到磁盘了。

操作系统:内存大页

客户端的写请求可能会修改正在进行持久化的数据。在这一过程中,Redis 就会采用写时复制机制

如果采用了内存大页,那么,即使客户端请求只修改 100B 的数据,Redis 也需要拷贝 2MB 的大页。相反,如果是常规内存页机制,只用拷贝 4KB。

在实际生产环境中部署时,我建议你不要使用内存大页机制,操作也很简单,只需要执行下面的命令就可以了:

echo never /sys/kernel/mm/transparent_hugepage/enabled

20 内存碎片清理

内存碎片是如何形成的

内因:内存分配器一般是按固定大小来分配内存,而不是完全按照应用程序申请的内存空间大小给程序分配

外因:键值对大小不一样和删改操作

如何判断是否有内存碎片

Redis 自身提供了 INFO 命令

INFO memory
# Memory
used_memory:1073741736
used_memory_human:1024.00M
used_memory_rss:1997159792
used_memory_rss_human:1.86G
…
mem_fragmentation_ratio:1.86

这里有一个 mem_fragmentation_ratio 的指标,它表示的就是 Redis 当前的内存碎片率

例如,Redis 申请使用了 100 字节(used_memory),操作系统实际分配了 128 字节(used_memory_rss),此时,mem_fragmentation_ratio 就是 1.28

mem_fragmentation_ratio 大于 1.5 。这表明内存碎片率已经超过了 50%。一般情况下,这个时候,我们就需要采取一些措施来降低内存碎片率了

如何清理内存碎片

从 4.0-RC3 版本以后,Redis 自身提供了一种内存碎片自动清理的方法

Redis 需要启用自动内存碎片清理,可以把 activedefrag 配置项设置为 yes

具体什么时候清理,会受到下面这两个参数的控制。这两个参数分别设置了触发内存清理的一个条件,如果同时满足这两个条件,就开始清理

active-defrag-ignore-bytes 100mb:表示内存碎片的字节数达到 100MB 时,开始清理;

active-defrag-threshold-lower 10:表示内存碎片空间占操作系统分配给 Redis 的总空间比例达到 10% 时,开始清理。

还设置了两个参数,分别用于控制清理操作占用的 CPU 时间比例的上、下限,既保证清理工作能正常进行

active-defrag-cycle-min 25: 表示自动清理过程所用 CPU 时间的比例不低于 25%,保证清理能正常开展;

active-defrag-cycle-max 75:表示自动清理过程所用 CPU 时间的比例不高于 75%,一旦超过,就停止清理,从而避免在清理时,大量的内存拷贝阻塞 Redis,导致响应延迟升高。

21 缓冲区

缓冲区的功能是用一块内存空间来暂时存放命令数据,以免出现因为数据和命令的处理速度慢于发送速度而导致的数据丢失和性能问题。

Redis 是典型的 client-server 架构,所有的操作命令都需要通过客户端发送给服务器端。所以,缓冲区在 Redis 中的一个主要应用场景,就是在客户端和服务器端之间进行通信时,用来暂存客户端发送的命令数据,或者是服务器端返回给客户端的数据结果。此外,缓冲区的另一个主要应用场景,是在主从节点间进行数据同步时,用来暂存主节点接收的写命令和数据。

客户端输入和输出缓冲区

输入缓冲区就是用来暂存客户端发送的请求命令的,所以可能导致溢出的情况主要是下面两种:

写入了 bigkey,比如一下子写入了多个百万级别的集合类型数据;

服务器端处理请求的速度过慢;

要查看和服务器端相连的每个客户端对输入缓冲区的使用情况,我们可以使用 CLIENT LIST 命令:

CLIENT LIST
id=5 addr=127.0.0.1:50487 fd=9 name= age=4 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 events=r cmd=client

qbuf,表示输入缓冲区已经使用的大小; qbuf-free,表示输入缓冲区尚未使用的大小

客户端输入缓冲区溢出,Redis 的处理办法就是把客户端连接关闭,结果就是业务程序无法进行数据存取了

输入缓冲区的大小默认是固定的最多1G,我们无法通过配置来修改它

针对命令数据处理较慢的问题,解决方案就是减少 Redis 主线程上的阻塞操作,例如使用异步的删除操作

如何应对输出缓冲区溢出:

避免 bigkey 操作返回大量数据结果;

避免在线上环境中持续使用 MONITOR 命令;

使用 client-output-buffer-limit 设置合理的缓冲区大小上限,或是缓冲区连续写入时间和写入量上限;

主从集群中的缓冲区

复制缓冲区的溢出问题:

按通常的使用经验,我们会把主节点的数据量控制在 2~4GB,这样可以让全量同步执行得更快些,避免复制缓冲区累积过多命令

复制积压缓冲区的溢出问题:

复制积压缓冲区是一个大小有限的环形缓冲区。当主节点把复制积压缓冲区写满后,会覆盖缓冲区中的旧命令数据;可以调整复制积压缓冲区的大小,也就是设置 repl_backlog_size 这个参数的值

持久化与主从模式

04 AOF日志

Redis为什么要持久化

一旦服务器宕机,内存中的数据将全部丢失。我们很容易想到的一个解决方案是,从后端数据库恢复这些数据,但这种方式存在两个问题:一是,需要频繁访问数据库,会给数据库带来巨大的压力;二是,这些数据是从慢速数据库中读取出来的,性能肯定比不上从 Redis 中读取,导致使用这些数据的应用程序响应变慢。所以,对 Redis 来说,实现数据的持久化,避免从后端数据库中进行恢复,是至关重要的。

Redis 的持久化主要有两大机制,即 AOF(Append Only File)日志和 RDB 快照。

AOF,是写后日志,

好处是,可以避免出现记录错误命令的情况;它是在命令执行后才记录日志,所以不会阻塞当前的写操作。

风险是,如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险;AOF 虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险,AOF 日志也是在主线程中执行的

三种写回策略

AOF 机制给我们提供了三个选择,也就是 AOF 配置项 appendfsync 的三个可选值:

Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;

Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;

No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。

AOF日志文件太大了怎么办?

AOF 重写机制就是在重写时,Redis 根据数据库的现状创建一个新的 AOF 文件,也就是说,读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写入,(当我们对一个列表先后做了 6 次修改操作后,列表的最后状态是[“D”, “C”, “N”],此时,只用 LPUSH u:list “N”, “C”, "D"这一条命令就能实现该数据的恢复,这就节省了五条命令的空间。)

和 AOF 日志由主线程写回不同,重写过程是由后台子进程 bgrewriteaof 来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。

05 内存快照RDB

用 AOF 方法进行故障恢复的时候,需要逐一把操作日志都执行一遍。如果操作日志非常多,Redis 就会恢复得很缓慢,影响到正常使用

另一种持久化方法:内存快照, RDB 就是 Redis DataBase 的缩写。

和 AOF 相比,RDB 记录的是某一时刻的数据,并不是操作,所以,在做数据恢复时,我们可以直接把 RDB 文件读入内存,很快地完成恢复。听起来好像很不错,但内存快照也并不是最优选项。为什么这么说呢?

我们还要考虑两个关键问题:

给哪些内存数据做快照?

Redis 的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是全量快照

Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave。

save:在主线程中执行,会导致阻塞;bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。好了,这个时候,我们就可以通过 bgsave 命令来执行全量快照,这既提供了数据的可靠性保证,也避免了对 Redis 的性能影响。

在对内存数据做快照时,这些数据还能被修改吗?

为了快照而暂停写操作,肯定是不能接受的。所以这个时候,Redis 就会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。

简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。此时,如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据就会被复制一份,生成该数据的副本(键值对 C’)。然后,主线程在这个数据副本上进行修改。同时,bgsave 子进程可以继续把原来的数据(键值对 C)写入 RDB 文件。

虽然跟 AOF 相比,快照的恢复速度快,但是,快照的频率不好把握,如果频率太低,两次快照间一旦宕机,就可能有比较多的数据丢失。如果频率太高,又会产生额外开销

Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法

简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。

T1 和 T2 时刻的修改,用 AOF 日志记录,等到第二次做全量快照时,就可以清空 AOF 日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。

06 主从库数据同步

Redis 具有高可靠性,其实,这里有两层含义:一是数据尽量少丢失,二是服务尽量少中断

AOF 和 RDB 保证了前者,而对于后者,Redis 的做法就是增加副本冗余量,将一份数据同时保存在多个实例上。

Redis 提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分离的方式。

读操作:主库、从库都可以接收;

写操作:首先到主库执行,然后,主库将写操作同步给从库。

主从库间如何进行第一次同步?

例如,现在有实例 1(ip:172.16.19.3)和实例 2(ip:172.16.19.5),我们在实例 2 上执行以下这个命令后,实例 2 就变成了实例 1 的从库,并从实例 1 上复制数据:

replicaof 172.16.19.3 6379

主从库间数据第一次同步的三个阶段:

我们可以通过“主 - 从 - 从”模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。

主从库间网络断了怎么办?

在 Redis 2.8 之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步。

增量复制时,主从库之间具体是怎么保持同步的呢?这里的奥妙就在于 repl_backlog_buffer 这个缓冲区。

repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。

如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。因此,我们要想办法避免这一情况,一般而言,我们可以调整 repl_backlog_size 这个参数。

如果并发请求量非常大,连两倍的缓冲空间都存不下新操作请求的话,此时,主从库数据仍然可能不一致。

针对这种情况,一方面,你可以根据 Redis 所在服务器的内存资源再适当增加 repl_backlog_size 值,比如说设置成缓冲空间大小的 4 倍,另一方面,你可以考虑使用切片集群来分担单个主库的请求压力

练习题:

使用一个 2 核 CPU、4GB 内存、500GB 磁盘的云主机运行 Redis,Redis 数据库的数据量大小差不多是 2GB。当时 Redis 主要以修改操作为主,写读比例差不多在 8:2 左右,也就是说,如果有 100 个请求,80 个请求执行的是修改操作。在这个场景下,用 RDB 做持久化有什么风险吗?

内存不足的风险:Redis fork 一个 bgsave 子进程进行 RDB 写入,如果主线程再接收到写操作,就会采用写时复制。写时复制需要给写操作的数据分配新的内存空间。本问题中写的比例为 80%,那么,在持久化过程中,为了保存 80% 写操作涉及的数据,写时复制机制会在实例内存中,为这些数据再分配新内存空间,分配的内存量相当于整个实例数据量的 80%,大约是 1.6GB,这样一来,整个系统内存的使用量就接近饱和了。此时,如果实例还有大量的新 key 写入或 key 修改,云主机内存很快就会被吃光。如果云主机开启了 Swap 机制,就会有一部分数据被换到磁盘上,当访问磁盘上的这部分数据时,性能会急剧下降。如果云主机没有开启 Swap,会直接触发 OOM,整个 Redis 实例会面临被系统 kill 掉的风险。

主线程和子进程竞争使用 CPU 的风险:生成 RDB 的子进程需要 CPU 核运行,主线程本身也需要 CPU 核运行,而且,如果 Redis 还启用了后台线程,此时,主线程、子进程和后台线程都会竞争 CPU 资源。由于云主机只有 2 核 CPU,这就会影响到主线程处理请求的速度。

32 主从同步与故障切换有哪些坑

Redis 同时使用了两种策略来删除过期的数据,分别是惰性删除策略和定期删除策略

惰性删除策略。当一个数据的过期时间到了以后,并不会立即删除数据,而是等到再有请求来读写这个数据时,对数据进行检查,如果发现数据已经过期了,再删除这个数据。

定期删除策略是指,Redis 每隔一段时间(默认 100ms),就会随机选出一定数量的数据,检查它们是否过期,并把其中过期的数据删除。

我们在应用主从集群时,要注意将 protected-mode 配置项设置为 no,并且将 bind 配置项设置为其它哨兵实例的 IP 地址。这样一来,只有在 bind 中设置了 IP 地址的哨兵,才可以访问当前实例,既保证了实例间能够通信进行主从切换,也保证了哨兵的安全性。

哨兵机制

07 哨兵机制

在 Redis 主从集群中,哨兵机制是实现主从库自动切换的关键机制,它有效地解决了主从复制模式下故障转移

哨兵机制的基本流程

哨兵主要负责的就是三个任务:监控、选主和通知。

主观下线和客观下线

哨兵进程会使用 PING 命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。如果哨兵发现主库或从库对 PING 命令的响应超时了,那么,哨兵就会先把它标记为“主观下线”

如果检测的是主库,那么,哨兵还不能简单地把它标记为“主观下线”,开启主从切换。因为很有可能存在这么一个情况:那就是哨兵误判了,其实主库并没有故障。

误判一般会发生在集群网络压力较大、网络拥塞,或者是主库本身压力较大的情况下。

那怎么减少误判呢?通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群。引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。只有到了quorum 配置项值或者大多数的哨兵实例,都判断主库已经“主观下线”了,主库才会被标记为“客观下线”,这个叫法也是表明主库下线成为一个客观事实了。这个判断原则就是:少数服从多数。

如何选定新主库?

把哨兵选择新主库的过程称为“筛选 + 打分”

我们在多个从库中,先按照一定的筛选条件,把不符合条件的从库去掉。然后,我们再按照一定的规则,给剩下的从库逐个打分,将得分最高的从库选为新主库

筛选,按照在线状态、网络状态,筛选过滤掉一部分不符合要求的从库

打分,依次从库优先级(可以配置)、从库复制进度(和旧主库同步程度最接近)以及从库 ID 号(最小的实例ID号)打分

问题 1:在主从切换过程中,客户端能否正常地进行请求操作呢?

主从集群一般是采用读写分离模式,当主库故障后,客户端仍然可以把读请求发送给从库,让从库服务。但是,对于写请求操作,客户端就无法执行了。

问题 2:如果想要应用程序不感知服务的中断,还需要哨兵或客户端再做些什么吗?

一方面,客户端需要能缓存应用发送的写请求。只要不是同步写操作(Redis 应用场景一般也没有同步写),写请求通常不会在应用程序的关键路径上,所以,客户端缓存写请求后,给应用程序返回一个确认就行。

另一方面,主从切换完成后,客户端要能和新主库重新建立连接,哨兵需要提供订阅频道,让客户端能够订阅到新主库的信息。同时,客户端也需要能主动和哨兵通信,询问新主库的信息。

08 哨兵集群

一旦多个实例组成了哨兵集群,即使有哨兵实例出现故障挂掉了,其他哨兵还能继续协作完成主从库切换的工作

哨兵之间互通机制:基于pub/sub机制,在主库中有一个"__sentinel__:hello"的频道,哨兵之间互相发现通信

哨兵与主从库互通机制:哨兵向主库发送INFO指令,可以获取所有从库的信息,实现对主库,从库的监控

客户端和哨兵之间的事件通知:基于哨兵自身的 pub/sub 功能,哨兵是一个特殊的redis实例,所以客户端可以订阅哨兵的指定频道获得redis主从库的信息

哨兵集群执行主从切换机制:谁发现,谁就发起投票流程,谁获得多数票,谁就是哨兵Leader,由Leader负责主从库切换

任何一个实例只要自身判断主库“主观下线”后,就会给其他实例发送 is-master-down-by-addr 命令。接着,其他实例会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y 相当于赞成票,N 相当于反对票。一个哨兵获得了仲裁所需的赞成票数后,1.就可以标记主库为“客观下线”

此时,2.这个哨兵就可以再给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所有其他哨兵进行投票。这个投票过程称为“Leader 选举”。

在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

如果哨兵集群只有 2 个实例,此时,一个哨兵要想成为 Leader,必须获得 2 票,而不是 1 票。所以,如果有个哨兵挂掉了,那么,此时的集群是无法进行主从库切换的。因此,通常我们至少会配置 3 个哨兵实例。

练习 1:5 个哨兵实例的集群,quorum 值设为 2。在运行过程中,如果有 3 个哨兵实例都发生故障了,此时,Redis 主库如果有故障,还能正确地判断主库“客观下线”吗?如果可以的话,还能进行主从库自动切换吗?

因为判定主库“客观下线”的依据是,认为主库“主观下线”的哨兵个数要大于等于 quorum 值,现在还剩 2 个哨兵实例,个数正好等于 quorum 值,所以还能正常判断主库是否处于“客观下线”状态。如果一个哨兵想要执行主从切换,就要获到半数以上的哨兵投票赞成,也就是至少需要 3 个哨兵投票赞成。但是,现在只有 2 个哨兵了,所以就无法进行主从切换了。

练习2:哨兵实例是不是越多越好呢?如果同时调大 down-after-milliseconds 值,对减少误判是不是也有好处?

哨兵实例越多,误判率会越低,但是在判定主库下线和选举 Leader 时,实例需要拿到的赞成票数也越多,等待所有哨兵投完票的时间可能也会相应增加,主从库切换的时间也会变长,客户端容易堆积较多的请求操作,可能会导致客户端请求溢出,从而造成请求丢失。如果业务层对 Redis 的操作有响应时间要求,就可能会因为新主库一直没有选定,新操作无法执行而发生超时报警。

调大 down-after-milliseconds 后,可能会导致这样的情况:主库实际已经发生故障了,但是哨兵过了很长时间才判断出来,这就会影响到 Redis 对业务的可用性。

分布式/切片集群

09 切片集群

切片集群,也叫分片集群,就是指启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。

如何保存更多数据?

Redis 应对数据量增多的两种方案:纵向扩展(scale up)和横向扩展(scale out)

纵向扩展:升级单个 Redis 实例的资源配置,包括增加内存容量、增加磁盘容量、使用更高配置的 CPU。

横向扩展:横向增加当前 Redis 实例的个数

纵向扩展好处是,实施起来简单、直接;第一个问题是,当使用 RDB 对数据进行持久化时,如果数据量增加,需要的内存也会增加,主线程 fork 子进程时就可能会阻塞;第二个问题:纵向扩展会受到硬件和成本的上限限制

在面向百万、千万级别的用户规模时,横向扩展的 Redis 切片集群会是一个非常好的选择。

切片集群多个实例的分布式管理问题

从 3.0 开始,官方提供了一个名为 Redis Cluster 的方案,用于实现切片集群。Redis Cluster 方案中就规定了数据和实例的对应规则。

Redis Cluster 方案采用哈希槽(Hash Slot,接下来我会直接称之为 Slot),来处理数据和实例之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。例如,如果集群中有 N 个实例,那么,每个实例上的槽个数为 16384/N 个。

客户端如何定位数据?

客户端和集群实例建立连接后,实例就会把哈希槽的分配信息发给客户端。客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。

另外,集群的实例增减,或者是为了实现负载均衡而进行的数据重新分布,会导致哈希槽和实例的映射关系发生变化,客户端发送请求时,会收到命令执行报错信息。了解了 MOVED 和 ASK 命令,你就不会为这类报错而头疼了。

问题:为什么 Redis 不直接用一个表,把键值对和实例的对应关系记录下来?

如果使用表记录键值对和实例的对应关系,一旦键值对和实例的对应关系发生了变化(例如实例有增减或者数据重新分布),就要修改表。如果是单线程操作表,那么所有操作都要串行执行,性能慢;如果是多线程操作表,就涉及到加锁开销。此外,如果数据量非常大,使用表记录键值对和实例的对应关系,需要的额外存储空间也会增加。基于哈希槽计算时,虽然也要记录哈希槽和实例的对应关系,但是哈希槽的个数要比键值对的个数少很多,无论是修改哈希槽和实例的对应关系,还是使用额外空间存储哈希槽和实例的对应关系,都比直接记录键值对和实例的关系的开销小得多。

29 并发访问控制:原子操作

并发访问中需要对什么进行控制

并发访问控制,是指对多个客户端访问操作同一份数据的过程进行控制,以保证任何一个客户端发送的操作在 Redis 实例上执行时具有互斥性。例如,客户端 A 的访问操作在执行时,客户端 B 的操作不能执行,需要等到 A 的操作结束后,才能执行。

加锁会导致系统并发性能降低,和加锁类似,原子操作也能实现并发控制,但是原子操作对系统并发性能的影响较小

如果我们执行的 RMW 操作是对数据进行增减值的话,Redis 提供的原子操作 INCR 和 DECR 可以直接帮助我们进行并发控制;

如果我们有多个操作要执行,但是又无法用 INCR/DECR 这种命令操作来实现,可以把这些要执行的操作编写到一个 Lua 脚本中。然后,我们可以使用 Redis 的 EVAL 命令来执行脚本

30 使用Redis实现分布式锁

Redis 属于分布式系统,当有多个客户端需要争抢锁时,我们必须要保证,这把锁不能是某个客户端本地的锁。否则的话,其它客户端是无法访问这把锁的,当然也就不能获取这把锁了,此时,锁是保存在一个共享存储系统中的,可以被多个客户端共享访问和获取。

基于单个 Redis 节点实现分布式锁

SETNX 命令,在执行时会判断键值对是否存在,如果不存在,就设置键值对的值,如果存在,就不做任何设置。

我们就可以用 SETNX 和 DEL 命令组合来实现加锁和释放锁操作,同时给锁变量设置一个过期时间防止没主动释放;

// 加锁, unique_value作为客户端唯一性的标识
SET lock_key unique_value NX PX 10000

基于多个 Redis 节点实现高可靠的分布式锁

为了避免 Redis 实例故障而导致的锁无法工作的问题,Redis 的开发者 Antirez 提出了分布式锁算法 Redlock

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作。这里的依次加锁操作和在单实例上执行的加锁操作一样,使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。

练习

我们是可以用下面的方式来实现加锁操作呢

// 加锁
SETNX lock_key unique_value
EXPIRE lock_key 10S
// 业务逻辑
DO THINGS

不可以这么使用。使用 2 个命令无法保证操作的原子性,在异常情况下,加锁结果会不符合预期。

31 Redis事务机制能实现ACID属性吗

事务是数据库的一个重要功能。所谓的事务,就是指对数据进行读写的一系列操作。事务在执行时,会提供专门的属性保证,包括原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),也就是 ACID 属性。

原子性。原子性的要求很明确,就是一个事务中的多个操作必须都完成,或者都不完成。

一致性。就是指数据库中的数据从一个正确的状态到另一个正确的状态,没有约束条件被破坏

隔离性。它要求数据库在执行一个事务时,其它操作无法存取到正在执行事务访问的数据。

持久性。数据库执行事务后,数据的修改要被持久化保存下来。

Redis 如何实现事务

事务的执行过程包含三个步骤,Redis 提供了 MULTI、EXEC 两个命令来完成这三个步骤。

第一步,客户端要使用一个命令显式地表示一个事务的开启。在 Redis 中,这个命令就是 MULTI。

第二步,客户端把事务中本身要执行的具体操作(例如增删改数据)发送给服务器端。这些操作就是 Redis 本身提供的数据读写命令,例如 GET、SET 等。不过,这些命令虽然被客户端发送到了服务器端,但 Redis 实例只是把这些命令暂存到一个命令队列中,并不会立即执行。

第三步,客户端向服务器端发送提交事务的命令,让数据库实际执行第二步中发送的具体操作。Redis 提供的 EXEC 命令就是执行事务提交的。

#开启事务
127.0.0.1:6379> MULTI
OK
#将a:stock减1,
127.0.0.1:6379> DECR a:stock
QUEUED
#将b:stock减1
127.0.0.1:6379> DECR b:stock
QUEUED
#实际执行事务
127.0.0.1:6379> EXEC
1) (integer) 4
2) (integer) 9

35 Codis 对比 Redis Cluster

Codis 和 Redis Cluster 的选型考虑:

  1. 从稳定性和成熟度来看,Codis 应用得比较早,在业界已经有了成熟的生产部署。虽然 Codis 引入了 proxy 和 Zookeeper,增加了集群复杂度,但是,proxy 的无状态设计和 Zookeeper 自身的稳定性,也给 Codis 的稳定使用提供了保证。而 Redis Cluster 的推出时间晚于 Codis,相对来说,成熟度要弱于 Codis,如果你想选择一个成熟稳定的方案,Codis 更加合适些。
  2. 从业务应用客户端兼容性来看,连接单实例的客户端可以直接连接 codis proxy,而原本连接单实例的客户端要想连接 Redis Cluster 的话,就需要开发新功能。所以,如果你的业务应用中大量使用了单实例的客户端,而现在想应用切片集群的话,建议你选择 Codis,这样可以避免修改业务应用中的客户端。
  3. 从使用 Redis 新命令和新特性来看,Codis server 是基于开源的 Redis 3.2.8 开发的,所以,Codis 并不支持 Redis 后续的开源版本中的新增命令和数据类型。另外,Codis 并没有实现开源 Redis 版本的所有命令,比如 BITOP、BLPOP、BRPOP,以及和与事务相关的 MUTLI、EXEC 等命令。Codis 官网上列出了不被支持的命令列表,你在使用时记得去核查一下。所以,如果你想使用开源 Redis 版本的新特性,Redis Cluster 是一个合适的选择。
  4. 从数据迁移性能维度来看,Codis 能支持异步迁移,异步迁移对集群处理正常请求的性能影响要比使用同步迁移的小。所以,如果你在应用集群时,数据迁移比较频繁的话,Codis 是个更合适的选择。

Codis 集群包含 codis server、codis proxy、Zookeeper、codis dashboard 和 codis fe 这四大类组件

  • codis proxy 和 codis server 负责处理数据读写请求,其中,codis proxy 和客户端连接,接收请求,并转发请求给 codis server,而 codis server 负责具体处理请求。
  • codis dashboard 和 codis fe 负责集群管理,其中,codis dashboard 执行管理操作,而 codis fe 提供 Web 管理界面。
  • Zookeeper 集群负责保存集群的所有元数据信息,包括路由表、proxy 实例信息等。这里,有个地方需要你注意,除了使用 Zookeeper,Codis 还可以使用 etcd 或本地文件系统保存元数据信息。

37 数据倾斜

数据倾斜有两类:

数据量倾斜:在某些情况下,实例上的数据分布不均衡,某个实例上的数据特别多。

数据访问倾斜:虽然每个集群实例上的数据量相差不大,但是某个实例上的数据是热点数据,被访问得非常频繁。

实用项目

28 Pika: 基于SSD实现大容量Redis

Redis 使用内存保存数据,内存容量增加后,就会带来两方面的潜在问题,分别是,内存快照 RDB 生成和恢复效率低,以及主从节点全量同步时长增加、缓冲区易溢出。

跟 Redis 相比,Pika 的好处非常明显:既支持 Redis 操作接口,又能支持保存大容量的数据。如果你原来就在应用 Redis,现在想进行扩容,那么,Pika 无疑是一个很好的选择,无论是代码迁移还是运维管理,Pika 基本不需要额外的工作量。

不过,Pika 毕竟是把数据保存到了 SSD 上,数据访问要读写 SSD,所以,读写性能要弱于 Redis。针对这一点,我给你提供两个降低读写 SSD 对 Pika 的性能影响的小建议:

利用 Pika 的多线程模型,增加线程数量,提升 Pika 的并发请求处理能力;

为 Pika 配置高配的 SSD,提升 SSD 自身的访问性能。

36 Redis支撑秒杀场景的关键技术和实践

秒杀场景的负载特征对支撑系统的要求

第一个特征是瞬时并发访问量非常高。

第二个特征是读多写少,而且读操作是简单的查询操作。

Redis 可以在秒杀场景的哪些环节发挥作用

  • 秒杀活动前:

用户会不断刷新商品详情页,应对方案,一般是尽量把商品详情页的页面元素静态化,然后使用 CDN 或是浏览器把这些静态化的元素缓存起来,不需要使用 Redis

  • 秒杀活动开始:

这个阶段的操作就是三个:库存查验、库存扣减和订单处理。

查验和扣减商品库存,库存查验面临大量的高并发请求,而库存扣减又需要和库存查验一起执行,以保证原子性。这就是秒杀对 Redis 的需求。

订单处理会涉及支付、商品出库、物流等多个关联操作,这些操作本身涉及数据库中的多张数据表,要保证处理的事务性,需要在数据库中完成。

  • 秒杀活动结束后

这个阶段中的用户请求量已经下降很多了,服务器端一般都能支撑,我们就不重点讨论了

基于原子操作支撑秒杀场景

在秒杀场景中,一个商品的库存对应了两个信息,分别是总库存量和已秒杀量

我们可以使用一个 Hash 类型的键值对来保存库存的这两个信息

key: itemID
value: {total: N, ordered: M}

其中,itemID 是商品的编号,total 是总库存量,ordered 是已秒杀量。

因为库存查验和库存扣减是两个操作,无法用一条命令来完成,所以,我们就需要使用 Lua 脚本原子性地执行这两个操作。

Lua 脚本写的伪代码:

#获取商品库存信息            
local counts = redis.call("HMGET", KEYS[1], "total", "ordered");
#将总库存转换为数值
local total = tonumber(counts[1])
#将已被秒杀的库存转换为数值
local ordered = tonumber(counts[2])  
#如果当前请求的库存量加上已被秒杀的库存量仍然小于总库存量,就可以更新库存         
if ordered + k <= total then
    #更新已秒杀的库存量
    redis.call("HINCRBY",KEYS[1],"ordered",k)                              return k;  
end               
return 0

有了 Lua 脚本后,我们就可以在 Redis 客户端,使用 EVAL 命令来执行这个脚本了。最后,客户端会根据脚本的返回值,来确定秒杀是成功还是失败了。如果返回值是 k,就是成功了;如果是 0,就是失败。

基于分布式锁来支撑秒杀场景

具体做法是,先让客户端向 Redis 申请分布式锁,只有拿到锁的客户端才能执行库存查验和库存扣减。

伪代码:

//使用商品ID作为key
key = itemID
//使用客户端唯一标识作为value
val = clientUniqueID
//申请分布式锁,Timeout是超时时间
lock =acquireLock(key, val, Timeout)
//当拿到锁后,才能进行库存查验和扣减
if(lock == True) {
   //库存查验和扣减
   availStock = DECR(key, k)
   //库存已经扣减完了,释放锁,返回秒杀失败
   if (availStock < 0) {
      releaseLock(key, val)
      return error
   }
   //库存扣减成功,释放锁
   else{
     releaseLock(key, val)
     //订单处理
   }
}
//没有拿到锁,直接返回
else
   return

在使用分布式锁时,客户端需要先向 Redis 请求锁,只有请求到了锁,才能进行库存查验等操作,这样一来,客户端在争抢分布式锁时,大部分秒杀请求本身就会因为抢不到锁而被拦截。

41 Redis和Memcached对比

Memcached 有一个明显的优势,就是它的集群规模可以很大.

使用一致性哈希算法把数据分散保存到多个实例上,而一致性哈希的优势就是可以支持大规模的集群。

Redis运维工具

最基本的监控命令:INFO 命令

INFO 命令在使用时,可以带一个参数 section,这个参数的取值有好几种

面向 Prometheus 的 Redis-exporter 监控

Prometheus是一套开源的系统监控报警框架。它的核心功能是从被监控系统中拉取监控数据,结合Grafana工具,进行可视化展示。而且,监控数据可以保存到时序数据库中,以便运维人员进行历史查询。同时,Prometheus 会检测系统的监控指标是否超过了预设的阈值,一旦超过阈值,Prometheus 就会触发报警。

Prometheus 正好提供了插件功能来实现对一个系统的监控,我们把插件称为 exporter,每一个 exporter 实际是一个采集监控数据的组件。exporter 采集的数据格式符合 Prometheus 的要求,Prometheus 获取这些数据后,就可以进行展示和保存了。

Redis-exporter就是用来监控 Redis 的,它将 INFO 命令监控到的运行状态和各种统计信息提供给 Prometheus,从而进行可视化展示和报警设置。目前,Redis-exporter 可以支持 Redis 2.0 至 6.0 版本,适用范围比较广。

posted @ 2022-04-13 17:45  字符串爱了数组  阅读(395)  评论(0编辑  收藏  举报