缓存把昂贵操作的结果——一次数据库查询、一次 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 缓存。
把频繁读取、很少变化的数据缓存到靠近消费者的地方。按你的一致性和写放大预算选写策略(cache-aside、write-through、write-behind、write-around、read-through)。用 TTL 和淘汰策略(LRU 是安全默认,ARC 自适应)约束内存。为缓存未命中而设计——冷启动或击穿应优雅降级,而非级联成数据库崩溃。用布隆过滤器防穿透,用本地复制防热点 key,用谨慎失效或短 TTL 防分布式不一致。
在哪里缓存:分层缓存栈
缓存可以存在于栈的多个层,各有不同的延迟、可见性和失效复杂度取舍:
- 客户端(浏览器)——HTTP 缓存头(
Cache-Control、ETag、Last-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(懒加载)
应用完全拥有缓存交互。读时:先查缓存;未命中则查数据库,把结果写入缓存,再返回。写时:更新数据库,然后删除或更新缓存条目。
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-Aside | DB,然后让缓存失效 | 最终(TTL 受限) | 快(无缓存写) | 通用读多 |
| Read-Through | DB,缓存未命中时自动回填 | 最终 | 快 | 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 分钟);无需淘汰策略,因为过期是唯一的生命周期事件。
绝不要给一大批 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(如部署刷新后),抖动防止同步过期。
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%)。
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:0、product: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 --hotkeys和MONITOR命令,在热点 key 引发事故前在生产中识别它们。
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 都是内存键值存储,但它们在设计哲学和特性集上实质分道扬镳。一旦你知道你的需求,这个决定很少难分高下。
| 维度 | Redis | Memcached |
|---|---|---|
| 数据结构 | 字符串、哈希、列表、集合、有序集合、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 通过 maxmemory-policy 支持多种淘汰策略:noeviction(满时返回错误)、allkeys-lru(所有 key 上 LRU)、volatile-lru(仅在设了 TTL 的 key 中 LRU)、allkeys-lfu(所有 key 上 LFU)、allkeys-random、volatile-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: private或no-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。
-- 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_memoryvsused_memory_rss;高碎片(比率超 1.5)表示可通过重启实例或运行MEMORY PURGE(Redis 4.0+)回收的内存浪费。 - P99 缓存延迟——Redis 应在 1 ms 内服务命令。超过 5 ms 的尖峰表示热点 key 问题、大 key(如巨大的序列化 JSON),或慢命令(对大集合的
KEYS、SMEMBERS)。 - 连接数——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 前就吸收大部分。