本文面向对 Redis 有一定使用经验,并希望深入理解其内存管理和性能调优的中高级工程师。我们将从一个常见的“缓存雪崩”现象切入,系统性地剖析 Redis 内存淘汰(Eviction)策略的底层计算机科学原理,深入其近似 LRU 与 LFU 算法的源码级实现细节,并最终给出一套在复杂业务场景下如何选择、权衡与演进缓存策略的实战方法论。这不仅仅是关于配置项的解读,更是关于在有限资源下,如何做出最优架构决策的深度思考。
现象与问题背景
设想一个典型的电商大促场景。商品详情页的后端服务严重依赖 Redis 集群作为核心缓存,缓存着商品信息、库存、价格等高频访问数据。系统在日常流量下运行平稳,缓存命中率维持在 98% 以上。但在大促零点开启的瞬间,运维监控平台突然告警:数据库主库 CPU 使用率飙升至 95%,大量慢查询涌现,商品详情页 API 响应时间从 50ms 增长到 3s 以上,最终引发大规模的用户请求超时,现象酷似“缓存雪崩”。
团队紧急排查,首先发现 Redis 的 `evicted_keys` 指标在短时间内急剧增加。这说明 Redis 内存已满,正在疯狂地淘汰数据。但令人困惑的是,被淘汰的似乎是那些最热门的商品,也就是最不应该被淘汰的数据。进一步分析发现,在大促前夕,运营团队进行了一次全量商品数据预热,通过一个后台脚本将数百万商品数据依次加载到缓存中。这个“好心”的操作,却成了灾难的导火索。它污染了整个缓存,导致真正高频访问的热点数据被挤出,从而在流量洪峰到来时,几乎所有请求都穿透到了后端的数据库,压垮了整个系统。
这个案例暴露了一个核心问题:当内存资源达到上限时,Redis 如何决定“牺牲”谁?选择错误的淘汰策略,即使拥有再大的内存,缓存系统也形同虚设。理解并正确运用 Redis 的内存淘汰策略,是构建高可用、高性能系统的基石。
关键原理拆解
要理解 Redis 的淘汰策略,我们必须回归到计算机科学的本源——操作系统的页面置换算法。无论是 CPU 的 L1/L2/L3 Cache,还是操作系统的虚拟内存管理,亦或是我们应用层的 Redis 缓存,它们都面临同一个经典问题:当高速、昂贵且空间有限的存储区域(Cache)满了之后,如何从众多的数据项中选择一个“受害者”进行淘汰,以便为新来的数据腾出空间。这个选择的优劣,直接决定了缓存的效率,即命中率。
从学术角度,理想的置换算法是 OPT (Optimal) 算法,也称为 MIN。它的策略是,淘汰那些在未来最长时间内不会被访问的数据。这无疑是效率最高的,因为它最大化地保留了即将被使用的数据。然而,这是一种“先知”算法,需要预知未来的所有访问序列,在现实系统中无法实现。但它为我们提供了一个理论上的性能上限,作为衡量其他实用算法优劣的标尺。
所有实用的缓存淘汰算法,都是在尝试用过去的访问历史来“预测”未来,其中最经典的就是 LRU 和 LFU:
- LRU (Least Recently Used):最近最少使用。其核心思想是基于“时间局部性原理” (Temporal Locality),即如果一个数据项最近被访问过,那么它在不久的将来也很有可能被再次访问。反之,如果一个数据项已经很久没有被访问了,那么它在未来被访问的概率就很低。因此,LRU 倾向于淘汰掉最长时间未被访问的数据。在数据结构上,一个完美的 LRU 可以通过一个哈希表和一个双向链表实现,保证每次访问和淘汰操作的时间复杂度都是 O(1)。
- LFU (Least Frequently Used):最不经常使用。其核心思想是,如果一个数据在过去一段时间内被访问了很多次,那么它就是“热点”数据,未来被访问的概率也更高。LFU 关注的是访问频率,而非最近一次访问时间。它会淘汰掉访问次数最少的数据。LFU 的一个经典问题是,对于一个曾经是热点但现在不再被频繁访问的数据,它的高访问计数值可能会使其“永生”,占据缓存空间。因此,现代 LFU 算法通常会引入某种形式的衰减机制。
理解 LRU 和 LFU 的根本区别至关重要:LRU 关心的是“最后一次访问的时间”,而 LFU 关心的是“总共被访问的次数”。文章开头案例中的缓存污染问题,正是 LRU 策略的典型弱点:一次性的、大规模的顺序扫描(数据预热),会让这些只被访问一次的“冷”数据,因为其“访问时间最新”而淘汰掉之前所有真正高频的“热”数据。
系统架构总览
在深入代码实现之前,我们先从宏观上理解 Redis 淘汰机制的运作流程。它并非一个后台常驻线程在持续进行清理,而是“惰性”地、嵌入在命令执行的流程中。
我们可以将这个机制想象成一个由以下几个部分组成的系统:
- 内存上限触发器 (Maxmemory): 在 `redis.conf` 中配置的 `maxmemory` 指令是整个淘汰机制的入口。当 Redis 执行一个写命令(如 SET, LPUSH 等),在真正分配内存之前,它会检查当前已用内存是否即将超过 `maxmemory` 限制。如果超过,淘汰流程就会被触发。
- 淘汰策略配置 (Policy): `maxmemory-policy` 指令决定了 Redis 具体采用哪种算法来挑选“受害者”。这包括 `volatile-lru`, `allkeys-lru`, `volatile-lfu`, `allkeys-lfu`, `volatile-random`, `allkeys-random`, `volatile-ttl`, `noeviction` 八种策略。
- 候选键池 (Candidate Pool): 这是理解 Redis 近似算法的关键。Redis 不会去全局扫描所有的键来找到那个最完美的被淘汰者(例如,在数千万个键中找到最老的那个)。这样做对于一个追求低延迟的系统是不可接受的。相反,Redis 会从一个特定的键空间中(`allkeys-*` 策略是整个键空间,`volatile-*` 策略是设置了过期时间的键空间)随机采样 N 个键,构成一个临时的“候选池”。这个 N 由 `maxmemory-samples` 参数配置。
- 淘汰执行器 (Evictor): 淘汰执行器会对这个小小的候选池应用所选的策略(LRU 或 LFU),在池中找到最符合条件的键,然后将其从内存中删除。如果删除一个键后内存仍然不足,这个采样和淘汰的过程会循环进行,直到内存占用降到 `maxmemory` 以下,或者无法再淘汰任何键为止。
整个流程可以概括为:写命令触发检查 -> 超出 `maxmemory` -> 根据策略采样 N 个 key -> 在样本中找到最优淘汰者 -> 删除 -> 循环直到内存达标。这个设计本身就是一种性能与精确性之间的权衡,用可控的 CPU 开销换取了“足够好”的淘汰效果。
核心模块设计与实现
现在,让我们戴上极客工程师的眼镜,深入到 Redis 源码层面,看看 LRU 和 LFU 到底是如何实现的。你会发现,工程实现远比理论模型要“脏”和“巧”得多。
Redis Object 与近似 LRU (Approximated LRU)
首先要明确,Redis 并没有为实现 LRU 去维护一个全局的双向链表。对于一个动辄百万、千万键的实例,维护这样一个链表所带来的内存开销和并发控制复杂性是无法接受的。Redis 的选择是:在每个 Redis 对象(`redisObject`)的头部,复用一个 24 bit 的字段,名为 `lru`。
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:24; // 24 bits for LRU time or LFU data
int refcount;
void *ptr;
} robj;
在 LRU 模式下,这个 `lru` 字段存储的是该对象最后一次被访问时,Redis server 的一个全局时钟 `server.lruclock` 的值。这个 `lruclock` 是一个以毫秒为单位的粗粒度时间戳。当需要淘汰数据时,Redis 从候选池中挑选的 N 个 key,会比较它们各自 `redisObject` 中的 `lru` 值,`lru` 值最小的(即距离当前 `server.lruclock` 最远的),就是“最近最少使用”的那个,随即被淘汰。
以下是淘汰逻辑的伪代码:
// Simplified eviction logic for LRU
function evict():
best_key = NULL
max_idle_time = 0
// Sample N keys
for i in 1..N:
key = sample_random_key()
obj = lookup_key(key)
idle_time = estimate_idle_time(obj->lru)
if idle_time > max_idle_time:
max_idle_time = idle_time
best_key = key
if best_key:
db_delete(best_key)
这就是所谓的 **近似 LRU (Approximated LRU)**。它的精确度直接取决于采样数量 `maxmemory-samples`。当 `samples` 值较小(如默认值 5),其行为与严格 LRU 有差距,但性能开销极低。当 `samples` 值增大,其效果会越来越逼近严格 LRU,但 CPU 消耗也随之增加。Redis 官方文档指出,当 `samples` 设置为 10 时,其效果已经非常接近理论上的 LRU。
精巧的 LFU 实现
LFU 的实现比 LRU 更为复杂和精巧,它同样复用了那个 24 bit 的 `lru` 字段,但把这 24 bit 拆成了两部分:
- 高 16 bit (ldt – Last Decrement Time): 存储一个以分钟为单位的粗粒度时间戳,用于实现访问计数的“衰减”。
- 低 8 bit (logc – Logarithmic Counter): 存储一个对数形式的访问计数值。
为什么计数器是对数的?因为现实世界的数据访问频率遵循齐夫定律(Zipf’s law),即少数项目被访问的次数远超其他项目。如果用一个 8 bit 的线性计数器,最大只能记到 255,一个热点 key 很快就会达到上限,无法再与其他热点 key 区分。采用对数计数,可以用有限的空间表达更大的访问频率范围。计数值增加的逻辑大致是:每次访问,产生一个 0 到 1 之间的随机数 r,只有当 `r < 1 / (旧计数值 * lfu-log-factor + 1)` 时,计数值才会加 1。`lfu-log-factor` 是一个可配置的参数,它控制了计数器增长的速度,factor 越大,增长越慢。
// Simplified LFU increment logic
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
double r = (double)rand()/RAND_MAX;
double baseval = counter - LFU_INIT_VAL; // LFU_INIT_VAL is typically 5
if (baseval < 0) baseval = 0;
double p = 1.0 / (baseval * server.lfu_log_factor + 1);
if (r < p) counter++;
return counter;
}
而 LFU 的衰减机制则由高 16 bit 的 `ldt` 和配置项 `lfu-decay-time` 共同实现。当一个 key 被访问时,Redis 会计算当前时间与它的 `ldt` 之间的差值,这个差值代表了多少分钟没有对这个 key 的计数值进行衰减了。根据这个分钟数和 `lfu-decay-time`,Redis 会对 8 bit 的 `logc` 进行相应的减法操作。这确保了曾经的热点数据,如果长时间不再被访问,其访问计数值会慢慢“腐烂”,最终有机会被淘汰,从而解决了经典 LFU 的“缓存永生”问题。
性能优化与高可用设计
理论和实现都已清晰,现在是架构师进行决策的时刻。选择哪种策略,不是一个非黑即白的问题,而是一系列基于业务场景、性能目标和运维成本的权衡。
策略选择 Trade-off
- `allkeys-lru`:
- 适用场景: 最通用的选择。当你无法确定数据访问模式,或者数据访问模式较为平均,没有绝对的“超级热点”时,它是一个安全且高效的起点。比如,缓存用户登录的 session 信息。
- 优点: 实现简单,CPU 开销低,易于理解。
- 缺点: 对批量扫描、全量预热等操作极其敏感,容易造成缓存污染,导致热点数据被淘汰。
- `allkeys-lfu`:
- 适用场景: 数据访问模式有明显的“幂律分布”,即存在长期、稳定的热点数据。例如,电商系统中的爆款商品、社交平台上的大 V 用户信息。
- 优点: 抗缓存污染能力强,能长期锁定真正的热点数据,缓存命中率更稳定。
- 缺点: CPU 开销略高于 LRU。配置相对复杂,`lfu-log-factor` 和 `lfu-decay-time` 需要根据业务数据的生命周期进行调优,否则可能导致热点更新不及时或旧热点无法淘汰。
- `volatile-ttl`:
- 适用场景: 缓存的数据都有明确的生命周期,且你希望优先淘汰那些快要过期的数据,以节省内存。比如缓存手机验证码、临时授权 token 等。
- 优点: 能有效管理具有时效性的数据,最大化利用内存来存储“存活”时间更长的数据。
- 缺点: 如果所有 key 的 TTL 设置得差不多,其效果会退化成近似随机。
- `noeviction`:
- 适用场景: Redis 不仅仅用作缓存,而是作为持久化或半持久化的数据存储。例如,用作分布式锁、计数器、或者存储关键的业务元数据。
- 优点: 数据安全性最高,绝不会因为内存压力而丢失数据。
- 缺点: 内存写满后,所有写操作都会失败并返回错误。这要求客户端必须有完善的错误处理和降级逻辑,否则会造成服务中断。
- `volatile-random` / `allkeys-random`:
- 适用场景: 当你对数据访问模式一无所知,或者所有数据的访问概率都差不多时。或者,你对 CPU 性能要求极为苛刻,不希望在淘汰逻辑上花费任何额外的计算。
- 优点: CPU 开销最低。
- 缺点: 缓存效率最低,完全是碰运气。
`volatile-*` vs `allkeys-*` 的抉择
这个选择本质上是在回答一个架构问题:你的 Redis 实例是“纯缓存”还是“混合存储”?
如果你的 Redis 实例中所有数据都可以被随时删除(即,所有数据在后端数据库或其他地方都有源可查),那么请大胆使用 `allkeys-*` 策略。这给了 Redis 最大的自由度去优化内存使用。
反之,如果你的实例中同时存在“可淘汰的缓存数据”和“不可淘汰的持久化数据”(例如,业务配置、用户 session 等),那么必须使用 `volatile-*` 策略。这种策略只会从设置了过期时间的 key 中进行淘汰,从而保护了那些没有设置 TTL 的重要数据。混用存储模式却错误地配置了 `allkeys-*` 策略,是导致生产环境数据丢失的常见原因之一。
架构演进与落地路径
在一个复杂的系统中,一次性就选对最优策略几乎是不可能的。正确的做法是采用一种数据驱动、逐步演进的方法论。
- 阶段一:基线建立与监控先行
对于新上线的业务,如果没有明确的数据访问模式作为参考,请从最简单、最安全的 `allkeys-lru`(或 `volatile-lru`)开始。但最关键的一步是,建立完善的监控体系。你必须持续追踪以下核心指标:
- `used_memory`: 内存使用情况,观察是否接近 `maxmemory`。
- `evicted_keys`: 单位时间内的淘汰键数量,飙升通常意味着问题。
- `keyspace_hits` & `keyspace_misses`: 计算缓存命中率 (hits / (hits + misses)),这是衡量缓存有效性的黄金指标。
- `connected_clients`: 客户端连接数。
- `instantaneous_ops_per_sec`: Redis QPS。
- 阶段二:分析模式与识别瓶颈
通过监控数据,回答以下问题:
- 缓存命中率是否符合预期?通常,一个好的缓存系统命中率应在 95% 以上。
- `evicted_keys` 是否有与业务高峰或特定后台任务(如数据刷新、报表生成)强相关的不规律尖峰?如果存在,这很可能是 LRU 缓存污染的明确信号。
- 分析业务日志,确认数据的访问模式。是否存在一部分数据(如商品ID)的访问频次远高于其他数据?
- 阶段三:策略调优与 A/B 测试
如果识别出 LRU 的问题,并且业务访问模式符合 LFU 的假设,那么可以考虑切换到 `allkeys-lfu`。但这个切换过程必须谨慎。最佳实践是在预发环境或通过灰度发布(例如,将部分流量切到一个采用 LFU 策略的新 Redis 集群)进行 A/B 测试。对比新旧策略下的缓存命中率、CPU 使用率和业务 API 延迟。同时,开始调整 LFU 的 `lfu-log-factor` 和 `lfu-decay-time`。例如,如果你的热点数据生命周期很长(比如经典款商品),`decay-time` 可以设置得长一些;如果热点变化快(比如新闻资讯),`decay-time` 则需要短一些。
- 阶段四:分片与隔离
对于超大规模、业务复杂的系统,试图用单一的淘汰策略满足所有需求是不现实的。更高级的架构演进方向是,根据数据的不同特性,将其隔离到不同的 Redis 实例或集群中,并为每个集群配置最适合的策略。例如:
- 用户会话集群: 数据量大,但都有明确 TTL。使用 `volatile-ttl`。
- 商品元数据集群: 存在稳定热点,适合使用 `allkeys-lfu`。
- 配置与分布式锁集群: 数据绝不能丢失,配置为 `noeviction`,并配有严格的容量监控和告警。
这种物理隔离的方式,虽然增加了运维成本,但提供了最强的稳定性和最精细的性能调优能力,是大型互联网公司架构的常见模式。
总而言之,Redis 内存淘汰策略的选择不是一个静态的配置动作,而是一个动态的、持续优化的过程。它要求架构师不仅要理解算法原理和实现细节,更要深刻洞察业务数据的访问模式和生命周期,最终通过数据驱动的方式,找到资源成本与系统性能之间的最佳平衡点。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。