Как мы построили конвейер tracio.ai со временем отклика менее 30 мс
От сбора сигналов до идентификатора посетителя менее чем за 30 мс: наша архитектура на Go, ClickHouse, Redis и распределённой обработке.
Когда мы взялись за создание движка идентификации устройств tracio.ai, у нас было одно бескомпромиссное требование: весь конвейер — от приёма зашифрованных сигналов до возврата идентификатора посетителя — должен укладываться менее чем в 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 с полями для хеша каждого уровня сигналов, идентификатора посетителя, отметки времени последнего появления и метаданных достоверности.
Запрос разрешения идентичности — это единственный вызов HGETALL с последующим сравнением хешей входящих сигналов с сохранёнными. Если аппаратный уровень совпадает, мы возвращаем существующий идентификатор посетителя с высокой достоверностью. Если совпадает только программный уровень, мы выполняем сравнение схожести данных на уровне сигналов, чтобы определить, то же ли это устройство с обновлённым браузером. Если ничего не совпадает, мы генерируем новый идентификатор посетителя.
ClickHouse для хранения событий
Каждое событие идентификации записывается в ClickHouse асинхронно. Мы используем буферизованный писатель, который батчит вставки — накапливая события в течение 100 мс или до достижения 1000 событий, смотря что наступит раньше. Такое батчирование критично, поскольку ClickHouse работает лучше всего с крупными вставками (тысячи строк за раз), а не с построчными.
Наша схема ClickHouse оптимизирована под два самых частых паттерна запросов: выборку всех событий по конкретному идентификатору посетителя и агрегацию событий по временным периодам. Мы используем движок MergeTree с первичным ключом (visitor_id, timestamp), что обеспечивает быстрые точечные выборки и эффективные диапазонные сканирования. Материализованные представления поддерживают предагрегированные суточные и часовые метрики.
Как мы достигаем менее 30 мс при масштабе
Три архитектурных решения оказались критичными для достижения нашей цели по задержкам. Во-первых, конвейер полностью потоковый — мы начинаем обрабатывать сигналы ещё до того, как получено всё тело HTTP-запроса. Во-вторых, обращения к Redis используют пул соединений с постоянными подключениями, устраняя накладные расходы на TCP-рукопожатие. В-третьих, записи в ClickHouse полностью асинхронны и никогда не блокируют путь ответа.
Под нагрузочным тестированием при 50K запросов/с наша задержка p50 составляет 12 мс, p95 — 24 мс, а p99 — 38 мс. Значение p99 иногда превышает нашу цель в 30 мс во время ребалансировки кластера Redis, но p95 стабильно остаётся ниже 30 мс. Для клиентов с более строгими требованиями к задержкам мы предлагаем выделенные кластеры Redis, устраняющие конкуренцию в мультитенантной среде.