缓存把昂贵操作的结果——一次数据库查询、一次 API 调用、一个计算值——存进快速存储,这样后续请求无需重复工作即可被服务。它是分布式系统中杠杆最高的性能技术之一:一个放对位置的缓存能把数据库负载降低数个数量级,把响应时间从几百毫秒砍到个位数。其核心是一个简单的经验观察,即 80/20 法则:80% 的流量只触及 20% 的数据。如果你能把那热的 20% 留在内存里,就能吸收绝大部分负载而完全不碰更慢的持久层。

但缓存不仅仅是"把东西塞进 Redis"。每个设计决策——在栈的哪一层缓存、用哪种写策略、什么淘汰策略管理内存压力、怎么处理 TTL、怎么防御击穿和雪崩——叠加起来,要么成就一个高可用、低延迟的系统,要么成为一个在生产高负载下崩溃的微妙一致性雷区。本指南覆盖全部:从五种经典读/写策略到淘汰算法内部,到分布式缓存一致性、热点 key 病理、CDN 边缘缓存,以及失效模式的完整分类与缓解。

⚡ 速览要点
  • Cache-aside(懒加载)是最安全的默认——只缓存实际被请求的,缓存故障时优雅降级。
  • Write-through 让缓存和 DB 同步但在从不读取的数据上浪费空间;write-behind 最大化写吞吐但崩溃时有丢数据风险。
  • Write-around 在写时绕过缓存——当写入数据短期内不太可能被再读时有用(大批量导入、归档写)。
  • LRU 是通用淘汰策略;稳定热点 key 负载用 LFU;ARC 自动适应;加 TTL 抖动防雪崩。
  • 缓存击穿——热门 key 过期时,大量线程同时打 DB;用互斥锁或概率提前刷新修复。
  • Redis vs Memcached——丰富数据结构、持久化、pub/sub 用 Redis;规模化纯简单字符串吞吐用 Memcached。
  • 布隆过滤器无需 DB 查找就能防止不存在 key 的缓存穿透。
  • 热点 key 在分布式缓存中会打爆单个分片——复制到多个槽,或在 Redis 前加进程内 L1 缓存。
tldr

把频繁读取、很少变化的数据缓存到靠近消费者的地方。按你的一致性和写放大预算选写策略(cache-aside、write-through、write-behind、write-around、read-through)。用 TTL 和淘汰策略(LRU 是安全默认,ARC 自适应)约束内存。为缓存未命中而设计——冷启动或击穿应优雅降级,而非级联成数据库崩溃。用布隆过滤器防穿透,用本地复制防热点 key,用谨慎失效或短 TTL 防分布式不一致。

分布式系统栈中的缓存层
分布式系统栈中的缓存层

在哪里缓存:分层缓存栈

缓存可以存在于栈的多个层,各有不同的延迟、可见性和失效复杂度取舍:

设计原则

把缓存从快到慢分层:L1 进程内 → L2 分布式 → 数据库。L1 未命中先打 L2 再碰数据库。这让数据库连接留给写和真正不可缓存的读。关键洞见是每层吸收不同比例的流量,所以即使中等的 L1 命中率也能显著降低 L2 和 DB 压力。

五种缓存写策略

没有唯一正确的策略。正确选择取决于你的一致性容忍度、写频率,以及缓存和数据库分歧时会发生什么。大多数真实系统跨不同数据类型组合多种策略。

1. Cache-Aside(懒加载)

应用完全拥有缓存交互。时:先查缓存;未命中则查数据库,把结果写入缓存,再返回。时:更新数据库,然后删除或更新缓存条目。

python
def get_product(product_id):
    key = f"product:{product_id}"
    data = cache.get(key)                     # 1. 查缓存
    if data:
        return data                            # 命中——结束
    data = db.query("SELECT * FROM products WHERE id=?", product_id)
    cache.setex(key, 300, data)               # 2. 未命中时回填
    return data

def update_product(product_id, payload):
    db.execute("UPDATE products SET ... WHERE id=?", product_id)
    cache.delete(f"product:{product_id}")     # 3. 写时失效

2. Read-Through(读穿透)

类似 cache-aside,但缓存库本身位于应用和数据库之间。应用总是与缓存对话;未命中时缓存去数据库、回填自己并返回值。应用代码无需显式实现"未命中并回填"逻辑。

3. Write-Through(写穿透)

每次写在向调用方确认成功前同步写入缓存和数据库。任何写之后缓存立即与数据库一致。

4. Write-Behind(写回)

写立即落到缓存;数据库写在后台被延迟并批量异步执行。缓存作为主要写目标,数据库最终被带入同步。

5. Write-Around(写绕过)

直接到数据库,完全绕过缓存。缓存仅在后续读时(懒加载)被回填。这防止缓存被写入后短期内不太可能被再读的数据填满。

策略写路径一致性写速度最适合
Cache-AsideDB,然后让缓存失效最终(TTL 受限)快(无缓存写)通用读多
Read-ThroughDB,缓存未命中时自动回填最终ORM 管理缓存
Write-Through缓存 + DB(同步)较慢(双写)写少、读关键
Write-Behind缓存立即,DB 异步最终(有风险)最快高写吞吐
Write-Around仅 DB,绕过缓存最终(冷写)批量/归档写

淘汰策略:算法深入

缓存满时,必须淘汰一些东西腾地方。你选的策略决定哪些数据存活——选错就会淘汰热条目而留下冷的,毁掉你的命中率。每种算法编码了对你访问模式的不同假设。

LRU——最近最少使用

淘汰最久未被访问的项。实现为一个双向链表,被访问的项移到头部;尾部的项是淘汰候选。配合哈希表支撑链表,访问和更新都是 O(1)。

LRU 工作得好是因为大多数负载呈现时间局部性——最近访问的数据往往很快又被访问。然而 LRU 有个著名失效模式:对大数据集的一次顺序扫描(全表备份、批量报表)会淘汰整个工作集,留下冷缓存给随后的热 OLTP 查询。这有时被称为缓存污染事件。

LFU——最不经常使用

淘汰访问计数最低的项。无论访问新近度如何,自然保活病毒式内容和稳定热点 key。缺点:新插入项以计数 1 开始,即使即将变热也易被立即淘汰。现代 LFU 实现(TinyLFU,在 Redis 4.0+ 中作为 allkeys-lfu)把频率 sketch 与一个小的新近度窗口结合,处理"新热门项"问题。

ARC——自适应替换缓存

ARC 通过维护两个列表自动在 LRU 和 LFU 间适应:一个给最近使用项(T1)、一个给频繁使用项(T2),外加 ghost 列表(B1、B2)记住最近淘汰项的身份而不存其数据。当 ghost 列表命中时,ARC 把平衡向那个列表的策略倾斜。结果是一个实时学习你访问模式、无需配置就自调的缓存。ARC 有专利(IBM),所以一些系统转而实现 LIRS 或 CLOCK-Pro 等近似。

FIFO——先进先出

最简单的策略:淘汰最早插入的项。不需要访问跟踪,实现起来快且省内存。对缓存几乎从不最优,因为它与访问频率或新近度无关;最先插入的项可能是系统中访问最多的。FIFO 的正当用例是简单消息缓冲和限流窗口,而非通用缓存。

基于 TTL 的过期

项在越过其过期时间戳时被淘汰,独立于淘汰策略。TTL 和淘汰协同工作:TTL 控制数据新鲜度;淘汰控制内存压力。Redis 把两者结合——你为每个 key 设 TTL,并选一个策略(LRU、LFU 等)管理内存满而尚无 TTL 触发时该怎么办。

策略淘汰优势弱点
LRU最近最少访问时间局部性极好;简单 O(1)顺序扫描污染;忽略频率
LFU总访问次数最少长期保活稳定热点 key新热点项起步冷;陈旧计数滞留
ARC动态适应 LRU+LFU自调;两全其美更多内存开销;有专利
FIFO最早插入零开销;确定性与访问模式无关系
TTL越过过期时间戳保证新鲜;可预测的陈旧窗口非内存压力驱动;过期触发前数据陈旧

TTL 设计:新鲜度 vs. 稳定性

设 TTL 是新鲜度(缓存更新多快反映数据库变化)和稳定性(你多久付一次未命中代价)之间的取舍。设对它需要理解数据的变化频率和服务陈旧数据的业务成本。

TTL 抖动

绝不要给一大批 key 设相同 TTL——若你把 1 万条商品记录用统一 300 秒 TTL 加载进 Redis,它们 5 分钟后会同时过期,在数据库上产生 thundering-herd 雪崩。加随机抖动:ttl = base_ttl + random.randint(0, base_ttl // 5)。这把过期分散到时间上,消除同步的未命中突发。

三种致命失效模式

缓存击穿(Thundering Herd)

当单个热门 key 在高流量下过期,许多并发请求同时发现缓存未命中,全都竞相从数据库重建它。若重建耗时 200 ms、你有 1000 req/s 打那个 key,光第一秒你就产生 200 个并发数据库查询——足以搞垮一个中等规模的数据库。

缓解:

python
import time, math, random

def fetch_with_per(key, beta=1.0):
    # 概率提前重算——避免击穿
    entry = cache.get_with_ttl(key)          # 返回 (value, remaining_ttl)
    if entry is None:
        return recompute_and_store(key)       # 冷未命中
    value, ttl = entry
    delta = time.monotonic() - time.monotonic()  # 计算耗时的代理
    if -beta * math.log(random.random()) > ttl:
        return recompute_and_store(key)       # 提前重算
    return value                               # 服务缓存值

缓存穿透(Cache Penetration)

缓存和数据库里都不存在的 key 的请求,每次调用都穿透到数据库。每个请求都未命中缓存(结果为空没什么可缓存)并触发完整数据库查询。一个常见原因是恶意客户端循环查询无效 ID——一个完全绕过缓存的 DoS 向量。

缓解:

python
import redis, random
from pybloom_live import BloomFilter

r = redis.Redis(host='localhost', port=6379)
bloom = BloomFilter(capacity=1_000_000, error_rate=0.01)

def get_user(user_id):
    if user_id not in bloom:
        return None                            # 布隆说一定缺失

    key = f"user:{user_id}"
    cached = r.get(key)
    if cached == "__null__":
        return None                            # 缓存的空结果
    if cached:
        return cached

    user = db.query("SELECT * FROM users WHERE id=?", user_id)
    if user is None:
        r.setex(key, 30, "__null__")            # 缓存空结果
        return None

    ttl = 300 + random.randint(0, 60)         # 抖动防雪崩
    r.setex(key, ttl, user)
    bloom.add(user_id)
    return user

缓存雪崩(Cache Avalanche)

一大批 key 同时过期,在完全相同的时刻向数据库发送未命中洪流。常见触发:刷新缓存的滚动部署、用相同 TTL 填充数千 key 的批量数据导入,或维护窗口后缓存层重启。

缓解:

热点 Key:沉默的瓶颈

在 Redis Cluster 这样的分布式缓存集群里,每个 key 恰好活在一个分片上。一个热点 key——单个商品页、一条爆款推文、一篇病毒新闻——可能每秒收到数百万请求,全都路由到同一分片。无论其他分片多空闲,那个分片都成了瓶颈。这是个与缓存击穿(关乎过期)不同的问题——热点 key 是一个吞吐问题,即使 key 已填充且从不过期也存在。

缓解:

python
import random

NUM_REPLICAS = 10

def get_hot_product(product_id):
    # 把读分散到不同分片上的 N 个副本 key
    replica_idx = random.randint(0, NUM_REPLICAS - 1)
    key = f"product:{product_id}:r{replica_idx}"
    return cache.get(key) or rebuild_and_fan_write(product_id)

def invalidate_hot_product(product_id):
    # 更新时,让所有副本 key 失效
    keys = [f"product:{product_id}:r{i}" for i in range(NUM_REPLICAS)]
    cache.delete(*keys)

分布式缓存一致性

分布式缓存引入第二个记录系统:缓存和数据库在任意时刻可能为同一 key 持有不同值。管理这种分歧是规模化缓存最难的问题之一。核心张力在两种失效方式之间:

正确的顺序

总是写数据库,再让缓存失效——不是反过来。若你先让缓存失效然后 DB 写失败,你就有一个冷缓存配一个陈旧(或缺失)的 DB 值。若你先写 DB 然后缓存失效失败,你的缓存短暂陈旧但 DB 是权威的;TTL 最终会清理它。这种不对称很重要。

对需要更强保证的系统,考虑通过变更数据捕获(CDC)的事件驱动失效:一个 CDC 连接器(Debezium、Maxwell)读数据库的预写日志并把变更事件发布到消息队列。缓存 worker 订阅并失效或刷新缓存条目。这把写路径与缓存失效延迟完全解耦,并确保缓存与 DB 的 WAL(所有写的权威记录)最终一致。

Redis vs. Memcached:一个诚实的对比

Redis 和 Memcached 都是内存键值存储,但它们在设计哲学和特性集上实质分道扬镳。一旦你知道你的需求,这个决定很少难分高下。

维度RedisMemcached
数据结构字符串、哈希、列表、集合、有序集合、HyperLogLog、流、位图、地理空间仅字符串(二进制安全)
持久化RDB 快照 + AOF 预写日志;可配置持久性 vs 性能取舍无——仅内存;重启丢全部数据
集群 / HA原生 Redis Cluster(哈希槽分区)+ Sentinel 自动故障转移客户端一致性哈希;无服务端集群
Pub/Sub有——原生发布/订阅 + 流(简单用例的轻量 Kafka)
Lua 脚本有——原子服务端脚本;支持复杂原子操作
事务MULTI/EXEC(乐观)+ WATCH 做 CAS
线程模型单线程事件循环(6.0 起网络 I/O 多线程)多线程——每连接真正 CPU 并行
内存效率丰富结构增加开销;小对象用哈希精简,裸字符串开销最小
用例会话、排行榜(有序集合)、限流、队列、pub/sub、特性开关极致规模的简单字符串缓存、只读对象缓存

Redis 是大多数新系统的默认选择。Memcached 在纯裸字符串吞吐上在多核机器上保有可观优势——它的多线程架构允许每连接真正的 CPU 级并行,而 Redis 的单线程命令处理无论可用核心多少都串行化所有命令(网络 I/O 在 6.0 多线程,但命令执行仍单线程)。对一个字面上是数百万简单 GET/SET 纯字符串、无需持久化、丰富类型或 pub/sub 的负载,Memcached 在极致规模下能比 Redis 快 20–40%。对其他一切——有序集合排行榜、流处理、Lua 原子限流、持久会话——Redis 毫无疑问是更好的选择。

要知道的 Redis 淘汰策略

Redis 通过 maxmemory-policy 支持多种淘汰策略:noeviction(满时返回错误)、allkeys-lru(所有 key 上 LRU)、volatile-lru(仅在设了 TTL 的 key 中 LRU)、allkeys-lfu(所有 key 上 LFU)、allkeys-randomvolatile-ttl(先淘汰 TTL 最短的 key)。最常见的生产选择是纯缓存用 allkeys-lru,当 Redis 同时被用作缓存和数据存储时用 volatile-lru(只淘汰会过期的缓存条目,绝不淘汰持久数据)。

CDN 边缘缓存

内容分发网络是位于网络边缘——地理上靠近终端用户——的全球分布式缓存。对面向公众的内容,CDN 是你能部署的杠杆最高的缓存:与其让一个来自新加坡的请求穿行 200 ms 到你的 US-East 源,它命中新加坡的 CDN 边缘节点并在 10 ms 内返回。

在 CDN 缓存什么

不要在 CDN 缓存什么

CDN 的缓存失效

CDN 失效(缓存清除)在两方面昂贵:它有 API 速率限制,且要数秒才能传播到全球所有边缘节点。标准策略是避免对静态资源需要失效(内容哈希 URL),并对半动态内容依赖短 TTL。对紧急失效(数据泄露需立即移除),用 CDN 的清除 API,但把它当稀有的应急工具,而非常规的缓存刷新机制。

生产中的缓存模式:真实场景

电商商品页

一个商品详情页聚合来自多个服务的数据:目录、库存、定价、评论。典型模式是片段缓存(fragment caching):在各自合适的 TTL 独立缓存每个片段,而非缓存组装好的整页。商品描述:1 小时 TTL;定价:30 秒 TTL(或事件失效);库存:不缓存(总从源读)。CDN 为匿名用户以 60 秒 TTL 缓存组装页;个性化或已认证响应完全绕过 CDN。

社交 feed(news feed / timeline)

用户时间线从原始图数据组装起来很昂贵。行业标准(Facebook Memcache、Twitter Pelikan)是每用户缓存预组装的时间线对象。写扇出到所有关注者的时间线——写时推(push-on-write)模型——所以读总是命中缓存。对有数百万关注者的用户(名人),扇出无界;对那些账号切到读时拉(pull-on-read)模型,在读时把他们的内容合并进时间线。

用 Redis 限流

Redis 有序集合或原子自增是分布式限流的标准。滑动窗口日志模式把每个请求时间戳存为有序集合成员;ZRANGEBYSCORE 计数最近 N 秒的请求;ZADD 加新请求;ZREMRANGEBYSCORE 修剪旧条目——全在一个 Lua 脚本里保证原子性。固定窗口计数器更简单:INCR key + EXPIRE key window_seconds

lua
-- Redis Lua:滑动窗口限流器(原子)
local key      = KEYS[1]               -- 如 "ratelimit:user:42"
local now      = tonumber(ARGV[1])     -- 当前时间戳 ms
local window   = tonumber(ARGV[2])     -- 窗口大小 ms(如 60000)
local limit    = tonumber(ARGV[3])     -- 每窗口最大请求数

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
  return 0                             -- 被限流
end
redis.call('ZADD', key, now, now)
redis.call('PEXPIRE', key, window)
return 1                               -- 放行

缓存可观测性:测量什么

无法观测的缓存是无法调优的缓存。要埋的关键指标:

最佳实践与反模式

该做

不该做

总结

Cache-aside 是最安全的起点——它简单、只缓存真正需要的,且缓存故障时优雅降级。从第一天起加 TTL 抖动以避免雪崩。理解你的淘汰策略相对于访问模式——通用负载 LRU、稳定热点 key 集 LFU、无法预测时 ARC。需要持久化、pub/sub、有序集合或原子 Lua 脚本时上 Redis;只在极致规模的裸字符串吞吐是唯一需求时才考虑 Memcached。把热点 key、击穿、穿透、雪崩当作不同的失效模式——每个需要特定缓解,而非笼统的"加更多缓存"。

🎯 面试速答

缓存击穿 vs 缓存雪崩——区别是什么?击穿:一个热门 key 过期,许多线程同时打 DB——用互斥锁或概率提前刷新修复。雪崩:许多 key 同时过期,发送未命中洪流——用 TTL 抖动和预热修复。不同问题,不同修法。
怎么处理缓存穿透?缓存空结果(像 "__null__" 这样的哨兵带短 TTL),或在前面放布隆过滤器,在不存在的 key 打缓存或 DB 前拒绝它们。
Redis 还是 Memcached?超出简单字符串缓存的一切都用 Redis——持久化、pub/sub、排行榜的有序集合、Lua 原子限流、流。只在多核极致规模的裸字符串吞吐是唯一需求且运维简单至上时用 Memcached。
用什么淘汰策略?纯缓存用 allkeys-lru;Redis 兼作持久数据时用 volatile-lru。工作集有稳定、明确的热点 key(排行榜、爆款内容)时切到 allkeys-lfu
怎么处理打爆单个 Redis 分片的热点 key?把 key 用随机后缀复制到 N 个槽并扇出读;加一个 1 秒 TTL 的进程内 L1 缓存,在请求打到 Redis 前就吸收大部分。

← 上一篇
负载均衡