本文档旨在通过剖析Redis的底层设计,解释其高性能的秘密,并结合生产环境中的常见问题(缓存雪崩、穿透、一致性、分布式锁)提供架构级解决方案。

1. Redis 高性能的底层秘密

Redis 之所以快(QPS 可达 10w+),核心在于它如何处理计算与 IO。

1.1 核心三要素

  1. 纯内存操作:寻址速度是纳秒级,而非磁盘的毫秒级。
  2. 单线程模型 (Reactor模式)
    • 避免上下文切换:没有多线程的 CPU 寄存器保存/恢复开销。
    • 无锁竞争:不需要考虑各种 mutex 锁,死锁问题。
  3. 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 采用 定期删除 + 惰性删除 的组合。

  1. 惰性删除 (Lazy)
    • 访问 Key 时,检查 expire 时间。如果过期,通过 expireIfNeeded 函数删除。
  2. 定期删除 (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),请求直透数据库。

解决

  1. 缓存空对象set key null, expire 30s
  2. 布隆过滤器 (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。

解决

  1. 逻辑过期:Value 中包含过期时间,后台异步更新。
  2. 互斥锁 (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 宕机。

解决

  1. 随机 TTLexpire = base_time + random(1-300s)
  2. 高可用:搭建 Redis Cluster。
  3. 降级限流: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 主从复制原理

  1. 全量同步:Slave 发送 PSYNC。Master 执行 bgsave 生成 RDB,发送给 Slave。
  2. 增量同步:Master 将 RDB 期间产生的写命令写入 replication buffer (复制积压缓冲区),RDB 发完后再发缓冲区数据。

7.2 分片集群 (Redis Cluster)

  • 无中心架构:没有 Proxy。
  • Hash Slot (槽):整个集群共有 16384 个槽。
    • Key 计算:CRC16(key) % 16384
  • 请求重定向:如果 Client 访问的 Key 不在当前 Node,Node 返回 MOVEDASK 错误,Client 需重定向。

8. 双写一致性深度剖析

当需要更新数据时,数据库和缓存怎么保持一致?

8.1 为什么是“先删缓存,再更DB”?(延时双删)

单纯的先删缓存,可能导致:线程A删缓存 -> 线程B读DB旧值 -> 线程B写回缓存 -> 线程A更新DB。导致缓存永远是脏数据。

延时双删流程

  1. 删除缓存。
  2. 更新数据库。
  3. Sleep(N ms) (N > 数据库主从同步时间)。
  4. 再次删除缓存。

8.2 终极方案:Canal + Binlog 异步删除

为了解耦业务代码,不影响吞吐量:

  1. 业务只更新 MySQL。
  2. Canal 伪装成 MySQL Slave,订阅 Binlog。
  3. 解析出变更的 Key,投递到 MQ (Kafka/RabbitMQ)
  4. 消费者服务从 MQ 拿 Key,调用 Redis 删除。
  5. 重试机制:如果删除失败,MQ 会自动重试(ACK机制),保证最终一致性。

总结

Redis 的核心在于内存计算IO多路复用。在实际开发中,不仅要会用 API,更要理解数据结构的选择(如 ZSet 做排行榜,Bitmap 做签到),并能针对缓存失效的极端场景设计防御性代码(如分布式锁、布隆过滤器)。掌握这些原理,才能在面试和架构设计中游刃有余。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐