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,排查起来非常费劲。我的建议是先把三个组件的版本钉死,再去找匹配的连接器,而不是反过来。
| 组件 | 建议版本区间 | 选择理由 |
|---|---|---|
| Flink | 1.17 或 1.18 | 这两版 API 相对稳定,社区连接器覆盖完整,1.19 之后部分接口有调整 |
| StarRocks | 3.0 及以上 | 主键模型持久化索引默认开启,写入性能比 2.x 明显好 |
| Kafka | 3.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 Exists | label 前缀重复或未配 | 每个任务用唯一前缀 |
Tablet ... version冲突 | 并发导入同一批 tablet | 降低 Sink 并行度或增大分桶数 |
| 数据写入成功但查不到 | 攒批未 flush 或事务未提交 | 检查 flush 间隔,等待 checkpoint 完成 |
| 字段全部为 null | JSON 字段名与表列名不匹配 | 配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 的写入什么时候算"生效了",这三个问题的答案构成了整条链路的可靠性基础。我现在的习惯是每接一个新任务,先把这三个问题在文档里写清楚,再动手写代码,看似慢了半天,实际省下的排查时间要以周计。另外,链路监控的优先级要提到和功能开发一样高,一个没有监控的实时任务,等于在生产环境埋了一颗不知道什么时候爆的雷。