
Случайные UUIDv4 удобны: глобально уникальны, легко генерируются где угодно. Но у них есть один неприятный эффект для баз данных: вставки идут в произвольные места B‑Tree индекса. В итоге:
Монотонные идентификаторы — это такие, которые в среднем растут по времени. Их главное свойство: новые записи попадают в «хвост» индекса, уменьшается число разбиений страниц и повышается локальность. Практически это даёт:
В бизнес‑терминах: быстрее ответы API, стабильнее пики нагрузки, меньше расходы на базу и инфраструктуру.
Рассмотрим популярные подходы к «монотонным» идентификаторам.
Плюсы:
Минусы:
Вывод: хорош для монолитной БД, но ограничивает архитектуру и публикуемость ID.
Плюсы:
Минусы:
Вывод: удобно для публичных URL и логов, если важно удобочитаемо и сортируемо. В БД лучше хранить как байты, если нужна компактность.
Плюсы:
Минусы:
Вывод: отличный «дефолт» для распределённых систем и PostgreSQL.
Плюсы:
Минусы:
Вывод: оправдано при очень высоких RPS и когда критична компактность bigint; сложнее в эксплуатации.
Ключевые рекомендации:
-- Таблица заказов с 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;
Такой пейджинг качественно разгружает базу, особенно на больших наборах данных.
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; убедитесь, что используете актуальную версию.
// 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);
})();
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.
Менять первичный ключ на лету — рискованная операция. Лучше действовать прагматично:
Новые таблицы — сразу на UUIDv7/ULID. Проще всего начинать с новых сущностей.
Добавить «монотонный ключ сортировки». Если сейчас сортируете по 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).
Практический совет: если меняете только способ сортировки (а не внешний идентификатор), используйте отдельный sort_id — так вы избегаете трогать внешние ключи и контракты API.
UUIDv4 — простой, но дорогой для индексов выбор. Монотонные идентификаторы вроде UUIDv7 и ULID дают реальную экономию: меньше раздувание индексов, выше локальность, быстрее вставки и сортировки «по времени». Для PostgreSQL наиболее практичен UUIDv7: он совместим с типом uuid, хорошо ложится в существующую архитектуру и улучшает производительность без сложных миграций. Начните хотя бы с новых таблиц и переводите пейджинг на keyset — выгода станет заметна быстро, а риски минимальны.