Перейти к содержанию

Обслуживание партиций PostgreSQL: от плана до проверенного архива

Месячные партиции требуют ухода: каждый месяц кто-то должен создать таблицу на следующий месяц и убрать старую.

Ломается это в двух местах: вставка падает, если таблицы на следующий месяц нет, а старую таблицу удаляют раньше, чем проверили архив.

Разберём один запуск обслуживания в сервисе аналитики 15 сентября 2026 года: партиции на октябрь нет, квитанция всё ещё ссылается на майское событие, а старую таблицу можно удалить только через семь дней после отсоединения и после проверки архива. Потом заберём обслуживание таблицы у pg_partman. Практикумы работают на PostgreSQL 17 и pg-partsmith 1.8.0.

Задаём правила в календарных месяцах

В events три столбца: id bigint, created_at timestamptz и payload text; первичный ключ (id, created_at), ключ партиционирования created_at. Квитанция ссылается на майское событие составным ключом (event_id, event_at). Отчётам нужны текущий месяц и два предыдущих, а для записи заранее готовим ещё два месяца вперёд:

def events_config(*, protect_references=True):
    retention = KeepNewest(count=3)
    if protect_references:
        retention = ExpireIf(
            when=AllOf(members=(retention, Unreferenced())),
        )
    return TablePartitionConfig(
        schema="public",
        table_name="events",
        partition_column="created_at",
        granularity=PartitionGranularity.MONTH,
        lifecycle=LifecyclePolicy(
            creation=CreateAhead(count=3),
            retention=retention,
            drop=DropAfter(grace=timedelta(days=7)),
        ),
    )
Настройка в сентябре Что означает
CreateAhead(count=3) Гарантирует партиции на сентябрь, октябрь и ноябрь: текущий месяц тоже входит в счёт
KeepNewest(count=3) Храним июль, август и сентябрь; будущие диапазоны историю не сокращают
Unreferenced() вместе с правилом хранения Старая партиция остаётся подключённой, пока на её строки ссылается другая таблица
DropAfter(grace=timedelta(days=7)) Отсоединённую таблицу удаляем не раньше чем через семь дней после отметки об отсоединении

Правила работают с месяцами целиком, фиксированного срока жизни строки нет: сентябрьская строка остаётся, пока декабрьский запуск не отсоединит сентябрь.

План строится без DDL, печатается и применяется отдельным шагом; toolkit здесь создан как PartitionToolkit.from_engine(engine) поверх AsyncEngine из SQLAlchemy:

async def preview(toolkit, config, *, now=None):
    plan = await toolkit.service.plan(config, now=now)
    print(plan.describe())
    return plan


async def apply_reviewed(toolkit, config, plan):
    result = await toolkit.service.apply(config, plan)
    if result.error or result.issues:
        raise RuntimeError(f"Maintenance needs attention: {result}")
    return result

Кроме обычных месячных партиций, в базе есть три необычные таблицы:

Таблица Что с ней происходит
events_may_manual, созданная вручную с границами мая Обслуживается как обычная месячная партиция; пока её защищает квитанция
events__2026_03 с 1 февраля по 1 апреля Остаётся нетронутой, план сообщает unmanaged_partition
events_export_2025_01, отдельная и без метки В план не попадает

Практикум строит план с now=datetime(2026, 9, 15, tzinfo=UTC). План создаёт октябрь и ноябрь, отсоединяет апрель и июнь и оставляет май, на который ссылается квитанция; удалений пока нет. Построение плана ничего не меняет в каталоге.

Какие таблицы библиотека считает своими

Для подключённой RANGE-партиции pg-partsmith смотрит на границы, а не на имя или на то, кто её создал: если границы укладываются в один настроенный период, партицию обслуживают. Поэтому месячные партиции, созданные вручную или другим инструментом, становятся кандидатами на отсоединение, а неровный диапазон вроде февраля–марта попадает в отчёт и остаётся как есть.

Перед самым отсоединением библиотека записывает в комментарий к таблице имя родителя и время отсоединения. По этой метке следующие запуски находят таблицы, которые можно удалить.

Отсоединяем старые диапазоны и сохраняем данные

После apply_reviewed счётчики такие: created=2, detached=2, dropped=0. Октябрьская вставка, которую до обслуживания отклоняла SQLSTATE 23514, теперь проходит. Апрель и июнь исчезают из запросов через events, но их таблицы и четыре строки на месте. Вставка запоздавшего апрельского события теперь падает с 23514: партиции DEFAULT нет.

Чтобы проверить защиту внешним ключом, построим план без Unreferenced(). Май попадает в план, но PostgreSQL отказывается его отсоединять из-за квитанции: в результате success=True и непустой issues, а май и квитанция на месте. success означает, что не было фатальной ошибки, а не то, что все операции прошли. Когда квитанцию удаляют, обычная политика отсоединяет май. Неудачная попытка в 1.8.0 оставляет след: отметку об отсоединении библиотека пишет до запроса, и она остаётся на майской таблице. Когда май потом действительно отсоединяется, отметка не обновляется, и семь дней отсчитываются от неудачной попытки (#105).

Операции не объединены в одну транзакцию: поздний сбой не откатывает уже созданные или отсоединённые партиции, поэтому повторный запуск начинается с текущего состояния. В режиме по умолчанию AUTO pg-partsmith выполняет DETACH ... CONCURRENTLY, которому на родителе нужна только SHARE UPDATE EXCLUSIVE, но он ждёт завершения всех транзакций, работающих с родителем. PostgreSQL отказывает в этом варианте, например если у родителя есть партиция DEFAULT, и только тогда pg-partsmith выполняет обычный DETACH: ACCESS EXCLUSIVE на родителе и партиции и SHARE (блокирует запись) на каждой таблице, чьи внешние ключи ссылаются на родителя. Блокирующие шаги ждут блокировку не дольше ddl_lock_timeout_ms, по умолчанию три секунды, и вместо очереди за длинными транзакциями сообщают об ошибке в issues. Удаление уже отсоединённой таблицы не трогает родительскую таблицу, поэтому отсоединение и удаление разделены на два шага; остальные ограничения перечислены в описании ALTER TABLE.

ИДЕЯ В СХЕМЕПуть апрельской партиции до удаления
---
config:
  theme: default
  look: classic
  flowchart:
    useMaxWidth: false
    wrappingWidth: 150
    padding: 12
    nodeSpacing: 24
    rankSpacing: 32
---
flowchart TD
    accTitle: Путь апрельской партиции до удаления
    accDescr: Сентябрьское обслуживание отсоединяет апрель. Через семь дней обработчик сохраняет и проверяет архив, и только потом таблицу можно удалить.
    A["Сентябрьский план"]
    B["Отсоединить апрель"]
    C["Подождать семь дней"]
    D["Сохранить JSON"]
    E["Сравнить строки"]
    F["Удалить таблицу"]
    A --> B --> C --> D --> E --> F

Сентябрьское обслуживание отсоединяет апрель. Через семь дней обработчик сохраняет и проверяет архив, и только потом таблицу можно удалить.

Архивируем строки, а затем разрешаем удаление

Обработчик before_drop вызывается перед удалением каждой таблицы. Исключение в нём останавливает запуск перед этим DROP, и все следующие операции пропускаются; с continue_on_error=True сбой вместо этого попадает в issues. Наш обработчик сначала проверяет, что у таблицы всё ещё OID из плана, потом сохраняет строки в JSON, перечитывает файл и сравнивает каждое поле:

class JsonArchive(BasePartitionLifecycleHooks):
    def __init__(self, connection, directory: Path):
        self.connection = connection
        self.directory = directory

    async def before_drop(self, event: PartitionEvent):
        oid = await self.connection.fetchval("SELECT to_regclass($1)::oid", event.partition.name)
        if oid != event.operation.oid:
            raise RuntimeError(f"{event.partition.name} is not the planned table")
        schema, name = event.partition.name.split(".", 1)
        quoted = ".".join('"' + part.replace('"', '""') + '"' for part in (schema, name))
        rows = await self.connection.fetch(
            f"SELECT id, created_at, payload FROM {quoted} ORDER BY id, created_at",
        )
        data = [[row["id"], row["created_at"].isoformat(), row["payload"]] for row in rows]
        document = {"table": event.partition.name, "rows": data}
        path = self.directory / f"{oid}.json"
        if not path.exists():
            temporary = path.with_suffix(".tmp")
            temporary.write_text(json.dumps(document, ensure_ascii=False), encoding="utf-8")
            temporary.replace(path)
        if json.loads(path.read_text(encoding="utf-8")) != document:
            raise RuntimeError(f"Archive verification failed: {path.name}")

Обработчик подключается через PartitionToolkit.from_engine(engine, hooks=[JsonArchive(connection, directory)]); ему нужны asyncpg.Connection и существующий каталог. Файл называется по OID, поэтому повторный вызов проверяет уже записанный архив, а расхождение запрещает удаление. Настоящему архиву нужен потоковый экспорт в надёжное хранилище архивов и своя проверка восстановления; запись в отсоединённые таблицы до экспорта остановите.

Практикум проверяет весь путь:

Ситуация Что проверяется в БД и архиве
Семь дней ещё не прошли В плане нет DROP
Срок прошёл, хранилище архивов недоступно Обработчик бросает исключение; обе таблицы на месте
Существующий архив с неверным содержимым Проверка запрещает удаление
Повтор после исправления Две таблицы удалены; все четыре строки восстановлены из JSON во временную таблицу и совпадают с исходными

Запускаем по расписанию и замечаем частичные отказы

Запускайте обслуживание из CronJob с concurrencyPolicy: Forbid или с каждой реплики: advisory lock таблицы пропускает к работе только одного воркера за раз.

async def maintenance_tick(toolkit, config):
    try:
        result = await toolkit.maintainer.run_maintenance(config)
    except LockAcquisitionError:
        print({"skipped": "another worker holds the maintenance lock"})
        return None
    print({
        "created": result.created_count,
        "detached": result.detached_count,
        "dropped": result.dropped_count,
        "duration_ms": result.duration_ms,
        "issues": [issue.model_dump() for issue in result.issues],
    })
    if result.issues:
        raise RuntimeError("Partition maintenance finished with issues")
    return result

run_maintenance строит и применяет план под advisory lock таблицы. Если блокировку держит другой воркер, LockAcquisitionError превращает этот запуск в пропуск, а не в сбой; любое другое исключение роняет задачу, как и непустой issues, даже при success=True. run_maintenance_safe мы не используем: он превращает любое исключение, включая отмену, в result.error, и asyncio.timeout() не может его остановить (#107).

Блокировку берёт сессионный pg_try_advisory_lock, поэтому воркерам нужно прямое подключение к базе, а не PgBouncer в транзакционном режиме, и одинаковый lock_prefix; с другими инструментами она не согласуется. Отдельно настройте оповещения на время с последнего чистого запуска и на наличие будущих диапазонов. Если есть партиция DEFAULT, считайте строки в ней сами: 1.8.0 сообщает о них как rows_in_default, но не при политике с Unreferenced(), как здесь (#108).

Передаём обслуживание от pg_partman

Во втором практикуме обслуживание месячной таблицы переходит от pg_partman 5.5.0, и три настройки переносятся напрямую. premake = N будущих таблиц превращается в CreateAhead(count=N + 1), потому что pg-partsmith считает и текущий месяц; retention = '3 months' хранит текущий месяц и три предыдущих, то есть это KeepNewest(count=4); retention_keep_table = true превращается в DropNever(). При таком соответствии первый план pg-partsmith пуст, и это и есть приёмочная проверка передачи.

Сначала сохраните part_config, OID таблиц и число строк. Выставьте automatic_maintenance = 'off' и остановите задачи, которые явно вызывают run_maintenance('public.events'): этот флаг их не останавливает. Дождитесь завершения текущих вызовов и удалите строку таблицы из partman.part_config; после этого даже явный вызов ничего не меняет. На границе следующего месяца проверьте, что pg-partsmith создал партицию: в практикуме он создаёт одну и отсоединяет самую старую.

Кое-что остаётся вам: из-за events_default, созданной pg_partman, отсоединение будет только блокирующим, таблица-шаблон pg_partman никуда не девается, ранее отсоединённые им таблицы не имеют метки pg-partsmith и никогда не удаляются, а новые партиции получают имена вида events__YYYY_MM рядом со старыми events_pYYYYMMDD.

Что проверить в своём сервисе

Перед тем как включать обслуживание по расписанию, проверьте, что партиции подготовлены хотя бы на месяц вперёд, что каждый DETACH в следующем плане вы бы одобрили и что before_drop отказывает при несовпадении архива. Оповещения настраивайте на исключения и issues, а не на success.

Примеры и практикумы

Каждый практикум поднимает одноразовый контейнер PostgreSQL 17; для практикума с pg_partman сначала нужно собрать образ, как описано в его README.