文章目录
- 需求背景
- 数仓分层
-
- ODS:原始数据层
- DWD:明细层
- DWM:中间聚合层
-
- [PV/Click 分钟预聚合](#PV/Click 分钟预聚合)
- [UV 去重中间态](#UV 去重中间态)
- DWS:汇总层
- ADS:应用数据服务层
- 总结
需求背景
在电商 SaaS 业务中,平台通常会为商家提供各类营销活动投放能力,例如生成活动链接、推广链接,并通过站内外渠道进行分发。终端用户通过点击这些链接进入活动页面,从而产生访问、浏览等行为数据。
随着业务规模的扩大,平台与商家对活动效果的实时感知能力提出了更高要求,主要体现在以下几个方面:
- 商家希望能够实时查看活动链接的访问情况,例如当前 PV、UV、DAU 变化趋势
- 运营人员需要在活动进行过程中,及时判断投放效果是否达标,并快速做出调整
- 平台侧需要对异常流量、投放效果波动进行实时监控与预警
在传统的数据处理模式中,用户行为数据通常通过离线数仓进行统计分析,数据延迟往往在 T+1 甚至更长。这种方式在日常分析场景下尚可接受,但在电商活动投放这种强时效性场景中已明显无法满足需求:
- 无法实时感知活动效果
- 无法支撑活动过程中的快速决策
- 无法及时发现和定位异常问题
因此,我们需要构建一套低延迟、高吞吐、可扩展的实时数仓体系,对电商活动链接的点击与访问行为进行实时采集与分析,支撑核心指标(如 PV、UV、DAU)的秒级或分钟级统计,为业务运营和商家决策提供实时数据支撑。
基于上述背景,本文设计并实现了一套基于 Kafka + Flink + ClickHouse 的电商活动链接点击行为实时数仓方案,用于对用户访问行为进行实时计算与分析。
整体采用 Kafka 作为实时数据总线、Flink 作为实时计算引擎、ClickHouse 作为实时 OLAP 存储,并按照 ODS → DWD → DWM → DWS → ADS 的分层思想组织数据流转与产出,保证口径稳定、数据可追溯、链路可扩展。
在该体系中,Flink 主要承担 DWD / DWM / DWS 三层的计算职责;ODS 属于"实时采集与缓冲层"(采集端 + Kafka),ADS 属于"面向查询的服务层"(ClickHouse)。这种职责拆分可以有效降低计算引擎与采集、存储系统之间的耦合度。
数仓分层
当用户点击活动链接并进入页面时,会触发前端埋点(或服务端日志)生成行为事件,例如 page_view、click、stay 等。事件通过 SDK/网关统一上报进入 Kafka Topic。Flink 消费 Kafka 流,完成清洗、标准化、去重、聚合,并将明细与指标结果分别写入 ClickHouse,最终由运营后台/商家看板进行查询展示。
ODS:原始数据"原封不动"进来,保留可追溯性(通常在 Kafka)
DWD:清洗、规范化、补全维度字段,形成"干净的明细事实"
DWM:在明细基础上做"轻度加工/中间结果",如会话归因、去重中间态、拉宽
DWS:面向主题的汇总层,产出可复用的指标(按分钟/小时/活动)
ADS:面向应用的最终结果表,直接给 BI/看板/接口用(ClickHouse)
| 分层 | 载体 | 核心职责 | 产出示例 |
|---|---|---|---|
| ODS | Kafka | 原始事件落地,追溯回放 | ods_user_behavior |
| DWD | Kafka | 清洗、标准化、统一口径 | dwd_user_behavior |
| DWM | Kafka | 中间事实/预聚合/去重中间态 | dwm_uv_mark / dwm_pv_click_minute |
| DWS | Kafka | 主题域指标汇总宽表 | dws_activity_kpi_minute |
| ADS | ClickHouse | 面向查询/看板的服务层 | ads_activity_kpi_minute |
数据流简图:
用户埋点
↓
Kafka(日志队列)
↓
Flink(实时计算)
↓
ClickHouse(实时数仓)
↓
BI / 看板 / 实时监控
ODS:原始数据层
ODS 在实时链路里它通常就是"埋点入口 → Kafka 原始 topic。而"埋点入口"在很多电商系统里确实是 Controller/网关接口(或 SDK 直连采集服务)负责接收事件,然后把原始事件写进 Kafka------这一步就可以视为 ODS 写入。
关键原则:ODS 不做复杂清洗,最多做"必要校验 + 保护性补全",其余放到 DWD,伪代码如下:
java
@RestController
@RequestMapping("/track")
public class TrackController {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
private static final String ODS_TOPIC = "ods_user_behavior";
@PostMapping("/event")
public String track(@RequestBody TrackEventDTO dto) {
kafkaTemplate.send(ODS_TOPIC, dto.getUserId(), dto.toString());
return "ok";
}
}
DWD:明细层
DWD(Data Warehouse Detail)明细层负责把 ODS 的"原始埋点"加工成"可复用、口径统一的行为明细"。在电商活动场景里,ODS 里的数据往往存在:字段缺失、格式不统一、channel 不规范、userId 为空(匿名用户)等问题。如果让每个下游都各自清洗,不仅重复建设,还容易出现口径不一致。
ODS 由采集服务(Controller)把原始埋点写入 ods_user_behavior;DWD 由 Flink 作业消费 ODS,完成清洗、口径统一与字段标准化,并输出到 dwd_user_behavior 作为后续 DWM/DWS 的统一明细输入。
DWD 的输出写到独立 Kafka Topic:dwd_user_behavior,供 DWM/DWS 多下游复用,伪代码如下:
java
public class DwdJobDemo {
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(60_000);
Properties props = new Properties();
props.setProperty("bootstrap.servers", "kafka:9092");
props.setProperty("group.id", "flink-dwd-demo");
// 1) ODS:读 Kafka 原始日志
FlinkKafkaConsumer<String> source = new FlinkKafkaConsumer<>(
"ods_user_behavior",
new SimpleStringSchema(),
props
);
source.setStartFromLatest();
// 2) DWD:清洗 + 统一口径(输出仍为 JSON 字符串)
SingleOutputStreamOperator<DwdEvent> dwd = env
.addSource(source)
.flatMap((String json, Collector<DwdEvent> out) -> {
try {
JsonNode n = MAPPER.readTree(json);
String event = text(n, "event");
String activityId = text(n, "activityId");
long ts = n.path("ts").asLong(0);
// 极简校验:关键字段必须有
if (event == null || activityId == null || ts <= 0) return;
if (!("page_view".equals(event) || "click".equals(event))) return;
String userId = text(n, "userId");
String deviceId = text(n, "deviceId");
String uid = (userId != null && !userId.isBlank()) ? userId : deviceId;
if (uid == null || uid.isBlank()) return;
String channel = text(n, "channel");
channel = normalizeChannel(channel);
// 产出 DWD 明细
DwdEvent e = new DwdEvent();
e.event = event;
e.activityId = activityId;
e.uid = uid;
e.channel = channel;
e.ts = ts;
out.collect(e);
} catch (Exception ignored) {}
})
.returns(DwdEvent.class)
.assignTimestampsAndWatermarks(
WatermarkStrategy.<DwdEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((e, t) -> e.ts)
);
// 3) 写回 Kafka:dwd_user_behavior
FlinkKafkaProducer<String> sink = new FlinkKafkaProducer<>(
"dwd_user_behavior",
new SimpleStringSchema(),
props
);
dwd
.map(e -> MAPPER.writeValueAsString(e))
.addSink(sink);
env.execute("DWD Demo Job");
}
}
DWM:中间聚合层
DWM(Data Warehouse Middle)中间层承接 DWD 的"干净明细",做轻量加工,产出可复用的中间事实表。
PV/Click 分钟预聚合
在活动效果分析中,PV/Click 属于最基础、最常用的指标。如果让 DWS 每次都从明细层(DWD)全量扫一遍事件再聚合,会造成重复计算。因此在 DWM 层先做 分钟级预聚合,产出 dwm_pv_click_minute,供 DWS 直接消费汇总,降低计算成本并提升链路清晰度。
伪代码如下:
java
public class DwmPvClickJobDemo {
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(60_000);
Properties props = new Properties();
props.setProperty("bootstrap.servers", "kafka:9092");
props.setProperty("group.id", "flink-dwm-pvclick-demo");
// 1) 读 DWD 明细
FlinkKafkaConsumer<String> source = new FlinkKafkaConsumer<>(
"dwd_user_behavior",
new SimpleStringSchema(),
props
);
source.setStartFromLatest();
SingleOutputStreamOperator<DwdEvent> dwd = env
.addSource(source)
.map(json -> MAPPER.readValue(json, DwdEvent.class))
.returns(DwdEvent.class)
.assignTimestampsAndWatermarks(
WatermarkStrategy.<DwdEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((e, t) -> e.ts)
);
// 2) 分钟窗口聚合:PV/Click
SingleOutputStreamOperator<PvClickMinute> pvClickMinute = dwd
.keyBy(e -> e.activityId + "|" + e.channel)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.process(new ProcessWindowFunction<DwdEvent, PvClickMinute, String, TimeWindow>() {
@Override
public void process(String key, Context ctx, Iterable<DwdEvent> elements, Collector<PvClickMinute> out) {
long pv = 0, click = 0;
for (DwdEvent e : elements) {
if ("page_view".equals(e.event)) pv++;
else if ("click".equals(e.event)) click++;
}
String[] p = key.split("\\|");
PvClickMinute r = new PvClickMinute();
r.activityId = p[0];
r.channel = p[1];
r.minuteStart = ctx.window().getStart();
r.pv = pv;
r.click = click;
out.collect(r);
}
})
.name("DWM-PV-CLICK-MINUTE");
// 3) 写 Kafka:dwm_pv_click_minute
FlinkKafkaProducer<String> sink = new FlinkKafkaProducer<>(
"dwm_pv_click_minute",
new SimpleStringSchema(),
props
);
pvClickMinute
.map(x -> MAPPER.writeValueAsString(x))
.addSink(sink)
.name("SINK-DWM-PVCLICK");
env.execute("DWM PV/Click Minute Demo Job");
}
}
UV 去重中间态
UV 是最典型的中间层产物:如果直接在 DWS/ADS 里做 count distinct(uid),流式计算会带来较大的状态压力,且乱序/迟到处理更复杂。因此我们把 UV 的"去重中间态"下沉到 DWM:在同一分钟内,同一 uid 在同一 activityId + channel 维度只输出一次,形成 UV=1 的标记数据流。
java
public class DwmUvJobDemo {
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(60_000);
Properties props = new Properties();
props.setProperty("bootstrap.servers", "kafka:9092");
props.setProperty("group.id", "flink-dwm-demo");
// 1) 读 DWD 明细
FlinkKafkaConsumer<String> source = new FlinkKafkaConsumer<>(
"dwd_user_behavior",
new SimpleStringSchema(),
props
);
source.setStartFromLatest();
SingleOutputStreamOperator<DwdEvent> dwd = env
.addSource(source)
.map(json -> MAPPER.readValue(json, DwdEvent.class))
.returns(DwdEvent.class)
.assignTimestampsAndWatermarks(
WatermarkStrategy.<DwdEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((e, t) -> e.ts)
);
// 2) DWM:一分钟内对 uid 去重,输出 UV mark
SingleOutputStreamOperator<UvMark> uvMark = dwd
.filter(e -> "page_view".equals(e.event)) // UV 口径:按浏览统计
.keyBy(e -> {
long minuteStart = (e.ts / 60_000) * 60_000;
// key = 活动 + 渠道 + 分钟 + 用户
return e.activityId + "|" + e.channel + "|" + minuteStart + "|" + e.uid;
})
.process(new MinuteUvDedup())
.name("DWM-UV-MARK");
// 3) 写 Kafka:dwm_uv_mark
FlinkKafkaProducer<String> sink = new FlinkKafkaProducer<>(
"dwm_uv_mark",
new SimpleStringSchema(),
props
);
uvMark.map(m -> MAPPER.writeValueAsString(m)).addSink(sink);
env.execute("DWM UV Demo Job");
}
// ---------- 去重算子:同一分钟同 uid 只输出一次 ----------
public static class MinuteUvDedup extends KeyedProcessFunction<String, DwdEvent, UvMark> {
private transient ValueState<Boolean> seen;
@Override
public void open(org.apache.flink.configuration.Configuration parameters) {
seen = getRuntimeContext().getState(new ValueStateDescriptor<>("seen", Boolean.class));
}
@Override
public void processElement(DwdEvent e, Context ctx, Collector<UvMark> out) throws Exception {
if (Boolean.TRUE.equals(seen.value())) return;
seen.update(true);
long minuteStart = (e.ts / 60_000) * 60_000;
long minuteEnd = minuteStart + 60_000;
out.collect(new UvMark(e.activityId, e.channel, minuteStart, minuteEnd, e.uid));
// 窗口结束后清理状态,防止状态无限增长
ctx.timerService().registerEventTimeTimer(minuteEnd + 1);
}
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector<UvMark> out) throws Exception {
seen.clear();
}
}
}
DWS:汇总层
DWS 以 DWM 的中间结果为唯一输入,做主题域指标汇总与拼接:DWS 以 DWM 的中间结果为唯一输入,不再直接处理明细或做复杂去重,而是对已标准化的中间事实进行主题域指标汇总与拼接:
从 dwm_pv_click_minute 获取分钟 PV/Click
从 dwm_uv_mark 聚合得到分钟 UV
最终合并为 dws_activity_kpi_minute(activityId, channel, minuteStart, pv, uv,click),下游 ADS 可直接写 ClickHouse 或供看板查询。
伪代码如下:
java
public class DwsFromDwmJobDemo {
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(60_000);
Properties props = new Properties();
props.setProperty("bootstrap.servers", "kafka:9092");
props.setProperty("group.id", "flink-dws-from-dwm-demo");
// 1) 读 DWM:分钟 PV/Click
FlinkKafkaConsumer<String> pvClickSource =
new FlinkKafkaConsumer<>("dwm_pv_click_minute", new SimpleStringSchema(), props);
pvClickSource.setStartFromLatest();
SingleOutputStreamOperator<PvClickMinute> pvClick = env
.addSource(pvClickSource)
.map(json -> MAPPER.readValue(json, PvClickMinute.class))
.returns(PvClickMinute.class)
.assignTimestampsAndWatermarks(
WatermarkStrategy.<PvClickMinute>forBoundedOutOfOrderness(Duration.ofSeconds(2))
.withTimestampAssigner((e, t) -> e.minuteStart)
);
// 2) 读 DWM:UV mark(需要在 DWS 内聚合成 UV)
FlinkKafkaConsumer<String> uvMarkSource =
new FlinkKafkaConsumer<>("dwm_uv_mark", new SimpleStringSchema(), props);
uvMarkSource.setStartFromLatest();
SingleOutputStreamOperator<UvMark> uvMark = env
.addSource(uvMarkSource)
.map(json -> MAPPER.readValue(json, UvMark.class))
.returns(UvMark.class)
.assignTimestampsAndWatermarks(
WatermarkStrategy.<UvMark>forBoundedOutOfOrderness(Duration.ofSeconds(2))
.withTimestampAssigner((e, t) -> e.minuteStart)
);
// 3) UV mark -> 分钟 UV 聚合(count)
SingleOutputStreamOperator<UvMinute> uvMinute = uvMark
.keyBy(m -> m.activityId + "|" + m.channel)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.process(new ProcessWindowFunction<UvMark, UvMinute, String, TimeWindow>() {
@Override
public void process(String key, Context ctx, Iterable<UvMark> elements, Collector<UvMinute> out) {
long uv = 0;
for (UvMark ignored : elements) uv++;
String[] p = key.split("\\|");
UvMinute r = new UvMinute();
r.activityId = p[0];
r.channel = p[1];
r.minuteStart = ctx.window().getStart();
r.uv = uv;
out.collect(r);
}
});
// 4) 把 pvClick 和 uvMinute 合并成 DWS 宽表(按 activityId+channel+minuteStart)
SingleOutputStreamOperator<DwsKpiMinute> dws = pvClick
.map(p -> Partial.fromPvClick(p)).returns(Partial.class)
.union(uvMinute.map(u -> Partial.fromUv(u)).returns(Partial.class))
.keyBy(x -> x.activityId + "|" + x.channel + "|" + x.minuteStart)
.process(new MergeWide())
.name("DWS-MERGE");
// 5) 输出 DWS topic
FlinkKafkaProducer<String> sink =
new FlinkKafkaProducer<>("dws_activity_kpi_minute", new SimpleStringSchema(), props);
dws.map(x -> MAPPER.writeValueAsString(x)).addSink(sink);
env.execute("DWS From DWM Demo Job");
}
// -------- 合并:部分字段 -> 宽表 --------
public static class MergeWide extends KeyedProcessFunction<String, Partial, DwsKpiMinute> {
private transient ValueState<DwsKpiMinute> state;
@Override
public void open(org.apache.flink.configuration.Configuration parameters) {
state = getRuntimeContext().getState(new ValueStateDescriptor<>("wide", DwsKpiMinute.class));
}
@Override
public void processElement(Partial in, Context ctx, Collector<DwsKpiMinute> out) throws Exception {
DwsKpiMinute cur = state.value();
if (cur == null) {
cur = new DwsKpiMinute();
cur.activityId = in.activityId;
cur.channel = in.channel;
cur.minuteStart = in.minuteStart;
}
cur.pv += in.pv;
cur.click += in.click;
cur.uv += in.uv;
state.update(cur);
// 分钟结束后输出
long fireTs = in.minuteStart + 60_000 + 2_000;
ctx.timerService().registerEventTimeTimer(fireTs);
}
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector<DwsKpiMinute> out) throws Exception {
DwsKpiMinute cur = state.value();
if (cur != null) {
out.collect(cur);
state.clear();
}
}
}
}
ADS:应用数据服务层
ADS(Application Data Service)应用层是面向业务消费的最终数据层,强调"可用、稳定、高性能"。在实时数仓中,ADS 一般承接 DWS 的主题指标,将其落到面向分析查询的存储(如 ClickHouse),并根据查询场景设计分区与排序键,保证大促期间高并发下仍能秒级响应。
CK 表结构大致如下:
sql
CREATE TABLE IF NOT EXISTS ads_activity_kpi_minute (
activity_id String,
channel String,
minute_start DateTime,
pv UInt64,
uv UInt64,
click UInt64
)
ENGINE = MergeTree
PARTITION BY toDate(minute_start)
ORDER BY (activity_id, channel, minute_start);
伪代码如下:
java
public class AdsToClickHouseDemo {
static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(60_000);
Properties props = new Properties();
props.setProperty("bootstrap.servers", "kafka:9092");
props.setProperty("group.id", "flink-ads-demo");
// 1) 读 DWS 指标
FlinkKafkaConsumer<String> source =
new FlinkKafkaConsumer<>("dws_activity_kpi_minute", new SimpleStringSchema(), props);
source.setStartFromLatest();
var dws = env.addSource(source)
.map(json -> MAPPER.readValue(json, DwsKpiMinute.class))
.returns(DwsKpiMinute.class)
// 这层其实不需要严格 watermark,随便加一个 demo 用
.assignTimestampsAndWatermarks(
WatermarkStrategy.<DwsKpiMinute>forBoundedOutOfOrderness(Duration.ofSeconds(2))
.withTimestampAssigner((e, t) -> e.minuteStart)
);
// 2) ClickHouse JDBC Sink(插入 ADS 表)
var ckSink = JdbcSink.sink(
"INSERT INTO ads_activity_kpi_minute(activity_id, channel, minute_start, pv, uv, click) VALUES(?,?,?,?,?,?)",
(PreparedStatement ps, DwsKpiMinute k) -> {
ps.setString(1, k.activityId);
ps.setString(2, k.channel);
ps.setTimestamp(3, new java.sql.Timestamp(k.minuteStart));
ps.setLong(4, k.pv);
ps.setLong(5, k.uv);
ps.setLong(6, k.click);
},
JdbcExecutionOptions.builder()
.withBatchSize(1000)
.withBatchIntervalMs(1000)
.withMaxRetries(3)
.build(),
new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
.withUrl("jdbc:clickhouse://clickhouse:8123/default")
.withDriverName("com.clickhouse.jdbc.ClickHouseDriver")
.build()
);
dws.addSink(ckSink).name("ADS-CLICKHOUSE");
env.execute("ADS to ClickHouse Demo");
}
// DWS 输出结构(和你 DWS 一致)
public static class DwsKpiMinute {
public String activityId;
public String channel;
public long minuteStart;
public long pv;
public long uv;
public long click;
}
}
DWS 输出的主题指标是"计算结果",ADS 是"可查询的服务层";通过 ClickHouse 的 MergeTree(分区 + 排序)把实时指标固化下来,支撑大促看板的秒级查询与高并发读取。
总结
| 层级 | 核心问题 | 主要作用 | 是否做计算 |
|---|---|---|---|
| ODS | 发生了什么? | 原始落地、可追溯 | ❌ |
| DWD | 这是什么行为? | 清洗、统一口径 | ⚠️ 轻 |
| DWM | 能不能提前算? | 中间事实、预聚合 | ✅ |
| DWS | 指标是多少? | 主题指标汇总 | ✅ |
| ADS | 怎么给人用? | 查询 / 服务 / 看板 | ❌ |
- ODS:Controller/SDK → Kafka(ods_user_behavior)
- DWD:ods_user_behavior → 清洗统一 → Kafka(dwd_user_behavior)
- DWM:
- dwd_user_behavior → UV 去重 → Kafka(dwm_uv_mark)
- dwd_user_behavior → PV/Click 预聚合 → Kafka(dwm_pv_click_minute)
- DWS:只读 DWM → 合并输出 → Kafka(dws_activity_kpi_minute)
- ADS:dws_activity_kpi_minute → ClickHouse(ads_activity_kpi_minute)