最近帮一个电商团队把实时报表链路从 Lambda 架构切到了 Kappa 架构,改造完之后,整个主链路从原来的 4 套系统缩到 2 套,凌晨的调度依赖全部消失,关键是实时看板和离线报表数字终于能对上了。这篇文章就是这次改造的完整复盘:Kappa 架构的选型依据、实时数仓分层怎么设计、组件版本怎么配、Flink SQL 全链路代码怎么跑通、压测和调优怎么做,以及踩过的几个比较隐蔽的坑。适合正在做实时数据仓库、或者准备从 Lambda 往 Kappa 迁移的读者参考,内容偏实战,可以直接照着落地。
1. 为什么从 Lambda 切到 Kappa,你的业务到底适不适合
1.1 Lambda 架构的痛:两套代码和永远对不齐的口径
先花点时间说说我们为什么要做这次切换。原系统是典型的 Lambda 架构:实时链路用 Flink 算当天累计,凌晨再用 Spark 批任务算 T-1 汇总。看起来是“双保险”,实际上痛苦全在运维和口径上。
最大的问题是同一套业务逻辑要维护两遍。就拿订单金额来说,批处理会过滤掉测试订单、退款单、部分内部单,而实时 SQL 为了“先跑起来”,当时加过滤条件的时候并没有和批任务完全对齐。结果就是每天早上对账,实时数字和离线数字差个几万块,每次都要靠人工去排查差在哪。改一个指标要同时改 Spark 批任务和 Flink 实时任务,两边上线节奏还不一致,经常出现“批任务先上了,实时任务忘了同步”的情况。时间一长,谁都不敢动那套实时 SQL,因为改完你根本不知道会影响哪个下游报表。
这不是代码质量的问题,是结构问题。Lambda 结构上就要求批流两套物理链路并存,那么“两套代码对不齐”就是一个必然结果,而不是偶发 bug。
1.2 Kappa 的解法:一条流,靠重放解决回溯
Kappa 架构的思路很直接:只保留实时一条链路,所有数据都走流式引擎处理,如果需要修正历史数据,就重放 Kafka 里保留的消息,让流式计算重新跑一遍。这样数据从 ODS 到 DWS,永远是同一套 Flink SQL,不存在两套代码分叉的问题。
很多人对 Kappa 有个误解,以为它是“不要批处理、全部实时”,其实它只是把“批”这件事从架构里拿掉了,但保留了一个核心能力:Kafka 的消息重放。当业务逻辑变化需要回算历史数据时,你不需要写一套批任务去跑 Hive,而是把 Flink 的状态清掉,把消费位点重置到需要回放的 offset,重新消费、重新计算、重新写出。只要 Kafka 的保留时间足够覆盖你需要回放的时间窗口,这个操作就能完成。
Kafka 在这里的定位就很重要了——它不只是实时链路的缓冲层,更是整个数据仓库的“可回放存储”。
1.3 什么样的业务适合 Kappa
Kappa 确实好,但不是所有业务都适合。我自己的判断依据是这样的:
| 场景 | 适合程度 | 原因 |
|---|---|---|
| 分钟级甚至秒级可见性需求 | 适合 | 只有一套实时链路,天然能满足 |
| 指标口径相对稳定,不频繁改口径 | 适合 | 改一次口径 = 重放一次,重放有成本 |
| Kafka 保留期内能完成全量重放 | 适合 | 数据量太大、重放时间超过保留期就会断档 |
| 需要 T-1 精确对账、对历史数据精确到天的强校验 | 不太适合 | 需要 OLAP 存储配合主键模型才能强一致 |
| 指标经常变,每次变更都要重算历史 N 天 | 不太适合 | 重放预算会非常吃紧,状态回收也麻烦 |
我们最终的选择是“Kappa 为主,离线批做补充”,但不是让两套口径打架,而是明确分工:实时链路的 Kappa 出口是业务方每天看的实时看板、大屏和告警;离线 Hive 数仓只承担长周期的历史分析和报表归档。Kappa 输出的结果会持久化到 StarRocks,离线数仓也不再去覆盖实时口径,两边各管一段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 实时数仓分层设计:每一层在 Kappa 里用什么承载
Kappa 一样有分层,只是物理载体变了。原来 ODS 是 Hive 表,DWD 是 Hive 表,DWS 是宽表,ADS 是导出的 MySQL/ES;在 Kappa 里,中间层变成了 Kafka Topic + Flink 状态,最终结果落到 OLAP 存储。每一层的作用没有变,只是“查到哪一层的数据”这个逻辑变了。
2.1 ODS 层:Kafka 就是你的原始事件总线
ODS 层在 Kappa 里的角色就是 Kafka 的原始业务 Topic。订单、支付、退款、库存变动这些事件按业务域拆分 Topic,分区键按 user_id 或者 order_id 做 hash,保证同一个业务实体的消息有序进入同一个分区,这是后面做状态聚合的前提。
ODS 的保留时间不建议设太短,至少 72 小时,如果磁盘够的话放到 7 天。这一层就是整个数仓的“原始底片”,任何下游指标计算错误,最终都要通过重放这一层来找回和新算。我们当时把 log.retention.hours 设成了 72,峰值流量下 Kafka 单 Topic 日增约 70GB,3 个副本下来整个集群一天新增差不多 600GB,磁盘规划要按这个量级去评估。
2.2 DWD 层:清洗、维表补齐、以及那个关键的中间 Topic
DWD 层在 Kappa 里做的事情和离线数仓一样:去重、过滤无效数据、补维度字段、统一字段格式,然后输出成一行行的明细宽表。
区别在于,离线 DWD 是一张 Hive 表,等着批任务去扫描;Kappa 的 DWD 是一条 Kafka Topic,Flink 实时写入,下游所有消费者实时订阅。我强烈建议 DWD 不要直接写到 StarRocks 明细表,而是先落一个 Kafka Topic,再让 ADS 层消费。为什么呢?因为数仓不只是服务一张报表,BI、算法、消息推送都可能需要明细数据;走 Kafka Topic 可以让任意下游独立消费、独立回放,不用都挤到 StarRocks 上跑查询。
2.3 DWS 层:预聚合和 Build 的粒度选择
DWS 是做预聚合的地方。在 Lambda 里,这一层经常用 Kylin 或者 Spark 把明细汇总成宽表;Kappa 里这一层是 Flink SQL 的窗口聚合,按 1 分钟、5 分钟、1 小时粒度汇总订单量、GMV、UV 这类指标,结果写到一个新的 Kafka Topic,同时物化一份到 StarRocks 汇总表。
这里有一点比较关键:聚合粒度怎么选。粒度越细,下游越灵活,但 Flink 的状态和计算压力也越大。我们当时定的是 1 分钟粒度和小时粒度各一份。1 分钟的给实时大屏用,小时的给运营报表用。如果你一开始不确定要什么粒度,至少先做分钟的,因为后续可以用分钟结果再自动汇总到小时,反过来就没法补。
2.4 ADS 层:StarRocks 物化与即席查询
ADS 层是真正给业务方查数的地方,我们用 StarRocks 承载。明细表、汇总表、一级宽表全部建在 StarRocks 上,Flink 通过 StarRocks Connector 写入。
选 StarRocks 而不是 ClickHouse,主要原因是实时数仓里大量操作是“更新已有行”而不是纯追加:订单状态流转、用户信息变更、退款单状态变化,这些都是 UPDATE。StarRocks 的 Primary Key 模型对高频更新支持得比较好,配合 Flink 的 changelog 流可以做 upsert 写入,ClickHouse 在更新场景相对要别扭一些。
3. 环境搭建与版本选型:组件之间的兼容性才是硬功夫
3.1 组件版本和部署模式
版本选型是这次改造里最不能拍脑袋的部分。Flink 的 SQL 语法、CDC 连接器、StarRocks 连接器,每个组件都有自己的版本兼容矩阵,配错了光编译就能卡两天。我们最终定的版本组合:
| 组件 | 版本 | 选择理由 |
|---|---|---|
| Kafka | 3.4.0 | KRaft 模式已经比较稳,但我们还是用 ZooKeeper 模式,保守 |
| Flink | 1.17.2 | SQL 语法、Checkpoint 机制稳定,StarRocks 和 CDC 兼容性好 |
| StarRocks | 3.1 | Primary Key 模型 + Flink Connector 完善 |
| Flink CDC | 2.4 | 支持 MySQL 全量+增量,做维表同步够用 |
| JDK | 1.8 (OpenJDK) | Flink 1.17 官方推荐的部署基线 |
部署模式我们用的 Flink Standalone + YARN 分离:Flink 跑在独立的 YARN 队列上,避免和在线业务互相抢资源。这里提醒一句,如果你在云上,容器化部署 Flink 会更灵活,但一定要把 JobManager 和 TaskManager 的资源做独立配额,不然实时任务很容易被其他任务的 GC 抖动影响。
3.2 Kafka 保留时间、分区数与副本:直接决定重放能力
Kafka 这里的几个参数直接决定了 Kappa 架构能不能玩得转。
分区数:我们订单 Topic 建了 24 个分区。分区数是实时链路并行度的上限,如果 Flink 并行度大于分区数,多出来的并行度其实是空转;如果小于分区数,就会出现部分分区消费不及时。24 个分区配合 Flink 并行度 12 是个还不错的配比,实际压测下来消费吞吐能到 25 万条/秒以上。
副本数:3 副本,min.insync.replicas 设为 2。这意味着允许一台 broker 挂掉而不丢数据。实时链路对数据完整性的要求不比离线低,Kafka 出问题会直接反映在最终结果上。
保留时间:log.retention.hours=72。保留时间越长,重放窗口越大,但占磁盘。如果业务需要回放 7 天数据,那 72 小时就不够,必须根据“最慢一次回放需要多少小时”来倒推。我们压测过,从 Kafka 重放一天的数据,Flink 计算大概 1.5 小时能跑完,所以 72 小时的重放窗口实际是够用的。
3.3 Flink 的 Checkpoint 与状态后端配置
Kappa 架构里的状态就是实时的“批”,Checkpoint 是这个状态的安全网。我们的 Flink 配置如下:
yaml复制execution.checkpointing.interval: 60s
execution.checkpointing.mode: EXACTLY_ONCE
execution.checkpointing.timeout: 120s
execution.checkpointing.min-pause: 30s
execution.checkpointing.max-concurrent: 1
state.backend.type: rocksdb
state.backend.incremental: true
table.exec.state.ttl: 1h
RocksDB 是必须的,大状态任务用 Heap 状态后端直接 OOM。状态 TTL 我后面会专门讲为什么是 1 小时。Checkpoint 间隔 60 秒这个值不是随便定的,它要和 Kafka 的事务超时配合,这个坑后面详细说。
4. 完整代码实现:从 Kafka 接入到可查数的完整 SQL
这一节我们把一条完整的实时链路代码串起来。为了让例子好理解,场景是电商订单统计:订单明细从 Kafka 进来,关联用户维度,生成 DWD 明细,再按店铺维度做分钟聚合,最后落到 StarRocks 供大屏查询。
4.1 Source 表建模:Kafka 原始订单流
首先是 ODS 层的 Kafka 源表:
sql复制CREATE TABLE kafka_order (
order_id BIGINT,
user_id BIGINT,
sku_id BIGINT,
sku_name STRING,
shop_id BIGINT,
order_amount DECIMAL(10, 2),
order_status INT,
raw_ts STRING,
proc_time AS PROCTIME(),
event_time AS TO_TIMESTAMP_LTZ(CAST(raw_ts AS BIGINT), 3),
WATERMARK FOR event_time AS event_time - INTERVAL '5' SECOND
) WITH (
'connector' = 'kafka',
'topic' = 'ods_order',
'properties.bootstrap.servers' = 'kafka-1:9092,kafka-2:9092,kafka-3:9092',
'properties.group.id' = 'dwd_order_group',
'scan.startup.mode' = 'earliest-offset',
'scan.watermark.idle-timeout' = '60s',
'format' = 'json',
'json.ignore-parse-errors' = 'true'
);
有两个细节说明一下。raw_ts 存的是业务侧埋点的时间戳毫秒值,用 TO_TIMESTAMP_LTZ 转成事件时间,而不是直接用 Kafka 自带的时间戳。原因是最开始我们用过 METADATA FROM 'timestamp',但发现只要上游 producer 做了批量重试,Kafka 自带时间戳和业务真实时间会差不少,窗口聚合的结果就乱了。
scan.watermark.idle-timeout 是个很重要的参数,后面坑 4 会细说,先记住:没有它,某个分区没数据时会拖死整条窗口计算。
4.2 维表建模:MySQL CDC 维表
用户维度通过 Flink CDC 实时同步:
sql复制CREATE TABLE dim_user (
user_id BIGINT PRIMARY KEY NOT ENFORCED,
user_name STRING,
user_level STRING,
register_ts TIMESTAMP(3)
) WITH (
'connector' = 'mysql-cdc',
'hostname' = 'mysql-master',
'port' = '3306',
'username' = 'cdc_user',
'password' = 'cdc_pass',
'database-name' = 'shop',
'table-name' = 'dim_user'
);
如果用户表数据量大、变更频繁,建议不要直接让 Flink SQL join MySQL,而是用 Flink CDC 把维度表同步到一个 Kafka Topic,再把这个 Topic 注册成 Flink 的维表或者直接作为流表去 join。我们当时用户表 1000 万行,直接 MySQL CDC 关联会有两个问题:一是 join 时每行数据都要走一次 lookup,QPS 高的时候会打爆 MySQL;二是 MySQL CDC 的维表 join 语法和普通流表 join 有差别,排查问题更费劲。
4.3 DWD 加工逻辑:关联、过滤、落中间 Topic
DWD 层我们建了一个 upsert-kafka 的 Sink 表,主键是 order_id:
sql复制CREATE TABLE dwd_order_detail (
order_id BIGINT PRIMARY KEY NOT ENFORCED,
user_id BIGINT,
user_name STRING,
user_level STRING,
sku_id BIGINT,
sku_name STRING,
shop_id BIGINT,
order_amount DECIMAL(10, 2),
order_status INT,
order_date STRING,
event_time TIMESTAMP_LTZ(3)
) WITH (
'connector' = 'upsert-kafka',
'topic' = 'dwd_order_detail',
'properties.bootstrap.servers' = 'kafka-1:9092,kafka-2:9092,kafka-3:9092',
'key.format' = 'json',
'value.format' = 'json'
);
然后从 kafka_order 读取数据,LEFT JOIN 用户维表,过滤掉内部测试订单(order_status = 99),写入 DWD:
sql复制INSERT INTO dwd_order_detail
SELECT
o.order_id,
o.user_id,
IF(u.user_id IS NOT NULL, u.user_name, 'unknown') AS user_name,
IF(u.user_id IS NOT NULL, u.user_level, 'unknown') AS user_level,
o.sku_id,
o.sku_name,
o.shop_id,
o.order_amount,
o.order_status,
DATE_FORMAT(o.event_time, 'yyyy-MM-dd') AS order_date,
o.event_time
FROM kafka_order o
LEFT JOIN dim_user FOR SYSTEM_TIME AS OF o.proc_time u
ON o.user_id = u.user_id
WHERE o.order_status <> 99;
LEFT JOIN 而不是 JOIN 的原因很简单:维表数据如果比订单晚到,JOIN 会丢掉这张订单,而 LEFT JOIN 至少保证订单不丢,只是用户维字段暂时是 unknown,后面可以通过补数任务修正。
4.4 DWS 和 ADS 的聚合代码
DWS 层按店铺和 1 分钟窗口做聚合:
sql复制CREATE TABLE dws_shop_order_stats (
stat_time TIMESTAMP(3),
shop_id BIGINT,
order_count BIGINT,
order_amount_sum DECIMAL(14, 2),
PRIMARY KEY (stat_time, shop_id) NOT ENFORCED
) WITH (
'connector' = 'upsert-kafka',
'topic' = 'dws_shop_order_stats',
'properties.bootstrap.servers' = 'kafka-1:9092,kafka-2:9092,kafka-3:9092',
'key.format' = 'json',
'value.format' = 'json'
);
INSERT INTO dws_shop_order_stats
SELECT
TUMBLE_START(event_time, INTERVAL '1' MINUTE) AS stat_time,
shop_id,
COUNT(DISTINCT order_id) AS order_count,
COALESCE(SUM(order_amount), 0) AS order_amount_sum
FROM dwd_order_detail
WHERE order_status = 1
GROUP BY TUMBLE(event_time, INTERVAL '1' MINUTE), shop_id;
注意这里 dwd_order_detail 是 upsert-kafka 源表,下游聚合时 Flink 需要维护 COUNT(DISTINCT order_id) 的去重状态,数据量大的时候对状态压力不小。如果你的业务场景允许近似去重,可以用 APPROX_COUNT_DISTINCT 来换性能,但实时看板通常要求精确,所以我们是硬扛这部分状态的。
ADS 层从 DWS 的 Kafka Topic 中读取分钟汇总,再按天汇总后写入 StarRocks:
sql复制CREATE TABLE ads_shop_daily_stats (
stat_date STRING,
shop_id BIGINT,
order_count BIGINT,
order_amount_sum DECIMAL(14, 2)
) WITH (
'connector' = 'starrocks',
'jdbc-url' = 'jdbc:mysql://starrocks-fe:9030',
'load-url' = 'starrocks-fe:8030',
'database-name' = 'ads',
'table-name' = 'shop_daily_stats',
'username' = 'admin',
'password' = 'admin',
'sink.label-prefix' = 'flink-sink-daily',
'sink.properties.format' = 'json',
'sink.properties.strip_outer_array' = 'true',
'sink.buffer-flush.max-rows' = '100000',
'sink.buffer-flush.interval-ms' = '5000',
'sink.max-retries' = '3'
);
INSERT INTO ads_shop_daily_stats
SELECT
DATE_FORMAT(stat_time, 'yyyy-MM-dd') AS stat_date,
shop_id,
SUM(order_count) AS order_count,
SUM(order_amount_sum) AS order_amount_sum
FROM dws_shop_order_stats_topic
GROUP BY DATE_FORMAT(stat_time, 'yyyy-MM-dd'), shop_id;
4.5 一点附加:DataStream API 里同样要配好的三件事
如果你用的是 DataStream API 而不是纯 Flink SQL,那么下面三件事必须配好,否则后面稳定性和事务问题就够你受的:
java复制StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(60_000, CheckpointingMode.EXACTLY_ONCE);
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(30_000);
env.getCheckpointConfig().setCheckpointTimeout(120_000);
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
env.setStateBackend(new EmbeddedRocksDBStateBackend());
第一,Checkpoint 间隔选 60 秒而不是默认的无 checkpoint。第二,setMaxConcurrentCheckpoints(1) 可以防止多个 checkpoint 并发,避免 Kafka producer 同时创建多个事务导致混乱。第三,RocksDB 增量 checkpoint 必须开,否则状态目录会越滚越大,恢复时间也越来越长。
5. 压测与调优:延迟、倾斜、Exactly-Once 怎么用数据说话
代码跑通只算第一步,上线前必须压测。我们压测的数据量是模拟线上一天的流量:订单明细 2000 万条,峰值 TPS 3000,Flink 并行度 12,Kafka 分区 24。压测结果如下:
| 指标 | 实测值 |
|---|---|
| Kafka -> DWD 端到端延迟(中位数) | 1.2s |
| DWD -> DWS 窗口计算延迟(中位数) | 4.6s |
| DWS -> StarRocks 写入延迟(p95) | 8.9s |
| Checkpoint 平均耗时 | 18s |
| Checkpoint 失败率(压测 6 小时) | 0% |
这个结果已经很能说明问题。但压测过程中我们还是暴露了几个需要调优的点。
5.1 数据倾斜:大商家的 Key 热怎么破
压测跑了一个小时后,发现某个大店铺(shop_id = 888888)的聚合结果一直比其他店铺慢,Flink UI 的 BackPressure 指标已经到了 0.98,说明某个 subtask 严重过载。这是典型的“热点 Key”问题:一个店铺的订单量占了总量的 30%,所有数据全砸在同一个 Key 上,一个 subtask 扛不动。
解决办法是两阶段聚合。第一阶段给热点 Key 加随机后缀拆分,第二阶段再把拆出来的结果合并。SQL 大概长这样:
sql复制INSERT INTO dws_shop_order_stats_pre_agg
SELECT
TUMBLE_START(event_time, INTERVAL '1' MINUTE) AS stat_time,
CONCAT(CAST(shop_id AS STRING), '_', CAST(MOD(UNIX_TIMESTAMP(event_time), 10) AS STRING)) AS shop_key,
COUNT(DISTINCT order_id) AS order_count,
COALESCE(SUM(order_amount), 0) AS order_amount_sum
FROM dwd_order_detail
WHERE order_status = 1
GROUP BY TUMBLE(event_time, INTERVAL '1' MINUTE),
CONCAT(CAST(shop_id AS STRING), '_', CAST(MOD(UNIX_TIMESTAMP(event_time), 10) AS STRING));
第二阶段的 SQL 再按真实 shop_id 聚合,把 shop_key 还原成原始店铺 ID。这样热点被拆到了 10 个临时 key 上,不会单个 subtask 满载。
5.2 背压与空闲分区导致的水位停滞
压测中我们还遇到过水位停滞的问题:DWS 窗口聚合迟迟不出结果,Flink UI 上 Watermark 一直停在很早的时间点。排查下来是两个原因叠加:一是某个 Kafka 分区数据量很少,导致该分区的 watermark 一直不推进,而全局 watermark 是所有分区的最小值,直接拖慢所有窗口;二是 StarRocks 写入短暂阻塞,导致 Sink 处积压,上游算子背压,整个链路变慢。
空闲分区的问题在创建源表时加 'scan.watermark.idle-timeout' = '60s' 解决,让长时间没有数据的分区自动“放行”,不再拖后腿。背压问题则把 StarRocks 的 buffer-flush.max-rows 调到 100000、interval-ms 调到 5000,让写入更批量,减少小文件/小批次请求带来的开销。
5.3 一键对账脚本:实时和离线口径如何对齐
架构从 Lambda 切到 Kappa 后,最敏感的就是数字是否还能对得上。我们做了一个很朴素但很有效的对账脚本,每天凌晨用 SQL 分别从 StarRocks(实时结果)和 Hive(离线结果)取数做对比,差异超过阈值就告警:
bash复制#!/bin/bash
DAY=$1
starrocks_count=$(mysql -h starrocks-fe -P 9030 -u admin -N \
-e "SELECT SUM(order_count) FROM ads.shop_daily_stats WHERE stat_date = '${DAY}'")
hive_count=$(hive -e "SELECT COUNT(*) FROM dws.dws_order WHERE dt = '${DAY}'")
echo "starrocks: ${starrocks_count}, hive: ${hive_count}"
if [ "${starrocks_count}" != "${hive_count}" ]; then
echo "[WARN] ${DAY} 实时/离线订单量不一致"
exit 1
fi
脚本虽然简单,但它逼着我们在上线前就把“哪个指标实时算、哪个指标离线算”的边界划清楚,也帮我们后续定位问题省了不少时间。
6. 踩坑实录:六个让链路“体检不合格”的细节
6.1 Kafka 事务超时和 Checkpoint 互相打架
现象:任务跑了一个多小时,突然 JobManager 抛出 ProducerFencedException,任务自动重启,重启完过一会儿又报错,形成循环。
排查链路:先去看 checkpoint 日志,发现平均耗时 1 分半,而我们 Kafka 的 transaction.timeout.ms 是默认的 60 秒。Flink 写入 Kafka 时用的是事务型 producer,一个事务的存续时间跟着 checkpoint 走,checkpoint 没完成之前事务不会提交。所以当一次 checkout 超过了 60 秒,Kafka 就会把事务当成超时自杀,producer 再次提交时就会被 fencing。
修复也很直接:把 Kafka 服务端的 transaction.timeout.ms 调大到 120000,同时让 Flink checkpoint timeout 保持在 120 秒内,给事务留足余量。这里给个经验值:transaction.timeout.ms 至少要大于等于 execution.checkpointing.interval + checkpoint 平均耗时,不然迟早出问题。
6.2 时间字段解析与时区错位
现象:早上看昨天 GMV 日报,发现 order_date 整体偏移,晚上 8 点以后的数据算到了第二天。
排查链路:原始 raw_ts 是 Unix 毫秒时间戳,本身没有时区。Flink 默认 table.exec.local-time-zone 是本地时区,在服务器是 UTC 的情况下,DATE_FORMAT 会按 UTC 格式化,和业务所在的东八区差了 8 小时。
修复:在 Flink 全局配置里明确设置时区:
yaml复制table.exec.local-time-zone: Asia/Shanghai
另外在时间字段的转换上,尽量保存 TIMESTAMP_LTZ 类型,不要把时间字符串在源头就转成 VARCHAR,否则后面所有下游都要跟着处理字符串,很容易再出时区错位。
6.3 维表迟变导致的结果漂移
现象:一个用户的会员等级在当天下午被改了,但当天上午订单的 DWS 汇总结果没有跟着变。业务方投诉“等级改了半天,实时报表金额还没变”。
排查链路:这条链路里 dim_user 是 MySQL CDC 同步的,DWD 层做的是 FOR SYSTEM_TIME AS OF o.proc_time 的 lookup join。用户等级改变后,对已经写进 DWD 的订单不会产生任何更新事件,所以下游 DWS 自然也不会重算。这不是 bug,是时点 join 本身的性质:历史订单用历史时点的维度快照,维度变了不会自动回改历史结果。
修复方案要看业务对强一致的要求。我们当时做了一个折中:对于“用户等级”这种变化相对低频、但对收入统计影响大的维度,单独把维表变更流也注入到 DWS 计算链路,让维度变更触发相关店铺的重算;对于其它普通维度,就接受 T+1 用离线任务修正。不可能所有维度都做实时重算,成本太高。
6.4 事件时间窗口迟迟不触发
现象:DWS 的窗口聚合结果半天不出数,Flink UI 上看 Watermark 远远落后于当前时间。
排查链路:到 Flink 的 Watermark 监控页发现,某个分区的水位线停在了启动时刻。原因是这个分区后续一直没有新消息,但全局水位线取的是所有分区的最小值,一个分区不推进,所有窗口都不触发。这正是我在 4.1 里已经提过 scan.watermark.idle-timeout 的原因,这里再强调一次:这个参数必须开,尤其是分区数多、部分分区流量稀疏的场景。
还有一个容易忽略的点:如果上游 Kafka producer 开了幂等后重试,在某些极端情况下会导致消息顺序乱掉,进而影响事件时间的有序性,加重水位线滞后。这种情况下要保证 producer 端 max.in.flight.requests.per.connection 不超过 5,并且对单个分区只用一个 producer 线程。
6.5 Retract/删除语义没想清楚,数字越滚越大
现象:上线第三天,运营反馈店铺今日订单数比实际多了好几千。查下来是订单取消、退款、退货这些状态变化没有正确触发“减数”。
排查链路:DWD 表用的是 upsert-kafka,主键是 order_id,这个没问题。问题出在 DWS 的聚合逻辑上:如果订单取消时只是更新 order_status,窗口聚合要正确处理这条数据的 retract,需要 Flink SQL 能看到完整的 changelog 流。但当我们用 append-only 模式写 StarRocks 明细表时,StarRocks 那边只会插入新行,旧行还在,导致同一订单出现多行,SUM 自然虚高。
修复:StarRocks 侧的表必须建 Primary Key 模型,以 order_id 为主键;Flink 写入时使用 StarRocks Connector 的 upsert 语义,保证同一 order_id 只保留一行最新状态。同时,DWS 聚合 SQL 里的 WHERE order_status = 1 这种过滤条件,对 retract 流要特别小心,Flink SQL 本身能处理,但前提是你没有在 Sink 端把 changelog 弄丢。
6.6 状态无限膨胀
现象:任务跑了一周,RocksDB 的状态目录从 10GB 涨到 80GB,checkpoint 耗时越来越长,GC 频繁报警。
排查链路:查 table.exec.state.ttl,一开始我们没配,默认是永不过期。DWS 层有 COUNT(DISTINCT order_id),这个算子为了让过期数据不再参与最终结果,必须保留所有的 order_id 去重状态。订单量一天 2000 万,一周不清理,状态自然爆炸。
修复:把聚合状态 TTL 配成和窗口延后时间匹配的一个值,我们当时设的 'table.exec.state.ttl' = '1 h'。这里有个权衡:TTL 太短会把还没关闭的窗口状态清掉,导致结果不准;太长又会白白消耗内存和磁盘。一个参考原则:TTL 要大于“窗口最大时长 + allowedLateness + 上游数据最大乱序时间”,在这个范围之外的历史状态都是可以安全清理的。
Kappa 架构做到最后,你会发现难点其实不在“流式计算”本身,而在于怎么把状态管理、事务语义、时区和重放策略这些细节都处理好。如果让我重新做一次这个项目,我会先把核心指标收敛到 3 到 5 个必须实时的指标,不要一上来就把所有报表都实时化,否则 DWS 层的聚合逻辑和状态维护会非常复杂,状态爆炸几乎是必然的。上线之前,一定先从 ODS 的 Kafka 里导一份最近 3 天的数据,用离线批处理算一遍基准值,再和实时链路的输出对比,并把这份结果作为验收基线存档。后面一旦对不上数,能快速判断是代码问题还是数据问题,而不是靠猜。
