Back to Apache Flink
Q2Question 2 of 33

What is stream deduplication in Flink and why is it important

Key Concepts
  • Stream deduplication removes duplicate events by keeping only the first or latest occurrence per key.
  • In Flink SQL, ROW_NUMBER() partitioned by key and ordered by time with row_number = 1 deduplicates efficiently.
  • In the DataStream API, a KeyedProcessFunction with a seen flag in ValueState and a timer for TTL deduplicates.
  • Deduplication bridges at-least-once delivery to effectively-exactly-once processing.
  • CDC pipelines redeliver events after connector restarts, and deduplication prevents double-processing.
  • The deduplication key must be truly stable and unique; timestamps or collision-prone hashes break correctness.
  • State TTL must be sized to cover the maximum realistic redelivery window to catch duplicates while bounding memory.

Definition

Stream deduplication keeps only the first or latest event per key, via ROW_NUMBER in Flink SQL or keyed state plus timers in the DataStream API, and it is essential because CDC and event-sourced pipelines can redeliver events.
  • Stream deduplication – Removing duplicate events that share a deduplication key, keeping only the first or latest occurrence.
  • ROW_NUMBER pattern – In Flink SQL, ROW_NUMBER() OVER (PARTITION BY key ORDER BY time) with filter row_num = 1 deduplicates.
  • Keyed state + timer – In the DataStream API, a ValueState boolean tracks seen keys and a timer expires state after a TTL.
  • At-least-once bridge – Deduplication turns at-least-once delivery into effectively-exactly-once processing.
  • State TTL – Must cover the maximum realistic redelivery window to catch duplicates while keeping memory bounded.

The National Bank of India sometimes receives the same transaction message twice because a network retry resent it. If the bank processed both, it would debit Rajesh twice for one transfer. Stream deduplication is like a doorkeeper with a guest list: each transaction carries a unique id, the doorkeeper lets the first copy through and turns away any later copy carrying the same id. To avoid keeping the guest list forever, the doorkeeper only remembers ids for a sensible window.

Understanding the Concepts

What is stream deduplication?

Stream deduplication removes duplicate events that share a key, keeping only the first or latest occurrence. It is essential for correctness in pipelines where sources can redeliver events.

SQL
-- Flink SQL deduplication: keep first occurrence per txn_id
SELECT txn_id, account_id, amount, event_time
FROM (
    SELECT *,
        ROW_NUMBER() OVER (
            PARTITION BY txn_id
            ORDER BY event_time ASC
        ) AS row_num
    FROM raw_transactions
)
WHERE row_num = 1;
-- Ascending order + row_num = 1 = keep-first
-- Descending order + row_num = 1 = keep-last

Flink recognizes the ROW_NUMBER + WHERE row_num = 1 pattern and runs it as an efficient streaming dedup operator with per-key state.

SQL
-- Source: Kafka topic with potential duplicates
CREATE TABLE raw_transactions (
    txn_id     STRING,
    account_id STRING,
    amount     DECIMAL(15, 2),
    event_time TIMESTAMP(3),
    WATERMARK FOR event_time AS event_time - INTERVAL '5' SECOND
) WITH (
    'connector' = 'kafka',
    'topic'     = 'nbi-raw-transactions',
    'format'    = 'json',
    'properties.bootstrap.servers' = 'kafka.bank.internal:9092'
);

-- Deduplicate: keep first occurrence per txn_id
CREATE VIEW deduped_transactions AS
SELECT txn_id, account_id, amount, event_time
FROM (
    SELECT *,
        ROW_NUMBER() OVER (PARTITION BY txn_id ORDER BY event_time ASC) AS rn
    FROM raw_transactions
) WHERE rn = 1;

-- Use deduped view downstream
SELECT account_id, SUM(amount) AS total
FROM deduped_transactions
GROUP BY account_id;

How does deduplication work in the DataStream API?

In the DataStream API, you implement deduplication with a KeyedProcessFunction that keeps a seen flag in keyed state and a timer for TTL.

JAVA
package com.bank.flink;

import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;

public class TransactionDeduplicator
        extends KeyedProcessFunction<String, Transaction, Transaction> {

    private ValueState<Boolean> seenState;
    private static final long TTL_MS = 3600000; // 1 hour

    @Override
    public void open(Configuration parameters) {
        seenState = getRuntimeContext().getState(
            new ValueStateDescriptor<>("seen", Boolean.class));
    }

    @Override
    public void processElement(Transaction txn, Context ctx, Collector<Transaction> out)
            throws Exception {
        Boolean seen = seenState.value();
        if (seen == null || !seen) {
            // First occurrence: emit and mark as seen
            seenState.update(true);
            out.collect(txn);
            // Register timer to clean up state after TTL
            ctx.timerService().registerEventTimeTimer(
                ctx.timestamp() + TTL_MS);
        }
        // Duplicate: drop silently
    }

    @Override
    public void onTimer(long timestamp, OnTimerContext ctx, Collector<Transaction> out) {
        // TTL expired: clear state so a reused txn_id is treated as new
        seenState.clear();
    }
}
JAVA
// Usage: key by transaction id, apply deduplicator
DataStream<Transaction> deduped = transactions
    .keyBy(Transaction::getTxnId)
    .process(new TransactionDeduplicator());

Why is deduplication important for exactly-once?

Flink's own checkpointing gives exactly-once for state within the job, but end-to-end exactly-once also requires handling duplicates from outside Flink.

DIAGRAM
Sources of duplicates:
  1. CDC connector restart: re-reads a few changes around the last checkpointed offset
  2. At-least-once Kafka producer: resends on timeout
  3. Network retry: same HTTP request sent twice
  4. Event replay: manual reprocessing overlaps with live stream

Without deduplication:
  Duplicate TXN-001 (debit 50,000) processed twice
  → Rajesh debited 100,000 instead of 50,000 ← WRONG

With deduplication:
  First TXN-001 → processed, emitted
  Duplicate TXN-001 → dropped (already seen)
  → Rajesh debited 50,000 ← CORRECT

What is keep-first vs keep-last?

Choose keep-first for true duplicates and keep-last for collapsing a changelog to its newest value per key.

SQL
-- Keep-first: true deduplication (ORDER BY ASC, row_num = 1)
SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY txn_id ORDER BY event_time ASC) AS rn
    FROM raw_transactions
) WHERE rn = 1;

-- Keep-last: collapse changelog to newest value (ORDER BY DESC, row_num = 1)
SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY updated_at DESC) AS rn
    FROM account_updates
) WHERE rn = 1;

How do you size the state TTL?

The TTL must be at least as long as the maximum realistic duplicate delay. Too short misses duplicates. Too long wastes memory.

DIAGRAM
TTL sizing:
  CDC connector restart window: up to 5 minutes
  Kafka producer retry window:  up to 2 minutes
  Manual replay overlap:        up to 30 minutes

  → Set TTL to 1 hour (covers all scenarios with margin)
  → After 1 hour, a txn_id is forgotten and can be reused
JAVA
// Set state TTL in Flink SQL
SET 'table.exec.state.ttl' = '3600000'; // 1 hour

What about idempotent sinks?

An upsert sink keyed by the logical id is itself a form of deduplication at the write side. It composes well with in-stream dedup for defense in depth.

SQL
-- Defense in depth: dedup in stream + upsert sink
INSERT INTO account_totals  -- upsert sink keyed by account_id
SELECT account_id, SUM(amount)
FROM deduped_transactions   -- already deduplicated
GROUP BY account_id;
-- Even if a duplicate slips past the TTL, the upsert sink
-- applies it idempotently (overwrites, not double-counts)

What should a senior engineer know?

  • The deduplication key must be truly stable and unique. Timestamps or collision-prone hashes break correctness.
  • Unbounded dedup state is a classic memory leak. Always set a TTL.
  • Flink SQL recognizes the ROW_NUMBER pattern and optimizes it into a streaming dedup operator.
  • Combine in-stream dedup with idempotent sinks for defense in depth.
  • Monitor the dedup hit rate: a high rate may signal a source-side problem to investigate.
JAVA
// Senior tip: monitor dedup state size and hit rate
// Flink metrics: number of keys in dedup state
// If growing unbounded → TTL not configured or too long
// If hit rate is high → investigate why source sends so many duplicates

Visual Overview

DIAGRAM
  Stream Deduplication Flow

  ┌──────────────────────────┐
  │  Raw Stream (with dupes)  │
  │  TXN-001 (50,000)        │
  │  TXN-002 (30,000)        │
  │  TXN-001 (50,000) ← dup │
  │  TXN-003 (10,000)        │
  └──────────┬───────────────┘
             │
             ▼
  ┌──────────────────────────┐
  │  Deduplicator             │
  │  (keyed by txn_id)        │
  │                           │
  │  TXN-001: first → EMIT   │
  │  TXN-002: first → EMIT   │
  │  TXN-001: seen  → DROP   │
  │  TXN-003: first → EMIT   │
  └──────────┬───────────────┘
             │
             ▼
  ┌──────────────────────────┐
  │  Deduped Stream           │
  │  TXN-001 (50,000)        │
  │  TXN-002 (30,000)        │
  │  TXN-003 (10,000)        │
  └──────────────────────────┘
DIAGRAM
  ROW_NUMBER Dedup Pattern in Flink SQL

  SELECT * FROM (
      SELECT *,
          ROW_NUMBER() OVER (
              PARTITION BY txn_id     ← group duplicates
              ORDER BY event_time ASC ← keep first
          ) AS rn
      FROM raw_transactions
  ) WHERE rn = 1;                    ← filter to first

Key Takeaway

Stream deduplication in Flink removes duplicate events by keeping only the first or latest occurrence per key. In Flink SQL you express it with ROW_NUMBER partitioned by the deduplication key and ordered by time, keeping row number one. In the DataStream API you use a KeyedProcessFunction with a seen flag in keyed state and a timer that expires the state after a TTL.

Deduplication bridges at-least-once delivery to effectively-exactly-once processing. CDC pipelines redeliver events after connector restarts, and at-least-once Kafka producers resend on timeout. Without deduplication, these physical duplicates cause double debits, inflated aggregates, or corrupted views. The dedup key must be truly stable and unique, and the state TTL must cover the maximum realistic redelivery window.

At the National Bank of India, transactions arrived from an at-least-once source and after retries or CDC restarts the same transaction id sometimes came through twice. We deduplicated on transaction id, keeping the first occurrence and dropping the rest, with a one-hour TTL covering the longest realistic retry window. We also wrote to an upsert sink keyed by transaction id for defense in depth.

Remember one sentence: deduplicate on a stable unique key with a bounded TTL to turn at-least-once delivery into effectively-exactly-once processing, using ROW_NUMBER in Flink SQL or keyed state with timers in the DataStream API.

---

Share
Use ← → arrow keys to navigate