前两天在整理旧代码仓库的时候,翻出了一个叫rea的目录。这个名字起得相当随意,当时大概是想说“Real-time Event Analytics”,结果敲着敲着就缩成了三个字母。但项目本身一点不随意——它是我们团队当时用来支撑线上监控大屏的实时事件分析系统。这篇文章就是围绕rea这个项目的完整复盘,从需求分析、技术选型、架构设计,到具体编码落位和后面踩过的一堆坑,都按当时的真实过程写下来。
如果屏幕前的你正准备做实时大屏、行为埋点分析、实时报表,或者正在为“Flink + Kafka + ClickHouse”这套组合怎么落地而发愁,这篇文章应该能帮你省掉不少试错时间。我会尽量把参数、配置、代码片段都写清楚,也把那些文档里不会告诉你的坑一并交代出来。
1. 先想清楚:rea 到底要解决什么
很多人拿到项目需求的第一反应是“快上 Flink、快上 ClickHouse”,但在动手之前,把问题本身拆清楚更重要。rea这个项目名义上是一个“实时事件分析系统”,但它的边界并不是一开始就明确的。
1.1 名字的由来和它代表的系统边界
rea的完整含义是 Real-time Event Analytics,也就是实时事件分析。它的输入是各类端上(App、H5、小程序)的埋点事件流,输出是被聚合加工后的实时指标和可查询的明细数据。系统只关心“事件从进入 Kafka 到被大屏页面消费”这一段链路,不负责埋点 SDK 本身怎么埋——但埋点协议必须由 rea 侧定义清楚,否则后面所有解析逻辑都会失控。
我见过很多团队做实时项目,上来就把 K8s 集群、Flink 集群、ClickHouse 集群全铺开,结果业务方连“实时”的定义都没对齐。rea在立项初期就和业务侧确认了四个核心问题:
- 数据延迟目标:从用户行为发生到展示在大屏上,可接受的延迟是多少。
- 数据准确性要求:是否允许实时链路和离线数仓存在误差。
- 指标口径:PV、UV、点击率这些词在业务侧到底是什么定义。
- 数据保留周期:明细需要存多少天,聚合结果需要存多少天。
这四个问题直接决定了后续的架构复杂度和资源成本。拿数据延迟来说,如果业务方只要求分钟级,那么完全可以用 Kafka Streams 或者 Spark Structured Streaming,没必要上一套 Flink;如果要求秒级且还需要处理乱序事件、做状态计算,那么 Flink 几乎是唯一稳妥的选择。
1.2 实时与离线的取舍:为什么我砍掉了 Lambda 架构
早期方案其实参考了经典的 Lambda 架构——实时链路算一份结果,离线链路每天再算一份结果,最后把两份数据合并展示。但实际做了两周之后,我发现 Lambda 架构在中小规模团队中很容易变成灾难:离线任务和实时任务用两套代码,口径经常对不上;业务方看到一个指标在实时和离线两个入口数值不一致时,信任度会迅速崩塌。
后来rea选型改为 Kappa 架构的思路:以 Kafka 中的事件流作为唯一的“事实来源”,实时链路直接从 Kafka 消费,需要重算时就从 Kafka 保留的历史位点重新消费,不再维护一套独立的离线计算引擎。这样代码只有一套,口径天然统一。
但 Kappa 架构也有代价。实时计算通常只能做到“尽量准确”,因为窗口机制的触发、乱序数据的延迟到达、重复上报等问题都会让实时结果和离线结果存在细微差异。rea的处理方式是做一个“双层口径”:
- 实时链路的近实时结果,用于大屏、监控告警、运营实时作战室。
- 离线任务在日级对账后给出“最终准确口径”,用于财务、报表等严肃场景。
实时链路保证趋势正确,离线链路保证结果精确,两边各司其职,不再互相替代。
2. 技术选型与整体架构拆解
技术选型这件事很看团队现状,不存在“某个技术一定最好”的说法。rea当时的选型结论是:Kafka 做事件总线,Flink 做流式处理,ClickHouse 存明细和聚合结果,Redis 存热状态,后端用 WebSocket 推数据给前端大屏。这一节讲清楚每个组件为什么被选中,以及它们之间容易踩的坑。
2.1 流处理引擎:Flink 到底赢在哪
当时备选方案有 Spark Structured Streaming 和 Flink。如果你的延迟目标是分钟级,Spark 其实也够用,但rea要求的是秒级窗口聚合,而且需要处理会话拆分、事件时间乱序、精确一次语义这些问题,这些恰恰是 Flink 的强项。
Flink 的核心优势有三个:状态管理、事件时间处理和精确一次语义。
- 状态管理:实时计算里经常需要记住“这个用户上一个事件是什么”,比如做会话超时拆分时,必须为每个用户保存会话状态。Flink 的 Keyed State 天然支持这种场景,而且可以通过检查点机制做故障恢复。
- 事件时间处理:埋点数据在网络传输时不可能完全有序,Flink 用事件时间和水位线机制来处理乱序,而不是像微批处理那样机械地按到达时间切窗口。
- 精确一次语义:结合 Kafka 的消费者位点和外部存储的幂等写入,Flink 能做到数据不重不丢。Spark Streaming 早期的 exactly-once 实现要靠写回 Kafka,链路比较绕。
另外 Flink SQL 在当时已经比较成熟,rea约一半的计算逻辑直接使用 Flink SQL 实现,写起来快,业务方也能看懂。复杂的自定义逻辑再用 DataStream API 补充。
2.2 消息队列与存储:选型和关键避坑
流处理链路里消息队列是咽喉。当时对比的是 Kafka 和 Pulsar。没有选择 Pulsar 不是因为 Pulsar 不好,而是团队对 Kafka 的运维经验更足,而且周边生态(监控、客户端、连接器)足够成熟。Kafka 最重要的特性是“可回放”——消费者可以从任意位点重新消费,这给数据修复、逻辑更新后的重算提供了极大的便利。
存储层对比过三个方案:ClickHouse、Druid、Elasticsearch。选型结果非常明确:
| 维度 | ClickHouse | Druid | Elasticsearch |
|---|---|---|---|
| 写入吞吐 | 极高,列式存储天然适合批量写入 | 较高,但部署复杂 | 中等,吃内存 |
| 聚合查询能力 | SQL 语法支持好,GROUP BY 快 | 原生聚合能力强 | 擅长搜索,聚合较弱 |
| 运维成本 | 单机就能跑,集群部署也简单 | 组件多,学习成本高 | 集群维护成本高 |
| 典型场景 | 明细大表、指标聚合 | 时序数据探索 | 日志检索 |
rea的数据形态非常明确,就是事件明细和指标结果,最常用的操作是“按时间范围做聚合分组”,这正好是 ClickHouse 的看家本领。Elasticsearch 被排除的原因也很简单:数据量大之后内存占用吞掉太多预算,而且按多维度 group by 的查询延迟不够稳定。
Redis 在存储层里看起来不太起眼,但它在rea里承担了很关键的任务:保存大屏需要实时读取的热状态、UV 去重用的位图(Bitmap)、以及用户会话的缓存。后面专门有一节讲它的用法和内存计算。
2.3 rea 的分层链路与一次事件的生命周期
rea的整体链路用一个简单的分层描述就是:
- 接入层:埋点 SDK 将事件发送到接入服务,接入服务做基础校验后写入 Kafka。
- 处理层:Flink 消费 Kafka 事件,做清洗、转换、维表关联、窗口聚合,结果写入 ClickHouse,热数据写入 Redis。
- 存储层:ClickHouse 存放明细数据和聚合结果,Redis 存放实时计数和位图状态。
- 查询服务层:后端服务从 ClickHouse 和 Redis 读取数据,加工成前端需要的结构。
- 展示层:大屏通过 WebSocket 接收实时推送,前端做增量渲染。
一次事件的生命周期大致是:用户在页面点击 → 埋点 SDK 构造 JSON 事件 → 接入服务校验后写入 Kafka → Flink 消费并解析 → 完成聚合计算 → 聚合结果写入 ClickHouse / Redis / 查询服务 → WebSocket 推送给浏览器 → 页面上的数字刷新。
从点击发生到出现在大屏上,rea实测的端到端延迟通常在 10~15 秒。这里有个容易被忽略的点:埋点上报本身有自己的延迟策略,很多 SDK 会做批次上报,导致事件不是实时到服务器,而是攒了几秒才发出来。如果业务方要求“严格秒级”,你在埋点层就得改配置,这不是后端链路能解决的问题。
3. 核心实现细节与关键参数把控
技术栈定下来之后,真正拉开项目差距的是细节。rea里很多问题都不是工具本身的问题,而是参数配置、数据模型设计、异常场景处理这些“抗氧化”的东西没做好。这一节把事件模型、Flink 参数、ClickHouse 写入、Redis 状态存储四个部分分别拆开讲。
3.1 事件模型设计:一开始就把字段协议定死
rea在上线后遇到过一次比较大的返工,原因是早期事件模型太随意。最初接入的业务方按自己习惯塞字段,同一个“按钮点击”事件,有人写成action=click,有人写成type=click,还有人把业务 ID 放在ext_info深不可测的层级里,Flink SQL 解析时只能用一堆get_json_object硬抠,性能和可维护性都很差。
之后我们把事件模型收敛成了统一的结构:
{ "event_id": "e8934798-xxxx", "event_time": 1713571200000, "user_id": "u_20240", "session_id": "s_88", "device_id": "d_abc", "page": "home", "action": "click", "event_type": "button", "ext_info": { "bid": 1234, "source": "banner" } }关键约定有三点:
event_id是每条事件的全局唯一 ID,用来做幂等去重。生成方式建议由埋点 SDK 生成 UUID,服务端不再二次生成。event_time必须是毫秒时间戳(Long 类型),不要用2024-04-20 10:00:00这种字符串。字符串解析在 Flink 里既慢又容易因时区产生偏差。ext_info只放不需要参与过滤和分组的业务属性。凡是需要在查询时作为WHERE条件或GROUP BY维度的字段,必须提升为顶层字段,否则查询性能和易用性都会很差。
事件模型的定义最好沉淀为一个 JSON Schema,接入方必须按 Schema 校验后才允许上报。rea当时是在接入层做了一层简单的校验:字段缺失直接丢弃,类型不对统一转字符串并标记为脏数据。这样至少保证进入 Flink 的数据是符合预期的。
3.2 Flink 作业稳定性:五个参数决定成败
Flink 作业运行得稳不稳,很多坑在配置阶段就埋下了。rea里踩过几个比较惨的教训,这里把最终沉淀下来的关键参数写出来。
首先是检查点配置。实时任务的故障恢复靠检查点,没有检查点或者检查点配置不合理,重启后状态就乱了。rea的检查点配置如下:
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.enableCheckpointing(30000); // 每 30 秒做一个检查点 env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE); env.getCheckpointConfig().setMinPauseBetweenCheckpoints(5000); // 两个检查点之间最小间隔 env.getCheckpointConfig().setCheckpointTimeout(600000); // 检查点超时时间 10 分钟 env.getCheckpointConfig().setMaxConcurrentCheckpoints(1); // 最多同时 1 个检查点 env.setStateBackend(new HashMapStateBackend()); env.getCheckpointConfig().setExternalizedCheckpointCleanup( ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);检查点间隔不能太短。太短会导致频繁做快照,反而影响正常数据处理;太长又会导致故障恢复的窗口太大。30 秒是一个比较通用的中间值。如果业务要求更快的故障恢复,可以压到 10~15 秒,但要考虑对 CPU 和磁盘的额外消耗。
其次是并行度设置。rea的 Kafka topic 定的是 6 个分区,那么 Flink Kafka Source 的并行度就设置为 6,保证一个分区对应一个读取线程。窗口聚合环节的并行度通常和分区分开设置,一般给 3~6 即可。很多人以为并行度越大处理越快,实际上并行度太大会导致网络 Shuffle 增多、小文件变多、下游写入压力增大,性能反而下降。
再次是关联维表的方式。实时任务经常需要把事件里的业务 ID 关联成业务名称,比如把bid=1234关联成“促销位A”。如果每次都去查数据库,Kafka 的吞吐会直接把数据库打崩。rea用的是旁路缓存方案:启动时把维表加载到本地内存,定时刷新,只有缓存未命中时才去查数据库。这样既保证了维度字段的实时性,又不会给存储造成压力。
最后是空闲 Source 的处理。如果一个 Kafka topic 长时间没有数据,Flink 默认不会推进水位线,所有事件时间窗口都不会触发。解决方案是设置空闲 Source 超时:
WatermarkStrategy.<JSONObject>forBoundedOutOfOrderness(Duration.ofSeconds(3)) .withIdleness(Duration.ofMinutes(1));这样即使某个分区暂时没有数据,系统也会基于其他活跃分区推进水位线。
3.3 ClickHouse 写入与查询优化:分区键决定性能天花板
ClickHouse 的性能上限很大程度上由表结构决定。rea的明细表最终设计如下:
CREATE TABLE events_local ON CLUSTER default ( event_date Date, event_time DateTime64(3), event_id String, user_id String, page String, action String, ext_info String ) ENGINE = MergeTree() PARTITION BY toYYYYMMDD(event_date) ORDER BY (event_date, event_time, event_id) TTL event_date + INTERVAL 30 DAY SETTINGS index_granularity = 8192;分区键选event_date,是因为rea的绝大多数查询都是按天过滤的。如果你有非常明显的业务维度查询,可以再加一列到分区键里,但分区粒度不要太细,否则 ClickHouse 会产生大量小分区,反而影响查询和合并性能。
ORDER BY在这里承担了类似“二级索引”的角色。ClickHouse 的 MergeTree 是列式存储,ORDER BY后面列的数据顺序就是存储顺序,查询时能在索引粒度下快速跳过无关数据。(event_date, event_time, event_id)这个顺序是经过验证的:按日期过滤后按时间排序,最后用 event_id 做唯一性散列,避免同一时间戳下数据堆积。
Flink 写入 ClickHouse 时,最忌讳的做法是来一条写一条。rea使用的是攒批写入:积攒 10000 条记录或 2 秒时间窗口,无论哪个先满足都触发一次批量写入。这样能极大降低 ClickHouse 的写入压力。不要在生产环境用Buffer表做中间缓冲层,那玩意儿在测试环境看着方便,数据量大时会引入内存不可控和写入延迟不可预估的问题。
3.4 Redis 状态存储:UV 去重没有你想的那么贵
大屏上最常看的一个指标是“实时 UV”。Flink SQL 里的COUNT(DISTINCT user_id)在数据量上来之后会让状态无限膨胀,因为每看到一个用户 ID 都要保存在状态里,等窗口结束才能计算。rea的实际做法是让 Flink 只做基础聚合(比如 PV、点击次数),UV 用 Redis Bitmap 来做。
原理很简单:把一个用户 ID 映射到 Bitmap 的某个位上,置为 1。查询 UV 时用BITCOUNT统计整个 Bitmap 中有多少个 1。两个用户如果映射到同一个位会出现碰撞,所以需要用一个分布足够均匀的 Hash 函数,比如 MurmurHash 或 CRC64。
内存开销可以提前估算:
- 100 万用户 Bitmap 占用内存 = 1000000 / 8 / 1024 ≈ 122 KB。
- 1000 万用户 Bitmap 占用内存 ≈ 1.19 MB。
- 1 亿用户 Bitmap 占用内存 ≈ 11.92 MB。
这个量级对 Redis 来说完全没有压力,而且SETBIT的时间复杂度是 O(1),适合高频写入。rea的 key 设计成uv:{date}:{hour}:{minute},按分钟存一个 Bitmap,前端要显示“当前小时 UV”时,把 60 个 Bitmap 做BITOP OR合并,再BITCOUNT即可。
这里有个细节:BITCOUNT在超大 Bitmap(比如超过 16KB)上性能会下降,但 1 亿用户的 Bitmap 才 12MB,早就超过了这个阈值。好在我们每分钟只执行几十次统计,实测完全没有影响。如果你的规模比这大得多,可以考虑用多个分片 Bitmap 分散到多把 key 上,最后按分片汇总结果。
4. 实操重现:从启动环境到跑通指标
纸上谈兵讲再多,不如自己跑一遍。这一节按rea的最小可运行方案来复现:本地用 Docker 起一套 Kafka + ClickHouse + Redis,写一个 Flink SQL 作业消费 Kafka 事件流,聚合出每分钟的 PV/UV,写入 ClickHouse,再通过 WebSocket 推到前端大屏。
4.1 本地环境搭建
rea的本地开发环境很简单,核心组件都用 Docker Compose 起:
version: '3.8' services: kafka: image: bitnami/kafka:3.5 ports: - "9092:9092" environment: KAFKA_CFG_NODE_ID: 0 KAFKA_CFG_PROCESS_ROLES: controller,broker KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093 KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: false clickhouse: image: clickhouse/clickhouse-server:23.8 ports: - "8123:8123" - "9000:9000" redis: image: redis:7 ports: - "6379:6379"启动之后创建一个名为event_topic的 topic,分区数设 6,副本数设 1:
docker exec -it kafka bash cd /opt/bitnami/kafka/bin kafka-topics.sh --bootstrap-server localhost:9092 \ --create --topic event_topic --partitions 6 --replication-factor 1如果你用的是 Kafka 自带的kafka-console-producer.sh来测试,可以手动投递几条 JSON 事件,这对于验证链路很有用。
4.2 核心代码与表结构
事件示例:
{ "event_id": "e8934798-xxxx", "event_time": 1713571200000, "user_id": "u_20240", "page": "home", "action": "click", "ext_info": "{\"bid\":1234}" }Flink SQL 中创建 Kafka 数据源表:
CREATE TABLE source_events ( event_id STRING, event_time BIGINT, user_id STRING, page STRING, action STRING, ts_ltz AS TO_TIMESTAMP_LTZ(event_time, 3), WATERMARK FOR ts_ltz AS ts_ltz - INTERVAL '3' SECOND ) WITH ( 'connector' = 'kafka', 'topic' = 'event_topic', 'properties.bootstrap.servers' = 'localhost:9092', 'properties.group.id' = 'rea_flink_group', 'format' = 'json', 'scan.startup.mode' = 'earliest-offset', 'json.ignore-parse-errors' = 'true' );注意这里把毫秒时间戳转换成了TIMESTAMP_LTZ类型,并定义了 watermark:允许最多 3 秒的乱序延迟。这是事件时间处理的基石。
每分钟页面维度的 PV/UV 计算:
CREATE TABLE page_metrics_sink ( win_start TIMESTAMP(3), page STRING, pv BIGINT, uv BIGINT ) WITH ( 'connector' = 'clickhouse', 'url' = 'clickhouse://localhost:8123', 'table-name' = 'page_metrics', 'format' = 'json', 'sink.batch-size' = '10000', 'sink.flush-interval' = '2000' ); INSERT INTO page_metrics_sink SELECT TUMBLE_START(ts_ltz, INTERVAL '1' MINUTE) AS win_start, page, COUNT(*) AS pv, COUNT(DISTINCT user_id) AS uv FROM source_events WHERE action = 'click' GROUP BY TUMBLE(ts_ltz, INTERVAL '1' MINUTE), page;ClickHouse 侧建聚合结果表:
CREATE TABLE default.page_metrics ( win_start DateTime, page String, pv UInt64, uv UInt64 ) ENGINE = MergeTree() ORDER BY (win_start, page);这个链路跑通之后,大屏上最基本的“每分钟页面点击量”和“每分钟访问用户数”就有了。这里的 UV 用的是 Flink SQL 的COUNT DISTINCT,刚才也提到过,如果你需要精确且低存储成本的实时 UV,可以把 UV 这部分换成 Redis Bitmap 方案,Flink SQL 只负责算 PV 和其他基础聚合。
4.3 大屏数据推送的最后一公里
聚合结果写入 ClickHouse 之后,前端大屏还需要一个实时推送通道。rea用的是 WebSocket,但推数据的方式有一个重要的经验教训:不要每秒都推。
第一版实现时,后端每秒往 WebSocket 推送一条全量 JSON,前端大屏的 ECharts 每秒整体重绘一次。结果是浏览器直接掉到 20 帧,CPU 占满,页面卡到没法用。后来改成两个策略:
- 推送频率降到 5 秒一次,或者只推增量变化,而不是全量数据。
- 前端使用
setOption的增量合并模式,让 ECharts 只更新变化的系列,不重建整个图表。
服务端推送的核心伪代码大致如下:
for (String message : latestMetrics) { String payload = objectMapper.writeValueAsString(mapOf(metrics)); session.getBasicRemote().sendText(payload); }rea在前端大屏上还做了心跳机制:每 10 秒发一次 ping,服务端发现连接断开后主动回收资源;前端断线后自动重连,重连成功后带上“最近一次接收到的数据时间”,服务端把断线期间缺失的数据补推回来。这个补偿逻辑很重要,不然网络抖动一次,大屏上的数据就停在那不动了。
5. 踩坑实录:常见问题速查与排查技巧
每个实时项目都是在坑里摸爬滚打过来的。rea从开发到稳定运行,前后经历了大大小小几十个问题。这里挑六个别处文档里不太容易找到的细节展开,另外附一个速查表,方便你将来按图索骥。
5.1 六个印象深刻的问题
第一个是时间字段的时区问题。上线第二天发现大屏的 PV 曲线整体推迟了 8 个小时。排查到最后,发现 Flink SQL 创建表时把事件里的字符串时间直接当成无时区时间处理,没有转成 UTC。事件时间是毫秒时间戳,但源表里用的TIMESTAMP还是TIMESTAMP_LTZ混着来的结果。后来统一收敛为TO_TIMESTAMP_LTZ(event_time, 3),并且在 Flink 作业里显式设置table.local-time-zone为 UTC,避免本机时区对语义的影响。
第二个是维表关联导致状态膨胀。第一次做维表 join 时,天真地对每个事件都查一次业务库,后来加了全量加载的缓存在本地。可这还不够,事件量一大,本地缓存的更新频率跟不上,于是改成启动时全量加载 + 每 5 分钟增量刷新一次。这样做有两个好处:查询时不再访问外部存储,同时维表更新不会影响正在处理的事件流。
第三个是数据积压。某天大屏上的指标延迟越来越高,Flink Web UI 上显示 Kafka 消费延迟到了百万级。排查下来发现是 ClickHouse 写入端的并发只有 1,而且没开批量攒批,每条数据都走一次网络。后改为攒批写入,单次写入攒 10000 条或 2 秒 flush,之后消费延迟迅速降到几千条以内。
第四个是 UV 被高估。业务方发现实时 UV 和离线 UV 差了近三分之一。原因是用户卸载重装 App 后,客户端生成的device_id变了,被当成新用户统计。rea最终方案是引入服务端下发的稳定 user_id:用户每次冷启动时优先使用加密的本地存储 ID 拼接服务端返回的用户唯一标识,只有完全查不到时才回退到设备 ID。这个改动相当于把“设备维度”和“用户维度”在埋点层就分开了。
第五个是大屏初始化洪水。每次大屏断线重连,前端都会向后端请求一次“近 24 小时数据”,后端的 SQL 要扫全天的聚合结果,压得 ClickHouse 查询耗时飙到几十秒。后来改成:断线重连只加载最近 1 分钟的增量数据,页面最初的整体快照单独从一个预聚合结果表中读取,不再实时扫描明细。
第六个是检查点反复失败。在测试集群上,Flink 作业运行几个小时就会重启,日志显示检查点超时。排查发现 RocksDB 状态后端的本地磁盘容量只有 200M,做快照时直接写慢。后来状态后端换成了本地大容量磁盘,并配置了保留 3 份检查点的清理策略,才彻底稳定下来。
5.2 常见问题速查表
| 问题表现 | 可能原因 | 快速排查方向 | 长期解法 |
|---|---|---|---|
| 指标明显偏低或缺失 | 埋点协议不匹配,事件被丢弃 | 查看接入层日志、Flink 解析失败数 | 建立 Schema 校验,脏数据单独入死信队列 |
| 窗口数据整体偏移 | 时间字段时区处理错误 | 检查事件时间类型和 local-time-zone 配置 | 统一使用 UTC 毫秒时间戳,显式设置时区 |
| 重复计数 | 上游重试导致重复事件 | 看 event_id 是否唯一,检查下游是否幂等 | 明细表用 ReplacingMergeTree 按 event_id 去重 |
| 内存耗尽或频繁 GC | 状态设计不合理、维表缓存过大 | 查看检查点大小和堆内存占用 | 限制 Keyed State 存活时间,压缩缓存 |
| 大屏 WebSocket 卡顿 | 推送频率过高、全量重绘 | 看浏览器 Network 面板请求频率 | 降频 + 增量消息 + 前端增量更新 |
| Flink 消费延迟飙升 | 下游写入变慢或算子繁忙 | 查看背压面板和 Kafka lag | 优化下游攒批,合理调整并行度 |
6. 运维监控与质量保障心得
实时系统最怕的不是功能做不出来,而是跑着跑着数据就偏了、链路就断了,你还得能及时发现并快速恢复。这一节把rea在运维侧的沉淀总结一下。
6.1 实时指标凭什么让人相信
为了保证实时数据“可信”,rea做了一个小时级别的对账机制:离线数仓每过一个小时,用同样的口径算一次过去一小时的数据,实时链路也算了同一时段的数据,两者对比误差超过 2% 就触发告警。这个机制很大程度缓解了“实时和离线数值不一致”的信任危机。
实时链路和离线链路存在误差几乎不可避免,原因包括:
- 实时窗口基于事件时间,离线任务基于数据到达时间。
- 实时链路允许乱序数据迟到,活体窗口关闭后就丢弃了。
- 埋点存在重试上报,实时链路可能重复消费。
- 离线任务能拿到实时任务看不到的修正数据(比如补录)。
所以对账的目标不是让两边完全相等,而是把差异控制在一个可解释、可接受的范围内。一旦误差超过阈值,立刻排查是实时这边漏数了,还是离线这边口径变了。
6.2 健康度监控与告警阈值
rea的监控体系覆盖了四个层面:
- Kafka 消费延迟。消费延迟超过 30 万条时触发告警,说明处理能力跟不上下游。
- Flink 检查点成功率。检查点连续失败 2 次以上就要立即处理,否则状态语义无法保证。
- 下游写入失败率。ClickHouse 或 Redis 写入失败率超过 1% 需要告警。
- 链路空窗检测。连续 2 分钟某个核心 topic 没有任何事件上报,说明埋点或者接入层可能出问题了,需要立刻确认。
告警的阈值不是瞎设的。消费延迟阈值要和吞吐量挂钩,比如目标端到端延迟是 15 秒,Kafka 单分区每秒大约 2000 条事件,那么最多允许积压 30000 条才能保证 15 秒内消化,但我们设的是 30 万条——这意味着系统已经积压到无法按预期恢复,需要人工介入。告警阈值太灵敏也会造成告警疲劳,这一点需要根据自己的业务量来定。
监控面板除了延迟和成功率,还有一个容易被忽略的指标:Flink 背压(Backpressure)持续时间。如果某个算子背压持续超过 5 分钟,后续链路的处理能力就会出现波动。处理思路通常是:先看下游是不是变慢,再看算子并行度是不是不够,最后再排查是否有倾斜数据。
rea做空窗检测时遇到过一件有意思的事:某天凌晨大屏曲线突然跳水,从几万跌到 0,值班的人以为是系统挂了,结果发现是某业务方临时把埋点开关关了,事件根本没上报。链路本身是健康的,但业务数据源断了。这件事之后我们得出一条经验:空窗告警不能只看“有没有数据”,还要区分“链路故障”和“业务停摆”两种情况,不然半夜把运维折腾起来却只是虚惊一场。
实时系统跑起来之后,稳定压倒一切。如果让我再重做一次rea,我会把第一周的时间全部用来定义事件模型、确定指标口径和对账机制,而不是急着搭环境写代码。数据链路的三分在开发,七分在规范;Flow 写得再漂亮,埋点一通乱埋,实时大屏也就成了个精致的摆设。如果你也正在做一个代号只有几个字母的实时项目,建议先把口径、字段、Schema、对账方式都摊在桌面上聊清楚,再让链路跑起来——这个顺序千万别反过来。