Ошибка PostgreSQL «cannot execute CREATE TABLE in a read-only transaction» сейчас препятствует выполнению миграций у многих команд, оставляя схемы частично примененными, а таблицы истории миграций — заблокированными. Сбой обычно происходит, когда операция записи попадает на реплику вместо основного узла (primary), что останавливает любые инструменты, полагающиеся на изменения схемы — Flyway, Liquibase, Django ORM и подобные.

Почему возникает эта ошибка

PostgreSQL отключает все операции записи, изменения схемы и обновления последовательностей, когда транзакция помечена как «только для чтения» (read-only). Чаще всего миграция оказывается в таком состоянии по следующим причинам:

  • Пуллеры соединений (например, PgBouncer), которые непреднамеренно направляют соединение для миграции на реплику для чтения.
  • Роли, у которых параметр default_transaction_read_only по умолчанию установлен в значение on.
  • Облачные эндпоинты, предоставляющие отдельные URL для чтения и записи; использование URL для чтения (что часто встречается в AWS RDS или Aurora) направляет запросы на запись на реплику.

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

Немедленное исправление внутри транзакции

Если вы уже столкнулись с этой ошибкой, вы можете переопределить флаг read-only для текущей транзакции, не затрагивая остальную часть сессии — это критически важно, когда пул соединений повторно использует ту же сессию для других задач.

BEGIN;
SET LOCAL default_transaction_read_only = off;
SET TRANSACTION READ WRITE;
CREATE TABLE orders (id SERIAL PRIMARY KEY, total NUMERIC);
COMMIT;

SET LOCAL изменяет настройку только на время выполнения транзакции. Использование обычного SET закрепит изменение на всю сессию, что может нарушить работу других операций, которым по праву требуется режим «только для чтения» по умолчанию.

Меры профилактики

1. Убедитесь, что вы на основном узле (primary)

Добавьте быструю проверку перед запуском любой миграции:

SELECT CASE WHEN pg_is_in_recovery() THEN 'REPLICA' ELSE 'PRIMARY' END;

Если результат — REPLICA, прервите миграцию. Функция pg_is_in_recovery() возвращает true на резервном сервере (standby), что гарантирует, что вы не пытаетесь выполнить запись на копию, доступную только для чтения.

2. Используйте выделенные роли для миграций

Создайте роль, у которой режим транзакций по умолчанию разрешает запись, и предоставьте ей только необходимые привилегии:

  • CREATE в целевой базе данных.
  • CONNECT для того, чтобы инструмент миграции мог открыть сессию.

Избегайте назначения этой роли атрибута default_transaction_read_only = on, который иногда устанавливается для пользователей общего назначения.

3. Направляйте инфраструктуру на эндпоинт для записи (writer)

В IaC-скриптах (Terraform, CloudFormation и т. д.) настройте инструменты запуска миграций на использование эндпоинта writer кластера, а не эндпоинта reader. Эндпоинт writer указывает на основной узел, в то время как эндпоинт reader указывает на реплику, которая будет отклонять запросы на запись.

4. Добавьте проверку в CI/CD

Вставьте shell-шаг в ваш пайплайн (GitHub Actions, GitLab CI и т. д.), который выполняет запрос pg_is_in_recovery(). Если он возвращает true, завершите выполнение задачи с ненулевым кодом возврата, чтобы остановить развертывание на раннем этапе.

if psql $DATABASE_URL -c "SELECT pg_is_in_recovery()" | grep -q t; then
  echo "Connected to replica – aborting migration"
  exit 1
fi

5. Настройте повторные попытки в инструментах миграции

Такие инструменты, как Flyway, часто автоматически повторяют попытки подключения. Для Flyway 9+ установите flyway.connectRetries=0. Это предотвратит многократные обращения инструмента к реплике, что в противном случае может увеличить задержку репликации и привести к пустой трате ресурсов.

На что обратить внимание в дальнейшем

  • Метрики задержки репликации (replication lag): Растущая задержка может указывать на то, что миграция непреднамеренно была направлена на реплику, из-за чего попытки записи встали в очередь.
  • Правила маршрутизации пуллера соединений: Убедитесь, что конфигурации пуллера явно направляют трафик миграций на основной хост.
  • Настройки ролей по умолчанию после обновлений: Обновления базы данных иногда сбрасывают параметры ролей; проведите повторный аудит default_transaction_read_only после перехода на новые мажорные версии.

Подводя итог: ошибка read-only транзакции редко является багом PostgreSQL; это симптом того, что трафик направляется не на тот узел или роль настроена неверно. Проверяя роль узла, используя выделенные учетные записи для миграций и укрепляя ваш CI/CD пайплайн, вы сможете обеспечить бесперебойную работу миграций и избежать частично примененных схем, которые парализуют дальнейшую разработку.