你好,我是利威尔xu。
这是**「Go IM 源码实战」系列第 14 篇**。前面 13 篇里,Redis 出现了无数次------seq、水位、token、在线状态、会话缓存。这一篇专门讲缓存本身。
先算一笔账,说明为什么"有 Redis 还不够"。
假设 1 万条消息/秒,其中 10% 是群消息、群平均 100 人:
消息发送:10000/秒
每条消息的校验:
· 发送者是否在群 1 次 group_member 查询
· 群是否被禁言 1 次 group_info 查询
· 发送者是否被禁言 1 次 group_member 查询
· 是否好友(单聊) 1 次 relation 查询
· 用户全局免打扰 1 次 user 查询
→ 平均 3--4 次校验/消息 → 3--4 万次缓存查询/秒
消息推送:10000 × 扇出
· 每条消息要查接收者的会话设置
· 群消息扇出 100 人 → 100 次 conversation 查询
→ 10% × 10000 × 100 = 10 万次/秒
合计 15 万+ 次缓存查询/秒。 Redis 单机能扛(10 万 QPS 是保守估计,实际能到 20 万),但两件事会先出问题:
- 带宽:每次 200 字节 × 15 万 = 30MB/s ≈ 240Mbps,全耗在"查一个几乎不变的数据"上;
- 延迟累积:每次 0.3ms,一条消息 4 次校验 = 1.2ms,纯网络开销。
把最热的、几乎不变的数据搬进进程内存:Redis 0.3ms → 本地 LRU 100ns,差 3000 倍,而且不吃带宽。
这篇讲四件事:LRU 为什么要两个数据结构、为什么分 500 个槽、本地缓存怎么失效(以及它为什么比 Redis 更容易出错)、还有一个我在这套代码里发现的循环依赖死锁风险。
源码位置(全系列同一份):
- GitHub:https://github.com/CYQ111999/IM-Server
- 基线:OpenIM Server v3.8 瘦身版,main @
07f9ff6 - 本篇涉及:
pkg/localcache/(cache.go、option.go、lru/)、pkg/common/storage/cache/、pkg/rpccache/
前 4 篇:1 全局地图、2 WS 网关、3 HTTP API 层、4 发送链路。第 5 篇起全部在同名专栏里,见作者文章列表;系列导航在《新粉必看:技术博客学习路线》。
文章目录
-
- [一、LRU 为什么是"哈希表 + 双向链表"](#一、LRU 为什么是"哈希表 + 双向链表")
-
- [本项目用的不是标准 LRU](#本项目用的不是标准 LRU)
- [二、为什么是 500 个槽](#二、为什么是 500 个槽)
-
- [那个 unsafe 转换是什么](#那个 unsafe 转换是什么)
- 分片带来的两个麻烦
- [三、两个 TTL:为什么失败也要缓存](#三、两个 TTL:为什么失败也要缓存)
- [四、缓存依赖:删一个 key,为什么另一片也得删](#四、缓存依赖:删一个 key,为什么另一片也得删)
-
- 问题
- 朴素解法为什么不行
- [本项目的解法:无向图 + 遍历](#本项目的解法:无向图 + 遍历)
- 级联删除:图的连通分量遍历
- 依赖是"读的时候"建立的
- [五、一个为了"不死锁"而起的 goroutine](#五、一个为了"不死锁"而起的 goroutine)
- [六、单飞:1000 个请求同时 miss 会怎样](#六、单飞:1000 个请求同时 miss 会怎样)
-
- 两级锁
- [单飞的关键:先放一个空 item](#单飞的关键:先放一个空 item)
- 双重检查不能少
- 一个容易忽略的点:排队时的超时
- [七、跨进程失效:12 个进程各有各的 L1](#七、跨进程失效:12 个进程各有各的 L1)
-
- [为什么分 5 个 channel](#为什么分 5 个 channel)
- [这个方案的天花板(和第 11 篇同一个问题)](#这个方案的天花板(和第 11 篇同一个问题))
- 八、三级缓存的账:什么时候该上本地缓存
- [九、踩坑记录:两个坑,一共 8 小时](#九、踩坑记录:两个坑,一共 8 小时)
- 十、面试会怎么问
- 十一、小结:一个结构、一张图、两个兜底
一、LRU 为什么是"哈希表 + 双向链表"
LRU 的淘汰策略是"优先淘汰最久没被访问的"。三个操作都要求 O(1):
- 查找:给 key 找到值;
- 淘汰:找到最久没用的那个并删掉;
- 更新:每次访问把这个 key 移到"最近使用"的位置。
单看每种结构:
| 结构 | 查找 | 按顺序淘汰 | 移到头部 |
|---|---|---|---|
| 数组 | O(n) | O(1) | O(n) |
| 单向链表 | O(n) | O(1) | O(1)(已知节点) |
| 哈希表 | O(1) | ❌ 无序 | ❌ |
| 哈希表 + 双向链表 | O(1) | O(1) | O(1) |
- 哈希表:
key → 链表节点指针,解决查找; - 双向链表:节点按访问时间排序,头是最近用的、尾是最久没用的,解决淘汰。
为什么必须是双向链表? 因为删除一个节点需要它的前驱。单向链表要 O(n) 找前驱,双向链表有 prev,O(1)。
head → [最近用] ⇄ [B] ⇄ [C] ⇄ [最久没用] ← tail
Get(B):哈希表找到节点 → 从原位置摘除 → 插到 head 后面,两步都 O(1)
📌 这是"数据结构组合"最经典的一道题。面试被问 LRU,能画出这个图 + 说清"为什么必须双向",比背 container/list 的 API 有用得多。
本项目用的不是标准 LRU
pkg/localcache/lru/ 有两个实现:
| 实现 | 淘汰时机 | 过期处理 |
|---|---|---|
LazyLRU |
容量满时 | 惰性 :读的时候检查 expires,过期就重新 fetch |
ExpirationLRU |
容量满时 + 定时器 | 主动:内部后台 goroutine 定期清理 |
go
// pkg/localcache/option.go:27-29
// expirationEvict 为 true 表示过期定时器到期时主动清理缓存,
// 为 false 表示采用惰性删除。
expirationEvict bool
默认 false(惰性),理由是:
| 惰性删除 | 主动删除 | |
|---|---|---|
| 后台开销 | 无 goroutine | 定时扫描,O(n) |
| 内存占用 | 过期 key 一直占着(直到被访问或 LRU 淘汰) | ≈ 有效数据量 |
| 风险 | 内存可能涨到实际写入量 | 数据量大时扫描本身卡顿 |
📌 Redis 自己也是"惰性 + 定期采样"的混合策略。 本项目选了纯惰性,靠 LRU 容量上限兜底 ------每槽最多 20000 条,涨不死。这是一个很实用的判断:如果有硬上限,惰性删除就是安全的;如果没有上限,惰性删除就是内存泄漏。
二、为什么是 500 个槽
go
localSlotNum: 500, // 500 个槽
localSlotSize: 20000, // 每槽 20000 条
总容量 500 × 20000 = 1000 万条 。看起来离谱(一个进程缓存一千万条用户信息?),实际永远不会装满------因为 TTL 只有 5 分钟。
分片解决的是锁竞争。
5000 QPS 打到一个 LRU 上
→ 每个 Get 都要 Lock/Unlock
→ 锁竞争剧烈,CPU 大量时间自旋
500 个槽之后:5000 / 500 = 每槽 10 QPS → 锁竞争降低 500 倍
槽位选择是哈希取模:
go
// pkg/localcache/cache.go:23-27
func LRUStringHash(key string) uint64 {
h := fnv.New64a()
h.Write(*(*[]byte)(unsafe.Pointer(&key))) // ← 零拷贝把 string 转 []byte
return h.Sum64()
}
// pkg/localcache/lru/lru_slot.go:57-59
func (x *slotLRU[K, V]) getIndex(k K) uint64 {
return x.hash(k) % x.n
}
那个 unsafe 转换是什么
go
*(*[]byte)(unsafe.Pointer(&key))
string 的底层是 {数据指针, 长度},[]byte 的也是 {数据指针, 长度, 容量}。内存布局前缀相同,所以可以把 string 的头部强转 成 []byte,跳过内容拷贝。
正常写法 []byte(key) 会拷贝一份(因为 string 不可变、\[\]byte 可变,Go 必须拷贝保证安全)。
这里安全吗?安全,两个条件都满足:
h.Write只读不写,不会破坏 string 的不可变性;key的生命周期覆盖整个函数调用。
📌 面试被问"你用过 unsafe 吗",这是个好例子,但必须能说清"为什么这里安全"。 只说"为了性能"是扣分的------因为 unsafe 的正确性论证永远是"谁在什么条件下保证不变"。
代价 :这段代码依赖 Go 的字符串/切片头布局,属于语言实现细节(虽然实际上稳定)。如果哪天 Go 改了布局,这里会静默出错。
分片带来的两个麻烦
- 不能跨片原子操作 :
MGet([k1, k2])如果 k1 在槽 3、k2 在槽 7,要分别加锁。SlotLRU.GetBatch就是按槽分组后再分别处理(lru_slot.go:21-45); - 级联删除要注意锁顺序:A 依赖 B,两者可能在不同槽,删 A 时删 B 要小心死锁------这是本篇第六节的主题。
三、两个 TTL:为什么失败也要缓存
go
// pkg/localcache/option.go:10-21(默认值)
localSuccessTTL: time.Minute, // 成功 → 1 分钟
localFailedTTL: time.Second * 5, // 失败 → 5 秒
// config/local-cache.yml(实际生效)
successExpire: 300 // 成功 → 5 分钟
failedExpire: 5 // 失败 → 5 秒
成功缓存 5 分钟、失败只缓存 5 秒。
为什么失败也要缓存?这是缓存穿透的防线:
如果失败不缓存:
查询一个不存在的用户 → 每次都穿透到 Redis/DB
恶意请求(或用 bug 的客户端)刷不存在的 ID → 打穿到数据库
但失败不能缓存太久:
如果失败缓存 5 分钟:
用户刚注册,但缓存里还记着"不存在" → 5 分钟内查不到自己
5 秒 vs 5 分钟这个比例(1:60)是经验值:足够挡住穿透风暴,又短到"新建的数据最多 5 秒后可见"。
📌 判断"负缓存"该设多长,就看两件事 :穿透的破坏力(打穿 DB 多严重)和"新建后多久必须可见"的产品要求。这两者之间取平衡,比例通常在 1:10 到 1:100 之间。
⚠️ 顺带一个细节:失败缓存 5 秒意味着"数据刚被创建"和"数据真的不存在"在这 5 秒内无法区分。 如果业务上有"创建后立即查"的强需求(比如注册后马上登录查资料),要么在这条链路上跳过本地缓存,要么在创建成功后主动发一次失效广播(第五节)。
四、缓存依赖:删一个 key,为什么另一片也得删
这是我读这个项目最佩服的一处设计,也是这篇的核心。
问题
好友系统里有两个缓存(第 12 篇会讲到关系链):
FRIEND_IDS:user1 = ["user2", "user3", ...] ← 好友列表
IS_FRIEND:user2-user1 = true ← user2 是不是 user1 的好友
IS_FRIEND:user2-user1 的答案完全由 FRIEND_IDS:user1 决定。
现在 user1 把 user2 删了:
① 删掉 FRIEND_IDS:user1 ← 主动失效
② IS_FRIEND:user2-user1 还在! ← 缓存里仍是 true
③ 用户查「user2 是不是好友」→ 读到 true → 错误
这就是"缓存依赖":失效了 A,但由 A 推导出来的 B 没失效。
朴素解法为什么不行
go
// 删好友时
cache.Del("FRIEND_IDS:user1")
cache.Del("IS_FRIEND:user2-user1") // 但你得知道有哪些人
cache.Del("IS_FRIEND:user3-user1")
两个问题:
- 你得知道所有依赖它的 key------好友有 1000 个,就要删 1000 次;
IS_FRIEND还是双向的 (user2-user1和user1-user2都可能存在),漏一个就是脏数据。
本项目的解法:无向图 + 遍历
pkg/localcache/link/link.go,109 行,实现了一个无向图:
go
type Link interface {
Link(key string, link ...string) // 建立双向边
Del(key string) map[string]struct{} // 删除节点,返回所有连通的节点
}
数据结构是邻接表:
go
type linkKey struct {
lock sync.Mutex
data map[string]map[string]struct{}
}
Link 会双向建边:
go
func (x *slot) Link(key string, link ...string) {
mk := key
x.slots[x.index(mk)].link(mk, lks...) // key → link
for _, lk := range lks {
x.slots[x.index(lk)].link(lk, mk) // link → key(反向)
}
}
为什么必须双向? 因为删除时要知道"哪些 key 依赖我"。只存单向的话,删除就得遍历全表找依赖者------O(n)。双向之后,data["FRIEND_IDS:user1"] 直接就是所有依赖它的 key 集合。
级联删除:图的连通分量遍历
go
func (x *slot) delKey(k string) map[string]struct{} {
del := make(map[string]struct{})
stack := []string{k}
for len(stack) > 0 {
curr := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if _, ok := del[curr]; ok {
continue // 已访问,跳过(防环)
}
del[curr] = struct{}{}
childKeys := x.slots[x.index(curr)].del(curr) // 删节点,拿到邻居
for ck := range childKeys {
stack = append(stack, ck)
}
}
return del
}
走一遍:
A ←→ B ←→ C
删 A:
栈=[A] → 弹 A、标记、删邻接表、邻居 {B} 入栈
栈=[B] → 弹 B、标记、删、邻居 {A,C} 入栈
栈=[A,C] → 弹 C、标记、删、邻居 {B} 入栈
栈=[A,B] → 都访问过,跳过
栈空,返回 {A,B,C}
三个关键点:
| 点 | 为什么 |
|---|---|
if _, ok := del[curr]; ok { continue } |
防环。图可能有环(A←→B←→A),不判重就死循环 |
边遍历边删(x.slots[...].del(curr)) |
图越来越小,后续遍历更快 |
| 用栈 + 循环而不是递归 | 递归深度受图大小限制,10 万节点的依赖链会栈溢出 |
⚠️ 顺带指出:变量名和注释里叫 "BFS",但用 stack(后进先出)实现的是 DFS 。对"求连通分量"来说结果一样,但命名和实现对不上,读代码的人会多花十分钟确认。
依赖是"读的时候"建立的
go
func (c *cache[V]) GetLink(ctx context.Context, key string, fetch func(ctx) (V, error), link ...string) (V, error) {
if c.local != nil {
return c.local.Get(key, func() (V, error) {
if len(link) > 0 && c.link != nil {
c.link.Link(key, link...) // ← 回源时建立依赖
}
return fetch(ctx)
})
}
...
}
注意 link.Link 在 fetch 之前 调用。所以:只要发生过一次"缓存未命中 + 回源",依赖关系就建立了。
实际用法(rpccache/friend.go:57-60):
go
return cache.Unmarshal(f.local.GetLink(ctx,
cachekey.GetIsFriendKey(possibleFriendUserID, userID), // 缓存的 key
func(ctx context.Context) ([]byte, error) {
return cache.Marshal(f.client.FriendClient.IsFriend(ctx, ...))
},
cachekey.GetFriendIDsKey(possibleFriendUserID), // 依赖的 key
))
语义:IS_FRIEND:x-y 依赖 FRIEND_IDS:x。于是删 FRIEND_IDS:x 时,图会带出 {IS_FRIEND:a-x, IS_FRIEND:b-x, ...} 一起删。
这就是"删一个、连带删一片"的实现。
📌 这个设计的思想价值大于它的实现 :把"缓存失效"从"人工维护一份依赖清单"变成"运行时自动建图 + 图遍历"。人工清单一定会漏,图不会。 任何有"派生缓存"的系统(权限、计数、聚合视图)都可以用这个思路。
代价 :图本身占内存(邻接表)、级联删除是 O(连通分量大小)、而且只在单进程内有效(跨进程还得靠广播)。
五、一个为了"不死锁"而起的 goroutine
onEvict(LRU 淘汰回调)这段只有几行,但注释值得单独讲:
go
func (c *cache[V]) onEvict(key string, value V) {
if c.link != nil {
// 底层 LRU 仍持锁时不要删除其他 key;
// 将关联删除延后执行,避免重入同一分片导致死锁。
if lks := c.link.Del(key); len(lks) > 0 {
go c.delLinked(key, lks) // ← 起 goroutine
}
}
}
如果不加这个 go 会怎样:
goroutine G1: 槽 3 的 Get 未命中 → 加锁槽 3 → 触发淘汰 → onEvict
→ 删除关联 key(可能也在槽 3)→ 再次 Lock 槽 3 → 死锁
Go 的 sync.Mutex 不可重入 ,同一个 goroutine 对同一把锁二次 Lock() 会永久阻塞。
修法:起一个新 goroutine 做级联删除。新 goroutine 是另一条执行流,可以安全地等锁。
📌 这个 go 不是为了性能,是为了正确性。 很多人看到这种地方会下意识觉得"异步更快",但真正的理由是避开锁不可重入。这个区别在面试里很关键------如果你说"为了不阻塞",对方会追问"那为什么不用无锁队列",你就答不上来了。
代价:级联删除变成异步的。紧接着读一个依赖 key,可能读到还没被删的旧值。窗口是微秒级,IM 能接受。
⚠️ 另一个隐患 :go c.delLinked(...) 没有任何生命周期管理。进程退出时这些 goroutine 被直接杀掉,链接图可能处于"删了一半"的状态。因为图只在内存里、进程重启就重建,实际影响不大------但这类"裸 goroutine"在需要优雅退出的系统里是要记账的。
六、单飞:1000 个请求同时 miss 会怎样
1000 个请求同时查 user1 → 缓存全 miss → 1000 个都去 RPC 查 → RPC 被打爆
单飞(singleflight):同一个 key 的并发请求,只放一个去回源,其他等着复用结果。
LazyLRU.Get 的实现很巧(lru_lazy.go:44-75):
go
func (x *LazyLRU[K, V]) Get(key K, fetch func() (V, error)) (V, error) {
x.lock.Lock()
v, ok := x.core.Get(key)
if ok {
x.lock.Unlock()
v.lock.Lock() // ← 锁「这个 item」
expires, value, err := v.expires, v.value, v.err
if expires != 0 && expires > time.Now().UnixMilli() {
v.lock.Unlock()
return value, err // 命中
}
// 过期了:不解锁,继续往下走去 fetch
} else {
v = &lazyLruItem[V]{} // 没这个 key:新建一个空占位
x.core.Add(key, v)
v.lock.Lock()
x.lock.Unlock()
}
defer v.lock.Unlock()
if v.expires > time.Now().UnixMilli() { // ← 双重检查
return v.value, v.err // 别人已经 fetch 完了
}
v.value, v.err = fetch() // 只有一个人会走到这里
...
}
两级锁
x.lock ← 保护整个 LRU 的 map(粗粒度,持有极短)
v.lock ← 保护单个 item(细粒度,持有时间长,包含 fetch)
为什么必须两级? 如果只用 x.lock 保护一切,fetch(可能 10ms)期间要一直持全局锁 → 所有其他 key 的访问全被阻塞。两级锁之后,拿到 item 的锁就释放全局锁,别人可以继续访问别的 key。
单飞的关键:先放一个空 item
go
v = &lazyLruItem[V]{} // 空 item,expires=0
x.core.Add(key, v) // 立即放进 map
v.lock.Lock() // 锁住这个空 item
这样第 2 个并发请求 会 Get(key) 拿到这个空 item,然后 v.lock.Lock() 阻塞 ------直到第 1 个请求 fetch 完 Unlock()。
同一个 key 只有第一个回源,其他都在 item 的锁上排队。 这就是单飞,而且不需要额外的 singleflight 库。
双重检查不能少
go
if v.expires > time.Now().UnixMilli() {
return v.value, v.err // 别人已经填好了
}
因为排队期间,第一个请求可能已经 fetch 完成。第二个拿到锁后看到 expires 已设置,直接返回。
📌 如果没有第二层检查 :1000 个请求排队,第 1 个 fetch 完,剩下 999 个拿到锁后各自再 fetch 一次 (它们是排队等到的,不是同时进来的)。单飞完全失效------而且从外面看"有锁、有单飞",只有压测时才会发现 QPS 没降。
这是"双重检查锁"这个模式的本质:第一层无锁快速路径,加锁后必须再查一次,因为加锁本身耗时。
⚠️ 一个真实的缺口 :GetBatch 里没有单飞保护(lru_lazy.go:77-138)。它是"先收集所有 miss 的 key,然后一次 fetch"。两个 goroutine 同时 GetBatch 同样的 key 集合,会各自 fetch 一次。
批量场景下概率不高(通常同一个上游发起),但确实存在重复回源。修法:给批量也加一层"按 key 集合的 in-flight 表",或者退化成"逐个走单飞的 Get"。
一个容易忽略的点:排队时的超时
v.lock.Lock() 是阻塞且没有超时的。如果第一个请求的 fetch 卡住 30 秒(下游 RPC 没设超时),剩下 999 个请求会全部阻塞 30 秒。
这就是为什么第 10 篇强调"fetch 里的 RPC 必须有超时" ------单飞把"并发压力"转换成了"排队等待",一旦队头卡住,整个 key 的访问全卡住。单飞不是免费的午餐,它把风险集中到了队头。
七、跨进程失效:12 个进程各有各的 L1
本地缓存的根本问题:12 个进程各有各的 L1,改了一个,其他 11 个不知道。
解法是广播:
节点 A 更新了 user1 的信息
→ 删自己 L1 的 USER_INFO:user1
→ DEL Redis 的 USER_INFO:user1
→ PUBLISH DELETE_CACHE_USER ["USER_INFO:user1"]
↓
节点 B、C、D... 都订阅了这个 channel
→ 收到 → 删自己 L1 的 USER_INFO:user1
发布方(cache/redis/batch_handler.go:52-79):
go
func (c *BatchDeleterRedis) execDel(ctx context.Context, keys []string) error {
if len(keys) > 0 {
// ① 删 Redis(TagAsDeleted,延迟过期)
err := ProcessKeysBySlot(ctx, c.redisClient, keys, func(ctx context.Context, slot int64, keys []string) error {
return c.rocksClient.TagAsDeletedBatch2(ctx, keys)
})
if err != nil { return err }
// ② 广播给所有节点
if len(c.redisPubTopics) > 0 {
keysByTopic := localcache.GetPublishKeysByTopic(c.redisPubTopics, keys)
for topic, keys := range keysByTopic {
if len(keys) > 0 {
data, _ := json.Marshal(keys)
c.redisClient.Publish(ctx, topic, string(data))
}
}
}
}
return nil
}
订阅方(rpccache/subscriber.go):
go
func subscriberRedisDeleteCache(ctx, client, channel, del func(ctx, key ...string)) {
defer func() {
if r := recover(); r != nil { log.ZPanic(ctx, "subscriberRedisDeleteCache Panic", errs.ErrPanic(r)) }
}()
for message := range client.Subscribe(ctx, channel).Channel() {
var keys []string
if err := json.Unmarshal([]byte(message.Payload), &keys); err != nil {
log.ZError(ctx, "subscriberRedisDeleteCache json.Unmarshal error", err)
continue
}
if len(keys) == 0 { continue }
del(ctx, keys...)
}
}
为什么分 5 个 channel
yaml
auth: topic: DELETE_CACHE_AUTH
user: topic: DELETE_CACHE_USER
group: topic: DELETE_CACHE_GROUP
friend: topic: DELETE_CACHE_FRIEND
conversation: topic: DELETE_CACHE_CONVERSATION
因为按业务域分流 。一个 channel 混杂所有 key,订阅方收到后还要判断"这个 key 我关心吗";分 5 个之后,每个订阅者只订阅自己关心的,收到就是自己的。
GetPublishKeysByTopic 就是做这个分流:按前缀把 key 分组,一组发一个 topic。
📌 这是"用 topic 做路由"代替"用消息内容做过滤" ,代价是发布方要知道前缀映射,收益是订阅方零判断。在消息量大、订阅者多的场景下,这个交换很值。
这个方案的天花板(和第 11 篇同一个问题)
for message := range client.Subscribe(...) ------ Pub/Sub 没有 ack,没有重投。
三个后果:
| 情况 | 后果 |
|---|---|
| 订阅者断线期间有广播 | 那几条失效消息永久丢失 |
| Redis 主从切换丢了 Pub/Sub 消息 | 同上 |
| 订阅协程 panic(有 recover,但协程退出了) | 该节点从此不再接收任何失效 |
后果是:某个节点上的这份 key 一直是旧值,直到 L1 的 TTL(5 分钟)到期。
所以 TTL 不是"顺便设的",它是 Pub/Sub 失效机制的兜底。 没有 TTL,广播丢一次 = 永久脏数据。
📌 结论性的一条原则:任何"推送式失效"都必须配"过期式兜底"。 推送负责"快"(毫秒级失效),TTL 负责"对"(最坏 5 分钟内一定失效)。只靠推送,就是把系统的一致性押在一个不保证送达的通道上。
反过来也有一条 :如果业务能接受 5 分钟不一致,那这套广播其实不是必需品 ,只是"让常态下更快生效"的优化。能这么定位自己的组件,说明设计者想清楚了。
八、三级缓存的账:什么时候该上本地缓存
把三层摆在一起:
| 层 | 延迟 | 容量 | 一致性 | 成本 |
|---|---|---|---|---|
| L1 本地 LRU | ~100ns | 受进程内存限制 | 弱(靠广播 + TTL) | 进程内存 |
| L2 Redis | ~0.3ms | 大(可集群) | 强(单点真相) | 网络带宽 + 运维 |
| L3 MongoDB | ~1--10ms | 很大 | 最强 | 磁盘 IO |
判据不是"越近越好",而是这笔账:
L1 的收益 = 命中率 × 单次节省的延迟 × 请求量
L1 的成本 = 内存 + 跨进程失效的复杂度 + 不一致窗口带来的排查成本
按文章开头那笔账算:
- 15 万次/秒的查询,如果 90% 命中 L1,就省下 13.5 万次 × 0.3ms ≈ 40 秒的累计等待/秒(分摊到各个请求上,体现为 P99 明显下降);
- 带宽从 240Mbps 降到 24Mbps。
所以本地缓存对这种"高读放大 + 低变更率"的场景是刚需(用户资料、群成员、好友关系------几分钟不变,一秒查几十次)。
反过来,什么数据不该进 L1?
| 特征 | 为什么 |
|---|---|
| 变更频繁(如在线状态、未读数) | 失效比命中还多,纯亏 |
| 强一致要求(如余额、token 状态) | 不一致窗口不可接受 |
| 体量巨大(如消息正文) | 内存放不下,命中率低 |
| 只被一个请求访问一次(如历史消息分页) | 没有复用,缓存只是浪费一次写 |
📌 一句话判断:读远多于写、且能容忍"秒级不一致"的数据,才配进 L1。 这个标准能筛掉 80% 想塞进本地缓存的字段。
九、踩坑记录:两个坑,一共 8 小时
坑一:改了昵称,过了 5 分钟才生效(5 小时)
现象:用户在 A 端改了昵称,B 端(另一个服务)看到的还是旧名字,5 分钟后才更新。
定位:先看广播------DELETE_CACHE_USER 是正确的,发布成功了。再看订阅端:某个服务的订阅协程在启动时 panic 过一次 (json.Unmarshal 遇到一条脏消息),虽然 defer recover 兜住了 panic,但 for range 循环已经退出了,那个进程从此不再接收任何失效广播。
所以它只能等 L1 的 TTL(5 分钟)到期。
改法:把 recover 放到循环内部 (每条消息一层),单条消息解析失败只 continue,不让整个订阅协程退出。同时加一个「订阅存活」的监控指标(比如每秒心跳 +1,超过 60 秒没涨就告警)。
教训 :defer recover 保住的是进程,不是协程。 一个 for range channel 的订阅协程,panic 后即使被 recover,循环也结束了------这类"恢复了个寂寞"的写法在后台常驻协程里非常常见,必须把 recover 放在循环体内。
坑二:删了好友,但"是否好友"还是 true(3 小时)
现象:用户 A 删了 B,但 A 发消息给 B 时提示"你们已是好友"(读到了旧的 IS_FRIEND)。
定位:第四节那个依赖问题。FRIEND_IDS:A 删了,IS_FRIEND:B-A 还在。
为什么没级联删?因为依赖图是"读的时候"建立的 (GetLink 里 link.Link),而这个 IS_FRIEND 从来没被读过------没有读,就没有依赖边,删除时就带不出它。
等等,没读过的话缓存里也没有这个 key 啊?
这里有个细节:依赖边和缓存值是两条独立的生命周期。 缓存值可能因为 LRU 淘汰已经没了,但依赖边还在图里;反过来,如果 key 是通过其他路径 写进去的(比如预热脚本、或者另一条代码路径没走 GetLink),那它有值但没有依赖边。
我们的情况是第二种:有一段老代码直接 local.Set 写入了 IS_FRIEND,没走 GetLink。于是它是"孤儿节点"------删除 FRIEND_IDS 时带不出它。
改法:统一走 GetLink;并在 Set 的封装里加断言(debug 模式下如果发现有值但图里没节点,打日志)。
教训 :依赖图只对"走过同一入口"的数据有效。 任何绕过入口的直接写入,都会变成孤儿。这类机制的价值完全取决于"入口是否唯一" ------所以我更倾向于把它封装成"只能通过 GetLink 读、只能通过 Del 删"的 API,不给 Set 暴露出去。
十、面试会怎么问
| 追问 | 怎么答(一句话版) |
|---|---|
| 有 Redis 了为什么还要本地缓存? | 15 万 QPS 下带宽和延迟都吃不消;0.3ms → 100ns,差 3000 倍且不吃带宽 |
| LRU 为什么是哈希表 + 双向链表? | 查找 O(1) + 淘汰 O(1) + 移动 O(1);必须双向因为删除要前驱 |
| 为什么要分 500 个槽? | 降低锁竞争,5000 QPS / 500 槽 = 每槽 10 QPS |
| 惰性删除和定时删除怎么选? | 有硬容量上限就用惰性(零开销),没有上限就必须定时(否则是内存泄漏) |
| 成功 TTL 5 分钟、失败 TTL 5 秒,为什么? | 失败缓存防穿透,但不能长到"新建数据不可见",比例通常 1:10~1:100 |
| 缓存依赖怎么处理? | 运行时建无向图 + 遍历连通分量级联删除;人工维护清单一定会漏 |
那个 go c.delLinked() 为什么异步? |
规避 Mutex 不可重入导致的同槽死锁,不是为了性能 |
| 单飞怎么实现? | 先放一个空 item 再锁它,后来者阻塞在 item 锁上;必须双重检查 |
| 跨进程失效怎么做? | Pub/Sub 广播 + TTL 兜底;只靠广播,丢一条就是永久脏数据 |
| 这个项目缓存层有什么问题? | GetBatch 没单飞、依赖图对绕过入口的写入失效、订阅协程 panic 后 recover 了但循环退出 |
十一、小结:一个结构、一张图、两个兜底
- 一个结构:LRU = 哈希表 + 双向链表;分片是为了降锁竞争,不是为了省内存。
- 一张图 :缓存依赖用无向图 + 连通分量遍历来实现级联失效------机器维护依赖比人维护清单可靠。
- 两个兜底:Pub/Sub 的兜底是 TTL;单飞的兜底是 fetch 必须带超时。
这张表建议收藏,接任何"多级缓存"的系统都能照着过:
| 检查项 | 该问什么 | 本项目的答案 |
|---|---|---|
| 要不要 L1 | 读放大够大吗?能容忍秒级不一致吗? | 是,15 万 QPS + 数据分钟级不变 |
| 淘汰 | LRU/LFU/FIFO?怎么过期? | LRU + 惰性过期(靠容量上限兜底) |
| 并发 | 锁粒度多大?会不会热 key 打爆一把锁? | 500 槽分片 |
| 回源 | 并发 miss 怎么处理? | 空 item + 双重检查锁实现单飞 |
| 依赖 | 派生数据怎么级联失效? | 无向图 + 连通分量遍历 |
| 跨进程 | 怎么通知其他节点? | 5 个 topic 的 Pub/Sub |
| 兜底 | 广播丢了怎么办? | TTL 5 分钟 |
| 一致性 | 最坏多久不一致? | 5 分钟(可接受) |
留个问题,评论区一句话能答: 如果把依赖图的建立时机从"回源时"改成"启动时全量预热",能解决"孤儿节点"的问题吗?会引入什么新问题?
下一篇:《Go IM 源码实战(15):11 个开关少传一个就全开》------回到字节层面,讲清三个 ID 的分工、信封为什么用 JSON 而载荷用 protobuf,以及一个建了 map 又把它扔掉的函数。
作者:利威尔xu | CSDN 专栏《Go IM 源码实战》