Redis 核心原理与架构实战深度解析
Redis 核心原理与架构实战深度解析
本文档旨在通过剖析Redis的底层设计,解释其高性能的秘密,并结合生产环境中的常见问题(缓存雪崩、穿透、一致性、分布式锁)提供架构级解决方案。
1. Redis 高性能的底层秘密
Redis 之所以快(QPS 可达 10w+),核心在于它如何处理计算与 IO。
1.1 核心三要素
- 纯内存操作:寻址速度是纳秒级,而非磁盘的毫秒级。
- 单线程模型 (Reactor模式):
- 避免上下文切换:没有多线程的 CPU 寄存器保存/恢复开销。
- 无锁竞争:不需要考虑各种 mutex 锁,死锁问题。
- IO 多路复用 (Epoll):这是关键。
1.2 深度原理:IO 多路复用与 Epoll
Redis 并不是简单地“单线程”,它利用了操作系统的多路复用机制(Linux 下是 epoll,Mac 下是 kqueue)。
-
传统阻塞 IO:一个线程处理一个连接,等待 Read 时线程挂起,效率低。
-
Redis 的 Epoll:
Redis 内部实现了一个事件分离器(Event Loop)。它通过
epoll_ctl注册成千上万个 socket 描述符。一旦某个 socket 有数据写入(Read Ready),内核回调通知 Redis,Redis 将其放入“就绪队列”,由主线程串行处理。
源码逻辑 (伪代码):
C
// Redis 的 ae.c 事件循环核心
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
// epoll_wait 等待事件就绪,非阻塞
aeProcessEvents(eventLoop, AE_ALL_EVENTS);
}
}
注意:从 Redis 6.0 开始,引入了多线程 IO。核心命令执行依然是单线程,但网络数据的 Read(读取)和 Write(回写响应)由多线程处理,解决了网络带宽成为瓶颈的问题。
2. 数据结构与应用场景
Redis 的对象不仅是 Key-Value,底层有着精细的编码设计(redisObject)。
2.1 常见类型与底层编码
| 数据类型 | 应用场景 | 底层源码结构 (Encoding) |
|---|---|---|
| String | 缓存 JSON、计数器 (INCR) |
int (整数), embstr (短字符串), raw (SDS动态字符串) |
| Hash | 存储对象、购物车 | ziplist (压缩列表, 小数据), hashtable (大数据) |
| List | 消息队列 (LPOP/RPUSH) |
quicklist (双向链表+压缩列表) |
| Set | 去重、交集 (共同好友) | intset (整数集合), hashtable |
| ZSet | 排行榜 (Score 排序) | ziplist (数据少), skiplist (跳表) |
2.2 重点解析:ZSet 的跳表 (SkipList)
为什么 ZSet 不用红黑树?因为跳表实现简单,范围查询(Range Query)效率极高。
Java 实战:排行榜实现
Java
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void updateScore(String userId, double score) {
// 对应 ZADD key score member
redisTemplate.opsForZSet().add("game_rank", userId, score);
}
public Set<Object> getTop10() {
// 对应 ZREVRANGE 0 9
return redisTemplate.opsForZSet().reverseRange("game_rank", 0, 9);
}
3. 持久化机制:RDB 与 AOF 的权衡
3.1 RDB (Redis Database - 快照)
- 原理:调用
fork()创建子进程。子进程拥有父进程的内存副本。 - Copy-On-Write (写时复制):Linux 内核机制。父子进程共享物理内存,只有当父进程修改数据时,OS 才会为被修改的页复制一份副本给子进程。
- 优点:文件小,恢复快。
- 缺点:会丢失最后一次快照后的数据。
3.2 AOF (Append Only File - 日志)
- 原理:记录写命令(RESP 协议格式)。
- 刷盘策略 (
appendfsync):always:每条命令都刷盘(慢,安全)。everysec:每秒刷盘(默认,折中)。no:操作系统决定。
- 重写机制 (Rewrite):当 AOF 文件过大,后台重写,只保留构建当前数据所需的最小命令集。
4. 内存管理:过期策略与淘汰算法
4.1 过期删除策略 (How keys expire)
Redis 采用 定期删除 + 惰性删除 的组合。
- 惰性删除 (Lazy):
- 访问 Key 时,检查
expire时间。如果过期,通过expireIfNeeded函数删除。
- 访问 Key 时,检查
- 定期删除 (Active):
- Redis 默认每 100ms 随机抽取 Key 检查。如果过期比例超过 25%,继续抽取。
- 原理:避免全量扫描导致的主线程卡顿。
4.2 内存淘汰策略 (Eviction)
当 maxmemory 满了,Redis 必须剔除数据。
- LRU (Least Recently Used):最近最少使用。
- LFU (Least Frequently Used):Redis 4.0 引入,最不经常使用(基于访问频率)。
- Random: 随机。
源码细节:Redis 的 LRU 是近似 LRU。它随机采样 5 个 Key,淘汰 idle time 最大的那个,而不是维护一个全局链表(为了节省内存)。
5. 生产环境三大缓存难题解决方案
5.1 缓存穿透 (Penetration)
现象:查根本不存在的数据(如 id=-1),请求直透数据库。
解决:
- 缓存空对象:
set key null, expire 30s。 - 布隆过滤器 (Bloom Filter):
- 原理:利用多个 Hash 函数将数据映射到 Bitmap 位数组中。
- 特性:说存在不一定存在,说不存在一定不存在。
伪代码逻辑:
Java
if (!bloomFilter.contains(id)) {
return null; // 直接拦截
}
Data data = redis.get(id);
if (data == null) {
data = db.get(id); // 可能发生误判,仍有少量流量打到DB
redis.set(id, data);
}
5.2 缓存击穿 (Breakdown)
现象:热点 Key 突然过期,并发流量瞬间击穿 DB。
解决:
- 逻辑过期:Value 中包含过期时间,后台异步更新。
- 互斥锁 (Mutex):重建缓存时加锁。
互斥锁代码演示:
Java
public String getData(String key) {
String value = redis.get(key);
if (value == null) {
// 尝试获取分布式锁
if (tryLock("lock:" + key)) {
try {
// 双重检查
value = redis.get(key);
if (value == null) {
value = db.get(key);
redis.set(key, value);
}
} finally {
unlock("lock:" + key);
}
} else {
Thread.sleep(100); // 休眠重试
return getData(key);
}
}
return value;
}
5.3 缓存雪崩 (Avalanche)
现象:大量 Key 同时过期 或 Redis 宕机。
解决:
- 随机 TTL:
expire = base_time + random(1-300s)。 - 高可用:搭建 Redis Cluster。
- 降级限流:Hystrix / Sentinel。
6. 分布式锁的实现与 Redisson 原理
6.1 基础实现:SETNX
早期做法:SET lock_key uuid NX PX 10000。
缺陷:锁续期问题(业务没跑完锁丢了)、不可重入。
6.2 进阶:Redisson 原理
Redisson 解决了看门狗 (WatchDog) 和 原子性 问题。
Redisson 加锁 Lua 脚本源码解析:
为了保证判断锁是否存在、加锁、设置过期时间的原子性,必须使用 Lua。
Lua
-- KEYS[1]: 锁名称
-- ARGV[1]: 锁持有时间
-- ARGV[2]: 线程唯一标识 (UUID + ThreadId)
if (redis.call('exists', KEYS[1]) == 0) then
-- 锁不存在,创建一个 Hash 结构
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 锁存在且是当前线程,重入次数 +1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 锁被别人占用了,返回剩余时间
return redis.call('pttl', KEYS[1]);
- 看门狗:后台启动一个线程,每隔 10s (默认 1/3 leaseTime) 检查锁是否还在,若在则重置过期时间。
7. 高可用架构:主从、哨兵与集群
7.1 主从复制原理
- 全量同步:Slave 发送
PSYNC。Master 执行bgsave生成 RDB,发送给 Slave。 - 增量同步:Master 将 RDB 期间产生的写命令写入 replication buffer (复制积压缓冲区),RDB 发完后再发缓冲区数据。
7.2 分片集群 (Redis Cluster)
- 无中心架构:没有 Proxy。
- Hash Slot (槽):整个集群共有 16384 个槽。
- Key 计算:
CRC16(key) % 16384。
- Key 计算:
- 请求重定向:如果 Client 访问的 Key 不在当前 Node,Node 返回
MOVED或ASK错误,Client 需重定向。
8. 双写一致性深度剖析
当需要更新数据时,数据库和缓存怎么保持一致?
8.1 为什么是“先删缓存,再更DB”?(延时双删)
单纯的先删缓存,可能导致:线程A删缓存 -> 线程B读DB旧值 -> 线程B写回缓存 -> 线程A更新DB。导致缓存永远是脏数据。
延时双删流程:
- 删除缓存。
- 更新数据库。
Sleep(N ms)(N > 数据库主从同步时间)。- 再次删除缓存。
8.2 终极方案:Canal + Binlog 异步删除
为了解耦业务代码,不影响吞吐量:
- 业务只更新 MySQL。
- Canal 伪装成 MySQL Slave,订阅 Binlog。
- 解析出变更的 Key,投递到 MQ (Kafka/RabbitMQ)。
- 消费者服务从 MQ 拿 Key,调用 Redis 删除。
- 重试机制:如果删除失败,MQ 会自动重试(ACK机制),保证最终一致性。
总结
Redis 的核心在于内存计算与IO多路复用。在实际开发中,不仅要会用 API,更要理解数据结构的选择(如 ZSet 做排行榜,Bitmap 做签到),并能针对缓存失效的极端场景设计防御性代码(如分布式锁、布隆过滤器)。掌握这些原理,才能在面试和架构设计中游刃有余。
更多推荐




所有评论(0)