Справочник DBA: базы данных изнутри читается по порядку

Статья 13.7 · раздел 13 «Инфраструктура и облако» · 101 из 109

База как код: миграции Flyway, Liquibase и Sqitch, блокировка второго запуска, дрейф схемы и конфигурации, CI за 12,5 с

опубликовано
читать
≈ 10 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнерах postgres:18 с --memory 1g, по одному на инструмент; Flyway OSS 13.11.1, Liquibase 5.0.4 с PostgreSQL JDBC 42.7.14, Sqitch 1.6.1, sqlfluff 4.4.0; таблица orders из трёх миграций (у Flyway четыре), в CI на копии 1 млн строк; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

После ручных ALTER TABLE и CREATE INDEX команда sqitch status отвечает «Nothing to deploy», а sqitch verify — «Verify successful»: инструмент знает только свой журнал. Вокруг него «база как код» держится на блокировке одновременного запуска, сравнении живой схемы и конфигурации с репозиторием и конвейере, который останавливает опасную миграцию. Какие ALTER опасны, разобрано в статье про миграции без простоя.

DBA / 01

Конвейер PR: шесть шагов с замеренным временем

Конвейер проверки миграции: шесть шагов и развилка на шаге ddl-checkШесть шагов слева направо: lint 0,5 с, migrate 4,8 с, копия 1 млн строк 2,5 с, ddl-check 0,1 с, apply V5 4,6 с, explain 0,05 с. От шага ddl-check две ветки. Миграция V5 с DEFAULT clock_timestamp() даёт rewritten=true, шаг занимает 1,0 с, конвейер останавливается. Миграция V5 без DEFAULT даёт rewritten=false, шаг занимает 0,1 с, конвейер идёт дальше. Красный прогон остановился на шаге 4, сумма его шагов 8,6 с, зелёный прошёл за 12,5 с.PR с файлом V5__add_created_at.sql: шаги идут по очереди, красный останавливаетlint0,5 сsqlfluffmigrate4,8 сflywayкопия2,5 с1 млн строкddl-check0,1 сpg_locksapply V54,6 сflywayexplain0,05 сIndex ScanDEFAULT clock_timestamp()rewritten=trueшаг 4 занял 1,0 с, конвейер остановленADD COLUMN created_at timestamptzrewritten=falseшаг 4 занял 0,1 с, дальше apply и explainКрасный прогон: сумма шагов 8,6 с и остановка на шаге 4. Зелёный: 12,5 с целиком.
Опасная миграция останавливается на четвёртом шаге: rewritten=true виден по смене файла таблицы до того, как миграция дошла до прода.

Опыт 1: одна миграция в трёх инструментах

Три базы postgres:18, по одной на инструмент, в общей сети; образы инструментов официальные. --version дал Flyway OSS Edition 13.11.1, Liquibase 5.0.4 и sqitch (App::Sqitch) v1.6.1:

docker network create dba-dbcode-net
for r in fw lb sq; do docker run -d --name dba-dbcode-pg-$r --network dba-dbcode-net --memory 1g -e POSTGRES_PASSWORD=pg postgres:18; done

Миграция одна и та же: таблица orders, затем столбец со значением по умолчанию, затем столбец paid_at. Вторая и третья содержат SELECT pg_sleep(8) и pg_sleep(10), чтобы имитировать долгую миграцию и успеть запустить второй экземпляр. Flyway читает файлы V1__create_orders.sql, V2__add_status.sql, V3__add_paid_at.sql из каталога; Liquibase — один файл changelog.sql с --changeset adry:1, adry:2, adry:3; Sqitch — sqitch.plan и три тройки файлов в deploy/, revert/ и verify/:

docker run --rm --network dba-dbcode-net -v $PWD/fw:/flyway/sql:ro flyway/flyway \
  -url=jdbc:postgresql://dba-dbcode-pg-fw:5432/postgres -user=postgres -password=pg migrate
docker run --rm --network dba-dbcode-net -v $PWD/lb:/liquibase/changelog:ro -v $PWD/drv:/drv:ro liquibase/liquibase \
  --classpath=/drv/postgresql-42.7.14.jar --url=jdbc:postgresql://dba-dbcode-pg-lb:5432/postgres \
  --username=postgres --password=pg --changelog-file=changelog.sql --search-path=/liquibase/changelog update
docker run --rm --network dba-dbcode-net -v $PWD/sq:/repo -e SQITCH_USER_NAME=adry -e SQITCH_USER_EMAIL=a@example.com \
  sqitch/sqitch deploy

Образ Liquibase драйвера PostgreSQL не содержит: без --classpath с postgresql-42.7.14.jar (скачан с Maven Central) он отвечает Cannot find database driver: org.postgresql.Driver. После запуска история лежит в разных местах и в разном виде (запросы к flyway_schema_history, databasechangelog и sqitch.changes):

вывод (сжато)
fw:  1 | 1 | create orders | V1__create_orders.sql |  130914030 | t
     2 | 2 | add status    | V2__add_status.sql    | -805322606 | t
     3 | 3 | add paid at   | V3__add_paid_at.sql   | 1631958934 | t
     4 | 4 | add note      | V4__add_note.sql      | 1747658240 | t
lb:  1 | adry | changelog.sql | 1 | EXECUTED | 9:fce7056a86198ba7842099dd1a68a7d1
     2 | adry | changelog.sql | 2 | EXECUTED | 9:1c9263f489ada51531bbacf461ed756a
     3 | adry | changelog.sql | 3 | EXECUTED | 9:131d0eca3b95009705cb3f00930a5d7c
sq:  create_orders | orders | 2026-10-28 12:19:26.224017+00
     add_status    | orders | 2026-10-28 12:19:34.284005+00
     add_paid_at   | orders | 2026-10-28 12:19:46.308626+00

У Flyway одна таблица flyway_schema_history в схеме public (в той же схеме, что и данные), у Liquibase две: databasechangelog и databasechangeloglock, у Sqitch целая схема sqitch с шестью таблицами: changes, dependencies, events, projects, releases, tags. Журнал Flyway хранит контрольную сумму файла, Liquibase — md5sum набора изменений, Sqitch — script_hash в sqitch.changes. Таблица orders у lb и sq получилась одинаковой: pg_dump отличался только случайным ключом \restrict.

Опыт 2: два запуска одновременно

Последняя миграция с pg_sleep(10) запускается двумя экземплярами инструмента с разницей в секунду (у Flyway это V4__add_note.sql, у Sqitch до этого развёрнуто всё до add_status). Около девятой секунды читается pg_locks: advisory-блокировки и все ожидающие. Ниже вывод проигравшего экземпляра дословно (пустые строки убраны), done t — секунды от старта первого, строки с pg_locks сжаты из вывода psql.

Flyway, проигравший экземпляр (победитель закончил на t=13):

вывод
WARNING: Storing migrations in 'sql' is not recommended and default scanning of this location may be deprecated in a future release
Flyway OSS Edition 13.11.1 by Redgate
See release notes here: https://help.red-gate.com/help/flyway-cli13/help_11.aspx?topic=release-notes-and-older-versions/release-notes-for-flyway-engine
Environment: default
Database: jdbc:postgresql:******** (PostgreSQL 18.6)
Successfully validated 4 migrations (execution time 00:00.051s)
Current version of schema "public": 4
Schema "public" is up to date. No migration necessary.
done t=14
pg_locks на 9-й секунде: advisory | 18028 | 4163491309 | ExclusiveLock | granted | idle in transaction

Flyway ничего не печатает про ожидание: проигравший экземпляр молча ждал и проснулся на версии 4. В pg_locks виден один держатель с advisory и состоянием idle in transaction, ожидающего нет. Так устроен цикл pg_try_advisory_xact_lock с повтором:

    private boolean tryLockTransactional() throws SQLException {
        final List<Boolean> results = jdbcTemplate.query("SELECT pg_try_advisory_xact_lock(" + lockNum + ")",
            rs -> rs.getBoolean("pg_try_advisory_xact_lock"));
        return results.size() == 1 && results.get(0);
    }

    private boolean tryLock() throws SQLException {
        final List<Boolean> results = jdbcTemplate.query("SELECT pg_try_advisory_lock(" + lockNum + ")",
            rs -> rs.getBoolean("pg_try_advisory_lock"));
        return results.size() == 1 && results.get(0);
    }

tryLockTransactional в PostgreSQLAdvisoryLockTemplate.java (строки 100–110, flyway-13.11.1) не встаёт в очередь в самом PostgreSQL, а возвращает false, и Flyway повторяет попытку. Транзакционный вариант включён по умолчанию, а flyway.postgresql.transactional.lock=false переключает на сеансовый pg_try_advisory_lock (см. документацию настройки: он нужен для CREATE INDEX CONCURRENTLY).

Liquibase, второй экземпляр:

вывод
Starting Liquibase at 12:13:56 using Java 21.0.11 (version 5.0.4 #11282 built at 2026-08-18 12:10:05 UTC)
Liquibase Version: 5.0.4
Waiting for changelog lock....
UPDATE SUMMARY
Run:                          0
Previously run:               3
Filtered out:                 0
-------------------------------
Total change sets:            3
Liquibase: Update has been successful. Rows affected: 1
Liquibase command 'update' was executed successfully.
done t=14

На девятой секунде advisory-блокировок в pg_locks не было, зато databasechangeloglock показала держателя:

вывод
 id | locked |        lockgranted         |          lockedby
----+--------+----------------------------+-----------------------------
  1 | t      | 2026-10-28 12:13:57.209365 | 1f85645b8d93 (192.168.48.5)

Блокировка Liquibase — флаг в строке databasechangeloglock, а не блокировка PostgreSQL:

            SqlStatement lockedStatement = new SelectFromDatabaseChangeLogLockStatement("LOCKED");
            Boolean locked = ChangelogJdbcMdcListener.query(database, ex -> ex.queryForObject(lockedStatement, Boolean.class));

            if (locked) {
                return false;
            } else {

                executor.comment("Lock Database");
                SqlStatement lockDatabaseStatement = new LockDatabaseChangeLogStatement();
                int rowsUpdated = ChangelogJdbcMdcListener.query(database, ex -> ex.update(lockDatabaseStatement));

acquireLock читает LOCKED, при true возвращает false, иначе делает UPDATE (строки 314–323, v5.0.4). Поэтому такая блокировка не привязана к сеансу. Проверка: во время длинного changeset (pg_sleep(30)) контейнер Liquibase убит через docker kill, затем запущен второй экземпляр с --changelog-lock-wait-time-in-minutes=1:

вывод (сжато)
Unexpected error running Liquibase: Could not acquire change log lock.  Currently locked by cada4bf11cc9 (192.168.48.7) since 10/28/26, 12:28 PM

Клиент с cada4bf11cc9 уже убит, а строка осталась locked (lockgranted 12:28:51); второй экземпляр повторял «Waiting for changelog lock…» и через минуту сдался. Освободила строку команда liquibase release-locks.

Sqitch, проигравший экземпляр:

вывод
Blocked by another instance of Sqitch working on db:pg://postgres@dba-dbcode-pg-sq:5432/postgres; waiting 60 seconds...
Nothing to deploy (up-to-date)
done t=21
pg_locks на 8-й секунде:
 advisory | 75474063 | ExclusiveLock | granted | LOCK TABLE changes IN EXCLUSIVE MODE
 advisory | 75474063 | ExclusiveLock | not granted | SELECT pg_advisory_lock(75474063)

Первый экземпляр закончил на 13-й секунде, второй на 21-й. Sqitch использует константу 75474063 и честно встаёт в очередь pg_advisory_lock (а в pg_locks видны обе строки):

# Override to try to acquire a lock on a constant number without waiting.
sub try_lock {
    my $self = shift;
    return 1 if $self->_provider ne 'postgres';
    $self->dbh->selectcol_arrayref(
        'SELECT pg_try_advisory_lock(75474063)'
    )->[0]
}

try_lock в lib/App/Sqitch/Engine/pg.pm (строки 287–294, v1.6.1) пробует без ожидания; не вышло — lock_destination в Engine.pm печатает «Blocked by another instance» и вызывает wait_lock, а проверку готовности _timeout делает с нарастающей паузой до 10 секунд (max_delay => 10, строка 1147). Восемь секунд разницы между 13 и 21 согласуются с паузой опроса до 10 секунд.

Опыт 3: дрейф схемы и конфигурации

Ручные правки на базе sq, о которых репозиторий не знает:

docker exec dba-dbcode-pg-sq psql -U postgres -c "ALTER TABLE orders ALTER COLUMN total TYPE numeric(12,2)" \
  -c "CREATE INDEX orders_status_idx ON orders (status)"
D() { docker exec dba-dbcode-pg-$1 pg_dump -U postgres --schema-only --no-owner --restrict-key=ci -t orders postgres; }
D lb > etalon.sql; D sq > actual.sql; diff etalon.sql actual.sql; echo exit=$?
S() { docker run --rm --network dba-dbcode-net -v $PWD/sq:/repo -e SQITCH_USER_NAME=adry -e SQITCH_USER_EMAIL=a@example.com sqitch/sqitch "$@"; }
S status | tail -1; S verify | tail -1
вывод
32c32
<     total numeric(10,2) NOT NULL,
---
>     total numeric(12,2) NOT NULL,
43a44,50
>
>
> --
> -- Name: orders_status_idx; Type: INDEX; Schema: public; Owner: -
> --
>
> CREATE INDEX orders_status_idx ON public.orders USING btree (status);
exit=1
Nothing to deploy (up-to-date)
Verify successful

Последние две строки — sqitch status и sqitch verify после ручной правки: оба довольны, потому что проверяют только собственный журнал и свои verify-скрипты. Без --restrict-key diff сам по себе не пустой: pg_dump 18.6 вставляет в вывод случайный ключ \restrict ... и такой же \unrestrict, и две выгрузки одной схемы различались на строках 5 и 50.

Тот же дрейф бывает и у конфигурации. В postgresql.conf положены work_mem = '32MB' и max_connections = 120, затем сделаны ALTER SYSTEM SET work_mem = '64MB' и ALTER SYSTEM SET max_connections = 200, после чего pg_reload_conf():

Запросы — к pg_settings (с sourcefile, sourceline, pending_restart) и к pg_file_settings (с applied и error) по этим двум параметрам:

вывод (сжато)
postgresql.auto.conf:
# Do not edit this file manually!
# It will be overwritten by the ALTER SYSTEM command.
work_mem = '64MB'
max_connections = '200'

pg_settings:
max_connections | 100   | configuration file | postgresql.conf      | 65 | t
work_mem        | 65536 | configuration file | postgresql.auto.conf |  3 | f

pg_file_settings:
postgresql.conf      | 65  | max_connections | 100  | f |
postgresql.conf      | 891 | max_connections | 120  | f |
postgresql.auto.conf | 4   | max_connections | 200  | f | setting could not be applied
postgresql.conf      | 890 | work_mem        | 32MB | f |
postgresql.auto.conf | 3   | work_mem        | 64MB | t |

work_mem действует из postgresql.auto.conf (64 МБ, файл в репозитории с 32 МБ проигнорирован). max_connections пока 100: значение 200 лежит в auto.conf, 120 в файле, pending_restart = t. Три значения у одного параметра — живое, файловое и ожидающее. Для work_mem победила строка postgresql.auto.conf (applied = t), а у max_connections не применилась ни одна.

Опыт 4: конвейер CI как локальный скрипт

GitHub не запускался: шаги записаны скриптом ci.sh, каждый под time. Контейнеры: dba-dbcode-pg-ci (postgres:18), dba-dbcode-lint (debian:bookworm). sqlfluff поставился в контейнер без хоста: apt-get install python3-venv, python3 -m venv /v, pip install sqlfluff — 22,5 с, sqlfluff, version 4.4.0. Он же нашёл стиль в V1__create_orders.sql из опыта 1:

вывод
== [/sql/V1__create_orders.sql] FAIL
L:   1 | P:  62 | LT01 | Expected single whitespace between comma ',' and numeric
                       | literal. [layout.spacing]

В CI-копии миграции исправлено numeric(10, 2). Скрипт ниже сокращён: обёртки PG (docker exec psql) и FW (docker run flyway) и копирование каталогов в контейнеры через docker cp опущены. В CI-копии миграций нет pg_sleep.

TIMEFORMAT='    %R s'
step() { echo "== $1"; shift; time "$@"; local rc=$?; [ $rc -ne 0 ] && { echo "FAIL ($rc)"; exit $rc; }; }
lint()    { docker exec dba-dbcode-lint /v/bin/sqlfluff lint --dialect postgres /ci-sql /ci-pr; }
fresh()   { PG -d postgres -c "DROP DATABASE IF EXISTS ci WITH (FORCE)" -c "CREATE DATABASE ci" && FW filesystem:/ci/sql; }
seed()    { PG -d ci -c "INSERT INTO orders (id, total, status) SELECT g, g % 1000, CASE WHEN g % 1000 = 0 THEN 'paid' ELSE 'new' END FROM generate_series(1, 1000000) g" -c "ANALYZE orders"; }
ddlcheck(){ out=$(PG -d ci -f /ci/ddl-check.sql) && echo "$out" && ! grep -q 'rewritten=true' <<<"$out"; }
apply()   { FW filesystem:/ci/sql,filesystem:/ci/pr; }
explain() { PG -d ci -c "EXPLAIN (COSTS OFF) SELECT * FROM orders WHERE status = 'paid'" | tee /dev/stderr | { ! grep -q 'Seq Scan'; }; }
step "1 lint (sqlfluff)" lint; step "2 migrate V1-V4 на чистую базу" fresh; step "3 копия: 1 млн строк" seed
step "4 ddl-check: V5 в транзакции" ddlcheck; step "5 migrate V5" apply; step "6 explain" explain

ddl-check.sql — тот же приём, что в статье про миграции без простоя: внутри BEGIN запоминается pg_relation_filenode('orders'), выполняется файл PR, читаются режимы из pg_locks и сравнивается файл таблицы, затем ROLLBACK. PR добавляет столбец created_at. Сначала с DEFAULT clock_timestamp():

вывод (шаги 1–3: 0.460, 4.695 и 2.435 с)
== 4 ddl-check: V5 в транзакции
locks=ShareLock,AccessExclusiveLock
rewritten=true
    0.988 s
FAIL (1)

После правки на ADD COLUMN created_at timestamptz без DEFAULT:

вывод
== 1 lint (sqlfluff)
All Finished!
    0.451 s
== 2 migrate V1-V4 на чистую базу
    4.769 s
== 3 копия: 1 млн строк
    2.489 s
== 4 ddl-check: V5 в транзакции
locks=AccessExclusiveLock
rewritten=false
    0.105 s
== 5 migrate V5
    4.582 s
== 6 explain
Index Scan using orders_status_idx on orders
  Index Cond: (status = 'paid'::text)
    0.054 s
CI зелёный

Весь зелёный прогон — 12,451 с по time. Два запуска Flyway (4,8 и 4,6 с) занимают почти весь прогон, хотя миграции в нём — одна таблица, два столбца и индекс. Шаг ddl-check стоит 0,1 с без перезаписи и 1,0 с с перезаписью 1 млн строк. Стенд убирается командами из чек-листа.

Что из этого следует

  • Журнал инструмента не знает про ручные правки: sqitch status и verify остались зелёными при другом типе столбца и лишнем индексе. Дрейф ловит только сравнение pg_dump --schema-only --restrict-key=... с эталоном, который собран из миграций на чистой базе.
  • Блокировки устроены по-разному. Flyway опрашивает pg_try_advisory_xact_lock и молчит; Sqitch ждёт pg_advisory_lock; Liquibase пишет флаг в таблицу, и убитый клиент оставляет его взведённым до release-locks. Это нужно знать при отмене деплоя.
  • Конфигурацию проверяет pg_file_settings, а не чтение файла: postgresql.auto.conf побеждает файл репозитория, а max_connections ждёт перезапуска. Опасный ALTER отсекает шаг в транзакции с ROLLBACK: 0,1–1,0 с на копии в 1 млн строк.

Чек-лист

  1. Дрейф схемы проверяй pg_dump --schema-only --restrict-key=<строка> и diff с эталоном из миграций; журнал инструмента его не покажет.
  2. Для Liquibase после аварии убитого деплоя смотри databasechangeloglock и вызывай release-locks, только убедившись, что клиент мёртв.
  3. После ALTER SYSTEM сверяй pg_file_settings: строка с applied = f — значение из файла не действует; postgresql.auto.conf держи пустым.
  4. В CI запускай миграцию в транзакции с ROLLBACK и сравнивай pg_relation_filenode до и после, а EXPLAIN — без Seq Scan на больших таблицах.
  5. Убери стенд: docker rm -f -v dba-dbcode-pg-fw dba-dbcode-pg-lb dba-dbcode-pg-sq dba-dbcode-pg-ci dba-dbcode-lint && docker network rm dba-dbcode-net.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru