Прошлая статья серии дала словарь и оси сравнения, но намеренно не привела ни одного числа. Здесь — числа. Один и тот же тезис — «случайный идентификатор хуже монотонного для вставки» — разбирается на честном стенде, и сразу выясняется, что цена случайности не одна и не универсальная: в 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). Общая оговорка, которая относится ко всем разделам: все узлы — на одном хосте, без сетевой латентности и конкуренции за диск между инстансами. Числа показывают характер эффекта — знак, наличие или отсутствие, — а не его величину на проде.
В статье
- Механика: почему случайный ключ дороже монотонного
- Стенд PostgreSQL: локальность, bloat, WAL
- Вторичные индексы: PostgreSQL против MySQL/InnoDB
- MongoDB
_id: ObjectId против 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.
Источники
- «Выбор идентификатора: bigint, UUID, ULID, Snowflake» — статья #1 серии, словарь и оси сравнения
- «Индексы в БД: зачем нужны, какие бывают и как устроены» — устройство B-tree
- «Индексы и языки/ORM: зависимость и особенности» —
::text-каст, ломающий индекс - «Шардирование на практике»
- «MongoDB: шардирование в проде»
- PostgreSQL Documentation — Write-Ahead Logging (WAL)
- PostgreSQL 18 Release Notes — встроенная функция
uuidv7() - MySQL Documentation — Clustered and Secondary Indexes (InnoDB)
- MongoDB Manual — Sharding: Ranged vs Hashed Shard Keys
- ScyllaDB Documentation — Murmur3Partitioner
- Стенд:
digital-cookbook/databases/identifiers— PostgreSQL 18.4, MySQL/InnoDB 8.4.10, MongoDB 8.3.4, ScyllaDB 2026.2.1
Комментарии