diff --git a/notes/Redis.md b/notes/Redis.md index 7d2ecf57..1387a75f 100644 --- a/notes/Redis.md +++ b/notes/Redis.md @@ -63,15 +63,17 @@ * [A12 长时间运行的 Lua 脚本](#a12-长时间运行的-lua-脚本) * [A13 槽迁移与 ASK 重定向](#a13-槽迁移与-ask-重定向) * [A14 HASH 编码的转换](#a14-hash-编码的转换) + * [A15 主从复制的全量复制与部分重同步](#a15-主从复制的全量复制与部分重同步) + * [A16 字符串的 int 与 embstr 编码](#a16-字符串的-int-与-embstr-编码) + * [A17 AOF 文件的内容与快照失败](#a17-aof-文件的内容与快照失败) + * [A18 写满内存后的淘汰](#a18-写满内存后的淘汰) * [参考资料](#参考资料) ## 一、概述 -Redis 是速度非常快的非关系型(NoSQL)内存键值数据库,可以存储键和五种不同类型的值之间的映射。 - -键的类型只能为字符串,值支持五种基本数据类型:字符串、列表、集合、哈希、有序集合,此外还有位图、HyperLogLog、地理空间、流(Stream)等类型。 +Redis 是速度非常快的非关系型(NoSQL)内存键值数据库。键的类型只能为字符串,值支持五种基本数据类型:字符串、列表、集合、哈希、有序集合,此外还有位图、HyperLogLog、地理空间、流(Stream)等类型。 Redis 支持很多特性,例如将内存中的数据持久化到硬盘中,使用复制来扩展读性能,使用分片来扩展写性能。 @@ -84,6 +86,8 @@ Redis 支持很多特性,例如将内存中的数据持久化到硬盘中, 数据都在内存中时,MySQL 按主键查询也很快。用 50 个连接分别执行 20 万次同一条主键查询和同一个键的 GET,两者每秒都能处理 13 万~17 万次,但 MySQL 用了约 6 个 CPU 核心,每次查询消耗约 36 微秒的 CPU 时间;Redis 只用 1 个核心,每次约 7 微秒(MySQL 8.0、Redis 7.4 实测,见[附录 A1](#a1-mysql-与-redis-的主键查询))。Redis 的优势在于开销小、延迟稳定,以及计数器、排行榜这类 MySQL 不擅长的数据结构;需要持久保存、按多种条件查询的业务数据仍然放在 MySQL 中。两者通常一起使用,Redis 作为缓存,见 [缓存与数据库的一致性](缓存.md#缓存一致性)。 +与本地缓存(例如 Caffeine)相比,本地缓存在应用进程内,读取不需要网络往返,但每个实例各存一份,更新后难以保持一致,容量也受单机内存限制;Redis 由所有实例共享同一份数据,还能实现分布式锁、全局计数这类需要多个实例协调的功能。两者常组合成[多级缓存](缓存.md#多级缓存)。 + ## 二、数据类型 🔥🔥 | 数据类型 | 可以存储的值 | 操作 | @@ -104,6 +108,8 @@ Redis 支持很多特性,例如将内存中的数据持久化到硬盘中, 签到、日活和独立访客数这类统计用 BITMAP 和 HyperLogLog,附近的门店用 GEO,需要消费者组和确认的消息用 Stream。 +需要注意的是,LIST、SET、HASH、ZSET 的元素只增不减时会变成大 Key,对它执行 HGETALL、DEL 会阻塞其它命令,见 [大 Key 与热 Key](缓存.md#大-key-与热-key)。 + ### STRING

@@ -293,12 +299,12 @@ SETBIT 会把字符串扩展到 offset 所在的字节,中间的内存一次 解决方案: - 先把 ID 转换成从 0 开始的连续序号,例如减去起始值,或者为用户另外分配一个自增序号; -- 位图很大时按序号分段拆成多个键,键名为序号 / M,offset 为序号 mod M,避免单个大键; +- 位图很大时按序号分段拆成多个键,键名为序号 / M,offset 为序号 mod M,避免单个大 Key; - 活跃的 ID 很少时改用 SET 或 HyperLogLog:1000 个分布在 1 亿以内的 ID,位图要 12.5 MB,SET 只要 30 KB。按实测每个整数成员在 SET 中约占 38 字节(304 位),活跃 ID 不到 ID 范围的 1/300 时 SET 更省内存。 #### 4. 与布隆过滤器的区别 -Bitmap 与布隆过滤器的区别在于映射方式:Bitmap 一个整数对应一位,结果是精确的,但成员必须能编号成范围不大的整数;布隆过滤器用多个哈希函数把任意字符串映射到位数组的多个位置,可以保存 URL、手机号这类无法编号的 Key,代价是存在误判,见 [缓存穿透](缓存.md#缓存穿透)。所以 ID 是自增整数时,可以直接用 Bitmap 记录哪些 ID 存在来拦截不存在的请求;其它 Key 需要布隆过滤器,它的位数组可以存放在 Redis 的位图中,由客户端算出各个位置后用 SETBIT 写入、GETBIT 检查。 +Bitmap 与布隆过滤器的区别在于映射方式:Bitmap 一个整数对应一位,结果是精确的,但成员必须能编号成范围不大的整数;布隆过滤器用多个哈希函数把任意字符串映射到位数组的多个位置,可以保存 URL、手机号这类无法编号的 Key,代价是存在误判,见 [缓存穿透](缓存.md#缓存穿透)。所以 ID 是自增整数时,可以直接用 Bitmap 记录哪些 ID 存在来拦截不存在的请求;其它 Key 需要布隆过滤器,它的位数组可以存放在 Redis 的位图中,由客户端算出各个位置后用 SETBIT 写入、GETBIT 检查;Redis 8 起也可以直接用内置的 BF.ADD、BF.EXISTS。 ### HyperLogLog 与 GEO @@ -324,7 +330,7 @@ GEO 用于“附近的门店”这类查询:GEOADD 保存经纬度,GEOSEARCH ### 缓存 -将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率。缓存与数据库的一致性,以及缓存穿透、击穿和雪崩的处理,见 [缓存](缓存.md)。 +将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率,见 [七、数据淘汰策略](#七数据淘汰策略)。缓存与数据库的一致性,以及缓存穿透、击穿和雪崩的处理,见 [缓存](缓存.md)。 ### 查找表 @@ -340,15 +346,17 @@ Redis 5.0 加入的流(Stream)支持消费者组和确认:XREADGROUP 读 ### 会话缓存 -可以使用 Redis 来统一存储多台应用服务器的会话信息。 +多台应用服务器把会话统一存储在 Redis 中,应用服务器本身不再具有状态,一个用户可以请求任意一台服务器,从而更容易实现高可用性以及可伸缩性。 -当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用性以及可伸缩性。 +例如用户 1001 登录后,服务器生成随机令牌 9f2c7a41(缩写,实际用安全随机数生成至少 128 位)返回给客户端,并执行 SET session:9f2c7a41 1001 EX 1800。之后的请求都带上这个令牌,服务器用 GETEX session:9f2c7a41 EX 1800 查出用户 ID 并把有效期顺延 30 分钟,查不到就要求重新登录。 + +多设备登录时,可以用 HSET user:1001:sessions phone 9f2c7a41 按设备类型记录令牌。每类设备只能登录一台时,新手机登录先删除旧令牌对应的 session 键再写入新令牌,并发登录时用 [WATCH](#九事务) 防止两个新令牌都生效。退出所有设备或封禁账号时,删除 HASH 中每个令牌对应的 session 键,会话立即失效,这是它相对 [JWT](HTTP.md#11-jwt) 的优势。 ### 分布式锁实现 在分布式场景下,无法使用单机环境下的锁来对多个节点上的进程进行同步。 -可以使用 SET 命令的 NX 和 PX 选项实现单实例的分布式锁,除此之外,还可以使用官方提出的 RedLock 算法在多个独立实例上实现分布式锁,详见 [分布式](分布式.md)。 +可以使用 SET 命令的 NX 和 PX 选项实现单实例的分布式锁,见 [Redis 的 SET NX 指令](分布式.md#redis-的-set-nx-指令);除此之外,还可以使用官方提出的 RedLock 算法在多个独立实例上实现分布式锁,见 [Redis 的 RedLock 算法](分布式.md#redis-的-redlock-算法)。 ### 排行榜 @@ -382,8 +390,8 @@ Set 可以实现交集、并集等操作,从而实现共同好友等功能。 | --- | --- | --- | --- | | STRING | int(整数)、embstr | raw | 44 字节 | | LIST | listpack | quicklist(多个 listpack 组成的链表) | 单个 listpack 超过 8 KB | -| HASH | listpack | hashtable | 512 个字段,或者有值超过 64 字节 | -| SET | intset(全是整数)、listpack | hashtable | 整数 512 个,其它 128 个 | +| HASH | listpack | hashtable | 512 个字段,或者有字段名、值超过 64 字节 | +| SET | intset(全是整数)、listpack | hashtable | 整数 512 个;其它 128 个,或者有成员超过 64 字节 | | ZSET | listpack | skiplist(跳跃表 + 字典) | 128 个成员,或者有成员超过 64 字节 | 例如一个 HASH 有 512 个字段时编码为 listpack,再写入一个字段就变为 hashtable。ZSET 转换后同时使用跳跃表和字典:字典按成员查分数,跳跃表按分数排序和范围查询。 @@ -402,6 +410,8 @@ Redis 7.0 之前用的是压缩列表(ziplist),每个元素要记录前一 embstr 编码把对象头和 SDS 放在同一块内存中,只需分配一次,44 字节的上限正好让两者一起放进 64 字节;它是只读的,APPEND 等修改会先把它转为 raw。追加时实际分配的内存见[进阶:对象编码](Redis%20-%20进阶.md#对象编码)。 +值能无损转换为 64 位有符号整数时使用 int 编码(001234、+5 这类写法除外,APPEND 后变为 raw),整数直接存放在对象头的指针字段中,不再分配 SDS,0~9999 还默认共用启动时创建好的对象。所以 1234 比 abcd 省内存,MEMORY USAGE 分别为 56 和 72 字节,其中包括键名和字典项(Redis 7.4 实测,见[附录 A16](#a16-字符串的-int-与-embstr-编码))。 + ### 字典 键空间、HASH 和 SET 的 hashtable 编码、ZSET 中按成员查分数的部分,都是用字典(dict)实现的。字典是一个哈希表,用拉链法解决哈希冲突:桶的数量是 2 的幂,键的哈希值与桶数减 1 做位与得到桶的下标,落在同一个桶的键用链表连起来。 @@ -412,7 +422,7 @@ embstr 编码把对象头和 SDS 放在同一块内存中,只需分配一次 跳跃表(skiplist)是有序集合在成员较多时的底层结构,它在一条有序链表上增加了多层索引。 -最底层是包含所有成员、按分值排序的链表。每个节点插入时随机决定层数:以 1/4 的概率升高一层,最多 32 层。所以大约每 4 个节点有 1 个出现在第 2 层,每 16 个节点有 1 个出现在第 3 层,平均每个节点约 1.33 个指针。 +最底层是包含所有成员、按分值排序的链表。每个节点插入时随机决定层数:以 1/4 的概率升高一层,最多 32 层。所以大约每 4 个节点有 1 个出现在第 2 层,每 16 个节点有 1 个出现在第 3 层,平均每个节点约 1.33 个指针。这只是期望值,好处是插入节点时不需要调整其它节点的层数。 查找时从最高层开始向右走,下一个节点比目标大时下降一层,直到最底层。每层平均只走几步,查找、插入和删除的期望时间复杂度都是 O(logN)。下图演示了查找 26 的过程。 @@ -426,6 +436,10 @@ embstr 编码把对象头和 SDS 放在同一块内存中,只需分配一次 MySQL 的索引用 B+ 树,是因为 B+ 树的一个节点存放很多个键,每次读磁盘都取回一整页,减少 I/O 次数;有序集合全部在内存中,没有这个需要。Redis 的作者解释选择跳跃表时还提到:调整升层的概率就能控制内存,按 1/4 升层时每个节点平均只有 1.33 个指针;ZRANGE 这类范围遍历沿底层链表进行,缓存局部性不比平衡树差。跳跃表的代价是性能依赖随机数,最坏情况下退化为 O(N),只是概率极小。 +插入时先像查找一样从最高层往下走,记下每一层最后一个排在新成员前面的节点(先比分值,分值相同再比成员名),再随机决定新节点的层数,把它接在这些节点的后面。例如在上图中插入 24,三层记下的节点依次是 15、22、22;随机得到 2 层时,新节点接在第 0 层和第 1 层的 22 之后,第 2 层不变。 + +删除时同样先记下每一层的前驱,在被删节点所在的各层让前驱指向它的后继,其它层只把跨度减 1。ZINCRBY 修改分值后位置不变时直接改值,否则先删除再插入。 + Redis 的跳跃表还在每个指针上记录了跨度,即这一步跳过了多少个节点,查找时把经过的跨度相加就得到排名,所以 ZRANK 也是 O(logN)。 ## 五、Redis 与 Memcached @@ -442,7 +456,7 @@ Redis 支持两种持久化策略:RDB 快照和 AOF 日志,而 Memcached 不 ### 分布式 -Memcached 不支持分布式,只能通过在客户端使用一致性哈希来实现分布式存储,这种方式在存储和查询时都需要先在客户端计算一次数据所在的节点。 +Memcached 不支持分布式,只能通过在客户端使用[一致性哈希](缓存.md#六一致性哈希)来实现分布式存储,这种方式在存储和查询时都需要先在客户端计算一次数据所在的节点。 Redis Cluster 实现了分布式的支持。 @@ -468,28 +482,30 @@ Redis 可以为每个键设置过期时间,过期时间保存在一个单独 ## 七、数据淘汰策略 🔥 -可以设置内存最大使用量,当内存使用量超出时,会施行数据淘汰策略。 +过期删除只清理已经过期的键;内存淘汰是在内存达到上限时删除一部分键来腾出空间,被删除的键可能还没有过期。 + +上限由 maxmemory 设置,默认为 0,在 64 位系统上表示不限制,内存会一直增长到用上 swap 或被操作系统杀掉,所以作缓存时要设置它。设置之后,Redis 在执行每条命令之前检查内存,超出上限就先按 maxmemory-policy 淘汰键,再执行命令。 Redis 4.0 之后具体有 8 种淘汰策略: | 策略 | 描述 | | :--: | :--: | -| volatile-lru | 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰 | +| volatile-lru | 从已设置过期时间的数据集中挑选最久未使用的数据淘汰 | | volatile-ttl | 从已设置过期时间的数据集中挑选将要过期的数据淘汰 | -|volatile-random | 从已设置过期时间的数据集中任意选择数据淘汰 | -| allkeys-lru | 从所有数据集中挑选最近最少使用的数据淘汰 | +| volatile-random | 从已设置过期时间的数据集中任意选择数据淘汰 | +| allkeys-lru | 从所有数据集中挑选最久未使用的数据淘汰 | | allkeys-random | 从所有数据集中任意选择数据进行淘汰 | | volatile-lfu | 从已设置过期时间的数据集中挑选最不经常使用的数据淘汰 | | allkeys-lfu | 从所有数据集中挑选最不经常使用的数据淘汰 | -| noeviction | 禁止驱逐数据 | +| noeviction | 不淘汰数据 | Redis 8.6 又增加了 allkeys-lrm 和 volatile-lrm,按最近一次修改的时间淘汰,读取不更新这个时间。 -默认策略是 noeviction:内存达到上限后,写命令返回错误,读命令照常执行。volatile 开头的策略只淘汰设置了过期时间的键,没有这样的键时,表现得和 noeviction 一样。 +默认策略是 noeviction:内存超过上限后,SET 这类可能增加内存的命令返回 OOM 错误,GET、DEL 等命令照常执行。volatile 开头的策略只淘汰设置了过期时间的键,没有这样的键时,表现得和 noeviction 一样。例如 maxmemory 为 10 MB 时连续写入 10 万个 1 KB 的值:allkeys-lru 下全部成功,最后剩下 5733 个键,最早的 k:0 已被淘汰;noeviction 下从第 5759 次 SET 开始返回 OOM 错误;键都没有过期时间时,volatile-lru 与 noeviction 相同(Redis 7.4 实测,见[附录 A18](#a18-写满内存后的淘汰))。 -Redis 的 LRU 和 LFU 都是近似实现。精确的 LRU 要用双向链表维护所有键的访问顺序(见 [LRU](缓存.md#七lru)),每个键多两个指针,每次访问都要移动节点。Redis 只在每个对象中用 24 位记录最近一次访问的时间(秒级),淘汰时随机抽取 maxmemory-samples(默认 5)个键,放入一个大小为 16 的候选池,淘汰池中空闲时间最长的键。抽样数调到 10 时已经很接近精确的 LRU,代价是多耗一些 CPU。 +Redis 的 LRU 和 LFU 都是近似实现。精确的 LRU 要用双向链表维护所有键的访问顺序(见 [LRU](缓存.md#七lru)),每个键多两个指针,每次访问都要移动节点。Redis 只在每个对象中用 24 位记录最近一次访问的时间(秒级),淘汰时随机抽取 maxmemory-samples(默认 5)个键,按空闲时间放入一个大小为 16 的候选池,淘汰池中空闲时间最长的键。候选池在多次淘汰之间保留,之前抽到的冷键继续参与比较,所以比每次只看 5 个键更准。抽样数调到 10 时更接近精确的 LRU,代价是多耗一些 CPU。 -LFU 复用这 24 位:高 16 位是上次访问的时间(分钟),低 8 位是对数计数器,最大为 255。新键的计数为 5,每次访问以 1 / ((计数 − 5) × lfu-log-factor + 1) 的概率加 1,计数越大越难增长;距离上次访问每过 lfu-decay-time 分钟(默认 1),计数减 1,过去的热点会逐渐冷却。例如对一个键执行 10、100、1000、1 万、10 万、100 万次 GET 后,OBJECT FREQ 依次为 6、10、20、52、146、255(Redis 7.4 实测,见[附录 A10](#a10-lfu-计数器))。 +LFU 复用这 24 位:高 16 位是上次访问的时间(分钟),低 8 位是对数计数器,最大为 255。新键的计数为 5,每次访问以 1 / (max(计数 − 5, 0) × lfu-log-factor + 1) 的概率加 1,计数越大越难增长;距离上次访问每过 lfu-decay-time 分钟(默认 1),计数减 1,最低减到 0,过去的热点会逐渐冷却。衰减不靠定时遍历,而是在访问或淘汰比较这个键时按经过的分钟数一次算出。例如对一个键执行 10、100、1000、1 万、10 万、100 万次 GET 后,OBJECT FREQ 依次为 6、10、20、52、146、255(Redis 7.4 实测,见[附录 A10](#a10-lfu-计数器))。 选择策略时: @@ -507,19 +523,19 @@ Redis 是内存型数据库,为了保证数据在断电后不会丢失,需 save 配置的默认值为 3600 1 300 100 60 10000,表示 1 小时内至少有 1 次修改、5 分钟内至少 100 次、1 分钟内至少 1 万次,满足任意一个条件就自动生成一次快照(Redis 7.4 实测,见[附录 A11](#a11-持久化与脚本的默认配置))。SAVE 命令在主进程中生成快照,期间不处理其它命令,所以一般使用 BGSAVE。 -执行 BGSAVE 或满足 save 配置的条件时,Redis fork 一个子进程把内存中的数据写入 RDB 文件,主进程继续处理命令。fork 之后父子进程共享同一份内存页,主进程修改某一页时操作系统才复制这一页(写时复制),所以生成快照期间写入越多,额外占用的内存越多。 +执行 BGSAVE 或满足 save 配置的条件时,Redis fork 一个子进程把内存中的数据写入 RDB 文件,主进程继续处理命令。fork 之后父子进程共享同一份内存页,主进程修改某一页时操作系统才复制这一页(写时复制),所以子进程写出的是 fork 那一刻的数据,代价是快照期间写入越多,额外占用的内存越多。 -可以将快照复制到其它服务器从而创建具有相同数据的服务器副本。 +

-如果系统发生故障,将会丢失最后一次创建快照之后的数据。 +RDB 文件是紧凑的二进制文件,便于备份,全量复制发送的也是 RDB 快照。系统发生故障时,最后一次快照之后的写入都会丢失。 -如果数据量很大,保存快照的时间会很长。 +需要注意的是,磁盘满、目录没有写权限时快照会失败。stop-writes-on-bgsave-error 默认开启,配置了 save 条件时,最近一次 BGSAVE 失败后 Redis 拒绝写命令并返回 MISCONF 错误,读命令照常执行,直到下一次快照成功,让应用立刻发现持久化出了问题(Redis 7.4 实测,见[附录 A17](#a17-aof-文件的内容与快照失败))。 ### AOF 持久化 将写命令添加到 AOF 文件(Append Only File)的末尾。AOF 默认关闭,需要配置 appendonly yes 开启。 -使用 AOF 持久化需要设置同步选项,从而确保写命令同步到磁盘文件上的时机。这是因为对文件进行写入并不会马上将内容同步到磁盘上,而是先存储到缓冲区,然后由操作系统决定什么时候同步到磁盘。有以下同步选项: +写命令先执行,成功后才追加到内存中的 AOF 缓冲区,执行出错的命令不会进入 AOF。之后主线程在每轮事件循环回复客户端之前,用 write() 把缓冲区写入 AOF 文件。write() 返回时数据只到了操作系统的页缓存,调用 fsync 才写到磁盘,同步选项 appendfsync 决定 fsync 的时机: | 选项 | 同步频率 | | :--: | :--: | @@ -528,12 +544,16 @@ save 配置的默认值为 3600 1 300 100 60 10000,表示 1 小时内至少有 | no | 让操作系统来决定何时同步 | - always 选项会严重降低服务器的性能; -- everysec 选项比较合适,可以保证系统崩溃时只会丢失一秒左右的数据,并且 Redis 每秒执行一次同步对服务器性能几乎没有任何影响; +- everysec 选项比较合适,Redis 每秒执行一次同步对服务器性能几乎没有任何影响,系统崩溃时通常只丢失一秒左右的数据,磁盘繁忙时可能更多; - no 选项并不能给服务器性能带来多大的提升,而且也会增加系统崩溃时数据丢失的数量。 +

+ +需要注意的是,丢失一秒左右指的是机器断电或操作系统崩溃。只有 Redis 进程崩溃时,已经 write() 的数据还在页缓存中,由操作系统写回磁盘,不会丢失;但 everysec 因磁盘繁忙推迟 write()(最多约 2 秒)时,还在 AOF 缓冲区中的写入会丢失。 + 随着服务器写请求的增多,AOF 文件会越来越大。Redis 提供了一种将 AOF 重写的特性,能够去除 AOF 文件中的冗余写命令。 -重写同样由子进程完成,它不读取旧的 AOF 文件,而是按内存中的当前数据生成最少的命令。AOF 文件比上次重写后增长一倍(auto-aof-rewrite-percentage 100),并且不小于 64 MB(auto-aof-rewrite-min-size)时自动重写,也可以执行 BGREWRITEAOF。 +重写同样由子进程完成,它不读取旧的 AOF 文件,而是按内存中的当前数据生成新的基础文件,默认使用 RDB 格式,见下文的混合持久化。AOF 文件比上次重写后增长一倍(auto-aof-rewrite-percentage 100),并且不小于 64 MB(auto-aof-rewrite-min-size)时自动重写,也可以执行 BGREWRITEAOF。 Redis 7.0 起 AOF 由多个文件组成:重写生成的基础文件,以及之后追加写命令的增量文件,由一个清单文件(manifest)记录。重写期间主进程仍在处理写命令:它在开始重写时打开一个新的增量文件,之后的写命令都追加到这里;子进程写完新的基础文件后,主进程生成新的清单文件,原子地替换旧的,再删除旧的基础文件和增量文件。重写失败时,旧的文件加上新打开的增量文件仍是完整的数据。7.0 之前只有一个 AOF 文件,重写期间的写命令先保存在内存的重写缓冲区中,子进程完成后再追加到新文件的末尾。 @@ -544,18 +564,16 @@ Redis 7.0 起 AOF 由多个文件组成:重写生成的基础文件,以及 两种方式的对比: - 丢失的数据:RDB 丢失最后一次快照之后的写入,按 save 配置可能是几分钟;AOF 使用 everysec 时约丢失 1 秒; -- 文件和恢复:RDB 是紧凑的二进制快照,适合备份,数据量大时恢复也快;AOF 记录的是命令,文件通常更大; +- 文件和恢复:RDB 是紧凑的二进制快照,适合备份,数据量大时恢复也快;AOF 的增量文件按协议格式记录文本命令,文件通常更大,但可以直接编辑:误执行 FLUSHALL 后,只要还没有重写,停止服务、备份 AOF 文件,删掉增量文件中的 FLUSHALL 及之后的命令再重启,就能恢复数据; - 开销:RDB 只在生成快照时 fork 子进程,数据量大时 fork 本身可能让主进程停顿;AOF 每次写入都要追加,并且需要定期重写。 能接受丢失几分钟数据时只用 RDB 即可;数据重要时两种都开启。官方文档不建议只用 AOF,因为定期的 RDB 快照便于备份,重启时恢复也更快。只作缓存、数据可以从数据库重建时,也可以都不开启。 ## 九、事务 -一个事务包含了多个命令,服务器在执行事务期间,不会改去执行其它客户端的命令请求。 +Redis 的事务用 MULTI 和 EXEC 把多个命令包围起来:MULTI 之后的命令先放入队列,EXEC 时依次执行,执行期间不会插入其它客户端的命令。 -事务中的命令会先被服务器放入队列,EXEC 时依次执行。流水线(Pipelining)是另一个概念,指客户端不等待响应就连续发送多条命令,从而减少网络往返次数,事务通常和流水线一起使用。 - -Redis 最简单的事务实现方式是使用 MULTI 和 EXEC 命令将事务操作包围起来。 +流水线(Pipelining)是另一个概念,指客户端不等待响应就连续发送多条命令,从而减少网络往返次数,事务通常和流水线一起使用。 Redis 的事务不支持回滚:EXEC 时某条命令执行出错,其它命令照常执行。例如 name 的值是字符串 alice,对它执行 INCR 会出错,但同一事务中的两条 SET 都生效: @@ -578,13 +596,15 @@ QUEUED WATCH 可以实现乐观锁:EXEC 之前被 WATCH 的键被其它客户端修改,EXEC 返回 nil,事务中的命令都不执行,由客户端决定是否重试。 +按 ACID 衡量,Redis 的事务满足隔离性,相当于串行执行;原子性只做到入队出错时都不执行,执行出错时不回滚;持久性取决于[持久化](#八持久化)的配置。 + ### Lua 脚本 🔥 事务中的命令在 EXEC 时才一起执行,不能根据前一条命令的结果决定下一条做什么。Lua 脚本可以做到这一点,并且整个脚本原子执行:Redis 用一个线程执行命令,执行脚本时不处理其它客户端的命令,整个脚本就像一条命令。用事务实现“先读后写”时,只能用 WATCH 检测冲突后重试;流水线只是减少网络往返,几条命令之间仍可能插入其它客户端的命令。 这里的原子性指的是执行期间不被打断,而不是出错时全部回滚,见下文的注意事项。 -例如每个用户每天最多领 3 张券,键为 coupon:1001,有效期一天: +例如每个用户每天最多领 3 张券,键为 coupon:1001:20261030,键名带上日期,第二天自然换成新的键,过期时间只用于清理: ```lua local n = tonumber(redis.call('GET', KEYS[1]) or '0') @@ -598,13 +618,13 @@ end return n + 1 ``` -用 redis-cli --eval coupon.lua coupon:1001 , 3 86400 连续执行 4 次,返回值依次为 1、2、3、0。如果在客户端先 GET 再 INCR,两个请求可能同时读到 2,都领到第 3 张。 +用 redis-cli --eval coupon.lua coupon:1001:20261030 , 3 86400 连续执行 4 次,返回值依次为 1、2、3、0。如果在客户端先 GET 再 INCR,两个请求可能同时读到 2,都领到第 3 张。释放分布式锁时判断并删除、秒杀时判断并扣减库存也是这种用法,见 [Redis 的 SET NX 指令](分布式.md#redis-的-set-nx-指令) 和 [秒杀系统](系统设计基础.md#六秒杀系统)。 需要注意的是: -- 脚本中途出错时,之前执行的写命令不会回滚,之后的命令不再执行; +- 脚本中途出错时,之前执行的写命令不会回滚,之后的命令不再执行,所以参数、键的类型和业务前提要在第一条写命令之前校验完,尽量不在写入后才出错; - 脚本执行期间服务器不处理其它命令,其它客户端的请求一直等待。脚本运行超过 busy-reply-threshold(默认 5 秒)后,服务器才开始处理请求,但除了 SCRIPT KILL 和 SHUTDOWN NOSAVE 都返回 BUSY 错误。SCRIPT KILL 只能终止还没有执行写命令的脚本;已经写过数据的脚本只能等它结束,或者用 SHUTDOWN NOSAVE 关闭服务器并丢弃上次持久化之后的写入(Redis 7.4 实测,见[附录 A12](#a12-长时间运行的-lua-脚本))。所以脚本要短,不要在其中遍历大集合; -- 脚本用到的键要通过 KEYS 传入,集群模式下这些键必须在同一个槽中; +- 脚本用到的键要通过 KEYS 传入,集群模式下这些键必须在同一个槽中,否则报错 CROSSSLOT,可以用[哈希标签](#redis-cluster)让它们落到同一个槽; - 可以先用 SCRIPT LOAD 把脚本缓存在服务器上,之后用 EVALSHA 加脚本的 SHA1 调用,不用每次发送脚本内容。 ## 十、事件 @@ -615,16 +635,22 @@ Redis 服务器是一个事件驱动程序:主线程在一个循环中用 I/O Redis 用一个线程执行所有命令,仍然很快,原因主要有以下几点: -- 数据在内存中,一条 GET 只需要查一次哈希表; +- 数据在内存中,一条 GET 只需要查一次[哈希表](#字典); - 单线程执行命令,不需要加锁,也没有线程切换的开销; -- I/O 多路复用让一个线程同时处理大量连接; +- [I/O 多路复用](Socket.md#二io-复用)让一个线程同时处理大量连接; - 按数据的大小选择不同的编码,例如元素少时用紧凑的 listpack。 -用 redis-benchmark 测试,50 个连接逐条发送 GET,每秒约 16 万次;每次连续发送 16 条命令(流水线)时,每秒约 160 万次。逐条发送 100 万次 GET 时,服务器消耗的 CPU 时间中内核态 5.1 秒、用户态 1.0 秒:大部分时间花在网络读写的系统调用上,解析和执行一条命令只需要 1 微秒左右(Redis 7.4 实测,见[附录 A3](#a3-单线程的吞吐量和-cpu-时间))。 +用 redis-benchmark 测试,50 个连接逐条发送 GET,每秒约 16 万次;每次连续发送 16 条命令(流水线)时,每秒约 160 万次。逐条发送 100 万次 GET 时,服务器消耗的 CPU 时间中内核态 5.1 秒、用户态 1.0 秒:大部分时间花在网络读写的系统调用上,平均每条命令的用户态时间只有约 1 微秒(Redis 7.4 实测,见[附录 A3](#a3-单线程的吞吐量和-cpu-时间))。

-所以 Redis 的瓶颈通常是网络和内存,而不是 CPU。Redis 6.0 加入了 I/O 多线程(io-threads,默认关闭),开启后默认只把写回客户端交给 I/O 线程,读取和解析请求还需要再打开 io-threads-do-reads;Redis 8.0 重新实现了 I/O 多线程,I/O 线程同时负责读取、解析和写回,io-threads-do-reads 被废弃。两种实现中命令都由主线程逐个执行,不会有多个线程同时修改一个键,单条命令的原子性不变。此外,关闭文件、AOF 刷盘和 UNLINK 释放大键的内存这些耗时操作由后台线程完成。 +所以执行命令本身很少成为瓶颈,开销主要在网络读写上:流水线减少了系统调用的次数,吞吐量就提高到原来的 10 倍。 + +Redis 6.0 加入的 I/O 多线程(io-threads,默认关闭)分担的也是这部分开销:多个 I/O 线程并行读取、解析请求和写回响应,命令仍由主线程逐条执行,不会有两个线程同时修改一个键。6.0 和 7.x 默认只把写回交给 I/O 线程,读取和解析还要打开 io-threads-do-reads;8.0 重新实现后三者都由 I/O 线程负责。 + +

+ +除了主线程和 I/O 线程,关闭文件、AOF 刷盘和 UNLINK 释放大 Key 的内存这些耗时操作由后台线程完成,BGSAVE 生成 RDB 快照和重写 AOF 由 fork 出的子进程完成。 需要注意的是,单线程也意味着一条慢命令会让所有客户端等待:执行 KEYS *、对大 Key 执行 HGETALL 或 DEL、运行很长的 Lua 脚本时,其它命令都在排队,见 [大 Key 与热 Key](缓存.md#大-key-与热-key)。 @@ -636,15 +662,17 @@ Redis 用一个线程执行所有命令,仍然很快,原因主要有以下 ### 连接过程 -1. 主服务器创建快照文件,发送给从服务器,并在发送期间使用缓冲区记录执行的写命令。快照文件发送完毕之后,开始向从服务器发送存储在缓冲区中的写命令; +从服务器第一次连接时进行全量复制: -2. 从服务器丢弃所有旧数据,载入主服务器发来的快照文件,之后从服务器开始接受主服务器发来的写命令; +1. 从服务器发送 PSYNC,主服务器 fork 一个子进程生成 RDB 快照发给从服务器,并在这期间把新执行的写命令记在缓冲区中。Redis 7.0 起默认不写磁盘文件,子进程直接把快照通过网络发送(repl-diskless-sync yes); +2. 从服务器清空旧数据,载入快照,再执行缓冲区中的写命令; +3. 之后主服务器每执行一条写命令,就把它发给从服务器。 -3. 主服务器每执行一次写命令,就向从服务器发送相同的写命令。 +全量复制的代价很大,所以主服务器把最近发出的写命令保存在复制积压缓冲区(默认 1 MB)中,并给复制流中的每个字节编号,称为偏移量。从服务器断线重连后,发送 PSYNC 时带上复制 ID 和需要的下一个偏移量(已经复制到的偏移量加 1):复制 ID 与主服务器一致(故障转移后,新的主服务器也接受旧主服务器的复制 ID),并且这个偏移量还在积压缓冲区中,就只发送缺少的部分,称为部分重同步;否则重新进行全量复制。下图是三次连接的结果(Redis 7.4 实测,见[附录 A15](#a15-主从复制的全量复制与部分重同步)): -Redis 7.0 起默认不把快照写入磁盘,而是直接通过网络发送给从服务器(repl-diskless-sync yes)。 +

-以上是第一次连接时的全量复制。从服务器断线重连后,会带上复制 ID 和下一个需要的偏移量(已经复制到的偏移量加 1)发送 PSYNC 命令:如果复制 ID 与主服务器一致(故障转移后,新的主服务器也接受旧主服务器的复制 ID),并且这个偏移量还在主服务器的复制积压缓冲区(默认 1 MB)中,就只发送缺少的部分,称为部分重同步;否则重新进行全量复制。写入量大、断线时间长时,可以调大 repl-backlog-size,避免频繁全量复制。 +积压缓冲区的大小可以按“最长断线时间 × 每秒写入的字节数”估算,例如每秒写入 1 MB、要容忍 60 秒的断线,repl-backlog-size 至少设为 60 MB,避免断线后频繁全量复制。 ### 主从链 @@ -656,11 +684,13 @@ Redis 7.0 起默认不把快照写入磁盘,而是直接通过网络发送给 Sentinel(哨兵)可以监听集群中的服务器,并在主服务器进入下线状态时,自动从从服务器中选举出新的主服务器。 -通常部署 3 个或以上的哨兵,故障转移的过程如下: +哨兵只需要配置主服务器的地址:向它发送 INFO 得到从服务器的列表,通过主从服务器上的 `__sentinel__:hello` 频道发现其它哨兵。 + +通常部署 3 个或以上的哨兵,因为故障转移要得到多数哨兵的授权:只有 2 个时,一个和主服务器一起宕机,剩下的一个得不到多数票;只有 1 个时哨兵自身成了单点。故障转移的过程如下: 1. 主观下线:某个哨兵在 down-after-milliseconds 内没有收到主服务器的有效回复,认为它下线; 2. 客观下线:认为主服务器下线的哨兵数达到配置的 quorum,确认它下线; -3. 选出领头哨兵:哨兵之间投票,得到多数哨兵投票的一个负责这次故障转移,避免多个哨兵同时操作; +3. 选出领头哨兵:发起选举的哨兵把纪元(epoch,相当于 [Raft](分布式.md#单个-candidate-的竞选) 的任期号)加 1 后请求投票,每个哨兵在一个纪元中只投一票、先到先得,得票过半并且不少于 quorum 的哨兵负责这次故障转移,没有选出时用新的纪元重试; 4. 选择新的主服务器:排除断线太久的从服务器,再依次比较优先级(replica-priority,越小越优先)、复制偏移量(数据越新越好)和运行 ID; 5. 切换:让选中的从服务器执行 REPLICAOF NO ONE,其它从服务器改为复制它,客户端从哨兵查询到新的地址;原来的主服务器恢复后成为从服务器。 @@ -668,20 +698,20 @@ Sentinel(哨兵)可以监听集群中的服务器,并在主服务器进入 down-after-milliseconds 为 5 秒时,从主服务器宕机到客户端查到新地址约 6 秒,其中 5 秒是超时检测。新的主服务器保留了旧的复制 ID,另一个从服务器转向它时只需要部分重同步,只发送了 156 字节的积压数据;重启的旧主服务器复制 ID 不匹配,只能全量复制(Redis 7.4 实测,见[附录 A5](#a5-sentinel-故障转移))。 -需要注意的是,主从之间是异步复制,切换时还没有复制到从服务器的写入会丢失。网络分区时,旧的主服务器还可能继续接受写入(脑裂),恢复后它变为从服务器,这段时间的写入也会丢失。可以配置 min-replicas-to-write 1 和 min-replicas-max-lag 10:延迟不超过 10 秒的从服务器少于 1 个时,主服务器拒绝写入并返回 NOREPLICAS,把丢失的范围限制在 10 秒左右。 +需要注意的是,主从之间是异步复制,切换时还没有复制到从服务器的写入会丢失。网络分区时,旧的主服务器还可能继续接受写入(脑裂),恢复后它变为从服务器,这段时间的写入也会丢失。可以配置 min-replicas-to-write 1 和 min-replicas-max-lag 10:延迟不超过 10 秒的从服务器少于 1 个时,主服务器拒绝写入并返回 NOREPLICAS,旧的主服务器与所有从服务器断开后最多再接受约 10 秒的写入;它不保证每次写入都已复制,分区后还有从服务器和旧主在同一侧时也挡不住写入。分布式锁同样会因此丢失,见 [Redis 的 SET NX 指令](分布式.md#redis-的-set-nx-指令)。 ## 十三、分片 -分片是将数据划分为多个部分的方法,可以将数据存储到多台机器里面,这种方法在解决某些问题时可以获得线性级别的性能提升。 +分片是将数据划分为多个部分的方法,可以将数据存储到多台机器里面。 假设有 4 个 Redis 实例 R0,R1,R2,R3,还有很多表示用户的键 user:1,user:2,... ,有不同的方式来选择一个指定的键存储在哪个实例中。 -- 最简单的方式是范围分片,例如用户 id 从 0\~1000 的存储到实例 R0 中,用户 id 从 1001\~2000 的存储到实例 R1 中,等等。但是这样需要维护一张映射范围表,维护操作代价很高。 -- 还有一种方式是哈希分片,使用 CRC32 哈希函数将键转换为一个数字,再对实例数量求模就能知道应该存储的实例。 +- 最简单的方式是范围分片(即[顺序分布](缓存.md#顺序分布)),例如用户 id 从 0\~1000 的存储到实例 R0 中,用户 id 从 1001\~2000 的存储到实例 R1 中,等等。但是这样需要维护一张映射范围表,维护操作代价很高。 +- 还有一种方式是哈希分片,使用 CRC32 哈希函数将键转换为一个数字,再对实例数量求模就能知道应该存储的实例。缺点是实例数量变化时,大部分键对应的实例都会改变,见 [哈希分布](缓存.md#哈希分布)。 根据执行分片的位置,可以分为三种分片方式: -- 客户端分片:客户端使用一致性哈希等算法决定键应当分布到哪个节点; +- 客户端分片:客户端使用[一致性哈希](缓存.md#六一致性哈希)等算法决定键应当分布到哪个节点; - 代理分片:将客户端请求发送到代理上,由代理转发请求到正确的节点上; - 服务器分片:Redis Cluster,使用 CRC16(key) % 16384 计算键所属的哈希槽,再把 16384 个哈希槽分配给各个节点。 @@ -693,15 +723,22 @@ Redis 有三种常见的部署方式,解决的问题不同: - Sentinel:在主从复制的基础上自动监控和故障转移,但仍然只有一个主服务器负责写入,数据量受单机内存限制; - Redis Cluster:把数据分散到多个主节点上,每个主节点有自己的从节点,同时解决容量和高可用的问题。 -Redis Cluster 把键空间分为 16384 个哈希槽,每个主节点负责一部分槽。下图是一个 3 主 3 从的集群(Redis 7.4 实测,见[附录 A4](#a4-redis-cluster)): +Redis Cluster 把键空间分为 16384 个哈希槽,每个主节点负责一部分槽。槽的数量固定,键所在的槽不随节点数变化,增删节点时只迁移一部分槽。取 16384 是因为节点每次心跳都带着一张 16384 位(2 KB)的槽位图,65536 个槽就要 8 KB;而集群建议不超过约 1000 个主节点,16384 个槽已经够分。下图是一个 3 主 3 从的集群(Redis 7.4 实测,见[附录 A4](#a4-redis-cluster)):

- 客户端向不负责这个槽的节点发送命令时,会收到 MOVED 重定向。例如 user:1001 所在的槽 5712 属于 7002,发给 7001 会收到 MOVED 5712 127.0.0.1:7002。客户端会缓存槽与节点的对应关系,之后直接访问正确的节点; -- 一条命令涉及多个键时,这些键必须在同一个槽中,否则报错 CROSSSLOT,Lua 脚本也是如此。可以用哈希标签(hash tag)让相关的键落到同一个槽:只有 {} 中的部分参与计算,{user:1001}:cart 和 {user:1001}:orders 都在槽 5712; -- 主节点宕机后,它的一个从节点会被选为新的主节点。cluster-node-timeout 为 5 秒时,停掉一个主节点后,这部分槽约 7~9 秒无法写入。 +- 一条命令涉及多个键时,这些键必须在同一个槽中,否则报错 CROSSSLOT,Lua 脚本也是如此。可以用哈希标签(hash tag)让相关的键落到同一个槽:只有 {} 中的部分参与计算,{user:1001}:cart 和 {user:1001}:orders 都在槽 5712,但大量键共用同一个哈希标签时会集中到同一个槽,造成数据倾斜; +- 主节点宕机后,它的一个从节点会被选为新的主节点。cluster-node-timeout 为 5 秒时,停掉一个主节点后约 7~9 秒才恢复写入。主节点被判定下线后、从节点接替之前,整个集群都返回 CLUSTERDOWN,因为 cluster-require-full-coverage 默认为 yes,有槽没有节点负责时所有节点都停止服务,设为 no 后其它槽可以继续读写。 -需要注意的是,主从之间是异步复制,主节点宕机前刚写入、还没有复制到从节点的数据会丢失,所以 Redis Cluster 不保证强一致性。 +Redis Cluster 不需要哨兵,节点之间通过集群总线(端口号加 10000)定期互发 PING、PONG,消息中带着自己负责的槽和所知道的其它节点的状态(Gossip 协议),由此完成故障检测和选举。以停掉 7002 为例: + +1. 其它节点超过 cluster-node-timeout 没有收到 7002 的回复,各自把它标记为 PFAIL(疑似下线); +2. 某个节点通过 Gossip 得知多数主节点都认为 7002 疑似下线,把它标记为 FAIL 并广播; +3. 7002 的从节点按复制偏移量排名,数据最新的等待最短(500~1000 毫秒加上排名 × 1 秒),最先发起选举; +4. 从节点把纪元加 1 后请求投票,每个主节点在一个纪元中只投一票,得到多数主节点的票后接管 7002 的槽。 + +需要注意的是,主从之间是异步复制,主节点宕机前刚写入、还没有复制到从节点的数据会丢失,所以 Redis Cluster 不保证[强一致性](分布式.md#一致性)。网络分区时,少数派一侧的主节点超过 cluster-node-timeout 后拒绝写入,所以它在这之前接受的写入可能丢失。 扩容时要把一部分槽迁移到新节点。迁移以键为单位进行,期间一个槽的键一部分在源节点、一部分在目标节点,客户端靠 ASK 重定向找到它们。以把槽 5712 从 7002 迁移到 7003 为例(Redis 7.4 实测,见[附录 A13](#a13-槽迁移与-ask-重定向)): @@ -711,6 +748,8 @@ Redis Cluster 把键空间分为 16384 个哈希槽,每个主节点负责一 - 一条命令的多个键分散在两个节点上时,返回 TRYAGAIN,客户端稍后重试; - 所有键迁移完后,用 CLUSTER SETSLOT 5712 NODE 把槽分配给 7003,之后访问 7002 收到 MOVED 5712 127.0.0.1:7003。 +

+ MOVED 与 ASK 的区别在于:MOVED 表示槽已经归属新的节点,客户端更新本地缓存的槽映射;ASK 只把这一次请求转到新节点,下一次仍然先访问源节点,因为槽中其它的键可能还没有迁走。所以迁移期间请求仍然可以处理,只是多了一次重定向;迁移大 Key 时 MIGRATE 耗时较长,期间两个节点都会阻塞。 ## 十四、一个简单的论坛系统分析 @@ -733,7 +772,7 @@ Redis 没有关系型数据库中的表这一概念来将同种类型的数据 当有用户为一篇文章点赞时,除了要对该文章的 votes 字段进行加 1 操作,还必须记录该用户已经对该文章进行了点赞,防止用户点赞次数超过 1。可以建立文章的已投票用户集合来进行记录。 -为了节约内存,规定一篇文章发布满一周之后,就不能再对它进行投票,而文章的已投票集合也会被删除,可以为文章的已投票集合设置一个一周的过期时间就能实现这个规定。 +为了节约内存,规定一篇文章发布满一周之后,就不能再对它进行投票,文章的已投票集合也随之删除:发布文章时为已投票集合设置一周的过期时间,到期由 Redis 自动删除。集合被删除后 SADD 会重新创建它,所以点赞前还要用 ZSCORE 检查发布时间。

@@ -1178,6 +1217,126 @@ user:1001 listpack 3 个字段那一行的 used_memory 增量包含了客户端缓冲区等一次性的分配,不代表这个键的大小。 +SET 的成员同样以 64 字节为界(set-max-listpack-value 默认为 64),用 redis-cli 验证: + +```text +> sadd s1 a b c +(integer) 3 +> object encoding s1 +"listpack" +> sadd s1 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -- 65 字节 +(integer) 1 +> object encoding s1 +"hashtable" +``` + +### A15 主从复制的全量复制与部分重同步 + +主服务器 6620 先写入 1000 个键,再启动从服务器 6621 并指定 --replicaof 127.0.0.1 6620,两边都是默认配置。之后两次断开复制连接、在主服务器上写入数据,再让从服务器重连: + +```bash +kill -STOP <从服务器的 pid> # 暂停从服务器,它收不到数据,也不会立即重连 +redis-cli -p 6620 client kill type replica # 断开复制连接 +redis-cli -p 6620 set order:1024 shipped # 断开期间的写入,第 3 次改为写入约 2 MB +kill -CONT <从服务器的 pid> # 从服务器发现连接断开,重新发送 PSYNC +``` + +下面是主服务器的日志和 INFO replication(有删减): + +```text +== 1. 第一次连接 +* Full resync requested by replica 127.0.0.1:6621 +* Delay next BGSAVE for diskless SYNC +* Starting BGSAVE for SYNC with target: replicas sockets +* Synchronization with replica 127.0.0.1:6621 succeeded +从服务器 master_replid:f3aa870bbc674fa3dafd7bf8d12fb79eefa6461f,dbsize 1000 + +== 2. 断开后写入 SET order:1024 shipped、INCR views:1024、DEL order:1 +master_repl_offset:123 +repl_backlog_size:1048576 +repl_backlog_first_byte_offset:1 +repl_backlog_histlen:123 +* Partial resynchronization request from 127.0.0.1:6621 accepted. Sending 123 bytes of backlog starting from offset 1. + +== 3. 断开后写入 2000 个 1000 字节的值 +master_repl_offset:2073030 +repl_backlog_first_byte_offset:1011361 +repl_backlog_histlen:1061670 +* Unable to partial resync with replica 127.0.0.1:6621 for lack of backlog (Replica request was: 138). +* Starting BGSAVE for SYNC with target: replicas sockets +``` + +第 2 次重连后从服务器的偏移量为 137,多出的 14 字节是主服务器定期发送的 PING。 + +### A16 字符串的 int 与 embstr 编码 + +Redis 7.4.1,默认配置(maxmemory 为 0),macOS arm64。 + +```text +> set a abcd +> set b 1234 +> set c 123456789012 +> set d 12345678901234567890 +``` + +```text +键 值 OBJECT ENCODING MEMORY USAGE OBJECT REFCOUNT +a abcd embstr 72 1 +b 1234 int 56 2147483647 +c 123456789012 int 56 1 +d 12345678901234567890 embstr 88 1 +``` + +b 的引用计数为 INT_MAX,表示它是共享的整数对象;d 超出 64 位有符号整数的范围,按字符串保存。另外,SET p 001234、SET r +5 的编码为 embstr;SET q 12 后执行 APPEND q 34,值为 1234,编码为 raw。 + +### A17 AOF 文件的内容与快照失败 + +Redis 7.4.1,appendonly yes,执行 SET user:1001 Tom、INCR pv、DEL user:1001 后查看 appendonlydir,再把数据目录改为只读执行 BGSAVE: + +```text +$ ls appendonlydir +appendonly.aof.1.base.rdb appendonly.aof.1.incr.aof appendonly.aof.manifest +$ cat appendonlydir/appendonly.aof.1.incr.aof +*2 +$6 +SELECT +$1 +0 +*3 +$3 +SET +$9 +user:1001 +$3 +Tom +...(INCR pv、DEL user:1001 两条,格式相同) +$ head -c 9 appendonlydir/appendonly.aof.1.base.rdb +REDIS0012 + +> BGSAVE +Background saving started +> INFO persistence +rdb_last_bgsave_status:err +> SET k v +(error) MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk. ... +(恢复目录权限后再执行 BGSAVE) +> SET k v +OK +``` + +### A18 写满内存后的淘汰 + +不带配置文件启动 redis-server,maxmemory 的默认值为 0。设置 maxmemory 为 10mb,分别使用三种策略,清空数据库后用一个连接逐条执行 10 万次 SET k:i <1 KB 的值>(都不设过期时间),再执行 GET k:0: + +```text +maxmemory 默认 = 0 +[allkeys-lru] SET 成功 100000,失败 0;GET k:0 -> nil;DBSIZE 5733;used_memory 10484656 +[noeviction] SET 成功 5758,失败 94242,第一次失败在第 5759 次: + OOM command not allowed when used memory > 'maxmemory'. + GET k:0 -> 1024 字节的值;DBSIZE 5758;evicted_keys 0 +[volatile-lru] 与 noeviction 相同:SET 成功 5758,失败 94242;evicted_keys 0 +``` + ## 参考资料 - Carlson J L. Redis in Action [M]. Manning, 2013. diff --git a/notes/pics/redis-aof-fsync.png b/notes/pics/redis-aof-fsync.png new file mode 100644 index 00000000..f91fa756 Binary files /dev/null and b/notes/pics/redis-aof-fsync.png differ diff --git a/notes/pics/redis-aof-rewrite.png b/notes/pics/redis-aof-rewrite.png index 7eb917a9..af116a23 100644 Binary files a/notes/pics/redis-aof-rewrite.png and b/notes/pics/redis-aof-rewrite.png differ diff --git a/notes/pics/redis-article-vote.png b/notes/pics/redis-article-vote.png index 3bc27d25..7862c8c4 100644 Binary files a/notes/pics/redis-article-vote.png and b/notes/pics/redis-article-vote.png differ diff --git a/notes/pics/redis-cluster.png b/notes/pics/redis-cluster.png index 1c9b343d..eba379fb 100644 Binary files a/notes/pics/redis-cluster.png and b/notes/pics/redis-cluster.png differ diff --git a/notes/pics/redis-hash-encoding.png b/notes/pics/redis-hash-encoding.png index 38091faa..45361c68 100644 Binary files a/notes/pics/redis-hash-encoding.png and b/notes/pics/redis-hash-encoding.png differ diff --git a/notes/pics/redis-hash.png b/notes/pics/redis-hash.png index 2d4f7deb..e453ebda 100644 Binary files a/notes/pics/redis-hash.png and b/notes/pics/redis-hash.png differ diff --git a/notes/pics/redis-io-threads.png b/notes/pics/redis-io-threads.png new file mode 100644 index 00000000..d05290a9 Binary files /dev/null and b/notes/pics/redis-io-threads.png differ diff --git a/notes/pics/redis-psync.png b/notes/pics/redis-psync.png new file mode 100644 index 00000000..76af846e Binary files /dev/null and b/notes/pics/redis-psync.png differ diff --git a/notes/pics/redis-rdb-cow.png b/notes/pics/redis-rdb-cow.png new file mode 100644 index 00000000..99147897 Binary files /dev/null and b/notes/pics/redis-rdb-cow.png differ diff --git a/notes/pics/redis-set.png b/notes/pics/redis-set.png index fa68c55c..ee16c76e 100644 Binary files a/notes/pics/redis-set.png and b/notes/pics/redis-set.png differ diff --git a/notes/pics/redis-single-thread.png b/notes/pics/redis-single-thread.png index e6e05bae..fd9d6fe4 100644 Binary files a/notes/pics/redis-single-thread.png and b/notes/pics/redis-single-thread.png differ diff --git a/notes/pics/redis-slot-migration.png b/notes/pics/redis-slot-migration.png new file mode 100644 index 00000000..3858deaf Binary files /dev/null and b/notes/pics/redis-slot-migration.png differ diff --git a/notes/pics/redis-zset.png b/notes/pics/redis-zset.png index e10c26f5..00c98391 100644 Binary files a/notes/pics/redis-zset.png and b/notes/pics/redis-zset.png differ