Redis 核心架构与原理
为什么 Redis 这么快?
- 完全基于内存:绝大部分请求是纯内存操作。
- 高效的数据结构:专门设计的 SkipList、SDS、压缩列表等。
- 单线程模型:避免了多线程频繁上下文切换和锁竞争的消耗。 (注意:Redis 6.0+ 引入多线程 IO,但执行命令依然是单线程)。
- IO 多路复用:利用
epoll机制,使其能够同时处理大量的客户端连接。
核心数据类型及应用场景
| 数据类型 | 底层实现 | 典型场景 |
|---|---|---|
| String | SDS (简单动态字符串) | 缓存、计数器、分布式锁 |
| Hash | ziplist, hashtable | 存储对象(如用户信息) |
| List | quicklist | 消息队列、最新消息排行 |
| Set | intset, hashtable | 去重、交集/并集(共同好友) |
| ZSet | ziplist, skiplist | 排行榜、限流、带权重的消息队列 |
高效的数据结构
按前面“核心数据类型及应用场景”的顺序,这里依次介绍 String -> Hash -> List -> Set -> ZSet 对应的底层结构。
SDS (Simple Dynamic String)
Redis 的字符串不是 C 的 char*,而是 SDS。它把字符串长度等元信息放在头部,从而避免反复扫描。
- 为什么快:获取长度是 $O(1)$,不需要
strlen全量遍历。 - 为什么稳:自动预分配和惰性空间回收,减少频繁内存分配。
- 为什么安全:支持二进制数据,不依赖
\0作为结束符。
Dict / Hashtable (Hash)
Hash、Set 在元素变多后会转为哈希表实现。Redis 通过渐进式 rehash把扩容成本摊到多次请求中,避免一次性卡顿。
- 平均复杂度:查找、插入、删除通常为 $O(1)$。
- 扩容策略:维护
ht[0]和ht[1]两张表,逐步搬迁桶。 - 工程价值:在高并发下保持较平滑的延迟曲线。
ListPack (Hash 小对象编码)
当 Hash 元素较少且 field/value 较短时,Redis 会优先用 ListPack 做紧凑存储;规模变大后再转为 hashtable。
- 更省内存:连续内存布局,额外指针开销更低。
- 遍历效率好:顺序扫描简单直接,适合小对象集合。
- 编码基础件:很多高级数据类型都会复用它。
QuickList (List)
Redis 的 List 并不是简单链表,而是 quicklist: 双向链表的每个节点里放一个紧凑的 listpack。
- 两端操作友好:保留链表在头尾插入删除上的优势。
- 内存更紧凑:节点内部不是散乱小对象,而是连续内存块。
- 折中设计:在操作性能和空间利用率之间取得平衡。
IntSet (Set)
当 Set 里都是整数且元素较少时,Redis 会用 intset。它把整数放在一段连续内存里,极省空间。
- 紧凑存储:同类型整数连续排列,缓存友好。
- 按需升级:当出现更大数值时,从 16 位升级到 32/64 位。
- 适用场景:小规模 ID 集合、白名单、灰度用户列表。
SkipList (ZSet)
Redis 的 Sorted Set (ZSet) 在数据量较大时采用跳表作为底层实现。它在普通有序链表的基础上增加了多层索引,通过“空间换时间”策略实现近似二分查找的效率。
- 复杂度:查找、插入、删除的平均时间复杂度均为 $O(\log N)$。
- 实现简单:相比平衡树(如红黑树),跳表逻辑更简洁,工程实现更直接。
- 范围查询友好:执行
ZRANGE等范围操作时,只需定位到起点后顺序遍历。
选型直觉(面试/实战高频)
- 字符串读写多:关注
SDS的 $O(1)$ 长度和预分配机制。 - 键值映射/集合查找多:
Dict的平均 $O(1)$ 通常是首选。 - 队列/时间线类场景:
QuickList在头尾操作和内存之间更平衡。 - 纯整数且规模不大:
IntSet可以显著节省内存。 - 小对象聚合存储:
ListPack往往比通用结构更省空间。
持久化机制:AOF vs RDB
RDB (Redis Database):全量快照。
优点:恢复快,文件小。
缺点:会丢数据(两次快照间的数据)。
AOF (Append Only File):增量日志。
优点:数据安全性高,支持秒级持久化。
缺点:文件大,恢复慢。
混合持久化(Redis 4.0+):RDB 做全量 + AOF 做增量,目前的主流推荐配置。
高可用与集群方案
主从复制 (Replication)
解决读写分离,主节点写,从节点读. 存在单点故障风险。
哨兵模式 (Sentinel)
在主从基础上增加监控和自动故障转移。
- 原理:哨兵集群监控 Master 状态,Master 挂掉后通过选举算法提升 Slave 为新 Master。
Redis Cluster (分片集群)
解决单机内存瓶颈。
- 哈希槽 (Hash Slot):共有 16384 个槽。通过 $CRC16(key) \pmod{16384}$ 决定数据落在哪个节点。
哈希标签 (Hash Tag) 与同槽保证
在 Redis Cluster 中,数据分片导致不同的 Key 可能散落在不同节点。如果尝试对跨节点的 Key 执行原子操作(如 MGET, MSET, Lua 脚本),会触发 CROSSSLOT 错误。
核心机制:Hash Tag
当一个 Key 包含 {} 符号时,Redis 只会对 { 和 } 之间的内容进行哈希计算。
- 计算公式: $slot = CRC16(tag) \pmod{16384}$
- 规则: 只有当
{第一次出现,且其后有}第一次出现,且两者之间至少有一个字符时,才会触发 Hash Tag 逻辑。
举例对比:
| Key 名称 | 实际参与哈希的部分 | 说明 |
|---|---|---|
user:1001:profile | user:1001:profile | 对全名哈希,随机分配槽位 |
user:{1001}:profile | 1001 | 只对 1001 哈希 |
user:{1001}:order | 1001 | 只对 1001 哈希,必与上者同槽 |
Go 语言代码实现示例:
// 使用 {user100} 作为 Hash Tag 保证 profile 和 settings 在同一个 Slot
profileKey := "{user100}:profile"
settingsKey := "{user100}:settings"
// 此时可以使用 MSet 或在 Lua 脚本中同时操作它们,不会报 CROSSSLOT 错误
err := rdb.MSet(ctx, profileKey, "data1", settingsKey, "data2").Err()
注意事项(架构风险):
- 数据倾斜 (Data Skew):过度使用 Hash Tag 会导致某些分片负载远高于其他节点,失去集群横向扩展的意义。
- 建议:尽量使用细粒度标签(如
{user:1001}),仅在确实需要原子性或批量操作时才使用。
高频问题:穿透、击穿、雪崩
这是最考验实际项目经验的部分。
缓存穿透 (Cache Penetration)
- 现象:Key 在缓存和数据库中都不存在,大量请求直接打到数据库。
- 解决:
- 参数校验。
- 缓存空对象(设置短 TTL)。
- 布隆过滤器 (Bloom Filter)。
缓存击穿 (Cache Breakdown)
- 现象:某个热点 Key 突然失效,瞬间海量请求压垮数据库。
- 解决:
- 设置热点数据永不过期。
- 使用互斥锁 (Mutex Lock),只允许一个请求去加载 DB。
缓存雪崩 (Cache Avalanche)
- 现象:大量 Key 同时过期,或者 Redis 宕机。
- 解决:
- 过期时间加随机扰动值(防止同时失效)。
- 利用哨兵/集群实现高可用。
- 服务降级或限流。
Redis 常见问题
Q1:Redis 的过期删除策略 and 内存淘汰策略有什么区别?
- 删除策略:针对过期 Key。Redis 采用“惰性删除 + 定期删除”。
- 淘汰策略:针对内存满了。常用
allkeys-lru(最近最少使用)或volatile-lfu(最不经常使用)。
Q2:如何实现分布式锁?需要注意什么?
- 使用
SET key value NX PX milliseconds。 - 注意点:
- 必须设置过期时间(防止死锁)。
- Value 要具有唯一性(防止误删别人的锁)。
- 释放锁时要用 Lua 脚本保证原子性。
Q3:Redis 怎么保证和数据库的一致性?
常用方案:Cache Aside Pattern(旁路缓存)。
读取:先读缓存,没有则读 DB 并回写。
更新:先更新 DB,再删除缓存。
如果要求强一致性,可以结合 Read/Write Through 或使用 Canal 监听 Binlog 异步更新。
进阶与调优
- BigKey 问题:Key 很大(如百万成员的 Set)会导致阻塞。解决方法:拆分或异步删除(
UNLINK)。 - HotKey 问题:某个 Key 访问量巨大。解决方法:利用二级缓存(本地内存)或在 Key 后加随机后缀做副本。
缓存的模式
| 模式 | 工作方式 | 优点 | 缺点 |
|---|---|---|---|
| 旁路缓存 (Cache-Aside) | 读: 应用查缓存,无则查DB并入缓存。写: 直接更新DB。 | 简单,耦合度低。 | 首次读取延迟;数据不一致风险(写后立即读到旧缓存)。 |
| 读穿 (Read-Through) | 读: 应用请求缓存,缓存未命中时自行从DB加载。 | 应用代码简洁,无需处理DB逻辑。 | 需缓存提供程序支持;缓存层责任重。 |
| 写穿 (Write-Through) | 写: 应用写入缓存,缓存立即同步写入DB。 | 数据强一致性。 | 写入延迟较高(等待双写完成)。 |
| 回写 (Write-Back / Write-Behind) | 写: 应用写入缓存后立即返回,缓存异步批量写入DB。 | 写入性能极高。 | 缓存崩溃时可能丢失数据;最终一致性。 |
| 环绕写 (Write-Around) | 写: 直接写入DB,绕过缓存。读: 遵循旁路缓存模式。 | 避免低频读数据污染缓存;适合写多读少。 | 读取新写入数据时延迟高(首次必未命中);不适合写后立即读。 |
| 提前刷新 (Refresh-Ahead) | 刷新: 在缓存过期之前,系统自动异步从DB加载最新数据并刷新。 | 消除读取时的 Cache Miss,提供极低延迟。 | 预测不准会导致资源浪费;实现复杂度高。 |
Cache-Aside (旁路缓存)
Read-Through (读穿)
Write-Through (写穿)
Write-Back (回写)
Write-Around (环绕写)
Refresh-Ahead (提前刷新)
相关应用
Redis + Lua 脚本实现原子性扣减
-- KEYS[1]: 库存的 Key (例如 "item:1001:stock")
-- ARGV[1]: 想要扣减的数量 (例如 1)
local stockKey = KEYS[1]
local num = tonumber(ARGV[1])
-- 1. 获取当前库存
local currentStock = redis.call('GET', stockKey)
-- 2. 如果 Key 不存在,可以视业务情况返回特殊错误码(比如 -1)
if not currentStock then
return -1
end
-- 3. 转换为数字进行比较
currentStock = tonumber(currentStock)
if currentStock < num then
-- 库存不足,返回 -2
return -2
else
-- 4. 库存充足,进行扣减
redis.call('DECRBY', stockKey, num)
-- 返回扣减后的剩余库存
return currentStock - num
end