Update Redis.md

补充 HyperLogLog 合并、热点计数、跳跃表层数、定期删除、AOF 写入时机、事务与 Lua 脚本、I/O 多线程、WAIT 与故障转移等说明。
This commit is contained in:
CyC2018
2026-11-02 01:31:11 +08:00
parent 2ad6e916ad
commit f73cc9fe1d
+36 -14
View File
@@ -314,6 +314,8 @@ HyperLogLog(HLL)用于估计一个集合中不同元素的个数,例如页
例如 100 万个不同的元素,PFCOUNT 返回 999674(误差 −0.03%),HLL 占 12 KB,同样的元素用 SET 保存要 30 MB;30 组各 100 万个元素的误差均方根为 0.80%,最大为 2.0%(Redis 7.4 实测,见[附录 A8](#a8-hyperloglog-的误差和内存))。
HLL 只能求并集。PFCOUNT 带多个键时每次都临时合并一次,经常要查的周 UV 应该用 PFMERGE 存成一个键。交集,例如两天都访问过的用户数,只能用 |A| + |B| − |A ∪ B| 估计,三个估计值的误差叠加在一起,交集远小于并集时结果很不可靠:两天各 10 万个用户、真正重叠 1000 个时,5 次估计在 393~2554 之间(Redis 7.4 实测,见[附录 A8](#a8-hyperloglog-的误差和内存))。需要精确的交集时,按用户 ID 记 BITMAP,再用 BITOP AND 求交集。
GEO 用于“附近的门店”这类查询:GEOADD 保存经纬度,GEOSEARCH 查找某个点一定半径或矩形内的成员(加 ASC 时按距离由近到远排序),GEODIST 计算两点间的距离。它底层就是一个 ZSET,经纬度交错编码成 52 位的 Geohash 整数作为分值。同一个格子里的点分值落在一段连续的区间内,但格子边界两侧的点即使相距很近,分值也可能相差很远,所以查询时要查中心格和周围 8 个格子(最多 9 个分值区间),再逐个计算距离过滤;代价是有误差:距离按球面模型计算,官方文档说明由此产生的误差最坏可达 0.5%,经纬度编码成整数时也有精度损失。
## 三、使用场景 🔥
@@ -324,7 +326,7 @@ GEO 用于“附近的门店”这类查询:GEOADD 保存经纬度,GEOSEARCH
命令在服务器上原子执行,多个客户端同时加 1 不会丢失更新;数据在内存中,也适合这种频繁读写的场景。
如果直接在数据库中计数,所有请求都要更新同一行,行锁让这些更新只能一个接一个地执行。对同一行执行 UPDATE counter SET cnt = cnt + 1,MySQL 用 1、8、50 个连接时每秒都只有 5000~7000 次;Redis 的 INCR 用 50 个连接每秒约 10 万次(MySQL 8.0、Redis 7.4 实测,见[附录 A6](#a6-热点计数))。
如果直接在数据库中计数,所有请求都要更新同一行,行锁让这些更新只能一个接一个地执行。对同一行执行 UPDATE counter SET cnt = cnt + 1,MySQL 用 1、8、50 个连接时每秒都只有 5000~7000 次;Redis 的 INCR 用 50 个连接每秒约 10 万次(macOS 上的 MySQL 8.0、Redis 7.4 实测,见[附录 A6](#a6-热点计数);macOS 的 fsync 不要求磁盘清空写缓存,这组 MySQL 的数字不能直接外推到 Linux 服务器)。
常用的做法是在 Redis 中计数,再定期把计数写回数据库。单个计数仍然太热时,可以拆成多个键,例如 views:1024:0~views:1024:9,每次随机给其中一个加 1,读取时求和。点赞这类每个用户只能计一次的计数,还要记录点过赞的用户,见下文的 [点赞功能](#点赞功能)。
@@ -422,7 +424,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 层,即 1.33 个指向后继的指针。这只是期望值,好处是插入节点时不需要调整其它节点的层数。
查找时从最高层开始向右走,下一个节点比目标大时下降一层,直到最底层。每层平均只走几步,查找、插入和删除的期望时间复杂度都是 O(logN)。下图演示了查找 26 的过程。
@@ -434,7 +436,7 @@ embstr 编码把对象头和 SDS 放在同一块内存中,只需分配一次
- 范围查询方便,找到起点后沿最底层链表向后遍历即可,ZRANGEBYSCORE 就是这样实现的;
- 实现和调试更简单。
MySQL 的索引用 B+ 树,是因为 B+ 树的一个节点存放很多个键,每次读磁盘都取回一整页,减少 I/O 次数;有序集合全部在内存中,没有这个需要。Redis 的作者解释选择跳跃表时还提到:调整升层的概率就能控制内存,按 1/4 升层时每个节点平均只有 1.33 个指针;ZRANGE 这类范围遍历沿底层链表进行,缓存局部性不比平衡树差。跳跃表的代价是性能依赖随机数,最坏情况下退化为 O(N),只是概率极小。
MySQL 的索引用 B+ 树,是因为 B+ 树的一个节点存放很多个键,每次读磁盘都取回一整页,减少 I/O 次数;有序集合全部在内存中,没有这个需要。Redis 的作者回答“为什么不用 B 树”时提到:调整升层的概率就能让跳跃表比 B 树更省内存;ZRANGE 这类范围遍历沿底层链表进行,缓存局部性不比平衡树差;实现简单,后来让 ZRANK 做到 O(logN)(见下文的跨度)只改了很少的代码。跳跃表的代价是性能依赖随机数,最坏情况下退化为 O(N),只是概率极小。
插入时先像查找一样从最高层往下走,记下每一层最后一个排在新成员前面的节点(先比分值,分值相同再比成员名),再随机决定新节点的层数,把它接在这些节点的后面。例如在上图中插入 24,三层记下的节点依次是 15、22、22;随机得到 2 层时,新节点接在第 0 层和第 1 层的 22 之后,第 2 层不变。
@@ -474,7 +476,7 @@ Redis 可以为每个键设置过期时间,过期时间保存在一个单独
两者互相补充:惰性删除保证读不到过期的数据,定期删除回收没人访问的过期键,并且限制了自己占用的 CPU。例如关闭定期删除后,10 万个已经过期的键仍然留在内存中,DBSIZE 为 100000;GET 其中一个返回 nil,DBSIZE 变为 99999;重新打开定期删除,372 毫秒后全部删除。
需要注意的是,过期的键只占少数时,每批 20 个中过期的不到 10%,定期删除不会连续检查:100 万个键中有 5 万个过期,20 秒内只删除了 228 个(Redis 7.4 实测,见[附录 A9](#a9-过期删除))。所以过期的键可能长时间占用内存,内存紧张时还要依靠下一节的淘汰策略。
需要注意的是,过期的键只占少数时,每批 20 个中过期的不到 10%,定期删除不会连续检查:100 万个键中有 5 万个过期,20 秒内只删除了 228 个(Redis 7.4 实测,见[附录 A9](#a9-过期删除))。所以过期的键可能长时间占用内存,内存紧张时还要依靠下一节的淘汰策略。键空间通知中的 expired 事件也是在键被删除时才发出,同样可能晚到,不能当作准时的定时器,见[延迟消息](消息队列.md#延迟消息)。
主从复制时,从服务器不自己删除过期的键,而是等待主服务器发来的 DEL,避免两边的数据不一致;在从服务器上读取已经过期、但还没有收到 DEL 的键时,返回的是空。
@@ -484,7 +486,7 @@ Redis 可以为每个键设置过期时间,过期时间保存在一个单独
过期删除只清理已经过期的键;内存淘汰是在内存达到上限时删除一部分键来腾出空间,被删除的键可能还没有过期。
上限由 maxmemory 设置,默认为 0,在 64 位系统上表示不限制,内存会一直增长到用上 swap 或被操作系统杀掉,所以作缓存时要设置它。设置之后,Redis 在执行每条命令之前检查内存,超出上限就先按 maxmemory-policy 淘汰键,再执行命令。
上限由 maxmemory 设置,默认为 0,在 64 位系统上表示不限制,内存会一直增长到用上 swap 或被操作系统杀掉,所以作缓存时要设置它。设置之后,Redis 在执行每条命令之前检查内存,超出上限就先按 maxmemory-policy 淘汰键,再执行命令。每轮淘汰有时间预算,默认约 0.5 毫秒(maxmemory-eviction-tenacity 为 10,6.2 起),用完时还没淘汰够,命令照常执行,剩下的由后台定时任务继续淘汰,所以内存可能短时间超过上限。策略为 noeviction、或者已经没有符合策略的键可以淘汰时,才拒绝 SET 这类可能增加内存的命令(Redis 7.4 源码)。
Redis 4.0 之后具体有 8 种淘汰策略:
@@ -535,7 +537,7 @@ RDB 文件是紧凑的二进制文件,便于备份,全量复制发送的也
将写命令添加到 AOF 文件(Append Only File)的末尾。AOF 默认关闭,需要配置 appendonly yes 开启。
写命令先执行,成功后才追加到内存中的 AOF 缓冲区,执行出错的命令不会进入 AOF。之后主线程在每轮事件循环回复客户端之前,用 write() 把缓冲区写入 AOF 文件。write() 返回时数据只到了操作系统的页缓存,调用 fsync 才写到磁盘,同步选项 appendfsync 决定 fsync 的时机:
写命令先执行,成功后才追加到内存中的 AOF 缓冲区,执行出错的命令不会进入 AOF。之后主线程在每轮事件循环回复客户端之前,用 write() 把缓冲区写入 AOF 文件。write() 返回时数据只到了操作系统的[页缓存](计算机操作系统%20-%20概述.md#系统调用),调用 fsync 才写到磁盘,同步选项 appendfsync 决定 fsync 的时机:
| 选项 | 同步频率 |
| :--: | :--: |
@@ -575,7 +577,7 @@ Redis 的事务用 MULTI 和 EXEC 把多个命令包围起来:MULTI 之后的
流水线(Pipelining)是另一个概念,指客户端不等待响应就连续发送多条命令,从而减少网络往返次数,事务通常和流水线一起使用。
Redis 的事务不支持回滚:EXEC 时某条命令执行出错,其它命令照常执行。例如 name 的值是字符串 alice,对它执行 INCR 会出错,但同一事务中的两条 SET 都生效:
Redis 的事务不支持回滚:EXEC 时某条命令执行出错,其它命令照常执行。官方的解释是支持回滚会明显影响 Redis 的简洁和性能。例如 name 的值是字符串 alice,对它执行 INCR 会出错,但同一事务中的两条 SET 都生效:
```text
> multi
@@ -594,7 +596,7 @@ QUEUED
命令入队时就能发现的错误(例如命令不存在),EXEC 会放弃整个事务,返回 EXECABORT。
WATCH 可以实现乐观锁:EXEC 之前被 WATCH 的键被其它客户端修改,EXEC 返回 nil,事务中的命令都不执行,由客户端决定是否重试。
WATCH 可以实现乐观锁:从 WATCH 到 EXEC 之间被 WATCH 的键被修改,EXEC 返回 nil,事务中的命令都不执行,由客户端决定是否重试。这里的修改不只是其它客户端的写入,写入相同的值、本客户端在 MULTI 之前的写入、键过期也算(Redis 7.4 实测);EXEC 之后无论成败,WATCH 都会取消。
按 ACID 衡量,Redis 的事务满足隔离性,相当于串行执行;原子性只做到入队出错时都不执行,执行出错时不回滚;持久性取决于[持久化](#八持久化)的配置。
@@ -623,9 +625,9 @@ return n + 1
需要注意的是:
- 脚本中途出错时,之前执行的写命令不会回滚,之后的命令不再执行,所以参数、键的类型和业务前提要在第一条写命令之前校验完,尽量不在写入后才出错;
- 脚本执行期间服务器不处理其它命令,其它客户端的请求一直等待。脚本运行超过 busy-reply-threshold(默认 5 秒)后,服务器才开始处理请求,但除了 SCRIPT KILL 和 SHUTDOWN NOSAVE 都返回 BUSY 错误。SCRIPT KILL 只能终止还没有执行写命令的脚本;已经写过数据的脚本只能等它结束,或者用 SHUTDOWN NOSAVE 关闭服务器并丢弃上次持久化之后的写入(Redis 7.4 实测,见[附录 A12](#a12-长时间运行的-lua-脚本))。所以脚本要短,不要在其中遍历大集合;
- 脚本执行期间服务器不处理其它命令,其它客户端的请求一直等待。脚本运行超过 busy-reply-threshold(默认 5 秒)后,服务器才开始处理请求,但除了 SCRIPT KILL(运行的是函数时用 FUNCTION KILL)、SHUTDOWN NOSAVE 等少数命令,都返回 BUSY 错误。SCRIPT KILL 只能终止还没有执行写命令的脚本;已经写过数据的脚本只能等它结束,或者用 SHUTDOWN NOSAVE 关闭服务器并丢弃上次持久化之后的写入(Redis 7.4 实测,见[附录 A12](#a12-长时间运行的-lua-脚本))。所以脚本要短,不要在其中遍历大集合;
- 脚本用到的键要通过 KEYS 传入,集群模式下这些键必须在同一个槽中,否则报错 CROSSSLOT,可以用[哈希标签](#redis-cluster)让它们落到同一个槽;
- 可以先用 SCRIPT LOAD 把脚本缓存在服务器上,之后用 EVALSHA 加脚本的 SHA1 调用,不用每次发送脚本内容。
- 可以先用 SCRIPT LOAD 把脚本缓存在服务器上,之后用 EVALSHA 加脚本的 SHA1 调用,不用每次发送脚本内容。这个缓存不持久化,重启、故障切换或 SCRIPT FLUSH 之后不能保证脚本还在,EVALSHA 找不到脚本时返回 NOSCRIPT,客户端要重新加载;Redis 7.0 加入的 Functions 则随数据一起持久化和复制。
## 十、事件
@@ -646,7 +648,9 @@ Redis 用一个线程执行所有命令,仍然很快,原因主要有以下
所以执行命令本身很少成为瓶颈,开销主要在网络读写上:流水线减少了系统调用的次数,吞吐量就提高到原来的 10 倍。
Redis 6.0 加入的 I/O 多线程(io-threads,默认关闭)分担的也是这部分开销:多个 I/O 线程并行读取、解析请求和写回响应,命令仍由主线程逐条执行,不会有两个线程同时修改一个键。6.0 和 7.x 默认只把写回交给 I/O 线程,读取和解析还要打开 io-threads-do-reads;8.0 重新实现后三者都由 I/O 线程负责。
Redis 6.0 加入的 I/O 多线程(io-threads,默认关闭)分担的也是这部分开销:多个 I/O 线程并行读取、解析请求和写回响应,命令仍由主线程逐条执行,不会有两个线程同时修改一个键。6.0 和 7.x 默认只把写回交给 I/O 线程,读取和解析还要打开 io-threads-do-reads,并且主线程分发完任务后要忙等所有 I/O 线程完成。
8.0 重新实现后,每个 I/O 线程有自己的事件循环,负责普通客户端的读取、解析和写回;命令仍由主线程执行,主从复制、订阅等特殊连接的读写也由主线程处理(8.8 起主从复制的连接也交给 I/O 线程)。8.0 的 io-threads 把主线程也算作一个,例如 io-threads 4 表示主线程加 3 个 I/O 线程。
<div align="center"> <img src="pics/redis-io-threads.png" width="760px"/> </div><br>
@@ -698,7 +702,11 @@ 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 秒的写入;它不保证每次写入都已复制,分区后还有从服务器和旧主在同一侧时也挡不住写入。分布式锁同样会因此丢失,见 [Redis 的 SET NX 指令](分布式.md#redis-的-set-nx-指令)。
需要注意的是,主从之间是异步复制,切换时还没有复制到从服务器的写入会丢失。网络分区时,旧的主服务器还可能继续接受写入(脑裂),恢复后它变为从服务器,这段时间的写入也会丢失。分布式锁同样会因此丢失,见 [Redis 的 SET NX 指令](分布式.md#redis-的-set-nx-指令)。
可以配置 min-replicas-to-write 1 和 min-replicas-max-lag 10:延迟不超过 10 秒的从服务器少于 1 个时,主服务器拒绝写入并返回 NOREPLICAS,旧的主服务器与所有从服务器断开后最多再接受约 10 秒的写入;它不保证每次写入都已复制,分区后还有从服务器和旧主在同一侧时也挡不住写入。代价是即使主服务器本身正常,只要没有满足延迟要求的从服务器,它也会拒绝写入。
对单次写入,可以在写入后、在同一个连接上执行 WAIT 1 100(不放在事务和脚本中),阻塞到至少 1 个从服务器确认或超过 100 毫秒,返回确认的个数(7.2 起 WAITAOF 还能等待 AOF 落盘)。它只能缩小丢失的范围:哨兵在可达的从服务器中按优先级和复制偏移量选主,不一定选中确认过的那个,官方文档也写明 WAIT 不会让 Redis 成为强一致的存储;超时返回时写入已经在主服务器上生效,不会撤销。
## 十三、分片
@@ -948,7 +956,7 @@ NOREPLICAS Not enough good replicas to write.
### A6 热点计数
MySQL 8.0.44(innodb_flush_log_at_trx_commit 和 sync_binlog 都为 1),Redis 7.4.1,Apple M1 Pro。MySQL 用多个进程同时执行自动提交的 UPDATE counter SET cnt = cnt + 1 WHERE id = 1,最后核对 cnt;Redis 用 redis-benchmark 对同一个键执行 INCR:
MySQL 8.0.44(innodb_flush_log_at_trx_commit 和 sync_binlog 都为 1),Redis 7.4.1,Apple M1 Pro,macOS 15.8.1。MySQL 在 macOS 上刷盘调用的 fsync 只把数据交给磁盘,不要求磁盘清空写缓存。MySQL 用多个进程同时执行自动提交的 UPDATE counter SET cnt = cnt + 1 WHERE id = 1,最后核对 cnt;Redis 用 redis-benchmark 对同一个键执行 INCR:
```text
MySQL 1 个连接共更新 5000 次:0.92 s,5437 次/秒,cnt = 5000
@@ -1028,13 +1036,24 @@ Redis 7.4.1,向一个 HLL 写入 n 个不同的字符串 user:0~user:{n −
同样 100 万个 user:i 用 SET 保存:used_memory 增加 30080144
```
元素少时 STRLEN 为 1910 字节,是稀疏编码;转为密集编码后是 12288 字节的寄存器加 16 字节的头部。再写入 30 组、每组 100 万个不同的字符串:
元素少时 STRLEN 为 1910 字节,是稀疏编码;稀疏编码超过 hll-sparse-max-bytes(默认 3000 字节)时转为密集编码,逐个写入 u1、u2……时在第 1689 个元素发生转换,之后是 12288 字节的寄存器加 16 字节的头部。再写入 30 组、每组 100 万个不同的字符串:
```text
30 组误差(%) [1.41, 0.33, 0.19, -0.7, -0.19, -0.02, 0.84, 0.73, -1.02, -0.42, 0.59, -1.79, 0.32, -0.71, -0.48, -0.25, 0.25, 0.72, 0.57, 1.29, -0.35, 0.06, -0.17, 0.33, -0.63, 1.07, -0.37, 0.23, -1.05, -2.02]
均方根 0.80%,绝对值最大 2.02%,绝对值不超过 0.81% 的组数 22
```
两个 HLL 估计交集:a 写入 1~100000,b 写入 99001~199000,真实的交集为 1000、并集为 199000,用 5 组不同前缀的元素各做一次:
```text
PFCOUNT a PFCOUNT b PFCOUNT a b a + b − 并集
100268 98782 198097 953
100095 100667 200369 393
99610 99433 198247 796
101282 99941 198669 2554
102206 100855 201976 1085
```
GEO 的键类型:
```text
@@ -1357,6 +1376,8 @@ maxmemory 默认 = 0
- [GEOADD | Redis](https://redis.io/docs/latest/commands/geoadd/)
- [Redis: Scale with Redis Cluster](https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/)
- [High availability with Redis Sentinel](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/)
- [WAIT | Redis](https://redis.io/docs/latest/commands/wait/)
- [WAITAOF | Redis](https://redis.io/docs/latest/commands/waitaof/)
- [Redis replication](https://redis.io/docs/latest/operate/oss_and_stack/management/replication/)
- [Redis persistence](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)
- [Transactions | Redis](https://redis.io/docs/latest/develop/interact/transactions/)
@@ -1368,5 +1389,6 @@ maxmemory 默认 = 0
- [Redis 7.4.1 源码 expire.c](https://github.com/redis/redis/blob/7.4.1/src/expire.c)
- [Redis 7.4.1 源码 evict.c](https://github.com/redis/redis/blob/7.4.1/src/evict.c)
- [Redis 8.0.0 redis.conf](https://github.com/redis/redis/blob/8.0.0/redis.conf)
- [Redis PR #13695: Async IO Threads](https://github.com/redis/redis/pull/13695)
- [Redis cluster specification](https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/)
- [Why Redis uses skip lists (antirez, Hacker News)](https://news.ycombinator.com/item?id=1171423)