这一节不讨论连接本身
6.1 到 6.3 讨论的是「我这台设备连没连上」:选 WebSocket 还是 SSE、连接状态机怎么迁移、断线后页面怎么降级。7.1 到 7.3 讨论的是「连回来之后消息怎么补齐」。这些都在描述本端与服务器之间的通道。
本节转向另一个问题:我看到的别人,现在是什么状态? 这个状态不由本端连接决定,也不由对方最后一次点击决定。它是服务端若干观测的汇总,再经过网络延迟、客户端时钟和产品窗口衰减之后,落在屏幕上的一句话。8.2 讨论「正在输入」这条高频信号,8.3 讨论隐私与降级;本节只回答 presence 的判定模型本身。
「在线」是观测,不是事实
在单机程序里,一个对象存在就是存在。在分布式系统里,服务端没有能力确认「对方此刻正看着屏幕」。它能知道的只有:
- 刚刚收到过一条心跳;
- 刚刚收到过一次读写或输入事件;
- 某条长连接还没有被判定超时;
- 某个客户端主动发来了「我要下线」。
这些证据都有延迟,也都可能说谎。手机被系统挂起后,TCP 连接可能半开好几分钟,服务端仍持有 socket 对象;用户切换网络时,旧连接的关闭包可能永远到不了;服务端进程重启后,内存里的在线表全部丢失。如果产品把「在线」当成一个布尔事实,它迟早会在这些缝里说谎。
更准确的心智模型是租约(lease):每一次活跃信号把「在线」续期一小段时间,租约到期就降级。客户端展示的不是「他现在在线」,而是「我在多久之前收到过他的活跃信号」。这个措辞很笨拙,但它诚实。
四态:把「不知道」当成一等状态
只有 online / offline 两态时,所有异常都会被迫归入其中一边。网络抖动被显示成离线,缓存过期被显示成在线,用户无法判断哪一次是真的。把状态拆成四态,可以给每种证据一个出口:
| 状态 | 判定条件(示例窗口) | 界面文案 | 含义 |
|---|---|---|---|
online | 最后活跃距今小于 60 秒 | 在线 | 最近有可信信号 |
away | 60 秒到 5 分钟 | 离开 | 信号变旧,但仍在保留期内 |
offline | 5 分钟到 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 分钟里,所有好友都看到对方「在线」。
修复不是把超时调短,而是换模型:
- 服务端只记录
lastActiveAt,在心跳、发消息、已读、输入等真实事件上更新,不再对外广播online布尔值。 - 客户端按本节窗口计算四态,超过保留期显示「状态未知」。
- 多设备按「任一设备有新鲜信号即在线」聚合,单台设备断开不能把整个账号标记为离线。
- 增加一条回归用例:最后活跃在 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 到期后显示离线而不是未知。把「不知道」说成了确定事实。
- 相信未来时间戳,按负年龄算出「未来在线」,排序与文案全部错乱。
- 用「没收到离线事件」推断在线。离线事件会丢、会晚到、会被多设备覆盖。
- 单设备断开就把整个账号标记离线。多设备场景下状态被最后一次关闭事件劫持。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。