站在数据开发的角度聊一个绕不开的话题:数据仓库的流处理技术。以前我们做数仓,核心节奏是T+1批量跑批,凌晨跑任务,第二天早上看报表,数据晚一点没关系,能对上就行。但业务侧越来越不满足于这种时效性,实时大屏、实时风控、实时推荐、库存实时扣减,这些场景都在逼着数仓从离线往实时迁移。于是流处理不再是一个单独的引擎问题,而是成了整个数据仓库架构的一部分,和离线表、维度表、OLAP存储、消息队列全部串在一起。
这篇文章不是教科书式的架构科普,我会从实际做项目的角度,把数据仓库引入流处理技术时最常遇到的设计决策、技术选型、开发细节、线上问题排查都拆开讲一遍。适合正在做实时数仓调研的开发人员、刚从离线数仓转向实时方向的数据工程师,以及想弄清楚Flink和Kafka在数据仓库里到底怎么落地的架构师。我会尽量把“为什么这么做”讲清楚,很多结论不是凭空来的,都是真实踩坑换来的。
1. 流处理为何成为数据仓库的标配
1.1 从T+1到秒级:传统数仓的痛点
传统离线数仓的链路很清晰:业务库通过Sqoop或者DataX抽到Hive ODS层,然后一层一层往下加工,DW层做清洗和维度建模,ADS层出应用指标,最终查数用Impala或者Presto。整条链路跑完,数据至少延迟半天到一天。批处理模式下,每层调度之间有强依赖,一个上游任务失败了,下游全部要等重跑。
这种模型在数据量暴增和业务响应速度加快之后就开始吃力了。举一个真实场景:某公司的订单明细表,ODS层每天凌晨3点抽数,到ADS层生成前一天GMV、订单量、退款率,已经是早上8点。运营想调整当天的投放策略,用的却是昨天的数据,反馈链条拉得特别长。更麻烦的是,一旦某个Spark任务OOM,重跑几个小时的作业,整个报表链路继续往后顺延。
流处理解决的不是“快一点点”的问题,它改变的是数据在数仓里的流转形态。数据从产生到进入数仓体系,不再是“凑一批再搬”,而是每条数据产生之后立刻进入消息队列,由流计算引擎持续消费、实时加工。最直接的效果是:指标从T+1变成分钟级甚至秒级,而且从ODS到ADS全程都是流式管道,不再有凌晨集中跑批的波峰压力。
但这里要澄清一点:流处理不是要取代离线数仓。现实中绝大多数公司都是两条链路并行,一份离线数据供深度分析和历史回溯,一份实时数据供大屏和实时决策。这个局面持续了很久,也催生了后面要讲的Lambda架构和Kappa架构。
1.2 Lambda与Kappa:两种主流架构的取舍
聊实时数仓架构,Lambda和Kappa是绕不开的两个词。
Lambda架构的思路是“双引擎覆盖”:离线批处理链路照常跑,保证最终数据的准确性和完整性;实时链路用流处理引擎跑,保证数据的低延迟。两层数据最终在服务层合并,取数时根据业务时效性要求分别访问离线结果和实时结果。好处是各归各,离线重跑不影响实时,实时出故障不阻塞离线调度,架构上解耦干净。坏处也明显:同一套业务逻辑要写两遍,一遍在Hive/Spark里用SQL写,一遍在Flink里用SQL或DataStream写,维护成本翻倍。更头疼的是两套逻辑算出来的结果经常对不上,业务侧会问“为什么实时看板GMV和报表中心GMV不一样”,这种问题排查起来很烦,最后往往要靠对账脚本一遍遍比对口径。
Kappa架构就简单直接:所有数据都走流式管道,消息队列里保存足够长时间的数据,需要重算的时候就重放Kafka里的消息,让流任务从头消费一遍,相当于批处理只作为一种“重算手段”存在。Kappa的好处是逻辑只维护一套,不需要对账。但它对消息队列的存储能力要求非常高,Kafka单主题要保存几天甚至几周的数据,磁盘成本不容小觑。而且如果流引擎的状态管理做得不好,重放过程中状态恢复会很痛苦。
从我实际接触的项目看,全量上Kappa的很少,多数是混合形态:实时链路必须用流,但复杂的企业级数仓还要保留离线分层体系,对账、审计、历史回溯依赖离线。最终落地的时候不用纠结“我选Lambda还是Kappa”,而是按域拆分:核心交易指标用Kappa思路统一逻辑,分析型报表继续走离线管道,两套之间用宽表对账机制做校验。这个折中方案不算纯正,但最稳。
1.3 实时数仓的数据分层设计
离线数仓大家习惯搞ods、dwd、dws、ads四层,实时数仓也需要分层,但每一层的形态和存储载体完全不一样。
实时数仓的ODS层通常就是Kafka里的原始消息,格式是业务库的binlog或者应用埋点的JSON,这个阶段不做过多的数据治理,主要任务是把各种来源的数据统一接入Kafka,并且定义好topic命名规范和数据版本字段。DWD层在流计算里做清洗、补全、拆表,简单说就是把JSON掰开揉碎,整理成一个个事实明细流,这层写出来的数据一般重新落回Kafka,也可能落到Iceberg表中供后续读取。DWS层做的是轻度汇总,比如订单流按分钟维度预聚合出订单量、GMV,这层的产出可以直接服务大屏应用。ADS层就是最终的指标查询服务,通常落到ClickHouse或者Doris这类OLAP系统。
这里有个实操经验:实时数仓的分层不要照搬离线那套,别想着在流里把维度建模搞得特别严,流计算适合做“窄表加工”,不适合做宽表级联。真实场景里,DWD层把核心字段铺平就够了,复杂维表关联放OLAP层通过引擎能力解决,否则Flink状态越来越大,一个数据倾斜就能把任务拖垮。
2. 技术选型:消息队列、计算引擎与存储如何搭配
2.1 消息队列选型与核心参数计算
实时数仓的数据入口绝大多数是Kafka,原因不复杂:吞吐高、分区模型天然支持并行消费、消息可重复消费。Kafka之外也有Pulsar和RocketMQ,但数据仓库场景下Kafka生态成熟度最高,和Flink的集成也最顺。如果你只是轻度使用,比如每秒几百条消息、下游很少,用RocketMQ也能过;但一旦涉及几十个topic、下游几百个并发消费任务,Kafka的稳定性优势就体现出来了。
选Kafka之后要做几个关键参数的计算。第一个是分区数。分区数决定了Kafka的并行度和写入瓶颈上限,通常按下游消费者最大并行度来定,并且保证分区数大于等于消费组内最大并行实例数。如果下游Flink任务并行度是16,topic分区就可以设成16或32,留出扩容余量。第二个是副本数,生产环境建议3副本,允许一个broker宕机不影响数据读写;如果集群机器有限,至少2副本加同步复制保证强一致。第三个是保留时间,取决于下游消费失败后重放的容忍窗口。实时计算场景我一般配置retention.ms为24小时到72小时,兼顾存储成本和重放窗口。数据量大的topic可以按小时做存储分级,把超过1小时的消息搬到对象存储冷备,同时保证实时链路依旧能读到热数据。
这里额外说一个备份和重放问题:Kafka不是数据仓库,它就是一条“临时的河”。河里的消息冲到下游以后,Kafka自己不会长期为你保存。如果业务方要求“实时链路出错要能回补两天”,你必须在DWD层把明细同时落到对象存储或者Iceberg中做离线冗余,而不是盲目把Kafka的保留时间拉到7天。磁盘成本高只是其一,清理线程扫描大分区日志还会影响broker性能。
2.2 流式计算引擎为什么普遍选择Flink
流式计算引擎主流上就是Flink和Spark Structured Streaming。Spark在离线生态里很强,但实时数仓场景Flink的赢面更大,原因是它在事件时间、状态管理和端到端一致性上做得更彻底。
Flink最核心的是三件事:精确一次语义、事件时间处理和状态后端。精确一次保证数据不重不丢,即使任务崩溃重启,也能从检查点的位置继续消费,不会因为rebalance导致重复计算。事件时间处理让流计算能正确处理乱序数据,迟到数据有watermark机制兜底。状态后端则让Flink可以在内存和RocksDB之间切换,支撑大状态量的窗口聚合和维表关联。
你可能要问:Spark Structured Streaming不也支持这些吗?是支持,但实现路径不一样。Spark本质上是微批模型,哪怕把触发间隔压到500毫秒,它还是一个一个微批地算;Flink是纯流式模型,事件逐个处理,理论上延迟可以压到毫秒级。在需要大状态、长时间窗口的场景下,Flink的资源效率也更可控。
实际项目中Flink一般部署在YARN或者K8s上,任务用Flink SQL和DataStream API混合写。绝大数场景用SQL就够了,尤其是有Flink CDC插件之后,从MySQL的binlog直接入Kafka,再到DWD层做清洗汇总,SQL能覆盖70%~80%的工作。打个比方:Flink就像一个24小时不停工的流水线车间,SQL是接单指令,状态后端是车间里堆放半成品的仓储区,watermark是车间用来提醒“这一批货齐了,可以发货”的闹钟。
2.3 OLAP存储与实时数仓的交互模式
实时数仓加工完之后,数据要落到一个查询引擎上给业务用。这个环节用的不是Kafka,而是一类叫OLAP的引擎,常见的是ClickHouse和Doris,次选StarRocks等兼容MySQL协议的引擎。
选ClickHouse还是Doris,核心看查询模式和运维条件。ClickHouse适合大宽表聚合查询,单表性能极其强悍,但集群化运维复杂,数据更新走MergeTree的异步合并,不适合高频点查和明细级的update场景。Doris适合明细与汇总混查,支持标准SQL、毫秒级点查,并且有完善的副本机制,运维成本相对友好。如果实时指标最终都是“大屏展示+明细导出”,两者皆可;如果还涉及权限管控和多表join,Doris更容易让人省心。
实时数仓和OLAP引擎的衔接,业界比较成熟的是“双写”模式:Flink把明细写入DWD层的Iceberg表,同时把汇总指标写入ClickHouse/Doris。好处是两种需求都覆盖:明细可回溯、指标可秒查。但要注意双写一致性,Flink写Iceberg和写OLAP是两次Sink,任何一次失败都会造成两侧数据差异,所以这两步要放在同一个checkpoint机制内,或者借助下游组件的幂等写入来兜底。
2.4 技术选型的判断标准
面对一堆组件,选型时我建议用一套“防守型”标准:选熟悉度高、社区活跃、资料多的,而不是选参数最漂亮的。高吞吐的Kafka、计算框架Flink、存储ClickHouse/Doris,都是已经被大规模验证过的组合。如果你是在一个中小规模团队里做实时数仓,不要堆组件,Kafka + Flink + 一个OLAP引擎就够了。少一个组件,就少一类故障,这个原则在数据链路里尤其成立。
其次要关注连接器的成熟度。选型时先看你需要的上游和下游有没有官方连接器,比如Kafka、JDBC、Iceberg、ClickHouse这些是否有稳定Sink支持,这决定你开发周期是三天还是三周。自己写连接器不是不行,但流式写入的并发控制、断点续传、幂等保障这些细节非常耗时,能用现成方案就别重复造轮子。
3. 实操过程与核心环节实现
3.1 一条实时数仓链路的整体设计
我们以一个常见的“实时订单指标”项目为例来拆解完整链路。场景目标:实时统计每分钟的订单数、成交金额、新老客占比,并写入OLAP引擎供大屏展示。
整体链路分成四段:
- 业务库MySQL产生订单数据,通过Flink CDC捕获binlog变化,写入Kafka的ods_order_topic;
- Flink消费ods_order_topic,做数据清洗、字段补全、解析JSON,输出到dwd_order_detail_topic;
- Flink第二次消费dwd_order_detail_topic,按分钟窗口聚合生成dws_order_minute_topic;
- Flink第三次消费dws_order_minute_topic,写入ClickHouse/Doris的ads表,完成数据落地。
这里专门用三个Flink任务而不是一个任务把全链路串完,很多人不理解。原因有三个:第一,分开任务可以让每个环节有不同的并行度,消息量大和加工复杂的步骤独立扩容;第二,单个环节失败只需要重启一部分任务,避免全链路阻塞;第三,中间topic可以作为数据检查点,方便对账和问题定位。当然拆得过多也麻烦,链路拉长延迟增加,一般两层到三层就能应付大多数场景。
3.2 从Kafka到Flink的接入配置与参数解读
写一个Flink SQL消费Kafka的完整示例。假设DWD层已经准备好了订单明细JSON,每条记录里包含order_id、user_id、amount、order_ts、region等字段。在Flink SQL里建表语句长这样:
CREATE TABLE dwd_order_detail ( order_id STRING, user_id BIGINT, amount DECIMAL(10, 2), order_ts TIMESTAMP(3), region STRING, WATERMARK FOR order_ts AS order_ts - INTERVAL '5' SECOND ) WITH ( 'connector' = 'kafka', 'topic' = 'dwd_order_detail_topic', 'properties.bootstrap.servers' = 'kafka1:9092,kafka2:9092,kafka3:9092', 'properties.group.id' = 'flink-order-dwd-01', 'scan.startup.mode' = 'latest-offset', 'format' = 'json' );几处关键参数解释一下。WATERMARK FOR order_ts AS order_ts - INTERVAL '5' SECOND意思是不等所有数据都到齐就开始算窗口,允许最多5秒的迟到时间,超过5秒的迟到数据被丢弃或者进入侧输出。生产环境这个值要看网络抖动和上游采集延迟,给太大会让指标输出滞后,给太小会造成数据漏算。scan.startup.mode有三个选择:latest-offset只消费新数据,适合大屏场景;earliest-offset从头消费,适合补数;timestamp指定时间点消费,适合故障恢复后只回补特定时段。上线实时任务前,先想清楚要不要历史数据,可以省掉很多麻烦。
3.3 使用Flink SQL实现秒级指标聚合
DWS层的聚合逻辑,在Flink SQL里用GROUP BY加窗口函数实现。实时订单场景,按每分钟统计的SQL大概是这样的:
CREATE TABLE dws_order_minute ( minute_start TIMESTAMP(3), region STRING, order_count BIGINT, gmv DECIMAL(12, 2), new_user_count BIGINT ) WITH ( 'connector' = 'kafka', 'topic' = 'dws_order_minute_topic', 'format' = 'json' ); INSERT INTO dws_order_minute SELECT TUMBLE_START(order_ts, INTERVAL '1' MINUTE) AS minute_start, region, COUNT(*) AS order_count, SUM(amount) AS gmv, SUM(IF(user_type = 'new', 1, 0)) AS new_user_count FROM dwd_order_detail GROUP BY TUMBLE(order_ts, INTERVAL '1' MINUTE), region;这里TUMBLE是滚动窗口,1分钟一个窗口,和离线SQL里的group by日期很像,但细节在于:窗口的边界取决于order_ts这个事件时间,而不是数据到达Flink的处理时间。如果你没有声明watermark,窗口会按处理时间划分,数据晚到几分钟就会落错窗口,指标对不上。踩过这个坑之后就长记性了,凡是做窗口聚合,第一件事确认事件时间字段和watermark策略。
3.4 结果表与维表关联的细节
ADS层落到ClickHouse或Doris的表结构,需要针对实时查询的特点设计。比如dws_order_minute在OLAP引擎里要建一个ReplacingMergeTree或者Duplicate表,按minute_start和region组合为主键,写入时自然去重。一个常见错误是直接用订单明细主键去设计ADS表,结果是明细不断插入、更新无限堆积,OLAP引擎的合并压力巨大。
维度关联在实时链路里也很高频。比如订单流转需要把region名称从ID变成中文,在实时任务里做维表关联有两种方式:一是通过Flink的维表join直接查询外部存储,适合维表小、更新不频繁的场景;二是把维表也做成Kafka流,通过双流join实现,适合维表频繁变化的场景。前者实现简单,但每次join都打一次外部存储,QPS高时容易成为瓶颈,需要加缓存;后者实时性更好,但要求两个流的先后顺序能对得上,对状态清理策略要求高。实战里如果维表量级在万级别,我倾向用内存缓存方式,TTL设5~10分钟,既能满足实时性又不会把外部存储打爆。
3.5 一条完整链路的实测笔记
把完整链路在测试环境跑了一遍,用模拟数据每秒写入2000条订单消息。先说结果:从MySQL binlog产生到DWS层聚合完成,端到端延迟大概在3~5秒,链路稳定后Flink任务的内存使用量在1.5GB左右,事件时间watermark延迟维持在300ms内,没有出现明显反压。
但第一次上线时遇到过一个问题:DWS层聚合结果偶发少算。后来排查发现是Kafka topic的分区数和Flink的并行度不匹配:Flink的并行度是16,但topic分区只有8个,导致其中8个并发处理8个分区,另外8个并发空转,某些分区的数据消费速度跟不上。重新把topic分区和Flink并行度都扩到16之后,消费速度不再出现瓶颈。另一个问题是上游binlog的时区设置和Flink的时区不一致,结果窗口按UTC切分,分钟数据看上去总是延迟8小时,统一时区配置后解决。这些细节都在生产环境反复打磨过,写在这里供后来者参考。
4. 常见问题与排查技巧实录
4.1 数据延迟突然变大,怎么定位
实时链路最常见的症状就是“今天指标延迟很多”,大屏上的数据和业务库差异从几分钟拉到半小时。我的排查顺序是:先看Kafka消费组有没有堆积,再看Flink任务的反压情况,最后看下游OLAP的写入性能。
Kafka堆积可以用命令行直接查:
kafka-consumer-groups.sh --bootstrap-server kafka1:9092 --describe --group flink-order-dwd-01看LAG列,如果持续增长,说明Flink消费不过来。然后开Flink Web UI看每个算子是否有反压,BackPressure状态为HIGH表示下游处理速度低于上游数据进入速度。反压通常不是单点问题,要么是窗口算子状态量太大触发RocksDB读写慢,要么是下游Sink写入OLAP失败重试导致算子阻塞。顺着这条线排查,一般都能找到根因。
4.2 结果不准:乱序数据与迟到数据处理的边界
实时统计结果和离线对不上,十有八九是乱序数据引起的。我遇到过这样一个案例:订单实际创建时间是23点59分50秒,但因为网络延迟,这条消息在00点00分20秒才被Flink处理,如果不做事件时间处理,它会被归到00:00的窗口,结果造成前一个窗口少算、后一个窗口多算。
解决思路是watermark加窗口允许延迟。Flink SQL可以给窗口设置allowedLateness:
INSERT INTO dws_order_minute SELECT TUMBLE_START(order_ts, INTERVAL '1' MINUTE), region, COUNT(*) FROM dwd_order_detail WHERE order_ts >= TUMBLE_START(order_ts, INTERVAL '1' MINUTE) - INTERVAL '10' SECOND GROUP BY TUMBLE(order_ts, INTERVAL '1' MINUTE), region;允许迟到的时间不是一个固定玄学值,可以按网络延迟的P99来定。比如观察一晚上,发现99%的消息延迟在4秒内,就把watermark进度设成5秒,允许迟到设成10秒,既覆盖绝大多数延迟,又不会把窗口输出拖得太迟。超过窗口容忍的数据也别直接丢弃,用侧输出流存下来,定时跟离线对账时用。
4.3 Kafka消费堆积的几种救援方法
消费堆积是实时链路的家常便饭。堆积发生时,第一步不要急着扩容,先摸清堆积发生在哪个环节。如果LAG集中在某个topic,看是上游写入变多,还是下游任务吞吐下降。上游大促流量暴涨,这种属于正常波峰,可以临时扩容消费者实例数。如果是下游任务由于数据倾斜导致单分区消费卡住,再怎么扩消费者都没用,得先解决倾斜。
数据倾斜在实时任务里比离线更隐蔽。离线任务倾斜可以通过spark sql的shuffle分区看到热点,实时里往往表现为某个subtask busy率100%,其他subtask空闲。订单数据按user_id聚合时,大促秒杀场景少数头部用户产生大量订单,就很容易打满一个并发。我的处理思路是:把聚合key打散加盐,比如把user_id加上随机后缀先做一轮局部聚合,再按原始key做第二轮精确聚合。这相当于把热点分摊到多并发,能显著缓解单点压力,代价是数据会多一次shuffle。大促时加盐策略不能无脑用,因为结果精度会受影响,落地前要在测试环境压一把,看误差范围能不能接受。
4.4 Flink任务异常重启与checkpoint问题处理
Flink任务无故重启,常见诱因有几个:内存溢出、checkpoint超时、状态恢复失败。内存溢出如果是堆内导致,看TaskManager的堆内存配置和数据结构是否过于膨胀;如果是RocksDB状态后端,看本地磁盘空间是否够用,以及block cache参数是否合理。
checkpoint超时通常是因为算子执行时间超过了checkpoint.timeout默认值,或者反压导致barrier无法及时对齐。遇到这种情况,先处理反压,而不是盲目调大超时时间。如果某个算子的处理时间本身就是分钟级的,可以把检查点间隔和超时时间调大,但要明确这是以更长的故障恢复窗口为代价的。
状态恢复失败多数发生在更改了算子逻辑或者并行度之后,Flink的keyed state默认按operator名和uid绑定,改动之后状态对不上。所以我一直建议:Flink SQL任务里不管逻辑怎么改,每个算子的uid都要显式固定,不要依赖自动生成。这个习惯平时不起眼,等你要原地升级或者调整并行度的时候就能救命。
4.5 排查工具箱与常用命令速查
| 排查目标 | 推荐工具/命令 | 注意要点 |
|---|---|---|
| Kafka消费堆积 | kafka-consumer-groups.sh --describe | 记录LAG变化趋势而非只看当前值 |
| Flink任务指标 | Web UI / Prometheus + Flink metrics | 重点看busy%、checkpoint历史、反压 |
| OLAP写入性能 | Explain查询计划 / 系统表part数量 | ClickHouse看mutations积压,Doris看tablet健康度 |
| 数据完整性 | 实时侧输出流 + 离线对账脚本 | 对账口径要细到分钟,不只看总量 |
排查的时候不要只看单个指标,实时链路很长,问题常常跨组件传导。Kafka LAG涨可能是因为Flink任务重启,Flink任务重启又可能因为下游OLAP写不进去,OLAP写不进去则可能是表合并积压。所以每到一个组件,先看它自己的健康状态,再往上追上游,往下查下游,形成一个闭环,才能快速定位。
5. 我的实操心得与后续扩展方向
5.1 三个让我少走弯路的习惯
第一个习惯是“先定指标口径,再写实时链路”。实时任务上线最怕中途改口径,改一次SQL逻辑,状态可能作废,要全量重放数据。离线改口径翻新表就行,实时改口径等于把河里的水重新放一遍。所以接需求前先和业务方把指标的维度、粒度、过滤条件、时间口径确认到无歧义,花半天沟通能省三天返工。
第二个习惯是“实时任务也要留可回溯的中间数据”。Kafka消息消费完就消失,如果中间层状态丢了,想核对当时某个时间点的结果会非常吃力。我现在都会把DWD层关键明细同步写到Iceberg或对象存储一份,按天归档。这样就算半年前的数据,也能随时做实时链路和离线数据的交叉验证。
第三个习惯是“把监控做成自动化的,不靠人肉盯屏”。实时链路的报警至少要做到:Kafka LAG超过阈值报警、Flink任务重启报警、checkpoint连续失败报警、OLAP写入延迟报警。报警消息里带链接,谁收到谁点进去就能看具体哪个环节出问题。数据链路最怕的不是故障,而是故障半小时没人发现,等到业务反馈已经是事故了。
5.2 流式数仓还能怎么演进
数据仓库的流处理技术还在往前走,几个方向我认为值得持续关注。
Flink CDC的成熟让“数据库即数仓数据源”这件事变得更加顺滑,binlog直接入湖的体验比过去中间加一堆采集组件舒服很多。另一个方向是流式数据湖,比如Iceberg的流式读写能力,让实时链路和离线湖仓逐渐走向统一,未来可能真的能在一个存储上同时支撑批读和流读,Lambda架构里那套双轨对账机制就没那么必要了。还有一类是OLAP引擎的实时写入能力增强,分钟级甚至秒级导入越来越常见,实时数仓的ADS层可以更贴近查询需求。
不过技术演进归演进,落到工程上还是要稳字当头。数据仓库这种基础设施,可靠性远比技术先进性重要。一个新的组件再香,上线前先做故障演练、数据比对、压测,确认它在自己业务场景下真的站得住,再逐步切流量。
就我个人经验来说,实时数仓或者说“数据仓库的流处理技术”最大的推荐方式,是让它自然生长在已有的数据体系里,而不是推倒了重构。从一两个核心指标开始试水,把链路跑顺,把监控配上,再一点一点扩大覆盖范围。这样走下来的系统,也许不够炫,但一定靠谱。