去年双十一,某电商平台的商品详情页在零点流量洪峰期间出了事故。平时缓存命中率维持在 99.2%,但大促开始后三分钟内骤降到 60%。原因是运营团队在 23:55 批量更新了 12 万个商品的促销价格,触发了缓存的批量失效。命中率从 99% 跌到 60%,意味着打到数据库的请求量瞬间放大了 40 倍——从每秒 800 次飙升到每秒 32000 次。MySQL 主库的 CPU 在 30 秒内打满,连接池耗尽,整个商品服务链路雪崩,影响了支付和物流等下游系统。
这不是个案。缓存看起来简单——读的时候先查缓存,没有就查数据库再写回缓存——但一旦系统规模上来,缓存的设计决策直接决定了系统的稳定性和可用性。什么时候写缓存、什么时候删缓存、缓存和数据库不一致怎么办、缓存集群宕了怎么兜底——这些问题的答案不是”用 Redis”三个字能概括的。
这篇文章拆解缓存架构的核心问题:缓存读写模式怎么选、缓存失效策略怎么定、缓存穿透/击穿/雪崩怎么防、多级缓存怎么设计、分布式场景下的一致性怎么保障。最后会剖析 Facebook 在 NSDI 2013 发表的 Memcache 论文,看看全球最大规模的缓存系统是怎么设计的。
一、缓存读写模式
缓存的核心问题是:数据在缓存和数据库之间怎么流动。业界有五种主流模式,每种模式的一致性保证、复杂度和适用场景都不同。
1.1 Cache-Aside(旁路缓存)
Cache-Aside 是最常用的缓存模式。应用程序自己管理缓存的读写,缓存层不感知数据源。
读流程:先查缓存,命中则返回;未命中则查数据库,将结果写入缓存,再返回。 写流程:先更新数据库,再删除缓存(注意,是删除而不是更新)。
public class ProductService {
private final RedisTemplate<String, Product> cache;
private final ProductRepository db;
public Product getProduct(Long id) {
String key = "product:" + id;
// 1. 先查缓存
Product product = cache.opsForValue().get(key);
if (product != null) {
return product;
}
// 2. 缓存未命中,查数据库
product = db.findById(id).orElse(null);
if (product != null) {
// 3. 回填缓存,设置过期时间
cache.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
}
return product;
}
public void updateProduct(Long id, Product product) {
// 1. 先更新数据库
db.save(product);
// 2. 再删除缓存(而不是更新缓存)
String key = "product:" + id;
cache.delete(key);
}
}为什么写的时候删除缓存而不是更新缓存?因为更新缓存在并发场景下容易出现数据不一致。假设两个写请求 A 和 B 同时到达,A 先更新数据库,B 后更新数据库,但 B 先更新了缓存,A 后更新了缓存——此时缓存里存的是 A 的旧值,而数据库里存的是 B 的新值。删除缓存则不存在这个问题,因为下一次读请求会从数据库加载最新值。
sequenceDiagram
participant Client as 客户端
participant App as 应用服务
participant Cache as 缓存(Redis)
participant DB as 数据库
Note over Client,DB: 读流程
Client->>App: GET /product/123
App->>Cache: GET product:123
alt 缓存命中
Cache-->>App: 返回数据
App-->>Client: 返回数据
else 缓存未命中
Cache-->>App: null
App->>DB: SELECT * FROM product WHERE id=123
DB-->>App: 返回数据
App->>Cache: SET product:123(带 TTL)
App-->>Client: 返回数据
end
Note over Client,DB: 写流程
Client->>App: PUT /product/123
App->>DB: UPDATE product SET ... WHERE id=123
DB-->>App: OK
App->>Cache: DEL product:123
Cache-->>App: OK
App-->>Client: 200 OK
Cache-Aside 的优点是简单直观、应用拥有完全控制权、缓存层故障不影响数据库操作。缺点是首次请求必定未命中(冷启动问题)、应用代码和缓存逻辑耦合。
1.2 Read-Through(穿透读取)
Read-Through 模式下,缓存层自身负责从数据库加载数据。应用只和缓存交互,不直接访问数据库。
class ReadThroughCache:
"""缓存层封装了数据加载逻辑"""
def __init__(self, redis_client, db_loader, default_ttl=1800):
self.redis = redis_client
self.db_loader = db_loader
self.default_ttl = default_ttl
def get(self, key: str) -> dict | None:
# 1. 查缓存
cached = self.redis.get(key)
if cached is not None:
return json.loads(cached)
# 2. 缓存未命中,由缓存层自己去加载
data = self.db_loader(key)
if data is not None:
self.redis.setex(key, self.default_ttl, json.dumps(data))
return data
# 使用方式:应用代码不感知数据库
def product_loader(key: str) -> dict | None:
product_id = key.split(":")[1]
row = db.execute("SELECT * FROM product WHERE id = %s", (product_id,))
return dict(row) if row else None
cache = ReadThroughCache(redis_client, product_loader)
product = cache.get("product:123")Read-Through 和 Cache-Aside 的区别在于职责边界:Cache-Aside 中应用知道数据库的存在,Read-Through 中应用只知道缓存。这让应用代码更干净,但缓存层的实现变得更复杂。
1.3 Write-Through(同步写穿透)
Write-Through 模式下,写操作同时更新缓存和数据库,两者在同一个同步流程中完成。只有当两者都写成功,才返回成功。
public class WriteThroughCache {
private final RedisTemplate<String, String> cache;
private final JdbcTemplate db;
@Transactional
public void put(String key, Product product) {
// 1. 写数据库
db.update(
"INSERT INTO product (id, name, price) VALUES (?, ?, ?) " +
"ON DUPLICATE KEY UPDATE name=?, price=?",
product.getId(), product.getName(), product.getPrice(),
product.getName(), product.getPrice()
);
// 2. 写缓存(同步)
String json = objectMapper.writeValueAsString(product);
cache.opsForValue().set("product:" + product.getId(), json,
Duration.ofMinutes(30));
}
}Write-Through 保证了缓存和数据库的强一致性(在单机场景下),但写延迟较高,因为每次写操作都要等两个存储都完成。适用于读多写少、对一致性要求高的场景。
1.4 Write-Behind(异步写回)
Write-Behind(也叫 Write-Back)模式下,写操作只更新缓存,然后异步批量将变更刷回数据库。这是性能最好但风险最高的模式。
import threading
import time
from collections import defaultdict
class WriteBehindCache:
"""写操作只写缓存,后台线程异步刷回数据库"""
def __init__(self, redis_client, db_writer, flush_interval=5):
self.redis = redis_client
self.db_writer = db_writer
self.dirty_keys = set()
self.lock = threading.Lock()
self.flush_interval = flush_interval
self._start_flush_thread()
def put(self, key: str, value: dict):
# 只写缓存
self.redis.set(key, json.dumps(value))
with self.lock:
self.dirty_keys.add(key)
def _start_flush_thread(self):
def flush_loop():
while True:
time.sleep(self.flush_interval)
self._flush_to_db()
t = threading.Thread(target=flush_loop, daemon=True)
t.start()
def _flush_to_db(self):
with self.lock:
keys = list(self.dirty_keys)
self.dirty_keys.clear()
if not keys:
return
batch = []
for key in keys:
value = self.redis.get(key)
if value:
batch.append((key, json.loads(value)))
# 批量写数据库
self.db_writer(batch)Write-Behind 的写延迟极低(只写内存),而且可以合并多次写操作为一次数据库批量写入,大幅降低数据库压力。但代价是:如果缓存节点在刷盘之前崩溃,未持久化的数据会丢失。适用于写入频率极高但允许少量数据丢失的场景,比如用户行为日志、实时计数器。
1.5 Refresh-Ahead(提前刷新)
Refresh-Ahead 模式在缓存即将过期之前,主动异步刷新数据,避免过期瞬间的缓存未命中。
package cache
import (
"sync"
"time"
)
type RefreshAheadCache struct {
store map[string]*entry
mu sync.RWMutex
loader func(key string) (interface{}, error)
ttl time.Duration
refreshAt float64 // 比如 0.8,表示在 80% 生命周期时触发刷新
refreshing map[string]bool
refreshLock sync.Mutex
}
type entry struct {
value interface{}
expiresAt time.Time
createdAt time.Time
}
func (c *RefreshAheadCache) Get(key string) (interface{}, error) {
c.mu.RLock()
e, exists := c.store[key]
c.mu.RUnlock()
if !exists || time.Now().After(e.expiresAt) {
// 缓存未命中或已过期,同步加载
return c.loadAndSet(key)
}
// 检查是否需要提前刷新
elapsed := time.Since(e.createdAt).Seconds()
total := e.expiresAt.Sub(e.createdAt).Seconds()
if elapsed/total >= c.refreshAt {
// 触发异步刷新
go c.asyncRefresh(key)
}
return e.value, nil
}
func (c *RefreshAheadCache) asyncRefresh(key string) {
c.refreshLock.Lock()
if c.refreshing[key] {
c.refreshLock.Unlock()
return
}
c.refreshing[key] = true
c.refreshLock.Unlock()
defer func() {
c.refreshLock.Lock()
delete(c.refreshing, key)
c.refreshLock.Unlock()
}()
c.loadAndSet(key)
}
func (c *RefreshAheadCache) loadAndSet(key string) (interface{}, error) {
value, err := c.loader(key)
if err != nil {
return nil, err
}
now := time.Now()
c.mu.Lock()
c.store[key] = &entry{
value: value,
expiresAt: now.Add(c.ttl),
createdAt: now,
}
c.mu.Unlock()
return value, nil
}Refresh-Ahead 的关键参数是刷新阈值(refresh factor)。设为 0.8 意味着一个 TTL 为 10 分钟的缓存项,在第 8 分钟被访问时就会触发异步刷新。这样大多数热点数据永远不会真正过期,读请求始终能命中缓存。缺点是对非热点数据无效——如果数据在过期前没有被访问,刷新就不会触发。
二、缓存失效策略
计算机科学中有一句著名的话:“There are only two hard things in Computer Science: cache invalidation and naming things.”(计算机科学中只有两件难事:缓存失效和命名。)缓存失效之所以难,是因为它本质上是一个分布式一致性问题。
2.1 基于 TTL 的过期
最简单的失效策略:给每个缓存项设置生存时间(Time-To-Live),到期自动删除。
# Redis 设置 TTL
SET product:123 '{"name":"iPhone","price":9999}' EX 1800
# 1800 秒(30 分钟)后自动过期TTL 的设计需要权衡:TTL 太短,缓存命中率低,数据库压力大;TTL 太长,数据新鲜度差,用户看到过期信息。常见的做法是根据数据的变更频率分层设置:
- 静态配置数据:TTL 24 小时
- 商品基础信息:TTL 30 分钟
- 库存/价格:TTL 1-5 分钟
- 实时排行榜:TTL 10-30 秒
2.2 事件驱动的失效
当数据在数据库中发生变更时,通过消息队列或变更数据捕获(CDC,Change Data Capture)主动通知缓存层删除对应的缓存项。
// 基于消息队列的缓存失效
@KafkaListener(topics = "product-changes")
public void onProductChange(ProductChangeEvent event) {
String key = "product:" + event.getProductId();
redisTemplate.delete(key);
log.info("缓存已失效: {}", key);
}基于 CDC 的方案更可靠,因为它直接监听数据库的 binlog(MySQL)或 WAL(PostgreSQL),不依赖应用层发消息。即使应用忘了发失效消息,CDC 也能捕获到变更。常用工具包括 Canal(阿里开源,MySQL binlog 解析)和 Debezium(支持多种数据库的 CDC 平台)。
# Debezium MySQL Connector 配置示例
name: product-cdc-connector
config:
connector.class: io.debezium.connector.mysql.MySqlConnector
database.hostname: mysql-master
database.port: 3306
database.user: cdc_user
database.password: "${CDC_PASSWORD}"
database.server.id: 1
database.include.list: ecommerce
table.include.list: ecommerce.product
topic.prefix: cdc当 product 表发生变更时,Debezium 会将变更事件发送到
Kafka 的 cdc.ecommerce.product
主题,消费者收到后删除对应的缓存项。
2.3 基于版本号的失效
给每个缓存项附带一个版本号。写操作递增版本号,读操作校验版本号是否匹配。如果不匹配,说明数据已经过期。
class VersionedCache:
def __init__(self, redis_client):
self.redis = redis_client
def get(self, key: str) -> dict | None:
cached = self.redis.hgetall(f"vc:{key}")
if not cached:
return None
# 获取当前版本
current_version = self.redis.get(f"version:{key}")
if current_version and cached.get("version") != current_version:
# 版本不匹配,缓存已过期
self.redis.delete(f"vc:{key}")
return None
return json.loads(cached["data"])
def put(self, key: str, value: dict):
version = str(int(time.time() * 1000))
self.redis.hset(f"vc:{key}", mapping={
"data": json.dumps(value),
"version": version
})
self.redis.set(f"version:{key}", version)
def invalidate(self, key: str):
# 只需递增版本号,不用删数据
new_version = str(int(time.time() * 1000))
self.redis.set(f"version:{key}", new_version)版本号方案的优点是失效操作非常轻量——只需更新一个版本号,不用遍历和删除大量缓存项。缺点是每次读都要额外查一次版本号。
三、缓存常见问题与解法
3.1 缓存穿透(Cache Penetration)
问题描述:查询一个数据库中不存在的数据,缓存永远不会命中(因为没有东西可以缓存),每次请求都直接打到数据库。恶意攻击者可以利用这一点,大量请求不存在的 ID,压垮数据库。
解法一:缓存空值。
public Product getProduct(Long id) {
String key = "product:" + id;
// 尝试从缓存获取(包括空值标记)
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
if ("NULL".equals(cached)) {
return null; // 命中空值缓存
}
return objectMapper.readValue(cached, Product.class);
}
Product product = productRepository.findById(id).orElse(null);
if (product != null) {
redisTemplate.opsForValue().set(key,
objectMapper.writeValueAsString(product),
30, TimeUnit.MINUTES);
} else {
// 缓存空值,TTL 设短一些
redisTemplate.opsForValue().set(key, "NULL",
5, TimeUnit.MINUTES);
}
return product;
}解法二:布隆过滤器(Bloom Filter)。在缓存之前加一层布隆过滤器,记录所有合法的 key。查询时先过布隆过滤器,如果判定 key 不存在,直接返回,不查缓存也不查数据库。
from pybloom_live import BloomFilter
class BloomFilterProtectedCache:
def __init__(self, redis_client, expected_items=1_000_000, fp_rate=0.001):
self.redis = redis_client
self.bloom = BloomFilter(capacity=expected_items, error_rate=fp_rate)
def warm_up(self, all_valid_keys: list[str]):
"""启动时加载所有合法 key 到布隆过滤器"""
for key in all_valid_keys:
self.bloom.add(key)
def get(self, key: str) -> dict | None:
# 布隆过滤器判定:不存在则一定不存在
if key not in self.bloom:
return None
# 可能存在,继续查缓存和数据库
cached = self.redis.get(key)
if cached:
return json.loads(cached)
data = self.load_from_db(key)
if data:
self.redis.setex(key, 1800, json.dumps(data))
return data布隆过滤器有误判率(false positive)但没有漏判(false negative),即它说”不存在”就一定不存在,说”存在”可能是误判。对于缓存穿透防护来说,这个特性刚好合适。
3.2 缓存击穿(Cache Breakdown / Hotspot Invalidation)
问题描述:一个热点 key 在缓存中过期的瞬间,大量并发请求同时发现缓存未命中,全部涌向数据库查询同一条数据,形成”惊群效应”(Thundering Herd)。
解法一:互斥锁(Mutex Lock)。只允许一个请求去数据库加载数据,其他请求等待。
public Product getProductWithMutex(Long id) {
String key = "product:" + id;
String lockKey = "lock:product:" + id;
Product product = cacheGet(key);
if (product != null) {
return product;
}
// 尝试获取分布式锁
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查:再查一次缓存,可能别的线程已经加载好了
product = cacheGet(key);
if (product != null) {
return product;
}
// 从数据库加载
product = productRepository.findById(id).orElse(null);
if (product != null) {
cacheSet(key, product, 30, TimeUnit.MINUTES);
}
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 没拿到锁,短暂等待后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductWithMutex(id);
}
return product;
}解法二:逻辑过期 + 异步刷新。缓存项永不过期(不设 TTL),但在值中记录一个逻辑过期时间。读取时检查逻辑时间,如果已过期,触发异步线程去刷新,当前请求返回旧数据。
import threading
class LogicalExpirationCache:
def __init__(self, redis_client, db_loader):
self.redis = redis_client
self.db_loader = db_loader
self.refresh_locks = {}
def get(self, key: str) -> dict | None:
raw = self.redis.get(key)
if raw is None:
return self._sync_load(key)
entry = json.loads(raw)
if time.time() < entry["expire_at"]:
return entry["data"]
# 逻辑过期,异步刷新,返回旧数据
self._async_refresh(key)
return entry["data"]
def _async_refresh(self, key: str):
if key in self.refresh_locks:
return
self.refresh_locks[key] = True
def do_refresh():
try:
data = self.db_loader(key)
if data:
entry = {
"data": data,
"expire_at": time.time() + 1800
}
self.redis.set(key, json.dumps(entry))
finally:
self.refresh_locks.pop(key, None)
threading.Thread(target=do_refresh, daemon=True).start()
def _sync_load(self, key: str) -> dict | None:
data = self.db_loader(key)
if data:
entry = {
"data": data,
"expire_at": time.time() + 1800
}
self.redis.set(key, json.dumps(entry))
return data逻辑过期方案的优点是热点 key 永远不会出现真正的缓存未命中,缺点是短暂返回旧数据,适用于对实时性要求不高的场景。
3.3 缓存雪崩(Cache Avalanche)
问题描述:大量缓存项在同一时间过期(比如服务重启后批量预热的缓存 TTL 相同),或者缓存服务整体宕机,导致所有请求直接打到数据库。
解法一:随机化 TTL。在基础 TTL 上加一个随机偏移量,避免大量 key 同时过期。
public void cacheWithJitter(String key, Object value, long baseTtlSeconds) {
// 在基础 TTL 上加 0-300 秒的随机偏移
long jitter = ThreadLocalRandom.current().nextLong(0, 300);
long actualTtl = baseTtlSeconds + jitter;
redisTemplate.opsForValue().set(key, value, actualTtl, TimeUnit.SECONDS);
}解法二:多级缓存兜底。即使分布式缓存(Redis)不可用,本地缓存(如 Caffeine)仍然可以扛一部分流量。
解法三:熔断降级。当缓存大面积失效导致数据库压力过大时,触发熔断器,直接返回降级数据(如默认值、缓存的旧版本),而不是让所有请求压到数据库。
@CircuitBreaker(name = "productService", fallbackMethod = "getProductFallback")
public Product getProduct(Long id) {
// 正常查询逻辑
return queryWithCache(id);
}
public Product getProductFallback(Long id, Throwable t) {
// 降级:返回本地缓存的旧数据或默认值
Product cached = localCache.getIfPresent(id);
if (cached != null) {
return cached;
}
return Product.defaultProduct();
}3.4 缓存惊群(Cache Stampede)
缓存惊群和缓存击穿类似,但发生在更大的规模上——不是单个热点 key,而是多个 key 同时过期,导致大量请求同时穿透到数据库。
解法:概率性提前过期(Probabilistic Early Expiration)。每次读取缓存时,根据一个概率公式决定是否提前刷新,越接近过期时间,刷新的概率越高。这个算法来自论文”Optimal Probabilistic Cache Stampede Prevention”。
import math
import random
def should_refresh_early(
expire_at: float,
now: float,
beta: float = 1.0,
recompute_time: float = 0.5
) -> bool:
"""
XFetch 算法:概率性提前过期
beta: 控制提前刷新的激进程度,值越大越激进
recompute_time: 重新计算数据的预估耗时(秒)
"""
ttl_remaining = expire_at - now
# 到期前的随机提前量
random_early = recompute_time * beta * math.log(random.random())
# random_early 是负数,如果其绝对值大于剩余 TTL,则刷新
return -random_early >= ttl_remaining
def get_with_xfetch(cache, key, loader, ttl=1800, beta=1.0):
raw = cache.get(key)
if raw:
entry = json.loads(raw)
now = time.time()
if not should_refresh_early(entry["expire_at"], now, beta):
return entry["data"]
# 概率性触发异步刷新
# 多数请求仍然返回旧数据,只有少数请求触发刷新
data = loader(key)
entry = {"data": data, "expire_at": time.time() + ttl}
cache.set(key, json.dumps(entry))
return data四、多级缓存架构
单层缓存(只用 Redis)在大规模系统中往往不够。网络延迟、Redis 集群的带宽瓶颈、以及极端场景下的可用性要求,都推动了多级缓存架构的出现。
4.1 三级缓存体系
graph TB
Client[客户端] --> CDN[L3: CDN 缓存]
CDN --> Gateway[API 网关]
Gateway --> L1[L1: 进程内缓存<br/>Caffeine / Guava]
L1 --> L2[L2: 分布式缓存<br/>Redis Cluster]
L2 --> DB[(数据库<br/>MySQL / PostgreSQL)]
subgraph 应用节点 A
L1
end
subgraph 应用节点 B
L1b[L1: 进程内缓存<br/>Caffeine / Guava]
end
Gateway --> L1b
L1b --> L2
L2 -.->|失效通知<br/>Redis Pub/Sub| L1
L2 -.->|失效通知<br/>Redis Pub/Sub| L1b
L1:进程内缓存。
数据存在应用进程的堆内存中,访问延迟在纳秒级别。Java 生态中
Caffeine 是事实标准,它基于 W-TinyLFU
淘汰策略,命中率显著优于传统的 LRU。Go 中常用
sync.Map 加上自定义的过期逻辑,或者使用
Ristretto 库。Python 中可以用
functools.lru_cache 或
cachetools。
// Caffeine 本地缓存配置
Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(10_000) // 最多缓存 1 万个条目
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入 5 分钟后过期
.recordStats() // 开启统计
.build();L1 缓存的容量受限于 JVM 堆内存,通常只缓存最热的数据。一万到十万级别的条目是合理的。
L2:分布式缓存。 Redis Cluster 或 Memcached 集群,容量大(几十到几百 GB),所有应用节点共享同一份缓存。访问延迟在毫秒级别(通常 0.5-2ms)。
L3:CDN 缓存。 适用于静态或半静态内容,比如商品图片、CSS/JS 文件、以及可以容忍短暂过期的 API 响应。CDN 节点离用户最近,延迟最低。
4.2 多级缓存的读写流程
public class MultiLevelCacheService {
private final Cache<String, Product> l1Cache; // Caffeine
private final RedisTemplate<String, String> l2Cache; // Redis
private final ProductRepository db;
private final RedisMessageListenerContainer listenerContainer;
public Product getProduct(Long id) {
String key = "product:" + id;
// L1: 进程内缓存
Product product = l1Cache.getIfPresent(key);
if (product != null) {
return product;
}
// L2: Redis
String json = l2Cache.opsForValue().get(key);
if (json != null) {
product = deserialize(json);
l1Cache.put(key, product); // 回填 L1
return product;
}
// L3: 数据库
product = db.findById(id).orElse(null);
if (product != null) {
l2Cache.opsForValue().set(key, serialize(product),
30, TimeUnit.MINUTES);
l1Cache.put(key, product);
}
return product;
}
public void updateProduct(Long id, Product product) {
db.save(product);
String key = "product:" + id;
l2Cache.delete(key);
l1Cache.invalidate(key);
// 通知其他节点失效 L1 缓存
l2Cache.convertAndSend("cache-invalidation", key);
}
}4.3 多级缓存的一致性
多级缓存最大的挑战是一致性。当数据更新时,需要保证所有层级的缓存都能及时失效。
L2(Redis)的失效相对简单,因为所有节点共享同一个 Redis 集群,删除一个 key 所有节点立即可见。但 L1(进程内缓存)是每个应用节点独立持有的,一个节点删除了自己的 L1 缓存,其他节点并不知道。
解决方案是使用 Redis 的 Pub/Sub 机制做失效广播:
// 发布失效消息
public void invalidateAllLevels(String key) {
l2Cache.delete(key);
l1Cache.invalidate(key);
// 广播给所有节点
redisTemplate.convertAndSend("cache:invalidation", key);
}
// 所有节点订阅失效消息
@Component
public class CacheInvalidationListener implements MessageListener {
private final Cache<String, Product> l1Cache;
@Override
public void onMessage(Message message, byte[] pattern) {
String key = new String(message.getBody());
l1Cache.invalidate(key);
}
}这个方案是最终一致的——从数据更新到所有节点的 L1 缓存失效,中间有一个短暂的窗口期(通常几毫秒到几十毫秒)。对于大多数业务场景来说,这个延迟是可以接受的。
五、分布式场景下的缓存一致性
在分布式系统中,缓存一致性是一个”不可能完美解决”的问题。根据 CAP 定理,分布式缓存只能在一致性和可用性之间取一个平衡点。实际工程中,绝大多数系统选择的是最终一致性(Eventual Consistency)。
5.1 延迟双删(Double Delete)
Cache-Aside 模式的标准写流程是”先更新数据库,再删缓存”。但在高并发场景下可能出现如下时序问题:
- 请求 A 读取缓存,未命中
- 请求 A 从数据库读取旧值 V1
- 请求 B 更新数据库为新值 V2
- 请求 B 删除缓存
- 请求 A 将旧值 V1 写入缓存
此时缓存中存的是旧值 V1,数据库中存的是新值 V2,不一致产生了。
延迟双删的解法:在第一次删除缓存之后,延迟一段时间再删一次,把步骤 5 写入的脏数据清除掉。
public void updateProductWithDoubleDelete(Long id, Product product) {
String key = "product:" + id;
// 第一次删除缓存
redisTemplate.delete(key);
// 更新数据库
productRepository.save(product);
// 延迟第二次删除(延迟时间 > 一次读请求的耗时)
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500); // 延迟 500ms
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redisTemplate.delete(key);
});
}延迟时间的选择:应大于一次数据库读取 + 缓存写入的最大耗时。如果数据库查询 P99 耗时是 200ms,缓存写入是 5ms,那延迟 500ms 通常足够覆盖。
5.2 基于 CDC 的缓存失效
延迟双删虽然简单,但延迟时间是”拍脑袋定的”。更可靠的方案是基于 CDC(Change Data Capture)的缓存失效。
graph LR
App[应用服务] -->|写| DB[(MySQL)]
DB -->|binlog| Canal[Canal / Debezium]
Canal -->|变更事件| MQ[Kafka]
MQ -->|消费| Invalidator[缓存失效服务]
Invalidator -->|DEL| Redis[(Redis)]
Invalidator -.->|Pub/Sub| L1[各节点 L1 缓存]
CDC 方案的核心思路:不由应用程序负责删缓存,而是监听数据库的变更日志(MySQL 的 binlog、PostgreSQL 的 WAL),由一个独立的缓存失效服务来统一处理。
# Canal 消费者示例(简化)
from canal.client import Client
def consume_binlog():
client = Client()
client.connect(host="canal-server", port=11111)
client.subscribe(client_id="cache-invalidator", filter="ecommerce\\..*")
while True:
message = client.get(batch_size=100)
for entry in message.entries:
if entry.entry_type == "ROWDATA":
for row in entry.row_changes:
table = entry.header.table_name
if table == "product":
product_id = get_column_value(row, "id")
redis_client.delete(f"product:{product_id}")
# 广播 L1 失效
redis_client.publish(
"cache:invalidation",
f"product:{product_id}"
)
client.ack(message.id)CDC 方案的优势在于:与业务代码完全解耦,不管数据是被哪个服务、哪段代码修改的,只要数据库变了,缓存就会失效。缺点是引入了额外的组件(Canal/Debezium、Kafka),增加了系统的运维复杂度。
5.3 一致性与性能的权衡
| 方案 | 一致性延迟 | 复杂度 | 适用场景 |
|---|---|---|---|
| TTL 自然过期 | TTL 时间(分钟级) | 低 | 数据变更不频繁,容忍过期 |
| 先更新 DB 再删缓存 | 毫秒级(正常情况) | 低 | 大多数 CRUD 场景 |
| 延迟双删 | 延迟时间(百毫秒级) | 中 | 高并发写入场景 |
| CDC 异步失效 | 秒级(取决于 CDC 延迟) | 高 | 多数据源写入,需要解耦 |
| 分布式事务(2PC) | 零(强一致) | 极高 | 金融等强一致场景(极少用于缓存) |
工程建议:大多数场景用”先更新 DB 再删缓存 + TTL 兜底”就够了。只有在确实遇到不一致问题且业务无法容忍时,再升级到延迟双删或 CDC 方案。过度设计的一致性保障反而会引入更多的故障点。
六、Facebook Memcache 论文剖析
2013 年 Facebook 在 NSDI 会议上发表了论文”Scaling Memcache at Facebook”,这是业界关于大规模缓存系统设计最重要的文献之一。当时 Facebook 的 Memcached 集群每秒处理数十亿次请求,服务着超过 10 亿用户。以下拆解其中的关键设计决策。
6.1 问题规模
Facebook 面临的挑战远超普通互联网公司:
- 读写比接近 500:1——绝大多数请求是读取(用户浏览信息流、查看个人主页)
- 单个页面渲染需要从 Memcached 获取数百个缓存项
- 数据中心分布在全球多个区域(Region),需要跨地域一致性
- 必须在数千台 Memcached 服务器上保持线性扩展
6.2 单集群优化(Within a Cluster)
请求合并与批量获取。
一个页面渲染可能需要获取数百个缓存项。Facebook 构建了一个
DAG(有向无环图)调度器来管理这些依赖关系,尽可能将独立的获取请求合并为批量的
MGET 操作,减少网络往返次数。
滑动窗口的 UDP 协议。 对于读请求(GET),Facebook 使用 UDP 而不是 TCP,因为 GET 是幂等的,丢包可以容忍(视为缓存未命中)。这大幅减少了连接数和内核开销。写请求(SET/DELETE)仍然使用 TCP 以保证可靠性。
Lease 机制。 这是论文中最重要的贡献之一。Lease 同时解决了两个问题:
过期数据(Stale Set)问题: 请求 A 读取缓存未命中,去数据库加载数据。在 A 加载数据的过程中,数据被其他请求更新了。如果 A 此时将旧数据写入缓存,就会产生不一致。Lease 的解法是:缓存在返回 miss 时同时颁发一个 lease token。客户端写回缓存时必须带上这个 token。如果在此期间有删除操作(说明数据已更新),lease 会被作废,写回操作被拒绝。
惊群问题(Thundering Herd): 热点 key 失效时,大量请求同时去数据库加载。Lease 的解法是:只给第一个请求颁发 lease,后续请求在短时间内(通常 10ms)不会获得 lease,它们要么等待,要么返回略微过期的数据(stale data)。
客户端 A: GET key -> miss, lease_token=12345
客户端 B: GET key -> miss, 但 lease 已被 A 持有,等待或返回 stale data
客户端 A: 从 DB 加载数据
客户端 A: SET key value lease_token=12345 -> 成功
客户端 B: 重试 GET key -> hit
6.3 跨集群复制(Across Clusters - Regional Pools)
单个集群无法容纳所有数据。Facebook 将 Memcached 服务器划分为多个池(Pool):
- Wildcard Pool: 默认池,存放大多数 key
- Regional Pool: 多个前端集群共享的池,用于存放访问频率低但数据量大的 key,减少内存浪费
- Small Pool: 存放访问极其频繁但数据量小的 key,使用更少但更强的服务器
6.4 跨地域一致性(Across Regions)
Facebook 在多个地域部署了数据中心,一个地域为主(Master Region),其他为从(Replica Region)。数据库的主库在 Master Region,从库通过 MySQL 复制同步到其他 Region。
跨地域的缓存一致性是通过在 MySQL 复制流(replication
stream)中嵌入缓存失效命令来实现的。具体来说:当 Master
Region 的 Web
服务器更新数据库后,会将一个缓存失效标记(invalidation
marker)写入 MySQL 的事务中。当这个事务通过复制流同步到
Replica Region 时,一个名为 mcsqueal
的守护进程会解析复制流,提取出失效标记,然后删除本地 Region
的缓存。
Master Region:
Web Server -> 更新 DB + 删除本地缓存
DB 复制流 -> 携带 invalidation marker
Replica Region:
mcsqueal 守护进程 -> 解析复制流 -> 提取 invalidation -> 删除本地缓存
这个设计保证了即使跨地域延迟较大(几十到几百毫秒),缓存的失效也不会漏掉——因为它搭载在数据库复制流上,只要数据同步到了,缓存失效也就到了。
6.5 Gutter 服务器(故障兜底)
当 Memcached 服务器宕机时,如果直接让流量打到数据库,会造成级联故障。Facebook 的解法是部署了一组 Gutter 服务器——平时空闲的备用 Memcached 实例。当客户端检测到某台 Memcached 不可用时,会将请求转发到 Gutter 服务器。Gutter 服务器存储的数据 TTL 很短(通常几秒),只是作为临时缓冲,给故障恢复争取时间。
6.6 McRouter(连接管理)
随着 Memcached 服务器数量增长到数千台,TCP 连接数成为瓶颈。Facebook 开发了 McRouter 作为 Memcached 的代理层。McRouter 运行在每台 Web 服务器上,负责路由请求到正确的 Memcached 服务器、连接复用和请求批量化。McRouter 后来开源,成为了 Memcached 生态中最成熟的代理方案之一。
6.7 对中小规模系统的启示
Facebook 的方案是为”数十亿请求/秒”的规模设计的,大多数系统不需要这种复杂度。但其中的设计思路是通用的:
- Lease 机制的思路可以用分布式锁(Redis SETNX)简化实现
- Gutter 服务器的思路可以用多级缓存中的 L1 兜底替代
- 复制流嵌入失效标记的思路可以用 Canal/Debezium CDC 方案替代
- 分池策略的思路可以用 Redis 的多个逻辑数据库或不同的 Key 前缀替代
七、工程案例:电商商品目录的多级缓存设计
以下是一个真实电商系统的商品目录缓存架构设计案例。该系统日均 PV 约 2000 万,商品数量约 500 万,商品信息更新频率约每天 10 万次。
7.1 问题背景
最初的架构很简单:应用直接查 MySQL,Redis 做 Cache-Aside 缓存。运行了一年后,遇到三个问题:
- 大促期间缓存雪崩。 运营团队习惯在大促前批量更新商品信息,导致大量缓存同时失效。
- 热点商品缓存击穿。 爆款商品的缓存过期瞬间,数百个并发请求直接打到 MySQL。
- Redis 网络延迟波动。 高峰期 Redis 的 P99 延迟从 1ms 飙升到 20ms,导致商品详情页的整体延迟恶化。
7.2 改造方案
第一层改造:引入本地缓存
在每个应用节点上部署 Caffeine 本地缓存,作为 L1 缓存。配置如下:
@Configuration
public class CacheConfig {
@Bean
public Cache<String, Product> productLocalCache() {
return Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(2, TimeUnit.MINUTES)
.refreshAfterWrite(90, TimeUnit.SECONDS)
.build();
}
@Bean
public Cache<String, ProductDetail> productDetailLocalCache() {
return Caffeine.newBuilder()
.maximumSize(20_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
}
}关键决策:L1 缓存的 TTL 设为 2 分钟,远短于 Redis 的 30 分钟。这是为了在一致性和性能之间取得平衡——即使 L1 和 Redis 不一致,最多持续 2 分钟。
第二层改造:防击穿和防雪崩
public class ProductCacheService {
private final Cache<String, Product> l1;
private final StringRedisTemplate redis;
private final ProductRepository db;
public Product getProduct(Long id) {
String key = "product:" + id;
// L1
Product product = l1.getIfPresent(key);
if (product != null) {
return product;
}
// L2: Redis(带防击穿逻辑)
product = getFromRedisWithProtection(key, id);
if (product != null) {
l1.put(key, product);
}
return product;
}
private Product getFromRedisWithProtection(String key, Long id) {
String json = redis.opsForValue().get(key);
if (json != null) {
return deserialize(json);
}
// 缓存未命中,用分布式锁防击穿
String lockKey = "lock:" + key;
Boolean locked = redis.opsForValue()
.setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 双重检查
json = redis.opsForValue().get(key);
if (json != null) {
return deserialize(json);
}
Product product = db.findById(id).orElse(null);
if (product != null) {
// 防雪崩:TTL 加随机偏移
long ttl = 1800 + ThreadLocalRandom.current().nextLong(300);
redis.opsForValue().set(key,
serialize(product), ttl, TimeUnit.SECONDS);
}
return product;
} finally {
redis.delete(lockKey);
}
} else {
// 没拿到锁,等待后重试(最多重试 3 次)
sleep(50);
String retryJson = redis.opsForValue().get(key);
return retryJson != null ? deserialize(retryJson) : null;
}
}
}第三层改造:CDC 驱动的缓存失效
放弃由业务代码手动删缓存,改用 Canal 监听 MySQL binlog,统一处理缓存失效。
@Component
public class ProductCdcConsumer {
private final StringRedisTemplate redis;
private final Cache<String, Product> l1Cache;
@KafkaListener(topics = "cdc.ecommerce.product")
public void onProductChange(String message) {
CdcEvent event = parseEvent(message);
String key = "product:" + event.getRowId();
// 删除 L2 缓存
redis.delete(key);
// 广播 L1 失效
redis.convertAndSend("cache:invalidation:product", key);
// 如果是热点商品,主动预热
if (isHotProduct(event.getRowId())) {
Product product = loadFromDb(event.getRowId());
if (product != null) {
long ttl = 1800 + ThreadLocalRandom.current().nextLong(300);
redis.opsForValue().set(key, serialize(product),
ttl, TimeUnit.SECONDS);
}
}
}
}7.3 改造效果
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 缓存总命中率 | 95% | 99.5% |
| L1 命中率 | 无 | 85% |
| 商品详情页 P99 延迟 | 45ms | 12ms |
| 大促期间 MySQL QPS 峰值 | 32000 | 3500 |
| 缓存雪崩事故 | 3次/年 | 0 |
L1 缓存拦截了 85% 的请求,使得到达 Redis 的流量降低了一个数量级。Redis 的 P99 延迟也随之下降,因为它承载的请求量减少了。最重要的是,即使 Redis 出现短暂不可用,L1 缓存仍然可以扛住大部分流量,系统不会立即雪崩。
7.4 遗留问题与教训
改造后仍然存在一些问题:
L1 缓存的内存占用。 50000 个商品对象占用了约 200MB 堆内存,在 4GB 堆的 JVM 中占比不小。需要持续监控 GC 频率和停顿时间。
CDC 延迟。 Canal 在高峰期的延迟可能达到 2-3 秒,意味着缓存失效会有相应的延迟。对于价格信息,这个延迟是业务方可以接受的底线。
多机房部署的一致性。 如果应用部署在多个机房,Redis Pub/Sub 的失效广播只能在同一个 Redis 集群内生效。跨机房的 L1 失效需要额外的消息通道(比如跨机房 Kafka)。
八、缓存读写模式选型矩阵
| 维度 | Cache-Aside | Read-Through | Write-Through | Write-Behind | Refresh-Ahead |
|---|---|---|---|---|---|
| 一致性 | 最终一致(TTL 兜底) | 最终一致 | 强一致(单机) | 弱一致 | 最终一致 |
| 读延迟 | 首次命中慢 | 首次命中慢 | 低(缓存优先) | 低 | 极低(几乎无 miss) |
| 写延迟 | 中(DB + 删缓存) | 中 | 高(DB + 写缓存同步) | 极低(只写缓存) | 中 |
| 数据丢失风险 | 无 | 无 | 无 | 有(缓存崩溃前未刷盘的数据) | 无 |
| 实现复杂度 | 低 | 中 | 中 | 高 | 高 |
| 缓存与应用耦合 | 高(应用管理缓存) | 低(缓存自管理) | 低 | 低 | 中 |
| 冷启动问题 | 有 | 有 | 无 | 无 | 可通过预热缓解 |
| 典型场景 | 通用 CRUD 场景 | 需要解耦的微服务 | 读多写少、强一致 | 写密集、容忍丢失 | 热点数据、低延迟要求 |
| 代表实现 | 手动代码 | Guava LoadingCache | DynamoDB DAX | CPU L1/L2 Cache | Caffeine refreshAfterWrite |
选型建议:
- 默认选 Cache-Aside。 简单、可控、业界理解度最高,绝大多数场景足够。
- 需要解耦时选 Read-Through。 缓存层封装数据加载逻辑,应用代码不感知数据源。
- 写入量极大且容忍数据丢失时选 Write-Behind。 比如用户行为日志、浏览记录等。
- 热点数据需要极低延迟时加 Refresh-Ahead。 在 Cache-Aside 基础上叠加主动刷新,消除缓存 miss。
- 不要盲目选 Write-Through。 看似”最一致”,但写延迟的代价在高并发场景下很大,而且跨多个缓存节点时并不能真正保证强一致。
九、缓存架构设计原则
总结一套可操作的设计原则:
1. 缓存是加速层,不是数据层。 缓存随时可能丢失(驱逐、宕机、重启),系统必须能在没有缓存的情况下正常运行,只是慢一些。不要把缓存当成唯一的数据存储。
2. 先设计失效策略,再考虑缓存方案。 缓存写入很简单,难的是”什么时候让它消失”。如果无法定义清晰的失效策略,就不应该引入缓存。
3. 缓存的 TTL 不是越长越好。 TTL 应该匹配业务对数据新鲜度的容忍度。如果业务方说”价格变了 10 秒内必须看到”,那 TTL 就不能超过 10 秒。
4. 热点 key 要单独处理。 20% 的 key 可能承载 80% 的流量。对这些 key 应该使用本地缓存 + 异步刷新 + 防击穿保护的组合方案。
5. 缓存预热不能忽视。 系统重启或发布时,缓存是空的。如果在流量高峰期重启,冷缓存可能直接压垮数据库。必须有预热机制——在接收流量前批量加载热点数据到 Redis 和本地缓存。
6. 监控先于优化。 没有量化数据就不要优化缓存。先把命中率、延迟、内存使用率的监控建好,用数据驱动决策。核心监控指标:L1 命中率目标 > 80%,L2 命中率目标 > 95%,总命中率目标 > 99%。
参考资料
- Nishtala, R. et al. “Scaling Memcache at Facebook.” NSDI 2013. https://www.usenix.org/conference/nsdi13/technical-sessions/presentation/nishtala
- Redis 官方文档. https://redis.io/documentation
- Vattani, A. et al. “Optimal Probabilistic Cache Stampede Prevention.” VLDB 2015.
- Martin Kleppmann. “Designing Data-Intensive Applications.” O’Reilly, 2017. 第五章:复制,第十二章:数据系统的未来.
- Canal GitHub 仓库. https://github.com/alibaba/canal
- Debezium 官方文档. https://debezium.io/documentation/
- Caffeine GitHub 仓库. https://github.com/ben-manes/caffeine
- Facebook McRouter. https://github.com/facebook/mcrouter
上一篇:【系统架构设计百科】无状态设计
下一篇:【系统架构设计百科】数据库扩展
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计百科】架构质量属性:不只是"高可用高性能"
需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。
【系统架构设计百科】告警策略:如何避免"狼来了"
大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。
【系统架构设计百科】复杂性管理:架构的核心战场
系统复杂性是架构腐化的根源——本文从 Brooks 的本质复杂性与偶然复杂性划分出发,结合认知负荷理论与 Parnas 的信息隐藏原则,系统阐述复杂性的来源、度量与控制手段,并给出可操作的架构策略
【系统架构设计百科】微服务架构深度审视:优势、代价与适用边界
微服务不是免费的午餐。本文从分布式系统八大谬误出发,拆解微服务真正解决的问题与引入的代价,梳理服务边界划分的工程方法论,还原 Amazon 和 Netflix 从单体到微服务的真实演进时间线,给出微服务适用与不适用的判断框架。