Короткий ответ
При at-least-once повторная доставка нормальна, поэтому уникальность нельзя строить на времени получения или surrogate id хранилища. Идемпотентный ключ должен одинаково идентифицировать одно логическое событие при каждом ретрае.
Как это работает подробнее
Сначала дедуплицируйте сам batch в staging, затем сопоставляйте его с целевой таблицей. Победителя выбирайте детерминированно по версии, времени обновления и дополнительному tie-breaker, иначе повторный запуск может дать другой результат.
В ClickHouse ReplacingMergeTree(version) удаляет старые версии не мгновенно: до фонового merge запросы могут видеть несколько строк. Критичные чтения должны использовать агрегацию по версии или осознанно применять FINAL.
Пример кода
bigquery_event_merge.sql
MERGE dataset.events AS target
USING (
SELECT * EXCEPT(rn)
FROM (
SELECT s.*, ROW_NUMBER() OVER (
PARTITION BY event_id ORDER BY event_version DESC, received_at DESC
) AS rn
FROM dataset.events_staging AS s
)
WHERE rn = 1
) AS source
ON target.event_id = source.event_id
WHEN MATCHED AND source.event_version > target.event_version THEN
UPDATE SET event_version = source.event_version, payload = source.payload, received_at = source.received_at
WHEN NOT MATCHED THEN
INSERT (event_id, event_version, payload, received_at)
VALUES (source.event_id, source.event_version, source.payload, source.received_at);Что использовать на практике
- Закрепите контракт event_id. Опишите генерацию ключа, версию события и семантику поздних изменений.
- Храните сырой слой. Он позволяет повторить дедупликацию после исправления правила.
- Тестируйте ретраи. Повтор одного batch и параллельные загрузки должны приводить к тому же логическому состоянию.
Подводные камни
- Ключ получателя. Новый автоинкрементный id не распознает повтор одного события.
- Недетерминированный ROW_NUMBER. Равные времена без tie-breaker дают нестабильного победителя.
- Ожидание мгновенной дедупликации ClickHouse. Фоновый merge не гарантирует немедленную уникальность чтения.