Update Redis.md

为面试中常问的小节加上高频标记;补充 HASH 编码转换的示意图和实测(附录 A14),以及单线程模型的示意图。
This commit is contained in:
CyC2018
2026-10-30 02:57:52 +08:00
parent 1094a8fa42
commit 693df23519
3 changed files with 61 additions and 17 deletions
+61 -17
View File
@@ -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-编码的转换)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -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 转换后同时使用跳跃表和字典:字典按成员查分数,跳跃表按分数排序和范围查询。
<div align="center"> <img src="pics/redis-hash-encoding.png" width="760px"/> </div><br>
转换后内存明显增加: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-时间))。
<div align="center"> <img src="pics/redis-single-thread.png" width="760px"/> </div><br>
所以 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB