Redis 核心架构与原理

为什么 Redis 这么快?

核心数据类型及应用场景

数据类型底层实现典型场景
StringSDS (简单动态字符串)缓存、计数器、分布式锁
Hashziplist, hashtable存储对象(如用户信息)
Listquicklist消息队列、最新消息排行
Setintset, hashtable去重、交集/并集(共同好友)
ZSetziplist, skiplist排行榜、限流、带权重的消息队列

高效的数据结构

按前面“核心数据类型及应用场景”的顺序,这里依次介绍 String -> Hash -> List -> Set -> ZSet 对应的底层结构。

SDS (Simple Dynamic String)

Redis 的字符串不是 C 的 char*,而是 SDS。它把字符串长度等元信息放在头部,从而避免反复扫描。

D2 Diagram
qtopie.github.io
Dict / Hashtable (Hash)

Hash、Set 在元素变多后会转为哈希表实现。Redis 通过渐进式 rehash把扩容成本摊到多次请求中,避免一次性卡顿。

D2 Diagram
qtopie.github.io
ListPack (Hash 小对象编码)

当 Hash 元素较少且 field/value 较短时,Redis 会优先用 ListPack 做紧凑存储;规模变大后再转为 hashtable。

D2 Diagram
qtopie.github.io
QuickList (List)

Redis 的 List 并不是简单链表,而是 quicklist: 双向链表的每个节点里放一个紧凑的 listpack。

D2 Diagram
qtopie.github.io
IntSet (Set)

当 Set 里都是整数且元素较少时,Redis 会用 intset。它把整数放在一段连续内存里,极省空间。

D2 Diagram
qtopie.github.io
SkipList (ZSet)

Redis 的 Sorted Set (ZSet) 在数据量较大时采用跳表作为底层实现。它在普通有序链表的基础上增加了多层索引,通过“空间换时间”策略实现近似二分查找的效率。

D2 Diagram
qtopie.github.io
选型直觉(面试/实战高频)

持久化机制:AOF vs RDB


高可用与集群方案

主从复制 (Replication)

解决读写分离,主节点写,从节点读. 存在单点故障风险。

哨兵模式 (Sentinel)

在主从基础上增加监控和自动故障转移

Redis Cluster (分片集群)

解决单机内存瓶颈。

哈希标签 (Hash Tag) 与同槽保证

在 Redis Cluster 中,数据分片导致不同的 Key 可能散落在不同节点。如果尝试对跨节点的 Key 执行原子操作(如 MGET, MSET, Lua 脚本),会触发 CROSSSLOT 错误。

核心机制:Hash Tag 当一个 Key 包含 {} 符号时,Redis 只会对 {} 之间的内容进行哈希计算。

举例对比:

Key 名称实际参与哈希的部分说明
user:1001:profileuser:1001:profile对全名哈希,随机分配槽位
user:{1001}:profile1001只对 1001 哈希
user:{1001}:order1001只对 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()

注意事项(架构风险):


高频问题:穿透、击穿、雪崩

这是最考验实际项目经验的部分。

缓存穿透 (Cache Penetration)

缓存击穿 (Cache Breakdown)

互斥锁与看门狗自动续期流程

D2 Diagram
qtopie.github.io

缓存雪崩 (Cache Avalanche)


Redis 常见问题

Q1:Redis 的过期删除策略 and 内存淘汰策略有什么区别?

Q2:如何实现分布式锁?需要注意什么?

  1. 必须设置过期时间(防止死锁)。
  2. Value 要具有唯一性(防止误删别人的锁)。
  3. 释放锁时要用 Lua 脚本保证原子性。

Q3:Redis 怎么保证和数据库的一致性?

延迟双删 (Delayed Double Delete)

在并发写/读极高的场景下,“先更新 DB,再删缓存”或“先删缓存,再更新 DB”仍可能因并发读写导致脏数据回写缓存

脏数据产生的典型并发场景(先删缓存,后更新 DB)
  1. 线程 A 准备更新数据,先删除 Redis 缓存
  2. 线程 B 发起并发读请求,发现 Redis 未命中,遂读取 DB 中的旧数据
  3. 线程 A 完成 DB 数据更新
  4. 线程 B 将刚刚读取到的旧数据回写到 Redis 缓存
  5. 结果:缓存中留下了旧脏数据,直至该 Key 自然过期或再次被更新。
延迟双删的解决流程与步骤
  1. 第一次删除缓存:写请求到来,先删除 Redis 缓存。
  2. 更新数据库:将最新数据写入 DB。
  3. 休眠延迟一定时间 ($N$ 毫秒):等待在“第一次删缓存 $\to$ 更新 DB”这段危险时间窗口内发起的并发读请求完成“读 DB 旧数据 + 回写旧缓存”的过程。
  4. 第二次删除缓存(核心抹除):强制删除读线程刚回写的旧脏缓存,从根本上解决窗口期内并发读导致的脏数据问题。
D2 Diagram
qtopie.github.io
延迟时间 $N$ 如何确定?

延迟时间 $N \approx \text{业务读请求耗时} + \text{主从同步延迟} + \text{安全缓冲空间 (如 100~500ms)}$。

核心优势:读写无阻塞 (Non-blocking) 与性能 Trade-off
局限性与优化

进阶与调优

BigKey 问题

什么是 BigKey?

BigKey 的危害

  1. 阻塞单线程:Redis 核心命令执行是单线程的。读写或删除 BigKey(如 DEL 一个含百万元素的 Set)会导致 Redis 主线程长时间阻塞,引发客户端响应超时或 Sentinel 误判宕机。
  2. 网络 IO 拥塞:在读取 BigKey 时引发瞬间高带宽占用,打满网卡,拖慢同一 Redis 节点上的其他正常请求。
  3. 内存分布不均 / 倾斜:在 Redis Cluster 集群中,BigKey 所在的节点内存占用远高于其他节点。

如何发现与定位?

解决方案

  1. 大对象拆分
    • Hash/Set 拆分:通过 Hash Tag 将一个大的 Hash/Set 拆分为多个小 Key(如按用户 ID 模 100 散列到 user:info:0 ~ user:info:99)。
    • 时间/页码分片:对 List 或 ZSet 按时间维度(日/月)或分页维度进行切割存储。
  2. 异步清理与删除
    • 使用 Redis 4.0+ 提供的非阻塞删除指令 UNLINK key 代替传统的阻塞式 DEL
    • 对集合元素逐批清理:如 Hash 使用 HSCAN + HDEL,Set 使用 SSCAN + SREM 逐步删除。
  3. 过期机制调优:开启 lazyfree-lazy-user-del yeslazyfree-lazy-expire yes,让过期清理由后台 Bio 线程异步完成。

HotKey 问题

什么是 HotKey?

某个特定 Key 的 QPS(每秒请求数)极高(如秒杀商品、热点爆款新闻、大 V 微博),单 Key 访问并发打满单个 Redis 节点的 CPU 或网络吞吐。

HotKey 的危害

  1. 缓存节点过载 / 宕机:单点 Redis 节点 CPU 利用率飙升至 100%,引发大量请求超时。
  2. 击穿压垮数据库:当 HotKey 所在节点卡死或崩溃时,海量请求会瞬间穿透到底层 DB,引发系统雪崩。

如何发现与定位?

客户端 HotKey 统计:两层过滤架构
D2 Diagram
qtopie.github.io
第二层滑动窗口的底层实现:环形数组 (Circular Array) + 时间桶 (Bucket)

当 Key 通过第一层筛查被判定为“潜在热 Key”后,系统为其分配精细滑动窗口。最优雅高效的实现方式是环形数组 + 时间桶

为什么能做到“任意毫秒级”平滑滑动?(突破整数秒限制)

“只能按整数秒统计”是传统固定窗口算法 (Fixed Window) 的典型缺陷。而基于环形数组时间桶的滑动窗口滑动步长是小桶的时间粒度(Bucket Size,如 100ms 或 10ms),而不是 1 秒:

算法对比:固定窗口 vs 环形滑动窗口
核心优势与性能表现
  1. 固定内存开销 ($O(1)$):按 index = (currentTimestamp / bucketSize) % N 快速定位,无需记录逐笔请求的打点时间戳列表,内存完全可控。
  2. 零 GC 压力:数组与 Bucket 内存直接复用,覆盖旧数据时仅需重置 count 并更新起始时间,无任何对象频繁创建与销毁。
  3. 超高读写性能:更新与窗口求和均在 $O(1)$ 到 $O(N)$($N$ 极小,如 10~100)时间内完成。
三种具体落地方式:
  1. LRU / LFU 本地小缓存做“漏斗”
    • 在 SDK 内置一个超小容量(如仅存 1000~5000 个 Key)的 LRU / LFU 内存缓存。
    • 大量仅访问一两次的冷 Key 进入后会迅速被淘汰,无法累加频次;只有频繁访问的热 Key 才能长期保留在缓存中
    • 仅对保留在小缓存中的 Key 分配和更新滑动窗口计数器,内存开销极小且恒定。
  2. Heavy-Keeper / Count-Min Sketch 概率数据结构(大厂方案,如京东 HotKey)
    • 所有请求先经过固定大小的 Hash 计数矩阵(利用概率衰减算法 Decay 扣减低频 Key,仅占用几百 KB 内存)。
    • 当某个 Key 的衰减计数突破设定阈值后,说明其具备热 Key 潜力,再将其放入小顶堆并挂载精细的滑动窗口统计。
    • 优势:无须存储每个 Key 的全名与完整历史,用极小固定内存精准捕捉高频热 Key。
  3. 前置白名单 / 正则模式匹配
    • 结合业务场景,在 SDK 拦截器中设置规则或正则表达式(如只匹配 item_info:*sec_kill_*)。
    • 仅对符合特定前缀/模式的 Key 做滑动窗口统计,其他通用 Key 直接跳过,控制计数器数量。

解决方案

  1. 利用应用本地二级缓存 (Local Cache)
    • 在业务应用内存中构建二级缓存(如 Java Caffeine / Go sync.Map / bigcache),直接在应用进程内命中热点数据,请求无需落到 Redis。
  2. HotKey 散列多副本 (Key 随机后缀)
    • 将 HotKey 加上随机前缀或后缀散列到集群中的不同 Slot/节点(如 hot_item:1001_copy1, hot_item:1001_copy2hot_item:1001_copyN)。
    • 客户端读取时随机访问其中某一个副本 Key,将流量分散给集群中多个 Redis 节点。
  3. 读写分离与增加 Slave 节点
    • 将 HotKey 的读流量分摊到多个 Slave 读副本节点上,扩展读吞吐量。

缓存的模式

模式工作方式优点缺点
旁路缓存 (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 (旁路缓存)

D2 Diagram
qtopie.github.io

Read-Through (读穿)

D2 Diagram
qtopie.github.io

Write-Through (写穿)

D2 Diagram
qtopie.github.io

Write-Back (回写)

D2 Diagram
qtopie.github.io

Write-Around (环绕写)

D2 Diagram
qtopie.github.io

Refresh-Ahead (提前刷新)

D2 Diagram
qtopie.github.io

相关应用

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

参考