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)
- 现象与成因:请求查询一个在缓存和数据库中都不存在的数据。由于缓存未命中,请求每次都会穿透到数据库。如果是恶意的非法请求(如轮询不存在的非负数 ID),会给数据库造成巨大压力甚至压垮 DB。
- 解决思路:
- 接口层参数校验:在请求入口处做前置合法性校验(如非空校验、格式校验、ID 区间校验等),拦截不合法的请求。
- 缓存空对象 (Cache Null Object):从 DB 查询为空时,向 Redis 写入空值(如
""或特殊标识null),并设置较短的过期时间(如几分钟),防止对同一不存在 key 的并发打击。 - 布隆过滤器 (Bloom Filter):利用高效且省内存的布隆过滤器拦截不存在的 key。在查询 Redis 之前先经布隆过滤器检索,若判定不存在则直接返回,避免穿透到 DB。
缓存击穿 (Cache Breakdown)
- 现象与成因:某个访问量极高的热点 Key 突然失效(例如高并发促销活动期间 Key 到期),瞬间有海量请求同时并发访问该 Key,无法从缓存读取,导致并发请求一瞬间全部打到 DB 上,造成 DB 压力剧增或卡死。
- 解决思路:
- 互斥锁 / 分布式锁 (Mutex Lock):缓存失效时,只允许获取到分布式锁(如
SETNX)的单线程去加载 DB 并更新缓存,其他请求等待重试或直接降级返回,避免同时冲击 DB。- 自动续期(看门狗机制 / Watchdog):应对 DB 加载缓慢(超过固定 TTL)的场景。获取锁成功后启动后台定时任务,在锁到期前的周期节点(如 TTL 的 $1/3$ 时间)自动延长 TTL;写完缓存/释放锁时显式停止看门狗。即便服务突然崩溃,锁在超时后也会正常失效,避免死锁与并发击穿。
- 工业级落地:Java 生态中的 Redisson 框架已内置开箱即用的看门狗机制(未指定 leaseTime 时默认锁 30s,每 10s 自动续期一次),实际开发中直接使用其
RLockAPI 即可,无需手动实现续期逻辑。
- 逻辑过期 / 异步更新 (永不过期):物理上不设置 TTL,而是在 Value 中存储逻辑过期时间。发现逻辑过期时由独立异步线程去加载 DB 并更新缓存,原请求直接返回旧数据。
- 互斥锁 / 分布式锁 (Mutex Lock):缓存失效时,只允许获取到分布式锁(如
互斥锁与看门狗自动续期流程
缓存雪崩 (Cache Avalanche)
- 现象与成因:在短时间内出现大量 Key 集中过期失效,或者 Redis 缓存集群整体宕机,导致原本由缓存承担的大量读请求在同一时间全部打到数据库,引发数据库连锁故障。
- 解决思路:
- 过期时间随机打散 (Jitter / Standardized Expiration):在基础过期时间上增加随机因子(如 1-5 分钟的随机数),避免海量 Key 在完全相同的时刻批量失效。
- 构建高可用缓存架构:利用 Redis Sentinel 或 Redis Cluster 提供主从自动故障转移能力,防止单个节点挂掉引起缓存层瘫痪。
- 多级缓存 (Multi-Level Cache):结合本地内存缓存(如 Go
sync.Map/ JavaCaffeine)与 Redis 组成多级缓存,减轻对单层缓存及数据库的依赖。 - 服务限流与降级 (Rate Limiting & Fallback):当数据库承受超出阈值的压力时,采取限流措施保护 DB,对非核心业务实施降级返回默认值或友好提示。
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,再删除缓存(业界推荐标准)。
延迟双删 (Delayed Double Delete)
在并发写/读极高的场景下,“先更新 DB,再删缓存”或“先删缓存,再更新 DB”仍可能因并发读写导致脏数据回写缓存。
脏数据产生的典型并发场景(先删缓存,后更新 DB)
- 线程 A 准备更新数据,先删除 Redis 缓存。
- 线程 B 发起并发读请求,发现 Redis 未命中,遂读取 DB 中的旧数据。
- 线程 A 完成 DB 数据更新。
- 线程 B 将刚刚读取到的旧数据回写到 Redis 缓存。
- 结果:缓存中留下了旧脏数据,直至该 Key 自然过期或再次被更新。
延迟双删的解决流程与步骤
- 第一次删除缓存:写请求到来,先删除 Redis 缓存。
- 更新数据库:将最新数据写入 DB。
- 休眠延迟一定时间 ($N$ 毫秒):等待在“第一次删缓存 $\to$ 更新 DB”这段危险时间窗口内发起的并发读请求完成“读 DB 旧数据 + 回写旧缓存”的过程。
- 第二次删除缓存(核心抹除):强制删除读线程刚回写的旧脏缓存,从根本上解决窗口期内并发读导致的脏数据问题。
延迟时间 $N$ 如何确定?
延迟时间 $N \approx \text{业务读请求耗时} + \text{主从同步延迟} + \text{安全缓冲空间 (如 100~500ms)}$。
- 确保第二次删除操作发生在线程 B 的“回写旧缓存”之后。
核心优势:读写无阻塞 (Non-blocking) 与性能 Trade-off
- 读请求完全无阻塞:与分布式读写锁 (Read-Write Lock) 会在写操作时阻塞读请求不同,延迟双删允许并发读请求随时并行执行,极大提升读吞吐量。
- 写请求异步无阻塞:若将第二次删除交由异步处理(MQ / 线程池),写请求更新完 DB 后无需同步
Sleep即可快速响应。 - Trade-off:用极短时间内的最终一致性(延迟时间 $N$ 内读请求可能短期读到旧缓存/旧数据),换取了高并发下系统整体的高吞吐与低延迟。
局限性与优化
- 阻塞写线程:若同步
Sleep(N)会降低系统写吞吐。建议将第二次删除交由异步线程池或 MQ 延迟队列 执行。 - 删失败兜底:如果第二次删除失败,可结合 Canal 监听 MySQL Binlog 驱动重试机制进行重试删除。
进阶与调优
BigKey 问题
什么是 BigKey?
- 字符串类型:单个 String 的 Value 大小超过 10KB(或根据业务设定为 50KB+)。
- 集合类型:Hash、List、Set、ZSet 等结构的成员数量过多(如超过 5000 个)或整体占用空间巨大。
BigKey 的危害
- 阻塞单线程:Redis 核心命令执行是单线程的。读写或删除 BigKey(如
DEL一个含百万元素的 Set)会导致 Redis 主线程长时间阻塞,引发客户端响应超时或 Sentinel 误判宕机。 - 网络 IO 拥塞:在读取 BigKey 时引发瞬间高带宽占用,打满网卡,拖慢同一 Redis 节点上的其他正常请求。
- 内存分布不均 / 倾斜:在 Redis Cluster 集群中,BigKey 所在的节点内存占用远高于其他节点。
如何发现与定位?
- 内置工具:使用
redis-cli --bigkeys进行在线采样分析(主库上尽量避免在高峰期执行)。 - 分析 RDB 文件:使用
rdb-tools离线解析 RDB 快照文件,按占用内存大小排序定位 BigKey。 - 实时监控:使用
redis-cli --hotkeys或配置slowlog-log-slower-than监控耗时超过设定的慢查询。
解决方案
- 大对象拆分:
- Hash/Set 拆分:通过 Hash Tag 将一个大的 Hash/Set 拆分为多个小 Key(如按用户 ID 模 100 散列到
user:info:0~user:info:99)。 - 时间/页码分片:对 List 或 ZSet 按时间维度(日/月)或分页维度进行切割存储。
- Hash/Set 拆分:通过 Hash Tag 将一个大的 Hash/Set 拆分为多个小 Key(如按用户 ID 模 100 散列到
- 异步清理与删除:
- 使用 Redis 4.0+ 提供的非阻塞删除指令
UNLINK key代替传统的阻塞式DEL。 - 对集合元素逐批清理:如 Hash 使用
HSCAN+HDEL,Set 使用SSCAN+SREM逐步删除。
- 使用 Redis 4.0+ 提供的非阻塞删除指令
- 过期机制调优:开启
lazyfree-lazy-user-del yes和lazyfree-lazy-expire yes,让过期清理由后台 Bio 线程异步完成。
HotKey 问题
什么是 HotKey?
某个特定 Key 的 QPS(每秒请求数)极高(如秒杀商品、热点爆款新闻、大 V 微博),单 Key 访问并发打满单个 Redis 节点的 CPU 或网络吞吐。
HotKey 的危害
- 缓存节点过载 / 宕机:单点 Redis 节点 CPU 利用率飙升至 100%,引发大量请求超时。
- 击穿压垮数据库:当 HotKey 所在节点卡死或崩溃时,海量请求会瞬间穿透到底层 DB,引发系统雪崩。
如何发现与定位?
- 客户端 / 代理层频次统计:在客户端 SDK(如 Jedis / Go-Redis)或代理层(如 Codis、Envoy、Twemproxy)封装拦截器进行统计。
关键避坑:不能对每个 Key 都全量创建滑动窗口计数器!在海量 Key(如用户 ID、订单号)并发访问时,为每个 Key 分配独立计数器会导致客户端内存迅速被撑爆(OOM)。实际落地必须采用两层过滤/采样架构。
客户端 HotKey 统计:两层过滤架构
第二层滑动窗口的底层实现:环形数组 (Circular Array) + 时间桶 (Bucket)
当 Key 通过第一层筛查被判定为“潜在热 Key”后,系统为其分配精细滑动窗口。最优雅高效的实现方式是环形数组 + 时间桶。
为什么能做到“任意毫秒级”平滑滑动?(突破整数秒限制)
“只能按整数秒统计”是传统固定窗口算法 (Fixed Window) 的典型缺陷。而基于环形数组时间桶的滑动窗口滑动步长是小桶的时间粒度(Bucket Size,如 100ms 或 10ms),而不是 1 秒:
- 颗粒度切分:若统计周期为 1 秒(1000ms),切分为 10 个 100ms 的时间桶。
- 随时随地毫秒级统计:
- 在
1050ms统计时,窗口有效范围为[50ms, 1050ms]。 - 在
1120ms统计时,窗口有效范围为[120ms, 1120ms]。 - 随时调用都能实时求出“从当前毫秒往前倒推 1 秒内”的精确请求总数,桶切得越细,滑动越贴近绝对连续。
- 在
算法对比:固定窗口 vs 环形滑动窗口
- 固定窗口 (Fixed Window):
|--- 0.0s - 1.0s (100次) ---|--- 1.0s - 2.0s (100次) ---|- 临界突发盲区:若在
0.9s来了 100 次请求,在1.1s又来了 100 次请求。在0.9s ~ 1.1s这临界 0.2s 内实际涌入了 200 次突发流量,但固定秒级窗口无法感知(被拆到了两个不同整数秒区间)。
- 临界突发盲区:若在
- 环形滑动窗口 (Sliding Window):
当前 1050ms 时的窗口范围: [50ms~150ms] [150ms~250ms] ... [950ms~1050ms] <-- 实时覆盖过去 1 秒- 无盲区平滑覆盖:无论何时查询,系统都以
当前时刻 - 1000ms为起点,实时将落在此时间范围内的桶求和,精准捕获临界突发流量。
- 无盲区平滑覆盖:无论何时查询,系统都以
核心优势与性能表现
- 固定内存开销 ($O(1)$):按
index = (currentTimestamp / bucketSize) % N快速定位,无需记录逐笔请求的打点时间戳列表,内存完全可控。 - 零 GC 压力:数组与 Bucket 内存直接复用,覆盖旧数据时仅需重置
count并更新起始时间,无任何对象频繁创建与销毁。 - 超高读写性能:更新与窗口求和均在 $O(1)$ 到 $O(N)$($N$ 极小,如 10~100)时间内完成。
三种具体落地方式:
- LRU / LFU 本地小缓存做“漏斗”:
- 在 SDK 内置一个超小容量(如仅存 1000~5000 个 Key)的 LRU / LFU 内存缓存。
- 大量仅访问一两次的冷 Key 进入后会迅速被淘汰,无法累加频次;只有频繁访问的热 Key 才能长期保留在缓存中。
- 仅对保留在小缓存中的 Key 分配和更新滑动窗口计数器,内存开销极小且恒定。
- Heavy-Keeper / Count-Min Sketch 概率数据结构(大厂方案,如京东 HotKey):
- 所有请求先经过固定大小的 Hash 计数矩阵(利用概率衰减算法 Decay 扣减低频 Key,仅占用几百 KB 内存)。
- 当某个 Key 的衰减计数突破设定阈值后,说明其具备热 Key 潜力,再将其放入小顶堆并挂载精细的滑动窗口统计。
- 优势:无须存储每个 Key 的全名与完整历史,用极小固定内存精准捕捉高频热 Key。
- 前置白名单 / 正则模式匹配:
- 结合业务场景,在 SDK 拦截器中设置规则或正则表达式(如只匹配
item_info:*或sec_kill_*)。 - 仅对符合特定前缀/模式的 Key 做滑动窗口统计,其他通用 Key 直接跳过,控制计数器数量。
- 结合业务场景,在 SDK 拦截器中设置规则或正则表达式(如只匹配
- Redis 内部命令/机制:
- 使用
redis-cli --hotkeys扫描(需内存淘汰策略设置为 LFU 算法)。 - 使用
MONITOR命令抓包分析(仅在测试环境或低峰期使用,生产高并发下慎用)。
- 使用
解决方案
- 利用应用本地二级缓存 (Local Cache):
- 在业务应用内存中构建二级缓存(如 Java
Caffeine/ Gosync.Map/bigcache),直接在应用进程内命中热点数据,请求无需落到 Redis。
- 在业务应用内存中构建二级缓存(如 Java
- HotKey 散列多副本 (Key 随机后缀):
- 将 HotKey 加上随机前缀或后缀散列到集群中的不同 Slot/节点(如
hot_item:1001_copy1,hot_item:1001_copy2…hot_item:1001_copyN)。 - 客户端读取时随机访问其中某一个副本 Key,将流量分散给集群中多个 Redis 节点。
- 将 HotKey 加上随机前缀或后缀散列到集群中的不同 Slot/节点(如
- 读写分离与增加 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 (旁路缓存)
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