一、前言
Flink 和 ClickHouse 分别是实时计算和 OLAP 领域的翘楚,也是近些年非常火爆的开源框架,很多大厂都在将两者结合使用来构建各种用途的实时平台,效果很好。关于两者的优点就不再赘述,本文来简单介绍笔者团队在点击流实时数仓方面的一点实践经验。
1. 点击流及其维度建模
所谓点击流(click stream),就是指用户访问网站、App 等 Web 前端时在后端留下的轨迹数据,也是流量分析(traffic analysis)和用户行为分析(user behavior analysis)的基础。点击流数据一般以访问日志和埋点日志的形式存储,其特点是量大、维度丰富。以我们一个中等体量的普通电商平台为例,每天产生 200+GB、十亿条左右的原始日志,埋点事件 100+个,涉及 50+个维度。按照 Kimball 的维度建模理论,点击流数仓遵循典型的星形模型,简图如下。
2. 点击流数仓分层设计
点击流实时数仓的分层设计仍然可以借鉴传统数仓的方案,以扁平为上策,尽量减少数据传输中途的延迟。简图如下。
- DIM 层:维度层,MySQL 镜像库,存储所有维度数据。
- ODS 层:贴源层,原始数据由 Flume 直接进入 Kafka 的对应 topic。
- DWD 层:明细层,通过 Flink 将 Kafka 中数据进行必要的 ETL 与实时维度 join 操作,形成规范的明细数据,并写回 Kafka 以便下游与其他业务使用。再通过 Flink 将明细数据分别写入 ClickHouse 和 Hive 打成大宽表,前者作为查询与分析的核心,后者作为备份和数据质量保证(对数、补数等)。
- DWS 层:服务层,部分指标通过 Flink 实时汇总至 Redis,供大屏类业务使用。更多的指标则通过 ClickHouse 物化视图等机制周期性汇总,形成报表与页面热力图。特别地,部分明细数据也在此层开放,方便高级 BI 人员进行漏斗、留存、用户路径等灵活的 ad-hoc 查询,这些也是 ClickHouse 远超过其他 OLAP 引擎的强大之处。
二、要点与注意事项
1. Flink 实时维度关联
Flink 框架的异步 I/O 机制为用户在流式作业中访问外部存储提供了很大的便利。针对我们的情况,有以下三点需要注意:
- 使用异步 MySQL 客户端,如 Vert.x MySQL Client。
- AsyncFunction 内添加内存缓存(如 Guava Cache、Caffeine 等),并设定合理的缓存驱逐机制,避免频繁请求 MySQL 库。
- 实时维度关联仅适用于缓慢变化维度,如地理位置信息、商品及分类信息等。快速变化维度(如用户信息)则不太适合打进宽表,我们采用 MySQL 表引擎将快变维度表直接映射到 ClickHouse 中,而 ClickHouse 支持异构查询,也能够支撑规模较小的维表 join 场景。未来则考虑使用 MaterializedMySQL 引擎将部分维度表通过 binlog 镜像到 ClickHouse。
2. Flink-ClickHouse Sink 设计
可以通过 JDBC(flink-connector-jdbc)方式来直接写入 ClickHouse,但灵活性欠佳。好在 clickhouse-jdbc 项目提供了适配 ClickHouse 集群的 BalancedClickhouseDataSource 组件,我们基于它设计了 Flink-ClickHouse Sink,要点有三:
- 写入本地表,而非分布式表,老生常谈了。
- 按数据批次大小以及批次间隔两个条件控制写入频率,在 part merge 压力和数据实时性两方面取得平衡。目前我们采用 10000 条的批次大小与 15 秒的间隔,只要满足其一则触发写入。
- BalancedClickhouseDataSource 通过随机路由保证了各 ClickHouse 实例的负载均衡,但是只是通过周期性 ping 来探活,并屏蔽掉当前不能访问的实例,而没有故障转移——亦即一旦试图写入已经失败的节点,就会丢失数据。为此我们设计了重试机制,重试次数和间隔均可配置,如果当重试机会耗尽后仍然无法成功写入,就将该批次数据转存至配置好的路径下,并报警要求及时检查与回填。
当前我们仅实现了 DataStream API 风格的 Flink-ClickHouse Sink,随着 Flink 作业 SQL 化的大潮,在未来还计划实现 SQL 风格的 ClickHouse Sink,打磨健壮后会适时回馈给社区。另外,除了随机路由,我们也计划加入轮询和 sharding key hash 等更灵活的路由方式。
还有一点就是,ClickHouse 并不支持事务,所以也不必费心考虑 2PC Sink 等保证 exactly once 语义的操作。如果 Flink 到 ClickHouse 的链路出现问题导致作业重启,作业会直接从最新的位点(即 Kafka 的 latest offset)开始消费,丢失的数据再经由 Hive 进行回填即可。
3. ClickHouse 数据重平衡
ClickHouse 集群扩容之后,数据的重平衡(reshard)是一件麻烦事,因为不存在类似 HDFS Balancer 这种开箱即用的工具。一种比较简单粗暴的思路是修改 ClickHouse 配置文件中的 shard weight,使新加入的 shard 多写入数据,直到所有节点近似平衡之后再调整回来。但是这会造成明显的热点问题,并且仅对直接写入分布式表才有效,并不可取。
因此,我们采用了一种比较曲折的方法:将原表重命名,在所有节点上建立与原表 schema 相同的新表,将实时数据写入新表,同时用 clickhouse-copier 工具将历史数据整体迁移到新表上来,再删除原表。当然在迁移期间,被重平衡的表是无法提供服务的,仍然不那么优雅。
安装过程
1、docker-compose up -d 安装
version: '3.8'
services:
zookeeper:
image: bitnami/zookeeper:latest
container_name: zookeeper
ports:
- "2181:2181"
environment:
- ALLOW_ANONYMOUS_LOGIN=yes
kafka:
image: wurstmeister/kafka:2.12-2.2.1
container_name: kafka
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
depends_on:
- zookeeper
volumes:
- /var/run/docker.sock:/var/run/docker.sock
flink-jobmanager:
image: flink:1.17.2-scala_2.12
container_name: flink-jobmanager
ports:
- "9081:9081" # Flink Web UI
command: jobmanager # 必须指定启动模式
environment:
- JOB_MANAGER_RPC_ADDRESS=flink-jobmanager
flink-taskmanager:
image: flink:1.17.2-scala_2.12
container_name: flink-taskmanager
depends_on:
- flink-jobmanager
command: taskmanager # 必须指定启动模式
environment:
- JOB_MANAGER_RPC_ADDRESS=flink-jobmanager
clickhouse-server:
image: yandex/clickhouse-server:latest
container_name: clickhouse-server
ports:
- "8123:8123"
- "9000:9000"
volumes:
- clickhouse_data:/var/lib/clickhouse
ulimits:
nofile:
soft: 262144
hard: 262144
volumes:
clickhouse_data:
2、进入kafka创建Topic
docker exec -it kafka /bin/bash
kafka-topics.sh --create --topic user-events --bootstrap-server localhost:9092 --partitions 1 --replication-factor 1
查看已创建的Topic
kafka-topics.sh --list --bootstrap-server localhost:9092
3、进Clickhouse创建数据库和表
#进入clickhouose容器
docker exec -it clickhouse-server /bin/bash
#命令行进入clickhouse管理,类似mysql的mysql命令
clickhouse-client
#创建数据库
CREATE DATABASE IF NOT EXISTS testdb;
#切换数据库,和mysql一样
use testdb;
#创建表用户行为表(追加)
CREATE TABLE employee_behavior (
event_time DateTime DEFAULT now(), -- 行为发生时间
employee_id UInt32, -- 员工ID
team_id UInt32,
behavior_type Enum('forward'=1, 'new_fan'=2, 'view'=3, 'deal'=4, 'refund'=5),
amount Float64 DEFAULT 0 -- 金额(成交/退款)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(event_time)
ORDER BY (event_time, employee_id);
4、数据采集及查询
行为类型及值:view
、forward
、new_fan
等行为的 amount
就固定为 1
,虽然它们本身没“金额”,但你可以这样理解 👇:
✅ 推荐字段含义约定
行为类型(behavior_type ) | 含义 | 是否用 amount | 建议处理方式 |
---|---|---|---|
view | 浏览一次 | ✔️ 是(值为 1 ) | 写入 amount = 1 |
forward | 转发一次 | ✔️ 是(值为 1 ) | 写入 amount = 1 |
new_fan | 新增粉丝 | ✔️ 是(值为 1 ) | 写入 amount = 1 |
deal | 成交 | ✔️ 是(金额) | 写入 amount = 实收金额 |
refund | 退款 | ✔️ 是(退款金额) | 写入 amount = 退款金额 |
🔁 这样你可以用统一 SQL 聚合 amount
比如这个查询同时展示各行为数量:
SELECT
employee_id,
countIf(behavior_type = 'view') AS views,
countIf(behavior_type = 'forward') AS forwards,
countIf(behavior_type = 'new_fan') AS new_fans,
countIf(behavior_type = 'deal') AS deal_count,
sumIf(amount, behavior_type = 'deal') AS revenue,
sumIf(amount, behavior_type = 'refund') AS refund_amount
FROM employee_behavior
WHERE event_time >= today()
GROUP BY employee_id
ORDER BY revenue DESC;
✅ 前三类 count 类行为通过
countIf
✅ 金额类行为用sumIf
✅ 写入时的代码建议(Java 示例)
// 伪代码,行为类型 + 金额
public void reportBehavior(String type, int employeeId, double money) {
double amount = 0;
if (type.equals("deal") || type.equals("refund")) {
amount = money; // 金额行为
} else {
amount = 1; // 计数行为
}
String sql = "INSERT INTO employee_behavior (employee_id, behavior_type, amount) VALUES (?, ?, ?)";
jdbc.update(sql, employeeId, type, amount);
}
🧠 总结
行为 | amount 建议值 |
---|---|
浏览/转发/新粉 | 1 |
成交/退款 | 实际金额 |
前端直接上传来的用户行为数据,并且希望实现“实时排行榜”展示,采用以下这个高效稳定的架构方案👇:
✅ 总体架构:前端行为实时记录 + ClickHouse 实时聚合查询
[前端用户操作]
↓
[后端接口接收行为数据]
↓(写入)
[ClickHouse(行为日志表)]
↓(聚合查询)
[排行榜页面实时刷新展示]
🧱 Step 1:ClickHouse 表结构(行为日志追加表)
CREATE TABLE employee_behavior (
event_time DateTime DEFAULT now(), -- 行为发生时间
employee_id UInt32, -- 员工ID
team_id UInt32,
behavior_type Enum('forward'=1, 'new_fan'=2, 'view'=3, 'deal'=4, 'refund'=5),
amount Float64 DEFAULT 0 -- 金额(成交/退款)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(event_time)
ORDER BY (event_time, employee_id);
✅ 每次行为(转发、新粉、浏览、成交、退款)就写入一条记录,数据是只追加,不更新的,这正是 ClickHouse 最擅长的场景。
🔁 Step 2:前端上传行为数据格式
前端每次发生行为,比如转发、成交、浏览,都调用一个接口:
POST /api/report-behavior
{
"employee_id": 1001,
"team_id": 1,
"behavior_type": "deal",
"amount": 89.0
}
或者转发时:
{
"employee_id": 1001,
"team_id": 1,
"behavior_type": "forward"
}
🖥 Step 3:后端接收接口写入 ClickHouse(Java 示例)
@PostMapping("/api/report-behavior")
public void reportBehavior(@RequestBody BehaviorDTO dto) {
String sql = "INSERT INTO employee_behavior (employee_id, team_id, behavior_type, amount) VALUES (?, ?, ?, ?)";
clickHouseJdbcTemplate.update(sql, dto.employeeId, dto.teamId, dto.behaviorType, dto.amount == null ? 0 : dto.amount);
}
也可以批量写入、异步缓冲优化。
📊 Step 4:排行榜查询 SQL 示例
今日成交排行榜(实时)
SELECT
employee_id,
countIf(behavior_type = 'deal') AS deals,
sumIf(amount, behavior_type = 'deal') AS revenue
FROM employee_behavior
WHERE event_time >= today()
GROUP BY employee_id
ORDER BY deals DESC
LIMIT 10;
昨日浏览排行榜
SELECT
employee_id,
countIf(behavior_type = 'view') AS views
FROM employee_behavior
WHERE event_time >= today() - INTERVAL 1 DAY AND event_time < today()
GROUP BY employee_id
ORDER BY views DESC
LIMIT 10;
实时5分钟内排行榜(适合直播刷榜)
SELECT
employee_id,
countIf(behavior_type = 'deal') AS deals,
sumIf(amount, behavior_type = 'deal') AS revenue
FROM employee_behavior
WHERE event_time >= now() - INTERVAL 5 MINUTE
GROUP BY employee_id
ORDER BY deals DESC
LIMIT 10;
🚀 Step 5:前端展示页面定时轮询刷新
// uniapp / vue 示例,每10秒请求排行榜
setInterval(() => {
uni.request({
url: '/api/rankings?type=today',
success: (res) => {
this.rankingList = res.data;
}
});
}, 10000);
如果你想统计 “实际成交额排行” = 成交金额 - 退款金额,ClickHouse 非常适合通过 sumIf()
实现这个聚合公式,不用多表 JOIN,也不用临时表,一个 SQL 就能搞定 ✅。
✅ 示例 SQL:统计今日“实收金额排行”
SELECT
employee_id,
sumIf(amount, behavior_type = 'deal') AS total_deal_amount,
sumIf(amount, behavior_type = 'refund') AS total_refund_amount,
sumIf(amount, behavior_type = 'deal') - sumIf(amount, behavior_type = 'refund') AS net_income
FROM employee_behavior
WHERE event_time >= today()
GROUP BY employee_id
ORDER BY net_income DESC
LIMIT 10;
📌 字段解释:
字段名 | 含义 |
---|---|
total_deal_amount | 今日总成交金额 |
total_refund_amount | 今日退款金额 |
net_income | 实际收入 = 成交 - 退款 |
employee_id | 员工 ID |
🧠 扩展用法(带员工名、更多行为)
SELECT
employee_id,
any(employee_name) AS name,
sumIf(amount, behavior_type = 'deal') AS total_deal,
sumIf(amount, behavior_type = 'refund') AS total_refund,
countIf(behavior_type = 'deal') AS deal_count,
countIf(behavior_type = 'view') AS views,
total_deal - total_refund AS net_income
FROM employee_behavior
WHERE event_time >= today()
GROUP BY employee_id
ORDER BY net_income DESC
LIMIT 10;
🚨 如果你没有
employee_name
字段,也可以从别的维表关联进来,或者提前冗余进 ClickHouse。
如果你想支持:
-
查询“昨日实收排行”
-
查询“最近1小时实收排行”
-
查询“上周实收前10名”
也只需改一下 WHERE event_time
的时间条件即可。
都是ClickHouse 行为统计排行榜系统设计中的经典需求,逐一讲解并附上完整 SQL + 技术建议 ✅
✅ 1. 如何查「团队总成交额排行」?
按团队聚合就可以,核心是 GROUP BY team_id
🔍 SQL 示例(今日团队成交排行):
SELECT
team_id,
sumIf(amount, behavior_type = 'deal') AS total_deal,
sumIf(amount, behavior_type = 'refund') AS total_refund,
total_deal - total_refund AS net_income
FROM employee_behavior
WHERE event_time >= today()
GROUP BY team_id
ORDER BY net_income DESC
LIMIT 10;
如果你还有
team_name
字段,也可以加上any(team_name)
一起返回。
✅ 2. 如何查「前日 / 昨日 / 今日 / 总榜」一起返回?
建议拆成四个子查询再 UNION ALL
或前端并行请求(ClickHouse 不支持多个 SELECT
同时返回),下面是服务端方式:
🔄 SQL 示例(4段时间)
SELECT 'today' AS tag, employee_id, sumIf(amount, behavior_type = 'deal') - sumIf(amount, behavior_type = 'refund') AS net_income
FROM employee_behavior
WHERE event_time >= today()
GROUP BY employee_id
ORDER BY net_income DESC
LIMIT 10
UNION ALL
SELECT 'yesterday', employee_id, sumIf(amount, behavior_type = 'deal') - sumIf(amount, behavior_type = 'refund') AS net_income
FROM employee_behavior
WHERE event_time >= today() - INTERVAL 1 DAY AND event_time < today()
GROUP BY employee_id
ORDER BY net_income DESC
LIMIT 10
UNION ALL
SELECT 'day_before', employee_id, sumIf(amount, behavior_type = 'deal') - sumIf(amount, behavior_type = 'refund') AS net_income
FROM employee_behavior
WHERE event_time >= today() - INTERVAL 2 DAY AND event_time < today() - INTERVAL 1 DAY
GROUP BY employee_id
ORDER BY net_income DESC
LIMIT 10
UNION ALL
SELECT 'total', employee_id, sumIf(amount, behavior_type = 'deal') - sumIf(amount, behavior_type = 'refund') AS net_income
FROM employee_behavior
GROUP BY employee_id
ORDER BY net_income DESC
LIMIT 10
🧠 建议:
返回字段中用
tag
区分是哪一段;或者后端拆成 4 个 SQL,让前端接收结构更清晰;
或者前端用 tab 切换,每次查询不同时间段。
✅ 3. 如何查「我的排名 + 前10名」?
这个是排行榜系统中非常重要的功能:
➕ 查看前10名
➕ 查看自己当前排第几(哪怕你不在前10)
ClickHouse 从 23.3 起原生支持 rank()
和 row_number()
窗口函数!你可以这样:
💡 SQL 示例:查询前10名 + 我的排名(假设用户ID = 12345)
WITH leaderboard AS (
SELECT
employee_id,
sumIf(amount, behavior_type = 'deal') - sumIf(amount, behavior_type = 'refund') AS net_income,
rank() OVER (ORDER BY sumIf(amount, behavior_type = 'deal') - sumIf(amount, behavior_type = 'refund') DESC) AS rk
FROM employee_behavior
WHERE event_time >= today()
GROUP BY employee_id
)
SELECT *
FROM leaderboard
WHERE rk <= 10 OR employee_id = 12345
ORDER BY rk
这样你会获得:
employee_id | net_income | rk |
---|---|---|
10001 | 3421.00 | 1 |
... | ... | .. |
12345 | 340.00 | 48 |
✅ 不仅展示前10,也展示你自己第几名,哪怕你在第1000名。
🔧 小结:你现在的系统可以支持👇
功能 | 方法 |
---|---|
实时团队成交排行 | GROUP BY team_id 聚合 |
查询不同时间段排行榜 | 多个 SELECT 查询组合 |
展示我的名次 | 窗口函数 rank() over(...) |
自定义榜单粒度 | 用 WHERE 条件灵活控制 |
⚙️ Bonus:接口结构建议(REST API)
你可以设计一个统一接口,如:
GET /api/rankings?scope=today&type=employee&me=12345
参数 | 含义 |
---|---|
`scope=today | yesterday |
`type=employee | team` |
me=12345 | 是否返回自己的名次(选填) |
✅ 最终优势总结
项目 | ClickHouse 方案表现 |
---|---|
实时查询排行榜 | ✅ 毫无压力 |
每秒上千条写入 | ✅ OK(可分区/批量写) |
聚合性能 | ✅ 强大(适合复杂排名) |
前端实时体验 | ✅ 每5~10秒刷新即算“准实时”体验 |
高并发扩展 | ✅ 支持分布式部署 |
三、编写Flink程序
package com.aa.flinkkafkach;
import com.aa.flinkkafkach.model.EmployeeBehavior;
import com.aa.flinkkafkach.sink.ClickHouseSink;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.connector.kafka.source.KafkaSource;
import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
import org.apache.flink.connector.kafka.source.reader.deserializer.KafkaRecordDeserializationSchema;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import java.util.Map;
import java.util.Properties;
import org.yaml.snakeyaml.Yaml;
import java.io.InputStream;
import org.apache.kafka.common.serialization.StringDeserializer;
public class FlinkKafkaToClickHouseJob {
public static void main(String[] args) {
try {
// 从 application.yml 读取配置
Yaml yaml = new Yaml();
Map<String, Object> yamlMap;
try (InputStream input = FlinkKafkaToClickHouseJob.class.getClassLoader().getResourceAsStream("application.yml")) {
yamlMap = yaml.load(input);
}
Map<String, Object> flinkMap = (Map<String, Object>) yamlMap.get("flink");
Map<String, Object> kafkaMap = (Map<String, Object>) flinkMap.get("kafka");
Map<String, Object> clickhouseMap = (Map<String, Object>) flinkMap.get("clickhouse");
String kafkaBrokers = kafkaMap.get("brokers").toString();
String kafkaTopic = kafkaMap.get("topic").toString();
String clickhouseUrl = clickhouseMap.get("url").toString();
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.getConfig().disableClosureCleaner();
Properties kafkaProps = new Properties();
kafkaProps.setProperty("bootstrap.servers", kafkaBrokers);
kafkaProps.setProperty("group.id", "flink-clickhouse-group");
KafkaSource<String> kafkaSource = KafkaSource.<String>builder()
.setBootstrapServers(kafkaBrokers)
.setTopics(kafkaTopic)
.setGroupId("flink-clickhouse-group")
.setDeserializer(KafkaRecordDeserializationSchema.valueOnly(StringDeserializer.class))
.setStartingOffsets(OffsetsInitializer.latest())
.build();
env.fromSource(kafkaSource, WatermarkStrategy.noWatermarks(), "Kafka Source")
.map(json -> {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(json, EmployeeBehavior.class);
})
.addSink(new ClickHouseSink(clickhouseUrl));
env.execute("Kafka to ClickHouse Job");
} catch (Exception e) {
e.printStackTrace();
}
}
}
package com.aaa.flinkkafkach.sink;
import com.aaa.flinkkafkach.model.EmployeeBehavior;
import org.apache.flink.streaming.api.functions.sink.SinkFunction;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
public class ClickHouseSink implements SinkFunction<EmployeeBehavior> {
private transient Connection conn;
private transient PreparedStatement ps;
private final String jdbcUrl;
public ClickHouseSink(String jdbcUrl) {
this.jdbcUrl = jdbcUrl;
}
@Override
public void invoke(EmployeeBehavior value, Context context) throws Exception {
if (conn == null) {
conn = DriverManager.getConnection(jdbcUrl);
ps = conn.prepareStatement(
"INSERT INTO employee_behavior (event_time, employee_id, team_id, behavior_type, amount) VALUES (?, ?, ?, ?, ?)"
);
}
ps.setString(1, value.eventTime);
ps.setInt(2, value.employeeId);
ps.setInt(3, value.teamId);
ps.setString(4, value.behaviorType);
ps.setDouble(5, value.amount);
ps.executeUpdate();
}
public void close() throws Exception {
if (ps != null) ps.close();
if (conn != null) conn.close();
}
}
package com.aaa.flinkkafkach.model;
public class EmployeeBehavior {
public String eventTime;
public int employeeId;
public int teamId;
public String behaviorType;
public double amount;
public EmployeeBehavior() {}
// 可选 getter/setter
}
flink:
kafka:
brokers: localhost:9092
topic: user-events
clickhouse:
url: jdbc:clickhouse://localhost:8123/default
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>dateflink</groupId>
<artifactId>dateflink</artifactId>
<version>1.0-SNAPSHOT</version>
<name>dateflink</name>
<description>dateflink</description>
<properties>
<java.version>17</java.version>
<flink.version>1.17.2</flink.version>
<spring-boot.version>2.7.18</spring-boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>2.7.18</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
<version>2.7.18</version>
</dependency>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-streaming-java</artifactId>
<version>${flink.version}</version>
</dependency>
<!-- <dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-kafka</artifactId>
<version>${flink.version}</version>
</dependency> -->
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-kafka</artifactId>
<version>${flink.version}</version>
</dependency>
<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.4.6</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.13.4</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>1.33</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
四、验证消息生产消费到ClickHouse全流程
下面是完整的 Kafka → Flink → ClickHouse 测试验证流程,确保你能从前端发送数据,最后在 ClickHouse 查到结果。
✅ 一、确认前提组件都已运行
确保你通过 Docker 启动了:
-
Kafka(带 Zookeeper)
-
ClickHouse
-
Flink(JobManager & TaskManager)
这里我们直接用docker启动。 docker-compose.yml
如下
version: '3.8'
services:
zookeeper:
image: bitnami/zookeeper:latest
container_name: zookeeper
ports:
- "2181:2181"
environment:
- ALLOW_ANONYMOUS_LOGIN=yes
kafka:
image: wurstmeister/kafka:2.12-2.2.1
container_name: kafka
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
depends_on:
- zookeeper
volumes:
- /var/run/docker.sock:/var/run/docker.sock
flink-jobmanager:
image: flink:1.17.2-scala_2.12
container_name: flink-jobmanager
ports:
- "9081:9081" # Flink Web UI
command: jobmanager # 必须指定启动模式
environment:
- JOB_MANAGER_RPC_ADDRESS=flink-jobmanager
flink-taskmanager:
image: flink:1.17.2-scala_2.12
container_name: flink-taskmanager
depends_on:
- flink-jobmanager
command: taskmanager # 必须指定启动模式
environment:
- JOB_MANAGER_RPC_ADDRESS=flink-jobmanager
clickhouse-server:
image: yandex/clickhouse-server:latest
container_name: clickhouse-server
ports:
- "8123:8123"
- "9000:9000"
volumes:
- clickhouse_data:/var/lib/clickhouse
ulimits:
nofile:
soft: 262144
hard: 262144
volumes:
clickhouse_data:
✅ 二、ClickHouse 中建表
连接 ClickHouse 容器(或使用 DataGrip / DBeaver 等工具):
docker exec -it clickhouse-server clickhouse-client
执行建表语句:
CREATE TABLE employee_behavior
(
event_time DateTime DEFAULT now(),
employee_id UInt32,
team_id UInt32,
behavior_type Enum('forward' = 1, 'new_fan' = 2, 'view' = 3, 'deal' = 4, 'refund' = 5),
amount Float64 DEFAULT 0
)
ENGINE = MergeTree
ORDER BY (event_time, employee_id);
✅ 三、Kafka 创建 Topic
进入 Kafka 容器:
docker exec -it kafka bash
创建 topic(与 application.yml 中保持一致):
kafka-topics.sh --create --topic user-events --bootstrap-server localhost:9092 --partitions 1 --replication-factor 1
查看 topic 是否创建成功:
kafka-topics.sh --list --bootstrap-server localhost:9092
✅ 四、运行 Flink 程序
1. 启动 main 方法
你可以直接用 VS Code 或打包运行:
mvn clean package
java -jar target/xxx.jar
或直接在 IDE 里点 FlinkKafkaToClickHouseJob.main()
。
✅ 五、模拟发送消息到 Kafka(测试数据)
打开终端,使用 Kafka 自带命令行工具发送 JSON:
docker exec -it kafka bash
kafka-console-producer.sh --broker-list localhost:9092 --topic user-events
输入一条 JSON 消息(按 EmployeeBehavior
格式):
{"eventTime":"2025-07-03 19:00:00","employeeId":101,"teamId":5,"behaviorType":"deal","amount":399.99}
回车即可发送。
✅ 六、验证 ClickHouse 中是否有数据
在 ClickHouse 中查询:
SELECT * FROM employee_behavior ORDER BY event_time DESC;
✅ 补充建议(可选)
你也可以使用前端或 Postman 等工具写一个接口,把消息 POST 给 Kafka:
POST http://localhost:8080/produce
{
"eventTime": "2025-07-03T19:00:00",
"employeeId": 101,
"teamId": 5,
"behaviorType": "deal",
"amount": 399.99
}