Партиционирование PostgreSQL: опыт команды Геосервисов
- пятница, 27 февраля 2026 г. в 00:00:12
Всем привет! Поводом для написания этой статьи послужила ситуация, с которой мы в команде Геосервисов столкнулись.
Когда наша база данных нормализованных OSM-данных достигла размеров в 600+ ГБ, VACUUM стал занимать 6+ часов. Мы начали приближаться к пределу хранилища (600 GB из 1 TB), а производительность запросов деградировала. Стало очевидным — партиционирование неизбежно.
Масштаб задачи:
760M nodes (179 GB data + 170 GB indexes)
800M ways (120 GB data + 42 GB indexes)
5B way_nodes (~150 GB)
Изначальная оценка времени миграции: 40-70 часов. Мы выбрали подход shadow tables с zero downtime и автоматическими алертами.
В этой статье: как мы готовили миграцию, какие грабли собрали, и почему для управления долгоживущим процессом недостаточно стандартного мониторинга.
Мы храним данные OpenStreetMap в нормализованном виде, и вот что это значит.
Структура OSM:
nodes — точки на карте (координаты, теги);
ways — линии дорог (состоят из node-ов);
way_nodes — связь ways ↔ nodes (5B строк!).
Почему это боль:
Одна дорога = сотни node-ов;
Удаление/обновление дороги = обновление десятков таблиц;
Данные растут геометрически: 185M nodes → 800M ways → 5B way_nodes;
VACUUM на таких таблицах — часы простоя (downtime).
Партиционирование по H3 (геоиндекс) разделяет мир на ячейки.
H3 — это иерархическая система гексагонов от Uber. Каждый уровень делит ячейки предыдущего уровня на 7 частей. Чем выше уровень, тем мельче ячейки, тем больше партиций.
H3 Level | Количество ячеек | Средняя площадь одной ячейки |
0 | 122 | 4,357,449 км² (половина России) |
1 | 842 (≈122×7) | 609,788 км² (примерно размер Франции) |
2 | 5,882 | 86,802 км² (размер Австрии) |
3 | 41,162 | 12,393 км² (размер Москвы) |
4 | 288,122 | 1,770 км² |
5 | 2,016,842 | 253 км² |
Данные из официальной документации H3.
Мы выбрали H3 Level 0 — 122 гексагона покрывают всю Землю.
Level 1 дал бы 842 таблицы на каждую сущность (nodes, ways, relations). Level 2 — 5,882 таблиц. Для нас это феноменально много: PostgreSQL должен обслуживать тысячи таблиц, а это overhead на метаданные, autovacuum, статистику. А вот 122 партиции — удобное количество, VACUUM на одной партиции не блокирует остальные.
Да, большинство партиций пустые или почти пустые. OpenStreetMap данные распределены неравномерно: плотно в городах, разрежено в океанах/пустынях.
Но это было известно заранее, и это нормально:
пустые партиции не занимают много места (только метаданные, почти нет данных);
если масштабироваться на другие страны/континенты — партиции заполнятся;
122 таблицы для PostgreSQL — это хорошо (не тысячи).
Если бы мы выбрали Level 1/2, получили бы тысячи таблиц. Это было бы больновато контролировать. Но в будущем можно будет выборочно партицировать некоторые L0-партиции по L2-L3.
Это тоже плюс L0-партиционирования — можно позже «дробить» отдельные регионы.
Level 0 = правильный баланс. Пустые партиции — «плата» за масштабируемость в будущем.
Метрика | До партиционирования | После партиционирования |
VACUUM duration | 6+ часов | ~18 минут |
Disk space | 700GB / 1TB (70%) | 480GB / 1TB (48%) |
SELECT по h3_l0 (p95) | 2.3s | 340ms |
JOIN nodes ↔ ways (p95) | 8.5s | 1.2s |
Автоматический VACUUM | Отключён (лагал) | Включён, работает |
Когда мы начали планировать партиционирование, рассматривали три варианта.
Классический подход: поднять новую базу, перелить данные, переключить приложения.
Спойлер: к этому и придём
Плюсы:
чистая схема с нуля;
не нужно думать о shadow tables;
можно параллелить pg_dump с -j 8.
Минусы:
требуется вторая база данных (ещё 700GB+ storage);
downtime на переключение сервисов (оказалось проще чем shadow table);
нужно координировать деплой всех приложений;
при ошибке откат сложный.
Вопрос места для хранения (storage) был для нас критичным: при 700GB занятых из 1TB лимита, поднять вторую базу было нельзя без дополнительных ресурсов.
В PostgreSQL 12+ появилась возможность конвертировать обычную таблицу в партиционированную через ALTER TABLE. Звучит идеально, но:
требует exclusive lock на всё время операции;
на 700GB таблице это часы downtime;
не работает, если есть foreign keys.
Для production с требованием минимального downtime не подходит.
Создаём новую партиционированную таблицу рядом со старой, копируем данные batch-ами, в конце атомарный RENAME.
Плюсы:
минимальный downtime (~100ms на swap);
возможность pause/resume;
rollback через обратный RENAME;
верификация перед swap.
Минусы:
нужно 2x disk space на время миграции;
сложнее в реализации;
нужно отслеживать изменения в исходной таблице (для нас не критично: read-mostly данные, см. контекст ниже).
Мы выбрали shadow tables в связи с тем что в тот момент это показалось лучшим решением из-за того что:
заказать новый кластер оказалось не так просто, и требовало бы длительного ожидания;
планировалось закончить за 1-2 дня, shadow table в этом плане более гибкое решение.
Данные OpenStreetMap у нас обновляются раз в неделю из OSM diffs. 99.9% времени данные read-only, поэтому не отслеживали изменения во время миграции.
Довольно простая операция, оказалось что за последние 30 дней не использовалось целых 3 больших индекса в таблице ways:
idx_way_tags_gin — не использовался 30 дней (tags query ушёл из кода).
idx_way_geom_partially — дублирует idx_way_geom_full (частичный индекс, не нужен).
idx_way_timestamp_old — старый индекс, заменён на BRIN-индекс idx_way_timestamp_brin.
Проверка через pg_stat_user_indexes:
SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch FROM pg_stat_user_indexes WHERE relname = 'ways' ORDER BY idx_scan ASC;
Результат:
idx_way_tags_gin | 0 | 0 | 0 idx_way_geom_partially | 0 | 0 | 0 idx_way_timestamp_old | 0 | 0 | 0
Производительность не пострадала — индексы действительно не использовались.
Изначально пробовали LIST partitioning по top-150 H3 ячейкам. На staging получили 70% данных в DEFAULT партиции. Это убивает смысл партиционирования — большая часть данных всё равно в одной «куче».
Проблема: данные распределены неравномерно. Некоторые H3 ячейки содержат миллионы строк, другие — десятки тысяч. Top-150 ячеек покрывают только 30% данных.
RANGE по H3 Level 0 даёт 122 партиции. DEFAULT партиция пустая (если h3_l0 вычисляется правильно). Все таблицы (nodes, ways, way_nodes) партиционированы по одному ключу h3_l0, что позволяет использовать partition-wise JOIN.
У нас была настроена полноценная Grafana с метриками PostgreSQL: disk space, connections, query latency, replication lag. Казалось бы, всё покрыто. Но когда начали планировать партиционирование, стало очевидно, что дашборды мониторинга и дашборд управления процессом — это разные вещи.
У нас есть общая борда Grafana от команды PostgreSQL-менов. Она показывает детальную информацию: активные транзакции, долгие запросы, блокировки, кто кого ждёт. Проблема не в отсутствии видимости — она в том, что из неё нельзя действовать.
Видишь зависшую транзакцию, которая держит блокировку и мешает миграции. Что дальше? SSH на сервер, найти PID, убедиться, что это безопасно, выполнить pg_terminate_backend. В 3 часа ночи с телефона это не сделаешь.
В 2:47 AM во время копирования batch #342 миграция встала. В Grafana видели зависшую транзакцию PID 18432, которая держала AccessExclusiveLock на таблице ways. Через 3 минуты Telegram бот прислал алерт: «Migration paused: deadlock detected».
Открыли partition-skeleton дашборд (о нём чуть ниже будет рассказано подробнее) с телефона:
Вкладка «Active Transactions» → увидели PID 18432, запрос SELECT * FROM ways WHERE tags @> '{"highway":"primary"}'.
Нажали «Kill» → миграция продолжилась через 5 секунд.
Общее время реакции: 2 минуты.
Без инструмента пришлось бы: VPN → SSH → psql → искать PID → проверять безопасность → pg_terminate_backend. Минимум 15 минут, если всё делать с телефона.
Grafana показывает общую нагрузку на БД, но она не предоставляет инструментов управления миграцией. Она покажет:
на какой фазе миграция (preflight, migrate_batch, create_indexes);
сколько строк осталось обработать;
какой batch сейчас выполняется.
Но ничего не сможет с этим сделать. Команды в SQL клиенте решат проблему, но это неудобно: приходится вести внешний notebook, копировать ID транзакций, сопоставлять фазы миграции с метриками. Это увеличивает риск ошибок и замедляет реакцию.
Миграция требует места под новую таблицу плюс старую плюс индексы. Примерно 2x от размера таблицы. Grafana покажет, что диск заполнен на 95%, и алерт отправится, с этим всё в порядке.
Но в нашем случае алерты падают в общий канал команды. Дежурство не настроено: ночью тебя могут и не разбудить из-за проблем на проде.
Проблемы стандартных алертов:
общий канал с кучей шума;
алерт про disk space, а не про «твоя миграция под угрозой»;
нет автоматической реакции: миграция продолжает заполнять диск;
у нас в WB не настроен нормальный процесс дежурства и оповещения, каждая команда живёт в «своём мире».
Deadlock-и и блокировки видно в PostgreSQL dashboard. Кто кого ждёт, как долго, какие запросы. Информация есть.
Но чтобы «прибить» зависшую транзакцию, нужен доступ к базе: SSH, psql, найти PID, убедиться, что это не что-то критичное, выполнить команду. Это занимает время и усложняет реакцию в критических ситуациях.
Preflight: проверка disk space, unused indexes, compatibility
Create shadow table: ways_new с 122 партициями (по количеству H3 Level 0 ячеек)
Migrate data: batch-ами по 100K строк через INSERT ... SELECT (см. обоснование ниже)
Create indexes: concurrent index creation на ways_new
Verify: COUNT(*) должен совпадать
Swap: атомарный RENAME (downtime < 100ms)
Cleanup: DROP ways_old через 24 часа
Мы тестировали разные размеры batch-ей на staging (10% данных):
Batch size | Результат | Проблемы |
1K rows | Слишком медленно | Большой overhead на BEGIN/COMMIT |
10K rows | Хорошо | Миграция заняла бы 40+ часов |
100K rows | Оптимально | Баланс скорость/memory |
1M rows | Не работает | Превышает work_mem, начинается swap to disk |
Выбрали 100K строк на batch. Это даёт хороший баланс между скоростью копирования и потреблением памяти.
На staging (10% данных от прода):
копия 10% заняла 2 часа;
пропорция: 2 * 10 = 20 часов;
плюс запас на непредвиденное: *2 = 40 часов.
На prod прошло быстрее (~20 часов) за счёт:
более мощное железо (32 cores vs 16 cores на staging);
parallel workers (4 workers копировали разные партиции);
запуск в ночь пятницы (минимум нагрузки от пользователей).
Итог: оценка 40-70 часов была консервативной, реальность — ~20 часов.
Стандартный мониторинг (Grafana) показывал метрики, но не давал инструментов для быстрой реакции. Видишь deadlock — но чтобы прибить транзакцию, нужен SSH, psql, поиск PID. В 3 часа ночи с телефона это не сделаешь.
Написали partition-skeleton — web-дашборд на HTMX с обновлением в реальном времени.

Основные возможности:
управление миграцией: Pause/Resume/Swap/Rollback;
Storage Analyzer: размер таблиц, распределение по партициям, missing indexes;
Active Transactions: мониторинг и уничтожение транзакций из UI;
Watcher: автопауза при disk space < 10%, алерты в Telegram;
REST API + Prometheus метрики.
Вспомним реальный кейс, который был выше. На всё (бот прислал алерт, открыли дашборд с телефона → killed транзакцию) потребовалось 5 секунд. Без инструмента: 15+ минут.
Автоматические алерты несколько раз реально спасали нас:
2:47 AM: Telegram: «Disk space warning: 12% free». Миграция на паузе.
2:48 AM: Проверил с телефона. Увидел, что старые индексы не удалились.
2:50 AM: Через dashboard удалил индексы. Возобновил миграцию.
2:51 AM: Спокойно лёг спать.
Без автоматических алертов узнал бы утром, когда диск заполнился бы полностью и миграция упала с ошибкой. Откат и повторный запуск — это ещё 40+ часов.
При партиционировании можно создать DEFAULT партицию для «всего остального». Звучит безопасно. На практике:
CREATE TABLE ways_new ( id BIGINT, h3_l0 BIGINT, ... ) PARTITION BY RANGE (h3_l0); -- Создаём партиции для известных значений CREATE TABLE ways_p_000 PARTITION OF ways_new FOR VALUES FROM (...) TO (...); -- ... -- DEFAULT для "всего остального" (не создавали!) -- CREATE TABLE ways_default PARTITION OF ways_new DEFAULT;
Проблема: если h3_l0 вычисляется с ошибкой или NULL, строки попадают в DEFAULT. При добавлении новой партиции PostgreSQL сканирует весь DEFAULT, чтобы переместить подходящие строки. На 185M строк это занимает часы.
Вывод: DEFAULT партиция в идеале должна быть пустой, либо занимать не больше 5%. Если не ноль — это баг в вычислении partition key.
Новая партиционированная таблица создаётся рядом со старой, плюс индексы. Нужен точный расчёт.
Исходное состояние:
Занято на диске: 700GB (все таблицы + индексы);
Свободно: 324GB (1TB - 700GB).
Таблица ways:
Data: 162GB;
Indexes: 42GB;
Total: 204GB.
Shadow table ways_new:
Data: 162GB (копия);
Indexes: ~200GB (будут созданы заново, оптимизированные);
Total: 362GB.
Пиковое потребление:
Старый ways: 204GB (не дропаем до swap)
Новый ways_new: 362GB
Temp space для CREATE INDEX: ~100GB (SORT memory)
Итого пик: 666GB
Доступно: 324GB + 204GB (если дропнуть индексы на старом) = 528GB. Не хватает!
Решение:
Удалить 3 неиспользуемых индекса на старой таблице (см. «Какие индексы мы удалили» выше)
Освобождается ~200GB
Итоговый доступный объём: 528GB + 200GB = 728GB > 666GB ✅
Вывод: проверьте и удалите всё, что не нужно на старой таблице перед миграцией. Индексы всё равно создаются заново на партиционированной таблице. Конечно, на HIGH LOAD продакшен базе будет, скорее всего, невозможно удалять индексы.
Партиционирование сделано, запросы должны ускориться. Проверяем EXPLAIN:
EXPLAIN SELECT * FROM way_nodes wn JOIN nodes n ON n.id = wn.node_id;
Результат: сканирует все 122 партиции (по количеству H3 Level 0 ячеек) nodes. Почему?
Параметр enable_partitionwise_join по умолчанию выключен. PostgreSQL не использует partition pruning для JOIN-ов.
Решение: добавить в postgresql.conf:
enable_partition_pruning = on enable_partitionwise_join = on enable_partitionwise_aggregate = on
После этого JOIN между партиционированными таблицами работает эффективно, но только если в JOIN условии есть partition key:
-- Плохо: сканирует все партиции SELECT * FROM way_nodes wn JOIN nodes n ON n.id = wn.node_id; -- Хорошо: partition pruning работает SELECT * FROM way_nodes wn JOIN nodes n ON n.id = wn.node_id AND n.h3_l0 = wn.h3_l0;
После swap таблиц:
ALTER TABLE ways RENAME TO ways_old; ALTER TABLE ways_new RENAME TO ways;
Sequence ways_id_seq остаётся привязан к ways_old:
SELECT pg_get_serial_sequence('ways', 'id'); -- NULL или ways_old
INSERT с автоинкрементом будет сломан.
Решение: явно переназначить после swap:
ALTER SEQUENCE ways_id_seq OWNED BY ways.id;
Batch упал после INSERT. River Queue делает retry. Строки вставляются повторно.
Решение банально: использовать ON CONFLICT DO NOTHING:
INSERT INTO ways_new (id, h3_l0, tags, linestring, ...) SELECT id, h3_l0, tags, linestring, ... FROM ways WHERE id > $last_processed_id ORDER BY id LIMIT $batch_size ON CONFLICT (id, h3_l0) DO NOTHING;
PRIMARY KEY партиционированной таблицы должен включать partition key — это требование PostgreSQL.
Синтаксис: FK, ссылающиеся на партиционированную таблицу, работают с PostgreSQL 12+. FK из партиционированной таблицы требуют включения partition key в constraint.
Нельзя:
ALTER TABLE way_nodes ADD CONSTRAINT fk_way FOREIGN KEY (way_id) REFERENCES ways(id);
Можно:
ALTER TABLE way_nodes ADD CONSTRAINT fk_way FOREIGN KEY (way_id, h3_l0) REFERENCES ways(id, h3_l0);
После миграции 760M строк планировщик PostgreSQL не знает реальную статистику новой таблицы. Запросы могут выбирать неоптимальные планы.
Решение: запустить ANALYZE сразу после миграции данных, до создания индексов:
ANALYZE ways_new;
Важный спойлер: эти семь проблем мы преодолели в процессе партиционирования.
Более тщательный анализ индексов перед миграцией. На продакшене столкнулись с проблемой: преждевременно удалили индексы и усложнили Preflight-этап. Пришлось переносить. Теперь используем pg_stat_user_indexes, чтобы найти неиспользуемые индексы за неделю до миграции.
Тестировать на staging с реальным объёмом данных. Чем больше staging похож на prod, тем лучше. У нас из-за объёмов получилось проверить только на 10% от прода 😅
Настроить алерты ДО начала миграции. Telegram бот, webhook в Slack, что угодно. Узнавать о проблемах сразу, а не через N часов. У нас идея про дашборд пришла позже, чем хотелось бы.
Подготовить чеклист для типичных проблем:
место на диске заканчивается — какие индексы можно удалить;
долгая транзакция — как определить безопасность принудительного завершения;
миграция зависла — как найти причину и продолжить;
провести knowledge sharing с коллегами, чтобы все знали, что делать в той или иной ситуации.
Проверить совместимость всех сервисов. У нас 3 из 4 сервисов имели hardcoded имена таблиц. После swap они бы упали. Теперь проверяем codegrep-ом до начала миграции.
Безусловно, многих этих проблем можно было бы избежать. Нормализованные OSM данные растут быстро — это очевидно. Если бы мы спроектировали партиционирование на этапе архитектуры, когда таблицы были 50GB вместо 600GB, первая миграция прошла бы без боли.
Но история сложилась именно так. Нормализованные данные росли, VACUUM занимал 6+ часов, и мы действовали по принципу «лучшее время — сейчас».
Урок на будущее: если видите, что таблица растёт к 300GB+, думайте о партиционировании раньше, чем станет больно.
Партиционирование 560GB таблицы заняло 20 часов. Стоило ли оно того?
VACUUM сократился с 6+ часов до ~20 минут:
Раньше: VACUUM замедлял таблицу на 6+ часов;
Теперь: VACUUM на отдельной партиции — 20 минут, остальные партиции работают.
Запросы с фильтрацией по h3_l0 ускорились
Пример: SELECT * FROM ways WHERE h3_l0 = 5800000000000001;
Было: 2.3 секунды (Seq Scan по всей таблице);
Стало: 340ms (Index Scan по одной партиции из 122).
Размер индексов уменьшился на ~40%
Раньше: один индекс на 560GB таблицу;
Теперь: 122 маленьких индекса (по количеству H3 Level 0 ячеек) по 4-6GB каждый;
Экономия: ~200GB disk space.
Parallel operations
Раньше: один VACUUM/что угодно могло блокировать всё;
Теперь: VACUUM на 5 партициях одновременно, остальные работают.
Вот, что на самом деле важно. Партиционирование — это инвестиция в будущее:
Шардирование станет в N раз проще. Detach партицию → перенести на новый кластер → Attach. Zero downtime, работаем с 5GB вместо 600GB.
Maintenance без блокировок. VACUUM, REINDEX, ANALYZE на отдельных партициях пока остальные продолжают работать.
Faster recovery после сбоя. Повреждена одна партиция → восстанавливаем 5GB вместо 600GB.
Parallel operations. Несколько операций на разных партициях одновременно без блокировок.
Testing на production data. Берём одну партицию, тестируем изменения, применяем на все если работает.
Graceful data retention. Старые партиции можно архивировать или удалять без болезненных DELETE + VACUUM.
Партиционирование — это не просто «сегодня быстрее запросы». Это фундамент, который позволяет масштабироваться до петабайтов данных без архитектурных переделок.
Но фундамент должен быть надёжным. И наш фундамент имел скрытый дефект, который обнаружили слишком поздно. Именно об этом дефекте и о том, как наша успешная миграция сломалась, я расскажу в следующей статье. А вы пока пишите комментарии, что именно вы сделали бы по-другому!