本文解决什么问题:Redis 是后端开发的核心组件,但多数人仅能说出"Redis 是单线程的内存数据库"------深入理解时需追问:单线程为什么比多线程快?本文从四层原理、底层编码、持久化双引擎到高可用演进,帮助你将认知从"会用"提升到"理解内部机制"。
适合谁读:日常使用 Redis 但未系统研究过内部机制的 Java 后端开发。
核心内容:Redis 快的四层原因(内存 + 单线程 + IO 多路复用 + 精巧数据结构);五种数据类型的底层编码及 skiplist 不选红黑树的原因;过期删除策略(惰性+定期)及八种内存淘汰策略的选型依据;RDB vs AOF vs 混合持久化的设计取舍;高可用演进路线(主从 → 哨兵 → Cluster);缓存穿透/击穿/雪崩的完整应对方案。
一、Redis 为什么快?
Redis 的快不是单一原因,而是四层叠加的结果。
第一层:纯内存操作
数据驻留在内存中,读写不经过磁盘,单次操作达到微秒级。这是最快的硬件基础。
第二层:单线程模型
Redis 6.0 之前,网络 IO 和命令执行均由单线程完成。单线程带来了两个关键收益:
- 零上下文切换开销:多线程模式下,CPU 在线程间切换需要保存/恢复寄存器状态、刷新 TLB,这些都是额外开销
- 零锁竞争 :不存在"两个线程同时修改同一个 Key"的问题,数据操作天然具备原子性------
INCR不需要额外加锁
第三层:IO 多路复用
虽然是单线程,但可以同时监听多个客户端连接。核心在于操作系统的 epoll(Linux)/select/kqueue(BSD)机制------哪个连接有数据到达就处理哪个,不会阻塞在某个慢连接上。
第四层:高效数据结构
每种数据类型都有专门优化的底层编码,并非直接使用 C 原生数据结构:
- SDS(简单动态字符串):相比 C 字符串增加了长度记录,获取长度 O(1),从设计上杜绝了缓冲区溢出
- ziplist(压缩列表):以连续内存存储小数据,极致节省内存
- skiplist(跳表):多层索引结构,支持 O(logN) 范围查询
"Redis 的快 = 内存(硬件层)+ 单线程(消除锁竞争)+ IO 多路复用(高并发连接)+ 精巧数据结构(编码优化)。四层叠加,缺一不可。"
Redis 6.0 的多线程改了什么?
Redis 6.0 引入了 IO 多路线程,但命令执行仍然是单线程。为什么要改?因为单线程的网络 IO 已成为瓶颈------大量客户端连接的数据读写量过大,主线程将大量时间消耗在解析网络包上。
改进方案:IO 线程负责读取客户端请求数据(解析 TCP 包)和写回响应数据(发送给客户端),命令执行保持单线程(以保证原子性)。
"Redis 6.0 的多线程本质是'读写外包,核心自持'------搬货可以多人协作,但算账的只能一个人。"
二、五种数据类型 + 底层编码
| 类型 | 场景 | 底层编码 | 注意事项 |
|---|---|---|---|
| String | 缓存、计数器(INCR)、分布式锁(SETNX) | int / embstr(≤44 字节)/ raw | 最大 512MB,INCR 原子自增无需加锁 |
| Hash | 存储对象(用户信息、配置项) | ziplist(Redis 7.0 起升级为 listpack,原理相似但结构更紧凑)/ hashtable(两个 dict 实现渐进式 rehash) | 适合部分字段更新------仅改名字无需改整个 JSON |
| List | 消息队列(LPUSH+BRPOP 阻塞消费)、时间线 | quicklist(linkedlist+ziplist 混合) | 左右均可 push/pop |
| Set | 标签、去重、共同好友 | intset(纯整数)/ hashtable | SINTER 交集、SUNION 并集、SDIFF 差集 |
| ZSet | 排行榜、延时队列、带权重的去重 | ziplist / skiplist+dict | ZRANGE 按分值排序、ZRANK 按排名查询 |
ZSet 为什么用跳表而不用红黑树?
三个原因:
- 实现简单:跳表的插入/删除/查找逻辑比红黑树的旋转加染色直观得多
- 天然支持范围查询:跳表的 O(logN) 范围查询天然高效,红黑树则需要中序遍历
- 天然支持按排名查询:ZRANK 命令需要知道元素在有序集合中的位置排名,跳表在节点中维护了 span(跨度)字段,天然支持此类查询
跳表原理:多层索引结构,Redis 实现中每层节点数约为上一层的 1/4(概率因子 P=0.25),查找时从最高层开始跳跃,时间复杂度 O(logN)。
三、过期删除 + 内存淘汰
过期删除:惰性 + 定期双管齐下
Redis 未采用基于定时器的精确删除,而是两种策略组合:
(1)惰性删除(被动) :Key 过期后不会立即被删除,等到下次访问该 Key 时检查是否过期,过期则删除并返回空。优点是对 CPU 友好;缺点是过期但未被访问的 Key 会持续占用内存。
(2)定期删除(主动) :Redis 默认每秒执行 10 次 activeExpireCycle------每次随机抽取 20 个设置了过期时间的 Key 检查是否过期,若过期比例超过 25% 则继续抽取下一批,直至过期比例降低或达到时间上限(25ms)。优点是能清理"冷"过期数据;缺点是随机抽取可能漏掉部分过期 Key。
内存淘汰:八种策略如何选择?
| 策略 | 行为 | 适用场景 |
|---|---|---|
| noeviction(默认) | 内存满后直接返回错误,不删除任何数据 | Redis 用作数据库,配合持久化使用 |
| allkeys-lru | 在所有 Key 中淘汰最近最少使用的 | 缓存场景的首选 |
| volatile-lru | 仅在设置了过期时间的 Key 中淘汰 LRU | 混合场景(部分 Key 需持久保留) |
| allkeys-lfu | 淘汰访问频率最低的 Key | 热点数据保护(Redis 4.0+) |
| volatile-lfu | 在设置了过期时间的 Key 中淘汰 LFU | 同上,混合场景 |
| volatile-ttl | 淘汰剩余 TTL 最短的 Key | 对时效性敏感的数据 |
| allkeys-random / volatile-random | 随机淘汰 | 不推荐,除非 Key 大小和访问频率均匀分布 |
LRU vs LFU:LRU 淘汰"最久没被访问的"(时间维度),LFU 淘汰"访问次数最少的"(频率维度)。一个刚创建但只访问过一次的 Key,在 LRU 下可能因"新"而被保留,在 LFU 下则可能因频率低而被淘汰。LFU 更擅长区分热点数据与非热点数据。
"缓存场景用 allkeys-lru;需要热点数据保护用 allkeys-lfu;作为数据库使用选 noeviction 并配置持久化。"
四、持久化双引擎:RDB + AOF + 混合模式
RDB(快照)
SAVE(阻塞主线程)/ BGSAVE(fork 子进程写 RDB 文件)------生成某个时间点的全量数据快照。
- 优点:文件紧凑、恢复速度快、fork 后子进程不影响主进程
- 缺点:两次 RDB 之间的数据存在丢失风险(间隔通常为 5 分钟)、fork 大内存进程时可能出现短暂阻塞
- 机制:基于 Linux Copy-On-Write------fork 时父子进程共享物理内存页,父进程写入时才触发页面复制
- 配置 :
save 900 1(900 秒内至少 1 次修改即触发 BGSAVE)
AOF(追加日志)
每条写命令追加到 AOF 文件末尾,重启时重放 AOF 命令以恢复数据。
三种刷盘策略:always(每条命令刷盘,最安全但最慢)→ everysec(每秒刷一次,默认推荐,至多丢失 1 秒数据)→ no(交由操作系统决定刷盘时机)。
AOF 重写 :随着时间推移 AOF 文件不断增大,BGREWRITEAOF 将多条命令压缩为最少命令(如将 5 次 INCR counter 1 合并为 INCRBY counter 5)。重写同样通过 fork 子进程完成,基于当前内存数据生成最少命令,而非重放旧 AOF 文件。
混合持久化(Redis 4.0+,生产环境推荐)
AOF 文件的前半部分为 RDB 格式的快照,后半部分为快照之后的 AOF 增量日志。兼具 RDB 的恢复速度和 AOF 的数据安全性。
"生产环境使用混合持久化(RDB 全量 + AOF 增量),everysec 刷盘策略,即使宕机至多丢失 1 秒数据。"
五、高可用演进:主从 → 哨兵 → Cluster
第一代:主从复制
一主多从,读写分离。从节点只读,主节点写入后异步同步到从节点。问题:主节点宕机后需要手动切换从节点------高可用性不足。
同步流程:全量同步(RDB + replication buffer)→ 增量同步(replication backlog buffer + 偏移量)。
第二代:哨兵 Sentinel
在主从基础上增加自动故障转移能力。Sentinel 集群(至少 3 个节点,奇数个)监控主节点 → 主节点失联 → 主观下线 → 客观下线(不少于 quorum 个 Sentinel 确认)→ 选举新主(按优先级 → offset → runId 排序)→ 通知客户端切换连接。
第三代:Cluster 集群
分布式数据分片存储。共 16384 个 Hash Slot,CRC16(key) % 16384 定位到具体 Slot 再找到对应节点。自带故障转移,通过 Gossip 协议通信。支持多主多从水平扩展。
为什么是 16384 个 Slot? 集群节点间通过 Gossip 协议周期性交换状态信息,心跳包中包含本节点负责的 Slot 位图。16384 个 Slot 的位图占用 16384/8 = 2KB------这个大小在心跳包中是可接受的。如果采用 65536(CRC16 的理论上限),位图膨胀至 8KB,在 1000 节点规模的集群中 Gossip 消息的带宽开销将显著增大。16384 是在"足够均匀分布数据"和"心跳消息大小可控"之间取的工程平衡点。
分片注意事项:
- 跨 Slot 的多 Key 操作(如 MSET key1 key2)要求 key1 和 key2 位于同一 Slot,可使用
{hash_tag}强制映射(如user:{1001}:name和user:{1001}:age将被分配到同一 Slot) - 客户端需感知 Slot 映射变化:MOVED(永久迁移)vs ASK(临时迁移中的重定向)
"主从解决读扩展,哨兵解决高可用,Cluster 解决写扩展。三者是一步步演进出来的。技术选型的核心判断不是'这个技术能不能用',而是'这个技术是否匹配当前的规模和复杂度'。"
六、缓存三大问题及应对
缓存穿透
现象:查询一个数据库和缓存中均不存在的数据,每次请求直接打到数据库。
解决方案:
- 布隆过滤器:将所有存在的 key 存入布隆过滤器,查询前先经过布隆过滤器------返回"不存在"则一定不存在(100% 准确),返回"可能存在"才继续查缓存/数据库(存在小概率误判)。原理:多个哈希函数映射到位数组,全为 1 则"可能存在",有 0 则"一定不存在"
- 缓存空值:查不到也缓存一个 null 值,设置较短的过期时间(如 5 分钟)
缓存击穿
现象:某个热点 Key 过期的瞬间,大量并发请求同时打到数据库。
解决方案:
- 互斥锁 :获取缓存失败后加锁,拿到锁的线程负责查询数据库并更新缓存,其他线程等待锁释放后从缓存获取(双重检查:加锁后需再次读取缓存,避免重复查询数据库)
- 逻辑过期:在 value 中存储过期时间戳,不设置 TTL。发现已过期后获取锁去更新,其他线程先返回旧值(容忍短暂的不一致)
- 永不过期 + 主动刷新:热点 Key 不设置 TTL,通过消息驱动(如 XNet 广播、Canal binlog)主动推送更新------从根源上消除"过期瞬间的并发冲击"
缓存雪崩
现象:大量 Key 同时过期,或 Redis 集群宕机,导致全部请求压向数据库。
解决方案:
- 过期时间加随机偏移:TTL = 基础值 + random(0, 300s),避免大批量 Key 同时过期
- 多级缓存:本地 Caffeine + 远程 Redis 双层架构
- 降级熔断:Redis 不可用时,降级返回兜底数据或直接限流以保护数据库
七、分布式锁演进:从 SETNX 到 Redisson
vbnet
第一代:SETNX + EXPIRE(非原子操作,可能死锁)
SETNX lock_key value → EXPIRE lock_key 30
问题:SETNX 成功后、EXPIRE 执行前进程 crash,锁永远不释放,形成死锁
第二代:SET key value NX EX 30(原子操作,基本可用)
SET lock_key uuid NX EX 30
优点:加锁与设置过期时间一步完成
问题:(1)锁已过期但业务仍在执行,两个线程同时持有锁
(2)误删他人的锁:A 的锁过期后 B 获取到锁,A 执行完毕后 DEL 删除了 B 的锁
第三代:Redisson 看门狗 + Lua 脚本释放(生产环境推荐)
看门狗每 10s 检查锁持有状态,自动续期(默认 30s),避免锁在业务完成前过期
释放时通过 Lua 脚本原子判断:
if redis.call('get', lock_key) == uuid then
return redis.call('del', lock_key)
else return 0 end
仅自己持有的锁才能被自己删除,杜绝误删
第四代:RedLock(多节点分布式锁,存在较大争议)
向 N 个独立 Redis 节点分别申请锁,超过半数成功则视为加锁成功
缺点:性能开销大、存在时钟漂移问题、antirez 本人也表示并非银弹
实际项目:第三代(Redisson)已能满足绝大多数场景
什么时候用 Redis 分布式锁,什么时候不用?
判断标准:锁的持有时间是否跨越多个数据库操作或跨服务。
若在单个数据库事务内(如审核并发),使用数据库乐观锁(version 字段)更合适------复用事务连接,减少外部依赖。若涉及多个独立服务之间的协调,则使用 Redisson 看门狗机制。
八、缓存一致性
数据库更新后,缓存如何保持一致?四种策略递进:
- 先删缓存再更新数据库(问题:删缓存后被另一请求读到旧数据并写回缓存)------不推荐
- 先更新数据库再删缓存(问题:删缓存失败将导致数据不一致)------基本方案
- 先更新数据库再删缓存 + 重试机制(删缓存失败后放入重试队列异步重试)------生产环境常用
- 订阅数据库 Binlog(Canal 解析 MySQL binlog 后更新/删除对应 Redis Key)------最终一致性的最佳方案
"不存在绝对的强一致性,只有从弱到强的一致性级别选择。金融场景中'先更新数据库 + 删缓存 + 重试'通常已足够,对一致性要求更高的场景可使用 Canal 监听 binlog。"
九、Redis 7.x 新特性速览
项目使用 6.x 是正常的,但能谈论新版本的变化可以体现技术视野。
Sharded Pub/Sub(7.0):此前 Pub/Sub 消息会广播到集群中的每个节点,网络开销较大。Sharded Pub/Sub 将 Channel 按 slot 分配到不同节点,消息仅在所在节点处理,无需跨节点广播。
ACL v2 (7.0):支持 Channel/Key 级别的细粒度权限控制------ACL SETUSER app_user ~app:* %P~app:* 限制该用户只能访问 app: 前缀的 key 和 Channel。金融场景中可实现交易服务仅访问 trade:* key、客户服务仅访问 customer:* key。
Redis Functions(7.0):Lua 脚本的升级形态------将脚本组织为"库"(library),支持版本管理、按需加载,存储在 RDB/AOF 中,重启后仍然可用。
WAITAOF(7.2):等待指定数量的从节点将写入刷到 AOF 文件后再返回。相比 WAIT(仅等待同步到 replicas 内存),WAITAOF 确保数据已持久化到磁盘,对金融场景的可靠性保障具有直接价值。
"Redis 7.x 的方向 = 更省带宽(Sharded Pub/Sub)+ 更安全(ACL v2)+ 更工程化(Functions 替代 Lua 脚本)。这标志着 Redis 正在从缓存工具向企业级数据平台演进------这是后端工程师应当看到的趋势。"
总结
Redis 的快是四层叠加 的结果------纯内存操作(硬件基础)消除磁盘 IO、单线程模型消除锁竞争和上下文切换开销、IO 多路复用(epoll)支撑高并发连接、精巧数据结构(SDS/ziplist/skiplist)极致优化内存与 CPU。五种数据类型 各有专属底层编码:String 最通用(int/embstr/raw 三级编码)、Hash 适合部分字段更新的对象存储、ZSet 选用 skiplist 而非红黑树的原因是实现更简单、天然支持范围查询、天然支持按 span 排名。过期删除 采用惰性(访问时检查)+ 定期(每秒 10 次随机抽取 20 个)双策略组合,不存在基于定时器的精确删除。持久化 推荐混合模式(RDB 全量 + AOF 增量)加 everysec 刷盘,宕机至多丢失 1 秒数据。高可用 按业务规模逐级演进:主从解决读扩展、哨兵解决自动故障转移、Cluster 解决写扩展(16384 个 Hash Slot 分片)。缓存穿透 用布隆过滤器加缓存空值应对,缓存击穿 靠互斥锁(双重检查)加逻辑过期加永不过期三者递进防护,缓存雪崩 通过过期时间随机化加多级缓存加降级熔断三层兜底。分布式锁从 SETNX+EXPIRE(非原子)演进到 SET NX EX(原子操作)再到 Redisson 看门狗自动续期加 Lua 脚本原子释放,第三代是生产标准,RedLock 因性能开销和时钟漂移问题存在较大争议。
下一篇预告
《MySQL 数据库原理:B+Tree + MVCC + 行锁 + EXPLAIN 实战》 ------数据库是后端开发的另一核心领域。下一篇从 B+Tree 为什么是"矮胖树"讲起,拆解聚簇索引与二级索引的回表代价、MVCC 的 undo log 版本链加 ReadView 快照机制、四种行锁(Record/Gap/Next-Key/Insert Intention),以及 EXPLAIN 的 type+key+rows+Extra 四列驱动的优化实战。
系列说明:本文是《Java 后端核心知识图谱》系列第 2 篇。全系列共 17 篇正刊 + 2 篇番外,覆盖 JVM/并发/MySQL/Redis/消息队列/Spring/分布式/设计模式/分库分表/网络/负载均衡/容器与K8s/微服务治理。