如何正确使用缓存:常见陷阱与最佳实践

前言

缓存(Redis/Memcache)是提升系统性能的利器,但错误的使用方式不仅无法带来性能提升,反而会引入更多问题。本文通过真实案例和性能基准测试,帮助初级开发者理解缓存使用的常见误区和正确姿势。

一、常见的缓存误用模式

1.1 错误案例:直接缓存分页结果

很多新手开发者会直接缓存分页查询的结果,例如:

go 复制代码
// ❌ 错误做法
func GetUserFollowers(userID int64, page, pageSize int) ([]User, error) {
    cacheKey := fmt.Sprintf("followers:%d:%d:%d", userID, page, pageSize)
    
    // 尝试从缓存获取
    if cached := cache.Get(cacheKey); cached != nil {
        return cached, nil
    }
    
    // 缓存未命中,查询数据库
    followers := db.Query("SELECT * FROM users WHERE ... LIMIT ? OFFSET ?", 
                          pageSize, page*pageSize)
    
    // 将整页结果缓存
    cache.Set(cacheKey, followers, 3600)
    return followers, nil
}

这种做法有什么问题?

1.2 核心问题:内存爆炸和一致性噩梦

假设一个用户有 20,000 个粉丝,客户端可能以不同的分页参数请求:

复制代码
followers:123:1:20   → 第1页,每页20条
followers:123:2:20   → 第2页,每页20条
followers:123:1:50   → 第1页,每页50条
followers:123:1:100  → 第1页,每页100条
...
问题1:内存成本不可控(致命)

内存是有限的、昂贵的资源。

复制代码
计算:20,000个粉丝,每个用户数据500字节

可能的分页组合:
- page: 1-1000(深度分页)
- pageSize: 10, 20, 50, 100
- 不同排序: 关注时间、活跃度

保守估计100种组合:
100 × 20条/页 × 500字节 = 1MB(只是第1页的各种变体)
100 × 1000页 × 20条 × 500字节 = 1GB(所有分页组合)

一个用户的粉丝列表就能占用GB级缓存!
10万用户 × 1GB = 100TB缓存 ← 完全不可行

内存成本对比:

方案 单用户查询 1万人查询 10万人查询
缓存列表 ~1GB 10TB 100TB ❌
缓存实体(ID索引) ~10MB ~10MB ~10MB ✓
缓存实体(User对象共享) 取决于系统总用户数,不是查询用户数

关键区别:

  • 缓存列表:内存随"查询的用户数"线性增长(10万人查 = 100TB)
  • 缓存实体 :内存只和"系统中的实体总数"有关,与查询人数无关
    • 假设系统有 100万个用户实体,每个 500字节
    • 总内存 = 100万 × 500字节 = 500MB(固定)
    • 无论1个人查还是10万人查,User实体都是共享的

实体共享示例:

复制代码
User#5 的数据在Redis中只存1份:
user:5 → {"id":5, "name":"Alice", ...}  (500字节)

但被N个列表引用:
- follower_ids:123 → [..., 5, ...]  (User A的粉丝)
- follower_ids:456 → [..., 5, ...]  (User B的粉丝)
- following_ids:789 → [..., 5, ...]  (User C的关注)
- recommend:999 → [..., 5, ...]     (推荐列表)

→ 1份数据,无数次复用!

这就是实体缓存的核心优势:内存占用可预测且可控。

问题2:数据一致性无法保证(更致命)

这是更严重的问题:当数据更新时,你不知道该清理哪些缓存键。

场景:User#5 修改了昵称

复制代码
需要清理的缓存(你能找全吗?):
followers:123:1:20     ← User#5在这里吗?
followers:123:1:50     ← User#5在这里吗?
followers:123:2:20     ← User#5在这里吗?
followers:456:1:20     ← User#5是456的粉丝吗?
following:789:3:50     ← User#5关注了789吗?
recommend_users:100:1:20  ← 推荐列表里有User#5吗?
search_result:query1:1:20 ← 搜索结果有User#5吗?
... 成百上千个可能包含User#5的列表

你无法精确知道User#5出现在哪些列表缓存中!

三种错误的"解决"方案:

go 复制代码
// 方案A:全部清空(❌ 缓存雪崩)
cache.FlushAll()  // 所有用户的所有缓存都失效,数据库瞬间被打垮

// 方案B:模糊删除(❌ 性能差 + 不安全)
cache.DeletePattern("followers:*")  // KEYS命令会阻塞Redis
cache.DeletePattern("following:*")
cache.DeletePattern("recommend_*")
// 1. KEYS * 在生产环境禁止使用(阻塞所有操作)
// 2. 仍然会漏掉一些缓存(如search_result)

// 方案C:维护反向索引(❌ 复杂度爆炸)
// 维护一个映射:user:5 → [所有包含user5的缓存键]
cache.Set("user:5:in_lists", [
    "followers:123:1:20",
    "followers:123:2:20",
    ...  // 需要实时维护这个列表,工程噩梦
])

真实案例:

复制代码
某电商网站缓存商品列表:
- 首页推荐:cached
- 分类列表:cached  
- 搜索结果:cached
- 促销活动:cached

某商品修改价格后:
- 忘记清理搜索结果缓存 → 用户搜索看到旧价格
- 忘记清理活动页缓存 → 下单时发现价格不一致
- 投诉、退款、业务损失

最后不得不:
1. 定时全量刷新缓存(浪费资源)
2. 缩短TTL到5分钟(缓存效果大打折扣)
3. 接受数据不一致(损害用户体验)

为什么缓存实体可以解决这个问题?

复制代码
当User#5修改昵称:
只需删除: user:5  ← 只有1个键!

所有引用User#5的列表:
- followers:123 → 存的是ID列表 [1,2,3,5,...],不需要清理
- 下次查询时会自动获取最新的 user:5 数据

数据一致性得到保证,维护成本极低。

1.3 图示:缓存键爆炸

复制代码
用户123的粉丝列表(20,000人)

错误的缓存结构:
┌─────────────────────────────────────────┐
│  Cache                                  │
├─────────────────────────────────────────┤
│  followers:123:1:20  → [User1..User20]  │  ← 重复存储User1-20
│  followers:123:1:50  → [User1..User50]  │  ← 重复存储User1-20
│  followers:123:2:20  → [User21..User40] │
│  followers:123:1:100 → [User1..User100] │  ← 重复存储User1-20
│  ... 数百个类似的键                      │
└─────────────────────────────────────────┘

当User1更新资料时,需要清理所有包含User1的缓存键!

二、正确的缓存策略

2.1 原则:缓存实体,而非查询结果

正确的做法是缓存最小粒度的实体,然后在应用层组装。

go 复制代码
// ✅ 正确做法
type FollowerService struct {
    cache Cache
    db    Database
}

// 缓存粉丝ID列表(索引) - 使用Redis List
func (s *FollowerService) cacheFollowerIDs(userID int64) error {
    cacheKey := fmt.Sprintf("follower_ids:%d", userID)
    
    // 查询所有粉丝ID
    ids := s.db.Query("SELECT follower_id FROM follows WHERE user_id = ? ORDER BY created_at DESC", userID)
    
    // 存为Redis List(而非JSON)
    s.cache.Del(cacheKey)
    s.cache.RPush(cacheKey, ids...)  // 批量插入
    s.cache.Expire(cacheKey, 3600)
    return nil
}

// 直接获取指定范围的粉丝ID
func (s *FollowerService) getFollowerIDRange(userID int64, start, end int) ([]int64, error) {
    cacheKey := fmt.Sprintf("follower_ids:%d", userID)
    
    // 使用LRANGE直接获取范围内的ID(不需要全部加载!)
    ids := s.cache.LRange(cacheKey, start, end)
    
    if len(ids) == 0 {
        // 缓存未命中,初始化缓存
        s.cacheFollowerIDs(userID)
        ids = s.cache.LRange(cacheKey, start, end)
    }
    
    return ids, nil
}

// 批量获取用户信息(使用缓存)
func (s *FollowerService) getUsersByIDs(ids []int64) ([]User, error) {
    if len(ids) == 0 {
        return []User{}, nil
    }
    
    // 1. 批量构造缓存键
    cacheKeys := make([]string, len(ids))
    for i, id := range ids {
        cacheKeys[i] = fmt.Sprintf("user:%d", id)
    }
    
    // 2. 使用 MGET 批量获取缓存(单次网络往返!)
    cachedValues := s.cache.MGet(cacheKeys...)
    
    // 3. 解析缓存结果,收集未命中的ID
    cached := make(map[int64]User)
    missingIDs := make([]int64, 0)
    
    for i, val := range cachedValues {
        if val != nil {
            var user User
            json.Unmarshal([]byte(val.(string)), &user)
            cached[ids[i]] = user
        } else {
            missingIDs = append(missingIDs, ids[i])
        }
    }
    
    // 4. 批量查询缓存未命中的用户
    if len(missingIDs) > 0 {
        dbUsers := s.db.Query("SELECT * FROM users WHERE id IN (?)", missingIDs)
        
        // 批量回写缓存(使用Pipeline)
        pipe := s.cache.Pipeline()
        for _, user := range dbUsers {
            cached[user.ID] = user
            data, _ := json.Marshal(user)
            pipe.Set(ctx, fmt.Sprintf("user:%d", user.ID), data, 3600*time.Second)
        }
        pipe.Exec(ctx)  // 一次性执行所有SET
    }
    
    // 5. 按原始ID顺序返回结果
    users := make([]User, 0, len(ids))
    for _, id := range ids {
        if user, ok := cached[id]; ok {
            users = append(users, user)
        }
    }
    
    return users, nil
}

// 分页获取粉丝(应用层组装)
func (s *FollowerService) GetFollowers(userID int64, page, pageSize int) ([]User, error) {
    // 1. 计算范围
    start := page * pageSize
    end := start + pageSize - 1
    
    // 2. 直接获取这一页的粉丝ID(使用LRANGE,不加载全部!)
    pageIDs, err := s.getFollowerIDRange(userID, start, end)
    if err != nil {
        return nil, err
    }
    
    // 3. 批量获取用户信息(利用实体缓存)
    return s.getUsersByIDs(pageIDs)
}

2.2 性能陷阱:JSON反序列化的隐藏成本

在实现索引+实体方案时,我们遇到了一个严重的性能问题。

❌ 错误的实现(初版):

go 复制代码
// 将索引存为JSON
func cacheIndex(userID string, ids []string) {
    data, _ := json.Marshal(ids)  // 10,000个ID序列化成JSON
    redis.Set("follower_ids:"+userID, data)
}

func getFollowers(userID string, page, size int) {
    // 每次请求都这样做:
    data := redis.Get("follower_ids:" + userID)
    var allIDs []string
    json.Unmarshal(data, &allIDs)  // ← 反序列化10,000个ID!
    
    // 然后切片
    start := page * size
    pageIDs := allIDs[start:start+size]
    
    return getUsersByIDs(pageIDs)
}

性能测试结果:

复制代码
平均延迟: 5.0ms  ← 太慢了!

问题分析:

复制代码
每次查询第1页(20个用户):
1. Redis GET: 几百KB的JSON数据 → 100µs
2. JSON反序列化10,000个ID → 4ms ❌ 瓶颈!
3. 应用层切片获取20个ID → 1µs
4. Redis MGET 20个用户 → 300µs
总计:5ms

只需要20个ID,却反序列化了10,000个!

✅ 正确的实现(优化后):

go 复制代码
// 使用Redis List存储索引
func cacheIndex(userID string, ids []string) {
    redis.Del("follower_ids:" + userID)
    redis.RPush("follower_ids:"+userID, ids...)  // 存为List
}

func getFollowers(userID string, page, size int) {
    // 直接获取需要的范围
    start := page * size
    end := start + size - 1
    
    pageIDs := redis.LRange("follower_ids:"+userID, start, end)  // ← 只获取20个!
    
    return getUsersByIDs(pageIDs)
}

优化后的性能:

复制代码
平均延迟: 518µs  ← 提升了10倍!

为什么快了?

复制代码
查询第1页(20个用户):
1. Redis LRANGE 0-19: 只返回20个ID → 100µs ✓
2. 无需反序列化 → 0µs ✓
3. Redis MGET 20个用户 → 300µs
4. 应用层组装 → 100µs
总计:518µs

经验总结:

场景 错误做法 正确做法 性能差异
大数组索引 JSON序列化全部 Redis List + LRANGE 10倍
有序集合 JSON数组 Redis ZSet + ZRANGE 类似
无序集合 JSON数组 Redis Set + SRANDMEMBER 类似

核心原则:

  1. ❌ 不要用JSON存储大数组/大集合
  2. ✅ 优先使用Redis原生数据结构(List/Set/ZSet)
  3. ✅ 只取需要的数据,不要"全部加载再切片"

Redis vs Memcache:

这个案例也说明了为什么 Redis 比 Memcache 更适合现代应用

特性 Memcache Redis
数据结构 只有 String(必须序列化) List/Set/ZSet/Hash/String
范围查询 ❌ 必须全部读取再切片 ✓ LRANGE/ZRANGE 直接获取
内存效率 低(重复序列化) 高(原生数据结构)
性能 慢(JSON反序列化开销) 快(无需反序列化)

Memcache的困境:

go 复制代码
// Memcache只能这样做:
data := memcache.Get("follower_ids:123")
var allIDs []int64
json.Unmarshal(data, &allIDs)  // ← 必须反序列化全部!
pageIDs := allIDs[start:end]   // ← 然后切片

Redis的优势:

go 复制代码
// Redis可以直接获取范围
pageIDs := redis.LRange("follower_ids:123", start, end)  // ← 一步到位!

结论:对于列表、集合、排行榜等场景,优先选择Redis。

2.3 关键优化:使用 MGET 批量查询

❌ 常见错误:循环查询Redis

go 复制代码
// 错误示例:N次网络往返
for _, id := range userIDs {
    key := fmt.Sprintf("user:%d", id)
    user := cache.Get(key)  // 每次都是一次网络请求!
    users = append(users, user)
}

性能影响:

  • 假设查询100个用户,每次Redis往返 0.5ms
  • 总耗时:100 × 0.5ms = 50ms
  • 对于本地Redis可能还好,但跨机房网络往返会更慢

✅ 正确做法:使用 MGET

go 复制代码
// 一次网络往返获取所有数据
keys := []string{"user:1", "user:2", ..., "user:100"}
values := cache.MGet(keys...)  // 单次网络请求!
// 总耗时:1 × 0.5ms = 0.5ms

性能对比:

查询方式 网络往返 延迟(100个key) 适用场景
循环 GET N次 ~50ms (本地), >500ms (跨机房) ❌ 永远不要用
MGET 1次 ~0.5ms (本地), ~5ms (跨机房) ✅ 批量查询必选

实际项目中的差异:

复制代码
查询20个粉丝信息:
循环GET:  20 × 0.5ms = 10ms
MGET:     1 × 0.5ms  = 0.5ms   ← 快20倍!

查询100个推荐用户(跨机房):
循环GET:  100 × 5ms = 500ms   ← 接口超时风险
MGET:     1 × 5ms   = 5ms     ← 可接受

重要提醒:

  • Redis的 MGET / MSET 是原子操作
  • Memcache 使用 GetMulti
  • Pipeline 也能减少往返,但不如MGET方便

2.4 优化后的缓存结构

复制代码
正确的缓存结构:
┌──────────────────────────────────────┐
│  Cache                               │
├──────────────────────────────────────┤
│  follower_ids:123 → [1,2,3,...20000]│  ← 只存一次ID列表
│                                      │
│  user:1 → {id:1, name:"Alice", ...} │  ← 实体级缓存
│  user:2 → {id:2, name:"Bob", ...}   │
│  user:3 → {id:3, name:"Charlie",...}│
│  ...                                 │
└──────────────────────────────────────┘

优势:
1. 任何分页参数都能复用同一份ID索引
2. 用户实体在多处使用(关注列表、推荐、搜索)都能命中
3. 更新User1资料时,只需删除 user:1 这一个键

2.5 图示:请求流程对比

复制代码
错误做法(直接缓存分页结果):
┌──────┐    ┌───────┐    ┌──────────┐
│Client│───→│ Cache │───→│ Database │
└──────┘    └───────┘    └──────────┘
   │            │              │
   │ page=1,20  │              │
   ├───────────→│ MISS         │
   │            ├─────────────→│ SELECT * ... LIMIT 20 OFFSET 0
   │            │←─────────────┤
   │←───────────┤              │
   │            │              │
   │ page=1,50  │              │
   ├───────────→│ MISS ❌      │
   │            ├─────────────→│ SELECT * ... LIMIT 50 OFFSET 0
                     (重复查询相同数据)


正确做法(索引+实体):
┌──────┐    ┌───────┐    ┌──────────┐
│Client│───→│ Cache │───→│ Database │
└──────┘    └───────┘    └──────────┘
   │            │              │
   │ page=1,20  │              │
   ├───────────→│ 获取ID列表   │
   │            │ (1次查询)    │
   │            ├─────────────→│ SELECT id FROM follows
   │            │←─────────────┤
   │            │              │
   │            │ 批量获取用户 │
   │            │ (20个实体)   │
   │            │ HIT: 18个 ✓  │
   │            ├─────────────→│ SELECT * WHERE id IN (2个)
   │            │←─────────────┤
   │←───────────┤              │
   │            │              │
   │ page=1,50  │              │
   ├───────────→│ 获取ID列表   │
   │            │ HIT ✓        │(复用索引)
   │            │ 批量获取用户 │
   │            │ HIT: 48个 ✓  │(复用之前的实体)
   │            ├─────────────→│ SELECT * WHERE id IN (2个)

三、性能基准测试

为了验证不同缓存策略的性能差异,我们实现了真实的基准测试。

3.1 测试场景

核心设计:模拟多种列表场景,体现实体复用的价值

  • 数据规模:20,000个用户,3个测试用户各有10,000个粉丝
  • 用户重叠 :50%的粉丝在不同用户间重叠(模拟真实社交网络)
    • User1的粉丝:user 0-9,999
    • User2的粉丝:user 5,000-14,999(与User1有5,000重叠)
    • User3的粉丝:user 7,500-17,499(与User2有5,000重叠)
  • 请求负载:9,000次混合请求(每个用户3,000次)
  • 请求分布
    • 72% 第1页请求(模拟热点数据)
    • 28% 深度分页请求(page 2-122)
    • 分页大小随机:20, 40, 60条
  • 测试环境:PostgreSQL 18(Docker本地)+ Redis 7(Docker本地)

为什么这样设计?

在真实场景中,同一个用户会出现在多个不同的列表中:

  • 张三的粉丝列表
  • 李四的粉丝列表
  • 推荐用户列表
  • 搜索结果列表

策略2(朴素缓存) :每个列表都缓存一份完整用户数据 → 重复N倍
策略3(优化缓存):用户实体只存1份,被所有列表共享 → 零重复

这个测试通过3个用户的粉丝列表(50%重叠),模拟了真实场景的数据复用。

3.2 三种策略对比

策略1:无缓存(基线)

直接查询数据库,每次执行 JOIN 查询。

策略2:朴素缓存(错误做法)

缓存整页结果,Key格式:followers:uid:page:size

策略3:优化缓存(正确做法)

缓存粉丝ID索引 + 用户实体,应用层组装分页。

3.3 测试结果

📊 真实环境测试(PostgreSQL + Redis):

运行环境:

  • 数据库:PostgreSQL 18(Docker)

  • 缓存:Redis 7(Docker)

  • 测试数据:20,000个用户,3个测试用户(模拟多种列表场景)

  • 测试负载:9,000次混合请求

    策略1: 无缓存
    平均延迟: 4.3ms
    P95 延迟: 5.2ms
    P99 延迟: 6.0ms
    数据库查询次数: 9,000次 (每次请求1次完整JOIN)
    Redis内存占用: 1.2 MB (baseline)
    缓存键数量: 1

    策略2: 朴素缓存(缓存整页结果)
    平均延迟: 198µs ← 比无缓存快22倍!
    P95 延迟: 253µs
    P99 延迟: 306µs
    数据库查询次数: 1,008次 (缓存命中率89%)
    Redis内存占用: 6.0 MB ← 实测
    缓存键数量: 1,008个列表页

    策略3: 优化缓存(索引+实体)
    平均延迟: 518µs ← 比朴素缓存慢2.6倍,但合理
    P95 延迟: 637µs
    P99 延迟: 731µs
    索引加载次数: 3次(3个用户)
    用户批量查询: 323次
    数据库查询次数: 326次
    Redis内存占用: 6.0 MB ← 与策略2相同
    缓存键数量: 14,643 (3个索引 + 约14,640个用户实体)

    复制代码
    优化关键:
    - 使用 Redis List 存储索引(而非JSON)
    - 使用 LRANGE 直接获取范围内的ID
    - 避免反序列化完整索引的开销

关键发现:

策略 平均延迟 P99延迟 内存占用 缓存键数 适用场景
无缓存 4.5ms 6.8ms 1.2 MB 0 -
朴素缓存 200µs ✓ 312µs ✓ 6.0 MB 1,008 热点集中
优化缓存 518µs 731µs 6.0 MB 14,643 通用场景 ✓

为什么两者内存占用相同(都是6.0 MB)?

在当前测试场景中,由于存在大量深度分页和多个用户列表:

  • 策略2:缓存了1,008个列表页,包含重复的用户数据
  • 策略3:缓存了14,643个键(3个索引 + 约14,640个用户实体)

虽然策略3缓存键更多,但由于实体去重,总内存占用与策略2相同。

3.4 结果分析

为什么策略3比策略2慢2.6倍?

复制代码
策略2(朴素缓存)的操作:
1. Redis GET 完整页面(20-50个用户) → 200µs

策略3(优化缓存)的操作:
1. Redis LRANGE 获取ID范围 → ~100µs
2. Redis MGET 批量获取用户 → ~300µs  
3. 应用层组装数据 → ~118µs
总计:518µs(2.6倍开销是合理的)

多出来的开销主要在于:
- 多一次Redis调用(LRANGE获取索引)
- 应用层需要组装数据(按ID顺序重排)

策略3的核心优势:实体复用

在当前测试中,3个用户的粉丝有50%重叠:

  • 策略2:缓存了1,008个列表页,包含大量重复用户数据
  • 策略3:缓存了约14,640个用户实体,但同一用户只存1份

关键对比:

维度 朴素缓存 优化缓存 说明
延迟 200µs ✓ 518µs 多1-2次Redis调用
内存 6.0 MB 6.0 MB 当前相同,但...
实体复用 重复存储 ❌ 零重复 ✓ 同一User在多个列表中共享
数据一致性 无法精确失效 ❌ 删1个key即可 ✓
扩展性 新列表需重建 ❌ 复用已有实体 ✓

真实场景模拟:多种列表类型

假设系统需要支持5种不同的列表:

  • 用户的粉丝列表
  • 用户的关注列表
  • 推荐用户列表
  • 搜索结果列表
  • 附近的人列表

策略2的内存爆炸:

复制代码
每种列表 × 每种分页组合 × 每个用户 = 缓存键数量

5种列表 × 200种分页组合 × 10万用户 = 1亿个缓存键
内存占用:不可控,可能达到TB级

策略3的内存可控:

复制代码
用户实体总数(固定) + 列表索引

100万用户 × 500字节 = 500MB(用户实体,共享)
10万个索引 × 5KB = 500MB(各种列表索引)
总计:1GB(可预测)

为什么策略3在生产环境中更优?

  1. 实体复用:同一个User在多个列表中只存1份
  2. 内存可控:只看实体总数,不看列表数量
  3. 易于维护:更新User只删1个key,所有列表自动更新
  4. 扩展性强:添加新列表类型,复用已有实体缓存

3.5 如何选择缓存策略?

决策树:

复制代码
你的系统有多种列表类型吗?(粉丝、关注、推荐、搜索...)
  ├─ 是 → 策略3(优化缓存)✓ 必选
  │      理由:避免实体重复,内存成本可控
  │
  └─ 否:只有一种列表
       │
       └─ 数据会频繁更新吗?(用户修改资料、新增删除关系)
            ├─ 是 → 策略3(优化缓存)✓ 必选
            │      理由:数据一致性易于维护
            │
            └─ 否:数据几乎不变
                 └─ 策略2(朴素缓存)可接受
                    但长期仍建议策略3

推荐:生产环境 95% 的场景应该使用策略3

唯一适合策略2的场景:

  • 只有一种固定的列表类型
  • 数据几乎不变(如静态排行榜)
  • 用户只看第1页(如首页推荐)

四、缓存穿透与雪崩

4.1 缓存穿透

定义:查询一个数据库中不存在的数据,导致每次请求都打到数据库。

场景:攻击者恶意查询不存在的用户ID。

go 复制代码
// 问题代码
func GetUser(id int64) (*User, error) {
    cacheKey := fmt.Sprintf("user:%d", id)
    if cached := cache.Get(cacheKey); cached != nil {
        return cached.(*User), nil
    }
    
    user := db.QueryOne("SELECT * FROM users WHERE id = ?", id)
    if user == nil {
        return nil, nil  // ❌ 不存在的用户不缓存
    }
    
    cache.Set(cacheKey, user, 3600)
    return user, nil
}

解决方案:缓存空值

go 复制代码
// ✅ 缓存空结果,但设置较短的过期时间
const NULL_CACHE_MARKER = "__NULL__"  // 空值标记

func GetUser(id int64) (*User, error) {
    cacheKey := fmt.Sprintf("user:%d", id)
    cached, err := cache.Get(cacheKey).Result()
    
    if err == nil {
        // 缓存命中
        if cached == NULL_CACHE_MARKER {
            return nil, nil  // 明确知道不存在
        }
        var user User
        json.Unmarshal([]byte(cached), &user)
        return &user, nil
    }
    
    // 缓存未命中,查询数据库
    user := db.QueryOne("SELECT * FROM users WHERE id = ?", id)
    if user == nil {
        // 空值缓存60秒,防止频繁查询不存在的数据
        cache.Set(cacheKey, NULL_CACHE_MARKER, 60*time.Second)
        return nil, nil
    }
    
    // 正常数据缓存1小时
    data, _ := json.Marshal(user)
    cache.Set(cacheKey, data, 3600*time.Second)
    return user, nil
}

关键点:

  • 使用特殊标记(如 __NULL__)而非简单的 nil,避免和缓存未命中混淆
  • 空值TTL要比正常数据短(60秒 vs 3600秒),防止数据恢复后仍返回空值
  • 可以使用单独的空值缓存前缀,如 null:user:123,更容易监控和清理

其他防御手段:

  • 布隆过滤器:快速判断ID是否可能存在
  • 参数校验:拒绝明显非法的ID(如负数、超长数字)

4.2 缓存雪崩

定义:大量缓存同时失效,导致请求瞬间打到数据库。

场景1:缓存集中过期

go 复制代码
// ❌ 所有粉丝索引在同一时间过期
cache.Set("follower_ids:123", ids, 3600)
cache.Set("follower_ids:456", ids, 3600)
cache.Set("follower_ids:789", ids, 3600)

解决方案:随机过期时间

go 复制代码
// ✅ 添加随机偏移
import "math/rand"

ttl := 3600 + rand.Intn(600)  // 3600-4200秒之间
cache.Set("follower_ids:123", ids, ttl)

场景2:缓存服务宕机

解决方案:

  1. 多级缓存:本地缓存 + Redis
  2. 熔断降级:缓存故障时返回默认数据或降级查询
  3. 限流保护:数据库查询限流,防止打垮DB
go 复制代码
// 多级缓存示例
func GetUser(id int64) (*User, error) {
    // L1: 本地缓存(进程内存)
    if user := localCache.Get(id); user != nil {
        return user, nil
    }
    
    // L2: Redis
    if user := redis.Get(fmt.Sprintf("user:%d", id)); user != nil {
        localCache.Set(id, user, 60)  // 回填本地缓存
        return user, nil
    }
    
    // L3: 数据库(带限流保护)
    if !rateLimiter.Allow() {
        return nil, errors.New("too many requests")
    }
    
    user := db.QueryOne("SELECT * FROM users WHERE id = ?", id)
    if user != nil {
        redis.Set(fmt.Sprintf("user:%d", id), user, 3600)
        localCache.Set(id, user, 60)
    }
    
    return user, nil
}

五、代码实现演示

本项目实现了完整的性能基准测试代码,完整源码可在以下仓库找到:

📦 源码仓库:https://github.com/d60-Lab/RelationGraph

相关文件:

  • internal/cacheperf/followers.go:三种缓存策略的实现
  • cmd/cachebench/main.go:基准测试程序(PostgreSQL + Redis)

5.0 如何运行测试

前置要求:

  • Go 1.21+
  • Docker(运行 PostgreSQL 和 Redis)

步骤1:克隆仓库

bash 复制代码
git clone https://github.com/d60-Lab/RelationGraph.git
cd RelationGraph

步骤2:启动 PostgreSQL 和 Redis

bash 复制代码
# 使用 docker-compose 启动服务(使用自定义端口避免冲突)
docker-compose up -d postgres redis

# 等待服务启动(约5秒)
sleep 5

# 验证服务状态
docker-compose ps

步骤3:运行基准测试

bash 复制代码
# 运行测试(自动创建20,000个测试用户,执行6,000次请求)
go run cmd/cachebench/main.go

预期输出:

复制代码
Setting up test data...
Test data ready.
  Running benchmark... done
  Warming cache... done
  Running benchmark... done
  Warming cache... done
  Running benchmark... done

Follower list latency (6k req, mixed pages, PostgreSQL + Redis)
No cache           avg=10.7ms p95=22.5ms p99=25.2ms ...
Naive list cache   avg=191µs p95=244µs p99=317µs ...
Optimized cache    avg=9.5ms p95=10.0ms p99=10.4ms ...

步骤4:清理环境(可选)

bash 复制代码
# 停止并删除容器
docker-compose down -v

环境变量(可选):

bash 复制代码
# 自定义数据库连接
export DATABASE_URL="host=localhost user=postgres password=postgres dbname=postgres port=5434 sslmode=disable"

# 自定义Redis连接
export REDIS_ADDR="localhost:6380"

5.1 核心实现片段

go 复制代码
// internal/cacheperf/followers.go

// 策略3:优化缓存实现
func (s *FollowerService) FetchFollowersOptimized(userID, page, pageSize int64) ([]FollowerSnapshot, error) {
    // 1. 获取粉丝ID索引(缓存)
    indexKey := fmt.Sprintf("follower_ids:%d", userID)
    var allIDs []int64
    
    if val := s.cache.Get(s.ctx, indexKey).Val(); val != "" {
        json.Unmarshal([]byte(val), &allIDs)
    } else {
        // 缓存未命中,查询数据库
        rows, _ := s.db.Query("SELECT follower_id FROM follows WHERE user_id = ?", userID)
        for rows.Next() {
            var id int64
            rows.Scan(&id)
            allIDs = append(allIDs, id)
        }
        
        data, _ := json.Marshal(allIDs)
        s.cache.Set(s.ctx, indexKey, data, 10*time.Minute)
        atomic.AddInt64(&s.indexLoads, 1)
    }
    
    // 2. 计算分页范围
    start := page * pageSize
    end := start + pageSize
    if start >= int64(len(allIDs)) {
        return []FollowerSnapshot{}, nil
    }
    if end > int64(len(allIDs)) {
        end = int64(len(allIDs))
    }
    pageIDs := allIDs[start:end]
    
    // 3. 批量获取用户实体(利用缓存)
    return s.fetchUsersByIDs(pageIDs)
}

func (s *FollowerService) fetchUsersByIDs(ids []int64) ([]FollowerSnapshot, error) {
    if len(ids) == 0 {
        return []FollowerSnapshot{}, nil
    }
    
    // 1. 批量构造缓存键
    cacheKeys := make([]string, len(ids))
    for i, id := range ids {
        cacheKeys[i] = fmt.Sprintf("user:%d", id)
    }
    
    // 2. 使用 MGET 一次性获取所有缓存(关键优化!)
    cachedVals, _ := s.cache.MGet(s.ctx, cacheKeys...).Result()
    
    // 3. 解析缓存结果,收集未命中的ID
    cached := make(map[int64]FollowerSnapshot)
    missingIDs := make([]int64, 0)
    
    for i, val := range cachedVals {
        if val != nil {
            var snapshot FollowerSnapshot
            if str, ok := val.(string); ok && str != "" {
                json.Unmarshal([]byte(str), &snapshot)
                cached[ids[i]] = snapshot
            }
        } else {
            missingIDs = append(missingIDs, ids[i])
        }
    }
    
    // 4. 批量查询数据库中缓存未命中的用户
    if len(missingIDs) > 0 {
        atomic.AddInt64(&s.userBulkLoad, 1)
        
        query := "SELECT id, username, bio FROM users WHERE id IN (" +
                 placeholders(len(missingIDs)) + ")"
        rows, _ := s.db.Query(query, toInterfaceSlice(missingIDs)...)
        
        for rows.Next() {
            var snapshot FollowerSnapshot
            rows.Scan(&snapshot.ID, &snapshot.Username, &snapshot.Bio)
            cached[snapshot.ID] = snapshot
            
            // 回写缓存(这里可以进一步优化为 MSET 批量写入)
            data, _ := json.Marshal(snapshot)
            s.cache.Set(s.ctx, fmt.Sprintf("user:%d", snapshot.ID), data, 10*time.Minute)
        }
    }
    
    // 5. 按原始ID顺序返回结果(保持顺序很重要!)
    results := make([]FollowerSnapshot, 0, len(ids))
    for _, id := range ids {
        if snap, ok := cached[id]; ok {
            results = append(results, snap)
        }
    }
    
    return results, nil
}

5.2 进一步优化:批量回写缓存

上面的代码中,回写缓存仍使用循环 SET。在缓存未命中较多时,可以进一步优化为批量写入:

go 复制代码
// 更优化的回写方式:使用 MSET
if len(missingIDs) > 0 {
    query := "SELECT id, username, bio FROM users WHERE id IN (?)"
    rows, _ := s.db.Query(query, missingIDs...)
    
    // 收集需要批量写入的数据
    pipe := s.cache.Pipeline()
    
    for rows.Next() {
        var snapshot FollowerSnapshot
        rows.Scan(&snapshot.ID, &snapshot.Username, &snapshot.Bio)
        cached[snapshot.ID] = snapshot
        
        data, _ := json.Marshal(snapshot)
        key := fmt.Sprintf("user:%d", snapshot.ID)
        pipe.Set(s.ctx, key, data, 10*time.Minute)  // 加入pipeline
    }
    
    // 一次性执行所有SET(通过pipeline减少网络往返)
    pipe.Exec(s.ctx)
}

Pipeline vs 循环SET 性能对比:

复制代码
回写 100 个用户到缓存:

循环 SET:  100 × 0.5ms = 50ms
Pipeline:  1 × 0.5ms   = 0.5ms   ← 快100倍

5.3 运行基准测试

bash 复制代码
# 运行基准测试
go run cmd/cachebench/main.go

# 输出示例
=== 缓存性能基准测试 ===

[1/3] 策略: 无缓存
  平均延迟: 9.4ms
  P95: 10.8ms, P99: 11.2ms
  总查询次数: 6000

[2/3] 策略: 朴素缓存
  平均延迟: 64.7µs
  P95: 128.3µs, P99: 145.6µs
  总查询次数: 360

[3/3] 策略: 优化缓存
  平均延迟: 4.1ms
  P95: 4.5ms, P99: 4.7ms
  索引加载: 1, 批量用户查询: 176

六、最佳实践总结

6.1 缓存设计原则

  1. 缓存最小粒度的实体,而非查询结果
  2. ID索引 + 实体模式:列表类查询用ID索引,实体独立缓存
  3. ❗批量查询 :永远使用 MGET/MSET,禁止循环调用 GET/SET
    • Redis: MGET keys... / MSET key1 val1 key2 val2 ...
    • Memcache: GetMulti([]string{...}) / SetMulti(map[string]...)
    • 减少网络往返是缓存优化的第一要务
  4. 合理的TTL:热点数据长TTL,冷数据短TTL,添加随机抖动
  5. 缓存空值:防止缓存穿透,但TTL要短

6.2 缓存更新策略

go 复制代码
// 场景:用户更新资料
func UpdateUser(user *User) error {
    // 1. 更新数据库
    if err := db.Update(user); err != nil {
        return err
    }
    
    // 2. 删除实体缓存(让其自然失效)
    cache.Delete(fmt.Sprintf("user:%d", user.ID))
    
    // 3. 不要删除所有相关的索引!
    // ❌ 错误:cache.Delete(fmt.Sprintf("followers:*"))
    // ✅ 正确:只在添加/删除关注关系时清理索引
    
    return nil
}

// 场景:添加关注关系
func Follow(userID, targetID int64) error {
    if err := db.Insert(&Follow{UserID: userID, FollowerID: targetID}); err != nil {
        return err
    }
    
    // 清理受影响的索引
    cache.Delete(fmt.Sprintf("follower_ids:%d", targetID))  // 目标用户的粉丝列表
    cache.Delete(fmt.Sprintf("following_ids:%d", userID))   // 当前用户的关注列表
    
    return nil
}

6.3 监控指标

生产环境务必监控:

  • 缓存命中率:应保持在90%以上
  • 平均延迟:缓存命中 < 1ms,未命中 < 50ms
  • P99延迟:关注长尾请求性能
  • 缓存内存使用:防止OOM
  • 缓存穿透次数:异常增长可能是攻击

6.4 何时不用缓存

  • 数据强一致性要求(如交易金额)
  • 数据变化极其频繁(如实时计数器)
  • 数据量极小(几十条记录,数据库查询已经很快)
  • 查询频率极低(每小时几次,缓存命中率太低)

七、总结

缓存不仅仅是性能优化工具,更是资源管理和数据一致性的挑战。

核心原则

为什么要缓存实体而非查询结果?

  1. 内存是有限的、昂贵的资源

    • 缓存列表会导致内存成本爆炸(单用户GB级 → 系统TB级)
    • 实体缓存内存占用可控且可预测
  2. 数据一致性必须可维护

    • 缓存列表:更新时不知道该清理哪些键,容易脏数据
    • 缓存实体:更新时只需删除实体键,所有引用自动获取最新数据

最佳实践检查清单

  • 不要缓存查询结果(尤其是列表、分页、聚合结果)
  • 不要循环调用 GET/SET(100次往返可能比1次DB查询还慢)
  • 缓存最小粒度的实体(用户、商品、订单等独立对象)
  • 列表存ID索引(轻量、易失效、复用性高)
  • 永远批量查询(MGET/MSET/Pipeline)
  • 防御穿透和雪崩(空值缓存、随机TTL、多级缓存)
  • 监控核心指标(命中率、P99延迟、内存占用)

记住三个约束

  1. 内存约束:Redis成本是MySQL的10倍以上,必须精打细算
  2. 一致性约束:无法精确失效的缓存等于定时炸弹
  3. 网络约束:减少往返次数是性能优化的第一要务

希望本文能帮助你理解缓存设计的本质矛盾,在性能、成本、一致性之间找到平衡点!


📦 完整源码仓库

运行测试

bash 复制代码
git clone https://github.com/d60-Lab/RelationGraph.git
cd RelationGraph
go run cmd/cachebench/main.go
相关推荐
枫子有风2 小时前
Go语言流程控制
android·java·golang
深圳佛手2 小时前
操作系统的 页缓存(Page Cache)解析
缓存
Craaaayon3 小时前
如何选择两种缓存更新策略(写缓存+异步写库;写数据库+异步更新缓存)
java·数据库·redis·后端·缓存·mybatis
唐僧洗头爱飘柔95274 小时前
【GORM(3)】Go的跨时代ORM框架!—— 数据库连接、配置参数;本文从0开始教会如何配置GORM的数据库
开发语言·数据库·后端·golang·gorm·orm框架·dsn
Jonathan Star4 小时前
在 Go 语言中,模板字符串
开发语言·后端·golang
foxbillcsdn5 小时前
《Redis应用实例》Java实现(28):栈
java·redis
星辰_mya7 小时前
亲爱的redis你好
redis
想搞艺术的程序员10 小时前
Go Channel 深度指南:规范、避坑与开源实践
golang
巴塞罗那的风10 小时前
Eino框架快速搭建出行agent(二)引入12306 mcp
人工智能·golang·mcp