Зачем нужны 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. Если вы видите, как его можно улучшить — пишите!