У потокових системах з «майже реальним часом» дублікати подій — не виняток, а норма. Команда Confluent Developer у своєму розборі показує, чому прості підходи часто не працюють, і як коректно реалізувати дедуплікацію в Apache Flink SQL без зайвої затримки та помилкових втрат даних.

Чому дублікати взагалі з’являються
Сучасні фреймворки потокової обробки вміють забезпечувати семантику exactly-once, але це має ціну — додаткову затримку. Для частини сценаріїв (наприклад, високочастотна торгівля чи моніторинг) навіть кілька зайвих мілісекунд або секунд — критичні. Тому розробники часто обирають at-least-once доставку, приймаючи ризик появи дублікатів.
У такій конфігурації будь-який збій, повторна відправка або повторне зчитування з брокера можуть призвести до того, що одна й та сама подія потрапить у стрім кілька разів. Якщо потім ці дані агрегуються, збагачуються або використовуються для виявлення патернів, дублікати спотворюють результати — від подвійних сум до хибних тригерів.
Отже, дедуплікація стає окремим обов’язковим «будівельним блоком» у конвеєрі обробки подій.
Чому дедуплікація через tumbling window — антипатерн
Поширена інтуїтивна ідея: взяти tumbling window (ковзні вікна фіксованої довжини) і в межах кожного вікна прибирати дублікати за ключем. На практиці це створює одразу кілька проблем.
1. Дубль може потрапити в інше вікно
Уявімо потік трейдів, де кожна угода має унікальний trade_id, а часові мітки виставляє брокер Kafka. Оригінальна подія T1 приходить у чорному, дублікат — трохи пізніше, у червоному. Навіть якщо вони йдуть майже одночасно, різниця в timestamp може віднести їх у різні вікна.
Результат: перша подія потрапляє в одне вікно, дублікат — у наступне. Жодне з вікон не бачить обидві події разом, отже дубль не розпізнається й не відкидається. Це повністю нівелює задум дедуплікації.
2. Висока затримка через очікування кінця вікна
При віконній обробці результати з’являються лише після закриття вікна. Якщо вікно триває, скажімо, хвилину, то будь-яка подія чекатиме до хвилини, перш ніж потрапити в результуючий стрім. Для сценаріїв, де важлива низька затримка, це неприйнятно.
3. Проблеми з late events і watermarks
У Flink SQL робота вікон тісно пов’язана з watermarks. Пізні події, що приходять після того, як watermark «перестрибнув» їхній час, просто відкидаються. Для дедуплікації це означає ризик втратити або оригінал, або дублікат, що знову робить результат ненадійним.
Підсумок: використання tumbling window для дедуплікації в Flink SQL — радше антипатерн, ніж рішення.
Over-window: подія як тригер, а не кінець вікна
Набагато природніший підхід у Flink SQL — over-aggregation (over-window). Ідея в тому, що «вікно» фактично тригериться кожною подією, а не часом.
Як це працює концептуально
-
Партиціювання за унікальним ключем
Потік розбивається за унікальним ідентифікатором, наприкладtrade_id. Логічно це виглядає як окремий підпотік для кожної угоди. -
Збирання всіх подій з однаковим ключем
Для кожногоtrade_idзбираються всі події (зазвичай одна, але можуть бути дві чи більше у разі дублікатів). -
Вибір «правильної» події за порядком
Події впорядковуються за часом. Якщо використовується ascending порядок, береться перша подія, а всі наступні з тим самим ключем вважаються дублікатами й відкидаються. -
Миттєвий вивід результату
Як тільки приходить нова подія: - якщо ключ ще не зустрічався — подія одразу проходить далі;
- якщо ключ уже є в стані — подія розпізнається як дубль і відкидається без очікування будь-якого «кінця вікна».
Це дає дедуплікацію з низькою затримкою й без залежності від розміру вікна чи поведінки watermarks.
Що зі станом: скільки всього треба зберігати
Такий підхід створює необмежений стан: теоретично кількість унікальних ключів може зростати нескінченно. Втім, зберігати весь запис не обов’язково — достатньо пам’ятати сам унікальний ключ (trade_id), а не повний трейд.
Однак за високої активності (наприклад, у біржовій торгівлі) навіть набір ключів може стати дуже великим. Тому критично важливо налаштувати:
- State Time To Live (TTL) для таблиці/стану.
TTL визначає, як довго зберігається інформація про вже бачені ключі. Орієнтиром може бути максимальна очікувана тривалість збоїв або затримок, які можуть спричинити дублікати. Наприклад, якщо повторна доставка можлива в межах години, TTL у годину може бути достатнім.
Це дозволяє обмежити ріст стану й уникнути проблем із ресурсами.
Ascending vs descending: чому «останній» елемент у стрімі — пастка
Over-window дозволяє впорядковувати події як у ascending, так і в descending порядку. На перший погляд може здаватися логічним брати «останню» версію події (descending), але в потоковому світі це ускладнює життя.
Ascending: стабільний, «фінальний» результат
При ascending сортуванні береться перша подія за часом. У стрімінгу це добре визначена операція: як тільки перша подія з ключем приходить, її можна вважати результатом, а всі наступні — дублікатами. Вихідний потік при цьому є append-only — записи додаються, але не змінюються.
Це спрощує інтеграцію з подальшими системами (сховища, аналітика, дашборди), які очікують стабільні, неоновлювані записи.
Descending: оновлюваний стрім і складніший пайплайн
Якщо обрати descending порядок, логіка змінюється:
- фактично запитується «останній» елемент за часом;
- але в стрімі ніколи не можна бути впевненим, що не прийде ще один дубль пізніше.
Тому система змушена:
- Спочатку видати перший запис як поточний «останній».
- Якщо приходить новий дубль — відкликати попередній результат («забудьте те, що я казав») і видати оновлений.
Це перетворює вихідний потік на updating stream — з подіями типу «update» замість простих «insert». Подальші компоненти мають уміти коректно обробляти оновлення, що ускладнює архітектуру.
Тому, коли це можливо, доцільно:
- уникати descending у дедуплікації;
- віддавати перевагу ascending і append-only виходу.
Практичні рекомендації для дедуплікації у Flink SQL
Підсумовуючи, для побудови надійної дедуплікації в Flink SQL варто врахувати кілька ключових моментів:
-
Не покладатися на tumbling windows
Вони не гарантують, що дубль опиниться в тому самому вікні, створюють затримку й залежать від watermarks та обробки late events. -
Використовувати over-window з партиціюванням за унікальним ключем
Це дозволяє реагувати на кожну подію миттєво, без очікування кінця вікна. -
Обмежувати стан через TTL
Налаштуватиstate time to liveвідповідно до максимальної очікуваної тривалості збоїв/затримок, які можуть породжувати дублікати. -
Вибирати ascending порядок для стабільного виходу
Це дає append-only потік, спрощує подальшу обробку й інтеграцію. -
Обачно працювати з updating streams
Over-window може приймати на вхід оновлювані стріми й генерувати оновлюваний вихід. Якщо це неминуче, архітектура наступних компонентів має бути до цього готовою, але за можливості краще уникати такого сценарію.
Для тих, хто працює з DataStream API, залишається опція написати власну ProcessFunction для дедуплікації. Це дає повний контроль, але потребує самостійного врахування всіх описаних нюансів: управління станом, TTL, семантики часу та оновлень.


