题干与适用场景
移动端会上报购买事件,字段至少包含 eventid、eventtime、userid 和已经换算到统一币种的 amount。网络重试可能产生重复事件,离线设备会在数小时后补发,不同分区也不会保持全局顺序。请按 eventtime 计算 UTC 自然小时营收,峰值输入为每秒 2 万条事件。
业务希望当前小时的估算值在 1 分钟内可见,水位线越过窗口结束时间后产生准最终结果,并在窗口结束后的 24 小时内接受修正。超过 24 小时才到达的数据不能静默丢失,需要进入审计与离线对账。以上吞吐、时效和修正窗口都是本题输入,不是某个流处理引擎的性能承诺。
本题假设 event_id 由事件生产方稳定生成,同一 ID 的合法重试具有相同业务内容;原始事件会保存在可重放存储中。范围包括时间语义、watermark、窗口触发、去重、结果更新、状态容量、恢复和对账,不展开消息中间件选型与多币种汇率计算。核心能力属于数据工程:在完整性、延迟和状态成本之间建立能被验证的数据契约。
面试官考察点
第一个信号是能否把三个时间分开。eventtime 决定事件属于哪个业务小时;processingtime 表示引擎何时看到事件,可用于每分钟提前刷新;watermark 表示引擎对事件时间进度的估计。若直接按 processing time 分窗,同一批历史重放会因为运行时间不同而得到不同结果。
第二个信号是不会把 watermark 当成绝对保证。它是“此前事件大概率已经到齐”的进度边界。水位线推进得快,结果及时但更多事件会变成 late;推进得慢,完整性提高,却延迟窗口关闭并保留更多状态。强回答会用真实到达延迟分布和业务修正 SLO 选择策略,而不是背一个固定的 5 分钟或 1 小时。
第三个信号是把 watermark、allowed lateness 和去重保留期分开。watermark 决定何时触发准最终结果;允许迟到决定窗口状态还能保留并修正多久;去重状态必须覆盖可能出现重复副本的时间跨度。三个值可能相关,却不是同一个配置。
最后看输出是否可收敛。迟到事件会让同一窗口多次发结果,下游若只追加每次快照就会重复累计。输出必须按窗口键幂等 upsert,并带版本或最终性标记。检查点只保护算子状态;若 sink 无法与检查点协同提交,还需要事务写入或幂等版本覆盖。
回答前需要澄清的问题
- 业务按发生时间还是到达时间统计? 本题按购买发生的
event_time。只有监控“系统此刻处理了多少请求”时,processing time 才可能是正确业务时间。 - 1 分钟内可见的是估算值还是最终值? 本题允许估算值,因此使用 processing-time early trigger;若每次展示都必须完整,就只能等待更久或改用批处理。
- 24 小时后还能否改历史报表? 本题把 24 小时定义为流任务自动修正窗口,超期数据进入对账。若法规或结算要求任何迟到都必须改账,就不能把状态清理等同于数据终局。
- 重复事件能否依靠 ID 识别? 本题有稳定
event_id。若只能按用户、金额和时间猜测,误删合法购买与漏掉重复都无法避免,必须先修正生产端契约。 - 同一 ID 内容不一致怎么办? 不选择“先到”或“后到”覆盖;记录 payload 指纹冲突并隔离,因为这表示生产端违反了幂等契约。
- 下游接受快照、增量还是撤回消息? 本题输出窗口完整快照,并按
(window_start, dimensions)upsert。只支持 append 的 sink 需要改成带版本的变更日志,再由读取层选择最新版本。 - 事件时间是否可信? 对远未来、早于数据保留期或时区不合法的时间戳先隔离。一个错误的未来时间若参与最大事件时间计算,可能把水位线错误推得过快。
30 秒回答框架
“我会按 eventtime 分 UTC 小时窗口,每个活跃源分区生成 watermark,下游取最小安全进度。processing-time trigger 每分钟发估算,watermark 越过窗口末尾时发准最终结果,状态再保留 24 小时接收迟到修正。事件按 eventid 和 payload 指纹去重,结果按窗口键与 revision upsert。超期事件进入 side output 和日批对账;恢复依靠可重放源、检查点以及事务或幂等 sink。”
分步骤深入解答
先定义结果契约。窗口采用 [10:00, 11:00) 这样的左闭右开区间,窗口键至少包含 window_start 和报表维度。每条输出都是该窗口截至当前版本的完整营收快照:
HourlyRevenue {
window_start
window_end
revenue
revision
result_state // EARLY | ON_TIME | FINAL
}revision 对同一窗口单调递增,sink 仅接受更大的版本。EARLY 表示一分钟刷新,不承诺完整;ON_TIME 表示 watermark 已越过窗口末尾;FINAL 表示 24 小时自动修正期已经结束。这里的 FINAL 是运营契约,不代表世界上不会再出现数据,超期事件仍由对账链路处理。
时间策略由约束推导。每个活跃源分区提取经过校验的 event_time,常见的有界乱序策略可写成:
partition_watermark = max_valid_event_time_seen - out_of_order_bound
operator_watermark = min(active_partition_watermarks)outoforderbound 应来自过去一段时间的到达延迟分布、可接受的 late 比例和 ONTIME 时效目标。按分区生成水位线能避免一个快分区替慢分区宣布完成;多输入算子取最小进度,防止仍可能产生旧事件的输入被越过。空闲分区需要在确认一段时间没有数据后标记 idle,否则它会让全局水位线永久停住;阈值过短时,分区恢复后的旧事件会变 late,所以仍需 side output。
窗口使用两类触发。processing-time early trigger 每分钟发一次当前完整快照,满足可见性;watermark 越过 windowend 时发 ONTIME 版本。窗口状态保留到 watermark 越过 window_end + 24h,期间每个合法迟到事件更新聚合并产生更高 revision。到清理点后发 FINAL 并释放窗口状态。Beam 的触发与 accumulation 模型说明了为什么“何时输出”和“输出本轮增量还是累计快照”必须分别定义;本题选择累计快照加 upsert,避免下游自行拼接多个 pane。
事件进入聚合前按 eventid 去重。状态保存 eventid → (eventtime, payloadfingerprint):首次出现则放行;相同指纹的重复副本丢弃;同 ID 不同指纹写入冲突流并报警。去重状态的保留期不能只看窗口大小,而要覆盖上游可能重放重复副本的最长时间。Spark 的 watermark 去重语义也明确要求延迟阈值覆盖同一事件最早与最晚副本的时间差;过早清理后,迟到的重复会再次计入。
状态成本必须估算。若峰值每秒 2 万条事件持续 24 小时、最坏情况全部为不同 ID,需要同时记住 20,000 × 86,400 = 1,728,000,000 个 ID。这个上界即使每项逻辑负载只按 40 字节估算,也约为 69.12 GB;引擎对象、索引、状态后端、检查点和副本会进一步增加实际占用。因此状态要按 key 分片并增量检查点,保留期要由重放契约决定;如果 99% 的重复只在更短窗口内出现,可以采用短期流去重加 sink 业务键和离线对账,但必须量化超过短窗口的重复风险,不能把它隐藏在 TTL 后面。
输出链路要能处理重放。理想情况是 sink 参与检查点事务,使“消费偏移、算子状态、窗口结果”一起提交。若做不到,使用 (window_key, revision) 幂等 upsert:崩溃后相同或更旧 revision 的重放不会覆盖新结果。只依赖消息系统的 exactly-once 标签不够,因为外部数据库写入可能发生在检查点确认之前;端到端语义取决于源、状态和 sink 的共同提交边界。
超过 24 小时的事件进入 too-late side output,并继续保存在原始日志。日批按 event_time 重算受影响窗口,使用相同的 ID 去重与金额规则,将结果和流式 FINAL 快照比较。差异超过阈值时生成更高 revision 或进入财务审批。批任务必须按窗口原子替换或版本 upsert,不能向现有总额再加一次,否则重放批任务会重复计数。
用一个最小序列验证语义。窗口 [10:00, 11:00) 收到 A=100、B=50,随后收到 A 的相同副本;去重后 ON_TIME 结果是 150。watermark 越过 11:00 后,C=20 在允许迟到期内到达,窗口更新为更高 revision 的 170。D 在 watermark 越过清理点后到达,不直接修改已清理状态,而是进入对账流。若 A 的第二份 payload 金额变成 120,它必须进入冲突流,不能把结果改成 170 或 190。
验证矩阵还应覆盖:不同分区乱序;一个分区空闲导致水位线停滞;未来时间戳被隔离;watermark 前后的边界时间;进程在写 sink 前后崩溃;从检查点恢复并重放;同一窗口多 revision 乱序到达 sink;批量对账重复运行。线上监控事件到达延迟的 p50/p95/p99 与长尾、各算子的 current watermark 和 lag、early/on-time/late/too-late 计数及金额、去重命中与指纹冲突、状态字节数、检查点时长、sink 版本拒绝数和批流差异。
高质量示范回答
“我先把 1 分钟内的值定义为估算,把 24 小时定义为自动修正窗口。购买归属由 event_time 决定,processing time 只负责每分钟提前触发。每个活跃输入分区根据经过校验的最大事件时间减去乱序容忍量生成 watermark,下游取最慢活跃输入的进度;空闲分区要显式标记,未来异常时间戳先隔离。
数据先按 eventid 去重。我会保存事件时间和 payload 指纹:相同副本只算一次,同 ID 不同内容进入冲突流。然后进入自然小时窗口,每分钟发 EARLY 完整快照,watermark 过窗口末尾发 ONTIME,状态继续保留 24 小时;期间迟到事件会更新总额并增加 revision,清理前发 FINAL。
sink 按窗口键和 revision upsert,不把多次快照当增量相加。源可重放、算子状态随检查点恢复,sink 若不能参与检查点事务,就靠版本写入保证恢复重放不会覆盖新结果。超过 24 小时的事件进入 side output,日批从原始日志重算并对比 FINAL 结果。
容量上,若每秒 2 万条的峰值持续 24 小时且全部唯一,要保留 17.28 亿个去重 ID;每项仅按 40 字节算也有 69.12 GB 的逻辑状态上界,所以我要用真实延迟分布决定保留期,并监控状态与检查点成本。最后用重复、乱序、迟到、未来时间戳、空闲分区和崩溃恢复做故障注入,验证窗口最终值与对账结果一致。”
常见错误
- 按 processing time 分业务窗口 → 同一事件在实时运行与历史重放中可能进入不同小时 → 用 event time 决定归属,只用 processing time 触发早期结果。
- 把 watermark 说成“之前的数据绝不会再来” → 水位线通常是启发式进度,旧事件仍可能到达 → 定义 allowed lateness、too-late side output 和对账策略。
- watermark、允许迟到和去重 TTL 使用同一个值且不给依据 → 三者分别控制触发、窗口状态和重复识别,错误绑定会丢修正或重复计数 → 从时效 SLO、修正窗口和上游重放契约分别推导。
- 每次触发都向下游追加总额 → EARLY、ON_TIME 和 late firing 会被重复相加 → 输出完整快照并按窗口键与 revision upsert,或明确使用可撤回增量协议。
- 全局最大事件时间直接推进 watermark → 快分区或异常未来时间戳会让慢分区数据过早变 late → 按分区生成进度、取活跃输入最小值,并校验时间戳。
- 用永久不动的空闲分区参与最小值 → watermark 停止,窗口与去重状态无法清理 → 配置可观测的 idleness 策略,并处理恢复分区的迟到数据。
- 只保存 event ID,不检查内容 → 生产端复用 ID 时错误会被静默吞掉 → 同时保存 payload 指纹,把冲突隔离并报警。
- 只说启用了 exactly-once → 外部 sink 可能不在检查点提交边界内 → 核对端到端提交路径,使用事务 sink 或版本化幂等写入。
- 状态清理后直接丢弃超期事件 → 流任务看似稳定,财务结果却无法解释 → 保存原始事件,建立 side output、批量重算和差异审批。
追问及应对
追问一:watermark 的乱序容忍量应该设多少?
从生产数据计算 processingtime - eventtime 的分布,并按来源、客户端版本和地区分层。先确定 ON_TIME 结果最晚可等多久与允许进入修正流的比例,再选择能满足两者的分位点。上线后持续观察 p95、p99 和长尾;延迟分布改变时走配置发布,而不是自动追随单个异常值。水位线只决定准最终触发,24 小时修正窗口仍承担更长尾的数据。
追问二:一个 Kafka 分区长时间没有事件,为什么会卡住窗口?
多输入算子的安全进度由最小输入 watermark 决定;空闲分区不推进时,最小值保持不变。确认该分区在阈值内没有事件后,将它标记为 idle,使其暂时不参与最小值。阈值不能过短,因为恢复后的旧事件可能已经落后于新 watermark;这部分要进入允许迟到或 side output,并单独监控 idle 切换频率。
追问三:为什么去重状态保留 24 小时仍不能承诺永不重复?
24 小时只覆盖本题自动修正窗口。上游若在第 25 小时重放同一 ID,流状态已经删除,它可能再次进入聚合。永久不重复需要更长久的业务唯一索引、可查询的事件登记表,或离线全量去重;这些方案增加存储和写入成本。答案应把保证写成“在已声明的重放窗口内去重”,再用对账处理窗口外数据。
追问四:下游数据库只支持 INSERT,不能 upsert 怎么办?
把每次结果写成不可变变更日志,键中包含窗口和 revision;读取层用每个窗口的最大 revision 生成当前视图。消费者必须按结果状态区分完整快照与增量,不能把所有版本求和。若查询成本不可接受,再异步压实到支持原子替换的服务表;原始版本日志仍用于审计和恢复。
追问五:如何证明故障恢复不会多算或少算?
在固定输入序列上记录预期的去重集合和各窗口 revision,然后分别在“源已读但状态未检查点”“状态已检查点但 sink 未确认”“sink 已写但检查点未确认”三个位置终止任务。恢复后重放相同输入,断言同一 event ID 只贡献一次、sink 最终只保留最大 revision、最终窗口值与离线重算相同。没有这三个提交边界的故障注入,单看任务显示 RUNNING 不能证明端到端正确。