缓存键应采用“固定前缀+查询标识符+参数哈希”设计,如query:users:get_by_id:abc123;查缓存失败时用singleflight避免雪崩;JSON序列化需规范struct tag和time.Time字段;写DB后立即DEL缓存防脏数据。

缓存键怎么设计才不会撞车又容易失效
直接用 SQL 语句当缓存键最省事,但极容易出问题:SELECT * FROM users WHERE id = ? 和 SELECT name, email FROM users WHERE id = ? 是两个不同查询,结果结构、权限、甚至字段别名都可能不同,却可能被当成同一个键。更糟的是,参数顺序稍变(比如 WHERE status = ? AND id = ? vs WHERE id = ? AND status = ?)在某些序列化方式下会生成不同键,导致缓存击穿。
推荐做法是把「查询意图」显式编码进键:用固定前缀 + 查询标识符 + 参数哈希。比如:
cacheKey := fmt.Sprintf("query:users:get_by_id:%s", sha256.Sum256([]byte(fmt.Sprintf("%d", userID))).Hex()[:12])关键点:
-
userID必须是原始值(不是指针或结构体),避免序列化歧义 - 哈希截取 12 位足够防碰撞,又不拖慢键长度(Redis 对长键有内存和网络开销)
- 前缀带业务含义(如
query:users:),方便redis-cli用KEYS query:users:*批量清理 - 不要用
fmt.Sprintf拼接未校验的用户输入(如用户名),防止键污染或注入风险
查不到缓存时怎么避免雪崩式 DB 请求
多个请求同时发现 GET cache:xxx 返回空,全去查数据库,再并发写缓存——这就是缓存雪崩。Go 里没有语言级锁,得靠 sync.Map 或更稳妥的分布式锁(但 Redis 自身锁成本高)。实战中更轻量的做法是「单飞行(singleflight)」。
立即学习“go语言免费学习笔记(深入)”;
用 golang.org/x/sync/singleflight 包:
var group singleflight.Group
<p>func getUserFromCacheOrDB(id int) (<em>User, error) {
v, err, _ := group.Do(fmt.Sprintf("user:%d", id), func() (interface{}, error) {
// 先查缓存
if cached, ok := getFromRedis(fmt.Sprintf("user:%d", id)); ok {
return cached, nil
}
// 缓存没命中,查 DB
dbRes, err := db.QueryRow("SELECT ... FROM users WHERE id = ?", id).Scan(...)
if err != nil {
return nil, err
}
// 写入缓存(带过期)
setToRedis(fmt.Sprintf("user:%d", id), dbRes, 10</em>time.Minute)
return dbRes, nil
})
return v.(*User), err
}注意:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
-
group.Do的 key 必须和缓存 key 逻辑一致,否则起不到合并效果 - 如果 DB 查询失败,
singleflight会把错误也缓存一次(即“负缓存”),需在业务层判断是否重试或跳过 - 不要在
Do回调里调用另一个Do,容易死锁
JSON 序列化时 struct tag 和时间字段怎么不出错
Go 结构体直塞 Redis,常见报错:json: unsupported type: func()(因为嵌套了 sql.Rows 或自定义方法),或时间字段变成空对象 {}。根本原因是没控制序列化行为。
必须做两件事:
- 所有要缓存的 struct 字段加
jsontag,且显式声明omitempty或默认值,比如:CreatedAt time.Time `json:"created_at"` - 时间字段统一用
time.Time,别用int64时间戳——否则反序列化时 JSON 解析器不知道该转成 time 还是数字 - 避免缓存含
map[interface{}]interface{}或interface{}的字段,JSON 库对它们序列化不稳定 - 缓存前先用
json.Marshal测试能否成功,失败就立刻 log 错误,别静默丢弃
示例安全写法:
type User struct {
ID int `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
// 缓存前:
data, err := json.Marshal(user)
if err != nil {
log.Printf("marshal user %d failed: %v", user.ID, err)
return
}
redisClient.Set(ctx, "user:123", data, 10*time.Minute)缓存更新和删除时机不对会导致脏数据
很多人只记得「读时加缓存」,却忘了「写时清缓存」。比如用户改了头像,DB 更新成功了,但旧头像还躺在 Redis 里,下次读就返回过期内容。
必须和 DB 更新操作放在同一个事务上下文(哪怕 Redis 不支持跨服务事务):
- 更新 DB 成功后,立刻执行
DEL user:123,而不是SET user:123 new_value——删比写快,且避免写失败留脏数据 - 如果 DB 更新失败,千万别删缓存,否则造成「缓存穿透」
- 批量更新(如
UPDATE users SET status = ? WHERE category = ?)无法精确删 key,得用前缀扫描 +SCAN+DEL,但生产环境慎用——可能阻塞 Redis - 更稳的方式是「延迟双删」:更新 DB 前删一次缓存,更新 DB 后再删一次,中间加短 sleep(如 100ms),覆盖主从复制延迟窗口
真正难的不是代码,是意识到:缓存不是附加功能,它是数据流里一个必须参与协调的环节。漏掉一次 DEL,可能让某个用户看到三天前的订单状态。

















