2025-11-07 02:07:56

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

前言

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

一、常见的缓存误用模式

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

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

// ❌ 错误做法 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出现在哪些列表缓存中!

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

// 方案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 原则:缓存实体,而非查询结果

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

// ✅ 正确做法 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反序列化的隐藏成本

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

❌ 错误的实现(初版):

// 将索引存为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个!

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

// 使用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的困境:

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

Redis的优势:

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

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

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

❌ 常见错误:循环查询Redis

// 错误示例: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

// 一次网络往返获取所有数据 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。

// 问题代码 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 }

解决方案:缓存空值

// ✅ 缓存空结果,但设置较短的过期时间 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:缓存集中过期

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

解决方案:随机过期时间

// ✅ 添加随机偏移 import "math/rand" ttl := 3600 + rand.Intn(600) // 3600-4200秒之间 cache.Set("follower_ids:123", ids, ttl)

场景2:缓存服务宕机

解决方案:

  1. 多级缓存:本地缓存 + Redis
  2. 熔断降级:缓存故障时返回默认数据或降级查询
  3. 限流保护:数据库查询限流,防止打垮DB
// 多级缓存示例 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:克隆仓库

git clone https://github.com/d60-Lab/RelationGraph.git cd RelationGraph

步骤2:启动 PostgreSQL 和 Redis

# 使用 docker-compose 启动服务(使用自定义端口避免冲突) docker-compose up -d postgres redis # 等待服务启动(约5秒) sleep 5 # 验证服务状态 docker-compose ps

步骤3:运行基准测试

# 运行测试(自动创建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:清理环境(可选)

# 停止并删除容器 docker-compose down -v

环境变量(可选):

# 自定义数据库连接 export DATABASE_URL="host=localhost user=postgres password=postgres dbname=postgres port=5434 sslmode=disable" # 自定义Redis连接 export REDIS_ADDR="localhost:6380"

5.1 核心实现片段

// 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。在缓存未命中较多时,可以进一步优化为批量写入:

// 更优化的回写方式:使用 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 运行基准测试

# 运行基准测试 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 缓存更新策略

// 场景:用户更新资料 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. 网络约束:减少往返次数是性能优化的第一要务

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


📦 完整源码仓库

运行测试

git clone https://github.com/d60-Lab/RelationGraph.git cd RelationGraph go run cmd/cachebench/main.go

本文链接:http://blog.go2live.cn/post/how-to-use-cache.html

-- EOF --