Redis 命令执行与数据结构原理
很多人学 Redis 只会背:
Redis 是内存数据库,单线程,速度快。这不够。要真正学懂 Redis,必须知道一个命令从客户端发出后,Redis 内部经历了什么;也要知道 String、Hash、List、Set、ZSet 为什么适合不同业务场景。
这一页解决这些问题:
- Redis 单线程到底单在哪里。
- 一个
GET、SET、HGET命令如何执行。 - Redis 为什么用 IO 多路复用。
- Redis 对象是什么,编码是什么。
- 为什么同一个 Hash 小的时候和大的时候内部结构可能不同。
- 为什么大 Key、慢命令会阻塞其他请求。
- 业务上怎么根据访问方式选数据结构。
一条命令的总流程
flowchart TD
A["客户端发送命令"] --> B["Socket 可读事件"]
B --> C["IO 多路复用通知 Redis"]
C --> D["读取请求到输入缓冲区"]
D --> E["解析 RESP 协议"]
E --> F["查找命令实现"]
F --> G["访问 Redis 字典中的 key"]
G --> H["执行数据结构操作"]
H --> I["写入输出缓冲区"]
I --> J["Socket 可写时返回客户端"]一句话:
Redis 快,不只是因为内存快,还因为网络事件处理、命令执行模型、数据结构和协议都比较轻。
Redis 单线程到底指什么
Redis 常说单线程,主要指:
核心命令执行逻辑通常由一个主线程顺序执行。它不等于 Redis 进程里永远只有一个线程。Redis 还有后台线程、异步释放、AOF rewrite 子进程、网络 IO 多线程等能力。面试回答要说清楚“核心命令执行”和“整个进程”不是一回事。
flowchart TD
A["Redis 进程"] --> B["主线程<br/>处理命令"]
A --> C["后台任务<br/>异步释放 / lazy free"]
A --> D["持久化子进程<br/>RDB / AOF rewrite"]
A --> E["网络 IO 多线程<br/>部分版本可开启"]为什么核心命令执行偏单线程:
- 避免多线程同时修改字典和对象带来的锁竞争。
- 命令执行通常很短,单线程足够快。
- 顺序执行让原子性更容易保证。
- 降低复杂度。
但代价是:一个慢命令会让后续命令排队。
IO 多路复用是什么
Redis 要同时处理很多客户端连接。如果每个连接一个线程,会有大量线程切换和锁成本。
IO 多路复用可以让 Redis 用少量线程监听很多 socket 事件。
flowchart TD
A["很多客户端连接"] --> B["epoll / kqueue / select"]
B --> C{"哪个 socket 有事件"}
C --> D["可读:读取命令"]
C --> E["可写:返回响应"]
D --> F["主线程执行命令"]
E --> G["写回客户端"]可以把它理解为:
Redis 不需要傻等某一个连接,而是让操作系统告诉它“哪些连接现在有活干”。
Redis keyspace 是什么
Redis 每个数据库内部都有一个类似字典的结构,保存 key 到 value 对象的映射。
flowchart TD
A["Redis DB 字典"] --> B["key=user:1"]
A --> C["key=rank:article"]
A --> D["key=lock:order:1"]
B --> E["Redis Object"]
C --> F["Redis Object"]
D --> G["Redis Object"]查询一个 key,本质上先到字典里找 key 对应的对象。
Redis Object 和编码
Redis value 不是直接裸数据,而是 Redis Object。对象里会记录:
| 信息 | 作用 |
|---|---|
| type | String、Hash、List、Set、ZSet 等类型 |
| encoding | 底层编码,例如 int、embstr、listpack、hashtable、skiplist |
| ptr | 指向真实数据结构 |
| lru/lfu 信息 | 淘汰策略可能用到 |
| refcount | 引用计数,部分场景使用 |
为什么要有 encoding?
同一种类型在不同大小下,用不同底层结构更省内存或更快。
例如 Hash:
字段少、值短:可能用 listpack,省内存。
字段多、变大:转换为 hashtable,查询更快。String 原理和场景
String 适合:
- 普通缓存。
- 计数器。
- 分布式锁 token。
- JSON 小对象。
示例:
set user:profile:1001 '{"name":"Tom","age":18}' ex 3600
get user:profile:1001
incr article:read:1001String 的关键问题是:如果 value 很大,就会变成大 Key。
错误示例:
set org:all:users "[几十万用户 JSON]"问题:
- 单次网络传输大。
- 序列化和反序列化慢。
GET、SET、DEL都会变重。- 主从复制和 AOF 也会变重。
Hash 原理和场景
Hash 适合保存对象字段:
hset user:1001 name Tom age 18 city Hangzhou
hget user:1001 name
hincrby user:1001 loginCount 1Hash 的优点:
| 优点 | 说明 |
|---|---|
| 字段级读写 | 不必每次读写整个 JSON |
| 适合对象缓存 | 用户资料、购物车、配置字段 |
| 小 Hash 省内存 | 小对象可用紧凑编码 |
但 Hash 也可能成为大 Key:
hset org:1001:allUsers user:1 ... user:2 ... user:999999 ...如果 field 过多,hgetall 会非常危险。大 Hash 要拆分或用 hscan 分批。
List 原理和场景
List 适合两端操作:
lpush queue:task task1
rpop queue:task常见场景:
- 简单队列。
- 时间线。
- 最近 N 条记录。
但 List 不是可靠消息队列。它没有完整的消费确认、重试、死信、延迟投递治理。商业上关键异步任务更建议用 RocketMQ、RabbitMQ、Kafka 或 Redis Stream。
Set 原理和场景
Set 适合去重集合:
sadd article:liked:1001 user:1 user:2
sismember article:liked:1001 user:1
sinter tag:java tag:redis场景:
- 点赞用户去重。
- 标签集合。
- 共同好友。
- 黑白名单。
风险:大 Set 做交集、并集、差集可能很重。
ZSet 原理和场景
ZSet 是有序集合,每个 member 有一个 score。
zadd rank:article 100 article:1 80 article:2
zrevrange rank:article 0 9 withscores
zincrby rank:article 10 article:2适合:
- 排行榜。
- 延迟任务。
- 按时间排序的集合。
- TopN。
ZSet 底层常用跳表加字典的组合思路:
flowchart TD
A["ZSet"] --> B["dict: member -> score"]
A --> C["skiplist: 按 score 有序"]
B --> D["快速查 member 分数"]
C --> E["快速范围和排名查询"]为什么不是只用一个结构?
| 结构 | 优势 | 不足 |
|---|---|---|
| 字典 | 按 member 查快 | 不支持按 score 排序 |
| 跳表 | 范围和排序快 | 按 member 查不如字典直接 |
所以 ZSet 组合两者。
Bitmap、HyperLogLog、Stream
| 类型 | 适合 | 注意 |
|---|---|---|
| Bitmap | 签到、是否活跃、布尔状态 | 本质是 String 的 bit 操作,偏移过大也会占空间 |
| HyperLogLog | UV 估算 | 有误差,不适合精确计数 |
| Stream | 消息流、消费组 | 比 List 更像 MQ,但运维和可靠性仍要评估 |
签到:
setbit sign:202607 userId 1
getbit sign:202607 userId
bitcount sign:202607UV:
pfadd uv:20260705 user:1 user:2
pfcount uv:20260705命令复杂度为什么重要
Redis 单线程顺序执行命令,所以命令复杂度非常重要。
| 命令 | 风险 |
|---|---|
GET smallKey | 通常很快 |
HGETALL bigHash | 可能返回大量 field |
SMEMBERS bigSet | 可能阻塞和大流量返回 |
KEYS * | 扫描整个 keyspace,生产危险 |
| 长 Lua | 执行期间阻塞后续命令 |
一个慢命令的影响:
flowchart TD
A["慢命令开始执行"] --> B["主线程被占用"]
B --> C["后续命令排队"]
C --> D["客户端等待变长"]
D --> E["应用 timeout"]商业场景:购物车怎么设计
购物车适合 Hash:
hset cart:1001 sku:1 2 sku:2 1
hincrby cart:1001 sku:1 1
hgetall cart:1001
expire cart:1001 604800为什么不是 String JSON?
| 方案 | 问题 |
|---|---|
| String JSON | 每次加商品都要读整个 JSON、改完再写回 |
| Hash | 可以字段级更新某个 sku 数量 |
但如果购物车商品非常多,不要频繁 hgetall,应分页或限制上限。
商业场景:排行榜怎么设计
文章热度排行榜:
zincrby rank:article 1 article:1001
zrevrange rank:article 0 99 withscores
zrevrank rank:article article:1001为什么用 ZSet:
- score 表示热度。
- 自动按 score 排序。
- 能查 TopN。
- 能查某个文章排名。
如果用 MySQL 排序,每次都要扫描或维护复杂索引;如果用 List,没有按 score 排序能力。
排查命令
slowlog get 20
info commandstats
memory usage key
scan 0 match user:* count 100排查重点:
- 是否有慢命令。
- 是否有大 Key。
- 是否有全量命令。
- 是否某类命令调用异常高。
- 是否输出缓冲区变大。
面试标准回答
Redis 快不只是因为内存。它使用 IO 多路复用处理大量连接,核心命令执行线程模型简单,减少锁竞争和上下文切换;数据结构针对业务场景优化,很多命令复杂度低。Redis 的 keyspace 本质是字典,key 对应 Redis Object,Object 里有 type 和 encoding,同一种类型在不同大小下可能使用不同底层编码。String 适合缓存和计数,Hash 适合对象字段,Set 适合去重,ZSet 适合排行榜,Bitmap 适合签到,HyperLogLog 适合 UV 估算。因为命令顺序执行,大 Key、全量命令、长 Lua 会阻塞后续请求,生产要用 slowlog、bigkeys、memory usage、commandstats 排查,并按业务访问方式选择数据结构。