网站越做越大,数据库压力随之攀升。一篇热门文章可能瞬间带来上千次查询,每次都走数据库,再强的服务器也扛不住。缓存的本质是用内存换时间——把热点数据放到访问更快的介质里,避免重复计算与查询。但缓存用不好也会带来数据不一致、缓存雪崩等问题。本篇梳理尧图项目里沉淀的缓存实战经验。
一、多级缓存架构
缓存不是单一组件,而是一个分层体系。尧图常用的四层缓存从近到远依次是:浏览器缓存、CDN 缓存、本地缓存、分布式缓存。请求每往上一层,延迟更低但容量更小;每往下一层,容量更大但延迟更高。合理利用每一层,才能在性能与成本间取得平衡。
- 浏览器缓存:通过 Cache-Control、ETag 让静态资源直接走本地,零网络消耗。
- CDN 缓存:把静态资源推到边缘节点,用户就近获取,降低源站压力。
- 本地缓存:应用进程内的内存(如 Map),纳秒级访问,适合极热点数据。
- 分布式缓存:Redis 等独立缓存服务,跨进程共享,容量大、可持久化。
本地缓存与分布式缓存的取舍在于:本地缓存最快但无法跨实例共享,多实例部署时数据会重复;分布式缓存可共享但多一跳网络开销。尧图的做法是"本地缓存做一级、Redis 做二级",本地只缓存几十条最热数据并设短过期,其余走 Redis,兼顾速度与一致性。
二、缓存读写策略
缓存不是"加了就完事",怎么读、怎么写、怎么更新都有讲究。最常用的是 Cache Aside 模式:读时先查缓存,命中则返回;未命中查数据库,回填缓存再返回。写时先更新数据库,再删除缓存(而非更新缓存,避免并发写导致脏数据)。
// Cache Aside 读流程
function getData(key) {
let value = cache.get(key);
if (value !== null) {
return value; // 命中缓存
}
value = db.query(key); // 回源数据库
if (value) {
cache.set(key, value, 3600); // 缓存1小时
}
return value;
}
// Cache Aside 写流程
function updateData(key, newValue) {
db.update(key, newValue); // 先更新数据库
cache.delete(key); // 再删除缓存(下次读会回填)
}
为什么写操作是"删缓存"而不是"更新缓存"?因为在并发场景下,两个写请求交叉执行时,更新缓存可能把旧值覆盖新值,造成数据库与缓存不一致。而删除缓存是幂等操作,下次读时自然会从数据库加载最新值,更安全。此外缓存要设过期时间作为兜底,即使删除失败,过期后也会自动失效。
三、穿透、击穿、雪崩防护
缓存三大经典问题必须提前防御。穿透是指查询根本不存在的数据,每次都绕过缓存打数据库;击穿是指单个热点 key 过期瞬间,大量请求同时回源;雪崩是指大量 key 同时过期,数据库瞬间过载。
// 穿透防护:缓存空值 + 布隆过滤器
function getData(key) {
let value = cache.get(key);
if (value !== null) return value === 'NULL' ? null : value;
if (!bloomFilter.mightContain(key)) return null; // 不存在直接拦截
value = db.query(key);
if (value) {
cache.set(key, value, 3600);
} else {
cache.set(key, 'NULL', 60); // 空值短缓存,防穿透
}
return value;
}
// 击穿防护:互斥锁,只放一个请求回源
function getHotData(key) {
let value = cache.get(key);
if (value !== null) return value;
if (lock.tryLock(key, 3)) { // 抢锁成功
try {
value = db.query(key);
cache.set(key, value, 3600);
} finally {
lock.unlock(key);
}
return value;
}
sleep(50); // 抢锁失败稍等重试
return getHotData(key);
}
雪崩的防护则更简单:给过期时间加随机偏移量(如 3600 + random(0, 300)),避免大量 key 在同一时刻集体失效。同时做熔断降级,数据库压力过大时直接返回默认值或友好提示,保护系统不被拖垮。这些防护策略在尧图多个高流量站点稳定运行,足以应对企业官网常见的流量波动。