《互联网产品的客户端架构·内容、社交与实时互动》8.1 presence 的近似语义:没有真正的「在线」

客户端看到的「在线」不是事实,而是一次带衰减的观测。本节把 presence 拆成最后活跃时间、online/away/offline/unknown 四态与 TTL 过期,给出窗口判定、下一次转换时间与最后活跃文案的纯函数实现,并复盘一次把 socket 连通当成在线、导致用户对着「幽灵在线」发消息的事故。

这一节不讨论连接本身

6.1 到 6.3 讨论的是「我这台设备连没连上」:选 WebSocket 还是 SSE、连接状态机怎么迁移、断线后页面怎么降级。7.1 到 7.3 讨论的是「连回来之后消息怎么补齐」。这些都在描述本端与服务器之间的通道。

本节转向另一个问题:我看到的别人,现在是什么状态? 这个状态不由本端连接决定,也不由对方最后一次点击决定。它是服务端若干观测的汇总,再经过网络延迟、客户端时钟和产品窗口衰减之后,落在屏幕上的一句话。8.2 讨论「正在输入」这条高频信号,8.3 讨论隐私与降级;本节只回答 presence 的判定模型本身。

「在线」是观测,不是事实

在单机程序里,一个对象存在就是存在。在分布式系统里,服务端没有能力确认「对方此刻正看着屏幕」。它能知道的只有:

  • 刚刚收到过一条心跳;
  • 刚刚收到过一次读写或输入事件;
  • 某条长连接还没有被判定超时;
  • 某个客户端主动发来了「我要下线」。

这些证据都有延迟,也都可能说谎。手机被系统挂起后,TCP 连接可能半开好几分钟,服务端仍持有 socket 对象;用户切换网络时,旧连接的关闭包可能永远到不了;服务端进程重启后,内存里的在线表全部丢失。如果产品把「在线」当成一个布尔事实,它迟早会在这些缝里说谎。

更准确的心智模型是租约(lease):每一次活跃信号把「在线」续期一小段时间,租约到期就降级。客户端展示的不是「他现在在线」,而是「我在多久之前收到过他的活跃信号」。这个措辞很笨拙,但它诚实。

四态:把「不知道」当成一等状态

只有 online / offline 两态时,所有异常都会被迫归入其中一边。网络抖动被显示成离线,缓存过期被显示成在线,用户无法判断哪一次是真的。把状态拆成四态,可以给每种证据一个出口:

状态判定条件(示例窗口)界面文案含义
online最后活跃距今小于 60 秒在线最近有可信信号
away60 秒到 5 分钟离开信号变旧,但仍在保留期内
offline5 分钟到 1 小时离线 / X 分钟前活跃可以确认当前不活跃
unknown无样本、超过 1 小时、时钟异常状态未知端上无法判断

四个窗口都是产品决策,不是协议常量。聊天产品可能把 online 窗口压到 30 秒,内容社区可能放宽到 5 分钟;重要的是窗口必须被写进同一份配置,并且客户端与服务端对「最后活跃时间」的含义达成一致。服务端应该下发 lastActiveAt,而不是下发一个 online: true。 前者让端上可以按自己的时钟与窗口判定,后者把服务端的瞬时判断固化成事实,网络一抖就过时。

unknown 不是错误状态,而是「证据不足」的正式表达。8.3 会看到,隐私关闭、拉黑、连接降级最终都会落到这个状态;如果模型里没有它,这些场景只能被迫显示「离线」,而「离线」是一个会误导用户的断言。

TTL 为什么必须存在

没有 TTL 的 presence 表只有一个结局:越积越多的「在线」用户。进程崩溃、客户端被杀、用户拔网线,都不会产生一条离线事件;服务端只能靠 TTL 把这些僵尸记录清掉。TTL 的作用不是节省内存,而是把「最后一次已知」转换成「不再可信」。

TTL 的长度要覆盖正常的心跳间隔,又不能长到让用户对着幽灵状态发呆。假设心跳每 30 秒一次,允许丢两次,TTL 取 90 秒左右;如果产品窗口是 60 秒在线、5 分钟离开,那么 1 小时的保留期已经足够宽松。TTL 到期后不要继续显示「离线」,而要显示「状态未知」:前者断言对方不在,后者承认自己不知道。

时钟:未来时间不可信

剩下的一个坑是时钟。lastActiveAt 通常由服务端生成,但也会出现客户端上报、跨服务汇总、缓存回放等路径。只要有一条路径用了设备时钟,端上就可能收到比 now 还大的时间戳。

对小幅超前要容忍:设备与服务端差几秒是常态,把 3 秒后的时间当成「刚刚活跃」没有问题。对大幅超前必须拒绝:如果时间戳比本地时间快 10 分钟,按负年龄计算会得到「未来在线」,排序、过期和文案全部错乱。正确做法是设一个 maxFutureSkewMs,超过就判 unknown,理由记为时钟异常,而不是悄悄钳到 0。

同理,不要用「没有收到离线事件」推断在线。离线事件会丢、会晚到、会因为多设备而互相覆盖;只有正向的活跃证据才能续租。

一个可运行的判定函数

下面的模块属于 L1:没有网络、没有定时器,输入是样本与当前时间,输出是一个可直接渲染的视图。nextTransitionAt 让 UI 只为「下一次状态可能变化」安排一个定时器,而不是每秒轮询整个好友列表。

// file: src/presence/presence.ts
// tier: L1
// verify: node --test test/presence/presence.test.ts

export type PresenceState = "online" | "away" | "offline" | "unknown";

export type PresenceSource = "signal" | "heartbeat" | "snapshot" | "none";

export interface PresenceSample {
  userId: string;
  lastActiveAt: number | null;
  source: PresenceSource;
}

export interface PresencePolicy {
  onlineWindowMs: number;
  awayWindowMs: number;
  retentionMs: number;
  maxFutureSkewMs: number;
}

export const DEFAULT_PRESENCE_POLICY: PresencePolicy = {
  onlineWindowMs: 60_000,
  awayWindowMs: 5 * 60_000,
  retentionMs: 60 * 60_000,
  maxFutureSkewMs: 30_000,
};

export type PresenceReason = "fresh" | "decayed" | "expired" | "missing" | "clock-skew";

export interface PresenceView {
  userId: string;
  state: PresenceState;
  reason: PresenceReason;
  label: string;
  lastActiveAt: number | null;
  ageMs: number | null;
  stale: boolean;
}

const LABELS: Record<PresenceState, string> = {
  online: "在线",
  away: "离开",
  offline: "离线",
  unknown: "状态未知",
};

export function classifyPresence(
  sample: PresenceSample,
  now: number,
  policy: PresencePolicy = DEFAULT_PRESENCE_POLICY,
): PresenceView {
  assertPolicy(policy);
  if (!Number.isFinite(now)) throw new Error("now 必须是有限数字");
  const make = (state: PresenceState, reason: PresenceReason, ageMs: number | null): PresenceView => ({
    userId: sample.userId,
    state,
    reason,
    label: LABELS[state],
    lastActiveAt: sample.lastActiveAt,
    ageMs,
    // 快照来自本地缓存,没有实时订阅兜底,必须让 UI 知道它可能已经过期。
    stale: state === "unknown" || sample.source === "snapshot",
  });

  if (sample.source === "none" || sample.lastActiveAt === null) {
    return make("unknown", "missing", null);
  }
  if (!Number.isFinite(sample.lastActiveAt)) {
    return make("unknown", "missing", null);
  }

  const rawAge = now - sample.lastActiveAt;
  if (rawAge < -policy.maxFutureSkewMs) {
    // 未来时间超过容忍范围时不可信;不能把负年龄当成「刚刚活跃」。
    return make("unknown", "clock-skew", null);
  }
  const ageMs = Math.max(0, rawAge);

  if (ageMs < policy.onlineWindowMs) return make("online", "fresh", ageMs);
  if (ageMs < policy.awayWindowMs) return make("away", "decayed", ageMs);
  if (ageMs < policy.retentionMs) return make("offline", "decayed", ageMs);
  return make("unknown", "expired", ageMs);
}

// 返回当前状态失效、需要重新判定的第一个毫秒;unknown 不再安排定时器。
export function nextTransitionAt(
  view: PresenceView,
  policy: PresencePolicy = DEFAULT_PRESENCE_POLICY,
): number | null {
  assertPolicy(policy);
  if (view.lastActiveAt === null || view.state === "unknown") return null;
  const at = view.lastActiveAt;
  if (view.state === "online") return at + policy.onlineWindowMs;
  if (view.state === "away") return at + policy.awayWindowMs;
  return at + policy.retentionMs;
}

export function formatLastSeen(view: PresenceView): string | null {
  if (view.lastActiveAt === null || view.state === "unknown" || view.state === "online") {
    return null;
  }
  const ageMs = view.ageMs ?? 0;
  if (ageMs < 60_000) return "1 分钟内活跃";
  const minutes = Math.floor(ageMs / 60_000);
  if (minutes < 60) return `${minutes} 分钟前活跃`;
  const hours = Math.floor(minutes / 60);
  return `${hours} 小时前活跃`;
}

function assertPolicy(policy: PresencePolicy): void {
  const values = [policy.onlineWindowMs, policy.awayWindowMs, policy.retentionMs, policy.maxFutureSkewMs];
  if (values.some((value) => !Number.isFinite(value) || value < 0)) {
    throw new Error("presence 窗口必须是非负有限数字");
  }
  if (!(policy.onlineWindowMs < policy.awayWindowMs && policy.awayWindowMs <= policy.retentionMs)) {
    throw new Error("presence 窗口必须满足 online < away <= retention");
  }
}

几个实现细节值得单独说明:

  • 半开区间。age < onlineWindow 才算在线,年龄正好等于 60 秒时进入 away。这样 nextTransitionAt 返回的边界就是状态真正变化的那一毫秒,不会出现「定时器醒来后状态没变,再睡一毫秒」的抖动。
  • 快照标记 stale。来自本地缓存的样本即使时间很新,也没有实时订阅兜底,stale 必须为真,让 UI 有机会加「上次看到」的限定词。
  • 未知不排定时器。unknown 不会因为时间流逝自动变成别的状态;它需要一条新信号,或用户手动刷新。返回 null 可以避免无意义的唤醒。
  • 文案与状态分离。formatLastSeen 只负责把年龄翻译成中文,不参与判定。产品想改成「刚刚」「昨天」时,不需要碰窗口逻辑。

测试与真实输出

本机运行结果如下:

$ node --test test/presence/presence.test.ts
✔ 窗口内的最近活跃判为在线 (0.595041ms)
✔ 超过在线窗口按半开区间进入离开,边界值不再算在线 (0.086459ms)
✔ 超过离开窗口判为离线,并保留最后活跃时间 (0.070834ms)
✔ 超过保留期后不再声称离线,而是回到状态未知 (0.079375ms)
✔ 没有观测样本时直接判为未知,不猜测离线 (0.065916ms)
✔ 未来时间超过容忍范围时判为时钟异常,不产生负年龄 (0.114292ms)
✔ 缓存快照始终标记 stale,但状态仍按时间衰减 (0.080208ms)
✔ 下一次状态转换指向各窗口边界,非法窗口被拒绝 (0.285ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 107.552458

八条用例覆盖了在线、离开、离线、过期、无样本、时钟异常、缓存快照与下一次转换时间。它们没有覆盖真实心跳与服务端在线表,因为这一层验证的是「给定一个最后活跃时间,端上如何判定与排期」,而不是「服务端如何收集证据」。

失败复盘:socket 连通就是在线

「回声」早期的私信列表用了一个非常直观的字段:对方 socket 还在,就显示绿点。上线后客服收到一类复现路径固定的反馈:用户给一个绿点好友发消息,等了十分钟没有回复;对方其实早在半小时前就把应用切到后台,手机装在口袋里。

复盘时日志很清楚(示例值):对方最后一次心跳是 32 分钟前,但服务端的连接对象一直没有触发 close。移动网络下的 NAT 映射超时不会通知对端,TCP 半开连接可以存活很久;服务端只在写失败或心跳超时后才清理,而心跳超时被设成了 45 分钟。于是这 45 分钟里,所有好友都看到对方「在线」。

修复不是把超时调短,而是换模型:

  1. 服务端只记录 lastActiveAt,在心跳、发消息、已读、输入等真实事件上更新,不再对外广播 online 布尔值。
  2. 客户端按本节窗口计算四态,超过保留期显示「状态未知」。
  3. 多设备按「任一设备有新鲜信号即在线」聚合,单台设备断开不能把整个账号标记为离线。
  4. 增加一条回归用例:最后活跃在 32 分钟前、来源为心跳,断言状态是 offline 而不是 online。

第二个事故来自多设备覆盖。用户的笔记本合盖后 socket 关闭,服务端把账号状态写成 offline,但手机一直在线。好友看到「离线」,消息却秒回。修复后,账号状态由各设备观测的最大值决定,单设备下线只移除该设备的租约;只有当所有设备都过期,账号才进入离线或未知。

多设备如何聚合成一个账号

账号级 presence 不是某台设备的状态,而是多份设备租约的聚合。常见做法是维护一张设备表,按设备记录最后活跃时间,再在读取时取最大值:

设备最后活跃按 1 小时保留期判定是否计入账号
手机20 秒前online是
平板8 分钟前offline否
笔记本3 小时前unknown否

账号状态取所有设备中最新的那一份,因此上表的结果是 online。这样处理有两个好处:一台设备断开不会把整个账号标记为离线;旧设备的过期记录会自然退出聚合,不需要额外的清理任务。

聚合也有边界。第一,设备表不能无限增长,同一账号反复安装、换机、重装会产生大量僵尸设备;需要按设备 id 去重,并给每台设备单独的 TTL。第二,不能把「设备在线」直接等同于「用户在看这台设备」:手机可能放在桌上充电,平板可能挂在后台。账号级状态只能回答「至少有一台设备最近活跃」,不能回答「用户此刻是否在看屏幕」。第三,多设备聚合会放大隐私问题:用户在一台设备上关闭展示,不能只影响这台设备,而要在账号级统一裁决,否则另一台设备的绿点会绕过设置。

与「最后上线时间」的区别

很多产品把「在线」和「最后上线时间」混在一起展示。两者其实是不同的读数:lastActiveAt 是证据的时间戳,online 是证据是否落在窗口内。一个用户可能刚上线又立刻切后台,最后活跃时间很新,但当前不一定在看屏幕;另一个用户可能一整天没上线,最后活跃时间很旧,但状态仍然是确定的离线。

因此界面上不要同时承诺两件事。显示绿点时,不要附带「5 分钟前活跃」;显示「5 分钟前活跃」时,就不要再给绿点。两者同时出现会让用户以为「5 分钟前活跃但现在是绿的」,而模型里根本没有「现在」这个事实。

小结

presence 不是布尔值,而是一条会衰减的证据链:服务端记录最后活跃时间,客户端按窗口判定 online / away / offline / unknown,TTL 到期后回到未知,时钟异常时不假装在线。四态的价值不在于更细,而在于给「证据不足」一个合法的出口。

判定模型确定之后,下一节处理同一类信号里频率最高的一种:用户每敲一个键都可能触发的「正在输入」。

阅读导航:上一节:7.3 断线期间的消息补拉与空洞检测 · 下一节:8.2 输入中状态的节流与超时 。

常见错误与边界

  • 把 socket 连通当成在线。半开连接、系统挂起、NAT 超时都会让绿点长时间说谎。
  • 服务端下发 online 布尔值而不是 lastActiveAt。判断被固化成快照,网络一抖就过时。
  • 没有 unknown 状态。证据不足时被迫显示离线,用户以为对方故意不理。
  • 没有 TTL。进程崩溃或客户端被杀后,在线表只增不减。
  • TTL 到期后显示离线而不是未知。把「不知道」说成了确定事实。
  • 相信未来时间戳,按负年龄算出「未来在线」,排序与文案全部错乱。
  • 用「没收到离线事件」推断在线。离线事件会丢、会晚到、会被多设备覆盖。
  • 单设备断开就把整个账号标记离线。多设备场景下状态被最后一次关闭事件劫持。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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