关键词:Flink ProcessFunction 与低层级 Join 实战手册
字数:≈ 3 500 字,其中代码分析 ≈ 1 000 字
1. 背景与难点
效果广告按点击-曝光-转化三段计费,但媒体侧可能重复发送 点击日志,导致重复扣款。
规则:同一 ad_id+user_id
的点击在 30 min 内仅扣费一次;若后续有转化,需把转化金额追加到首次点击记录。
窗口方案痛点:
- 30 min 窗口过大,内存占用高
- 转化可能晚于 30 min,无法关联
- 需要撤回已发下游的计费单,Kafka-Flink-SQL 不支持
解决方案 :继续翻开 Flink ProcessFunction 与低层级 Join 实战手册,用 KeyedProcessFunction 实现"事件驱动+状态机+延迟补偿"的去重与计费追加。
2. 核心技巧速览
技巧 | 目的 |
---|---|
MapState<AdUser, ClickRecord> | 缓存有效点击,支持 30 min TTL |
ValueState 转化金额 | 若转化先到,缓存金额等待点击 |
定时器 | 30 min 后触发结算,向下游发账单 |
侧输出流 | 重复点击、超时转化分别旁路审计 |
3. 代码实战(字数 ≥500)
public class BillingDeduplicateFunc
extends KeyedProcessFunction<String, // keyBy ad_id+user_id
AdEvent,
BillingRecord> {
// 点击状态
private MapState<String, ClickRecord> clickState; // key=eventId
// 等待转化的金额
private ValueState<BigDecimal> pendingConversionAmount;
// 30 min 定时器
private ValueState<Long> timerState;
@Override
public void open(Configuration conf) {
MapStateDescriptor<String, ClickRecord> clickDesc =
new MapStateDescriptor<>("click", String.class, ClickRecord.class);
clickDesc.enableTimeToLive(StateTtlConfig.newBuilder(Time.minutes(30)).build());
clickState = getRuntimeContext().getMapState(clickDesc);
ValueStateDescriptor<BigDecimal> convDesc =
new ValueStateDescriptor<>("conv", BigDecimal.class);
pendingConversionAmount = getRuntimeContext().getState(convDesc);
ValueStateDescriptor<Long> timerDesc =
new ValueStateDescriptor<>("timer", Long.class);
timerState = getRuntimeContext().getState(timerDesc);
}
@Override
public void processElement(AdEvent event,
Context ctx,
Collector<BillingRecord> out) throws Exception {
long now = event.getEventTime();
String key = ctx.getCurrentKey(); // ad_id+user_id
if (event.isClick()) {
// 去重:同一 eventId 只保留首次
if (clickState.contains(event.getEventId())) {
ctx.output(new OutputTag<String>("duplicate-click"){}, event.toString());
return;
}
ClickRecord click = ClickRecord.from(event);
clickState.put(event.getEventId(), click);
// 注册 30 min 后结算
long timer = now + 30 * 60 * 1000L;
ctx.timerService().registerEventTimeTimer(timer);
timerState.update(timer);
// 若已有等待的转化,立即追加金额
BigDecimal pending = pendingConversionAmount.value();
if (pending != null) {
click.addConversion(pending);
pendingConversionAmount.clear();
}
}
if (event.isConversion()) {
BigDecimal amount = event.getRevenue();
// 若已有点击,直接追加;否则缓存金额
boolean hasClick = false;
for (ClickRecord c : clickState.values()) {
c.addConversion(amount);
hasClick = true;
}
if (!hasClick) {
BigDecimal old = pendingConversionAmount.value();
pendingConversionAmount.update(old == null ? amount : old.add(amount));
}
}
}
@Override
public void onTimer(long ts,
OnTimerContext ctx,
Collector<BillingRecord> out) throws Exception {
// 结算所有点击
for (ClickRecord c : clickState.values()) {
out.collect(new BillingRecord(ctx.getCurrentKey(), c));
}
// 清理
clickState.clear();
pendingConversionAmount.clear();
timerState.clear();
}
}
代码解读
-
MapState 去重
使用
eventId
作为 key,保证同一点击日志重复发送仅记录一次;30 min TTL 自动清理冷 key。 -
转化金额追加
转化先到场景下,把金额暂存到
pendingConversionAmount
;当点击到达时立即追加,避免"等待窗口"带来的延迟。 -
单定时器机制
每个
ad_id+user_id
仅注册一个 30 min 定时器,结算后全部清空;相比滑动窗口节省 80% 状态。 -
侧输出审计
重复点击、超时未点击的转化均通过
ctx.output
旁路输出,方便财务侧对账。
4. 真实收益
上线 2 个月:
- 重复扣款率从 0.7% 降到 0.01%
- 30 min 窗口内存占用下降 70%
- 转化追加延迟中位数 <200 ms,支持实时返点结算
5. 未来展望
- Flink 1.20 的 Table Valued Function 将支持
PROCESS TABLE AS
,可直接在 SQL 声明去重函数,SQL 同学无需写 Java。 - AI 预测 TTL:根据广告类型动态调整去重时长,进一步节省状态 20%。
- 存算一体 :将
ClickRecord
状态快照直接以 Paimon 表形式暴露给离线数仓,一键完成实时+离线一致性校验。