本地缓存适合读多写少、更新不频繁且允许短暂不一致的场景,如用户配置、静态字典;优势是零网络开销、纳秒级延迟、百万级QPS,但存在进程重启丢失、多实例不同步、无法主动失效等问题。
当数据读多写少、更新不频繁、且允许短暂不一致(比如用户配置、静态字典、开关状态),sync.Map 或 ristretto 这类内存缓存就足够了。它没有网络开销,延迟在纳秒到微秒级,吞吐量轻松过百万 QPS。
但要注意:进程重启后全丢;多实例部署时各节点缓存不同步;无法主动失效——比如你改了数据库里某条商品价格,所有本地缓存不会自动刷新。
http.Handler 内部临时缓存请求上下文、短生命周期的计算结果goroutine 频繁写入同一 sync.Map 键——高并发下会退化为锁竞争,不如预分配 map + sync.RWMutex
ristretto 默认不开启 OnEvict 回调,想做缓存穿透防护或日志埋点得手动配Redis 是事实标准,但不是万能解。如果你需要强一致性(比如库存扣减)、原子操作(INCR、SETNX)、或复杂数据结构(ZSET 做排行榜),那必须上 Redis。但它的网络 RTT、序列化开销、连接池争用,会让 P99 延迟跳到毫秒级。
如果只是做纯读缓存,且能接受最终一致,Redis Cluster 节点扩缩容时会出现短暂 MOVED 或 ASK 错误;而 etcd 或 Consul 更适合元数据类缓存(服务发现、配置中心),它们不支持 LRU 驱逐,也不适合存大 Value。
github.com/redis/go-redis/v9 必须设置 MinIdleConns 和 MaxConnAge,否则空闲连接堆积导致 TIME_WAIT 爆满GET 一个 10MB 的 JSON 会阻塞 Redis 单线程,也拖慢 Go 的 goroutine;应提前拆分或压缩KEYS *:生产环境必须禁用,改用 SCAN 分批处理常见模式是「先查本地,未命中再查 Redis,回填本地」,但这个逻辑本身有竞态:两个 goroutine 同时查不到,都会去 Redis 加载,造成击穿和重复写本地缓存。
正确做法是加一层轻量级本地锁(比如 singleflight.Group),让同 key 的并发请求只放行一个去加载,其余等待返回。同时要控制本地缓存 TTL 略短于 Redis,防止本地一直不更新。
var cacheGroup singleflight.Groupfunc GetItem(id string) (Item, error) { // 先查本地 if item, ok := lo
calCache.Load(id); ok { return item.(Item), nil } // 未命中,用 singleflight 防击穿 v, err, _ := cacheGroup.Do(id, func() (interface{}, error) { item, err := redisClient.Get(ctx, "item:"+id).Result() if err != nil { return nil, err } localCache.Store(id, item) // 回填本地,TTL 设为 redisTTL - 5s return item, nil }) return v.(Item), err }
singleflight 不处理缓存删除,DEL Redis 后本地仍存在脏数据——需配合发布订阅(如 Redis Pub/Sub)通知其他节点清本地time.Now().Unix() 当作本地缓存过期依据,Go 的 time.Time 不可比较,要用 time.Since() 判断是否超时不能。这是个明确的取舍:你要强一致(比如订单状态变更后立刻可见),就得牺牲性能——用 Redis 事务 + Lua 脚本保证读写原子性,或引入消息队列异步双删;你要高性能,就得接受几秒甚至几分钟的不一致,靠定时任务或监听 binlog 主动刷新缓存。
最容易被忽略的是「缓存雪崩」:大量 key 设置相同过期时间,到期后集体失效,瞬间打垮下游 DB。解决方案不是加随机 offset(治标),而是用「永不过期 + 后台异步更新」策略,或者用 Redis 的 EXPIRE 配合 GETEX 命令实现懒更新。
ristretto 默认 128MB,但若 key 小 value 大(比如缓存整张用户表),OOM 风险极高json.Marshal 存 struct 到 Redis?注意字段 tag 是否含 omitempty,空值可能被忽略导致反序列化失败