缓存把昂贵操作的结果——一次数据库查询、一次 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 防分布式不一致。

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

在哪里缓存:分层缓存栈

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

  • 客户端(浏览器)——HTTP 缓存头(Cache-ControlETagLast-Modified)让浏览器无需任何服务器往返就复用资源。静态内容的免费性能;问题是不改资源 URL 就无法主动让浏览器缓存失效。
  • CDN(边缘缓存)——地理分布的缓存(Cloudflare、Fastly、AWS CloudFront)从靠近用户的接入点服务静态和半静态内容。把延迟从 ~200 ms 降到个位数毫秒,大幅削减源服务器负载。可配置 TTL 和缓存清除 API 允许定向失效。
  • 反向代理 / API 网关缓存——位于应用层前的 Nginx 或 Varnish 能缓存完整 HTTP 响应。当许多用户发相同请求时有用(公开新闻页、商品详情页);响应因用户而异时自动绕过。
  • 应用级进程内(L1)——一个 hash map 或像 Caffeine(JVM)、django-cacheops(Python)这样的库活在应用进程里。零网络开销,纳秒级访问。缺点:不跨实例共享、重启时被淘汰、对其他服务不可见。最适合作为 Redis 前最热 key 的超快层。
  • 分布式缓存(L2)——所有应用实例可访问的共享外部存储(Redis、Memcached)。会话数据、计算结果、热数据库行,以及任何必须在一群应用服务器间一致的数据的标准方案。加一跳网络(~0.1–1 ms)但能挺过进程重启并横向扩展。
  • 数据库查询缓存——某些数据库(MySQL query cache、PostgreSQL 的 shared buffer pool)在内部缓存结果集或缓冲页。这通常是不透明的、不是你设计的东西;它是热实例上首次慢查询不必恐慌的原因。
设计原则

把缓存从快到慢分层: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. 写时失效
  • 优点:只缓存被请求的数据——不在冷数据上浪费内存。缓存故障时降级为只用 DB。
  • 缺点:过期后的首个请求总是慢(缓存冷启动延迟尖峰)。若你在写时让条目失效但写本身在失效后失败,缓存就被白白搞冷了。
  • 最适合:读多、更新少的负载。大多数商品、用户、会话缓存的默认选择。

2. Read-Through(读穿透)

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

  • 优点:应用代码更简单——缓存关注点被完全封装。与 ORM 级缓存库配合良好。
  • 缺点:初始未命中延迟仍在。更难定制回填逻辑(如 fallback、转换)。
  • 最适合:ORM 或框架管理的缓存,你想避免在每个服务里写"未命中并回填"样板代码。

3. Write-Through(写穿透)

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

  • 优点:强一致——读总命中一个温热、准确的缓存。无陈旧数据窗口。
  • 缺点:每次写都付缓存写代价(额外一跳网络)。写入但从不读取的数据浪费缓存内存。写延迟是两个操作之和。
  • 最适合:读一致性关键、写频率低到中等的系统(用户资料更新、配置数据)。

4. Write-Behind(写回)

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

  • 优点:对调用方而言写极快、写延迟极小。降低数据库写压力——对同一 key 的多个 in-flight 更新可合并成一次 DB 写。
  • 缺点:若缓存节点在刷盘前故障,数据丢失。数据库瞬时陈旧,若其他系统直接读 DB 则有问题。需要复杂的故障恢复逻辑。
  • 最适合:写多、能接受近期写丢失的负载(分析计数器、游戏分数)。绝不用于金融交易。

5. Write-Around(写绕过)

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

  • 优点:防止大而不频繁的写(批量导入、归档操作)污染缓存。缓存保持温热,装着真正的热数据。
  • 缺点:写后首次读总会未命中缓存并打数据库——刚写完即被读的数据可能有高未命中率。
  • 最适合:批量写入但读取不频繁的数据(日志摄取、数仓 ETL 加载、媒体上传)。
策略写路径一致性写速度最适合
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(30–60 秒)并在价格变更事件上急切失效。
  • 用户资料——几分钟陈旧可接受;5 分钟 TTL 加资料更新时事件驱动失效是合理取舍。
  • 会话数据——通常用滑动 TTL,每次访问重置,带一个绝对上限(如每请求重置到 30 分钟,7 天硬过期)。
  • 静态参考数据——国家列表、货币代码、配置——可设小时甚至天级 TTL,因为它们极少变。
  • 限流计数器——固定 TTL 等于限流窗口(如 100 req/分钟用 1 分钟);无需淘汰策略,因为过期是唯一的生命周期事件。
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 个并发数据库查询——足以搞垮一个中等规模的数据库。

缓解:

  • 互斥锁——首个未命中获取分布式锁(Redis SET NX)并重建条目;所有其他并发未命中等待或服务一个略陈旧的值。以等待请求的未命中延迟增加为代价消除 thundering herd。
  • 概率提前过期(PER)——在 TTL 触发前,以一个随过期临近而增加的概率重新计算并刷新缓存条目。公式:若 -beta * log(rand()) > time_to_expiry 则刷新。这把刷新工作分散到时间上,所以 key 在重负载下从不真正过期。
  • 后台刷新——立即服务陈旧值,同时一个后台 goroutine/线程异步重填缓存。用户一个请求看到略陈旧数据;后续请求看到新鲜数据。需接受短暂不一致。
  • TTL 抖动——若许多用户在同一时刻缓存同一 key(如部署刷新后),抖动防止同步过期。
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 向量。

缓解:

  • 缓存空结果——key 未找到时存一个哨兵值(如 "__null__" 或空对象)带短 TTL(30–60 秒)。对同一不存在 key 的后续请求命中缓存并立即返回。
  • 布隆过滤器——在缓存前维护一个跟踪哪些 key 确实存在的概率数据结构。布隆过滤器从不产生假阴性(若它说"不存在",该 key 一定不在 DB 里),所以过滤器拒绝的任何 key 可立即返回而无需查缓存或 DB。假阳性(过滤器说"存在"但 key 缺失)仍打 DB,所以把过滤器大小定到低假阳性率(~1%)。
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 的批量数据导入,或维护窗口后缓存层重启。

缓解:

  • TTL 抖动——写时随机化过期时间,让过期分散在窗口内而非同步。
  • 缓存预热——在会刷新缓存的部署前,跑一个预热脚本把最高流量 key 填进新缓存,再切换流量。
  • 数据库连接池熔断器——若数据库突然被未命中流量压垮,熔断器防止应用开数百个新连接放大问题。快速失败并返回降级响应,而非无限排队。
  • 多层缓存——若 L2(Redis)雪崩,一个 L1 进程内缓存吸收相当比例的流量数秒,给 Redis 重建的时间。

热点 Key:沉默的瓶颈

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

缓解:

  • 本地复制——把热点 key 用带后缀的多个名字存(product:42:0product:42:1、...、product:42:N)分散到不同分片。读请求随机挑一个副本。这把读扇出到 N 个分片,代价是 N 份存储和更新时 N 倍的失效广播。
  • 进程内 L1 缓存——在每个应用实例的 Redis 前加一个小进程内缓存(Caffeine、简单 LRU dict)。热点 key 从本地内存服务;只在本地未命中时才查 Redis。即便 1 秒 TTL 也能在高流量应用服务器群上吸收大比例请求。
  • 缓存读副本——Redis Cluster 默认不把读路由到副本(必须每连接显式启用 READONLY 模式)。启用副本读把热点 key 读负载分散到主和它的副本上。
  • 主动识别热点 key——Redis 提供 redis-cli --hotkeysMONITOR 命令,在热点 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 值。若你先写 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 缓存什么

  • 静态资源——JavaScript、CSS、图片、字体。这些用内容哈希文件名,是不可变的;设 Cache-Control: max-age=31536000, immutable 头实现 1 年 TTL 且无需失效。
  • 公开 API 响应——商品列表、分类页、公开资料。用短 TTL(30–300 秒)缓存,用 Cache-Control: public, max-age=60, s-maxage=300——s-maxage 独立于浏览器 TTL 控制 CDN TTL。
  • HTML 页面——对非个性化页面(落地页、营销内容),在 CDN 缓存大幅降低源负载。谨慎使用 Vary: Accept-Encoding,避免把 gzip 内容发给无法解压的客户端。

不要在 CDN 缓存什么

  • 已认证或个性化响应——绝不在 CDN 后缓存含用户特定数据的响应;设 Cache-Control: privateno-store 阻止 CDN 缓存并确保隐私。
  • POST/PUT/DELETE 请求——CDN 默认只缓存 GET 和 HEAD。变更请求必须总是穿透到源。
  • 支付和结账流程——即使是 GET,也绝不缓存金融数据或安全敏感响应。

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                               -- 放行

缓存可观测性:测量什么

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

  • 命中率——hits / (hits + misses)。健康的通用缓存对热数据应维持 90%+ 命中率。低于 80% 的命中率暗示缓存不足、key 设计问题,或短 TTL 造成的过度churn。
  • 按 key 前缀的未命中率——按 key 模式分解未命中,找出哪些数据类型缓存得不好。product:* 高未命中而 session:* 低未命中,指向商品缓存问题,而非系统性问题。
  • 淘汰率——若淘汰率高,你的缓存相对工作集太小。要么增加内存,要么降低 TTL 缩小工作集。
  • 内存使用和碎片——Redis INFO memory 显示 used_memory vs used_memory_rss;高碎片(比率超 1.5)表示可通过重启实例或运行 MEMORY PURGE(Redis 4.0+)回收的内存浪费。
  • P99 缓存延迟——Redis 应在 1 ms 内服务命令。超过 5 ms 的尖峰表示热点 key 问题、大 key(如巨大的序列化 JSON),或慢命令(对大集合的 KEYSSMEMBERS)。
  • 连接数——Redis 单线程;连接开销真实存在。用连接池并监控活跃 vs 空闲连接。

最佳实践与反模式

该做

  • 给每个缓存条目加 TTL——无 TTL 意味着你完美信任你的失效逻辑,而这从不成立。
  • 对批量加载的数据用 TTL 抖动——消除同步过期雪崩。
  • 在正确的粒度缓存——缓存组装好的对象,而非原始 DB 行,让缓存命中最大化有用。但若子对象有不同新鲜度要求,分别缓存它们。
  • 高效序列化——JSON 方便但冗长;高量缓存值用 MessagePack 或 Protocol Buffers 降低内存和反序列化时间。
  • 用一致的层级命名方案给 key 命名——service:entity:id:field——让基于模式的失效和监控干净工作。
  • 测试缓存故障——定期在 staging 杀掉缓存,验证应用优雅降级而非级联成全面宕机。

不该做

  • 除非你有铁打的失效,否则别缓存可变金融数据(余额、订单状态)——这里的陈旧有直接金钱后果。
  • 别在 Redis 生产用 KEYS *——它是 O(N) 且执行时阻塞所有其他命令。用 SCAN
  • 别在 Redis 存大值——集群里 1 MB 值没问题,但序列化成一个 key 的 100 MB 值会造成延迟尖峰。把大对象分块,或存对象存储配一个缓存指针。
  • 别把缓存当主存储——它是数据库的快速配件,不是替代。若缓存空了,应用仍必须(也许慢点)通过落到 DB 正确运作。
  • 别忘了序列化版本控制——若你改了缓存对象的 schema 并重新部署,旧缓存条目会反序列化失败。用带版本的 key 前缀,或在序列化载荷里带一个版本字段。
总结

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 前就吸收大部分。

← 上一篇
负载均衡