文章目录
- [一、RabbitMQ Source:为什么只能 at-least-once?](#一、RabbitMQ Source:为什么只能 at-least-once?)
- [二、Flink Checkpoint 恢复:dup 是如何产生的?](#二、Flink Checkpoint 恢复:dup 是如何产生的?)
- [三、为什么 dup 在流处理中是"不可避免"的?](#三、为什么 dup 在流处理中是“不可避免”的?)
- [四、解决思路:基于 seq 的幂等去重](#四、解决思路:基于 seq 的幂等去重)
- [五、为什么强调「采集侧」生成 seq?](#五、为什么强调「采集侧」生成 seq?)
- [六、Flink 中如何基于 seq 去重?](#六、Flink 中如何基于 seq 去重?)
- 七、这个方案为什么非常稳?
- [八、真实业务场景:无人机 / 设备轨迹](#八、真实业务场景:无人机 / 设备轨迹)
- 九、总结
在使用 RabbitMQ 作为 Flink Source 进行实时流处理时,很多人都会遇到一个问题:
为什么已经处理过的数据,在任务重启后又被处理了一次?这到底是 Bug,还是设计使然?又该如何优雅解决?
本文从 Flink 容错机制 出发,解释 dup(重复数据)为什么不可避免 ,以及一个工业级、低成本、极其稳定的解决方案:基于 seq 的去重。
一、RabbitMQ Source:为什么只能 at-least-once?
在 Flink 中,大多数 RabbitMQ Source 的语义是 at-least-once,而不是 exactly-once。
at-least-once 的含义是:
👉 每条消息至少会被处理一次
👉 但有可能被处理多次
原因在于:
- RabbitMQ 本身并不原生支持与 Flink checkpoint 深度绑定的事务语义
- Source 在 checkpoint 未完成前,消息已经被消费并处理
结论:
❗ 使用 RabbitMQ Source,本身就要接受「可能重复」这个现实
二、Flink Checkpoint 恢复:dup 是如何产生的?
Flink 的容错机制基于 Checkpoint:
- Flink 定期对算子状态做 checkpoint
- 任务异常 / 重启
- 从最近一次成功的 checkpoint 恢复
- 重放 checkpoint 之后的一部分数据
这一步非常关键。
举个例子
- Checkpoint 成功时:消费到 offset / message = 100
- 实际运行时:已经处理到 105
- 任务崩溃
- 恢复后:从 100 重新消费
那么:
text
101、102、103、104、105 会被再次处理
👉 这部分数据就成了重复数据(dup)
⚠️ 这是 Flink 的正常行为,不是 Bug
三、为什么 dup 在流处理中是"不可避免"的?
即使你:
- 配置了 checkpoint
- 配置了 state backend
- 消费逻辑完全正确
只要是:
- at-least-once Source
- 有失败恢复
👉 dup 就一定会出现
所以正确的工程思路不是「避免 dup」,而是:
接受 dup,然后在业务层消化 dup
四、解决思路:基于 seq 的幂等去重
如果你的数据里已经有 seq(序号)字段,那事情会变得非常简单。
核心思想
每个实体(Entity)维护一个严格递增的 seq
不是全局一个 seq,而是:
text
entity_id = A : seq = 1,2,3,4...
entity_id = B : seq = 1,2,3,4...
只要满足:
- 同一实体内 seq 严格递增
- 不回退
- 不重复
那么 dup 问题就可以彻底解决
五、为什么强调「采集侧」生成 seq?
因为:
- Flink 是下游
- 一旦数据进入 MQ
- 下游无法判断「这是新数据,还是历史数据」
而采集侧(设备 / SDK / 服务端):
- 最清楚数据的真实顺序
- 最适合生成业务语义上的 seq
👉 这是一个经典的"上游负责唯一性,下游负责幂等"的设计
六、Flink 中如何基于 seq 去重?
处理逻辑(按实体维度)
text
keyBy(entity_id)
→ 维护 last_seq
→ 如果 seq <= last_seq:丢弃
→ 如果 seq > last_seq:处理并更新状态
示例代码(Java)
java
.keyBy(Event::getEntityId)
.process(new KeyedProcessFunction<String, Event, Event>() {
private ValueState<Long> lastSeq;
@Override
public void open(Configuration parameters) {
lastSeq = getRuntimeContext().getState(
new ValueStateDescriptor<>("lastSeq", Long.class)
);
}
@Override
public void processElement(Event e, Context ctx, Collector<Event> out) throws Exception {
Long last = lastSeq.value();
if (last == null || e.getSeq() > last) {
lastSeq.update(e.getSeq());
out.collect(e);
}
// else: 重复数据,直接丢弃
}
});
七、这个方案为什么非常稳?
这个方案有几个非常重要的工程优势:
✅ 不依赖 MQ 是否 exactly-once
✅ 不依赖 checkpoint 是否精确
✅ 不怕 Flink 重启
✅ 不怕 replay
✅ 不怕消息乱序
✅ 逻辑简单,性能高
✅ 状态小,易于扩展
👉 这是很多实时系统(轨迹、IoT、风控、日志)的标准做法
八、真实业务场景:无人机 / 设备轨迹
例如无人机轨迹上报:
json
{
"drone_id": "UAV_001",
"seq": 1024,
"lat": 39.9,
"lon": 116.3,
"ts": 1700000000
}
- 同一架无人机:seq 严格递增
- Flink 按
drone_id维度维护 last_seq - 重放的数据会被自然丢弃
轨迹不会抖、不回退、不重复绘制
九、总结
RabbitMQ + Flink 的重复数据是设计必然
真正稳定的解决方案,是在采集侧生成"每实体严格递增的 seq",
然后在 Flink 中基于 seq 做幂等去重。