ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flink+Kafka+StarRocks实时数仓链路设计与调优

Flink+Kafka+StarRocks实时数仓链路设计与调优

1. 这条链路到底在解决什么问题

第一次接到"Kafka 里的埋点数据要进 StarRocks 做实时看板"这个需求的时候,我脑子里第一反应是:这不就是标准的流式 ETL 吗,Source 接 Kafka,中间做点清洗和关联,Sink 打到 StarRocks,一天能搞定。真动手之后才发现,代码写完只花了半天,剩下的时间全耗在"为什么数据有重复""为什么看板延迟十分钟""为什么导入报 label 已存在""为什么半夜任务挂了没人知道"这些问题上。所以这篇东西我不打算写成一份 API 手册,而是把这条 Flink 读取 Kafka 数据、处理之后写进 StarRocks 的链路,从选型、建表、写代码、调参到排障,完整地讲一遍我在实际项目里踩出来的路径。

先说清楚它是什么。这条链路本质上是把消息队列里的原始事件流,经过 Flink 的清洗、过滤、补维、聚合,落到 StarRocks 这种能支撑高并发点查和实时分析的 OLAP 库里,让下游的报表、看板、指标接口能在一个可接受的延迟内读到数据。它解决的问题很具体:业务方不满足于 T+1 的离线结果,希望分钟级甚至秒级看到分渠道、分地区、分版本的实时指标;同时原始数据又不能直接查,格式乱、字段缺、口径不统一,必须有一层流处理做加工。适合读这篇的人,大致是三类:正在做实时数仓链路的同学,手上刚好要用 Flink 和 StarRocks 但不确定参数怎么配;已经跑起来但被数据重复、延迟、反压搞得头疼想优化的人;还有准备做技术选型、想知道这条链路在哪些地方容易翻车的人。

这里有个认知要先摆正:这条链路不是"三个组件连起来"这么简单,它是一条数据一致性的契约链。Kafka 保证消息不丢,Flink 保证处理状态可恢复,StarRocks 保证写入可幂等,任何一环的语义没对齐,最终结果就是脏数据。所以后面的内容里,Checkpoint 配置、Label 机制、攒批参数这些看起来琐碎的东西,才是真正决定这条链路能不能上生产的关键,代码本身反而是最短的那块板。

2. 整体架构与选型背后的取舍

2.1 为什么是 Flink 而不是别的流处理引擎

流处理引擎能选的其实不止一个,轻量点的有基于微批的思路,重一点的有 Spark Structured Streaming。之所以最后落在 Flink 上,核心原因有三个。第一是真正的事件驱动,不是靠微批攒出来的伪流,窗口触发和定时器精度能到毫秒级,对于需要按事件时间做迟到数据处理、乱序纠正的场景,这个能力是刚需。第二是状态管理成熟,RocksDB 状态后端加上 Checkpoint 和 Savepoint,可以让一个跑了几个月的任务在扩缩容之后从断点恢复,中间做维表缓存、去重、会话切分这些有状态的操作不用自己造轮子。第三是连接器生态完整,Kafka Source 和 StarRocks Sink 都有官方或社区维护的成熟实现,不用自己写一堆 HTTP 请求去拼。

当然也不是没有代价。Flink 的运维复杂度明显高于微批方案,checkpoint 大了之后做一次全量恢复可能要几十分钟,TaskManager 内存管理也要调。所以如果你的场景是"每分钟跑一次小批量同步",其实没必要上 Flink,用调度工具跑批更省心。但只要是持续不断的流、要做窗口聚合和有状态计算,Flink 的投入产出比就很划算了。

2.2 三段式链路的职责边界怎么划

这条链路我习惯分成三段来看,每段有明确的边界,越界就会出问题。第一段是 Kafka 侧,它的职责只有两个:把数据按合理的分区键投递,保证同一业务主键的消息落在同一分区从而有序;以及配置足够长的保留时间,让 Flink 在故障恢复时还有数据可以重放。至于数据长什么样、字段合不合法,Kafka 不负责。第二段是 Flink 侧,职责是清洗、标准化、补维、聚合和去重,把原始事件加工成可以直接查询的宽表或者指标表。这里最常见的设计错误是把大量查询型逻辑放到 Flink 里做,比如每次来一条数据就查一次外部接口补维,结果外部接口成了瓶颈,反压一路顶回 Kafka。第三段是 StarRocks 侧,职责是存好、建好索引、支撑查询,写入性能依赖表模型和分区分桶设计,查询性能依赖物化视图和排序列,这些都是在建表时定死的,事后改成本很高。

我见过一个典型的越界案例:某业务的埋点数据在 Flink 里做了七层嵌套的 JSON 解析,然后把几十个字段全打成一张明细表写进 StarRocks,结果单表一天几百亿行,查询全靠现场聚合,看板加载要十几秒。后来改成 Flink 里直接按分钟粒度预聚合,写入量降到原来的百分之一,查询变成点查。这个改动说明一件事:聚合放在流里做,明细放在仓里做,位置放反了性能就是灾难。

2.3 数据一致性语义怎么对齐

这是很多人容易忽略但必须提前想清楚的部分。Kafka 到 Flink 这一段,靠isolation.level=read_committed加上关闭自动提交 offset、把 offset 存在 checkpoint 里,能做到精确一次消费。Flink 到 StarRocks 这一段,靠 Stream Load 的 Label 机制实现幂等:每个批次带一个全局唯一的 label,StarRocks 侧相同 label 的导入只会成功一次,重复提交会返回已存在。两段合起来,配合 Flink 的 Checkpoint,整条链路才能做到端到端的精确一次。这里面任何一个环节掉链子,比如 checkpoint 没开、label 没配,语义就退化成至少一次,数据重复是必然的。所以我在项目里有个硬规矩:只要下游是给人看的指标,checkpoint 和 label 必须同时开,宁可牺牲一点延迟,也不接受对不上账。

3. 动手前必须搞定的环境与版本对齐

3.1 版本矩阵怎么选才不踩坑

这条链路上最容易出问题的地方不是代码,是版本。Flink 的大版本之间 API 有不兼容改动,StarRocks 的 Flink 连接器又是跟着 Flink 版本走的,选错了要么编译不过,要么运行时抛NoSuchMethodError,排查起来非常费劲。我的建议是先把三个组件的版本钉死,再去找匹配的连接器,而不是反过来。

组件建议版本区间选择理由
Flink1.17 或 1.18这两版 API 相对稳定,社区连接器覆盖完整,1.19 之后部分接口有调整
StarRocks3.0 及以上主键模型持久化索引默认开启,写入性能比 2.x 明显好
Kafka3.x客户端协议兼容性好,配合 Flink 的 Kafka 连接器没坑
flink-connector-starrocks与 Flink 大版本匹配的最新小版本连接器版本必须严格对应 Flink 版本,不能跨大版本使用
flink-connector-kafka与 Flink 大版本匹配Flink 1.15 之后 Kafka 连接器从主仓库剥离,需要单独引入

表里最后两行要特别强调一下,这是新手最容易翻车的地方。Flink 1.15 之后把 Kafka 连接器拆出去单独发版了,如果你用的是 1.17,却引了一个旧版本的flink-connector-kafka,编译期可能不报错,运行起来就是类找不到或者方法签名不匹配。我的做法是在pom.xml里用flink.version和connector.version两个 property 统一管理,改版本只改一处,避免同一个项目里依赖打架。

3.2 依赖引入的两种方式和各自的坑

第一种是 Maven 引入,本地开发打包提交集群。这种方式下要注意依赖的作用域,Flink 自身提供的包(flink-streaming-java、flink-clients这些)必须标成provided,否则打进 fat jar 之后会跟集群上的包冲突,典型症状是提交任务时报序列化异常或者ClassCastException。而 Kafka 连接器、StarRocks 连接器这些集群里没有的包,必须打进包里,不能标provided,否则运行时报ClassNotFoundException。这个边界划错了,本地能跑集群跑不了,或者反之,非常折磨人。

第二种是 Flink SQL 的CREATE TABLE ... WITH ('connector' = 'starrocks')方式,把 jar 放到集群的lib目录或者用-C参数指定。这种方式的好处是不用写 Java 代码,纯 SQL 就能跑,适合逻辑简单的同步任务;坏处是复杂的数据清洗和自定义函数不好写,而且参数拼错的时候报错信息比较隐晦,常常只告诉你"连接失败",不告诉你是哪个参数写错了。我的经验是:简单同步用 SQL,有复杂加工逻辑用 DataStream API,不要为了省事硬用 SQL,最后写成一坨两千行的嵌套查询,维护成本比 Java 还高。

注意:连接器的 jar 包版本和 Flink 版本不匹配时,最常见的报错不是编译错误,而是运行时抛java.lang.NoSuchMethodError或IncompatibleClassChangeError。看到这两类异常,先查版本,别急着看业务代码。

4. StarRocks 侧的表设计与写入前提

4.1 数据模型怎么选

StarRocks 有三种数据模型,选错了后面全是麻烦。明细模型适合保留所有原始行、需要查明细的场景,相同主键不去重;聚合模型适合预先定义好聚合方式的指标表,写入时就聚合,查询极快但不能查明细;主键模型适合需要按主键更新的场景,比如维表、状态表、需要 upsert 的订单表,写入新行会覆盖旧行。

流式写入场景下,我大部分时候选主键模型或者明细模型。选主键模型的判断标准是:数据是否存在同主键多次写入、且只需要保留最新状态。比如用户画像宽表、订单状态表,典型的主键模型。而埋点流水、日志这种每一条都要留痕的,用明细模型,配合分区做冷热分离。聚合模型的坑在于,一旦后续口径变了要新增维度,就得重新建表重刷数据,灵活度差,所以我一般只在指标口径极其稳定的场景才用。

主键模型建表时还有两个参数要留意:enable_persistent_index建议开启,把主键索引落到磁盘,避免内存撑爆;replication_num生产环境至少 3,测试环境可以调成 1,但千万别把测试配置带到生产。这里我踩过一次,测试环境建表时图省事写了replication_num = 1,上线时脚本直接复用,结果某台 BE 节点重启,数据不可用,看板直接白屏。

4.2 分区分桶参数怎么算

这是建表环节唯一需要动脑子算的地方,也是最容易被忽略的地方。分区决定数据怎么按时间切分,分桶决定数据怎么在 BE 之间打散。分区太细会导致小文件多、元数据压力大;分区太粗会导致单分区数据量过大,查询扫描不友好。我的经验值是:单分区数据量控制在 10GB 到 100GB 之间,按天分区是最常见的粒度,如果日增量超过 100GB 就考虑按小时分区。

分桶数的计算有个简单公式:单分区数据量除以单 tablet 的目标大小(建议 1GB 到 3GB),向上取整到 2 的幂,并且保证是 BE 节点数的整数倍。举个实际例子,假设日增 20 亿行、单行平均 200 字节,那一天大概 400GB,按天分区,单 tablet 目标 2GB,那需要 200 个 tablet 左右,取 256 个桶。如果集群有 8 台 BE,256 除以 8 等于 32,正好整除,数据分布均匀。如果算出个 200,取 256 比取 128 更合适,因为 128 意味着单 tablet 3GB 出头,略超目标值,但 256 会让单 tablet 降到 1.5GB 左右,查询并行度更好。

分桶键的选择原则是高基数、查询中经常作为过滤条件或者 join 键的字段。分桶键选错会导致数据倾斜,比如选了个 90% 都是同一个值的字段,那所有数据都挤到一两个 tablet 上,写入直接成瓶颈。我一般用业务主键或者用户 ID 做分桶键,不用时间字段(时间已经做分区了)。

CREATE TABLE dws_realtime_event ( dt DATE NOT NULL COMMENT "分区日期", user_id BIGINT NOT NULL COMMENT "用户ID", channel VARCHAR(64) NOT NULL COMMENT "渠道", event_cnt BIGINT NOT NULL COMMENT "事件数", amount DECIMAL(20,2) NOT NULL COMMENT "金额", update_time DATETIME NOT NULL COMMENT "更新时间" ) PRIMARY KEY (dt, user_id, channel) PARTITION BY RANGE (dt) ( START ("2024-01-01") END ("2025-01-01") EVERY (INTERVAL 1 DAY) ) DISTRIBUTED BY HASH (user_id) BUCKETS 256 PROPERTIES ( "replication_num" = "3", "enable_persistent_index" = "true", "storage_medium" = "SSD" );

这段建表语句里有几个细节值得说。主键必须包含分区列,所以dt放在了主键第一位;分区用START ... END ... EVERY批量创建,避免手写一堆PARTITION子句;storage_medium明确指定 SSD,因为实时写入频繁,机械盘会成为拖累。如果 BE 集群是混合存储,还可以加storage_cooldown_time让冷数据自动下沉到便宜介质。

4.3 Stream Load 的 Label 机制与事务边界

Flink 写 StarRocks 走的是 Stream Load 协议,本质是一个 HTTP 请求,把一批数据推过去。这个协议有几个特性必须理解。第一,它是同步的,请求发出去要等返回,返回成功才算写入完成,这跟某些异步落盘的方式不一样,好处是失败能立刻感知。第二,它是按 label 幂等的,同一个 label 重复提交,第二次会返回Label Already Exists,数据不会重复导入。第三,单次导入会形成一个事务,要么整批成功,要么整批失败,不会出现半批数据的情况。

理解这三点之后,很多设计就顺理成章了。比如为什么必须要配sink.label-prefix,因为连接器需要根据前缀加上 checkpoint ID 和子任务编号来生成唯一 label,Flink 从 checkpoint 恢复时用同样的 label 重放,StarRocks 侧自动去重。再比如为什么攒批不能攒太大,因为单次 Stream Load 的默认超时是 600 秒,攒得太多一旦超时整批白写,还要重试。

提示:sink.label-prefix在整个集群内必须全局唯一。多个任务共用同一个前缀,会导致 label 冲突,表现为大量导入失败并报 label 已存在。建议用"业务名_表名_任务版本"这种格式,改逻辑时顺手改版本号。

5. Flink 任务骨架的搭建与核心配置

5.1 Source 端:Kafka 消费怎么配对

Source 端的配置看着简单,实际上有好几个参数决定了下游的稳定性和数据准确性。首先是启动位点,scan.startup.mode有几个取值,earliest-offset从最早开始、latest-offset从最新开始、group-offsets从消费组提交的位点开始、timestamp从指定时间开始。生产环境我一般用group-offsets,配合properties.auto.offset.reset=earliest兜底,这样第一次启动能从头消费,后续重启从断点接上。绝对不要用latest-offset做生产任务,一旦任务停了几天再启动,中间的数据就永久丢了。

然后是properties.isolation.level,如果要保证精确一次,必须设成read_committed,只读已提交的消息,避免读到上游事务中途的数据。同时要把enable.auto.commit设成 false,offset 交给 Flink 的 checkpoint 管理,而不是让消费者自己提交,否则会出现"checkpoint 失败但 offset 已经提交"的尴尬局面,恢复之后数据就丢了。

Properties props = new Properties(); props.setProperty("bootstrap.servers", "kafka-broker-1:9092,kafka-broker-2:9092"); props.setProperty("group.id", "realtime-dws-event"); props.setProperty("enable.auto.commit", "false"); props.setProperty("isolation.level", "read_committed"); props.setProperty("auto.offset.reset", "earliest"); KafkaSource<String> source = KafkaSource.<String>builder() .setBootstrapServers(props.getProperty("bootstrap.servers")) .setTopics("ods_user_event") .setGroupId(props.getProperty("group.id")) .setStartingOffsets(OffsetsInitializer.committedOffsets(OffsetResetStrategy.EARLIEST)) .setProperty("isolation.level", "read_committed") .setValueOnlyDeserializer(new SimpleStringSchema()) .build();

还一个容易被忽略的点是分区数与并行度的关系。Kafka 单个分区同一时刻只能被一个消费者消费,所以 Source 的并行度上限就是分区数。如果你设了 16 个并行度但 topic 只有 4 个分区,那有 12 个 Source 子任务是空转的,白占资源。反过来说,如果分区数是 64 而并行度只有 8,那么每个子任务要消费 8 个分区,吞吐上不去。合理的配置是并行度等于分区数,或者分区数能被并行度整除。

5.2 Checkpoint:整条链路的生命线

Checkpoint 的配置直接决定了任务的容错能力和数据一致性,这部分我建议单独拉出来认真调。核心参数有四个:间隔、超时、最小间隔、模式。间隔设太短,每次 checkpoint 都要对齐屏障、上传状态,开销大;设太长,故障恢复时要重放的数据多,而且 StarRocks 侧的攒批数据在 checkpoint 之前是不可靠的。我一般设 30 秒到 1 分钟,配合超时 10 分钟,最小间隔给间隔的一半,避免前一次还没完成就触发下一次。

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.enableCheckpointing(60_000, CheckpointingMode.EXACTLY_ONCE); CheckpointConfig cpConfig = env.getCheckpointConfig(); cpConfig.setCheckpointTimeout(600_000); cpConfig.setMinPauseBetweenCheckpoints(30_000); cpConfig.setMaxConcurrentCheckpoints(1); cpConfig.setExternalizedCheckpointCleanup( ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION); cpConfig.setTolerableCheckpointFailureNumber(3); env.setStateBackend(new EmbeddedRocksDBStateBackend(true));

tolerableCheckpointFailureNumber这个参数值得单独说,默认是 0,意思是一次 checkpoint 失败任务就挂。生产环境我一般设成 3,给网络抖动、StarRocks 短暂不可用留点缓冲。但也不能设太大,否则连续失败十几次任务还在"苟活",状态早就落后了,问题被掩盖。

RocksDB 状态后端建议开启增量 checkpoint(构造参数传 true),状态大的时候能把每次 checkpoint 的数据量降一个数量级。配合本地恢复,常规故障恢复能在一两分钟内完成,而不是几十分钟。这里有个前提是集群要配好 checkpoint 存储目录,本地恢复依赖每个 TaskManager 上有历史状态副本,节点挂了就没法本地恢复,走远程恢复。

5.3 Sink 端:三种写法怎么选

往 StarRocks 写数据,实践中有三种写法,适用场景差别很大。

第一种是官方连接器的 DataStream API,用StarRocksSink.sink(...)构造一个 SinkFunction,配置通过StarRocksSinkOptions传入。这是最主流的做法,控制力最强,能精确控制攒批参数、序列化格式、重试策略,也能拿到写入失败的异常做自定义处理。缺点是要自己管序列化,字段顺序必须和表结构严格对应,改了表结构忘了改代码就是线上事故。

第二种是Flink SQL 的 connector,CREATE TABLE里指定'connector' = 'starrocks',参数通过WITH传。好处是纯声明式,配合 SQL 做数据加工很顺手,表结构变更时改 SQL 就行。缺点是参数校验比较松,写错了不报错直到运行时,而且不支持一些高级特性,比如自定义序列化。

第三种是自己封装 Stream Load 的 HTTP 请求,用RichSinkFunction加上批量缓冲自己做。这种写法除非有非常特殊的诉求(比如需要自己控制事务边界、或者连接器版本有 bug 绕不过去),否则不建议,因为 label 管理、重试、超时这些细节全要自己处理,坑很多。

StarRocksSinkOptions options = StarRocksSinkOptions.builder() .withProperty("jdbc-url", "jdbc:mysql://fe-host:9030") .withProperty("load-url", "fe-host:8030") .withProperty("database-name", "realtime_dw") .withProperty("table-name", "dws_realtime_event") .withProperty("username", "etl_user") .withProperty("password", "******") .withProperty("sink.properties.format", "json") .withProperty("sink.properties.strip_outer_array", "true") .withProperty("sink.buffer-flush.max-rows", "50000") .withProperty("sink.buffer-flush.max-bytes", "33554432") .withProperty("sink.buffer-flush.interval-ms", "5000") .withProperty("sink.max-retries", "3") .withProperty("sink.semantic", "exactly-once") .withProperty("sink.label-prefix", "dws_event_v1") .build(); DataStream<String> jsonStream = ...; jsonStream.sinkTo(StarRocksSink.sink(options));

load-url这里填的是 FE 的 HTTP 端口,请求会被 FE 重定向到具体的 BE,所以填 FE 地址就行,不要直接填 BE,因为 BE 可能扩缩容。如果 FE 前面有负载均衡,可以把多个 FE 地址用分号隔开,连接器会轮询选择,提高可用性。

6. 数据处理层的写法与那些绕不开的坑

6.1 反序列化与脏数据处理

从 Kafka 读出来的原始数据,第一道关卡就是反序列化。看起来简单,实际上一半的线上事故都出在这里。常见情况是上游字段类型变了、字段缺了、某条消息体是半截的 JSON,JSON.parseObject直接抛异常,整个任务挂掉。我一开始的做法是 try-catch 之后返回 null,让这条数据被过滤掉,结果跑了一周发现数据量比上游统计少了 3%,排查半天才定位到是某类消息带了非法字符,全被静默丢弃了。

正确的做法是把脏数据显式收集起来,而不是静默丢弃。可以用侧输出流把解析失败的消息连同失败原因一起输出到另一个 Kafka topic 或者对象存储,定期检查。这样既能保证主链路不被单条坏数据拖垮,又能保证丢失的数据可追溯。

public class EventDeserializer implements DeserializationSchema<Event> { @Override public Event deserialize(byte[] message) { try { String raw = new String(message, StandardCharsets.UTF_8); return JSON.parseObject(raw, Event.class); } catch (Exception e) { // 记录原始报文,交给侧输出流处理 FailedRecordHolder.set(new String(message, StandardCharsets.UTF_8), e.getMessage()); return null; } } @Override public boolean isEndOfStream(Event nextElement) { return false; } @Override public TypeInformation<Event> getProducedType() { return TypeInformation.of(Event.class); } }

还一个坑是时间字段的格式。上游给的可能是毫秒时间戳,也可能是"2024-01-01 10:00:00"这种字符串,还可能是 ISO8601 带时区的。StarRocks 的DATETIME类型只认yyyy-MM-dd HH:mm:ss这个格式,如果直接把时间戳塞进去,写入会失败或者变成 1970 年。所以中间必须有一个明确的格式转换步骤,把各类时间格式统一转成目标字符串。这里要特别注意时区,如果上游是 UTC 时间戳,转换时要显式加上时区偏移,否则整条链路的统计口径都会偏 8 小时。我们线上就出过一次,指标在凌晨 0 点到 8 点之间总是对不上,查了一下午才发现是时区没处理。

6.2 维表关联:什么时候该用 Lookup Join

实时链路做补维,也就是把 ID 换成名称,有两种做法:查外部存储(Lookup Join)和广播维表(Broadcast Join)。选错的话性能差距可能是十倍。Lookup Join 是每条流数据来了就去外部存储查一次,适合维表大、更新频繁、查询量可控的场景,典型实现是 Flink SQL 的LOOKUP JOIN加上 HBase 或 StarRocks 作为维表。广播维表是把整张维表加载到每个 TaskManager 的内存里,适合维表小、更新不频繁的场景,比如渠道字典、地区映射这种几千行的表。

我踩过的一个坑是在 Lookup Join 里没加缓存。每条数据都去查一次维表,QPS 一上来维表库直接被打满,反压一路传到 Kafka,消费延迟飙到几分钟。后来给 Lookup Join 加了 LRU 缓存,缓存 1 万条、过期时间 10 分钟,命中率到 95% 以上,数据库压力瞬间降下来。这个参数在 Flink SQL 里可以用LOOKUPhint 指定,也可以在连接器配置里设置。但缓存会导致维表更新有延迟,业务方要能接受这个延迟,一般十分钟内的延迟是能接受的。

SELECT e.user_id, e.channel, d.channel_name, e.amount FROM ods_event AS e LEFT JOIN dim_channel FOR SYSTEM_TIME AS OF e.proc_time AS d ON e.channel = d.channel_code

还有个细节是维表关联不上时的处理。如果用 INNER JOIN,关联不上的数据直接丢了,业务方会发现总量比上游少;用 LEFT JOIN,维表字段是 null,下游展示成"未知渠道"。我的习惯是 LEFT JOIN 加上默认值填充,同时把关联不上的比例做成监控指标,超过阈值就告警,因为关联率突然下降往往意味着维表更新出了问题。

6.3 水位线与迟到数据

只要涉及事件时间窗口,水位线就是绕不过去的话题。水位线的本质是对"数据还会不会更早"的一个估计,设得激进(延迟小)会丢迟到数据,设得保守(延迟大)窗口触发慢。我的经验是:先看上游数据的乱序程度,如果 Kafka 里同一分区的数据基本有序,水位线延迟可以设 3 到 5 秒;如果乱序严重(比如多端上报、网络抖动),就设到 30 秒甚至 1 分钟。

CREATE TABLE ods_event ( user_id BIGINT, channel VARCHAR(64), amount DECIMAL(20,2), event_ts TIMESTAMP(3), WATERMARK FOR event_ts AS event_ts - INTERVAL '10' SECOND ) WITH ( 'connector' = 'kafka', 'topic' = 'ods_user_event', 'properties.bootstrap.servers' = 'kafka-broker-1:9092', 'properties.group.id' = 'realtime-dws-event', 'scan.startup.mode' = 'group-offsets', 'format' = 'json' );

光设水位线还不够,迟到数据得有地方去。Flink 的窗口算子支持allowedLateness,允许数据迟到一段时间内还能更新窗口结果;超过这个时间的,可以用侧输出流接住。我在实际项目里会把超迟数据单独写到一张明细表,定期回刷,保证最终结果和上游对得上。这件事不做的话,每天早上对账都是一场灾难。

提示:水位线延迟设大了,窗口触发慢,看板数据会延迟;设小了,迟到数据被丢,指标偏低。这个权衡没有标准答案,只能根据业务对延迟和准确性的取舍来定,建议前期先设保守值,观察两周的迟到分布之后再收紧。

7. 写入性能调优与参数计算

7.1 攒批参数怎么算才不浪费

StarRocks 的写入性能高度依赖攒批,一批数据越大,摊到每行的开销越小,但延迟也越高,而且单批过大会撞上超时或者内存上限。三个核心参数是sink.buffer-flush.max-rows、sink.buffer-flush.max-bytes、sink.buffer-flush.interval-ms,任意一个条件满足就触发一次 flush,所以实际批次大小取决于最先触发的那个条件。

举个实际的算法。假设业务要求端到端延迟不超过 10 秒,上游 QPS 稳定在 2 万,单条记录序列化后大约 400 字节。如果按时间触发,配 5 秒一次的话,每批大概 10 万行、40MB,这个量级对 StarRocks 来说很舒服。但如果 QPS 波动大,高峰期可能 10 秒就攒到 200 万行、800MB,那就太大了。所以要把max-bytes设成一个上限,比如 64MB,那么高峰期实际是按字节触发,每批 16 万行左右,延迟反而更低。参数就按这个逻辑反推:

  • sink.buffer-flush.interval-ms= 5000,不超过业务延迟容忍度的一半
  • sink.buffer-flush.max-bytes= 67108864(64MB),防止突发流量攒太大
  • sink.buffer-flush.max-rows= 200000,作为行数兜底,防止单行特别小的时候字节数触发不了

这三者的关系是区间控制,不是精确控制。实际批次会在某个区间浮动,这是正常的,不用追求精确。

还有一点是并行度与分桶的对应关系。每个 Sink 子任务独立攒批、独立发起 Stream Load,如果并行度远小于分桶数,那么每个子任务要写很多个 tablet,单次导入涉及的分片多,协调开销大;如果并行度远大于分桶数,多个子任务写同一批 tablet,容易产生导入冲突和锁竞争。我的经验是Sink 并行度等于或者略小于分桶数除以 BE 数量,具体来说,256 个桶、8 台 BE,Sink 并行度设在 8 到 32 之间比较合适。

7.2 常见性能瓶颈怎么定位

写入慢的表现有很多种,定位思路是从后往前推。第一步看 StarRocks 侧,从 FE 的审计日志里看每个 Stream Load 的耗时、数据量、错误码。如果单次导入耗时超过 5 秒而且数据量不大,那可能是 BE 负载高或者 tablet 有版本堆积,需要 compaction 了。第二步看 Flink 侧,从 Web UI 看 Sink 算子的 busy 和 backPressured 指标,如果 Sink 一直处于 100% busy,说明写入是瓶颈;如果 Sink 是空闲但上游反压,说明上游处理慢了。第三步看网络和连接,如果导入频繁超时,可能是 FE 到 BE 的带宽不够,或者连接数打满了。

实际项目中我遇到最多的是两类。一类是小批频繁写入,每隔一两秒就发一次 Stream Load,数据量才几百行,导入开销远大于数据处理开销,BE 的导入线程被占满。解决办法就是调大攒批参数,宁可牺牲几秒延迟。另一类是主键模型写入热点,所有数据都往少数几个 tablet 上写,其他 tablet 闲着,整体吞吐上不去。这种要么换分桶键,要么在写入前给分桶键加个随机后缀打散。

现象可能原因排查手段处理方式
导入耗时忽高忽低BE 在做 compaction看 BE 的 compaction 指标错峰写入,调大攒批
持续报超时单批数据量过大看日志里的 batch size调小 max-bytes
写入吞吐上不去分桶键数据倾斜查各 tablet 的数据量分布更换分桶键
CPU 打满但吞吐低序列化开销大火焰图看热点换二进制格式或精简字段
内存持续增长状态没配 TTL看 checkpoint 大小变化给状态加 TTL

8. 线上问题排查实录与避坑清单

8.1 问题速查表

把过去一年踩过的坑整理成一张表,遇到问题时按图索骥能省下不少时间。

报错或现象根因解决方式
Label Already Existslabel 前缀重复或未配每个任务用唯一前缀
Tablet ... version冲突并发导入同一批 tablet降低 Sink 并行度或增大分桶数
数据写入成功但查不到攒批未 flush 或事务未提交检查 flush 间隔,等待 checkpoint 完成
字段全部为 nullJSON 字段名与表列名不匹配配jsonpaths显式映射
时间字段变成 1970时间戳未格式化统一转成yyyy-MM-dd HH:mm:ss
任务启动即失败连接器版本与 Flink 不匹配核对版本矩阵
消费延迟持续上涨反压,写入或处理慢从 Sink 往上游逐级排查
恢复后数据重复未配 exactly-once 或 label开启 checkpoint 加 label

8.2 几个印象最深的具体案例

第一个是字段顺序错位。有次改了 StarRocks 表结构,加了个字段在中间,Flink 侧用的是 CSV 格式按位置写,没同步改代码,结果数据全都串位了,用户 ID 写进了金额字段。这个问题最难发现,因为导入全部成功,没有任何报错。后来我们定了规矩:一律用 JSON 格式加显式字段名,不用 CSV 按位置写。虽然 JSON 的序列化开销大一点,但换来的是结构变更时的安全,这个代价值得。

第二个是checkpoint 一直失败导致任务反复重启。日志里报的是Checkpoint expired before completing,但看 checkpoint 大小才几十 MB,按理说很快。查了半天发现是某个 TaskManager 的磁盘 IO 打满了,写本地 state 卡住。根因是那台机器上还跑着别的服务,磁盘争抢。这个问题提醒我:Flink 集群最好独立部署,不要跟其他服务混部,尤其是对磁盘 IO 敏感的状态后端。

第三个是数据延迟规律性地出现尖峰。每天下午两点到四点,看板延迟从几秒涨到十几分钟,其他时间正常。最后定位到是那个时段有个大的离线任务在跑,把 StarRocks 的 BE CPU 占满了,导致 Stream Load 排队。解决办法是把离线任务的资源组和实时写入隔离开,用 StarRocks 的资源组功能做 CPU 和内存的硬隔离,之后延迟就稳定了。

8.3 上线前建议做的几项检查

任务写完能跑通只是第一步,上线前我一般会过一遍这几件事。压测,用生产流量的 1.5 倍打一遍,看反压和延迟,确认资源够用。故障演练,手动 kill 一个 TaskManager,看恢复时间是否在可接受范围内,数据是否有重复或丢失。监控埋点,至少要监控消费延迟、checkpoint 时长和大小、写入成功率、端到端延迟这四个指标,任何一个异常都要能告警。数据对账,跑一个批任务统计上游总量,跟 StarRocks 里的总量对比,差异率控制在万分之一以内才敢上线。

-- 对账查询示例:按小时统计上游与目标表行数 SELECT e.hour_str, e.src_cnt, s.dst_cnt, ROUND((e.src_cnt - s.dst_cnt) * 100.0 / e.src_cnt, 4) AS diff_pct FROM ( SELECT DATE_FORMAT(event_time, '%Y-%m-%d %H') AS hour_str, COUNT(1) AS src_cnt FROM ods_event_source GROUP BY 1 ) e LEFT JOIN ( SELECT DATE_FORMAT(update_time, '%Y-%m-%d %H') AS hour_str, COUNT(1) AS dst_cnt FROM dws_realtime_event GROUP BY 1 ) s ON e.hour_str = s.hour_str WHERE e.hour_str >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 DAY), '%Y-%m-%d %H') ORDER BY e.hour_str DESC;

这段对账 SQL 我一般做成定时任务,每天跑一次,差异率超过阈值就发告警。一开始觉得这是额外负担,后来发现它是发现隐性数据问题最有效的手段,有好几次都是靠它发现了上游字段变更导致的部分数据丢失。

注意:对账口径一定要和业务方确认清楚,是算去重后的用户数还是算原始事件数,是算写入时间还是算事件时间。口径没对齐,对账永远是"数据有问题",最后大家都不看了。

最后分享一个我在实践中最深的体会:这条链路的复杂度不在技术组件本身,而在于每一个环节的语义假设是否被显式地写下来并被验证。Kafka 的消息什么时候算"消费过了",Flink 的状态什么时候算"持久化了",StarRocks 的写入什么时候算"生效了",这三个问题的答案构成了整条链路的可靠性基础。我现在的习惯是每接一个新任务,先把这三个问题在文档里写清楚,再动手写代码,看似慢了半天,实际省下的排查时间要以周计。另外,链路监控的优先级要提到和功能开发一样高,一个没有监控的实时任务,等于在生产环境埋了一颗不知道什么时候爆的雷。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进