Як ми побудували конвеєр tracio.ai з відгуком менше 30 мс
Від збору сигналів до visitor ID менш ніж за 30 мс: наша архітектура на Go, ClickHouse, Redis і розподіленій обробці.
Коли ми взялися будувати рушій ідентифікації пристроїв tracio.ai, у нас була одна вимога, що не підлягала обговоренню: весь конвеєр — від отримання зашифрованих сигналів до повернення visitor ID — має завершуватися менш ніж за 30 мілісекунд на 95-му перцентилі. Ця стаття — детальний розбір архітектури, яку ми побудували, щоб досягти цієї цілі.
Огляд конвеєра
Конвеєр ідентифікації має п'ять етапів: розшифрування сигналів, нормалізація сигналів, обчислення хешів, визначення ідентичності та серіалізація відповіді. Кожен етап оптимізується незалежно, а етапи, які можна виконувати паралельно, так і виконуються. Загальний бюджет — 30 мс, розподілений приблизно так: розшифрування 2 мс, нормалізація 3 мс, хешування 2 мс, визначення ідентичності 20 мс, серіалізація 1 мс. Решта 2 мс — буфер.
Розшифрування сигналів обертає шифрований транспорт на боці клієнта. Ми використовуємо криптографічні пакети Go з апаратним прискоренням, які виконують розшифрування типового навантаження 4 КБ менш ніж за 1 мс. Нормалізація парсить JSON сигналу, перевіряє типи та застосовує специфічні для платформи перетворення — наприклад, нормалізує рядки user agent, щоб прибрати шум, пов'язаний із версіями.
Розподілене визначення ідентичності
Визначення ідентичності — з'ясування, чи цей пристрій бачили раніше — це найчутливіший до затримок етап. Ми зберігаємо профілі пристроїв у Redis, шардованому по кластеру за допомогою шару розподіленої маршрутизації ключів. Маршрутизація розподіляє ключі на основі відбитка апаратного рівня, що гарантує: пошук для того самого пристрою завжди потрапляє на той самий вузол Redis.
Наша реалізація шардування використовує віртуальні вузли (150 на фізичний вузол), щоб забезпечити рівномірний розподіл. Коли вузол додається або видаляється, перерозподілити треба лише 1/N ключів, де N — кількість вузлів. Ми реалізували шар маршрутизації на Go з часом пошуку O(log n) і нульовими алокаціями.
Redis як сховище ідентичності
Ми обрали Redis, а не альтернативи (Memcached, ScyllaDB, DynamoDB), через його стабільні субмілісекундні часи відгуку та підтримку складних структур даних. Кожен профіль пристрою зберігається як хеш Redis із полями для хешу кожного рівня сигналів, visitor ID, часової позначки останнього візиту та метаданих впевненості.
Запит на визначення ідентичності — це один виклик HGETALL із подальшим порівнянням вхідних хешів сигналів зі збереженими хешами. Якщо апаратний рівень збігається, ми повертаємо наявний visitor ID із високою впевненістю. Якщо збігається лише програмний рівень, ми виконуємо порівняння схожості даних на рівні сигналів, щоб з'ясувати, чи це той самий пристрій з оновленим браузером. Якщо не збігається нічого, ми генеруємо новий visitor ID.
ClickHouse для зберігання подій
Кожна подія ідентифікації записується в ClickHouse асинхронно. Ми використовуємо буферизований записувач, що батчить вставки — збираючи події протягом 100 мс або поки не накопичиться 1 000 подій, залежно від того, що настане раніше. Це батчування критично важливе, бо ClickHouse працює найкраще з великими вставками (тисячі рядків за раз), а не з вставками окремих рядків.
Наша схема ClickHouse оптимізована під два найпоширеніші шаблони запитів: пошук усіх подій для конкретного visitor ID та агрегація подій за проміжки часу. Ми використовуємо рушій MergeTree із первинним ключем (visitor_id, timestamp), що забезпечує швидкі точкові пошуки та ефективні діапазонні сканування. Матеріалізовані подання підтримують попередньо агреговані добові та годинні метрики.
Як досягти менше 30 мс у масштабі
Три архітектурні рішення були критичними для досягнення нашої цілі за затримкою. По-перше, конвеєр повністю потоковий — ми починаємо обробляти сигнали ще до того, як отримано все тіло HTTP-запиту. По-друге, пошуки в Redis використовують пулінг з'єднань із постійними з'єднаннями, усуваючи накладні витрати на TCP-рукостискання. По-третє, записи в ClickHouse повністю асинхронні й ніколи не блокують шлях відповіді.
Під навантажувальним тестуванням на 50K запитів/секунду наша затримка p50 становить 12 мс, p95 — 24 мс, а p99 — 38 мс. Значення p99 час від часу перевищує нашу ціль у 30 мс під час ребалансування кластера Redis, але p95 стабільно залишається нижче 30 мс. Для клієнтів зі суворішими вимогами до затримок ми пропонуємо виділені кластери Redis, які усувають конкуренцію в мультитенантному середовищі.