diff --git a/notes/Redis.md b/notes/Redis.md index e1b7c6e0..95f893a7 100644 --- a/notes/Redis.md +++ b/notes/Redis.md @@ -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 线程。