Идентификаторы и производительность: локальность, bloat, WAL

Случайный ID (UUIDv4) деградирует вставку в PostgreSQL: ниже плотность листьев B-tree, больше WAL, крупнее первичный индекс — измерено на стенде против bigint и монотонного UUIDv7. Плюс кросс-движковый разбор: миф «UUID-PK раздувает вторичные индексы» верен для MySQL/InnoDB и неверен для PostgreSQL; и когда монотонный ключ создаёт горячий шард (MongoDB ranged), а когда нет (ScyllaDB и Mongo hashed).

Прошлая статья серии дала словарь и оси сравнения, но намеренно не привела ни одного числа. Здесь — числа. Один и тот же тезис — «случайный идентификатор хуже монотонного для вставки» — разбирается на честном стенде, и сразу выясняется, что цена случайности не одна и не универсальная: в PostgreSQL она садится на первичный индекс, в MySQL/InnoDB — на каждый вторичный, в MongoDB — на размер индекса _id, а в схемах шардирования всё зависит не от того, монотонен ли ключ, а от того, хешируется ли он вообще.

Все числа ниже — вывод скрипта, прогнанного 2026-07-22 против стенда databases/identifiers (PostgreSQL 18.4, MySQL/InnoDB 8.4.10, MongoDB 8.3.4, ScyllaDB 2026.2.1). Общая оговорка, которая относится ко всем разделам: все узлы — на одном хосте, без сетевой латентности и конкуренции за диск между инстансами. Числа показывают характер эффекта — знак, наличие или отсутствие, — а не его величину на проде.

Ретрофутуристская схема «B-дерево: влияние ключа на структуру и издержки» в стиле «Полдень. XXI век»: слева монотонный ключ ложится в страницы плотно, дисплей «плотность 90 процентов», тонкая лента WAL; справа случайный ключ разлетается, страница раскалывается, «плотность 73 процента», толстая лента WAL и колба BLOAT; внизу cross-engine контраст вторичных индексов — в PostgreSQL «значение, ctid» размеры равны, в MySQL/InnoDB «PK в каждом листе» индекс по UUID толще

В статье

Механика: почему случайный ключ дороже монотонного

Устройство B-tree эта статья не пересказывает — оно разобрано в «Индексы в БД: зачем нужны, какие бывают и как устроены». Здесь важен только один конкретный механизм: что происходит на вставке, когда ключ монотонно растёт, и что — когда он случаен.

Когда ключ монотонно растёт (bigserial, UUIDv7, ULID), каждая новая вставка попадает в самый правый край B-tree — в тот же самый лист, что и предыдущая, до тех пор, пока лист не заполнится. Это правый-most (rightmost) паттерн вставки: страница заполняется последовательно, page split происходит редко и предсказуемо (лист переполнился — завести новый), а страницы в среднем плотнее забиты полезными данными.

Когда ключ случаен (UUIDv4), каждая новая вставка попадает в случайную точку 128-битного пространства ключей — то есть в случайный, уже существующий лист дерева. Если в этот лист попадает вставка, а места мало — происходит page split: существующий лист делится на два, каждый в среднем заполнен наполовину. При случайных вставках это происходит систематически, а не изредка, и итоговая плотность заполнения листьев (leaf density) оказывается устойчиво ниже, чем при монотонной вставке того же объёма данных.

Второе следствие — WAL. PostgreSQL пишет full-page image (полный образ страницы) в WAL при первом изменении страницы после чекпойнта — это защита от разрыва записи (torn page) при сбое. Page split — это изменение сразу нескольких страниц (исходный лист, новый лист, иногда родитель), и каждая из них требует полного образа. Чем чаще происходят page split’ы, тем больше full-page image в WAL за то же количество вставленных строк.

Стенд PostgreSQL: локальность, bloat, WAL

Стенд: PostgreSQL 18.4, N = 500 000 строк, wal_compression=off и checkpoint_timeout=1h/max_wal_size=8GB — чтобы за один прогон гарантированно не проскочил чекпойнт и не исказил wal_bytes смешиванием full-page image из разных чекпойнт-циклов. Три таблицы с одинаковым payload’ом, различие — только тип первичного ключа: bigint (sequence), uuid4 (случайный UUIDv4), uuid7 (монотонный UUIDv7, встроенная функция PostgreSQL 18 uuidv7()).

key rows insert rate table_bytes idx_bytes leaf_density wal_bytes
bigint 500000 92140 rows/s 58515456 11255808 89.98 172378000
uuid4 500000 64591 rows/s 63021056 19398656 73.27 186397752
uuid7 500000 83308 rows/s 63021056 15785984 89.98 113996968

Три наблюдения, строго по числам:

Локальность. leaf_density у uuid4 — 73.27, у bigint и uuid7 — одинаковые 89.98. Случайный ключ уступает по плотности заполнения листьев обоим монотонным вариантам; UUIDv7 по этой метрике неотличим от bigint, несмотря на то что физическое значение ключа у него совсем не то же самое, что у sequence, — важна не «природа» значения, а его порядок при вставке.

WAL. wal_bytes у uuid4 (186 397 752) больше, чем у bigint (172 378 000) — на том же объёме данных случайная вставка порождает больше write-ahead-лога, что согласуется с механикой выше: больше page split’ов → больше full-page image.

Размер первичного индекса. idx_bytes у uuid7 (15 785 984) больше, чем у bigint (11 255 808) в 1.4 раза (+40%) — но при этом leaf_density та же (89.98), что у bigint. Разница в размере — не штраф за случайность (которого здесь нет, uuid7 монотонен так же, как bigint), а прямая плата за ширину ключа: 16 байт против 8. Обратите внимание: сама ширина ключа выросла вдвое, а индекс — только на 40%, потому что ключ — не единственное содержимое строки индекса; есть постоянные per-tuple накладные расходы B-tree (заголовок строки индекса, указатели, выравнивание), которые не зависят от ширины ключа, поэтому размер индекса не масштабируется линейно с ней. uuid4 при этом крупнее ещё сильнее (19 398 656) — тут складываются оба эффекта: и ширина ключа, и худшая плотность заполнения от случайности.

Здесь стоит остановиться и не проговорить лишнего: в этом же прогоне wal_bytes у uuid7 (113 996 968) оказался ниже, чем у bigint (172 378 000) — то есть монотонный UUID с вдвое более широким ключом дал меньше WAL, чем bigint с более узким. FIXTURES этого прогона не объясняют этот конкретный разрыв (сравнение WAL по знаку эффекта в артефакте сделано только между uuid4 и bigint), и додумывать причину — значит выйти за рамки того, что стенд реально измерил. Число приводится как есть, без гипотез о причине: чтобы утверждать её, нужен отдельный целевой замер — раскладка WAL по типам записей (доля full-page images), фактическое расположение чекпойнтов относительно прогона, счётчики разделений страниц и попаданий в кэш. Пока такого замера нет, честнее оставить разрыв необъяснённым, чем предложить правдоподобное объяснение, которое стенд не проверял.

Итог раздела: у случайного ключа хуже локальность вставки и больше WAL по сравнению с монотонным того же размера; у монотонного, но более широкого ключа (UUIDv7 против bigint) локальность сохраняется, а платить приходится только шириной первичного индекса — в 1.4 раза (+40%), то есть меньше, чем удвоение самой ширины ключа (8→16 байт), из-за постоянных per-tuple накладных расходов B-tree.

Вторичные индексы: PostgreSQL против MySQL/InnoDB

Частый аргумент против UUID-первичных-ключей — «раздувает все вторичные индексы, потому что PK там тоже хранится». Стенд проверяет это на двух движках с одинаковым N = 500 000.

Движок вторичный индекс, bigint вторичный индекс, uuid отношение uuid/bigint
PostgreSQL 18.4 3850240 3850240 1.000
MySQL/InnoDB 8.4.10 16367616 18481152 1.129

В PostgreSQL оба индекса — 3 850 240 байт, побайтово равны (отношение 1.000). В MySQL/InnoDB — 16 367 616 против 18 481 152, разница +12.9%.

Причина разницы — в архитектуре хранения, а не в самом UUID. В PostgreSQL куча (heap) не кластеризована по первичному ключу: строки лежат в файле таблицы в порядке вставки/обновления, а не в порядке PK. Вторичный B-tree индекс хранит пару (значение индексируемой колонки, ctid), где ctid — физический адрес строки в куче, фиксированные 6 байт независимо от типа и ширины первичного ключа. Поэтому ширина PK на размер вторичного индекса в PostgreSQL не влияет вообще.

В MySQL/InnoDB таблица физически кластеризована по первичному ключу (это и есть сам clustered index — данные лежат внутри B-tree, построенного по PK). Вторичный индекс в такой архитектуре не может ссылаться на физический адрес строки, потому что строки при любой операции, меняющей порядок PK-дерева, физически перемещаются, — вместо этого вторичный индекс хранит (значение индексируемой колонки, значение PK). Значит, каждая запись каждого вторичного индекса содержит полную копию PK. 16-байтный UUID-PK попадает в каждый leaf каждого вторичного индекса и раздувает его относительно 8-байтного bigint-PK — отсюда и +12.9% на стенде.

Миф «UUID как PK раздувает вторичные индексы» — верен для MySQL/InnoDB и не верен для PostgreSQL. В PostgreSQL цена случайного или просто широкого UUID-ключа — не во вторичных индексах, а в первичном (раздел выше: bloat, WAL, размер).

Оговорка из FIXTURES: сравнение между PG и MySQL — по наличию/отсутствию эффекта, не по абсолютным величинам; разная архитектура хранения делает прямое сравнение байт-в-байт бессмысленным (абсолютные размеры 3 850 240 и 16 367 616 не сравнивать друг с другом напрямую — сравнивать только отношение uuid/bigint внутри каждого движка).

MongoDB _id: ObjectId против UUID

MongoDB генерирует _id по умолчанию как ObjectId — 12-байтную, приблизительно монотонную структуру, разобранную в статье #1. Стенд сравнивает индекс _id при ObjectId и при явно заданном UUID (BinData subtype 4, случайный) на N = 200 000, payload 64 байта, insertMany(ordered:false).

_id тип index bytes отношение к ObjectId
ObjectId (12 байт, монотонный) 1990656 1.00×
UUID (16 байт, случайный, BinData subtype 4) 4972544 2.50×

UUID-индекс _id крупнее ObjectId-индекса примерно в 2.5 раза — эффект того же знака, что в PostgreSQL: случайный ключ хуже ложится в структуру индекса (WiredTiger B-tree под капотом), чем монотонный.

Отдельно стоит методологическая оговорка, важная не только для чисел этого раздела, но и как общее предупреждение при измерении размера индексов WiredTiger: размер индекса снимался после принудительного db.adminCommand({fsync:1}). Без принудительного fsync размер _id-индекса читался как размер ещё не сброшенного на диск WiredTiger-чекпойнта (интервал чекпойнта по умолчанию ~60 с) — индекс выглядел искусственно маленьким (порядка одной страницы), а разница между ObjectId и UUID завышалась до ≈11×. После fsync разница честная — 2.50×, а не ≈11×. Мораль простая: если снимать размер индекса WiredTiger сразу после массовой вставки без принудительного сброса на диск, число будет артефактом момента снятия замера, а не свойством самих данных.

Шардирование: когда монотонность — не проблема, а когда — проблема

Здесь начинается контринтуитивная часть. «Монотонный ключ создаёт горячую партицию/шард» — расхожая мудрость, которая на разных движках оказывается то честным мифом, то реальным риском — в зависимости от того, что именно движок делает с ключом перед распределением по узлам.

ScyllaDB: честный негатив

ScyllaDB (партиционер по умолчанию — Murmur3Partitioner) всегда хеширует partition key перед тем, как определить, какому узлу/токену он принадлежит. Стенд (N = 20 000, replication_factor=1, однонодовый учебный стенд) сравнивает диапазон токенов (token span), покрытый случайным (UUID) и монотонным (bigint) partition key:

partition key token span
random (uuid) 18446236913449967981
monotonic (bigint) 18445925300228265653

Полный диапазон токенов Murmur3 (FULL = 2⁶⁴ − 1) ≈ 18446744073709551615. Оба span покрывают ≈ 0.99997·FULL — разница между random и monotonic составляет ≈ 0.0017%, что внутри шума, не эффект.

Это честный негатив: исходная гипотеза «монотонный ключ = узкий диапазон токенов» на стенде не подтвердилась ни разу за несколько прогонов. Под Murmur3-хешированием монотонный partition key покрывает такой же широкий диапазон токенов на кольце, как случайный, — потому что хеш-функция уничтожает порядок значения ключа ещё до того, как токен попадает на кольцо. Равномерность распределения значений внутри этого диапазона — свойство самой хеш-функции, а не то, что стенд измерил напрямую (измерялся span, не гистограмма распределения).

Миф «монотонный partition key = горячая партиция в Scylla/Cassandra» неверен в общем виде. Реальная горячая партиция в Scylla возникает от повторного использования одного и того же (или немногих) значений ключа, а не от того, что ключ монотонно растёт, — это принципиально другой механизм, чем физическая локальность B-tree в PostgreSQL: там хеширования нет, порядок ключа определяет физическое расположение страниц, здесь хеширование стирает порядок до распределения.

MongoDB ranged: реальный обратный трейдоф

У MongoDB хеширование shard key — не встроенное поведение, а выбор типа shard key: ranged (значение как есть, диапазонное разбиение на чанки) или hashed (значение предварительно хешируется). Стенд поднимает шардированный кластер (mcfg config server RS, msh1/msh2 — по одному узлу на шард, mongos-роутер), выключает балансировщик перед вставкой, предварительно разбивает обе коллекции на чанки на обоих шардах, и вставляет N = 2000 документов с одним и тем же монотонно растущим sk (0..N−1) в две коллекции — единственное отличие между ними: тип shard key (1 — ranged, "hashed" — hashed).

Тип shard key s1 s2 max-шард
ranged (sk: 1) 0 (0.0%) 2000 (100.0%) 100.0%
hashed (sk: "hashed") 1038 (51.9%) 962 (48.1%) 51.9%

При ranged shard key все 2000 записей (100.0%) попадают в один шард (s2) — несмотря на то что чанки предварительно созданы на обоих шардах, монотонно растущий ключ физически льётся в последний по диапазону чанк, потому что каждое новое значение больше всех предыдущих, а значит принадлежит крайнему правому диапазону. При hashed shard key на тех же самых значениях sk распределение почти ровное: 1038 (51.9%) на одном шарде, 962 (48.1%) — на другом.

Это и есть реальный обратный трейдоф, которого нет у ScyllaDB: Scylla хеширует partition key всегда, безусловно, поэтому у неё «монотонный ключ = горячий узел» не воспроизводится в принципе (раздел выше). У MongoDB хеширование — опциональный выбор схемы: ranged-выбор для монотонно растущего ключа реально создаёт горячий шард (в этом стенде — 100% нагрузки на один узел), а hashed-выбор для того же самого ключа устраняет проблему тем же самым механизмом, что у Scylla работает всегда. Разбор шардирования MongoDB отдельно и подробно — в «MongoDB: шардирование в проде», общая теория шардирования — в «Шардировании на практике».

Практический вывод из двух артефактов вместе: вопрос не «монотонный ключ — это плохо для шардирования», а «хешируется ли ключ перед распределением по узлам, и если да — обязательно ли». Там, где хеширование обязательно и не настраивается (Scylla/Cassandra) — можно спокойно использовать монотонный, k-sortable идентификатор вроде UUIDv7 и не бояться горячего узла по одной лишь причине монотонности. Там, где хеширование опционально (MongoDB ranged vs hashed), выбор типа shard key для монотонного ключа — отдельное архитектурное решение, а не деталь реализации.

Честные оговорки

Прежде чем переходить к сводке, важно проговорить границы того, что стенд на самом деле показывает.

Все числа — с одного хоста. Все узлы всех артефактов выше — контейнеры на одном физическом хосте (Docker Desktop, Windows), без реальной сетевой латентности, без конкуренции за диск между независимыми инстансами, без многоузловой топологии. Числа показывают характер эффекта — знак, наличие или отсутствие, — а не его величину на проде. insert rate, wal_bytes, idx_bytes в статье — не прогноз того, что вы увидите на конкретном проде; это подтверждение того, что эффект вообще существует и в какую сторону.

Cross-engine сравнение — по эффекту, не по байтам. Сравнение PostgreSQL и MySQL/InnoDB (раздел про вторичные индексы) валидно как «есть эффект / нет эффекта» — разная архитектура хранения делает прямое сравнение абсолютных чисел (3 850 240 против 16 367 616) бессмысленным; сравнивать корректно только отношение uuid/bigint внутри каждого движка.

Не все read-паттерны страдают от случайности. Всё выше — о цене на вставке и о размере индекса. Точечное чтение по случайному UUID (WHERE id = $1) через тот же B-tree индекс работает без деградации — индекс одинаково находит строку что по bigint, что по случайному UUID за O(log n). Цена случайности — именно во вставке (page splits, WAL) и в размере (16 байт вместо 8), а не в скорости точечного поиска по готовому индексу.

Когда 16 байт не имеют значения. Если таблица небольшая (десятки-сотни тысяч строк, а не сотни миллионов) или read/write-профиль не упирается в throughput вставки, разница в 8 байт на ключ и связанный с ней bloat может быть меньше, чем выгода от децентрализованной генерации без похода в БД (см. статью #1 про то, кто генерирует идентификатор и зачем это иногда важнее размера).

Итоги: что переключить

Сводка по осям, разобранным в статье — что конкретно меняется при переходе со случайного идентификатора на монотонный или наоборот, и в какой системе на это стоит смотреть:

Ось Что происходит Где смотреть
Локальность вставки (PostgreSQL) Случайный ключ (uuid4) → ниже leaf_density (73.27 против 89.98), больше WAL раздел «Стенд PostgreSQL»
Размер первичного индекса (PostgreSQL) Монотонный, но широкий ключ (uuid7) → та же плотность, что у bigint, но индекс крупнее в 1.4 раза (+40%) при ключе 16 байт против 8 раздел «Стенд PostgreSQL»
Вторичные индексы (MySQL/InnoDB) Широкий UUID-PK раздувает КАЖДЫЙ вторичный индекс (+12.9%) — из-за кластеризации по PK раздел «Вторичные индексы»
Вторичные индексы (PostgreSQL) Ширина PK не влияет — индексы побайтово равны раздел «Вторичные индексы»
Индекс _id (MongoDB) Случайный UUID _id → индекс в 2.50× крупнее ObjectId; замер обязательно после fsync раздел «MongoDB _id»
Распределение по узлам (ScyllaDB) Хеширование partition key всегда — монотонность безопасна (span-разница ≈0.0017%, шум) раздел «Шардирование»
Распределение по шардам (MongoDB) Хеширование опционально: ranged + монотонный ключ → 100% в один шард; hashed → 51.9%/48.1% раздел «Шардирование»

Один и тот же выбор — «случайный или монотонный идентификатор» — даёт разную цену в зависимости от движка и схемы распределения: в PostgreSQL плата за случайность — в первичном индексе, в MySQL/InnoDB — во всех вторичных, в MongoDB — в размере _id-индекса и (при неверном выборе типа shard key) в горячем шарде, а в ScyllaDB монотонность не создаёт проблемы вообще, потому что хеширование обязательно. Следующая статья серии, «Идентификаторы в разных языках и БД», переходит от «какую схему выбрать» к «как её реализовать»: генерация UUIDv7/ULID/Snowflake на Go, Java и Rust, и типы колонок PostgreSQL (uuid против bytea против text) с ценой ::text-каста, ломающего индекс, — тот самый эффект, который уже разобран для ORM-параметров в статье об индексах и языках/ORM.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии