Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

UUIDv7 и ULID: быстрее индексы в PostgreSQL и сортировка по времени — меньше задержки и расходы на базу

Разработка и технологии18 января 2026 г.
Случайные UUIDv4 тормозят индексы: записи попадают в разные страницы, растут накладные расходы, падает пропускная способность. Разбираем, зачем переходить на монотонные идентификаторы (UUIDv7/ULID), чем они отличаются от автоинкремента и Snowflake, как выбрать и внедрить без боли. В статье — примеры кода, DDL для PostgreSQL, чек‑лист внедрения и типичные ловушки.
UUIDv7 и ULID: быстрее индексы в PostgreSQL и сортировка по времени — меньше задержки и расходы на базу

  • Содержание
    • Зачем менять случайный UUIDv4 на монотонные идентификаторы
    • Варианты: UUIDv7, ULID, Snowflake и автоинкремент
    • Что выбрать для вашего кейса
    • PostgreSQL: схема, индексы и запросы
    • Примеры кода: Go (UUIDv7), Node.js (ULID), Python (UUIDv7)
    • Миграция без даунтайма: стратегии
    • Безопасность и приватность
    • Частые ошибки и как их избежать
    • Чек‑лист внедрения

Зачем менять случайный UUIDv4 на монотонные идентификаторы

Случайные UUIDv4 удобны: глобально уникальны, легко генерируются где угодно. Но у них есть один неприятный эффект для баз данных: вставки идут в произвольные места B‑Tree индекса. В итоге:

  • Разбиваются страницы индекса (частые split), возрастает фрагментация и размер индекса.
  • Падает эффективность кэширования страниц: «горячий» набор страниц чаще вытесняется.
  • Снижается скорость вставки и выборок по индексу, растёт нагрузка на диск и память.

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

  • До заметного ускорения вставок и упорядоченных выборок (ORDER BY id DESC).
  • Меньше перерасхода на хранение индекса, ниже давление на автovacuum.
  • Удобную естественную сортировку «по времени» без дополнительного столбца.

В бизнес‑терминах: быстрее ответы API, стабильнее пики нагрузки, меньше расходы на базу и инфраструктуру.

Варианты: UUIDv7, ULID, Snowflake и автоинкремент

Рассмотрим популярные подходы к «монотонным» идентификаторам.

Автоинкремент (SERIAL/BIGSERIAL)

Плюсы:

  • Простой, компактный (64 бита), быстрые индексы.

Минусы:

  • Точка координации в одной базе/шарде; сложно генерировать вне БД и в распределённых системах.
  • Предсказуемость: по публичному ID можно оценить объёмы (не всегда приемлемо).
  • Миграции и репликации между кластерами требуют аккуратного распределения диапазонов.

Вывод: хорош для монолитной БД, но ограничивает архитектуру и публикуемость ID.

ULID (Universally Unique Lexicographically Sortable Identifier)

Плюсы:

  • Сортируется лексикографически по времени, человеко‑читаемая форма (Base32 без похожих символов).
  • Генерация в любом сервисе без централизованного узла.

Минусы:

  • Это строка; в PostgreSQL как text занимает больше места, чем uuid, и индекс тяжелее.
  • Для строгой монотонности внутри одной миллисекунды нужна «монотонная фабрика» (счётчик).

Вывод: удобно для публичных URL и логов, если важно удобочитаемо и сортируемо. В БД лучше хранить как байты, если нужна компактность.

UUIDv7 (черновик стандарта IETF, уже де‑факто принят сообществом)

Плюсы:

  • Встраивает метку времени (мс) в привычный тип uuid (128 бит), сортировка по времени при обычном ORDER BY id.
  • Хорошо ложится в тип uuid PostgreSQL, экономичнее, чем хранить строковый ULID.
  • Не требует централизованного генератора, устойчив к конфликтам даже при высоких нагрузках.

Минусы:

  • Требуются библиотеки/реализации (они уже есть в популярных языках);
  • Время частично раскрывается внутри ID (см. раздел про приватность).

Вывод: отличный «дефолт» для распределённых систем и PostgreSQL.

Snowflake‑подобные 64‑битные ID

Плюсы:

  • Компактный числовой формат (bigint), строгая монотонность, удобно для шардирования.

Минусы:

  • Нужны генераторы (воркеры) с конфигурацией и защитой от сбоев часов.
  • Управление пулом генераторов — отдельная подсистема.

Вывод: оправдано при очень высоких RPS и когда критична компактность bigint; сложнее в эксплуатации.

Что выбрать для вашего кейса

  • Если у вас PostgreSQL и нужны глобально уникальные ID без центрального генератора — берите UUIDv7. Это хороший баланс скорости, простоты и совместимости.
  • Если ID видят пользователи и важна «красивость» и лексикографическая сортировка в строковом виде — ULID. Внутри БД можно хранить бинарную форму, наружу отдавать строку.
  • Если нужен компактный числовой ключ и экстремальная производительность с контролем кластеров — Snowflake/Leaf‑подобные схемы.
  • SERIAL/BIGSERIAL — только для простых одно-БД проектов, где ID не покидают систему.

PostgreSQL: схема, индексы и запросы

Ключевые рекомендации:

  • Храните UUID как тип uuid, а не text. Индекс компактнее, операции быстрее.
  • Для сортировки «последние сначала» можно просто использовать ORDER BY id DESC с UUIDv7 — это будет почти эквивалентно «по времени создания».
  • Для выборок «пейджинга» используйте keyset‑подход по id, а не OFFSET/LIMIT. Пример:
-- Таблица заказов с UUIDv7, генерируем ID в приложении
CREATE TABLE IF NOT EXISTS orders (
  id uuid PRIMARY KEY,
  user_id uuid NOT NULL,
  amount_cents integer NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

-- Индекс по пользователю и времени — для выборок истории
CREATE INDEX IF NOT EXISTS idx_orders_user_time ON orders (user_id, id DESC);

-- Выборка первых N заказов пользователя (последние сначала)
SELECT id, amount_cents, created_at
FROM orders
WHERE user_id = $1
ORDER BY id DESC
LIMIT 50;

-- Продолжение листинга по последнему полученному id (keyset)
SELECT id, amount_cents, created_at
FROM orders
WHERE user_id = $1
  AND id < $2  -- $2 = last_seen_id
ORDER BY id DESC
LIMIT 50;

Такой пейджинг качественно разгружает базу, особенно на больших наборах данных.

Примеры кода: Go (UUIDv7), Node.js (ULID), Python (UUIDv7)

Go: генерация UUIDv7 и запись в PostgreSQL

package main

import (
    "database/sql"
    "fmt"
    "log"

    _ "github.com/lib/pq"
    "github.com/google/uuid"
)

func main() {
    dsn := "postgres://postgres:postgres@localhost:5432/app?sslmode=disable"
    db, err := sql.Open("postgres", dsn)
    if err != nil { log.Fatal(err) }
    defer db.Close()

    // Генерация UUIDv7
    id, err := uuid.NewV7()
    if err != nil { log.Fatal(err) }

    // Пример вставки
    var orderID uuid.UUID = id
    _, err = db.Exec(`INSERT INTO orders (id, user_id, amount_cents) VALUES ($1, $2, $3)`,
        orderID, uuid.New(), 1999,
    )
    if err != nil { log.Fatal(err) }

    fmt.Println("Inserted order:", orderID)
}

Примечание: пакет github.com/google/uuid поддерживает UUIDv7; убедитесь, что используете актуальную версию.

Node.js: ULID и keyset‑пагинация

// package.json: { "type": "module" }
import { createPool } from 'mysql2/promise';
import { monotonicFactory } from 'ulid';

const ulid = monotonicFactory();
const pool = createPool({
  host: 'localhost', user: 'root', password: 'root', database: 'app',
});

// В MySQL/SQLite ULID часто хранят как CHAR(26). В PostgreSQL лучше хранить как BYTEA.
// Здесь просто демонстрация генерации и пейджинга по строке.

async function insertOrder(userId, amountCents) {
  const id = ulid();
  await pool.execute(
    'INSERT INTO orders_ulid (id, user_id, amount_cents, created_at) VALUES (?, ?, ?, NOW())',
    [id, userId, amountCents]
  );
  return id;
}

async function listOrdersPage(userId, lastId = null, limit = 50) {
  if (!lastId) {
    const [rows] = await pool.execute(
      'SELECT id, user_id, amount_cents, created_at FROM orders_ulid WHERE user_id = ? ORDER BY id DESC LIMIT ?',[userId, limit]
    );
    return rows;
  }
  const [rows] = await pool.execute(
    'SELECT id, user_id, amount_cents, created_at FROM orders_ulid WHERE user_id = ? AND id < ? ORDER BY id DESC LIMIT ?',
    [userId, lastId, limit]
  );
  return rows;
}

(async () => {
  const user = 'user-1';
  const id = await insertOrder(user, 2599);
  console.log('Inserted', id);
  const page = await listOrdersPage(user);
  console.log('Page size:', page.length);
  process.exit(0);
})();

Python: UUIDv7 и вставка в PostgreSQL

import psycopg2
from uuid6 import uuid7  # pip install uuid6

conn = psycopg2.connect("dbname=app user=postgres password=postgres host=localhost port=5432")
conn.autocommit = True

with conn.cursor() as cur:
    order_id = uuid7()
    user_id = uuid7()  # можно и v4, но для однородности используем v7
    cur.execute(
        """
        INSERT INTO orders (id, user_id, amount_cents) VALUES (%s, %s, %s)
        """,
        (str(order_id), str(user_id), 3499)
    )
    print("Inserted order:", order_id)

conn.close()

Эти примеры показывают простейшую схему: ID генерируется в приложении и без конфликтов попадает в uuid‑поле PostgreSQL.

Миграция без даунтайма: стратегии

Менять первичный ключ на лету — рискованная операция. Лучше действовать прагматично:

  1. Новые таблицы — сразу на UUIDv7/ULID. Проще всего начинать с новых сущностей.

  2. Добавить «монотонный ключ сортировки». Если сейчас сортируете по id (UUIDv4), добавьте столбец sort_id uuid NOT NULL, генерируйте UUIDv7 для новых строк и индексируйте:

ALTER TABLE events ADD COLUMN sort_id uuid;
UPDATE events SET sort_id = gen_random_uuid(); -- временно, если нужно заполнить; но это v4 и не даёт выигрыша для старых
ALTER TABLE events ALTER COLUMN sort_id SET NOT NULL;
CREATE INDEX IF NOT EXISTS idx_events_sort ON events (sort_id DESC);

Далее переводите запросы на ORDER BY sort_id. Для старых записей выигрыш будет скромным, но новые будут вставляться монотонно и не раздувать индекс. В будущем можно пересоздать таблицу «в тени» и аккуратно перелить данные с новыми ключами (см. стратегию shadow table).

  1. «Теневая» таблица и переключение. Создаёте новую таблицу с UUIDv7 как PK, на время пишете в обе (двойная запись), читаете из старой. Когда накопите достаточный буфер и проверите целостность, переключаете чтение на новую, затем мигрируете хвост и отключаете двойную запись. Этот путь потребует больше работы (и у нас уже была отдельная статья про безостановочные миграции), но он самый чистый.

Практический совет: если меняете только способ сортировки (а не внешний идентификатор), используйте отдельный sort_id — так вы избегаете трогать внешние ключи и контракты API.

Безопасность и приватность

  • UUIDv7 и ULID частично раскрывают время создания объекта. Если ID публичный (в URL), это может выдать приблизительный объём операций и динамику. Смягчение: используйте отдельные публичные токены или ULID только для внутренних ссылок.
  • Монотонность не делает ID «угадываемым»: остаётся достаточная случайная часть, чтобы избежать коллизий и подбора.
  • Если у вас строгие требования по конфиденциальности, где даже «намёк на время» нежелателен, оставьте UUIDv4 или выпускайте отдельные непрозрачные токены для внешнего мира.

Частые ошибки и как их избежать

  • Хранить ULID как text в PostgreSQL и удивляться тяжёлому индексу. Лучше хранить бинарно (bytea) или использовать UUIDv7 в типе uuid.
  • Генерировать UUID в БД функцией gen_random_uuid() и считать, что это «как v7». Нет, это v4 (случайный). Генерацию v7 делайте в приложении или через профильный расширение/функцию.
  • Смешивать типы ключей в связях. Если одна таблица на ULID‑строках, а другая на uuid — приведите к единому представлению на границе (конвертируйте ULID в 16 байт перед записью, если нужна компактность).
  • Опора на «монотонность» для строгой сортировки при высокой конкуренции. Помните, что в одном миллисекундном слоте несколько событий могут иметь одинаковую часть времени. Для стабильного порядка добавляйте tie‑breaker (например, вторым ключом id или created_at).

Чек‑лист внедрения

  • Принять решение по формату: UUIDv7 — по умолчанию; ULID — для внешнего строкового вида; Snowflake — при требовании компактного bigint и контролируемых генераторах.
  • Обновить библиотеки в сервисах: убедиться, что генерация v7/ULID есть и покрыта тестами.
  • В PostgreSQL использовать тип uuid для UUIDv7; для ULID предпочесть бинарное хранение или отдельный тип/модуль.
  • Перевести критичные запросы листинга на keyset‑пагинацию по новому ключу.
  • Прогнать нагрузочные тесты: оценить размер индексов и время вставки/выборки до/после.
  • Обновить алерты и дашборды: следить за размерами индексов, вакуумом, p99 вставок/чтений.
  • Продумать политику публичности ID (нужны ли отдельные токены для внешних URL).

Краткий итог

UUIDv4 — простой, но дорогой для индексов выбор. Монотонные идентификаторы вроде UUIDv7 и ULID дают реальную экономию: меньше раздувание индексов, выше локальность, быстрее вставки и сортировки «по времени». Для PostgreSQL наиболее практичен UUIDv7: он совместим с типом uuid, хорошо ложится в существующую архитектуру и улучшает производительность без сложных миграций. Начните хотя бы с новых таблиц и переводите пейджинг на keyset — выгода станет заметна быстро, а риски минимальны.


postgresqluuidv7ulid