← На главную

Хватит секционировать по дате: переходи на id и фоновый сервис

01.07.2026 13:05 · hackernews

Секционирование таблицы по дате — это ловушка. Команда разбила таблицу orders по created_at, а потом удивилась, почему запрос SELECT * FROM orders WHERE id = 12345 читает все 36 секций. В EXPLAIN — простыня из Partitions: orders_p2025_01, orders_p2025_02.... Типичное «лечение» — добавить created_at >= '2024-11-01' в запрос. Работает. Потом то же самое делают в админке, в миграциях, в тулзах. Через три месяца в код-ревью обязательно спрашивают: «фильтр по дате добавил?». Ключ секционирования из технического решения превратился в контракт для каждого запроса.

Проблема глубже. И PostgreSQL, и MySQL требуют, чтобы ключ секционирования входил в первичный ключ. Секционируешь по created_at — получаешь PRIMARY KEY (id, created_at). id перестаёт быть уникальным сам по себе. База спокойно примет две строки с одинаковым id, если у них разные временные метки. Отдельный UNIQUE (id) не поставишь — он тоже обязан включать колонку секционирования. Уникальность принесена в жертву.

С планами запросов та же беда. Раньше WHERE id = 1 был поиском за константное время (const), а JOIN по id — быстрейшим eq_ref. Теперь это ref — сканирование по префиксу индекса, которое может вернуть несколько строк. Оптимизатор уже не гарантирует «одну строку», а полагается на статистику. Чтобы вернуть const, нужно в каждом запросе указывать полный ключ: WHERE id = 1 AND created_at = '2026-04-01 12:34:56'. Опять утечка даты в код.

Решение, которое предлагает автор — секционировать по первичному ключу. Для BIGINT AUTO_INCREMENT идентификаторы монотонно растут, что идеально для RANGE-секционирования. CREATE TABLE orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, ...) PARTITION BY RANGE (id). Все запросы с фильтром по id получают отсечение партиций бесплатно, без единого изменения в коде приложения. Точечные SELECT попадают ровно в одну секцию.

Да, границы не привязаны ко времени напрямую, но это решается маленьким фоновым сервисом. Он раз в час (или реже) проверяет размер активной секции, и когда она заполняется, выполняет SELECT MAX(id) FROM orders WHERE created_at < '2026-03-01', чтобы найти ID-границу для нужного месяца. Потом делает ALTER TABLE ... REORGANIZE PARTITION — режет пустой MAXVALUE-кэтчолл на новую именованную секцию и новый пустой MAXVALUE. Пока кэтчолл пуст, операция чисто метаданная. Сервис же и дропает старые секции по тому же принципу — переводит дату в ID и удаляет всё, что ниже.

Сравнение с существующими инструментами: pg_partman и TimescaleDB завязаны на колонку времени. Для наблюдаемости или телеметрии, где каждый запрос уже фильтрует по дате, это правильно. Но для OLTP со смешанной нагрузкой подход с секционированием по id и сервисом-планировщиком оказывается дешевле, чем растаскивание AND created_at >= ? по всей кодовой базе.

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

Главный вывод: ключом секционирования должна быть колонка, которая уже есть в каждом значимом запросе. Обычно это первичный ключ. Или tenant_id в мультитенантной системе. Или дата во временных рядах. Иначе он просочится в код навсегда.

Читать оригинал →