sync.map适合读多写少、key稳定且无需ttl或淘汰的场景(如配置项、权限白名单),它轻量、并发安全、无gc压力;但无容量限制、无过期机制、不支持自动驱逐,需自行处理清理与数据污染问题。

直接用 sync.Map 搭配 go-redis/redis/v8 就能跑通多级缓存,但顺序、写入时机和 key 设计错一点,性能优势就全没了——本地缓存可能完全不命中,甚至引发缓存击穿。
本地缓存该用 sync.Map 还是第三方 LRU 库?
读多写少、key 稳定、不需要过期控制的场景(比如配置项、权限白名单),sync.Map 是最轻量、最可控的选择。它原生并发安全,没 GC 压力,也不引入淘汰逻辑干扰。
但注意三点:
-
sync.Map没容量限制、没 TTL、不自动驱逐——你得自己加定时 goroutine 清理,一旦开始这么干,说明你其实需要的是缓存库,不是 map - 结构体值别直接塞进去:含
map、slice或指针时,后续修改会意外污染缓存值;建议只存不可变类型,或深拷贝后存 -
LoadOrStore里传的函数体不是原子的:如果函数内调用了 DB 或 Redis,多个 goroutine 同时触发会导致重复加载;必须在外层加singleflight.Group防穿透
Redis key 设计不当会导致数据串扰或解析失败
裸 ID 当 key(如 "123")极危险:不同业务表 ID 可能重叠,删一条就误清其他业务缓存。
推荐格式:"user:profile:123"、"order:summary:20260424:uid_789"。前缀明确语义,中间段可嵌时间、租户、版本等维度,方便运维批量清理。
关键细节:
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
- 所有 key 必须统一编码:用
encoding/json序列化结构体后存 Redis,别用fmt.Sprintf拼接字符串——后者对空字段、浮点精度、嵌套map处理不可靠 - 避免在 key 中拼接用户输入(如用户名):先做
strings.ReplaceAll(username, ":", "_"),防止冒号破坏分段语义 - 如果用 hash 结构(如
HSET user:123 name "Alice" age "30"),记得配套用HGETALL+redis.StringMap解析,别用redis.Strings得到错位数组
查缓存必须“先本地再 Redis”,顺序反了就失去意义
典型流程应是:请求进来 → 调 localCache.Load("user:123");命中则直接返回;未命中 → 查 Redis(rdb.Get(ctx, "user:profile:123"));Redis 命中 → 反序列化 + localCache.Store(...) → 返回;Redis 未命中 → 查 MySQL → 序列化 → 写 Redis(带 EX 60)→ 写本地 → 返回。
容易忽略的点:
- 本地缓存写入时机不能只在 Redis 命中时:MySQL 查询成功后也必须写本地,否则冷启动或 Redis 故障期间本地永远不热
- Redis 写入必须带 TTL:不设过期时间,故障恢复后旧数据会长期滞留,导致一致性问题
- 本地缓存写入前建议校验反序列化结果:若 JSON 解析失败,别把脏数据塞进
sync.Map,否则后续所有请求都失败
缓存失效时如何避免雪崩和击穿?
单机层面,用 singleflight.Group 包住回源逻辑,确保同一 key 的并发请求只有一路打到数据库;分布式层面,需借助 Redis Pub/Sub 或独立消息通道通知所有节点清除本地缓存。
更稳妥的做法是:本地缓存不主动删除,而是设较短 TTL(比如 10 秒),让其自然过期;Redis 删除操作只做一层兜底。这样即使通知失败,本地缓存最多 stale 10 秒,不会永久错乱。
真正难处理的是「热点 key + 短 TTL」组合:大量请求在过期瞬间同时回源。这时除了 singleflight,还得配合「逻辑过期」设计——即 value 里额外存一个 expireAt 字段,本地和 Redis 都按它判断是否过期,而非依赖存储层 TTL。










