Go 缓存设计实战——本地缓存 + 分布式缓存的多级缓存架构
Go 缓存设计实战——本地缓存 + 分布式缓存的多级缓存架构
适读人群:需要在 Go 服务中设计高性能缓存的工程师 | 阅读时长:约17分钟 | 核心价值:多级缓存架构设计,缓存击穿、穿透、雪崩的实战解法
一个被缓存穿透差点击垮的周末
那是一个周六下午,我正在公园遛狗,手机突然开始报警。告警说数据库连接池满了,所有 API 都在超时。
我一脸懵,周末又没发布什么,怎么会突然出问题?
远程连上去一看,数据库 QPS 从平时的 200 涨到了 8700,全是查询某个用户表,而且这些查询几乎全部返回"not found"。
原来是某个竞品做竞争调研,写了一个脚本,用随机生成的用户 ID 来探测我们的用户数量。这些随机 ID 在数据库里不存在,每次查询都要打到数据库,Redis 缓存完全无效(你不可能把"不存在"也缓存起来……吧?其实可以)。
这就是缓存穿透。
多级缓存架构
在我们的场景里,多级缓存是这样的:
请求 → L1缓存(进程内,ristretto) → L2缓存(Redis) → 数据库
<50μs 响应 <1ms 响应 <10ms 响应L1 缓存在进程内存里,没有网络开销,读取极快。但容量有限(GB 级别),而且多实例之间不共享。
L2 缓存是 Redis,可以被所有实例共享,容量更大,但需要网络 I/O。
踩坑实录
坑一:自己实现的 L1 缓存没有淘汰策略,内存暴涨
现象: 上线一周后,进程内存从 300MB 涨到了 2.1GB,触发了 OOM。
原因: 我用了一个全局的 map[string]interface{} 做 L1 缓存,只有 TTL 过期才删除,没有 LRU 淘汰。缓存的 key 越来越多,内存一直涨。
解法: 换用专业的内存缓存库,我选的是 github.com/dgraph-io/ristretto,它支持基于内存大小的 LRU 淘汰,而且是高并发场景下设计的(比简单的加锁 map 快很多)。
坑二:缓存击穿——热 key 过期瞬间,大量请求同时打到数据库
现象: 某个热门商品的缓存 TTL 到了,在缓存重建的 200ms 内,有 4700 个请求同时打到数据库,数据库直接报连接超时。
原因: 热 key 过期后,没有保护机制,所有等待这个数据的请求都并发去查数据库。
解法: singleflight——多个相同 key 的并发请求,只让第一个打到数据库,其他的等待并复用结果。
坑三:缓存雪崩——大批缓存在同一时间过期
现象: 每天早上 7 点,服务有一个明显的响应时间峰值,大概持续 30-60 秒。
原因: 前一天晚上某个批量任务更新了大量数据,同时设置了相同的 TTL(8小时),导致第二天早上 7 点左右大批缓存集体过期。
解法: TTL 加随机抖动:ttl = baseTTL + rand.Intn(jitter),把过期时间打散。
完整实现
package cache
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/rand"
"sync"
"time"
"github.com/dgraph-io/ristretto"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
// ErrNotFound 缓存未命中
var ErrNotFound = errors.New("cache: not found")
// NullValue 空值占位符(用于缓存"不存在"结果,防止缓存穿透)
const NullValue = "__null__"
// MultiLevelCache 多级缓存
type MultiLevelCache struct {
l1 *ristretto.Cache // 进程内缓存
l2 *redis.Client // Redis 缓存
sfGroup singleflight.Group // 防击穿
}
// Config 缓存配置
type Config struct {
L1MaxCost int64 // L1 最大内存使用(字节),如 128MB = 128*1024*1024
L1NumCounters int64 // L1 计数器数量,建议是最大条数的10倍
L2DefaultTTL time.Duration // L2 默认 TTL
L2NullTTL time.Duration // 空值的 TTL(防穿透),通常设短一点
TTLJitter time.Duration // TTL 抖动范围(防雪崩)
}
func New(rdb *redis.Client, cfg Config) (*MultiLevelCache, error) {
l1, err := ristretto.NewCache(&ristretto.Config{
NumCounters: cfg.L1NumCounters,
MaxCost: cfg.L1MaxCost,
BufferItems: 64,
})
if err != nil {
return nil, fmt.Errorf("create L1 cache: %w", err)
}
return &MultiLevelCache{
l1: l1,
l2: rdb,
}, nil
}
// Get 从缓存获取数据,未命中时调用 loader 从数据库加载
// loader 返回 nil 时,会缓存空值(防穿透)
func (c *MultiLevelCache) Get(
ctx context.Context,
key string,
loader func(ctx context.Context) (interface{}, error),
ttl time.Duration,
dest interface{},
) error {
// 1. 先查 L1 缓存
if val, ok := c.l1.Get(key); ok {
if str, ok := val.(string); ok && str == NullValue {
return ErrNotFound // 命中空值缓存
}
return copyValue(val, dest)
}
// 2. 查 L2 缓存(Redis)
val, err := c.l2.Get(ctx, key).Result()
if err == nil {
if val == NullValue {
// 空值缓存:写入 L1 然后返回 not found
c.l1.SetWithTTL(key, NullValue, 1, ttl/2)
return ErrNotFound
}
// 命中 L2:反序列化并写入 L1
if err := json.Unmarshal([]byte(val), dest); err != nil {
return fmt.Errorf("unmarshal cache value: %w", err)
}
c.l1.SetWithTTL(key, val, estimateCost(val), ttl/2) // L1 的 TTL 短一些
return nil
}
if err != redis.Nil {
return fmt.Errorf("redis get: %w", err)
}
// 3. 缓存未命中,用 singleflight 去数据库加载
// singleflight 保证同一 key 并发时只有一个请求打到数据库
result, err, _ := c.sfGroup.Do(key, func() (interface{}, error) {
data, err := loader(ctx)
if err != nil {
return nil, err
}
// 加 TTL 抖动,防止雪崩
actualTTL := addJitter(ttl, ttl/10) // 10% 的抖动
if data == nil {
// 数据不存在,缓存空值防穿透
c.l2.Set(ctx, key, NullValue, actualTTL/5) // 空值TTL更短
c.l1.SetWithTTL(key, NullValue, 1, actualTTL/10)
return nil, ErrNotFound
}
// 序列化并写入两级缓存
jsonData, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("marshal data: %w", err)
}
c.l2.Set(ctx, key, jsonData, actualTTL)
c.l1.SetWithTTL(key, string(jsonData), estimateCost(string(jsonData)), actualTTL/2)
return data, nil
})
if err != nil {
return err
}
if result == nil {
return ErrNotFound
}
return copyValue(result, dest)
}
// Invalidate 使缓存失效(写操作后调用)
func (c *MultiLevelCache) Invalidate(ctx context.Context, key string) error {
c.l1.Del(key)
return c.l2.Del(ctx, key).Err()
}
// addJitter 给 TTL 加随机抖动
func addJitter(base, jitter time.Duration) time.Duration {
if jitter <= 0 {
return base
}
return base + time.Duration(rand.Int63n(int64(jitter)))
}
func estimateCost(val string) int64 {
return int64(len(val))
}
func copyValue(src, dst interface{}) error {
data, err := json.Marshal(src)
if err != nil {
return err
}
return json.Unmarshal(data, dst)
}使用示例:
type UserService struct {
cache *cache.MultiLevelCache
repo UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
var user User
err := s.cache.Get(ctx, "user:"+id,
func(ctx context.Context) (interface{}, error) {
// 这个函数只在缓存未命中时调用
u, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err // 数据库错误,不缓存
}
return u, nil // u 为 nil 时,缓存空值
},
10*time.Minute,
&user,
)
if errors.Is(err, cache.ErrNotFound) {
return nil, nil // 用户不存在
}
if err != nil {
return nil, err
}
return &user, nil
}
func (s *UserService) UpdateUser(ctx context.Context, user *User) error {
if err := s.repo.Update(ctx, user); err != nil {
return err
}
// 更新后失效缓存
return s.cache.Invalidate(ctx, "user:"+user.ID)
}Go vs Java:Caffeine vs ristretto
Java 的 Caffeine 是目前最好的本地缓存库,性能极高,W-TinyLFU 淘汰算法也很先进。
Go 的 ristretto 受 Caffeine 启发,设计思路类似,在 Go 的并发场景下性能也很好。
主要区别:ristretto 的 API 是基于"cost"(成本)而不是"count"(数量)来控制缓存大小,这让基于内存大小的控制更准确。如果你的缓存对象大小差异很大(有的几十字节,有的几十 KB),ristretto 的成本控制比按条数限制更合理。
