diff --git a/notes/Redis.md b/notes/Redis.md index 2fededae..7d2ecf57 100644 --- a/notes/Redis.md +++ b/notes/Redis.md @@ -3,7 +3,7 @@ * [Redis](#redis) * [一、概述](#一概述) * [与 MySQL 的区别](#与-mysql-的区别) - * [二、数据类型](#二数据类型) + * [二、数据类型](#二数据类型) 🔥🔥 * [STRING](#string) * [LIST](#list) * [SET](#set) @@ -11,7 +11,7 @@ * [ZSET](#zset) * [BITMAP](#bitmap) * [HyperLogLog 与 GEO](#hyperloglog-与-geo) - * [三、使用场景](#三使用场景) + * [三、使用场景](#三使用场景) 🔥 * [计数器](#计数器) * [缓存](#缓存) * [查找表](#查找表) @@ -21,7 +21,7 @@ * [排行榜](#排行榜) * [其它](#其它) * [四、数据结构](#四数据结构) - * [对象编码](#对象编码) + * [对象编码](#对象编码) 🔥🔥 * [字典](#字典) * [跳跃表](#跳跃表) * [五、Redis 与 Memcached](#五redis-与-memcached) @@ -30,20 +30,20 @@ * [分布式](#分布式) * [内存管理机制](#内存管理机制) * [六、键的过期时间](#六键的过期时间) - * [七、数据淘汰策略](#七数据淘汰策略) - * [八、持久化](#八持久化) + * [七、数据淘汰策略](#七数据淘汰策略) 🔥 + * [八、持久化](#八持久化) 🔥🔥 * [RDB 持久化](#rdb-持久化) * [AOF 持久化](#aof-持久化) * [九、事务](#九事务) - * [Lua 脚本](#lua-脚本) + * [Lua 脚本](#lua-脚本) 🔥 * [十、事件](#十事件) - * [单线程为什么快](#单线程为什么快) + * [单线程为什么快](#单线程为什么快) 🔥 * [十一、复制](#十一复制) * [连接过程](#连接过程) * [主从链](#主从链) * [十二、Sentinel](#十二sentinel) * [十三、分片](#十三分片) - * [Redis Cluster](#redis-cluster) + * [Redis Cluster](#redis-cluster) 🔥 * [十四、一个简单的论坛系统分析](#十四一个简单的论坛系统分析) * [文章信息](#文章信息) * [点赞功能](#点赞功能) @@ -62,6 +62,7 @@ * [A11 持久化与脚本的默认配置](#a11-持久化与脚本的默认配置) * [A12 长时间运行的 Lua 脚本](#a12-长时间运行的-lua-脚本) * [A13 槽迁移与 ASK 重定向](#a13-槽迁移与-ask-重定向) + * [A14 HASH 编码的转换](#a14-hash-编码的转换) * [参考资料](#参考资料) @@ -83,7 +84,7 @@ 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#缓存一致性)。 -## 二、数据类型 +## 二、数据类型 🔥🔥 | 数据类型 | 可以存储的值 | 操作 | | :--: | :--: | :--: | @@ -309,7 +310,7 @@ HyperLogLog(HLL)用于估计一个集合中不同元素的个数,例如页 GEO 用于“附近的门店”这类查询:GEOADD 保存经纬度,GEOSEARCH 查找某个点一定半径或矩形内的成员并按距离排序,GEODIST 计算两点间的距离。它底层就是一个 ZSET,经纬度交错编码成 52 位的 Geohash 整数作为分值,相邻的点分值相近,查询时只需查找覆盖查询范围的 9 个区域对应的分值区间;代价是距离和范围有误差,官方文档说明最坏可达 0.5%。 -## 三、使用场景 +## 三、使用场景 🔥 ### 计数器 @@ -373,7 +374,7 @@ Set 可以实现交集、并集等操作,从而实现共同好友等功能。 ## 四、数据结构 -### 对象编码 +### 对象编码 🔥🔥 同一种数据类型会根据元素的数量和大小选择不同的底层结构,称为编码,可以用 OBJECT ENCODING 查看。元素少时使用 listpack,它把所有元素连续地存放在一块内存中,节省内存,查找时虽然要逐个比较,但元素很少时并不慢;元素增多后转换为查找更快的结构。以下是在 Redis 7.4 上用默认配置验证的结果: @@ -387,6 +388,10 @@ Set 可以实现交集、并集等操作,从而实现共同好友等功能。 例如一个 HASH 有 512 个字段时编码为 listpack,再写入一个字段就变为 hashtable。ZSET 转换后同时使用跳跃表和字典:字典按成员查分数,跳跃表按分数排序和范围查询。 +

+ +转换后内存明显增加:512 个字段的 HASH 用 listpack 约占 10 KB,再写入一个字段转为 hashtable 后约占 40 KB(Redis 7.4 实测,见[附录 A14](#a14-hash-编码的转换))。 + Redis 7.0 之前用的是压缩列表(ziplist),每个元素要记录前一个元素的长度,这个长度占 1 或 5 个字节,在中间插入或删除元素时可能让后面的元素依次变长,称为连锁更新;listpack 的每个元素只记录自己的长度,避免了这个问题。 字符串的内容保存在简单动态字符串(Simple Dynamic String, SDS)中,它在字符数组前面记录了已用的长度 len 和分配的容量 alloc: @@ -461,7 +466,7 @@ Redis 可以为每个键设置过期时间,过期时间保存在一个单独 对于 HASH 这种容器,Redis 7.4 之前只能为整个键设置过期时间,7.4 开始可以使用 HEXPIRE 等命令为单个字段设置过期时间。 -## 七、数据淘汰策略 +## 七、数据淘汰策略 🔥 可以设置内存最大使用量,当内存使用量超出时,会施行数据淘汰策略。 @@ -492,7 +497,7 @@ LFU 复用这 24 位:高 16 位是上次访问的时间(分钟),低 8 - 同一个实例既存缓存又存不能丢的数据时,给缓存设置过期时间并用 volatile-lru,只淘汰缓存,官方文档更建议把两类数据放到两个实例中; - 当作数据库使用、数据不能被淘汰时用 noeviction,内存满时写入报错,由应用发现并处理。 -## 八、持久化 +## 八、持久化 🔥🔥 Redis 是内存型数据库,为了保证数据在断电后不会丢失,需要将内存中的数据持久化到硬盘上。 @@ -573,7 +578,7 @@ QUEUED WATCH 可以实现乐观锁:EXEC 之前被 WATCH 的键被其它客户端修改,EXEC 返回 nil,事务中的命令都不执行,由客户端决定是否重试。 -### Lua 脚本 +### Lua 脚本 🔥 事务中的命令在 EXEC 时才一起执行,不能根据前一条命令的结果决定下一条做什么。Lua 脚本可以做到这一点,并且整个脚本原子执行:Redis 用一个线程执行命令,执行脚本时不处理其它客户端的命令,整个脚本就像一条命令。用事务实现“先读后写”时,只能用 WATCH 检测冲突后重试;流水线只是减少网络往返,几条命令之间仍可能插入其它客户端的命令。 @@ -606,7 +611,7 @@ return n + 1 Redis 服务器是一个事件驱动程序:主线程在一个循环中用 I/O 多路复用等待套接字上的读写事件(文件事件),处理完这些事件后再执行到期的定时任务(时间事件)。事件的分类和调度过程见[进阶:事件](Redis%20-%20进阶.md#十事件)。 -### 单线程为什么快 +### 单线程为什么快 🔥 Redis 用一个线程执行所有命令,仍然很快,原因主要有以下几点: @@ -617,6 +622,8 @@ Redis 用一个线程执行所有命令,仍然很快,原因主要有以下 用 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 释放大键的内存这些耗时操作由后台线程完成。 需要注意的是,单线程也意味着一条慢命令会让所有客户端等待:执行 KEYS *、对大 Key 执行 HGETALL 或 DEL、运行很长的 Lua 脚本时,其它命令都在排队,见 [大 Key 与热 Key](缓存.md#大-key-与热-key)。 @@ -637,7 +644,7 @@ Redis 用一个线程执行所有命令,仍然很快,原因主要有以下 Redis 7.0 起默认不把快照写入磁盘,而是直接通过网络发送给从服务器(repl-diskless-sync yes)。 -以上是第一次连接时的全量复制。从服务器断线重连后,会带上复制 ID 和自己已经复制到的偏移量发送 PSYNC 命令:如果主服务器的复制积压缓冲区(默认 1 MB)中还保存着之后的写命令,就只发送缺少的部分,称为部分重同步;否则重新进行全量复制。写入量大、断线时间长时,可以调大 repl-backlog-size,避免频繁全量复制。 +以上是第一次连接时的全量复制。从服务器断线重连后,会带上复制 ID 和下一个需要的偏移量(已经复制到的偏移量加 1)发送 PSYNC 命令:如果复制 ID 与主服务器一致(故障转移后,新的主服务器也接受旧主服务器的复制 ID),并且这个偏移量还在主服务器的复制积压缓冲区(默认 1 MB)中,就只发送缺少的部分,称为部分重同步;否则重新进行全量复制。写入量大、断线时间长时,可以调大 repl-backlog-size,避免频繁全量复制。 ### 主从链 @@ -678,7 +685,7 @@ down-after-milliseconds 为 5 秒时,从主服务器宕机到客户端查到 - 代理分片:将客户端请求发送到代理上,由代理转发请求到正确的节点上; - 服务器分片:Redis Cluster,使用 CRC16(key) % 16384 计算键所属的哈希槽,再把 16384 个哈希槽分配给各个节点。 -### Redis Cluster +### Redis Cluster 🔥 Redis 有三种常见的部署方式,解决的问题不同: @@ -1134,6 +1141,43 @@ OK MOVED 5712 127.0.0.1:7003 ``` +### A14 HASH 编码的转换 + +默认配置下 hash-max-listpack-entries 为 512、hash-max-listpack-value 为 64。用 redis-py 的流水线写入字段 field1~fieldN、值 value1~valueN,记录写入前后 INFO memory 中 used_memory 的差,并用 MEMORY USAGE 对照: + +```python +def used(): return r.info('memory')['used_memory'] +for n in (3, 512, 513): + key = f'user:1001:h{n}' + before = used() + p = r.pipeline(transaction=False) + for i in range(1, n + 1): p.hset(key, f'field{i}', f'value{i}') + p.execute() + print(f'{n} 个字段:{r.object("encoding", key)},used_memory 增加 {used() - before} 字节,MEMORY USAGE {r.memory_usage(key, samples=0)} 字节') +key = 'user:1001:h512' +print('对 512 个字段的键再 HSET 一个字段:') +before = r.memory_usage(key, samples=0); b2 = used() +r.hset(key, 'field513', 'value513') +print(f' 编码 {r.object("encoding", key)},MEMORY USAGE {before} → {r.memory_usage(key, samples=0)} 字节,used_memory 增加 {used() - b2} 字节') +r.hset('user:1001', mapping={'name': 'Alice', 'city': 'Shanghai'}) +print('user:1001', r.object('encoding', 'user:1001')) +r.hset('user:1001', 'bio', 'x' * 65) +print('写入 65 字节的值后', r.object('encoding', 'user:1001')) +``` + +```text +hash-max-listpack-entries {'hash-max-listpack-entries': '512'} hash-max-listpack-value {'hash-max-listpack-value': '64'} +3 个字段:listpack,used_memory 增加 74288 字节,MEMORY USAGE 120 字节 +512 个字段:listpack,used_memory 增加 10304 字节,MEMORY USAGE 10296 字节 +513 个字段:hashtable,used_memory 增加 41120 字节,MEMORY USAGE 37032 字节 +对 512 个字段的键再 HSET 一个字段: + 编码 hashtable,MEMORY USAGE 10296 → 37032 字节,used_memory 增加 30848 字节 +user:1001 listpack +写入 65 字节的值后 hashtable +``` + +3 个字段那一行的 used_memory 增量包含了客户端缓冲区等一次性的分配,不代表这个键的大小。 + ## 参考资料 - Carlson J L. Redis in Action [M]. Manning, 2013. diff --git a/notes/pics/redis-hash-encoding.png b/notes/pics/redis-hash-encoding.png new file mode 100644 index 00000000..38091faa Binary files /dev/null and b/notes/pics/redis-hash-encoding.png differ diff --git a/notes/pics/redis-single-thread.png b/notes/pics/redis-single-thread.png new file mode 100644 index 00000000..e6e05bae Binary files /dev/null and b/notes/pics/redis-single-thread.png differ