商品详情页、文章页和配置查询接口通常有一个共同特点:读请求远多于写请求,而且流量会集中到少数热点对象。把所有请求直接交给数据库,平时可能没有问题,一到活动、推送或爬虫集中访问,数据库连接池、CPU 和磁盘 I/O 就可能同时承压。
缓存并不是把数据放进 Redis 就结束了。真正需要设计的是:请求经过哪些层、热点 Key 失效时谁负责回源、大量 Key 同时过期怎么办,以及请求一个不存在的 ID 时如何阻止它反复访问数据库。下面以商品详情为例,给出一套可以迁移到内容站和 API 服务的缓存击穿防护方案。
先按数据特征设计缓存链路
缓存策略应先区分数据的访问量和一致性要求。商品图文描述通常允许短时间延迟;价格、库存和上下架状态则更需要及时更新。把这些字段全部使用同一套 TTL,往往会在性能和一致性之间留下不必要的冲突。
一个常见的读取链路如下:
浏览器或 CDN → 应用本地缓存 → Redis → 数据库- 浏览器/CDN:适合缓存变化不频繁的静态资源和可公开缓存的页面片段。
- 应用本地缓存:保存少量最热的数据,减少每次请求访问 Redis 的网络开销。
- Redis:保存跨实例共享的详情数据,承担大部分缓存命中。
- 数据库:只处理缓存未命中、数据回源和写入。
本地缓存容量和时间应受到限制。多实例部署时,每个实例都有自己的副本,TTL 过长会扩大实例之间的数据差异窗口。Redis 也不能被当作永久数据库使用,需要明确淘汰策略、容量上限和故障时的降级路径。
多级缓存的实现要点
L1 本地缓存只保留热点
可以使用 Caffeine 等进程内缓存保存少量热点商品。命中 L1 时不需要访问网络;未命中再查询 Redis,并把结果回填到本地缓存。示意代码如下:
Cache<String, ProductDetail> localCache = Caffeine.newBuilder()
.maximumSize(5000)
.expireAfterWrite(Duration.ofSeconds(30))
.recordStats()
.build();
ProductDetail getDetail(long skuId) {
String key = "product:detail:" + skuId;
ProductDetail value = localCache.getIfPresent(key);
if (value != null) return value;
value = redis.get(key);
if (value != null) {
localCache.put(key, value);
return value;
}
return loadAndCache(skuId, key);
}这里的 30 秒只是示例,不是通用参数。应结合更新频率、实例数量、对象大小和可接受的不一致时间,通过监控命中率与回源量调整。热点也可以在活动开始前预热,而不是等首个请求触发数据库查询。
L2 Redis 保存共享副本
Redis 的 Key 应包含明确的业务前缀和版本信息,例如 product:detail:v2:10001。对象写入时设置过期时间,并加入小幅随机抖动,避免同一批预热数据在相同时间失效:
import random
def ttl_with_jitter(base_seconds: int) -> int:
return base_seconds + random.randint(0, base_seconds // 5)
redis.set(key, serialize(product), ex=ttl_with_jitter(600))TTL 的作用是回收失效数据,不是数据一致性的全部保障。更新商品时通常采用 Cache Aside:先更新数据库,再删除对应缓存;读取请求发现缓存不存在时从数据库加载并回填。对于并发更新、删除失败和消息重试,还要配合重试、延迟删除或变更消息,避免旧值长期留在缓存中。
缓存击穿:保护单个热点 Key 的回源
缓存击穿指一个访问非常集中的 Key 在某个时刻失效,大量并发请求同时发现缓存为空,随后一起查询数据库。它与缓存雪崩的区别在于:击穿通常针对单个或少数热点 Key,雪崩则是大量 Key 集中失效。
一种常见做法是 Redis 分布式锁加双重检查:
- 先读缓存,命中立即返回。
- 未命中时尝试以
SET key value NX EX seconds获取锁。 - 抢到锁的请求再次检查缓存,确认仍为空后才查询数据库。
- 查询结果写回缓存并释放锁。
- 未抢到锁的请求短暂等待后重新读缓存,并限制重试次数。
伪代码:
def get_hot_product(sku_id):
key = f"product:detail:{sku_id}"
lock_key = f"lock:product:{sku_id}"
cached = redis.get(key)
if cached is not None:
return decode(cached)
token = random_token()
if redis.set(lock_key, token, nx=True, ex=10):
try:
cached = redis.get(key) # 双重检查
if cached is not None:
return decode(cached)
value = query_database(sku_id)
if value is not None:
redis.set(key, encode(value), ex=ttl_with_jitter(600))
return value
finally:
release_lock_if_owner(lock_key, token)
for _ in range(3):
sleep(0.05)
cached = redis.get(key)
if cached is not None:
return decode(cached)
return fallback_or_limited_reload(sku_id)锁必须设置过期时间,防止持锁进程崩溃后造成永久阻塞;释放锁时应校验随机 token,不能无条件 DEL,否则可能误删后来请求持有的新锁。若数据库查询时间可能超过锁 TTL,应设计续租或使用更可靠的锁实现。
对于允许返回短暂旧数据的详情内容,还可以使用逻辑过期:缓存值中记录业务过期时间,过期后先返回旧值,再由后台任务异步刷新。它降低了热点 Key 失效瞬间的等待,但需要接受短时旧数据,并处理刷新任务重复执行和失败重试。
缓存雪崩:避免大量请求同时回源
雪崩有两类常见诱因:大量 Key 设置了相同 TTL,几乎同时过期;或者 Redis 整体不可用,多个服务同时失去缓存层。对应措施应分层处理:
- 打散过期时间:基础 TTL 上增加随机值,批量预热时也不要使用完全相同的过期时间。
- 限制回源并发:为数据库查询设置并发上限、队列或舱壁,避免缓存故障把数据库连接池耗尽。
- 保留可用的本地副本:本地缓存可以作为短时兜底,但要控制容量和旧数据风险。
- 准备降级响应:对非核心字段返回静态摘要、默认状态或稍后重试;价格和库存等关键数据不能无条件使用旧值。
- 监控 Redis 依赖:观察命中率、回源 QPS、连接池等待、命令延迟和内存淘汰,提前发现缓存层退化。
高可用部署能降低 Redis 故障概率,但不能代替应用层限流。故障演练时应分别验证:单节点异常、网络超时、集群不可用和大量 Key 同时过期。
缓存穿透:拦截不存在的数据请求
缓存穿透是请求的数据在数据库中也不存在,因此每次都经历“缓存未命中→查询数据库→仍然为空”。恶意扫描不存在的 ID 时,这条路径会反复消耗数据库资源。
可组合使用两种手段:
空值缓存
查询结果为空时,写入一个短 TTL 的占位值,例如 __NULL__。后续相同请求命中占位值后直接返回,不再访问数据库。空值 TTL 不宜过长,否则新商品刚上线时可能继续被旧占位值挡住。
布隆过滤器
布隆过滤器适合做“是否可能存在”的前置判断:它可以确定某些 ID 不在集合中,但判断为存在时仍需继续查询,因为概率型结构可能产生误判。商品上架、删除和批量导入时要同步维护集合;如果无法可靠同步,就不能把它当作唯一的数据正确性来源。
典型读取顺序是:
参数校验 → 布隆过滤器否定判断 → 空值/正常缓存 → 数据库 → 回填缓存参数范围校验、接口鉴权和访问限流仍然需要保留。布隆过滤器主要减少无效查询,不能替代业务权限检查。
生产落地前的检查清单
- 按字段区分一致性要求,不要给价格、库存和图文描述套用同一个 TTL。
- 为热点 Key 设计互斥回源或逻辑过期,并对等待和重试设置上限。
- 所有锁都设置过期时间,释放时校验持有者身份。
- TTL 加随机抖动,批量预热和批量删除也要避免瞬时尖峰。
- 对不存在的对象使用短 TTL 空值缓存,必要时增加布隆过滤器。
- 为数据库回源设置限流、超时和熔断,Redis 故障时不要让请求无限重试。
- 用压测分别模拟热点 Key 失效、批量过期、Redis 超时和不存在 ID 扫描。
- 监控 L1 命中率、Redis 命中率、数据库回源 QPS、锁等待、缓存延迟和错误率。
结语
高并发读场景的重点不是简单增加缓存,而是让不同类型的风险由不同机制处理:多级缓存减少正常请求到达数据库,互斥回源和逻辑过期处理热点 Key 失效,TTL 抖动与回源限流处理批量失效,空值缓存和布隆过滤器处理不存在数据。
如果你在云服务器上自建 Redis 和数据库,小团队更应先把超时、限流、备份、监控和故障降级补齐,再考虑复杂的缓存拓扑。只有在压测和故障演练中验证回源量确实受控,这套方案才算真正可用。