订阅更新

采集到的数据有重复——Exactly-Once到底能不能保证?

数据采集手册 第 17 题 约 12 分钟读完

本文目录
  1. 先说结论
  2. 业务场景:下游看到的重复数据
  3. 方案一:端到端事务(技术上最严格)
  4. 方案二:幂等写入(生产最常用)
  5. 方案三:去重机制(基于唯一ID)
  6. 三方案对比
  7. 面试高频追问
  8. 实战踩坑:Flink Sink用异步Flush导致重复
  9. 总结

先说结论

严格意义的Exactly-Once很难纯技术实现:生产上99%的场景用的是"At-Least-Once + 下游幂等 = 等效Exactly-Once"。

三种实现路径:① 端到端事务(2PC/Kafka事务);② 幂等写入(主键+版本号);③ 去重机制(唯一ID+状态存储)。

最实用的方案:源端打全局唯一event_id + 下游主键upsert + 定时对账。简单、稳、通用。

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

"我们系统是Exactly-Once的"是大数据面试里最容易被追问的承诺——它听起来简单,但实际落地很复杂。采集链路动不动就出现重复数据(重试、故障恢复、作业重启),到底能不能做到Exactly-Once?面试官想看的不是背概念,而是你能不能讲清楚Exactly-Once的三种实现路径,以及生产上最实用的是哪一种。

业务场景:下游看到的重复数据

接入一个业务CDC时,业务方反馈"同一笔订单在数仓出现了3次"。排查发现不是BUG而是"预期的重复"——Flink CDC在一次作业重启后从Checkpoint恢复,那次Checkpoint还没持久化,所以重新消费了最近5分钟的Kafka消息,下游MERGE INTO时没做幂等,同一笔订单被写入了3次(create→update→update)。

重复数据的常见来源:
  1) 生产者重试:网络抖动时Producer.send重试,导致Kafka里有重复消息
     (Kafka 0.11+可以开idempotent producer避免,但仅限单会话)
 
  2) 消费者重启:Checkpoint/Offset未提交,重启后重新消费
 
  3) 作业升级:Savepoint恢复时回退几分钟数据
 
  4) 跨系统同步:A系统→Kafka→B系统,任何一环失败重试都产生重复
 
  5) 人工操作: 补数据/重跑调度
 
  结论: 重复是分布式系统的常态,必须靠幂等吸收

方案一:端到端事务(技术上最严格)

最严格的Exactly-Once是"跨多个系统的分布式事务"——Flink Checkpoint + Kafka事务 + Sink支持事务写入,整条链路用2PC绑定。理论完美,但实现复杂、性能损失大。

// Flink + Kafka 事务性Sink
FlinkKafkaProducer<String> kafkaProducer = new FlinkKafkaProducer<>(
    "output-topic",
    new SimpleStringSchema(),
    props,
    FlinkKafkaProducer.Semantic.EXACTLY_ONCE  // 核心: 事务语义
);
 
// 配合要求:
// 1) transaction.timeout.ms > flink checkpoint interval
props.setProperty("transaction.timeout.ms", "900000");  // 15分钟
 
// 2) Flink Checkpoint开启
env.enableCheckpointing(60_000);
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
 
// 3) 下游消费者必须配 isolation.level = read_committed
// 否则会读到未提交的事务数据
consumerProps.put("isolation.level", "read_committed");
 
// 工作原理: 2PC两阶段提交
// Phase-1: checkpoint barrier到达时,Sink开启Kafka事务写入
// Phase-2: checkpoint完成后,Sink commit事务 → 下游才能读到
// 如果checkpoint失败,事务abort → 数据不会被下游消费

适用场景:Flink→Kafka→Flink链路、所有环节都支持事务、对一致性要求极高(金融清结算)。

不适用:下游是MySQL/Redis等不支持Kafka事务的Sink;性能敏感场景(事务开销约20-30%)。

方案二:幂等写入(生产最常用)

99%的业务场景其实不需要严格Exactly-Once,用"At-Least-Once + 下游幂等"就够了。核心是保证"重复写入不产生重复结果"。

-- ✓ 方案: 主键upsert保证幂等
-- 示例1: Delta Lake MERGE INTO
MERGE INTO dwd_orders t
USING stream_orders s ON t.order_id = s.order_id
WHEN MATCHED AND s.op_ts > t.op_ts
  THEN UPDATE SET *, t.op_ts = s.op_ts
WHEN NOT MATCHED
  THEN INSERT *;
-- 关键: 用op_ts版本号防止老数据覆盖新数据
 
-- 示例2: MySQL ON DUPLICATE KEY UPDATE
INSERT INTO dwd_orders (order_id, amount, status, op_ts)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
  amount = IF(VALUES(op_ts) > op_ts, VALUES(amount), amount),
  status = IF(VALUES(op_ts) > op_ts, VALUES(status), status),
  op_ts  = IF(VALUES(op_ts) > op_ts, VALUES(op_ts), op_ts);
 
-- 示例3: Hologres upsert(主键表)
INSERT INTO dwd_orders VALUES (...) ON CONFLICT(order_id) DO UPDATE SET ...;
 
-- 示例4: HBase/KV存储
-- put操作天然幂等(相同rowkey覆盖)

关键设计:① 必须有稳定主键;② 必须有版本号(op_ts或source_ts)防止乱序覆盖;③ Sink必须支持upsert原语(不是所有存储都支持,比如Hive就不行)。

方案三:去重机制(基于唯一ID)

当没有稳定主键或Sink不支持upsert时,靠"全局唯一event_id + 去重状态存储"做去重。这个方案通用性最强。

// Flink KeyedProcessFunction去重(基于State)
public class DedupFunction extends KeyedProcessFunction<String, Event, Event> {
    private transient ValueState<Boolean> seen;
    
    public void open(Configuration cfg) {
        StateTtlConfig ttl = StateTtlConfig
            .newBuilder(Time.hours(24))      // 24小时去重窗口
            .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
            .build();
        ValueStateDescriptor<Boolean> desc = new ValueStateDescriptor<>("seen", Boolean.class);
        desc.enableTimeToLive(ttl);
        seen = getRuntimeContext().getState(desc);
    }
    
    public void processElement(Event e, Context ctx, Collector<Event> out) throws Exception {
        if (seen.value() == null) {
            seen.update(true);
            out.collect(e);    // 第一次看到,输出
        }
        // 重复事件直接丢弃
    }
}
 
// 使用:
stream.keyBy(Event::getEventId)
      .process(new DedupFunction())
      .addSink(...);
 
// 海量数据场景:用Redis/RocksDB做外部去重存储
// 因为State有TTL,如果重复间隔超过TTL就会漏去重
// 金融场景建议TTL=7天,权衡存储成本和去重窗口

三方案对比

维度端到端事务(2PC)幂等写入去重机制
严格程度真Exactly-Once等效Exactly-Once等效(有TTL)
性能损失20-30%<5%10-15%(State开销)
实现复杂度高低中
依赖条件所有组件支持事务有主键+版本号有唯一ID
适用场景金融清结算绝大多数业务无主键数据
生产占比约5%约85%约10%

面试高频追问

追问一:Kafka的幂等Producer就能保证Exactly-Once吗?

不能。Kafka的idempotent producer只保证"单个Producer会话内、单个partition上的去重"——同一次send的重试不会重复写。但跨会话(Producer重启)或跨partition就失效。严格Exactly-Once需要Kafka事务(transactional.id)+ 下游read_committed,这才能覆盖作业重启场景。面试时别混淆"idempotent"和"transactional"。

追问二:At-Least-Once + 幂等 跟 Exactly-Once 有什么实际差别?

结果上几乎没差别——最终状态都是一致的。差别在"中间状态":Exactly-Once下游看不到未提交数据,At-Least-Once下游可能看到"被覆盖的中间版本"。对绝大多数业务无感(反正看最新结果),对风控/反欺诈这种要看"事件序列"的场景就关键了。生产上根据业务诉求选,不是越严格越好。

追问三:对方系统不支持upsert怎么做幂等?

三种退化方案:① 先delete再insert(要求有事务保证原子性,否则中间状态可能导致下游读到空);② 写入时带unique约束,重复时忽略(INSERT IGNORE),缺点是更新会被忽略;③ 引入staging表+periodic swap,定期把staging数据merge到主表。最常见的还是方案三,牺牲实时性换幂等能力。

实战踩坑:Flink Sink用异步Flush导致重复

有个Flink作业sink写Hologres,为了性能开了async flush(攒10秒或1万条才真正写入),Checkpoint做完了但数据还在flush buffer里没落盘。作业挂掉重启后从checkpoint恢复,重新消费Kafka,buffer里的数据也写进去了——最终落库的是双份。

// ❌ 反模式: async Sink + Checkpoint不对齐
HologresSink sink = HologresSink.builder()
    .flushInterval(Duration.ofSeconds(10))  // 异步flush
    .flushBatchSize(10000)
    .build();
// Flink checkpoint完成时,buffer里的数据还没真正写入
// 作业挂重启 → checkpoint恢复消费 + buffer数据也写入 = 重复
 
// ✓ 正确做法: Checkpoint时强制flush
public class HologresCheckpointedSink extends RichSinkFunction<Row>
    implements CheckpointedFunction {
    
    public void snapshotState(FunctionSnapshotContext ctx) throws Exception {
        // Checkpoint barrier到达时,强制把buffer刷到Hologres
        // 只有flush完成,checkpoint才算成功
        flushBuffer();
        waitForAck();   // 等Hologres ack确认才返回
    }
    
    public void initializeState(FunctionInitializationContext ctx) { ... }
}
 
// 配合: 下游Hologres表用主键upsert,即使有重复写入也幂等
-- CREATE TABLE dwd_orders (order_id BIGINT PRIMARY KEY, ...);
-- 重复写入会被主键覆盖,结果幂等

解决方案:双保险——Sink在Checkpoint时强制flush + 下游主键幂等。这是生产上最稳的模式。单靠任何一个都不够:只flush没有幂等,丢数据的场景下还会漏;只幂等没有flush,checkpoint恢复时会覆盖更新状态。

总结

Exactly-Once在生产上几乎没有"纯技术"的方案,都是"At-Least-Once + 幂等/去重"的等效实现。严格2PC成本高,只在金融等极端场景用。生产最实用的是源端event_id + 下游主键upsert + op_ts版本号。面试时要讲清楚"真E2E vs 等效E2E的区别+为什么等效够用+具体实现",比空喊口号分高得多。

下一篇聊云厂商产品的实战题:DataWorks数据集成同步速度慢,并发、批量、网络怎么调?

你们公司对一致性要求到什么级别?用的是哪种方案?评论区聊聊~

关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长

刷题进度保存在当前浏览器