《互联网产品的客户端架构·内容、社交与实时互动》9.1 关注、互关与关系缓存

关注、粉丝与互关不是一张布尔表:方向、新鲜度和进行中的变更都要分别记录。本节用带假时钟的 FollowCache 实现四态关系缓存、TTL 降级、乐观关注与回滚,给出关系变化后的列表失效矩阵,并复盘一次把缓存互关当成私信授权、导致错误入口的事故。

关系缓存不是一张布尔表

关注按钮看起来只有两个状态:已关注、未关注。真正落到端上,关系至少有四个方向、两个时间点和一条进行中的变更。把它们压缩成一个布尔值,短期能跑,长期一定在互关判断、粉丝列表和私信入口上同时出错。

本节只讨论「关系数据到了端上怎么存、怎么失效、怎么乐观更新」。它不讨论关注流如何分页(那是第 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
nonefollowingnone
followingfollowingnone
followedBymutualfollowedBy
mutualmutualfollowedBy

这张表是关系缓存里最值得写成纯函数的部分。动作加上旧值得到新值,没有网络、没有时钟,一条断言就能覆盖一个格子。把这段逻辑散落在按钮事件里,最容易出现「互关用户再点关注变成单向关注」这种回退。

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 挂在自关注动作上,粉丝列表无谓重拉。
  • 用缓存里的互关状态决定私信权限,服务端拒绝后入口仍然亮着。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查