2. Redis 基础回顾:数据结构详解与单线程模型

在深入业务削峰与缓存一致性方案之前,必须夯实 Redis 核心基础。本章将系统回顾五种主要数据结构的底层实现与适用场景,并剖析 Redis 单线程模型与 IO 多路复用的工作原理,为后续章节的架构设计打下坚实基础。

2.1 五种核心数据结构详解

Redis 提供了丰富的数据结构,每种结构在底层都有对应的编码方式(encoding),以适应不同数据量和操作模式。下表总结了五种结构的核心特性与典型应用:

数据结构 底层编码(主要) 时间复杂度(关键操作) 典型业务场景
String int / embstr / raw GET/SET: O(1) 计数器、分布式锁、对象缓存
Hash ziplist / hashtable HGET/HSET: O(1) 对象属性存储、用户信息
List quicklist(ziplist + linkedlist) LPUSH/RPOP: O(1) 消息队列、时间线、最新消息
Set intset / hashtable SADD/SISMEMBER: O(1) 标签系统、去重、抽奖
Sorted Set ziplist / skiplist + dict ZADD: O(log N);ZRANGE: O(log N + M) 排行榜、延时队列、限流

2.1.1 String — 最基础的构建块

String 是 Redis 最基础的数据类型,最大存储 512 MB。其底层编码根据值类型自动切换:

  • int:当值为整数且可被 long 表示时使用,支持原子自增/自减。
  • embstr:当字符串长度 ≤ 44 字节时,使用连续内存块一次性分配,减少内存碎片。
  • raw:当字符串长度 > 44 字节时,使用 SDS(Simple Dynamic String)结构,支持动态扩容。
业务提示: 使用 String 做计数器时,务必关注 INCR/DECR 的原子性;在分布式锁场景中,结合 SET NX EX 命令可避免死锁。

2.1.2 Hash — 对象属性容器

Hash 适合存储结构化对象,如用户信息、商品详情。底层编码在字段数少且值较小时使用 ziplist(紧凑列表),当字段数超过 hash-max-ziplist-entries(默认 512)或字段值超过 hash-max-ziplist-value(默认 64 字节)时,自动升级为 hashtable。

# 用户信息存储示例
HSET user:1001 name "张三" age 28 city "北京"
HGETALL user:1001

2.1.3 List — 有序队列

List 底层使用 quicklist 结构,它是 ziplist 与双向链表的混合体。每个 quicklist 节点是一个 ziplist,既保证了内存紧凑,又支持两端的高效插入删除。List 是构建消息队列的天然选择,但需注意 BRPOP / BLPOP 的阻塞特性。

避坑指南: 使用 List 做消息队列时,若消费者处理速度跟不上生产者,List 会无限增长,建议配合 LLEN 监控队列长度,或使用 Redis Stream 替代。

2.1.4 Set — 无序去重集合

Set 支持集合运算(交集、并集、差集),底层在元素全为整数且数量较少时使用 intset(有序整数数组),否则使用 hashtable。intset 的查找通过二分法实现,效率极高。

# 标签系统示例
SADD article:101 tag:java tag:redis tag:architecture
SINTER article:101 article:102  # 获取共同标签

2.1.5 Sorted Set — 带权重的有序集合

Sorted Set 是 Redis 最强大的数据结构之一,底层结合 skiplist(跳跃表)与 dict(哈希表)。skiplist 保证按 score 排序和范围查询的高效性,dict 保证按 member 查找的 O(1) 性能。当元素数少于 zset-max-ziplist-entries(默认 128)且所有元素长度小于 zset-max-ziplist-value(默认 64 字节)时,使用 ziplist 以节省内存。

# 排行榜示例
ZADD leaderboard 100 "userA" 200 "userB" 150 "userC"
ZREVRANGE leaderboard 0 2 WITHSCORES  # 获取前三名

2.2 Redis 单线程模型与 IO 多路复用

Redis 的核心处理模型是 单线程 Reactor 模型,即所有命令的读取、解析、执行、回复都在一个主线程中串行完成。这一设计并非性能瓶颈,反而带来了原子性、无锁竞争和实现简单的优势。

2.2.1 为什么单线程还能这么快?

  • 内存操作:所有数据存储在内存中,读写延迟通常在微秒级。
  • IO 多路复用:使用 epoll(Linux)/ kqueue(macOS)实现单线程管理数千个客户端连接。
  • 避免上下文切换:单线程消除了多线程的锁竞争、上下文切换开销。
  • 非阻塞 IO:网络读写采用非阻塞模式,结合事件循环高效调度。
注意: 单线程模型意味着任何耗时操作(如 KEYS、FLUSHALL、大 key 删除)都会阻塞整个事件循环,导致所有客户端等待。生产环境应使用 SCAN 替代 KEYS,使用 UNLINK 替代 DEL。

2.2.2 IO 多路复用工作流程

Redis 的事件循环(aeEventLoop)通过 IO 多路复用函数同时监听多个 socket 的读写事件。当某个 socket 可读或可写时,事件循环将对应的回调函数(如 readQueryFromClient、sendReplyToClient)放入待处理队列,由主线程依次执行。

Redis 单线程事件循环与 IO 多路复用 Redis 单线程事件循环与 IO 多路复用 客户端 1 客户端 2 客户端 N IO 多路复用 epoll / kqueue 事件队列 待处理事件 主线程 串行执行 命令 内存数据 String / Hash / ... 流程说明: 1. 多个客户端连接通过 epoll 注册到 IO 多路复用模块。 2. 当某个 socket 可读/可写时,IO 多路复用将事件放入事件队列。 3. 主线程从事件队列中取出事件,串行执行命令并操作内存数据。 4. 执行完成后,主线程将结果写回客户端 socket(非阻塞写)。

2.2.3 单线程模型下的原子性保证

由于所有命令在单线程中串行执行,Redis 天然具备原子性。这意味着:

  • 单个命令(如 INCR、HSET)的执行不会被其他命令中断。
  • 多个命令可以通过 MULTI / EXEC 事务保证原子性,但事务中的命令不会立即执行,而是排队后一次性提交。
  • Lua 脚本在 Redis 中也是原子执行的,适合实现复杂业务逻辑。
# Lua 脚本原子扣减库存示例
local stock = redis.call('GET', KEYS[1])
if stock and tonumber(stock) > 0 then
    redis.call('DECR', KEYS[1])
    return 1
else
    return 0
end

2.3 数据结构选型与性能对比

在实际业务中,选择合适的数据结构直接影响系统性能与内存效率。下表从内存占用、读写速度、适用场景三个维度进行对比:

数据结构 内存效率 读写速度 推荐场景
String 高(直接存储值) 极快(O(1)) 简单缓存、计数器、分布式锁
Hash 中(ziplist 时高) 快(O(1)) 对象属性、用户信息、配置项
List 中(quicklist 平衡) 两端快(O(1)) 消息队列、时间线、日志
Set 中(intset 时高) 快(O(1)) 去重、标签、好友关系
Sorted Set 低(skiplist 开销) 中等(O(log N)) 排行榜、延时队列、限流
架构建议: 在削峰场景中,Sorted Set 常用于实现滑动窗口限流,List 用于异步削峰队列,String 用于令牌桶计数器。理解每种结构的底层特性,才能设计出高吞吐、低延迟的削峰方案。

2.4 本章小结