主题
01 - 缓存架构
缓存在系统中的位置
请求链路中的多级缓存:
客户端 数据库
│ │
▼ │
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐│
│ 浏览器 │ │ CDN │ │ 本地缓存 │ │ Redis ││
│ 缓存 │→│ 缓存 │→│ (进程内) │→│ 分布式 │→│
│ (HTTP) │ │ (静态) │ │ Caffeine │ │ 缓存 ││
└──────────┘ └──────────┘ └──────────┘ └────────┘│
│
命中率: 很高 很高 ~90% ~99% 最后兜底
延迟: 0ms ~10ms ~0.1ms ~1ms ~10ms
核心思想: 离用户越近,速度越快
80/20 法则: 20% 的数据承载 80% 的请求1. 缓存读写策略
Cache Aside(旁路缓存)— 最常用
读流程:
┌──────┐ 1.查缓存 ┌──────┐
│ App │──────────▶│Cache │
│ │◀──────────│ │
│ │ 命中返回 │ │
└──┬───┘ └──────┘
│ 未命中
│ 2.查数据库
▼
┌──────┐
│ DB │
│ │
└──┬───┘
│ 3.写入缓存
▼
┌──────┐
│Cache │
└──────┘
写流程:
┌──────┐ 1.更新DB ┌──────┐
│ App │───────────▶│ DB │
│ │ └──────┘
│ │ 2.删缓存 ┌──────┐
│ │───────────▶│Cache │ ← 删除,不是更新!
└──────┘ └──────┘
⚠️ 为什么是"删缓存"而不是"更新缓存"?
┌───────────────────────────────────────────────────┐
│ 假设更新缓存: │
│ 线程A: 更新DB(x=1) → 更新缓存(x=1) │
│ 线程B: 更新DB(x=2) → 更新缓存(x=2) │
│ │
│ 如果执行顺序变成: │
│ 线程A更新DB(x=1) → 线程B更新DB(x=2) │
│ → 线程B更新缓存(x=2) → 线程A更新缓存(x=1) │
│ 结果: DB=2, 缓存=1 不一致! │
│ │
│ 删缓存就没问题: 下次读会从 DB 加载最新值 │
└───────────────────────────────────────────────────┘Write Through / Write Behind
┌────────────────────────────────────────────────────────────┐
│ │
│ Write Through (同步写穿): │
│ App ──写──▶ Cache ──同步写──▶ DB │
│ 优点: 数据一致性强 │
│ 缺点: 写延迟高(要等 DB 写完) │
│ │
│ Write Behind (异步写回): │
│ App ──写──▶ Cache ──异步批量──▶ DB │
│ 优点: 写性能极高 │
│ 缺点: Cache 宕机可能丢数据 │
│ │
│ 选择: │
│ ┌──────────────────────────────────────────────┐ │
│ │ 大多数场景: Cache Aside(简单可靠) │ │
│ │ 强一致性: Write Through │ │
│ │ 高写吞吐: Write Behind + 持久化保障 │ │
│ └──────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘2. 缓存三大问题
┌────────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ 缓存穿透 │ │ 缓存击穿 │ │ 缓存雪崩 │ │
│ │ │ │ │ │ │ │
│ │ 查不存在的 │ │ 热点key过期 │ │ 大量key同时过期 │ │
│ │ 数据,每次 │ │ 大量请求直 │ │ 或缓存服务宕机 │ │
│ │ 都打到DB │ │ 接打到DB │ │ 请求全部涌向DB │ │
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
│ │
│ 穿透: 击穿: 雪崩: │
│ 请求 → Cache(无) 请求×1万 请求×10万 │
│ → DB(无) │ │ │
│ → Cache(无) ┌─▼──┐ ┌──▼───┐ │
│ → DB(无) │Cache│ key过期 │Cache │ 全挂 │
│ → ... 死循环! │ 无 │ │ 全无 │ │
│ └─┬──┘ └──┬───┘ │
│ ▼ ▼ │
│ ┌────┐ ┌─────┐ │
│ │ DB │ 被打爆 │ DB │ 被打爆 │
│ └────┘ └─────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
解决方案:
缓存穿透:
┌────────────────────────────────────────────────────┐
│ 方案1: 缓存空值 (null也缓存,设短过期时间) │
│ 方案2: 布隆过滤器 (BloomFilter 预判数据是否存在) │
│ │
│ 请求 → BloomFilter → 存在? → 查缓存 → 查DB │
│ → 不存在? → 直接返回空 │
└────────────────────────────────────────────────────┘
缓存击穿:
┌────────────────────────────────────────────────────┐
│ 方案1: 互斥锁 (只让一个请求去查DB,其他等待) │
│ 方案2: 永不过期 + 异步更新 │
│ │
│ if cache miss: │
│ if tryLock(): │
│ data = queryDB() │
│ setCache(data) │
│ unlock() │
│ else: │
│ wait → read cache again │
└────────────────────────────────────────────────────┘
缓存雪崩:
┌────────────────────────────────────────────────────┐
│ 方案1: 过期时间加随机值 (TTL = base + random) │
│ 方案2: 多级缓存 (本地缓存 + Redis) │
│ 方案3: 限流 + 熔断 (保护 DB) │
│ 方案4: Redis 集群高可用 (哨兵 / Cluster) │
└────────────────────────────────────────────────────┘Go 实现缓存穿透保护(singleflight)
go
package main
import (
"fmt"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
type Cache struct {
data map[string]string
mu sync.RWMutex
group singleflight.Group // 防缓存击穿的利器!
}
func NewCache() *Cache {
return &Cache{data: make(map[string]string)}
}
func (c *Cache) Get(key string) (string, error) {
// 先查缓存
c.mu.RLock()
if val, ok := c.data[key]; ok {
c.mu.RUnlock()
return val, nil
}
c.mu.RUnlock()
// 缓存未命中 → singleflight 保证同一个 key 只查一次 DB
// 100 个并发请求同一个 key,只有 1 个会真正查 DB
val, err, _ := c.group.Do(key, func() (interface{}, error) {
fmt.Printf(" [DB] 查询 key=%s (只执行一次!)\n", key)
time.Sleep(100 * time.Millisecond) // 模拟 DB 查询
result := "value-of-" + key
// 写入缓存
c.mu.Lock()
c.data[key] = result
c.mu.Unlock()
return result, nil
})
if err != nil {
return "", err
}
return val.(string), nil
}
func main() {
cache := NewCache()
var wg sync.WaitGroup
// 模拟 50 个并发请求同一个 key
for i := 0; i < 50; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
val, _ := cache.Get("hot-key")
_ = val
}(i)
}
wg.Wait()
fmt.Println("完成! DB 只被查询了 1 次")
}singleflight 原理:
没有 singleflight: 有 singleflight:
请求1 → 查DB 请求1 → 查DB ─┐
请求2 → 查DB 请求2 → 等待 │
请求3 → 查DB 请求3 → 等待 ├→ 共享结果
... ... │
请求50 → 查DB (50次!) 请求50 → 等待 │
←──┘ 1次!
Go 标准库 golang.org/x/sync/singleflight
简单几行代码就解决了缓存击穿问题!3. 小结
┌──────────────────────────────────────────────────────┐
│ 缓存架构设计速查 │
├──────────────────────────────────────────────────────┤
│ │
│ 读写策略: Cache Aside(最常用,先查缓存后查DB) │
│ 写策略: 先更新DB,再删缓存(不是更新缓存) │
│ │
│ 三大问题: │
│ ├── 穿透: 布隆过滤器 / 缓存空值 │
│ ├── 击穿: singleflight / 互斥锁 / 永不过期 │
│ └── 雪崩: TTL加随机 / 多级缓存 / 限流 │
│ │
│ Go 最佳实践: singleflight 是防击穿的利器 │
│ │
└──────────────────────────────────────────────────────┘下一节: 02 - 消息队列