Skip to content

Redis 命令执行与数据结构原理

很多人学 Redis 只会背:

text
Redis 是内存数据库,单线程,速度快。

这不够。要真正学懂 Redis,必须知道一个命令从客户端发出后,Redis 内部经历了什么;也要知道 String、Hash、List、Set、ZSet 为什么适合不同业务场景。

这一页解决这些问题:

  1. Redis 单线程到底单在哪里。
  2. 一个 GETSETHGET 命令如何执行。
  3. Redis 为什么用 IO 多路复用。
  4. Redis 对象是什么,编码是什么。
  5. 为什么同一个 Hash 小的时候和大的时候内部结构可能不同。
  6. 为什么大 Key、慢命令会阻塞其他请求。
  7. 业务上怎么根据访问方式选数据结构。

一条命令的总流程

mermaid
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 常说单线程,主要指:

text
核心命令执行逻辑通常由一个主线程顺序执行。

它不等于 Redis 进程里永远只有一个线程。Redis 还有后台线程、异步释放、AOF rewrite 子进程、网络 IO 多线程等能力。面试回答要说清楚“核心命令执行”和“整个进程”不是一回事。

mermaid
flowchart TD
    A["Redis 进程"] --> B["主线程<br/>处理命令"]
    A --> C["后台任务<br/>异步释放 / lazy free"]
    A --> D["持久化子进程<br/>RDB / AOF rewrite"]
    A --> E["网络 IO 多线程<br/>部分版本可开启"]

为什么核心命令执行偏单线程:

  1. 避免多线程同时修改字典和对象带来的锁竞争。
  2. 命令执行通常很短,单线程足够快。
  3. 顺序执行让原子性更容易保证。
  4. 降低复杂度。

但代价是:一个慢命令会让后续命令排队

IO 多路复用是什么

Redis 要同时处理很多客户端连接。如果每个连接一个线程,会有大量线程切换和锁成本。

IO 多路复用可以让 Redis 用少量线程监听很多 socket 事件。

mermaid
flowchart TD
    A["很多客户端连接"] --> B["epoll / kqueue / select"]
    B --> C{"哪个 socket 有事件"}
    C --> D["可读:读取命令"]
    C --> E["可写:返回响应"]
    D --> F["主线程执行命令"]
    E --> G["写回客户端"]

可以把它理解为:

Redis 不需要傻等某一个连接,而是让操作系统告诉它“哪些连接现在有活干”。

Redis keyspace 是什么

Redis 每个数据库内部都有一个类似字典的结构,保存 key 到 value 对象的映射。

mermaid
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。对象里会记录:

信息作用
typeString、Hash、List、Set、ZSet 等类型
encoding底层编码,例如 int、embstr、listpack、hashtable、skiplist
ptr指向真实数据结构
lru/lfu 信息淘汰策略可能用到
refcount引用计数,部分场景使用

为什么要有 encoding?

同一种类型在不同大小下,用不同底层结构更省内存或更快。

例如 Hash:

text
字段少、值短:可能用 listpack,省内存。
字段多、变大:转换为 hashtable,查询更快。

String 原理和场景

String 适合:

  1. 普通缓存。
  2. 计数器。
  3. 分布式锁 token。
  4. JSON 小对象。

示例:

bash
set user:profile:1001 '{"name":"Tom","age":18}' ex 3600
get user:profile:1001
incr article:read:1001

String 的关键问题是:如果 value 很大,就会变成大 Key。

错误示例:

bash
set org:all:users "[几十万用户 JSON]"

问题:

  1. 单次网络传输大。
  2. 序列化和反序列化慢。
  3. GETSETDEL 都会变重。
  4. 主从复制和 AOF 也会变重。

Hash 原理和场景

Hash 适合保存对象字段:

bash
hset user:1001 name Tom age 18 city Hangzhou
hget user:1001 name
hincrby user:1001 loginCount 1

Hash 的优点:

优点说明
字段级读写不必每次读写整个 JSON
适合对象缓存用户资料、购物车、配置字段
小 Hash 省内存小对象可用紧凑编码

但 Hash 也可能成为大 Key:

bash
hset org:1001:allUsers user:1 ... user:2 ... user:999999 ...

如果 field 过多,hgetall 会非常危险。大 Hash 要拆分或用 hscan 分批。

List 原理和场景

List 适合两端操作:

bash
lpush queue:task task1
rpop queue:task

常见场景:

  1. 简单队列。
  2. 时间线。
  3. 最近 N 条记录。

但 List 不是可靠消息队列。它没有完整的消费确认、重试、死信、延迟投递治理。商业上关键异步任务更建议用 RocketMQ、RabbitMQ、Kafka 或 Redis Stream。

Set 原理和场景

Set 适合去重集合:

bash
sadd article:liked:1001 user:1 user:2
sismember article:liked:1001 user:1
sinter tag:java tag:redis

场景:

  1. 点赞用户去重。
  2. 标签集合。
  3. 共同好友。
  4. 黑白名单。

风险:大 Set 做交集、并集、差集可能很重。

ZSet 原理和场景

ZSet 是有序集合,每个 member 有一个 score。

bash
zadd rank:article 100 article:1 80 article:2
zrevrange rank:article 0 9 withscores
zincrby rank:article 10 article:2

适合:

  1. 排行榜。
  2. 延迟任务。
  3. 按时间排序的集合。
  4. TopN。

ZSet 底层常用跳表加字典的组合思路:

mermaid
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 操作,偏移过大也会占空间
HyperLogLogUV 估算有误差,不适合精确计数
Stream消息流、消费组比 List 更像 MQ,但运维和可靠性仍要评估

签到:

bash
setbit sign:202607 userId 1
getbit sign:202607 userId
bitcount sign:202607

UV:

bash
pfadd uv:20260705 user:1 user:2
pfcount uv:20260705

命令复杂度为什么重要

Redis 单线程顺序执行命令,所以命令复杂度非常重要。

命令风险
GET smallKey通常很快
HGETALL bigHash可能返回大量 field
SMEMBERS bigSet可能阻塞和大流量返回
KEYS *扫描整个 keyspace,生产危险
长 Lua执行期间阻塞后续命令

一个慢命令的影响:

mermaid
flowchart TD
    A["慢命令开始执行"] --> B["主线程被占用"]
    B --> C["后续命令排队"]
    C --> D["客户端等待变长"]
    D --> E["应用 timeout"]

商业场景:购物车怎么设计

购物车适合 Hash:

bash
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,应分页或限制上限。

商业场景:排行榜怎么设计

文章热度排行榜:

bash
zincrby rank:article 1 article:1001
zrevrange rank:article 0 99 withscores
zrevrank rank:article article:1001

为什么用 ZSet:

  1. score 表示热度。
  2. 自动按 score 排序。
  3. 能查 TopN。
  4. 能查某个文章排名。

如果用 MySQL 排序,每次都要扫描或维护复杂索引;如果用 List,没有按 score 排序能力。

排查命令

bash
slowlog get 20
info commandstats
memory usage key
scan 0 match user:* count 100

排查重点:

  1. 是否有慢命令。
  2. 是否有大 Key。
  3. 是否有全量命令。
  4. 是否某类命令调用异常高。
  5. 是否输出缓冲区变大。

面试标准回答

text
Redis 快不只是因为内存。它使用 IO 多路复用处理大量连接,核心命令执行线程模型简单,减少锁竞争和上下文切换;数据结构针对业务场景优化,很多命令复杂度低。Redis 的 keyspace 本质是字典,key 对应 Redis Object,Object 里有 type 和 encoding,同一种类型在不同大小下可能使用不同底层编码。String 适合缓存和计数,Hash 适合对象字段,Set 适合去重,ZSet 适合排行榜,Bitmap 适合签到,HyperLogLog 适合 UV 估算。因为命令顺序执行,大 Key、全量命令、长 Lua 会阻塞后续请求,生产要用 slowlog、bigkeys、memory usage、commandstats 排查,并按业务访问方式选择数据结构。