图 1:本文封面,事件流穿过四环节,水位线标记时间的进度。
「设计一个支持数据库、NoSQL 和大数据之间数据实时流动的系统」是面试里的设计大题,那道题的解析主轴是设计问答展开,本文不重复;今天换个角度,把这类系统背后的流处理概念体系讲透——管道怎么搭、乱序怎么办、下游慢了会怎样、「恰好一次」怎么成立。
先看管道:四个环节各管一件事。
一条实时数据管道可以拆成四段。采集:数据从哪来——前端埋点、服务日志、对数据库的 binlog 订阅(把库里的变更当事件流读出来,这是打通数据库与大数据世界的标准动作)。传输:消息队列承担削峰与缓冲,分区机制保证同一键的事件按序进入同一通道。计算:流引擎做窗口聚合与状态计算,靠检查点给状态拍快照以便故障恢复。落库:结果写入目标存储,写法要么幂等(重复写无副作用)、要么事务化提交。
所谓流批一体,指同一套计算语义既能跑历史数据回放、又能跑实时增量——批处理不再是另一套代码,而是「把有界数据当流的特例」。
难题一:乱序与水位线。
现实里事件不会按发生顺序到达:网络抖动、端上缓存、分区并行,都会让「3 点钟的事件」比「3 点 01 分的事件」晚到。窗口计算需要知道「什么时候可以认为某个时间段的数据到齐了」,这个声明就是水位线。以 Flink 的语义为参照:水位线是随数据流前进的进度标记,Watermark(t) 声明「事件时间已到达 t,之后不应再有更早时间戳的事件」——它是引擎对乱序的容忍承诺。水位线越过窗口结束时间,窗口就结算输出。承诺之外的迟到者怎么办?两条路:允许窗口额外等一段时间,或者把迟到事件分流到侧输出单独处理,而不是硬塞进已结算的窗口。
难题二:背压。
下游消费得慢,压力不会消失,只会沿管道向上游反推:下游缓冲占满,上游的发送就得等着。Flink 对背压的判定基于输出缓冲的可用性——任务没有可用输出缓冲即视为被背压,并把背压时长作为监控指标暴露。工程对策不是无限加缓冲(缓冲只是把洪峰往后挪),而是消费端限速、扩容下游并行度、或给缓冲设配额摊平洪峰。看不懂背压,就会把「下游慢」误诊成「上游堵」。
难题三:端到端一致性。
「恰好一次」常被误解成「每条事件只被处理一次」,精确含义是:每条事件对最终结果的影响恰好发生一次。Flink 官方文档把端到端恰好一次的条件讲得很直白:源头必须可重放(故障后能倒回去重读),落库端必须事务化或幂等。中间的状态一致性靠检查点——定期给所有算子状态拍一致快照,故障后整体回滚重放。三个条件缺一,「恰好一次」就只是口号。
用代码把水位线跑一遍。
下面这份代码实现了一个迷你事件时间窗口处理器:5 秒滚动窗口,水位线取「最大事件时间减 2 秒乱序容忍」,迟到事件走侧输出。用 Node.js 22.15.0 直接运行(node watermark.js)即可:
'use strict';
// 迷你事件时间窗口处理器:5 秒滚动窗口 + 水位线(最大事件时间减 2 秒乱序容忍)
class WindowProcessor {
constructor(windowSizeSec, outOfOrdernessSec) {
this.windowSize = windowSizeSec;
this.bound = outOfOrdernessSec;
this.maxEventTime = -Infinity;
this.windows = new Map(); // 窗口起点 -> 事件列表
this.sideOutput = []; // 水位线之后才到的迟到事件
this.emitted = []; // 已触发输出的窗口结果
}
get watermark() {
return this.maxEventTime - this.bound; // 水位线:「t 之前的事件视为到齐」
}
onEvent(ts, value) {
if (ts <= this.watermark) {
this.sideOutput.push({ ts, value }); // 水位线已越过它
return;
}
const start = Math.floor(ts / this.windowSize) * this.windowSize;
if (!this.windows.has(start)) this.windows.set(start, []);
this.windows.get(start).push(value);
this.maxEventTime = Math.max(this.maxEventTime, ts);
// 水位线越过窗口结束时间的窗口可以结算
for (const [start2, values] of [...this.windows.entries()].sort((a, b) => a[0] - b[0])) {
if (start2 + this.windowSize <= this.watermark) {
this.emitted.push(`[${start2}, ${start2 + this.windowSize}) 秒窗口:${values.length} 条,合计 ${values.reduce((a, b) => a + b, 0)}`);
this.windows.delete(start2);
}
}
}
}
// 演示:一段乱序事件流,事件 = (发生秒, 数值)
const proc = new WindowProcessor(5, 2);
const stream = [
[1, 10], [3, 20], [2, 5], // 第一个窗口乱序到达
[7, 100], // 水位线推到 5,触发 [0,5) 结算
[4, 999], // 水位线 5,4 秒太迟 -> 侧输出
[12, 50], // 水位线推到 10,触发 [5,10) 结算
[8, 77], // 水位线 10,8 秒太迟 -> 侧输出
];
for (const [ts, v] of stream) {
proc.onEvent(ts, v);
console.log(`收到事件 t=${ts}s 值=${v},当前水位线=${proc.watermark}s`);
}
for (const line of proc.emitted) console.log(`窗口输出:${line}`);
console.log(`侧输出(迟到事件):${JSON.stringify(proc.sideOutput)}`);
实测输出:
收到事件 t=1s 值=10,当前水位线=-1s
收到事件 t=3s 值=20,当前水位线=1s
收到事件 t=2s 值=5,当前水位线=1s
收到事件 t=7s 值=100,当前水位线=5s
收到事件 t=4s 值=999,当前水位线=5s
收到事件 t=12s 值=50,当前水位线=10s
收到事件 t=8s 值=77,当前水位线=10s
窗口输出:[0, 5) 秒窗口:3 条,合计 35
窗口输出:[5, 10) 秒窗口:1 条,合计 100
侧输出(迟到事件):[{"ts":4,"value":999},{"ts":8,"value":77}]
两个细节值得对着输出看:t=2 的事件在 t=3 之后到达,但水位线才 1,它仍被正确归入 [0,5) 窗口;而 t=4 与 t=8 到达时水位线已越过它们,只能走侧输出。水位线就是那个「等多久」的旋钮:拧大更准但更慢,拧小更快但漏得多。
图 2:管道四环节与三大难题的对策全景。
常见误区有三个。 一是把水位线当精确时钟,以为设了就绝不丢迟到数据——它是容忍承诺,不是保险单。二是遇到背压只会加缓冲,洪峰只是被推迟,内存照样爆。三是把「开了检查点」当成端到端恰好一次,忽略源头可重放与落库幂等两个前提。
行动建议:新管道先画四环节图再选组件;窗口需求先定乱序容忍再定水位线参数;监控面板把背压时长放到显眼位置。你在实时管道里被乱序或背压坑过吗?欢迎聊聊。