Skip to content

缓存策略

欢迎来到缓存策略知识库!

💾 缓存简介

缓存是将频繁访问的数据存储在更快的存储介质中,以提高系统性能、减少数据库压力、降低响应时间的技术手段。

🎯 为什么需要缓存

性能对比

存储层级访问时间容量
CPU L1 缓存0.5 nsKB 级
CPU L2 缓存7 nsMB 级
内存100 nsGB 级
SSD150 μsTB 级
机械硬盘10 msTB 级
网络150 ms-

Redis (内存) vs MySQL (磁盘)

  • Redis: 10w+ QPS
  • MySQL: 1k-2k QPS

缓存的好处

  1. 提高性能 - 内存访问速度远超磁盘
  2. 降低数据库负载 - 减少数据库查询
  3. 提升用户体验 - 快速响应
  4. 节省成本 - 减少服务器资源消耗

🎯 缓存类型

1. 本地缓存

存储在应用进程内存中。

优点:

  • 访问速度最快
  • 无网络开销
  • 实现简单

缺点:

  • 不能跨服务器共享
  • 内存受限
  • 缓存更新复杂

适用场景:

  • 配置信息
  • 字典数据
  • 不常变化的数据

实现:

javascript
// Node.js - Memory Cache
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 600 });

// 设置缓存
cache.set('key', 'value', 10000);

// 获取缓存
const value = cache.get('key');

// 删除缓存
cache.del('key');

2. 分布式缓存

存储在独立的缓存服务器。

优点:

  • 多服务器共享
  • 容量大
  • 可扩展

缺点:

  • 网络开销
  • 维护成本高

适用场景:

  • 用户会话
  • 热点数据
  • 分布式系统

实现:

  • Redis
  • Memcached

3. CDN 缓存

内容分发网络,缓存静态资源。

优点:

  • 就近访问
  • 减轻源站压力
  • 加速访问

适用场景:

  • 图片、视频
  • JS、CSS 文件
  • 静态 HTML

4. 浏览器缓存

存储在浏览器中。

实现:

  • HTTP 缓存头 (Cache-Control)
  • LocalStorage / SessionStorage

📋 缓存读写策略

1. Cache-Aside (旁路缓存)

最常用的策略

读取流程

1. 查询缓存
   ├─ 命中 -> 返回数据
   └─ 未命中 -> 2. 查询数据库
                3. 写入缓存
                4. 返回数据

更新流程

1. 更新数据库
2. 删除缓存 (而不是更新缓存)

为什么删除而不是更新?

  • 避免并发问题
  • 减少无效更新(如果更新后没有读取)

代码示例

javascript
// 读取
async function getUser(userId) {
    // 1. 查询缓存
    let user = await redis.get(`user:${userId}`);
    
    if (user) {
        return JSON.parse(user);
    }
    
    // 2. 查询数据库
    user = await db.users.findById(userId);
    
    if (user) {
        // 3. 写入缓存
        await redis.setex(`user:${userId}`, 3600, JSON.stringify(user));
    }
    
    return user;
}

// 更新
async function updateUser(userId, data) {
    // 1. 更新数据库
    await db.users.update(userId, data);
    
    // 2. 删除缓存
    await redis.del(`user:${userId}`);
}

2. Read-Through (读穿透)

缓存层负责读取数据库。

应用 -> 缓存
        └─> 数据库 (缓存未命中时)
javascript
class CacheService {
    async get(key, loader) {
        let value = await redis.get(key);
        
        if (!value) {
            value = await loader();  // 加载器函数
            await redis.setex(key, 3600, value);
        }
        
        return value;
    }
}

// 使用
const user = await cacheService.get(`user:${userId}`, () => {
    return db.users.findById(userId);
});

3. Write-Through (写穿透)

先写缓存,缓存再写数据库。

应用 -> 缓存 -> 数据库

优点: 数据一致性好 缺点: 写入慢

4. Write-Behind (写回)

先写缓存,异步写数据库。

应用 -> 缓存
        └─> 异步 -> 数据库

优点: 写入快 缺点: 可能丢失数据

🚨 缓存问题及解决方案

1. 缓存穿透

问题: 查询不存在的数据,缓存和数据库都没有。

大量请求 key=999999
  ├─> 缓存未命中
  └─> 数据库查询(无数据)
      └─> 每次都查数据库 ❌

解决方案:

方案一:缓存空值

javascript
async function getUser(userId) {
    let user = await redis.get(`user:${userId}`);
    
    if (user === 'null') {
        return null;  // 缓存的空值
    }
    
    if (!user) {
        user = await db.users.findById(userId);
        
        if (user) {
            await redis.setex(`user:${userId}`, 3600, JSON.stringify(user));
        } else {
            // 缓存空值,设置较短过期时间
            await redis.setex(`user:${userId}`, 60, 'null');
        }
    }
    
    return user === 'null' ? null : JSON.parse(user);
}

方案二:布隆过滤器

javascript
const BloomFilter = require('bloom-filters').BloomFilter;

// 初始化布隆过滤器
const bf = BloomFilter.create(10000, 0.01);

// 将所有存在的 ID 加入过滤器
users.forEach(user => bf.add(user.id.toString()));

async function getUser(userId) {
    // 先用布隆过滤器判断
    if (!bf.has(userId.toString())) {
        return null;  // 一定不存在
    }
    
    // 可能存在,继续查询缓存和数据库
    // ...
}

2. 缓存击穿

问题: 热点数据过期,大量请求同时打到数据库。

热点 key 过期
  └─> 大量请求同时查询数据库 ❌

解决方案:

方案一:互斥锁

javascript
async function getUser(userId) {
    let user = await redis.get(`user:${userId}`);
    
    if (!user) {
        const lockKey = `lock:user:${userId}`;
        
        // 尝试获取锁
        const acquired = await redis.set(lockKey, '1', 'EX', 10, 'NX');
        
        if (acquired) {
            try {
                // 获取到锁,查询数据库
                user = await db.users.findById(userId);
                
                if (user) {
                    await redis.setex(`user:${userId}`, 3600, JSON.stringify(user));
                }
            } finally {
                // 释放锁
                await redis.del(lockKey);
            }
        } else {
            // 未获取到锁,等待后重试
            await sleep(50);
            return getUser(userId);
        }
    }
    
    return user ? JSON.parse(user) : null;
}

方案二:永不过期

javascript
// 逻辑过期
const data = {
    value: user,
    expireTime: Date.now() + 3600000  // 1小时后
};

await redis.set(`user:${userId}`, JSON.stringify(data));

// 读取时判断
async function getUser(userId) {
    const dataStr = await redis.get(`user:${userId}`);
    
    if (!dataStr) {
        // 缓存miss,查询数据库
        return loadFromDB(userId);
    }
    
    const data = JSON.parse(dataStr);
    
    if (Date.now() > data.expireTime) {
        // 逻辑过期,异步刷新
        refreshCache(userId);  // 异步执行,不阻塞
    }
    
    return data.value;
}

3. 缓存雪崩

问题: 大量缓存同时过期,请求全部打到数据库。

大量 key 同时过期 (如都是 12:00 过期)
  └─> 数据库压力激增 ❌

解决方案:

方案一:过期时间随机化

javascript
// 避免同时过期
const baseExpire = 3600;
const randomExpire = Math.floor(Math.random() * 300);  // 0-5分钟
const expire = baseExpire + randomExpire;

await redis.setex(`user:${userId}`, expire, JSON.stringify(user));

方案二:多级缓存

javascript
// L1: 本地缓存 (1分钟)
// L2: Redis (1小时)
// L3: 数据库

async function getUser(userId) {
    // 1. 查询本地缓存
    let user = localCache.get(`user:${userId}`);
    if (user) return user;
    
    // 2. 查询 Redis
    user = await redis.get(`user:${userId}`);
    if (user) {
        localCache.set(`user:${userId}`, user, 60);
        return JSON.parse(user);
    }
    
    // 3. 查询数据库
    user = await db.users.findById(userId);
    if (user) {
        await redis.setex(`user:${userId}`, 3600, JSON.stringify(user));
        localCache.set(`user:${userId}`, user, 60);
    }
    
    return user;
}

方案三:Redis 集群 + 持久化

  • 主从复制 + 哨兵
  • Redis 集群
  • 开启持久化(RDB + AOF)

4. 缓存一致性

问题: 缓存和数据库数据不一致。

解决方案:

方案一:先更新数据库,再删除缓存

javascript
async function updateUser(userId, data) {
    // 1. 更新数据库
    await db.users.update(userId, data);
    
    // 2. 删除缓存
    await redis.del(`user:${userId}`);
}

方案二:延迟双删

javascript
async function updateUser(userId, data) {
    // 1. 删除缓存
    await redis.del(`user:${userId}`);
    
    // 2. 更新数据库
    await db.users.update(userId, data);
    
    // 3. 延迟后再删除缓存
    setTimeout(async () => {
        await redis.del(`user:${userId}`);
    }, 1000);
}

方案三:Canal 监听 binlog

MySQL binlog -> Canal -> 删除缓存

💡 最佳实践

1. 缓存设计

javascript
// ✅ 好的缓存键设计
const key = `user:${userId}:profile`;
const key = `article:${articleId}:views`;

// ❌ 不好的缓存键设计
const key = userId;  // 太简单,可能冲突
const key = `user_${userId}_profile_data`;  // 使用下划线,不规范

2. 设置合理的过期时间

javascript
// 根据数据特点设置
await redis.setex('config', 86400, data);        // 配置:1天
await redis.setex('user:123', 3600, user);       // 用户:1小时
await redis.setex('article:456', 600, article);  // 文章:10分钟
await redis.setex('hotspot', 60, data);          // 热点:1分钟

3. 序列化

javascript
// JSON 序列化
await redis.set('user:123', JSON.stringify(user));
const user = JSON.parse(await redis.get('user:123'));

// 压缩(大对象)
const compressed = zlib.gzipSync(JSON.stringify(data));
await redis.set('large:data', compressed);

4. 监控告警

  • 命中率 - 高于 80% 为宜
  • 内存使用率 - 低于 80%
  • 慢查询 - 监控慢查询
  • 过期key数量 - 定期清理

📖 学习资源

推荐文章

推荐书籍

  • 《Redis 设计与实现》
  • 《Redis 深度历险》

准备好了吗?开始优化你的系统缓存!