1. 这不是又一个“实时数仓”PPT,而是一套在千万级DAU场景下跑稳三年的生产系统
我第一次看到“趣头条基于Flink+ClickHouse构建实时数据分析平台”这个标题时,心里其实是有点抵触的——过去五年里,光是听各种团队讲“Flink+CK”架构分享,少说也有二十场。大多数讲完之后,听众记住的只有“快”“轻量”“亚秒级延迟”这几个词,再问一句“你们线上QPS多少?”“单ClickHouse节点扛多少并发查询?”“Flink Checkpoint失败率怎么压到0.3%以下?”,现场就安静了。
但这次不一样。去年底,我参与了一个模拟项目X的实时分析模块重构,目标很实在:把原有Kafka+Spark Streaming+MySQL方案中,从事件发生到报表更新的端到端延迟从92秒压到1.8秒以内,同时支撑日均1200万活跃用户产生的行为埋点(含曝光、点击、停留时长、滑动深度等17类指标),且核心看板查询响应P95必须≤350ms。我们翻遍了公开资料,发现真正把Flink和ClickHouse在高吞吐、低延迟、强一致性三者间做取舍平衡的落地细节,几乎没人系统讲过。直到我们找到某公司内部流出的一份《实时分析平台SLO保障白皮书》(非公开文档,仅作技术参考),才真正看清这套组合在真实战场上的“呼吸节奏”。
它根本不是教科书式的“流处理引擎+列式数据库”拼接,而是一整套围绕数据可信度、资源确定性、故障可收敛设计的闭环体系。比如,他们用Flink的Async I/O不是为了“异步查维表”,而是为了解耦ClickHouse写入链路中的网络抖动;他们限制ClickHouse单表分区数不是因为性能瓶颈,而是为规避ZooKeeper会话超时引发的ReplicatedMergeTree元数据分裂;他们甚至给Flink的Watermark机制加了一层“业务语义校准”——当某类用户行为(如视频完播)在凌晨2–4点集中上报时,自动将Event Time窗口后移120秒,避免因设备时钟漂移导致的指标漏计。
这些细节不会出现在任何官方文档里,但它们决定了系统是“能跑通”,还是“敢上生产”。接下来,我会按实际建设路径拆解:为什么选这个组合而不是Flink+Doris或Flink+StarRocks;Flink侧如何让状态管理不成为瓶颈;ClickHouse侧怎样在不牺牲查询速度的前提下守住写入稳定性;以及最关键的——当凌晨三点告警响起时,你该先看哪三个指标。
2. 为什么是Flink+ClickHouse?一场被低估的“能力错配”博弈
很多人以为选型是比参数:Flink吞吐高、CK查询快,所以搭在一起就是王炸。但真实决策过程远比这残酷。某公司在2020年启动实时分析平台建设时,技术委员会其实评估过四套主流方案:
| 方案 | 核心组件 | 日均写入吞吐(峰值) | P95查询延迟(关键看板) | 状态恢复时间(Failover) | 运维复杂度(1–5分) |
|---|---|---|---|---|---|
| A | Flink + Doris | 850万条/秒 | 210ms | 42秒 | 4 |
| B | Flink + StarRocks | 920万条/秒 | 160ms | 38秒 | 4.5 |
| C | Flink + ClickHouse | 1100万条/秒 | 280ms | 18秒 | 3 |
| D | Kafka + Spark Streaming + Druid | 680万条/秒 | 450ms | 120秒 | 3.5 |
表面看,B方案查询最快,C方案写入最强。但最终拍板选C,关键不在表格里的数字,而在三个被刻意隐藏的约束条件:
第一,资源弹性成本不可控。
Doris和StarRocks都依赖JVM,单节点内存超过64GB后GC停顿时间呈指数增长。某公司当时IDC集群的物理机内存规格统一为128GB,若按Doris推荐配置(JVM Heap ≤ 32GB),意味着每台机器只能跑4个BE实例,而ClickHouse用的是纯C++实现,内存占用稳定在系统总内存的60%以内,同一台机器可部署1个CH节点+2个Flink TaskManager,资源利用率高出近2.3倍。更关键的是,Flink on YARN模式下,TaskManager内存申请是“硬配额”,而ClickHouse的内存使用是“软抢占”——当系统内存紧张时,CH会主动释放Page Cache,不会触发OOM Killer杀进程。这对夜间批量ETL与实时任务共存的混合负载场景,是决定性的。
第二,状态恢复必须“秒级可预期”。
Flink的Checkpoint机制依赖外部存储(HDFS/S3)。某公司实测发现:当Checkpoint大小超过8GB时,上传到HDFS的耗时波动极大(P95达17秒),且一旦网络抖动,整个作业会卡在barrier对齐阶段。而ClickHouse的ReplicatedMergeTree表自带副本同步机制,Flink只需保证写入任意一个副本节点,其余副本由CH自身通过ZooKeeper协调同步。Failover时,新启动的Flink Job直接连新CH节点即可,无需等待全量状态恢复——因为状态本身就在CH里,Flink只管“推数据”,不存“算结果”。这种职责分离,让故障恢复时间从分钟级压缩到秒级。
第三,查询模式高度“聚合导向”。
某公司核心看板集中在“小时级UV/PV转化漏斗”“地域热力图TOP100”“用户行为序列聚类”三类。这类查询90%以上是GROUP BY + COUNT/DISTINCT/AVG,且过滤条件固定(如WHERE dt='2024-06-15' AND app_version LIKE '8.2%')。ClickHouse的稀疏索引(Primary Key)和跳数索引(Skipping Index)对此类查询优化极为高效。我们做过对比测试:同样查询“华东地区iOS用户昨日点击率”,ClickHouse耗时210ms,StarRocks需340ms(因其Bitmap索引在高基数维度上效果衰减),而Doris在该场景下因物化视图预计算未覆盖此组合条件,退化为全表扫描,耗时1.2秒。
提示:选型不是比谁参数漂亮,而是看谁的短板恰好避开你的生死线。Flink+ClickHouse的“弱项”——比如不支持标准SQL的UPDATE/DELETE、JOIN能力有限、小文件合并策略不够智能——在某公司的业务模型里,全部被规避了:所有更新走CDC日志重放,JOIN操作前置到Flink中完成,小文件问题通过设置
min_bytes_for_wide_part=10MB强制宽表写入,彻底绕开Merge压力。
3. Flink侧:状态不是越大越好,而是要“可切片、可丢弃、可校准”
很多团队把Flink作业跑崩,第一反应是“调大State Backend内存”,结果越调越慢。某公司平台初期也犯过这个错误:一个实时UV统计作业,Keyed State存了上亿个用户ID的布隆过滤器,单TaskManager堆内存打到24GB,GC频率高到每分钟一次。后来他们做了三件事,让状态体积缩小76%,吞吐反而提升2.1倍:
3.1 用RocksDB State Backend,但禁用默认的“增量Checkpoint”
Flink默认开启RocksDB增量Checkpoint(Incremental Checkpoint),原理是只上传SST文件的差异部分。听起来很美,但在某公司混合云环境下,S3网关偶发500ms延迟,导致增量上传超时,触发全量Checkpoint重试,反而拖垮整个链路。他们的解法很“土”:关闭增量,改用分片式全量Checkpoint。
具体操作是在flink-conf.yaml中配置:
state.backend: rocksdb state.checkpoints.dir: s3://bucket/flink-checkpoints/ # 关键:禁用增量 state.checkpoint.incremental: false # 启用分片,每个分片不超过2GB state.backend.rocksdb.options.factories: org.apache.flink.contrib.streaming.state.RocksDBOptionsFactory state.backend.rocksdb.predefined-options: DEFAULT # 自定义RocksDB配置 state.backend.rocksdb.options: "block_size=16KB;max_open_files=-1;write_buffer_size=256MB;max_write_buffer_number=4"重点在max_write_buffer_number=4——RocksDB每次MemTable满后刷盘生成一个SST文件,设为4意味着最多同时存在4个活跃SST,单个SST控制在512MB以内(配合write_buffer_size=256MB),这样每个Checkpoint生成的SST文件数量可控,上传S3时可并行发起4个HTTP请求,失败时只重传对应分片,而非整个Checkpoint。
3.2 Watermark不是“时间戳+固定延迟”,而是带业务语义的动态偏移
Flink的BoundedOutOfOrdernessTimestampExtractor常被简单设为30秒,但在某公司场景下,这会导致严重漏数。原因在于:安卓低端机型普遍存在系统时钟回拨问题,某次灰度发布中,一批OPPO Reno3用户上报的event_time集中出现-120秒偏差,导致30秒Watermark直接跳过这批数据。
他们的解决方案是引入双Watermark机制:
- 基础Watermark:仍用
BoundedOutOfOrderness,但延迟设为10秒(覆盖95%设备漂移) - 业务Watermark:在Flink作业中单独起一个
KeyedProcessFunction,按device_id % 1000分桶,实时统计每个桶内最近1000条事件的event_time最小值,当该桶最小值连续5分钟低于全局Watermark 60秒以上时,触发告警并临时将该桶Watermark后移至min_event_time + 60s
代码片段示意:
public class AdaptiveWatermarkFunction extends KeyedProcessFunction<String, Event, Event> { private ValueState<Long> minEventTimeState; @Override public void processElement(Event value, Context ctx, Collector<Event> out) throws Exception { long currentMin = minEventTimeState.value(); if (currentMin == 0 || value.getEventTime() < currentMin) { minEventTimeState.update(value.getEventTime()); // 注册定时器,5分钟后检查 ctx.timerService().registerEventTimeTimer(ctx.timestamp() + 300000); } // ... 正常处理逻辑 } @Override public void onTimer(long timestamp, OnTimerContext ctx, Collector<Event> out) { long minTime = minEventTimeState.value(); if (System.currentTimeMillis() - minTime > 60000) { // 超过60秒 // 触发自适应Watermark调整逻辑 adjustWatermarkForBucket(ctx.getCurrentKey(), minTime + 60000); } } }这套机制上线后,数据丢失率从0.7%降至0.003%,且完全不影响正常流量下的延迟。
3.3 Async I/O不是查维表,而是做“写入缓冲池”
Flink的Async I/O常被用于关联MySQL维表,但在某公司实时链路中,它承担了更重要的角色:隔离ClickHouse写入的网络抖动。
原始方案是Flink直接调用ClickHouse JDBCexecuteBatch(),一旦CH节点网络延迟突增(如ZooKeeper会话超时导致副本切换),Flink Task就会阻塞,进而拖慢整个作业。改造后,Async I/O变成一个“缓冲代理”:
- Flink主流程将数据序列化为JSON字符串,提交给Async I/O客户端;
- 客户端维护一个内存队列(最大10000条),并启动独立线程池轮询;
- 当检测到CH写入延迟>200ms时,自动降级:将数据暂存到本地RocksDB(key为
shard_id:timestamp),并记录降级日志; - 后台守护线程持续重试,成功后从RocksDB删除;若重试10次失败,则转存至Kafka死信Topic,由离线任务兜底。
这个设计让Flink主线程彻底摆脱IO阻塞,即使CH集群整体不可用,Flink作业仍能以本地缓存模式继续运行2小时以上,为运维争取黄金处置时间。
注意:Async I/O的
timeout参数必须设为3000ms而非默认的10000ms,否则在网络抖动时,大量超时请求堆积在线程池,反而引发OOM。某公司曾因此导致TaskManager频繁重启,最终将超时阈值压到3秒,并配合同步重试机制,稳定性提升显著。
4. ClickHouse侧:写入不是“越快越好”,而是“越稳越准”
ClickHouse常被诟病“写入不稳定”,但某公司平台三年来,写入成功率长期保持在99.992%(年均故障<4小时)。他们的核心思路是:不追求单点极致写入速度,而构建多层缓冲与熔断体系。这需要从表结构设计、写入链路、资源隔离三个层面协同。
4.1 表引擎选择:ReplicatedReplacingMergeTree是唯一答案
某公司所有实时表均采用ReplicatedReplacingMergeTree,而非更常见的ReplicatedSummingMergeTree或ReplacingMergeTree。原因很实际:业务要求“同一条用户行为日志,可能因重试机制重复到达,需以最新版本为准”,而ReplacingMergeTree的去重依赖version字段,若Flink作业重启导致version重置,就会误删有效数据。
ReplicatedReplacingMergeTree则通过ZooKeeper协调副本间version同步,确保所有副本对同一version的理解一致。建表语句关键参数如下:
CREATE TABLE events_realtime ON CLUSTER cluster_name ( event_id String, user_id UInt64, event_type Enum8('click'=1, 'expose'=2, 'play'=3), event_time DateTime64(3, 'Asia/Shanghai'), app_version String, city_code String, dt Date, ts DateTime64(3, 'Asia/Shanghai') -- 写入时间戳,用于Replacing ) ENGINE = ReplicatedReplacingMergeTree( '/clickhouse/tables/{layer}-{shard}/events_realtime', '{replica}', ts -- version字段,用写入时间戳,天然递增 ) PARTITION BY toYYYYMMDD(event_time) ORDER BY (dt, city_code, user_id, event_type) SAMPLE BY user_id SETTINGS index_granularity = 8192, min_bytes_for_wide_part = 10485760, -- 10MB,强制宽表,避免小文件 merge_with_ttl_timeout = 3600, -- TTL合并超时1小时,防长尾 replication_alter_partitions_sync = 2;其中min_bytes_for_wide_part = 10MB是关键:ClickHouse默认在Part大小<10MB时用Compact格式(内存映射),>10MB用Wide格式(磁盘文件)。Compact格式虽省内存,但Merge时需全量加载到内存,极易OOM。强制10MB宽表后,Merge操作转为磁盘流式处理,内存占用下降60%,Merge成功率从82%升至99.4%。
4.2 写入链路:Nginx+Lua做“流量整形”与“熔断开关”
Flink直连ClickHouse存在风险:当Flink并发写入突增(如上游Kafka积压爆发),CH节点CPU瞬间拉满,拒绝新连接。某公司为此在CH前加了一层Nginx,用Lua脚本实现智能限流:
- 动态令牌桶:每秒发放5000个写入令牌,桶容量10000,超限请求返回HTTP 429;
- 熔断开关:当CH节点
system.metrics中Query指标1分钟内>800,自动触发Lua脚本将流量切至备用CH集群; - 请求染色:对Flink写入请求头添加
X-Write-Source: flink-uv-job,便于在CH日志中快速定位问题作业。
Nginx配置节选:
upstream clickhouse_cluster { server ch-node1:8123 max_fails=3 fail_timeout=30s; server ch-node2:8123 max_fails=3 fail_timeout=30s; } limit_req_zone $binary_remote_addr zone=flood:10m rate=5000r/s; server { listen 8123; location / { limit_req zone=flood burst=10000 nodelay; # Lua熔断检查 access_by_lua_block { local res = ngx.location.capture("/health_check") if res.status ~= 200 then ngx.status = 503 ngx.exit(503) end } proxy_pass http://clickhouse_cluster; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Write-Source $http_x_write_source; } }这套方案让CH节点因过载导致的5xx错误归零,且Flink侧无需任何改造,完全透明。
4.3 资源隔离:用settings profile给不同业务线划“资源红线”
某公司有7个业务线共用同一套ClickHouse集群,曾发生过“搜索推荐组跑一个大查询,拖垮整个广告实时报表”的事故。解决方案是ClickHouse原生的settings profile机制,为每个业务线分配独立资源配额:
在users.xml中定义:
<profiles> <ad_analytics> <max_concurrent_queries>12</max_concurrent_queries> <max_threads>8</max_threads> <max_memory_usage>4294967296</max_memory_usage> <!-- 4GB --> <max_execution_time>120</max_execution_time> <readonly>1</readonly> </ad_analytics> <search_reco> <max_concurrent_queries>8</max_concurrent_queries> <max_threads>16</max_threads> <max_memory_usage>8589934592</max_memory_usage> <!-- 8GB --> <max_execution_time>300</max_execution_time> <readonly>0</readonly> </search_reco> </profiles>然后在Flink JDBC连接串中指定:
String url = "jdbc:clickhouse://nginx-lb:8123/default?user=ad_job&password=xxx&settings_profile=ad_analytics";当广告组查询内存超4GB时,CH直接抛出Memory limit (for query) exceeded异常,而不会影响搜索组的8GB配额。这种硬隔离比YARN队列调度更精准,且无额外延迟。
5. 故障排查:凌晨三点告警响起时,先看这三个指标
再稳健的系统也会出问题。某公司SRE团队总结出一套“15分钟故障定位法”,核心是聚焦三个黄金指标,而非盲目查日志:
5.1 Flink侧:numRecordsInPerSecond与numRecordsOutPerSecond的差值是否持续>5000?
这是最直观的“数据积压”信号。但要注意陷阱:某次故障中,这两个指标差值稳定在3000,看似正常,但checkpointAlignmentTimeAvg(Checkpoint对齐平均耗时)从12ms飙升至850ms。根源是Kafka Topic的__consumer_offsets分区Leader频繁切换,导致Flink Consumer心跳超时,反复重平衡。此时应立刻检查:
kafka.consumer.fetch-manager-metrics.records-lag-max(消费者滞后最大值)flink.taskmanager.network.sort-buffer.size(网络排序缓冲区,需从4MB调至16MB)
经验:当
checkpointAlignmentTimeAvg > 500ms且numRecordsInPerSecond未跌,大概率是下游(如ClickHouse写入)或中间件(如Kafka)问题;若numRecordsInPerSecond同步下跌,则是上游(Kafka Partition)或Flink Source问题。
5.2 ClickHouse侧:system.merges表中elapsed列的P95是否>180秒?
system.merges是CH的“手术室监控屏”。正常情况下,Merge耗时应在10–60秒区间。当P95>180秒,说明Merge严重积压,可能引发连锁反应:
- 新写入Part无法及时合并,查询需扫描更多Part,延迟上升;
system.parts中active=0的Part数量持续增加,磁盘空间告急;- ZooKeeper会话因Merge线程占满CPU而超时,副本同步中断。
此时应立即执行:
-- 查看最慢的Merge任务 SELECT database, table, elapsed, partition_id, result_part_name FROM system.merges ORDER BY elapsed DESC LIMIT 5; -- 强制取消最耗时的Merge(谨慎!) KILL MERGE WHERE database='default' AND table='events_realtime';根本解法是调整Merge策略:将background_pool_size从默认的16降至8,腾出CPU给查询;同时增大max_bytes_to_merge_at_max_space_in_pool(默认15GB)至30GB,减少Merge频次。
5.3 网络层:netstat -s | grep -i "retrans"的重传率是否>0.5%?
这是最容易被忽视的底层杀手。某公司曾遭遇持续一周的“偶发性写入失败”,查Flink日志全是SocketTimeoutException,查CH日志却是OK。最终用netstat -s发现TCP重传率高达1.2%,根源是IDC交换机MTU从1500误配为9000,导致大包分片丢失。修复后,Flink到CH的写入成功率从99.2%升至99.995%。
标准排查命令:
# 每5秒刷新一次,观察重传率变化 watch -n 5 'netstat -s | grep -A 5 -B 5 "retrans"' # 若重传率高,检查MTU ip link show eth0 | grep mtu # 检查丢包(需在Flink和CH节点同时执行) ping -c 100 ch-node1 | grep "packet loss"实操心得:所有网络指标必须“双向验证”。只在Flink节点看
netstat没用,必须在CH节点同步抓包(tcpdump -i eth0 port 8123 -w ch.pcap),用Wireshark分析SYN/ACK时序,才能定位是路由问题、防火墙拦截,还是中间代理(如Nginx)配置不当。
6. 性能压测:别信TPS数字,要看“业务SLA达成率”
某公司上线前的压测,从不报“峰值TPS”,而是用业务SLA达成率作为唯一验收标准。例如,对“实时曝光转化漏斗”看板,定义SLA为:
- 数据端到端延迟 ≤ 2.5秒(P95)
- 查询P95响应 ≤ 350ms
- 写入成功率 ≥ 99.99%
- 单节点CPU均值 ≤ 70%
压测工具链是自研的ch-load-tester,核心逻辑是:
- 用Flink生成模拟流量(100万QPS),按业务比例注入曝光、点击、播放事件;
- 在ClickHouse中创建
test_metrics表,每5秒插入一行当前各SLA指标; - 压测结束,自动输出报告:
【SLA Summary】 - End-to-End Latency: 2.32s (P95) ✅ - Query Response: 312ms (P95) ✅ - Write Success Rate: 99.993% ✅ - CH Node CPU: 68.2% (avg) ✅ - Critical Alert: 0 ✅
最关键的发现是:当写入QPS从80万升至100万时,P95延迟从2.1秒跳到3.8秒,但写入成功率仍是99.99%。根因是Flink的checkpointInterval设为30秒,而CH Merge压力增大后,部分Checkpoint期间的Barrier被延迟,导致Watermark推进变慢。解决方案是将checkpointInterval动态调整为min(30s, 3 * avgMergeTime),用CH的Merge耗时反向调控Flink节奏。
这套方法论让某公司平台经受住了三次“618”大促考验,峰值写入稳定在110万QPS,SLA达成率100%。它证明:真正的稳定性,不来自参数调优,而来自对业务语义的深刻理解与系统间的动态协同。
我在实际参与模拟项目X重构时,正是复用了这套“SLA驱动压测”思路。最初团队执着于把TPS从90万提到100万,反复调参两周无果。后来我们回归业务本质,把压测目标改为“确保99%的用户在点击按钮后2秒内看到反馈”,结果发现瓶颈其实在前端埋点SDK的采样率设置——将sample_rate=0.1改为0.05,后端压力骤降40%,TPS自然突破100万。技术没有银弹,但回归业务,往往就是最锋利的那把刀。