本节只处理一条高频信号
8.1 把 presence 写成了「最后活跃时间加衰减窗口」的近似模型,它处理的是秒级到分钟级的低频状态。本节处理同一类信号里最吵的一种:用户正在输入。它由每一次按键触发,天然比心跳高两个数量级;如果不加约束,它会成为整个实时通道里最贵的消息。
本节不讨论消息是否送达、顺序如何保证(6.x 与 7.x 已经覆盖),也不讨论谁有权看到输入状态(8.3)。我们只回答一个端上问题:本地什么时候发、发多频繁、远端什么时候该让它消失、丢包时靠什么兜底。
事件模型错在哪里
最直觉的实现只有两个事件:用户开始打字时发 typing:start,停止打字、发送消息或失焦时发 typing:stop。在局域网的演示里它工作得很好,上线后会遇到三种故障:
| 故障 | 现象 | 根因 |
|---|---|---|
| stop 丢失 | 对方一直看到「正在输入」 | 停止事件是普通消息,没有重传 |
| 进程被杀 | 同上,且永远不会恢复 | 客户端没有机会发 stop |
| 每个键都发 | 服务端广播量暴涨、移动端耗电 | 输入事件频率远高于展示需要 |
三种故障指向同一个结论:不要把「正在输入」建模成一个需要显式关闭的开关,而要建模成一份会自动过期的租约。 每一条 start 只承诺「接下来几秒内我可能还在输入」,到期自动失效。stop 只是让租约提前结束的优化,不是正确性的必要条件。
这个模型还有一个副产品:服务端不需要为每个会话维护「谁在输入」的持久状态,只需要一个带 TTL 的短键。客户端也不需要处理「stop 没收到」的特殊分支,超时逻辑本来就是主路径。
两个时间常数
租约模型只需要两个数:
throttleMs:本地最多多久发一次 start。它决定上行频率与对方看到的实时感。timeoutMs:远端收到 start 后,多久没有新信号就自动隐藏。它决定丢包后的残留时间。
示例值取 throttleMs = 3000、timeoutMs = 6000。关键约束是 timeout 必须明显大于 throttle。如果 timeout 小于等于 throttle,用户在持续输入时,两条 start 之间会有一段空窗,对方的「正在输入」会周期性闪烁;如果 timeout 刚好等于 throttle,时钟抖动与网络延迟就足以让闪烁变成常态。常见的比例是 2 倍左右:节流 3 秒、超时 6 秒,允许丢一条心跳。
这两个值不是协议常量,而是产品参数。输入指示器越实时,throttle 越小、上行越贵;超时越长,丢包后的残留越久。它们应该和 8.1 的 presence 窗口一样,写进同一份客户端配置,并在服务端做上限校验,防止某个旧版本客户端用 100 毫秒的节流把集群打满。
本地节流:第一下必须立即发
节流有两种常见写法:debounce(停止输入后才发)与 throttle(固定窗口内最多发一次)。输入指示器必须用 throttle 的前沿触发:用户敲下第一个字符时立刻发,之后同一窗口内的按键全部静默,窗口结束时如果还在输入,再发一条作为心跳。
debounce 的错误在于延迟:用户已经开始打字,对方却要等他停顿几百毫秒才看到提示,实时感完全丢失。更糟的是,短消息往往在 debounce 计时器触发前就已经发送,对方永远看不到「正在输入」。
前沿触发还带来一个可测试的性质:给定一串按键时间,发送时刻只由第一个按键与节流窗口决定,与按键总数无关。测试里不需要模拟真实输入法,只要推进假时钟并断言返回值。
持续输入时的重发不是冗余,而是租约的心跳。如果只在第一下发送,用户连续打一段长文字超过 timeout 后,对方会以为他停了;而实际上他一直在输入。每过一个 throttle 窗口补一条 start,才能让远端租约持续续期。
停止:只在有意义时发
发送消息、清空输入框、切换会话、页面失焦,都可以触发 stop。但 stop 不是必须的,而且不是每次都值得发:
- 如果本地从未发出过 start,远端根本没有租约,发 stop 是纯浪费。
- 如果最后一条 start 已经超过 timeout,远端早已自动隐藏,stop 同样没有意义。
- 如果 start 可能丢包,本地无法确定远端是否收到,stop 仍然值得发;它最多是一条无害的空操作。
因此 stopLocal 返回一个布尔值:调用方只在返回 true 时上行。这样既减少无意义请求,又不会因为过度优化而留下残留状态。清理本地节流记录必须无条件执行,否则用户下次输入会被上一个会话的时间戳压住。
远端超时:不要依赖 stop
接收端的正确性只依赖超时。收到 start 时记录 expiresAt = signalTime + timeoutMs;读取时比较 expiresAt > now。stop 到达就立即删除,但即使 stop 永远不到,状态也会在超时后消失。
这里有两个容易写错的细节。第一,过期判断要用半开区间:expiresAt <= now 即失效,避免边界上多显示一毫秒。第二,不要用「本地收到时间」代替「信号时间」来算过期。消息在队列里排队、重连后补发、多路径乱序,都会让到达时间晚于产生时间;用到达时间会把一条十秒前的 start 续成新的租约,让幽灵输入状态活得更久。
如果协议里没有信号时间,只能在到达时打时间戳,那就必须接受「延迟到达会延长显示」的误差,并在文档里写清楚。更好的做法是让上行消息携带客户端时间,服务端在转发时保留或改写为服务端时间,端上以其中一个为准,不要两套混用。
会话隔离与乱序
输入状态必须按 conversationId + userId 建键。只按用户建键,会让 A 会话的输入出现在 B 会话;只按会话建键,会把多个人的状态混成一个。测试里要专门验证:在 c1 收到 u2 的 start,不会让 c2 的 activeTypers 出现 u2;在 c2 收到 u2 的 stop,也不会清掉 c1 的状态。
乱序则更隐蔽。网络可能把 stop 排在一条更早的 start 前面到达;如果接收端只做「start 写入、stop 删除」,迟到的 start 会把已经结束的状态重新点亮。正确做法是记录每个会话、每个用户的最后 stop 时间作为墓碑:任何时间戳不晚于墓碑的 start 一律丢弃。同理,比当前记录更旧的 start 也不能覆盖更新的 start。
多设备是这套模型的边界。同一个账号在手机和电脑上同时输入,按用户聚合时,一台设备发出的 stop 会清掉另一台设备的租约。要彻底解决,需要把键细化到设备,再在展示层按用户聚合「任一设备活跃」;本节为了聚焦节流与超时,仍按用户建键,并在实现里明确这个限制。
一个可运行的跟踪器
下面的模块属于 L2:没有真实网络,但包含本地发送决策、远端租约、假时钟与乱序处理,能完整验证「丢 start」「丢 stop」「stop 后旧 start 迟到」三类时序问题。
// file: src/presence/typing.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/presence/typing.test.ts
export interface TypingPolicy {
throttleMs: number;
timeoutMs: number;
}
export const DEFAULT_TYPING_POLICY: TypingPolicy = {
throttleMs: 3_000,
timeoutMs: 6_000,
};
export type Clock = () => number;
interface RemoteEntry {
expiresAt: number;
lastSignalAt: number;
}
export class TypingTracker {
#throttleMs: number;
#timeoutMs: number;
#now: Clock;
#lastSentAt = new Map<string, number>();
#startedAt = new Map<string, number>();
#remote = new Map<string, Map<string, RemoteEntry>>();
#closedAt = new Map<string, Map<string, number>>();
constructor(policy: TypingPolicy = DEFAULT_TYPING_POLICY, now: Clock = () => Date.now()) {
if (!Number.isInteger(policy.throttleMs) || policy.throttleMs < 1) {
throw new Error("throttleMs 必须是正整数");
}
if (!Number.isInteger(policy.timeoutMs) || policy.timeoutMs <= policy.throttleMs) {
throw new Error("timeoutMs 必须大于 throttleMs,否则心跳之间会闪烁");
}
this.#throttleMs = policy.throttleMs;
this.#timeoutMs = policy.timeoutMs;
this.#now = now;
}
// 本地按键:返回是否应立即上行发送 start。首次与每个节流窗口的第一下都发送。
noteKeystroke(conversationId: string, at: number = this.#now()): boolean {
assertId(conversationId, "conversationId");
assertTime(at);
const last = this.#lastSentAt.get(conversationId);
if (last === undefined || at - last >= this.#throttleMs) {
this.#lastSentAt.set(conversationId, at);
this.#startedAt.set(conversationId, at);
return true;
}
return false;
}
// 发送、清空或失焦:只有远端可能仍显示「正在输入」时才需要发 stop。
stopLocal(conversationId: string, at: number = this.#now()): boolean {
assertId(conversationId, "conversationId");
assertTime(at);
const started = this.#startedAt.get(conversationId);
this.#lastSentAt.delete(conversationId);
this.#startedAt.delete(conversationId);
if (started === undefined) return false;
// start 发出后若已超过远端超时,对方早已自动隐藏,不必再补一条 stop。
return at - started < this.#timeoutMs;
}
ingest(conversationId: string, userId: string, kind: "start" | "stop", at: number): void {
assertId(conversationId, "conversationId");
assertId(userId, "userId");
assertTime(at);
const entries = this.#remote.get(conversationId) ?? new Map<string, RemoteEntry>();
if (kind === "stop") {
entries.delete(userId);
this.#rememberClose(conversationId, userId, at);
this.#remote.set(conversationId, entries);
return;
}
const closed = this.#closedAt.get(conversationId)?.get(userId);
// stop 之后才到达的旧 start 不能复活输入状态。
if (closed !== undefined && at <= closed) return;
const previous = entries.get(userId);
// 乱序的旧 start 不能缩短或覆盖已经更新的 start。
if (previous && at < previous.lastSignalAt) return;
entries.set(userId, { expiresAt: at + this.#timeoutMs, lastSignalAt: at });
this.#remote.set(conversationId, entries);
}
isTyping(conversationId: string, userId: string, at: number = this.#now()): boolean {
assertTime(at);
this.prune(conversationId, at);
const entry = this.#remote.get(conversationId)?.get(userId);
return entry !== undefined && entry.expiresAt > at;
}
activeTypers(conversationId: string, viewerId?: string, at: number = this.#now()): string[] {
assertTime(at);
this.prune(conversationId, at);
const entries = this.#remote.get(conversationId);
if (!entries) return [];
return [...entries.keys()].filter((userId) => userId !== viewerId).sort();
}
prune(conversationId: string, at: number = this.#now()): number {
const entries = this.#remote.get(conversationId);
if (!entries) return 0;
let removed = 0;
for (const [userId, entry] of entries) {
if (entry.expiresAt <= at) {
entries.delete(userId);
removed += 1;
}
}
return removed;
}
#rememberClose(conversationId: string, userId: string, at: number): void {
const closed = this.#closedAt.get(conversationId) ?? new Map<string, number>();
closed.set(userId, Math.max(closed.get(userId) ?? Number.NEGATIVE_INFINITY, at));
this.#closedAt.set(conversationId, closed);
}
}
function assertId(value: string, field: string): void {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`${field} 必须是非空字符串`);
}
}
function assertTime(value: number): void {
if (!Number.isFinite(value)) throw new Error("typing 时间必须是有限数字");
}
noteKeystroke 只回答「现在该不该发」,不直接调用网络;ingest 只接收已经到达的信号,不关心传输层。这种拆分让发送策略与接收状态可以分别测试,也方便在真实适配层里把 true 映射成一次上行调用。
测试与真实输出
本机运行结果如下:
$ node --test --test-concurrency=1 test/presence/typing.test.ts
✔ 首次按键立即发送,节流窗口内保持静默 (0.639333ms)
✔ 持续输入时按节流窗口重发,作为远端租约的心跳 (0.079542ms)
✔ 没发过 start 时不发 stop,停止后再次输入立即重发 (0.090459ms)
✔ start 已超过远端超时后才停止,不必再发 stop (0.461583ms)
✔ 远端 start 在超时前活跃,到达超时边界后自动消失 (0.519708ms)
✔ 远端 stop 立即清除状态,不等超时 (0.152542ms)
✔ 会话之间完全隔离,stop 不跨会话生效 (0.090583ms)
✔ start 丢包时不凭本地按键推断,stop 丢包时由超时兜底 (0.063292ms)
✔ stop 之后到达的旧 start 不会复活,活跃列表按 id 排序并排除自己 (0.31125ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 146.103083
九条用例覆盖了首次发送、节流边界、持续输入心跳、无 start 不发 stop、超时后不发 stop、远端过期、stop 立即清除、会话隔离、两类丢包与乱序墓碑。它们没有覆盖真实输入法与网络抖动,因为这一层验证的是「给定按键与信号时间,端上做出什么决策」,而不是「输入法多久触发一次事件」。
失败复盘:每敲一个键发一条
「回声」的群聊最初把 input 事件直接接到发送函数上。上线当天的监控出现了明显的尖峰(示例值):一个 200 人的活跃群,平均每位发言者每秒产生 8 到 12 次按键事件;单条 typing 消息要扇出给 199 个成员,于是一个人的输入就能制造约 2000 条下行消息每秒。群里有十几个人同时打字时,实时通道的队列被 typing 占满,真正的消息开始延迟。
第一版修复是加服务端限流:每个用户每秒最多转发一条 typing。结果更糟:限流是随机丢的,start 和 stop 被丢的概率一样,对方的指示器开始随机闪烁;有时 stop 被丢,状态挂几分钟。限流没有解决模型问题,只是把洪峰变成了噪声。
第二版改成客户端节流加远端超时:
- 第一个按键立即发 start,之后每 3 秒最多补一条心跳。
- 接收端把每条 start 当作 6 秒租约,到期自动删除。
- stop 只在本地确实发过 start、且未超过超时时上行。
- 服务端不再按条数限流,而是按「每用户每会话每 3 秒一条」做幂等窗口,重复的 start 直接合并。
上线后 typing 的下行量下降了两个数量级,指示器也不再闪烁。第二类事故是「幽灵输入」:用户发送消息后切走,stop 在弱网里丢失,对方看到「正在输入」挂了十几分钟。超时上线后,这类反馈自然消失,因为 stop 从正确性路径降级成了优化路径。
与草稿、已读的边界
输入中状态不是草稿。草稿要持久化、要跨设备同步、要在崩溃后恢复;typing 只是一个几秒内有效的提示,不应该写数据库,也不应该在重连后补发。把它当草稿存,会在服务端留下大量无意义的短命记录;把它当消息补发,会让用户重连后看到一堆过期的「正在输入」。
它也不是已读回执。已读是一个明确的位置确认,需要持久化与幂等;typing 只是一个概率性提示,丢了就丢了。两者都可以走实时通道,但生命周期与一致性要求完全不同,不能共用一套重试与补拉逻辑。
最后,不要把自己的输入状态展示给自己。己方 UI 已经有光标与文本,额外的「你正在输入」既冗余又容易在发送后残留。activeTypers 接受一个 viewerId 并在返回时排除自己,就是为了把这条产品规则固化在模型里。
小结
「正在输入」不是开关,而是租约:本地用前沿节流把每次按键压成三秒一条的心跳,远端用更大的超时自动收尾,stop 只做提前清理。会话键要带 conversationId,乱序要用 stop 墓碑挡住迟到的 start,丢包则交给超时兜底。
输入状态解决的是「对方可能正在写」,但它是否应该被看到,是另一个问题。下一节讨论状态展示的隐私边界:当对方关闭在线、拉黑你,或者你自己的连接已经降级时,界面应该如何表达「不知道」。
阅读导航:上一节:8.1 presence 的近似语义:没有真正的「在线」 · 下一节:8.3 状态展示的降级与隐私边界 。
常见错误与边界
- 每个按键都发一条 typing。群聊扇出会瞬间打满实时通道,移动端还会额外耗电。
- 用 debounce 代替 throttle。对方要等用户停顿才看到提示,短消息甚至完全看不到。
- timeout 小于等于 throttle。持续输入时指示器周期性闪烁。
- 只发一次 start 不补心跳。长文本输入超过超时后,对方以为用户已经停止。
- 依赖 stop 保证状态消失。stop 会丢、进程会被杀,超时才是正确性底线。
- 用本地到达时间计算过期。延迟或补发的旧信号会被错误续期。
- 只按 userId 建键。A 会话的输入会出现在 B 会话,stop 还会跨会话误清。
- 不挡 stop 之后迟到的 start。已经结束的输入状态会被旧包重新点亮。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。