Зачем нужны ID запросов в логах?
Когда вы работаете с распределённой системой — будь то микросервисы, фронтенд + бэкенд или nginx + приложение — жизненно важно иметь возможность «протянуть» один и тот же идентификатор запроса через все её компоненты. Это позволяет сопоставлять логи из разных источников, быстро находить ошибки и проводить корреляционный анализ.
В nginx для этого из коробки есть переменная $request_id — 32-символьный hex-идентификатор (например, a1b2c3d4e5f678901234567890abcdef). Его можно передать бэкенду через proxy_set_header X-Request-ID $request_id; или fastcgi_param HTTP_X_REQUEST_ID $request_id;, а также сохранить в access-логах.
Однако стандартный $request_id — это просто случайная строка без временной привязки и без структуры, удобной для аналитики. В этой статье мы рассмотрим, как улучшить ситуацию с помощью ULID и UUIDv7.
Быстрое решение без модулей: UUIDv4 из $request_id через map
Если вы не хотите собирать nginx с модулями, можно «причесать» $request_id до формата UUIDv4 с помощью директивы map:
map $request_id $request_uuidv4 { "~^(.{8})(.{4})(.{4})(.{4})(.{12})$" "$1-$2-$3-$4-$5"; }
Теперь в логах и заголовках вы можете использовать $request_uuidv4 — он будет выглядеть как a1b2c3d4-e5f6-7890-1234-567890abcdef.
Это решение простое и не требует пересборки nginx.
Почему UUIDv4 — не лучший выбор для анализа логов?
UUIDv4 не содержит временной компоненты. При вставке в таблицу ClickHouse такие ID распределяются хаотично. Это означает невозможность эффективного использования MinMax-индексы, что приводит в полному сканированию данных. На больших объемах это может быть больно.
ULID и UUIDv7: сортируемые идентификаторы с временной меткой
ULID и UUIDv7 — это современные альтернативы UUIDv4, разработанн��е именно для аналитических сценариев:
Сортируемость: оба формата естественным образом сортируются по времени создания.
Встроенная временная метка: точность до миллисекунд (ULID — 48 бит времени, UUIDv7 — 48 бит Unix-времени в миллисекундах).
Уникальность: обеспечивается за счёт случайной или энтропийной части (80 бит у ULID, 74 бита у UUIDv7).
Совместимость: UUIDv7 соответствует RFC 9562, ULID — де-факто стандарт с широкой поддержкой.
Эффективность в ClickHouse: благодаря временной компоненте предполагается, что MinMax-индексы позволят пропускать нерелевантные блоки данных при поиске по ID.
Это делает ULID и UUIDv7 идеальными кандидатами для использования в логах, особенно если вы храните их в ClickHouse с временной партицией.
Практический эксперимент: генерация ID, загрузка в ClickHouse, замеры
Для генерации ULID, UUIDv4 и UUIDv7 я использовал модуль ngx_http_idgen_module. Модуль:
Генерирует ID лениво — только при первом обращении к переменной.
Использует стандартный
$request_idкак источник энтропии (32 hex-символа → 128 бит).Кэширует результат в контексте запроса — нулевые накладные расходы при повторном использовании.
Конфигурация nginx
user nginx; worker_processes 4; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { log_format json escape=json '{' '"timestamp": $msec,' '"request_id": "$request_id",' '"ulid": "$request_ulid",' '"uuidv4": "$request_uuidv4",' '"uuidv7": "$request_uuidv7"' '}'; access_log syslog:server=127.0.0.1:1514 json; server { listen 7080; location / { return 200 'OK'; } } }
В логах используется $msec это не обязательно, больше привычка.
Важно: время в ULID/UUIDv7 — это момент начала обработки запроса внутри nginx. Оно может отличаться от
$msec. Но эта разница не существенно повлияет на хаотичность расположения наших ID.
Схема таблицы в ClickHouse
CREATE TABLE logs.requests ( `timestamp` DateTime64(3), `request_id` FixedString(32), `ulid` FixedString(26), `uuidv4` UUID, `uuidv7` UUID, INDEX idx_ulid ulid TYPE minmax GRANULARITY 4, INDEX idx_uuidv7 uuidv7 TYPE minmax GRANULARITY 4 ) ENGINE = MergeTree PARTITION BY toStartOfHour(timestamp) ORDER BY timestamp
FixedString(26)— оптимальный тип для ULID (26 ASCII-символов).UUID— встроенный тип ClickHouse для UUIDv4 и UUIDv7 (хранится как 16 байт).Партицирование по часу — для удобства эксперимента.
Загрузка логов
Логи в формате JSON загружаются в ClickHouse через Vector:
api: enabled: true address: "127.0.0.1:8686" sources: source_nginx_udp: type: syslog address: 0.0.0.0:1514 max_length: 1024000 mode: udp transforms: remap_nginx: type: remap inputs: - source_nginx_udp reroute_dropped: true file: /etc/vector/remap_nginx.vrl sinks: to_click_nginx_access: inputs: [ remap_nginx ] type: clickhouse database: logs endpoint: 127.0.0.1:8124 table: requests skip_unknown_fields: true healthcheck: enabled: false encoding: timestamp_format: unix_ms auth: user: vector password: vector strategy: basic
Файл remap_nginx.vrl
if !is_nullish(.message) { json, .fail_parse.message_err = parse_json(.message) if .fail_parse.message_err != null { .fail_parse.message = del(.message) } else { . = json del(.fail_parse.message_err) } } if is_float(.timestamp) { .timestamp = from_unix_timestamp!(to_int!(.timestamp*1000 ?? 0), unit: "milliseconds") } else { .fail_parse.timestamp = to_string!(.timestamp) .timestamp = now() }
Осталось запустить генератор запросов, для этого нам подойдет ab из пакета apache2-utils
ab -c 50 http://10.10.10.110:7080/
Через некоторое время получаем некоторое количество данных
SELECT column, type, formatReadableSize(sum(column_data_compressed_bytes)) AS compressed, formatReadableSize(sum(column_data_uncompressed_bytes)) AS uncompressed, sum(rows) AS rows_cnt, round(sum(column_data_uncompressed_bytes) / sum(rows), 2) AS avg_row FROM system.parts_columns WHERE (active = 1) AND (database = 'logs') AND (`table` = 'requests') GROUP BY column, type ORDER BY avg_row ASC ┌─column─────┬─type────────────┬─compressed─┬─uncompressed─┬─rows_cnt─┬─avg_row─┐ 1. │ timestamp │ DateTime64(3) │ 35.80 MiB │ 430.66 MiB │ 56689348 │ 7.97 │ 2. │ uuidv7 │ UUID │ 688.86 MiB │ 861.32 MiB │ 56689348 │ 15.93 │ 3. │ uuidv4 │ UUID │ 864.87 MiB │ 861.32 MiB │ 56689348 │ 15.93 │ 4. │ ulid │ FixedString(26) │ 1.04 GiB │ 1.37 GiB │ 56689348 │ 25.89 │ 5. │ request_id │ FixedString(32) │ 1.69 GiB │ 1.68 GiB │ 56689348 │ 31.86 │ └────────────┴─────────────────┴────────────┴──────────────┴──────────┴─────────┘
Сначала посмотрим как происходит выборка по полям request_id и uuidv4
SELECT * FROM logs.requests WHERE request_id = '75c0bbe9c789ffa3ad1d0035b395e461' Row 1: ────── timestamp: 2025-10-22 15:56:29.281 request_id: 75c0bbe9c789ffa3ad1d0035b395e461 ulid: 01K85ZW2B1EQ0BQTE7H7ZT7B8X uuidv4: 75c0bbe9-c789-4fa3-ad1d-0035b395e461 uuidv7: 019a0bfe-0961-7fa3-ad1d-0035b395e461 1 row in set. Elapsed: 34.825 sec. Processed 56.69 million rows, 1.81 GB (1.63 million rows/s., 52.09 MB/s.) Peak memory usage: 3.92 MiB.
SELECT * FROM logs.requests WHERE uuidv4 = '75c0bbe9-c789-4fa3-ad1d-0035b395e461' Row 1: ────── timestamp: 2025-10-22 15:56:29.281 request_id: 75c0bbe9c789ffa3ad1d0035b395e461 ulid: 01K85ZW2B1EQ0BQTE7H7ZT7B8X uuidv4: 75c0bbe9-c789-4fa3-ad1d-0035b395e461 uuidv7: 019a0bfe-0961-7fa3-ad1d-0035b395e461 1 row in set. Elapsed: 17.914 sec. Processed 56.69 million rows, 907.03 MB (3.16 million rows/s., 50.63 MB/s.) Peak memory usage: 1.90 MiB.
В целом, все в пределах ожиданий, сканируются все 56 миллионов строк, у uuidv4 есть небольшое ускорение за счет более оптимального формата хранения.
SELECT * FROM logs.requests WHERE ulid = '01K85ZW2B1EQ0BQTE7H7ZT7B8X' Row 1: ────── timestamp: 2025-10-22 15:56:29.281 request_id: 75c0bbe9c789ffa3ad1d0035b395e461 ulid: 01K85ZW2B1EQ0BQTE7H7ZT7B8X uuidv4: 75c0bbe9-c789-4fa3-ad1d-0035b395e461 uuidv7: 019a0bfe-0961-7fa3-ad1d-0035b395e461 1 row in set. Elapsed: 0.205 sec. Processed 32.77 thousand rows, 852.03 KB (159.65 thousand rows/s., 4.15 MB/s.) Peak memory usage: 11.85 KiB.
Результат впечатляет, но он ожидаем, попробуем по колонке в которой храним UUIDv7 в формате UUID, должно быть эффективнее?
SELECT * FROM logs.requests WHERE uuidv7 = '019a0bfe-0961-7fa3-ad1d-0035b395e461' Row 1: ────── timestamp: 2025-10-22 15:56:29.281 request_id: 75c0bbe9c789ffa3ad1d0035b395e461 ulid: 01K85ZW2B1EQ0BQTE7H7ZT7B8X uuidv4: 75c0bbe9-c789-4fa3-ad1d-0035b395e461 uuidv7: 019a0bfe-0961-7fa3-ad1d-0035b395e461 1 row in set. Elapsed: 16.020 sec. Processed 56.69 million rows, 907.03 MB (3.54 million rows/s., 56.62 MB/s.) Peak memory usage: 1.42 MiB.
Что-то пошло не так, откуда 56 миллионов? Сравним планы запросов
EXPLAIN indexes = 1 SELECT * FROM logs.requests WHERE uuidv7 = '019a0bfe-0961-7fa3-ad1d-0035b395e461' ┌─explain────────────────────────────────────────────────────────────┐ 1. │ Expression ((Project names + Projection)) │ 2. │ Expression ((WHERE + Change column names to column identifiers)) │ 3. │ ReadFromMergeTree (logs.requests) │ 4. │ Indexes: │ 5. │ MinMax │ 6. │ Condition: true │ 7. │ Parts: 19/19 │ 8. │ Granules: 6929/6929 │ 9. │ Partition │ 10. │ Condition: true │ 11. │ Parts: 19/19 │ 12. │ Granules: 6929/6929 │ 13. │ PrimaryKey │ 14. │ Condition: true │ 15. │ Parts: 19/19 │ 16. │ Granules: 6929/6929 │ 17. │ Skip │ 18. │ Name: idx_uuidv7 │ 19. │ Description: minmax GRANULARITY 4 │ 20. │ Parts: 19/19 │ 21. │ Granules: 6929/6929 │ └────────────────────────────────────────────────────────────────────┘ EXPLAIN indexes = 1 SELECT * FROM logs.requests WHERE ulid = '01K85ZW2B1EQ0BQTE7H7ZT7B8X' ┌─explain────────────────────────────────────────────────────────────┐ 1. │ Expression ((Project names + Projection)) │ 2. │ Expression ((WHERE + Change column names to column identifiers)) │ 3. │ ReadFromMergeTree (logs.requests) │ 4. │ Indexes: │ 5. │ MinMax │ 6. │ Condition: true │ 7. │ Parts: 19/19 │ 8. │ Granules: 6929/6929 │ 9. │ Partition │ 10. │ Condition: true │ 11. │ Parts: 19/19 │ 12. │ Granules: 6929/6929 │ 13. │ PrimaryKey │ 14. │ Condition: true │ 15. │ Parts: 19/19 │ 16. │ Granules: 6929/6929 │ 17. │ Skip │ 18. │ Name: idx_ulid │ 19. │ Description: minmax GRANULARITY 4 │ 20. │ Parts: 1/19 │ 21. │ Granules: 4/6929 │ └────────────────────────────────────────────────────────────────────┘
Видно, что по какой-то причине для поля ulid обрабатывалось 4/6929 гранул, а для uuidv7 6929/6929
Если заглянуть в документацию, то можно встретить строку:
Due to historical reasons, UUIDs are sorted by their second half.
Для UUIDv7 это означает, что сортировка будет происходить сначала по рандомной части, и так же как для UUIDv4, индекс MinMax перестанет иметь смысл.
Тесты с записью UUIDv7 в String проводить не стал, т.к. статья и так получилась длиннее изначального плана.
Выводы и рекомендации:
Индекс MinMax прекрасно себя показывает на полях начинающихся с временной компоненты и отсортированных по полю которое коррелирует со временем этой компоненты.
Индекс MinMax работает не корректно для UUIDv7 в случае хранения в формате
UUIDЕсли у вас есть необходимость работать именно с UUIDv7, то для ускорения выборки с помощью индекса MinMax, храните в формате
FixedString(36)илиString
Если вы уже используете Lua в nginx (например, через OpenResty), вы можете реализовать генерацию ULID/UUIDv7 на стороне скрипта. Я буду рад увидеть живые примеры — особенно с замерами производительности и объёмов. Возможно, Lua-решение окажется ещё проще для некоторых сценариев.
Послесловие
Я — автор модуля ngx_http_idgen_module, и эта статья родилась из реальных задач по оптимизации логирования в ClickHouse. Модуль открыт для конструктивной критики, предложений и PR. Если вы видите, как его можно улучшить — пишите!
