关系缓存不是一张布尔表
关注按钮看起来只有两个状态:已关注、未关注。真正落到端上,关系至少有四个方向、两个时间点和一条进行中的变更。把它们压缩成一个布尔值,短期能跑,长期一定在互关判断、粉丝列表和私信入口上同时出错。
本节只讨论「关系数据到了端上怎么存、怎么失效、怎么乐观更新」。它不讨论关注流如何分页(那是第 2 章的内容),也不讨论点赞、评论的乐观更新(第 5 章已经拆过一遍);消息通道的断线重连属于第 7 章。这里的关系是账号与账号之间的边,不是内容与用户之间的互动。
四个方向,而不是「关注了/没关注」
服务端返回的单点关系通常包含两个布尔值:我是否关注对方、对方是否关注我。端上如果只存一个 following,会丢掉「对方关注我但我没回关」这个状态。
| 本地边 | 含义 | 常见展示 |
|---|---|---|
none | 两个方向都没有 | 关注 |
following | 我关注对方,对方没关注我 | 已关注 |
followedBy | 对方关注我,我没回关 | 回关 |
mutual | 两个方向都有 | 互相关注 |
followedBy 不是 following 的反面。取关一个人,不等于对方也取关你;反过来,粉丝列表里的一个人也不代表你关注了他。把两个方向混成一个布尔值,最先坏掉的往往是「互关才能私信」这类规则。
缓存条目要同时记边、时间和版本
一条关系缓存至少包含三个字段:
edge:上面四个方向之一。fetchedAt:这条数据是什么时候从服务端拿到的,用于 TTL 判断。revision:本地发生了几次乐观变更,用于拒绝晚到的旧快照。
为什么不缓存完整的关注列表和粉丝列表?列表有分页、排序、总数和权限过滤,属于另一套接口;关系缓存只回答「我和某个人是什么关系」这种单点问题。把列表塞进单点缓存,会让失效范围失控:一个人取关,究竟要重拉多少页,端上根本算不清。
TTL:允许旧,但必须标明旧
关系数据可以旧。头像旁边的「已关注」晚几秒更新,用户通常感知不到;但私信入口、拉黑状态这类安全相关判断不能靠旧数据。
因此缓存读取返回的不是一个 edge,而是 { edge, stale, known }。stale 为真时,界面仍然可以先用旧值避免闪烁,同时后台重新拉取;调用方也可以选择不用旧值。把「有没有值」和「值新不新」分开,比在读取时直接返回 null 更有用。
TTL 不应该写死在业务代码里。构造时注入时钟和 TTL,测试才能在不等待真实时间的情况下推进过期。60 秒只是一个常见的示例值,真实取值要看关系变化的频率和接口成本。
乐观关注:方向由动作和旧值共同决定
用户点「关注」时,端上先改本地,再发请求。新边不是简单的 following,而要结合旧边:
| 旧边 | 执行 follow | 执行 unfollow |
|---|---|---|
none | following | none |
following | following | none |
followedBy | mutual | followedBy |
mutual | mutual | followedBy |
这张表是关系缓存里最值得写成纯函数的部分。动作加上旧值得到新值,没有网络、没有时钟,一条断言就能覆盖一个格子。把这段逻辑散落在按钮事件里,最容易出现「互关用户再点关注变成单向关注」这种回退。
revision:拦住晚到的旧快照
乐观更新之后,后台可能还有一个更早发出的关系查询正在路上。它带回的是变更前的旧边。如果直接写入缓存,用户会看到「刚关注完又变回未关注」。
解决办法不是比较服务端时间戳,而是给每个用户维护一个本地 revision。发请求时记下当时的 revision;响应回来时如果 revision 已经变化,说明期间发生过乐观更新,旧快照直接丢弃。乐观变更本身会把 revision 加一,提交服务端结果时再校验一次,晚到的旧提交同样无效。
这与第 2 章内容窗口的 epoch 是同一类思路,但作用域不同:epoch 保护一次刷新,revision 保护一个用户的关系边。
回滚要恢复整条缓存
关注请求失败时要回滚。回滚不是把 edge 改成 none,而是恢复变更前的那条缓存,包括它原来的 fetchedAt。否则回滚后的数据会假装成「刚刚拉取过」,TTL 重新计时,端上会继续用一条并不新鲜的关系。
如果变更前根本没有缓存,回滚应该删除条目,让读取重新回到 known: false。这一点在测试里必须单独覆盖,因为「恢复成 none」和「恢复成未知」在界面上看起来相似,语义却完全不同。
关系变化后,哪些列表必须失效
一次关注或取关,影响的不只是按钮。端上通常要同时失效这些键:
| 变更 | 必须失效 | 说明 |
|---|---|---|
| 关注 | lists:following、profile:<id>、feed:home、lists:suggestions | 关注列表和主页按钮立刻变化 |
| 关注后变互关 | 追加 lists:mutual、dm:hint:<id> | 互关列表和私信提示需要更新 |
| 取关 | 同上 | 主页和信息流不再出现对方内容 |
| 取关后不再是互关 | 追加 lists:mutual、dm:hint:<id> | 私信提示降级,但权限不能由此推导 |
粉丝列表 lists:followers 不在这张表里。你自己的关注动作不会改变「谁关注你」,只有服务端推送或对方动作才应该让粉丝列表失效。把所有关系列表打包失效,会让一次点击触发四五个请求,弱网下反而制造更多旧响应。
还有一个键永远不应该出现:dm:permission。关系缓存可以影响「是否展示私信入口的提示」,但不能决定「是否允许发消息」。权限必须由服务端下发的能力决定,这条边界下一节还会展开。
一个可运行的关注缓存
下面的 FollowCache 属于 L2:它没有网络,但通过注入时钟模拟了 TTL,通过 revision 模拟了请求与响应乱序。测试用假时钟推进过期,用「先发请求、后做乐观更新」的顺序制造晚到快照。
// file: src/relations/follow-cache.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/relations/follow-cache.test.ts
export type Edge = "none" | "following" | "followedBy" | "mutual";
export type FollowAction = "follow" | "unfollow";
export type DmAccess = "granted" | "hint" | "denied";
export type Clock = () => number;
interface Entry {
edge: Edge;
fetchedAt: number;
}
interface PendingMutation {
revision: number;
previous: Entry | null;
action: FollowAction;
}
export interface ReadResult {
edge: Edge;
stale: boolean;
known: boolean;
}
export interface MutationResult {
revision: number;
edge: Edge;
invalidates: string[];
}
// 关注方向只有两种:我关注对方、对方关注我。互关是两个方向的交集。
export function nextEdge(current: Edge, action: FollowAction): Edge {
if (action === "follow") {
return current === "followedBy" || current === "mutual" ? "mutual" : "following";
}
return current === "mutual" ? "followedBy" : "none";
}
export function invalidationKeys(
userId: string,
action: FollowAction,
previous: Edge,
next: Edge,
): string[] {
const keys = ["lists:following", "feed:home", "lists:suggestions", `profile:${userId}`];
if (previous === "mutual" || next === "mutual") {
keys.push("lists:mutual", `dm:hint:${userId}`);
}
// dm:permission 故意不在这里:私信权限必须由服务端下发的能力决定,
// 关系缓存只能影响提示,不能成为授权依据。
return keys;
}
// 私信入口只接受三种结果:服务端明确允许、服务端明确拒绝、仅有关系缓存提示。
// 没有 grant 时,互关最多得到 hint,不能直接打开输入框。
export function dmAccess(edge: Edge, serverGrant: boolean | null): DmAccess {
if (serverGrant === true) return "granted";
if (serverGrant === false) return "denied";
return edge === "mutual" ? "hint" : "denied";
}
export class FollowCache {
#entries = new Map<string, Entry>();
#revisions = new Map<string, number>();
#pending = new Map<string, PendingMutation>();
#now: Clock;
#ttlMs: number;
constructor(now: Clock = () => Date.now(), ttlMs = 60_000) {
if (!Number.isFinite(ttlMs) || ttlMs < 0) throw new Error("ttlMs 必须是非负数");
this.#now = now;
this.#ttlMs = ttlMs;
}
revision(userId: string): number {
return this.#revisions.get(userId) ?? 0;
}
read(userId: string): ReadResult {
const entry = this.#entries.get(userId);
if (!entry) return { edge: "none", stale: true, known: false };
return {
edge: entry.edge,
stale: this.#now() - entry.fetchedAt > this.#ttlMs,
known: true,
};
}
// revisionAtRequest 由调用方在发请求时读取。晚到的旧快照不能覆盖
// 已经发生的乐观更新,因此 revision 不相等时直接丢弃。
writeSnapshot(userId: string, edge: Edge, revisionAtRequest: number): boolean {
if (revisionAtRequest !== this.revision(userId)) return false;
this.#entries.set(userId, { edge, fetchedAt: this.#now() });
return true;
}
follow(userId: string): MutationResult {
return this.#mutate(userId, "follow");
}
unfollow(userId: string): MutationResult {
return this.#mutate(userId, "unfollow");
}
commit(userId: string, revision: number, edge: Edge): boolean {
if (revision !== this.revision(userId)) return false;
this.#pending.delete(userId);
this.#entries.set(userId, { edge, fetchedAt: this.#now() });
return true;
}
// 回滚恢复的是「变更前那条缓存」而不只是 edge,包括它原来的 fetchedAt,
// 这样回滚后的数据不会假装成刚拉取过的新鲜数据。
rollback(userId: string, revision: number): Edge | null {
if (revision !== this.revision(userId)) return null;
const pending = this.#pending.get(userId);
if (!pending) return null;
this.#pending.delete(userId);
if (pending.previous) this.#entries.set(userId, pending.previous);
else this.#entries.delete(userId);
return pending.previous?.edge ?? "none";
}
isPending(userId: string): boolean {
return this.#pending.has(userId);
}
#mutate(userId: string, action: FollowAction): MutationResult {
const previous = this.#entries.get(userId) ?? null;
const previousEdge = previous?.edge ?? "none";
const edge = nextEdge(previousEdge, action);
const revision = this.revision(userId) + 1;
this.#revisions.set(userId, revision);
this.#pending.set(userId, { revision, previous, action });
this.#entries.set(userId, { edge, fetchedAt: this.#now() });
return {
revision,
edge,
invalidates: invalidationKeys(userId, action, previousEdge, edge),
};
}
}
四个关键决策
方向由动作和旧值共同决定
nextEdge 是纯函数,四个旧边乘两个动作只有八个格子。把它单独抽出来,测试可以穷举,界面代码只负责调用。反过来,如果在点击事件里直接写 edge = "following",互关用户会被降级成单向关注。
stale 不是不可用
读取结果同时给出 edge 和 stale,而不是在过期时返回空。这样列表页可以用旧边避免闪烁,权限路径可以要求 stale === false 再展示敏感入口。一个返回值服务两种场景,比维护两套缓存简单。
revision 只保护单点关系
revision 按 userId 维护,不做一个全局计数器。全局计数器会让任何一次关注都让其他用户的在途快照失效,后台请求被打断,缓存命中率下降。保护范围越小,误伤越少。
权限不从关系推导
dmAccess 只在服务端明确 granted 时返回 granted;仅有互关缓存时返回 hint。这个函数的存在不是为了多一层抽象,而是把「提示」和「授权」在类型上分开,让调用方无法顺手把 mutual 当成 true。
测试与真实输出
本机运行结果如下:
$ node --test --test-concurrency=1 test/relations/follow-cache.test.ts
✔ 未知关系返回 none,并标记为需要拉取 (0.907625ms)
✔ 快照在 TTL 内新鲜,超时后保留 edge 但标记 stale (0.497958ms)
✔ 关注陌生人立即变为 following,并失效关注列表与主页 (0.205958ms)
✔ 对方已关注时,关注动作直接得到 mutual (0.078167ms)
✔ 取关互关对象后回落到 followedBy,并失效互关列表 (0.088583ms)
✔ 失败回滚恢复变更前缓存,未知关系回滚后重新变为未知 (0.12325ms)
✔ 晚到的旧快照被 revision 拦截,提交后的服务端结果才生效 (0.095ms)
✔ 互关缓存只能给私信提示,服务端明确拒绝优先 (0.057417ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 138.552333
八条用例覆盖了未知、新鲜度、四种方向变化、回滚、乱序快照和私信提示。它们没有覆盖真实的粉丝列表分页,也没有覆盖服务端推送;那两部分依赖接口契约,纯内存模型证明不了。
失败复盘:缓存互关让私信入口亮错
「回声」早期把私信入口写成 canDm = edge === "mutual"。上线后收到一类投诉:用户明明已经取关对方,私信输入框仍然可以打开,消息发出去却返回 403,草稿留在输入框里,用户以为消息已经发出。
时间线很典型。A 与 B 互关,B 在另一台设备取关 A;A 的端上仍缓存着 mutual,TTL 还没到;A 点进 B 的主页,端上根据缓存显示「互相关注」,并允许打开私信;A 发送消息,服务端按最新关系拒绝。更糟的是,A 的界面上还挂着「互相关注」的徽标,用户无法判断到底哪一边是真的。
修复分三步。第一,私信入口改为读取服务端下发的能力,缓存互关只用于文案提示,dmAccess 在没有 grant 时最多返回 hint。第二,发送失败返回明确的关系错误码时,立即失效该用户的单点关系并降级入口,而不是只弹一个 toast。第三,关系变化的实时事件到达时,按用户维度失效缓存,不再等 TTL 自然过期。
这个事故的根因不是缓存太旧,而是把缓存用在了它不该负责的决策上。缓存可以回答「展示什么」,不能回答「允许什么」。
小结
关注关系在端上要存四个方向、一个获取时间和一个本地版本。TTL 允许旧值,但必须把「旧」告诉调用方;乐观更新由动作和旧值共同决定新边;revision 拦住晚到的旧快照;回滚恢复整条缓存而不是简单置空;关系变化按维度失效列表,权限永远不参与推导。
关系缓存回答的是「我和这个人是什么关系」,还没有回答「这条内容该给谁看」。下一节把可见性规则搬到端上,讨论预判、提示,以及预判错误时如何不泄露内容。
阅读导航:上一节:8.3 状态展示的降级与隐私边界 · 下一节:9.2 可见性规则在端上的表达 。
常见错误与边界
- 用一个布尔值表示关注关系,丢掉
followedBy,互关判断随之失真。 - 取关时把边直接置为
none,对方原本关注你的方向被一起抹掉。 - 关系缓存不记获取时间,旧数据永远显示为新鲜。
- 晚到的旧快照直接写入,覆盖用户刚刚完成的乐观关注。
- 回滚只改 edge,不恢复
fetchedAt,过期数据被重新当成新鲜数据。 - 一次关注就失效所有关系列表,弱网下放大请求量与乱序概率。
- 把
lists:followers挂在自关注动作上,粉丝列表无谓重拉。 - 用缓存里的互关状态决定私信权限,服务端拒绝后入口仍然亮着。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。