Go IM 源码实战(14):本地缓存快 3000 倍,失效却难 10 倍

你好,我是利威尔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 的淘汰策略是"优先淘汰最久没被访问的"。三个操作都要求 O(1):

  1. 查找:给 key 找到值;
  2. 淘汰:找到最久没用的那个并删掉;
  3. 更新:每次访问把这个 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 必须拷贝保证安全)。

这里安全吗?安全,两个条件都满足:

  1. h.Write 只读不写,不会破坏 string 的不可变性;
  2. key 的生命周期覆盖整个函数调用。

📌 面试被问"你用过 unsafe 吗",这是个好例子,但必须能说清"为什么这里安全"。 只说"为了性能"是扣分的------因为 unsafe 的正确性论证永远是"谁在什么条件下保证不变"。

代价 :这段代码依赖 Go 的字符串/切片头布局,属于语言实现细节(虽然实际上稳定)。如果哪天 Go 改了布局,这里会静默出错。

分片带来的两个麻烦

  1. 不能跨片原子操作 :MGet([k1, k2]) 如果 k1 在槽 3、k2 在槽 7,要分别加锁。SlotLRU.GetBatch 就是按槽分组后再分别处理(lru_slot.go:21-45);
  2. 级联删除要注意锁顺序: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")

两个问题:

  1. 你得知道所有依赖它的 key------好友有 1000 个,就要删 1000 次;
  2. 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 源码实战》

相关推荐
逃逸线LOF2 小时前
Ribbon负载均衡原理(Irule组件、源码分析)
后端·spring cloud·ribbon
慢云智慧空间2 小时前
从设备智能到空间理解,空间智能终端解决方案商如何重新定义行业标准
后端
利威尔xu2 小时前
Go IM 源码实战(13):30 个会话只有 2 个红点,未读数错在三条路径
redis·后端·微服务·面试·golang
薛定谔的猫-菜鸟程序员3 小时前
软件架构设计师开发资料大全(从入门到精通)
后端·python·系统架构·flask·架构设计师
FungLeo3 小时前
成为全栈·Go 后端篇·从 Node SQLite 到 PostgreSQL:把迁移做成独立交付物
postgresql·golang·sqlite·go·数据库迁移·成为全栈
IT_陈寒4 小时前
SpringBoot多环境配置把我坑惨了,这个踩坑经验你最好看看
前端·人工智能·后端
码事漫谈5 小时前
DeepSeek 搞了个 "Super-agent",但不是超级智能
后端
吴凡wudianmei5 小时前
从 0 搭一个 AI Agent(四):SSE 跑通之后,三处“契约错配”与它们的修复边界
前端·后端·aigc
小兔子5 小时前
defer 的参数在写下那一刻就求值了,循环里这样写会踩坑
后端