本节不重复协议层的补发
附录 A 讲过 Last-Event-ID、WebSocket 的业务确认与长轮询的游标,这些是通道能力,解决「服务端能不能从某个位置继续发」。本节解决端上的另一半:服务端愿意补,客户端怎么知道自己该从哪补、补回来的消息怎么与实时推送合并、补不全时怎么不假装完整。 7.2 的顺序门负责「到达之后怎么排队」,本节负责「没到达的怎么发现并取回来」;顺序门看到的是一个缺口状态,补拉逻辑要把这个状态变成一组区间请求。
重连成功后的第一个错误
最常见的补拉实现只有一行:重连成功后请求「最新一条消息」,把它显示出来,然后继续监听。这个做法在聊天里看起来完全正常:用户回到应用,看到最后一条消息,界面底部是最新内容,没有报错。问题是中间。假设断线前用户看到序列号 100,断线期间产生了 101 到 140,重连后只拉 140;界面上 140 出现了,101 到 139 不在。如果产品只展示最新消息、不回溯,用户可能暂时发现不了;一旦往上滑,或者 141 到达后触发一次「加载更多」,缺口就暴露成一段空白。
更糟的是,这个错误会自我固化。如果端上把「已收最大序列号」当成下一次补拉的起点,那么拉过 140 之后,下次就从 141 开始。101 到 139 永远不会再被请求。服务端的历史保留期一过,它们就真的丢了。「只拉最新一条」不是少拉了几条,而是把缺口从「待补」变成了「已越过」。
高水位不是已收最大值
补拉的正确起点不是「收到过的最大序列号」,而是「连续应用到的最大序列号」。这两个值在乱序时不同:
| 已收序列号 | 已收最大值 | 连续高水位 | 真实缺口 |
|---|---|---|---|
| 1, 2, 3 | 3 | 3 | 无 |
| 1, 2, 5 | 5 | 2 | 3–4 |
| 1, 4, 5, 6 | 6 | 1 | 2–3 |
第三行是关键:最大值是 6,但 2 和 3 从未到达。如果拿 6 当起点,2 和 3 会被永久跳过;拿高水位 1 当起点,补拉区间是 2–6,其中 4、5、6 已经在缓冲区里,补回来会被去重,2、3 则被真正补上。高水位的定义必须写进代码注释:它是「从起点开始,每一条都连续到达」的最大序列号,不是「见过的最大的那个」。 这两个概念混淆一次,补拉逻辑就会在第一次乱序时失效,而且失效得很安静。
空洞是怎么被看见的
端上需要维护三样东西:highWater 是连续应用到的最大序列号;buffered 保存序列号大于 highWater + 1 的已到达消息,按序列号索引;seenIds 记录已经处理过的消息 id,用于补拉与实时推送之间的去重。有了这三样,空洞可以随时计算:从 highWater + 1 开始向上扫描,跳过 buffered 里已有的序列号,剩下的连续段就是缺失区间。这个过程不需要知道服务端总共有多少消息,只需要一个上界——通常是服务端返回的当前高水位。
空洞检测与顺序门有一个重要区别:顺序门只需要判断「这条消息现在能不能应用」,补拉逻辑需要判断「哪些区间值得发请求」。前者是单条判定,后者是区间生成。把两者混在一个方法里,通常会导致「每来一条消息就发一次补拉请求」,把恢复过程变成请求风暴。
生成补拉区间
生成区间需要三个输入:highWater、buffered 的键集合、服务端高水位。算法很直接:从 highWater + 1 扫描到服务端高水位;遇到 buffered 里已有的序列号,结束当前缺口段;其余连续序列号合并成 { from, to },再按批大小切分,避免一次请求跨几千条消息。
批大小的取舍与上传分片类似:太大,单次响应慢、超时后重试成本高;太小,请求数多、恢复时间长。50–200 是一个常见起点,具体值应该由消息平均大小与服务端单次返回上限决定,而不是拍脑袋。
区间必须按序列号升序请求。补拉的目的是尽快把 highWater 推到服务端高水位;如果先拉后面的区间,前面的缺口仍然卡着,highWater 不会前进,缓冲区会越来越大。升序请求还有一个好处:一旦前面的区间补上,后面已缓冲的消息会立刻被连续放行,后续请求可能直接变成空操作。
补拉与实时推送并发
补拉不是「暂停实时通道,等历史拉完再恢复」。用户重连后,新消息仍在产生;如果暂停推送,补拉结束时又会缺一段。正确做法是让两条路径进入同一个状态容器:
- 实时推送到达时,直接调用
ingest,与补拉响应走同一条去重与排序路径。 - 补拉响应到达时,也调用
ingest;已经在实时路径处理过的消息按 id 去重。 - 补拉期间实时消息推进了
highWater,后续补拉区间即使包含这些序列号,也会被late或duplicate丢弃,不会重复应用。
这要求补拉请求发出前计算的区间允许「过期」。区间是一个提示,不是合同:服务端返回的消息可能已经被实时路径应用,端上必须能安全地重复处理。补拉逻辑的正确性不能依赖「请求发出后状态不变」。 另一个约束是单飞:同一时刻只允许一个补拉流程。用户可能连续触发重连、回到前台、手动刷新,如果每次都启动一个补拉,多个响应会交错进入容器,虽然去重能兜底,但请求量与日志会失控;用一个 running 标志拒绝并发补拉,让后来的调用等待或直接返回「已在进行中」。
补拉不是一次请求
服务端可能只返回一部分:单次响应有条数上限、历史保留期截断了更早的消息、或者请求在传输中被截断。补拉流程必须能表达「还没补完」:
- 响应返回后重新计算剩余空洞,而不是假设「请求过的区间就完整了」。
- 有剩余空洞时,报告
remainingGaps,由上层决定立即再拉、退避后重试,还是提示用户。 - 重试仍然使用 7.1 的退避与上限,不能因为「这是补拉」就无限重试。
- 「补拉完成」的判据不是「请求成功」,而是「没有剩余空洞,或服务端明确告知某段已不可用」;前者是正常完成,后者是部分完成,两者都必须能传到 UI。
一个可运行的补拉模块
下面的模块属于 L2:它没有真实端口,但包含异步请求、可控响应与假时钟,能验证「补拉期间实时消息到达」这种时序问题。GapTracker 负责状态,CatchUpSession 负责编排。
// file: src/reliability/gap.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/reliability/gap.test.ts
export interface SequencedMessage {
id: string;
seq: number;
}
export interface SeqRange {
from: number;
to: number;
}
export type GapIngestStatus = "applied" | "buffered" | "duplicate" | "late";
export interface GapIngestResult {
status: GapIngestStatus;
applied: SequencedMessage[];
gap: SeqRange | null;
}
export interface GapTrackerOptions {
startSeq?: number;
seenLimit?: number;
historyLimit?: number;
}
export class GapTracker {
#highWater: number;
#seenLimit: number;
#historyLimit: number;
#seen = new Set<string>();
#seenOrder: string[] = [];
#buffered = new Map<number, SequencedMessage>();
#appliedOrder: number[] = [];
constructor(options: GapTrackerOptions = {}) {
const startSeq = options.startSeq ?? 0;
const seenLimit = options.seenLimit ?? 4096;
const historyLimit = options.historyLimit ?? 1024;
if (!Number.isInteger(startSeq) || startSeq < 0) throw new Error("startSeq 必须是非负整数");
if (!Number.isInteger(seenLimit) || seenLimit < 1) throw new Error("seenLimit 必须是正整数");
if (!Number.isInteger(historyLimit) || historyLimit < 1) throw new Error("historyLimit 必须是正整数");
this.#highWater = startSeq;
this.#seenLimit = seenLimit;
this.#historyLimit = historyLimit;
}
get highWater(): number {
return this.#highWater;
}
get pendingCount(): number {
return this.#buffered.size;
}
appliedSeqs(): number[] {
return [...this.#appliedOrder];
}
ingest(message: SequencedMessage): GapIngestResult {
assertMessage(message);
if (this.#seen.has(message.id)) return { status: "duplicate", applied: [], gap: null };
if (message.seq <= this.#highWater) {
this.#remember(message.id);
return { status: "late", applied: [], gap: null };
}
if (message.seq > this.#highWater + 1) {
const existing = this.#buffered.get(message.seq);
if (existing) {
// 同一个序列号被两条不同 id 占用,按重复处理并保留先到者。
return { status: "duplicate", applied: [], gap: this.#gapBefore(message.seq) };
}
this.#buffered.set(message.seq, message);
this.#remember(message.id);
return { status: "buffered", applied: [], gap: this.#gapBefore(message.seq) };
}
this.#remember(message.id);
const applied: SequencedMessage[] = [message];
this.#highWater = message.seq;
this.#appliedOrder.push(message.seq);
while (this.#buffered.has(this.#highWater + 1)) {
const next = this.#buffered.get(this.#highWater + 1) as SequencedMessage;
this.#buffered.delete(next.seq);
applied.push(next);
this.#highWater = next.seq;
this.#appliedOrder.push(next.seq);
}
this.#trimHistory();
return { status: "applied", applied, gap: null };
}
// 只报告已缓存消息之间能确认的空洞;没有上界时无法推断尾部缺失。
gaps(): SeqRange[] {
return this.#missing(this.#bufferedHigh());
}
// 以服务端高水位为上界,生成补拉区间,并按 batchSize 切分。
planPull(serverHighWater: number, batchSize = 50): SeqRange[] {
if (!Number.isInteger(serverHighWater) || serverHighWater < 0) {
throw new Error("serverHighWater 必须是非负整数");
}
if (!Number.isInteger(batchSize) || batchSize < 1) throw new Error("batchSize 必须是正整数");
const upTo = Math.max(serverHighWater, this.#bufferedHigh());
return this.#missing(upTo).flatMap((range) => splitRange(range, batchSize));
}
#bufferedHigh(): number {
let high = this.#highWater;
for (const seq of this.#buffered.keys()) {
if (seq > high) high = seq;
}
return high;
}
#gapBefore(seq: number): SeqRange | null {
const ranges = this.#missing(seq - 1);
return ranges.length > 0 ? ranges[0] : null;
}
#missing(upTo: number): SeqRange[] {
const ranges: SeqRange[] = [];
let start = -1;
for (let seq = this.#highWater + 1; seq <= upTo; seq += 1) {
if (this.#buffered.has(seq)) {
if (start !== -1) {
ranges.push({ from: start, to: seq - 1 });
start = -1;
}
continue;
}
if (start === -1) start = seq;
}
if (start !== -1) ranges.push({ from: start, to: upTo });
return ranges;
}
#remember(id: string): void {
if (this.#seen.has(id)) return;
this.#seen.add(id);
this.#seenOrder.push(id);
while (this.#seenOrder.length > this.#seenLimit) {
const oldest = this.#seenOrder.shift() as string;
this.#seen.delete(oldest);
}
}
#trimHistory(): void {
if (this.#appliedOrder.length > this.#historyLimit) this.#appliedOrder.shift();
}
}
export interface PullSource {
serverHighWater(): number;
fetchRange(range: SeqRange): Promise<SequencedMessage[]>;
}
export interface CatchUpReport {
requests: number;
received: number;
applied: number;
duplicates: number;
remainingGaps: SeqRange[];
highWater: number;
finishedAt: number;
}
export type Clock = () => number;
export class CatchUpSession {
#tracker: GapTracker;
#source: PullSource;
#now: Clock;
#running = false;
constructor(tracker: GapTracker, source: PullSource, now: Clock = () => Date.now()) {
this.#tracker = tracker;
this.#source = source;
this.#now = now;
}
get running(): boolean {
return this.#running;
}
// 补拉期间实时通道仍可能推送,直接进入同一个 tracker,由 id 去重合并。
ingestLive(message: SequencedMessage): GapIngestResult {
return this.#tracker.ingest(message);
}
async catchUp(): Promise<CatchUpReport> {
if (this.#running) throw new Error("补拉已在进行中");
this.#running = true;
try {
const serverHigh = this.#source.serverHighWater();
const ranges = this.#tracker.planPull(serverHigh);
let requests = 0;
let received = 0;
let applied = 0;
let duplicates = 0;
for (const range of ranges) {
const page = await this.#source.fetchRange(range);
requests += 1;
received += page.length;
for (const message of page) {
const result = this.#tracker.ingest(message);
applied += result.applied.length;
if (result.status === "duplicate") duplicates += 1;
}
}
return {
requests,
received,
applied,
duplicates,
remainingGaps: this.#tracker.planPull(Math.max(serverHigh, this.#tracker.highWater)),
highWater: this.#tracker.highWater,
finishedAt: this.#now(),
};
} finally {
this.#running = false;
}
}
}
function splitRange(range: SeqRange, batchSize: number): SeqRange[] {
const chunks: SeqRange[] = [];
for (let from = range.from; from <= range.to; from += batchSize) {
chunks.push({ from, to: Math.min(range.to, from + batchSize - 1) });
}
return chunks;
}
function assertMessage(message: SequencedMessage): void {
if (typeof message.id !== "string" || message.id.length === 0) throw new Error("id 不能为空");
if (!Number.isInteger(message.seq) || message.seq < 1) throw new Error("seq 必须是正整数");
}
实现里的三个决定
- 用高水位,不用最大值。
planPull从#highWater + 1开始扫描,而不是从缓冲区里的最大值开始。用最大值当起点,看似能少发几次请求,实际会把「中间的空洞」永久跳过;用高水位当起点,补回来的已缓冲消息会被去重,代价只是少量重复传输。 - 缓冲区既参与生成区间,也参与去重。
#buffered的键在#missing里被跳过,避免请求已经在本地等待的消息;同时ingest会按 id 去重,防止补拉响应与实时推送重复应用同一条。 - 单飞与「未完成」是一等状态。
CatchUpSession用#running拒绝并发补拉,CatchUpReport用remainingGaps表达「还没补完」。调用方不能只检查 Promise 是否 resolve,还要检查剩余空洞。
测试与真实输出
本机运行结果如下:
$ node --test --test-concurrency=1 test/reliability/gap.test.ts
✔ 按已收序列识别缺口 (0.996042ms)
✔ 补拉区间按批大小切分 (0.260917ms)
✔ 补拉结果与已缓存的实时消息合并 (0.267209ms)
✔ 补拉期间到达的实时消息不重复应用 (0.186583ms)
✔ 只补最新一条会留下永远补不上的空洞 (0.10275ms)
✔ 已经连续时补拉不发请求 (0.0895ms)
✔ 服务端返回不完整时保留剩余空洞 (0.092709ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 175.77425
七条用例覆盖了缺口识别、分批、与实时消息合并、并发去重、只拉最新一条的失败、空操作与不完整响应。它们没有覆盖真实网络与服务端历史保留策略,因为这一层验证的是「给定已收序列号与服务端高水位,端上生成什么区间、合并后处于什么状态」。
失败复盘:只拉最新一条
「回声」的一个群聊频道上线后,客服收到一种零散反馈:用户从电梯出来后,往上滑能看到一段空白,但新消息正常。复现路径很稳定:进电梯断网两分钟,出来重连,群里在这两分钟里发了三十条消息。用户看到最新一条,往上滑却只有断网前的最后一条,中间三十条不在。日志还原了完整链路(示例值):
- 断线前
highWater = 100。 - 断线期间服务端产生 101–130。
- 重连后客户端请求「最新一条」,拿到 130,界面显示,同时把本地游标更新为 130。
- 此后实时消息从 131 正常应用,
highWater变成 131、132……;101–129 从未被请求,三天后服务端历史过期,它们永久缺失。
这个 bug 之所以难被发现,是因为它不产生错误、不产生重复、不影响新消息。用户只会在「往上滑」这个动作里看到异常,而大多数用户不会在断线后立刻回溯。直到有人要找一条断网期间的消息,才报上来。修复分三步:
- 端上不再用「最新一条」的序列号更新游标,而是维护连续高水位;重连后计算
highWater + 1到服务端高水位的区间。 - 补拉响应与实时推送进入同一个 tracker,按消息 id 去重,避免修复引入重复。
- 增加一条回归用例:已收 1,直接收到 5,断言
gaps()为[3,4]、planPull(5)仍包含这段区间。这条用例直接对应事故,不依赖真实网络。
修复上线后,同类反馈消失。更重要的是,补拉报告里开始出现 remainingGaps:当服务端历史保留期截断了更早的消息时,端上不再假装完整,而是能把这个状态交给产品决定怎么提示。
补拉完成不等于消息完整
服务端可能已经删除了请求区间内的消息:历史保留期到期、频道被清理、消息被合规删除。这时补拉请求成功,返回的条数却少于区间长度;端上不能把「请求成功」当成「区间完整」,否则会制造一个看不见的洞。正确的处理是让服务端明确返回「这段已不可用」或「返回被截断」,端上把剩余空洞标记为 unavailable,而不是继续重试。产品可以选择显示「部分历史消息已过期」,也可以选择不展示这段,但不能假装它不存在。用户看到一段空白,至少知道发生了什么;看到消息序号跳变却没有任何提示,才会怀疑应用坏了。
补拉也不能修复端上从未知道的序列号。如果服务端高水位是 130,端上只知道 100,它能补 101–130;如果服务端自己也不知道 101–129 去哪了,端上没有任何办法。端上的职责是「把可观测的缺口补上并报告不可补的部分」,不是「保证服务端数据永远完整」。
小结
补拉的核心不是「再请求一次」,而是「知道从哪里继续」。高水位是连续应用的最大序列号,不是收到过的最大值;补拉区间从高水位向上扫描,跳过已缓冲的序列号,按批请求;补拉与实时推送进入同一个容器,用消息 id 去重;补不全时要报告剩余空洞,而不是用成功响应掩盖。
到这里,实时可靠性的三块拼图齐了:重连负责把管道接回来,顺序门负责去重与排队,补拉负责把缺口填上。下一节离开消息层,讨论 presence:为什么端上看到的「在线」永远只是一个近似值。
阅读导航:上一节:7.2 消息去重与有序:端上能保证什么 · 下一节:8.1 presence 的近似语义:没有真正的「在线」 。
常见错误与边界
- 重连后只拉最新一条。中间缺口被游标越过,历史过期后永久丢失。
- 用已收最大序列号当补拉起点。乱序时会把中间空洞当成已覆盖。
- 补拉区间不跳过已缓冲的序列号。为本地已有的消息发无用请求。
- 补拉与实时推送使用两个容器。同一消息可能被应用两次,或顺序互相覆盖。
- 并发触发多次补拉。响应交错,日志与请求量失控,去重只能兜底不能防错。
- 把「请求成功」当成「补拉完成」。服务端截断或历史过期时,端上仍显示完整。
- 剩余空洞只写日志不传 UI。用户看到空白,产品却不知道发生过什么。
- 补拉失败后无限重试。补拉也是请求,必须遵守退避与次数上限。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。