正确顺序是先更新数据库再删除缓存,避免DB写失败导致缓存空缺而加载过期数据;删缓存失败需记日志但不回滚,因缓存仅为辅助层。

先改 DB 再删缓存,别颠倒顺序
“先删缓存再写 DB”看似直觉,但 DB 写失败时缓存已空,下次读就会加载过期数据——这是脏读的根源。正确顺序是:DB 更新成功后,再触发缓存删除。哪怕 DEL 命令失败,也只造成短暂脏读,不会永久错。
删缓存失败不能返回错误给调用方——DB 已改成功,缓存只是辅助层。必须记录日志(如 log.Warn("failed to delete cache key: user:123", "err", err)),然后继续流程。
- Redis 的
DEL是幂等的,但管道或事务中部分失败会导致漏删;建议单 key 操作,或用SCAN+DEL批量清理前缀(如user:123:*) - 涉及关联缓存(如用户资料更新后要清掉其订单列表),别只删主 key;用前缀扫描或布隆过滤器辅助识别待删 key 范围
- 禁用
SET覆盖代替DEL——万一 DB 写成功、缓存SET失败,旧值就卡死了
缓存穿透:空值也要设短过期
恶意请求或脏数据导致查不到 DB 记录,若直接不写缓存,攻击者反复刷相同 key 就会打穿缓存直击 DB。解决方法不是拒绝响应,而是缓存一个空结果(如 nil 或空结构体),并设极短 TTL(如 time.Minute)。
注意:不能缓存错误(如 sql.ErrNoRows),只缓存业务语义上的“不存在”。否则错误被放大,且无法自动恢复。
立即学习“go语言免费学习笔记(深入)”;
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- key 命名要有上下文,比如
"user:123:profile",别用裸 ID 或指针地址,避免冲突和误删 - 空值缓存需和正常值区分,建议统一包装为
struct{ Data *User; Exists bool },避免下游误判nil - 高频空 key 场景可叠加布隆过滤器,在缓存层前置拦截,减少无效 DB 查询
并发读写击穿:用 singleflight.Group 拦住重复查询
两个 goroutine 同时发现缓存 miss,都去查 DB,又都往缓存写——浪费资源,还可能因写入时序导致不一致。Go 标准库没提供原生方案,golang.org/x/sync/singleflight 是最轻量可靠的解法。
它让同一 key 的所有并发 Get 请求共享一个 DB 查询,其余等待结果返回后直接取值,不发第二条 SQL。
- 不要用
sync.Once,它只能用一次,不适用于动态 key -
singleflight.Group.Do返回的是interface{}和error,需做类型断言;建议封装一层,避免每个业务函数都重复处理 - 慎用全局
singleflight.Group实例——高并发下可能成为锁瓶颈;按业务域拆分更稳妥(如userGroup,orderGroup)
本地缓存 vs 分布式缓存:上线必须切 Redis
开发阶段用 ristretto 或 sync.Map 本地缓存没问题,但上线后多实例部署下,本地缓存无法同步,必然导致数据不一致。必须抽象出 Cache 接口,实现层切换为 Redis。
选客户端时优先用 github.com/redis/go-redis/v9:它原生支持 context 取消、pipeline、failover,且 API 清晰,错误分类明确(如 redis.Nil 表示 key 不存在,不是通用 error)。
- 别用
go-cache等带自动过期的本地库上线——它们不解决分布式一致性问题,反而掩盖真实缺陷 - Redis 连接池大小要压测调优;默认 10 太小,高并发下易阻塞;建议从 50 起步,观察
redis_client_oldest_idle_connection_seconds指标 - 缓存键设计要考虑 Redis 集群 slot 分布;避免用
user:{id}:profile这类带变量的 key,而应固定前缀如user_profile:{id},防止哈希倾斜

















