“基于 MaxCompute 的实时数据处理实践”——看到这个主题,可能不少人第一反应是:MaxCompute 不是跑离线批处理的吗?怎么和“实时”搭上边?我第一次接到这个需求时也是同样的疑问。直到后来真正把链路搭起来,才意识到所谓的“实时数据处理”,在绝大多数业务场景里并不是让 MaxCompute 去抢流计算引擎的活,而是让整个链路从“采集 → 计算 → 存储 → 服务”形成一个端到端的准实时闭环,MaxCompute 在这个闭环里承担的是大规模存储、批量合并和历史数据服务这一层关键底座。
这篇文章会把我在实际项目中搭建这套链路时的整体设计、核心配置、参数调优和真实踩坑记录完整梳理出来。如果你是正在做实时数仓、或者想把实时计算结果和离线大数据存储打通的数据工程师,这篇文章应该能帮你少走不少弯路。我不写教科书式的理论,只讲能直接落地的方案和为什么这么做。
1. 先理清思路:MaxCompute 在实时链路里到底该站在哪
1.1 为什么“MaxCompute 只适合离线”这个印象该更新了
MaxCompute 本质上是一个大规模并行计算的数据仓库服务,最擅长的是跑批任务、处理海量历史数据。但这并不代表它只能在 T+1 的场景里出现。我见过很多团队,上了 Flink 之后实时结果就算出来了,结果落库的时候犯了难:业务方要的是能按任意维度、任意时间范围去查的明细数据,而不是简单的 Redis 里存几个聚合值。这时候,你总不能拿 Redis 存上亿条明细吧?也不能把 HBase 里的数据再同步一份到关系型数据库里做分析。
MaxCompute 真正擅长的恰好就是这个:存得下海量明细、能跑复杂的分析 SQL、支持分区裁剪和列存优化,而且对于准实时场景,它完全可以通过增量分区、周期合并的方式来承接。也就是说,实时链路里的“实时”这个词,不应该被狭隘地理解成“从事件发生到可见只有几秒钟”,在大多数业务场景里,分钟级或者十分钟级的可见延迟,配合海量离线历史数据的统一分析能力,才是更实际的需求。
把 MaxCompute 放回这条链路里,它的定位就不是“实时计算引擎”,而是“实时与离线统一的数据底座”。前端 Flink 做流式清洗和指标计算,结果落到 MaxCompute 的增量分区里,再由 MaxCompute 的定时任务把增量合并进主表,最终用统一的 SQL 接口对外提供查询。这套思路既能拿到准实时的数据新鲜度,又不用把体量庞大的数仓整体迁移到流式框架上,成本和收益之间会有个非常好的平衡。
1.2 实时数据落地的四种路线,怎么选
我先画一下目前主流的实时数据落地路线。这么说吧,如果你现在打开任何一个数据团队的架构图,大概率跑不掉下面这几种组合。
第一种是“纯流式存储”。把数据全部放在消息队列或者内存数据库里,比如用 Kafka 做短期窗口、用 Redis 做热点查询。这种方式延迟最低,但能承载的数据量和服务能力有限,只适合做缓存层或实时看板,没法支撑复杂的关联分析,而且数据保留周期短,一旦超过保留时间就查不到了。
第二种是“实时数仓专用引擎”。用专门为实时设计的数据仓库,比如某些能支持高并发点查的分析型数据库,接收 Flink 或 spark streaming 的输出结果。好处是查询延迟低、体验好,缺点是存储成本通常比 MaxCompute 这类批量数仓要高不少,而且如果要存几十 TB 甚至上百 TB 的明细,费用会很敏感。
第三种是“流批混合”。也就是 Flink 做实时统计,写进一套存储,离线任务每天再跑一套全量结果,对账用。这种方式最稳,但也是最费人力的,因为同一套指标要维护实时和离线两套代码,数据对不上永远是说不完的痛。
第四种就是我这次要讲的“MaxCompute 为中心,实时写入加周期合并”。Flink 负责流式计算,把结果按分区粒度写进 MaxCompute 的增量分区,MaxCompute 上的周期任务再把增量数据合并进主表。查询端看到的是一张统一的、完整的数据表,底层真实数据从增量到合并的过程对业务方完全透明。
四种方式没有绝对的好坏,我整理一个表格直接对比:
| 方案 | 可见延迟 | 存储成本 | 支持数据量 | 运维复杂度 | 适合场景 |
|---|---|---|---|---|---|
| 纯流式存储 | 秒级 | 较高 | 中 | 低 | 实时大屏、热点数据 |
| 实时数仓专用引擎 | 秒级到分钟级 | 高 | 中高 | 中 | 高并发交互式分析 |
| 流批混合 | 分钟级到 T+1 | 中 | 高 | 很高 | 对账严谨、指标众多 |
| MaxCompute 实时写入加合并 | 分钟级到小时级 | 低 | 极高 | 中 | 海量明细统一分析 |
1.3 最终采用的方案:实时写入加周期合并
我参与的这个项目,典型场景是:业务方每天要看到当天到当前时刻为止的订单、转化、库存流水等明细数据,而且要和上个月、去年同比对比,意味着必须有完整的历史数据。一开始的方案是走流批混合,但很快发现一个问题:很多指标实时算一次、离线再算一次,两边的口径越来越难以保持一致。而且业务方不关心底层是实时还是离线,他们只想要一套数。
所以最终敲定的方案,就是围绕 MaxCompute 这条线搭一套“准实时链路”。具体工作机制是:实时计算引擎负责把原始事件流清洗、去重、关联维表,然后以分钟级频率把结果写到 MaxCompute 的增量分区表里。MaxCompute 内部通过周期合并任务,把增量分区的数据归并到主分区或者主表,对外查询时走的是合并后的统一视图。这套方案的核心优势在于,数据只有一套,口径只有一套,实时和离线之间的边界在做完合并之后就消失了。
2. 整体链路怎么搭:从数据源到 MaxCompute 的关键设计
2.1 一条完整链路的六个环节,每个环节别偷懒
整体数据链路我拆成了六个环节:数据采集、消息缓冲、流式计算、增量落地、周期合并、统一服务。每个环节对应一个清晰职责,这样出了问题容易排查,也容易做降级。
数据采集:业务系统的日志、订单变更、埋点数据,通过采集组件或者直接 SDK 上报,统一进入消息队列。这个环节的关键是消息丢失和重复的问题,采集端要能确认消息是否成功到达,最好有本地的缓冲机制。
消息缓冲:这里承载的是削峰填谷的作用。实时数据最怕的就是流量洪峰,如果没有缓冲层,直接压到下游计算任务,Flink 的背压机制会把压力传导到整条链路,最终就是处理延迟从毫秒级变成秒级甚至分钟级。我们当时用消息队列做了至少三天的数据保留,这样即使下游任务挂了,恢复后也能继续消费而不丢数据。
流式计算:Flink 作业负责解析、清洗、去重、关联维表、计算指标。这一层我建议别贪多,把能交给 MaxCompute 的批量计算全交出去,流上只做必须实时做、不做就看不到结果的事。
增量落地:Flink 输出到 MaxCompute 时,最常见的模式是写进一个以日期和小时为粒度的增量分区表。这块后面我会专门讲,因为这里藏着一个小文件的问题和并发度的问题。
周期合并:MaxCompute 上起一个定时调度任务,每隔一段时间把增量分区数据合并进主表,合并的窗口依据业务容忍的延迟来定。比如业务能接受 10 分钟延迟,就每 10 分钟跑一次合并。
统一服务:合并完成后,上层应用、报表、数据服务 API 都通过 MaxCompute SQL 或者查询引擎读取最终结果。这套东西对业务方是透明的,他们感知不到增量分区的存在。
2.2 分层建模和分区设计:先想清楚再建表
实时链路最怕的是“上来就写表”,结果过两周发现表结构撑不住需求,又得重跑历史数据。在 MaxCompute 里建表之前,我强烈建议你先做分层规划。
ODS 层直接对接实时计算产出的清洗结果,字段最原始,按业务时间分区。DWD 层做明细整合,比如把订单明细、支付明细、退款明细合并成一张事实宽表。DWS 层做汇总,比如按店铺、商品、小时粒度的累计指标。ADS 层服务应用。
分区设计上,一个容易踩的坑是“分区字段粒度太小”。比如为了追求实时性,有人会把分区粒度设到分钟级,结果 MaxCompute 里产生海量小分区。分区文件数超过一定量之后,查询扫描的元数据开销就非常可观,甚至比数据本身的读取还慢。我们实验下来,小时级分区是比较平衡的选择,对分钟级延迟的业务场景,通过合并任务把延迟控制在 10 到 15 分钟内完全够用。
另一个容易忽略的点是时区。数据源如果是带时区的时间戳,落地到 MaxCompute 分区时一定要统一转成东八区,否则跨天统计会出现数据落到前一天分区的情况。这个坑我们踩过一次,凌晨 0 点到 8 点的数据全部跑到前一天分区里去了,排查了半天才意识到是时区没有统一。
2.3 写入 MaxCompute 的三种通道,什么时候用哪种
要把实时数据写进 MaxCompute,实际可选的方式大致有三种。
第一种是用 Tunnel SDK 直接写。Tunnel 是 MaxCompute 的底层数据上传通道,通过 PartitionUploadSession 创建上传会话,然后把记录批量写入。这个方式最灵活,适合在 Flink 的 Sink 里自己控制批量大小和提交时机。缺点是代码开发量会大一些,需要对 SDK 的机制比较熟。
第二种是用流计算连接器,也就是 Flink 官方或者云厂商提供的 MaxCompute Connector。这种方式把 Tunnel 封装好了,只要配置表名、分区字段、批量大小等参数就能直接用。好处是省事,坏处是连接器版本各异,一些隐藏的坑不好排查。
第三种是离线同步工具,比如定时把实时结果从某个存储同步到 MaxCompute。这种方式适合数据先落到其他系统,再定期批量拉取的场景,但严格来说绕了一圈,延迟会变大,不太适合“准实时”的目标。
从我的实践来看,正式的实时链路里推荐用第二种,因为可维护性最好。只有在需要做很复杂的写入控制、比如自定义去重逻辑、动态分区路由时,才会考虑用第一种 SDK 方式。第三条路留给非实时场景的补充通道,我后面会再提到。
3. 核心实现:动态分区写入与增量合并
3.1 让 Flink 把结果直接写进 MaxCompute 动态分区
实时计算结果写 MaxCompute,最关键的是“攒批提交”和“动态分区路由”的配合。逐条写肯定不行,MaxCompute 毕竟是批量接口,单条写入的会话开销非常大。我的经验是,控制在每个分区至少攒够一定数据量再提交,同时让 Flink Sink 里根据事件时间自动路由到对应的分区字段。
用连接器的模式,一个典型的配置思路是:指定一个分区列,比如ds和hh,然后 Flink 在处理时把每条记录按时间戳计算出来属于哪一天哪个小时。连接器内部会维护每个分区对应的上传会话,一批记录攒够之后统一写入提交。这里有个很重要的点:分区上传会话的提交和 Flink 检查点机制要配合好,避免任务从 checkpoint 恢复时出现数据重复或丢失。
下面是一个基于 DataStream API 的大致代码骨架,实际生产环境里还需要封装成可配置的连接器,但核心逻辑长这样:
// Flink Sink 中按分区攒批写入 MaxCompute 的示意逻辑 // 真实环境请结合你选用的 Connector 与 SDK 版本调整 public class McPartitionSink extends RichSinkFunction<RowData> { private TableTunnel tunnel; private Map<String, UploadSession> sessionMap; // 每个分区对应一个上传会话 private int flushIntervalMs; private long lastFlushTime; @Override public void invoke(RowData value, Context context) throws Exception { String partition = generatePartition(value); // 事件时间 -> yyyyMMdd/HH UploadSession session = sessionMap.computeIfAbsent(partition, k -> tunnel.createUploadSession(project, table, partition)); // 先写入本地缓冲,累积到阈值再统一 openRecordWriter 写入 buffer(partition, value); if (shouldFlush(partition)) { flushPartition(partition); } // 周期性兜底,防止长时间小流量导致数据迟滞可见 if (System.currentTimeMillis() - lastFlushTime > flushIntervalMs) { flushAll(); } } private void flushPartition(String partition) throws Exception { UploadSession session = sessionMap.get(partition); try (RecordWriter writer = session.openRecordWriter(0)) { for (Record rec : bufferedRecords.get(partition)) { writer.write(rec); } } session.commit(new int[] {0}); // 提交上传会话 sessionMap.remove(partition); bufferedRecords.remove(partition); } }注意上面的代码是表达“按分区维护会话、攒批提交”这个思想。实际开发中如果是用现成的连接器,你只需要正确配置动态分区参数,以及设置批量写入的条数和时间窗口,比如每 10000 条或每 60 秒提交一次。
3.2 状态、去重和顺序:流上最容易翻车的三个点
流计算之所以难,不是因为“实时”两个字,而是因为数据可能是乱序的、重复的、晚到的。这三个问题如果不在流上处理好,写进 MaxCompute 之后,后面合并任务看到的数据就是错的,而且很难再回头修正。
去重是第一关。消息队列是 at least once 语义,也就是说一条消息可能会被消费多次。如果业务上对主键有唯一性要求,Flink 里必须用状态做去重。我们常用的做法是用KeyedProcessFunction,以主键分组后记录每条数据的更新时间,只处理比当前状态更新的记录。这里要特别关注状态的过期时间,状态设置得太长,会撑爆内存;设置得太短,超过窗口的数据又可能被当成新数据再次写入,产生重复。实践中可以根据业务数据的延迟分布,设置比如 24 小时到 48 小时的 TTL,并且配合 RocksDB 状态后端来控制堆内存的占用。
乱序问题是第二关。判断一条数据是不是“晚到了”,靠的是事件时间和水位线。Flink 里通过WatermarkStrategy来声明允许乱序的时间范围。比如我们允许 5 分钟内的乱序,水位线就设置为当前最大事件时间减 5 分钟。这里需要想清楚一个问题:晚到的数据到底是丢弃、重新计算、还是先写入一个补数据通道?如果业务能接受少量延迟数据,可以在窗口计算时把它丢弃;如果不能接受,就得让 Flink 在事件时间超过水位线之后仍然输出到旁路流。
顺序问题是第三关。对同一主键的记录,如果后到的先被处理,前面的记录反而更新,就会出现数据“倒退”。一个稳妥的方案是,在流上对同一主键的更新做排序,或者在下游合并 SQL 里通过ROW_NUMBER()按更新时间取最新值,后者实现起来更简单,而且天然具备幂等性。这也是后面合并脚本的基本逻辑之一。
3.3 周期合并不是简单的 overwrite,要看数据怎么“变”
增量分区落地之后,合并任务把增量合入主表。这里最忌讳的思路是:直接把增量分区INSERT OVERWRITE到主表,因为INSERT OVERWRITE会把目标分区的旧数据整个覆盖掉,如果增量数据里只包含发生变化的部分,而不是全量数据,直接覆盖会导致旧数据丢失。
我们用的合并策略是:把主表的历史数据加增量分区数据放到一起,按业务主键和更新时间去重,保留最新的一条,然后整体覆盖到目标分区。听起来很绕,但执行起来就是一个标准的 SQL。我以订单维度为例写个示例:
-- 将增量分区数据合并进主表 -- 思路:历史全量 + 当日增量 -> 按主键去重取最新 -> 覆盖写入主分区 INSERT OVERWRITE TABLE dwd_order_info PARTITION (ds='${bizdate}') SELECT order_id, user_id, product_id, amount, status, update_time FROM ( SELECT order_id, user_id, product_id, amount, status, update_time, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY update_time DESC) AS rn FROM ( SELECT order_id, user_id, product_id, amount, status, update_time FROM dwd_order_info WHERE ds = '${prev_date}' UNION ALL SELECT order_id, user_id, product_id, amount, status, update_time FROM dwd_order_delta WHERE ds = '${bizdate}' ) t ) tmp WHERE rn = 1;这段 SQL 的逻辑核心是ROW_NUMBER()加PARTITION BY去重,保证同一订单在历史数据和增量数据里都出现时,取更新时间更大的那一条。用这种方式,即使实时增量重复推送了几次,合并结果也是幂等的——这个特性非常重要,因为流任务重启、重跑是常态,幂等能让我们在出问题时大胆重算,不需要担心产生脏数据。
合并任务虽然简单,但执行频率会直接影响成本和延迟。执行太频繁,一个小时跑几十次全量合并,MaxCompute 的计算资源消耗会很大;执行太稀疏,数据的可见延迟就高。比较好的做法是:小时级增量分区,每 10 到 15 分钟合并最近一个分区,这样既保证了可见性,又把计算量控制在可以接受的范围。对数据量特别大的核心表,我建议拆两个阶段:先做一个轻量的增量汇总,把分区的数据量降下来,再做全量合并,避免每次都扫描几亿条历史数据。
4. 参数调优与成本控制:跑得快又要花得少
4.1 小文件是隐形杀手,控制文件数的几个参数
实时写入 MaxCompute,几乎必然会遇到小文件问题。所谓小文件,就是每个分区下面的数据文件特别多、每个文件又特别小。小文件多会带来两个隐患:一是查询时扫描文件的元数据开销大,明明只有几百万条数据,查询却慢得像在扫全表;二是合并任务处理这些碎片时会消耗大量计算资源。
小文件是怎么产生的?很简单,如果 Flink 连接器里设置的批量提交阈值太小,比如几十条就提交一次,或者并行度设得很大而在同一分区里同时写入的并发很高,就会形成大量小块文件。我碰过最夸张的一次,某个小时的增量分区下挂了 3000 多个小文件,跑一次合并任务要花二十多分钟,完全不可接受。
解决的思路有三个方向。第一,适当调大批量提交的阈值。比如单批至少 1 万条,如果流量低,再配合一个时间阈值比如 60 秒强制提交。第二,控制写入并行度。同一个分区的写入并发不要太高,宁可让部分并行度空置,也不要在写同一个小时分区时起二十个并发。第三,通过 MaxCompute 自带的文件合并能力定期做小文件合并。对于已经产生的小文件,写一个周期任务把分区数据重写一遍即可,但要注意频率,别把这个任务又变成另一个成本黑洞。
这里我建议一个关键参数范围,实际需要根据数据量做微调:批量大小设置在 5000 到 20000 条之间,强制提交时间设置在 60 秒左右。这样既能保证分钟级延迟,又不会疯狂产生小文件。如果你选用的连接器有类似write.bucket.size的配置,可以根据目标分区大小反向推算单桶条数,原则就是“让每个文件尽量接近 128MB 甚至更高”。
4.2 数据倾斜的治理:加盐、分桶、剪枝
写入阶段的数据倾斜,很多团队不太关注,直到被坑过才回头看。倾斜的典型表现是:Flink 作业整体并行度是 16,结果某个 subtask 处理的数据量是其他 subtask 的十倍,CPU 跑满了,其他 subtask 在空转。整体吞吐上不去,背压往上传播,最后延迟飙升。
这种情况通常是因为分区的路由键选择不当。比如按订单 id 分区,而某些大客户的订单量特别大,那么对应分桶的数据就多。处理手段也很经典:加盐。给分桶键加一个随机后缀,把大 key 拆散到多个桶里,写入之后再在合并阶段按原 key 聚合。加盐的方式要分层设计,不能全局加盐,否则查询时就找不回原始 key 的连续性了。
另一个容易被忽略的是“时间戳分区倾斜”。流量天然有高峰和低谷,凌晨写入量小,晚高峰写入量大。如果只按小时分区,晚高峰那个小时分区的文件数和数据量会明显偏大。这个问题很难彻底消除,但可以做的处理是:对高峰时段单独调高并行度,或者把这个小时分区的存储格式优化成列式压缩比更高的模式,减少后续查询的压力。
对 MaxCompute 侧的查询分析,也要善用分区裁剪和列裁剪。写 SQL 时永远把分区过滤条件放在最外层,比如查某一天的订单明细就只扫当天的分区,不要让查询去扫描全表分区。实时链路落地的表,如果常用过滤字段没有放在分区里,可以考虑建二级索引或者物化视图来优化。关于物化视图,有些团队忽略了 MaxCompute 的物化视图能力,对于经常要跑相同聚合查询的业务,建一个定时刷新的物化视图能显著降低重复计算成本。
4.3 账单视角的成本控制:存储与计算要怎么取舍
实时链路的成本,说起来就两块:存储费用和计算费用。存储上,增量分区会保存一份数据,主表又会保存一份全量数据,相当于短期内数据有冗余。这不可避免,但可以控制生命周期。增量分区在合并完成后,它的使命就已经结束了,你可以给增量分区设置一个较短的 Lifecycle,比如 7 天,过期自动清理,避免无限累积占用存储费用。
计算费用上,最有效的控制方式是“避免重复计算”。同一个指标,不要在 Flink 算一遍、合并任务里又算一遍、物化视图里再算一遍。我的原则是:能在实时计算阶段算好的指标就在实时计算阶段算好,合并阶段只做数据归并,不再做复杂聚合。如果业务方临时要加一个新指标,先评估能不能从现有明细里用 SQL 查出来,能查出来就不要额外加一个实时计算任务。
还有一点关于生命周期管理:MaxCompute 的分区表支持配置生命周期,对于 ODS 层的原始日志类数据,保留 30 天就够了;DWD 层和 DWS 层按业务审计需要,保留半年或者一年;DWS 以上的汇总数据,尽量用压缩率更高的模式存储。否则随着实时链路长期运行,存储成本会像滚雪球一样涨上去,月底账单出来再优化就晚了。
5. 真实环境常见问题与排查实录
5.1 现象一:任务运行正常,但 MaxCompute 分区迟迟不更新
这个现象我遇到不止一次。Flink 作业运行状态一切正常,Checkpoint 也在正常做,但查询 MaxCompute 的分区数据就是看不到新数据。排查下来发现,要么是连接器里没有设置定时提交,攒批的阈值又设得特别大,低流量时段要等很久才凑够一批;要么是分区字段配置错误,数据写到别的时间分区去了。
排查方式很简单:去 MaxCompute 表的分区列表里看一眼最新的分区,确认有没有新分区生成。如果新分区有生成,但查询不到数据,再看一下上传会话有没有成功 commit。Tunnel 上传有个特点,打开 session 后如果只是写入记录但不提交,数据是查询不到的。连接器如果没处理这个细节,就会表现成“什么都正常但啥都查不到”的诡异状态。
建议的兜底方案:在连接器或者自定义 Sink 里,强制增加一个时间触发提交,比如每 60 秒不管有没有攒够都提交一次。毕竟准实时链路的核心目标是低延迟,为了攒批优化而牺牲可见性,是本末倒置。
5.2 现象二:写入越来越慢,最终内存溢出
Write 慢,往往不是 MaxCompute 的问题,而是 Flink 侧攒批逻辑出了问题。常见的原因:缓冲列表没有上限,数据涌入速度大于写出速度,导致堆内存持续增长;或者 RecordWriter 没有正确关闭,连接数被不断打开,最终把资源耗光。
这里我吃过一次亏。当时的 Sink 实现里,每个分区都维护一个上传会话,并且把待写入的数据放在了一个没有容量限制的 List 里。高峰流量一上来,List 无限膨胀,OOM 就来了。后续改成有界缓冲,满了就触发 flush,同时对每个分区的会话数量做了上限控制,问题才解决。
另一个容易被忽略的因素是分区数量过多导致 session 数量膨胀。比如动态分区按分钟粒度,一个任务同时维护几十个分区上传会话,每个会话都有独立的内存缓冲区,累加起来非常可观。所以无论从减少小文件的角度,还是从降低内存压力的角度,控制动态分区的数量都极其重要。
5.3 现象三:实时报表和离线任务跑出来的结果对不上
实时和离线对不上,是最让人头大的问题。因为两边跑数的逻辑本来就有差异,再叠加乱序数据和重复数据,差异会在关键指标上被放大。
常见的原因有这么几个。其一,实时计算使用处理时间而不是事件时间,晚上补录的数据被算进了补录时刻的时段,而离线任务按业务发生时间归档,自然不一致。其二,去重逻辑不同,实时链路按主键去重,但离线 SQL 用了distinct或者group by,结果也会不同。其三,合并任务执行过程中,正在合并的分区可能同时被实时写入,导致查询到半合并状态的数据。
针对这些问题,我的建议是:实时计算统一使用事件时间加水位线,这个没得商量;对账的基准以离线为准,因为离线对于历史数据是重算全量,口径更稳。出现不一致时,先查增量分区里有没有覆盖那条记录的更新,再看合并任务执行的窗口有没有和写入窗口重叠。如果重叠,就把合并窗口和写入窗口错开 5 分钟再拉取数据,可以规避大部分半合并状态导致的对不上。
5.4 常见问题速查表
| 问题 | 可能原因 | 快速排查方向 |
|---|---|---|
| 分区不更新 | 攒批阈值过大、未提交 session、分区字段配置错误 | 查最新分区列表;确认 commit 日志 |
| 写入越来越慢 | Sink 缓冲无界、session 过多、连接未释放 | 检查堆内存和 GC 日志;统计 session 数量 |
| 实时与离线对不上 | 时间口径不一致、去重逻辑不一致 | 对比增量分区和离线结果;检查水位线配置 |
| 小文件过多 | flush 太频繁、并发过高 | 查看分区文件数统计;调大批量阈值 |
| 合并后数据丢失 | overwrite 目标分区时未做历史数据合并 | 检查 SQL 是否 UNION ALL 历史分区 |
| 内存溢出 | 分区上传会话过多、缓冲无上限 | 限制分区数量;增加 flush 时间触发 |
| 数据重复 | at-least-once 语义、未做去重状态管理 | 检查主键去重逻辑;确认状态 TTL 设置 |
我最后再分享两个个人经验。第一个是永远保留一个离线补数通道。实时链路再稳定,也会遇到源头数据修正、上游逻辑变更这类事情,这时候你不能只依赖实时任务去补历史数据,一定要能通过离线任务重刷一张表并切到线上。第二个是链路里每一层都要有“幂等”意识。写入可以重复、合并可以重跑、查询结果不能变,这个原则能让你在处理故障时有底气,不用怕把数据弄坏。这套基于 MaxCompute 的实时数据处理链路,在我实际项目中跑了很长时间,稳定性和成本都达到了预期的效果。如果你也在搭类似链路,希望这篇实践能给你提供一些参考。