Ревью SQL-запросов и миграций

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

// промпт
Ты — инженер баз данных, который ревьюит SQL и миграции перед выкатом на продакшен. Ты думаешь о данных, которых в тестах не было, и о таблице на миллионы строк, которая не может остановиться на время миграции. Проверь мой код. ## Контекст - **СУБД и версия:** {{baza_dannyx_i_versiia}} - **Объём данных и нагрузка:** {{obieem_dannyx}} - **Запросы или миграция на ревью:** {{zaprosy_ili_migraciia}} ## Что проверить **1. Корректность** - NULL в условиях и соединениях: `NOT IN` с NULL, `LEFT JOIN`, превращённый фильтром в `INNER` - Дубли строк после соединений, агрегаты по неуникальному ключу - Граница диапазонов дат и часовые пояса, сравнение строк вместо чисел **2. Производительность** - Какие индексы нужны под эти запросы и какие уже есть; функции и приведения типов на индексированных колонках - `SELECT *`, сортировка и `OFFSET` на больших выборках, коррелированные подзапросы - Что показать в `EXPLAIN`, чтобы подтвердить вывод **3. Миграция на живой базе** - Какие операции берут тяжёлую блокировку и как долго; как сделать то же без простоя (создание индекса без блокировки записи, добавление колонки без перезаписи таблицы, перенос данных пачками) - Порядок выката относительно кода: что должно выйти раньше, чтобы старая и новая версия приложения работали одновременно - Откат: обратима ли миграция и что потеряется при откате **4. Целостность** - Ограничения, внешние ключи, значения по умолчанию для существующих строк ## Формат ответа Кратко общий вердикт, затем замечания по приоритету «Критично / Важно / Желательно» — для каждого: что не так, на каких данных проявится, исправленный SQL. Особенности поведения конкретной СУБД помечай явно; если не уверен, как ведёт себя указанная версия, так и скажи.
Заполните переменные

Пример запуска

Сгенерировано Claude Opus 5.5, 20 октября 2026 г.

Значения переменных

База данных и версия
PostgreSQL 14.9 (Amazon RDS)
Объём данных
Таблица orders ~85 млн строк (~40 ГБ с индексами), таблица order_items ~260 млн строк. Нагрузка 24/7: 500–800 INSERT/UPDATE в секунду от биллинга, плюс постоянные SELECT от API и ночные отчёты. Окно простоя недопустимо, деплой без downtime.
Запросы или миграция
-- Миграция 2026_10_20_add_loyalty_status.sql -- Цель: добавить статус программы лояльности к заказам и быстро фильтровать по нему BEGIN; ALTER TABLE orders ADD COLUMN loyalty_status text NOT NULL DEFAULT 'none'; ALTER TABLE orders ADD CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers(id); CREATE INDEX idx_orders_loyalty_status ON orders (loyalty_status); COMMIT; -- Бэкофис-скрипт, которым разово проставляем реальные статусы -- из старой таблицы customers_loyalty (запускается сразу после миграции) UPDATE orders o SET loyalty_status = cl.status FROM customers_loyalty cl WHERE o.customer_id = cl.customer_id; -- Запрос API: список заказов клиента без активной подписки, -- используется в личном кабинете, страницы по 50 заказов SELECT * FROM orders o LEFT JOIN subscriptions s ON s.customer_id = o.customer_id WHERE o.customer_id = $1 AND o.created_at::date BETWEEN $2 AND $3 AND s.status != 'active' AND o.customer_id NOT IN ( SELECT customer_id FROM blocked_customers ) ORDER BY o.created_at DESC OFFSET $4 LIMIT 50; -- Отчёт для финансового отдела: сумма заказов за вчерашний день -- по часовому поясу клиента (хранится как text 'Europe/Moscow' и т.п.) SELECT customer_id, SUM(amount) AS total FROM orders WHERE created_at >= now() - interval '1 day' GROUP BY customer_id ORDER BY total DESC;

Ревью миграции 2026_10_20_add_loyalty_status и запросов

Вердикт

В таком виде выкатывать нельзя. Миграция заблокирует orders целиком, в том числе на чтение, на минуты или десятки минут. Бэкофис-скрипт одной транзакцией перепишет 85 млн строк и будет конфликтовать с биллингом. В API-запросе два логических бага: один из них при одном NULL в blocked_customers скроет заказы у всех клиентов. Отчёт не учитывает часовой пояс клиента, хотя это его главное требование.

Пометка [PG] означает поведение, специфичное для PostgreSQL 14. Где я не уверен, пишу об этом прямо.

Допущения, которые нужно подтвердить:

  • orders.id — bigint PK, монотонно растущий;
  • orders.created_at — timestamptz. Если это timestamp without time zone, вся логика с поясами меняется, и об этом нужно сказать отдельно;
  • часовой пояс лежит в customers.timezone.

Существующие индексы я не видел. Пришлите \d orders, \d subscriptions, \d blocked_customers, \d customers_loyalty.


🔴 Критично

К1. Вся миграция в одной транзакции держит ACCESS EXCLUSIVE на orders до COMMIT

Что не так. Блокировка берётся на первом же ALTER TABLE и не отпускается до конца транзакции:

Операция Блокировка [PG] Длительность
ADD COLUMN ... NOT NULL DEFAULT 'none' ACCESS EXCLUSIVE Сама операция мгновенная (см. ниже)
ADD CONSTRAINT ... FOREIGN KEY SHARE ROW EXCLUSIVE на orders и customers Полная проверка 85 млн строк
CREATE INDEX (без CONCURRENTLY) SHARE (блокирует запись) Построение индекса по 85 млн строк

Поскольку ACCESS EXCLUSIVE от первого шага держится до COMMIT, orders будет недоступна даже для SELECT всё время проверки FK и построения индекса. Это минуты или десятки минут, точнее без замера на копии не скажу.

[PG] Очередь блокировок. Даже мгновенный ALTER опасен. Если в этот момент идёт ночной отчёт (он держит ACCESS SHARE), ALTER встанет в очередь. Все новые запросы к orders, включая обычные SELECT, встанут в очередь уже за ним. Получается полный простой из-за «быстрой» операции. Защита: lock_timeout плюс повтор.

[PG] Про ADD COLUMN. С PG 11 добавление колонки с неволатильным DEFAULT (константа 'none') делается без перезаписи и без сканирования таблицы, даже с NOT NULL: значение хранится в pg_attribute.attmissingval. Сама эта строка в PG 14.9 нормальна, проблемы в соседях по транзакции и в очереди блокировок.

Исправление. Разбить на несколько миграций, каждая в своей короткой транзакции. Индекс создаётся вне транзакции, потому что CREATE INDEX CONCURRENTLY нельзя выполнять внутри BEGIN. Многие инструменты миграций (Flyway, Liquibase, Rails и др.) по умолчанию оборачивают файл в транзакцию, поэтому для таких шагов нужен нетранзакционный режим.

-- ===== Миграция A: колонка (мгновенно, но с защитой от очереди блокировок) =====
SET lock_timeout = '3s';          -- если не взяли блокировку за 3с — падаем, инструмент повторяет
SET statement_timeout = '10s';
ALTER TABLE orders
    ADD COLUMN loyalty_status text NOT NULL DEFAULT 'none';
-- CHECK сразу как NOT VALID: новые/изменяемые строки проверяются, старые — нет
ALTER TABLE orders
    ADD CONSTRAINT orders_loyalty_status_check
    CHECK (loyalty_status IN ('none', 'silver', 'gold', 'platinum')) NOT VALID;  -- подставьте реальный список
-- ===== Миграция B: внешний ключ (отдельно, это вообще другая задача) =====
SET lock_timeout = '3s';
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id) REFERENCES customers(id) NOT VALID;  -- мгновенно, старые строки не проверяет
-- ===== Миграция C: валидация (после очистки сирот, см. К2) =====
-- [PG] VALIDATE берёт SHARE UPDATE EXCLUSIVE на orders и ROW SHARE на customers:
-- чтение и запись не блокируются, идёт долго, но безопасно.
SET lock_timeout = '3s';
SET statement_timeout = 0;
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_customer;
-- после бэкфилла (К3):
ALTER TABLE orders VALIDATE CONSTRAINT orders_loyalty_status_check;
-- ===== Миграция D: индекс, ВНЕ транзакции, ПОСЛЕ бэкфилла =====
SET statement_timeout = 0;
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_loyalty_status
    ON orders (loyalty_status) WHERE loyalty_status <> 'none';  -- см. В6 про форму индекса

-- [PG] если CONCURRENTLY упал, остаётся INVALID-индекс, который замедляет запись и не используется:
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
-- такой индекс удалить и пересоздать:
-- DROP INDEX CONCURRENTLY idx_orders_loyalty_status;

CREATE INDEX CONCURRENTLY ждёт завершения всех транзакций, начатых до него, включая длинные отчёты. Он никого не блокирует, но может долго висеть. Индекс строим после бэкфилла: иначе каждая из 85 млн обновлённых строк будет обновлять ещё и этот индекс, и HOT-апдейты станут невозможны.


К2. FK валидируется по всей таблице и упадёт на «сиротах»

Что не так. Если в orders есть хоть одна строка с customer_id, которого нет в customers, ADD CONSTRAINT (или VALIDATE) упадёт. В исходном варианте это случится после многих минут полной блокировки, и транзакция откатится целиком. Такие строки — частое последствие ручных чисток, импорта или удалений клиентов. На тестовой базе их, скорее всего, нет.

Побочные эффекты FK на продакшене:

  • каждый INSERT и каждое изменение customer_id в orders делает lookup в customers с FOR KEY SHARE. При 500–800 записях в секунду это заметная, хотя и умеренная надбавка;
  • DELETE FROM customers у клиентов с заказами начнёт падать. Если есть процесс удаления клиентов (GDPR, чистка тестовых аккаунтов), он сломается после выката. Это нужно проверить;
  • для дешёвых проверок при удалении из customers нужен индекс по orders.customer_id. Скорее всего он уже есть, проверьте;
  • типы orders.customer_id и customers.id должны совпадать (int и bigint — частый источник проблем).

Предпроверка (тяжёлый запрос, запускать на реплике):

SELECT count(*)
FROM orders o
WHERE o.customer_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM customers c WHERE c.id = o.customer_id);

FK вообще не относится к задаче про лояльность. Вынесите его в отдельный тикет и отдельный релиз: при проблеме тогда не придётся откатывать фичу.


К3. Бэкфилл одним UPDATE на 85 млн строк

Что не так. Производительность и эксплуатация:

  • одна транзакция обновляет до 85 млн строк. [PG] MVCC: каждая обновлённая строка — новая версия, таблица и индексы распухают примерно вдвое, пока vacuum не освободит место;
  • гигабайты WAL, лаг реплик RDS, рост занимаемого хранилища, возможна просадка IOPS;
  • построчные блокировки держатся до конца транзакции. Биллинг, обновляющий те же заказы, будет ждать часами или ловить deadlock;
  • долгая транзакция удерживает xmin горизонт: autovacuum не сможет чистить мёртвые строки во всей базе, не только в orders;
  • если запрос упадёт через три часа, откатится всё, и откат тоже займёт время.

Что не так. Корректность:

  • [PG] Дубли в customers_loyalty. Если у клиента там больше одной строки, UPDATE ... FROM возьмёт произвольную из них, без ошибки и непредсказуемо (это поведение задокументировано). Для старой таблицы дубли вполне вероятны;
  • cl.status IS NULL. Нарушит NOT NULL, и весь UPDATE упадёт на последней строке;
  • значения вне справочника ('Gold', 'gold ', устаревшие статусы) молча попадут в таблицу, потому что CHECK нет;
  • гонка с новыми заказами. Если старый код ещё работает, заказ, созданный после бэкфилла, получит 'none' навсегда. Поэтому код, проставляющий статус при вставке, должен выйти до бэкфилла (см. В3).

Предпроверки:

-- дубли
SELECT customer_id, count(*) FROM customers_loyalty GROUP BY 1 HAVING count(*) > 1 LIMIT 20;
-- NULL и неизвестные значения
SELECT status, count(*) FROM customers_loyalty GROUP BY 1 ORDER BY 2 DESC;

Исправление. Сначала дедуплицированный staging, затем пачки по диапазону id, каждая в своей транзакции.

-- 1. Staging: одна строка на клиента, только валидные статусы.
--    Правило выбора при дублях — бизнес-решение; здесь «последняя по updated_at».
CREATE TABLE loyalty_backfill AS
SELECT DISTINCT ON (customer_id) customer_id, lower(trim(status)) AS status
FROM customers_loyalty
WHERE status IS NOT NULL
ORDER BY customer_id, updated_at DESC;

ALTER TABLE loyalty_backfill ADD PRIMARY KEY (customer_id);

-- проверить, что всё попадает в справочник:
SELECT status, count(*) FROM loyalty_backfill
WHERE status NOT IN ('none','silver','gold','platinum') GROUP BY 1;
ANALYZE loyalty_backfill;
-- 2. Пачками. [PG] COMMIT внутри DO работает с PG 11, но ТОЛЬКО если DO
--    вызван не внутри транзакции (иначе "invalid transaction termination").
--    Запускать в psql без BEGIN, в autocommit. Скрипт идемпотентен: перезапуск безопасен.
SET lock_timeout = '2s';        -- [PG] распространяется и на ожидание построчных блокировок
SET statement_timeout = '30s';

DO $$
DECLARE
    batch   constant bigint := 20000;   -- подобрать: цель — пачка < 1 с
    cur     bigint;
    max_id  bigint;
    n       bigint;
BEGIN
    SELECT min(id), max(id) INTO cur, max_id FROM orders;
    WHILE cur <= max_id LOOP
        UPDATE orders o
           SET loyalty_status = lb.status
          FROM loyalty_backfill lb
         WHERE lb.customer_id = o.customer_id
           AND o.id >= cur AND o.id < cur + batch
           AND o.loyalty_status IS DISTINCT FROM lb.status;   -- не трогать уже верные строки
        GET DIAGNOSTICS n = ROW_COUNT;
        RAISE NOTICE 'id % .. %: % rows', cur, cur + batch, n;
        COMMIT;
        cur := cur + batch;
        PERFORM pg_sleep(0.05);   -- дать дышать репликам и autovacuum
    END LOOP;
END $$;

Во время прогона следите за ReplicaLag и FreeStorageSpace в CloudWatch, за n_dead_tup в pg_stat_user_tables по orders и за ожиданиями блокировок в pg_stat_activity. Если autovacuum не успевает, периодически запускайте VACUUM (VERBOSE) orders; (обычный, не FULL). Для управляемости лучше реализовать этот цикл в бэкофис-скрипте приложения с сохранением прогресса (последний обработанный id): тогда ошибку lock_timeout можно пережить без перезапуска с начала.


К4. API: NOT IN по blocked_customers вернёт пустой результат всем, если там есть хоть один NULL

Что не так. Если в blocked_customers.customer_id есть хоть одна строка с NULL, выражение x NOT IN (..., NULL) даёт NULL, а не true. Условие не выполнится ни для кого, и личный кабинет покажет пустой список заказов всем клиентам. Достаточно одной кривой вставки. Если колонка объявлена NOT NULL, баг пока скрыт, но паттерн всё равно надо заменить.

[PG] Производительность. NOT IN (subquery) не превращается в anti-join. План будет либо hashed SubPlan (хеш всей таблицы при каждом выполнении), либо, если таблица не влезает в work_mem, обычный SubPlan с перебором для каждой строки.

Исправление: NOT EXISTS (см. итоговый запрос в К5).


К5. API: LEFT JOIN subscriptions + s.status != 'active' даёт неверную логику и дубли

Что не так, на конкретных данных:

Данные клиента Ожидание («без активной подписки») Что вернёт текущий запрос
Подписок нет вообще Показать заказы Ничего: s.status равен NULL, NULL != 'active' даёт NULL, строка отбрасывается. Фильтр в WHERE превратил LEFT JOIN в INNER
Одна отменённая подписка Показать Покажет
Три старые неактивные подписки Показать каждый заказ один раз Каждый заказ ×3. Страница из 50 строк содержит около 17 уникальных заказов, пагинация разваливается
Одна активная и одна отменённая Не показывать Покажет, через строку отменённой подписки
Подписка со status IS NULL Показать (вероятно) Не покажет

Исправление. Проверять существование активной подписки, а не соединять. Поскольку обе проверки относятся к самому клиенту ($1), а не к заказу, лучше ссылаться на $1. Тогда [PG] подзапрос станет некоррелированным, выполнится один раз как InitPlan и в плане будет виден как One-Time Filter.

-- Первая страница
SELECT o.id, o.customer_id, o.created_at, o.amount, o.status, o.loyalty_status  -- явный список, см. В2
FROM orders o
WHERE o.customer_id = $1
  AND o.created_at >= $2          -- timestamptz: начало первого дня периода в поясе клиента
  AND o.created_at <  $3          -- timestamptz: начало дня, СЛЕДУЮЩЕГО за последним
  AND NOT EXISTS (SELECT 1 FROM subscriptions s
                  WHERE s.customer_id = $1 AND s.status = 'active')
  AND NOT EXISTS (SELECT 1 FROM blocked_customers b
                  WHERE b.customer_id = $1)
ORDER BY o.created_at DESC, o.id DESC
LIMIT 50;

-- Следующие страницы: keyset вместо OFFSET; $4, $5 — created_at и id последней строки предыдущей страницы
SELECT o.id, o.customer_id, o.created_at, o.amount, o.status, o.loyalty_status
FROM orders o
WHERE o.customer_id = $1
  AND o.created_at >= $2
  AND o.created_at <  $3
  AND (o.created_at, o.id) < ($4, $5)     -- [PG] row comparison используется как Index Cond в btree
  AND NOT EXISTS (SELECT 1 FROM subscriptions s
                  WHERE s.customer_id = $1 AND s.status = 'active')
  AND NOT EXISTS (SELECT 1 FROM blocked_customers b
                  WHERE b.customer_id = $1)
ORDER BY o.created_at DESC, o.id DESC
LIMIT 50;

Делайте два отдельных запроса, а не один с ($4 IS NULL OR ...): такое условие ломает использование индекса в generic-плане prepared statement.

Индексы:

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_customer_created
    ON orders (customer_id, created_at, id);          -- [PG] DESC-сортировка = backward index scan, отдельный DESC не нужен
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_subscriptions_customer_active
    ON subscriptions (customer_id) WHERE status = 'active';
-- blocked_customers.customer_id — должен быть PK/UNIQUE; если нет:
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ux_blocked_customers_customer
    ON blocked_customers (customer_id);

Если уже есть индекс только по (customer_id), новый составной его покрывает, в том числе для FK. Удалять старый стоит только после проверки по pg_stat_user_indexes.idx_scan, что его больше никто не использует.


К6. Финансовый отчёт не считает «вчера по часовому поясу клиента»

Что не так:

  1. now() - interval '1 day' — это скользящие 24 часа от момента запуска, а не календарные сутки. Запуск в 02:00 и в 05:00 даст разные цифры, а перезапуск отчёта задним числом невозможен.
  2. Часовой пояс клиента не используется вообще. Для клиента из Asia/Vladivostok (UTC+10) «вчера» по его времени и окно «последние 24 часа по серверу» расходятся до 10 часов.
  3. [PG] now() — время начала транзакции. Это нормально, но результат всё равно зависит от момента запуска.

Исправление. Отчётная дата передаётся параметром. Границы суток считаются в поясе каждого клиента, а для использования индекса по created_at добавлен грубый фильтр: местные сутки в любом поясе от UTC−12 до UTC+14 гарантированно лежат внутри окна.

-- $1 — отчётная дата, например '2026-10-19'
SELECT o.customer_id,
       SUM(o.amount) AS total
FROM orders o
JOIN customers c ON c.id = o.customer_id          -- id — PK, дублей не будет
WHERE
    -- грубое окно под индекс orders(created_at): константы, Index Cond
        o.created_at >= ($1::date::timestamp       AT TIME ZONE 'UTC') - interval '15 hours'
    AND o.created_at <  (($1::date + 1)::timestamp AT TIME ZONE 'UTC') + interval '13 hours'
    -- точные местные сутки клиента; [PG] timestamp AT TIME ZONE tz = «это местное время в поясе tz» → timestamptz
    AND o.created_at >= ($1::date::timestamp       AT TIME ZONE COALESCE(c.timezone, 'Europe/Moscow'))
    AND o.created_at <  (($1::date + 1)::timestamp AT TIME ZONE COALESCE(c.timezone, 'Europe/Moscow'))
    -- AND o.status IN ('paid', 'completed')   -- уточнить у финансов: отменённые/возвраты считаются?
GROUP BY o.customer_id
ORDER BY total DESC NULLS LAST;

Почему так:

  • арифметика делается на локальной дате, а потом переводится в timestamptz. Поэтому дни перехода на летнее время (23 и 25 часов) обрабатываются правильно, а interval '24 hours' дал бы ошибку;
  • полуоткрытый интервал [начало; начало следующего) не теряет заказы в 23:59:59.999 и не задваивает полночь.

Подводные камни данных [PG]:

  • неизвестное имя пояса ('Moscow', 'Europe/Moskow') вызывает ошибку time zone "..." not recognized, и весь отчёт падает. NULL без COALESCE превратил бы границы в NULL, и заказы клиента молча выпали бы из отчёта;
  • AT TIME ZONE принимает POSIX-строки вида 'UTC+3', и у них инвертирован знак: это UTC−3. Если такие значения есть в данных, суммы будут смещены без всякой ошибки.
-- проверка данных до запуска
SELECT timezone, count(*)
FROM customers
WHERE timezone IS NULL
   OR timezone NOT IN (SELECT name FROM pg_timezone_names)
GROUP BY 1;

Фолбэк 'Europe/Moscow' для пустого пояса — бизнес-решение, согласуйте его с финансами.


🟠 Важно

В1. o.created_at::date BETWEEN $2 AND $3: индекс не используется, результат зависит от сессии

  • Функция над колонкой лишает планировщик возможности использовать индекс по created_at, и диапазон фильтруется по всем заказам клиента. Для крупного клиента с тысячами заказов это заметно.
  • [PG] Если created_at — timestamptz, то ::date зависит от параметра сессии TimeZone. Пул соединений с UTC и psql с Europe/Moscow вернут разные заказы на границах суток. По той же причине (выражение не IMMUTABLE) на него нельзя построить функциональный индекс.
  • Исправление: полуоткрытый диапазон по самой колонке с границами, посчитанными в поясе клиента (см. К5). Если API получает даты, переводите их в timestamptz на стороне приложения или так: ($2::date::timestamp AT TIME ZONE $tz).

В2. SELECT * в API

  • Коллизии имён. С соединением orders и subscriptions результат содержит две колонки id, две customer_id, вероятно две status и две created_at. Многие драйверы и ORM при маппинге в словарь или объект берут последнюю колонку, и клиент получит subscriptions.id вместо orders.id. В исправленном запросе соединения нет, но явный список колонок всё равно нужен.
  • [PG] cached plan must not change result type. Это напрямую связано с этой миграцией. Если драйвер использует серверные prepared statements (pgJDBC после prepareThreshold, pgx, asyncpg и др.), то после ADD COLUMN (и после DROP COLUMN при откате) запросы SELECT * FROM orders в уже открытых соединениях начнут падать с этой ошибкой до переподключения. Как именно реагирует ваш драйвер, зависит от его версии и настроек, и здесь я не могу сказать наверняка. pgJDBC, например, умеет перепрепарировать запрос при определённых настройках autosave. Проверьте на стейджинге: добавьте колонку при работающем приложении под нагрузкой. Надёжное лечение — явные списки колонок до выката миграции.
  • Кроме того, SELECT * тянет лишние данные и исключает Index Only Scan.

В3. Порядок выката относительно кода

Старая и новая версии приложения должны работать одновременно на каждом шаге:

# Шаг Почему здесь
0 Предпроверки: сироты (К2), дубли и NULL в customers_loyalty (К3), пояса (К6) Чтобы не упасть посреди выката
1 Релиз приложения v1.1: явные списки колонок вместо SELECT *, исправленные запросы К4–К6 Убирает риск cached plan must not change result type и не зависит от схемы
2 Миграция A: колонка + CHECK NOT VALID Старый код не знает о колонке и получает DEFAULT 'none'
3 Релиз v1.2: при создании заказа пишется реальный loyalty_status; для чтения колонка ещё не используется Новые заказы корректны до бэкфилла, гонки нет
4 Бэкфилл пачками Исторические строки
5 VALIDATE CHECK, затем CREATE INDEX CONCURRENTLY После массового UPDATE: индекс компактный, бэкфилл не платил за его обновление
6 Релиз v1.3: фильтрация по loyalty_status Индекс уже есть
— FK: отдельный трек (B → чистка сирот → C) Не зависит от фичи

NOT NULL и новый код. Если v1.2 явно передаёт loyalty_status = NULL для клиента без статуса (ORM часто так делает), INSERT упадёт: DEFAULT применяется, только когда колонка не указана. Код должен подставлять 'none' или вовсе не передавать колонку.

В4. Семантика денормализации: статус на момент заказа или текущий?

Скрипт копирует текущий статус клиента во все его заказы, включая заказы пятилетней давности, когда клиент был none. Если колонка означает «статус на момент заказа», бэкфилл записывает исторически неверные данные, и восстановить их можно только при наличии истории изменений статуса. Если колонка означает «текущий статус клиента», ей место в customers, а не в orders: иначе при каждом изменении статуса придётся обновлять все заказы клиента, и эти обновления будут конкурировать с биллингом. Это вопрос к постановщику задачи, и ответить на него нужно до бэкфилла.

Отдельно: обновляется ли ещё customers_loyalty? Если да, данные разойдутся сразу после бэкфилла.

В5. Нестабильная пагинация и OFFSET

  • ORDER BY o.created_at DESC без уникального тайбрейкера: заказы с одинаковым created_at (а при пакетных вставках биллинга совпадения точно будут) могут меняться местами между запросами. Заказ окажется на двух страницах или не попадёт ни на одну. Решение: добавить , o.id DESC.
  • OFFSET N читает и отбрасывает N строк. Для 50 заказов на клиента это неважно, для клиента с 20 тысячами заказов и далёких страниц — важно. К тому же новая вставка между запросами сдвигает страницы. Решение: keyset-пагинация (К5).

В6. Индекс по loyalty_status под неизвестный запрос

Запроса, который «быстро фильтрует» по этому статусу, в ревью нет. Индекс по колонке с 3–5 значениями, где подавляющее большинство строк — 'none', планировщик использует только для редких значений, а при 800 записях в секунду он стоит ресурсов на каждом INSERT. Варианты:

  • частичный индекс WHERE loyalty_status <> 'none' (в миграции D), если ищут именно лояльных;
  • составной, например (loyalty_status, created_at), если фильтр всегда идёт вместе с периодом.

Пришлите реальный запрос, и я подберу индекс точно.

В7. Откат

Отдельной down-миграции нет. Вариант:

-- Сначала откатить КОД до версии, не читающей/не пишущей колонку.
SET lock_timeout = '3s';
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_loyalty_status;              -- вне транзакции
ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_loyalty_status_check;
ALTER TABLE orders DROP COLUMN IF EXISTS loyalty_status;
-- FK — отдельно и только если откатывается именно он:
-- ALTER TABLE orders DROP CONSTRAINT IF EXISTS fk_orders_customer;
  • [PG] DROP COLUMN выполняется мгновенно (метаданные), но берёт ACCESS EXCLUSIVE, поэтому lock_timeout обязателен. Место на диске не освобождается до перезаписи строк. Это снова изменение набора колонок, то есть снова риск ошибки prepared statements из В2.
  • Что теряется: статусы из бэкфилла восстановимы, пока жива customers_loyalty и staging. Статусы, записанные приложением после шага 3, не восстановимы, если они не выводятся из других данных. Перед DROP COLUMN сохраните их: CREATE TABLE orders_loyalty_backup AS SELECT id, loyalty_status FROM orders WHERE loyalty_status <> 'none';.

🟡 Желательно

Ж1. Отчёт: детали данных.

  • [PG] При ORDER BY total DESC значения NULL идут первыми. Клиенты, у которых все amount равны NULL, окажутся наверху отчёта. Поэтому добавлено NULLS LAST.
  • Тип amount должен быть numeric, а не real/double precision: на суммах по миллионам строк накопится погрешность.
  • Если суммы хранятся в разных валютах, суммировать их без колонки валюты нельзя.
  • Запускайте отчёт на read replica: нужны индекс по orders (created_at) и hot_standby_feedback или достаточный max_standby_streaming_delay, иначе долгий запрос отменится конфликтом репликации.

Ж2. Бэкфилл: тюнинг. На время массового UPDATE можно временно сделать autovacuum на таблице агрессивнее: ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_cost_limit = 2000); После бэкфилла верните настройки (RESET). Это тоже ALTER TABLE, хоть и с лёгкой блокировкой, поэтому lock_timeout нужен и здесь.

Ж3. CHECK вместо enum. Пара CHECK + NOT VALID/VALIDATE проще в эволюции, чем CREATE TYPE ... AS ENUM: удалить значение из enum нельзя. Справочная таблица с FK тоже подходит, если статусы будут часто меняться.

Ж4. Что показать в EXPLAIN. Запускать EXPLAIN (ANALYZE, BUFFERS, SETTINGS) для SELECT на реплике или копии с реальными данными. EXPLAIN ANALYZE для UPDATE выполняет его, поэтому только внутри BEGIN; ... ROLLBACK; и не на продакшене.

Запрос Что должно быть в плане Красный флаг
API Index Scan Backward using idx_orders_customer_created, в Index Cond есть customer_id, created_at и row-comparison; два InitPlan с One-Time Filter; Limit без отдельного Sort Sort над тысячами строк, Rows Removed by Filter ≫ 50, SubPlan (не hashed) по blocked_customers
Отчёт Index Scan/Bitmap Index Scan по created_at с константными границами в Index Cond, затем Filter по поясу Seq Scan on orders, Sort Method: external merge (не хватает work_mem)
Пачка бэкфилла Index Scan using orders_pkey с диапазоном id, Nested Loop/Hash Join с loyalty_backfill, время < 1 с Seq Scan по orders или loyalty_backfill

Для API проверьте и prepared-вариант: PREPARE + шесть или более EXECUTE. [PG] После пяти выполнений планировщик может перейти на generic plan, который хуже для клиентов с «нетипичным» объёмом заказов. Если это проявится, помогает plan_cache_mode = force_custom_plan для этого запроса или сессии.


Сводка

# Приоритет Где Суть
К1 🔴 Миграция ACCESS EXCLUSIVE на всю транзакцию, затем CONCURRENTLY, NOT VALID, lock_timeout
К2 🔴 Миграция FK по 85 млн строк и сироты, затем NOT VALID → чистка → VALIDATE, отдельный релиз
К3 🔴 Бэкфилл Одна транзакция, дубли, NULL, гонка с вставками, затем staging + пачки
К4 🔴 API NOT IN + NULL дают пустой список всем, затем NOT EXISTS
К5 🔴 API LEFT→INNER, дубли, «активная + отменённая», затем NOT EXISTS по подпискам
К6 🔴 Отчёт Скользящие 24 ч вместо суток клиента, затем границы через AT TIME ZONE
В1–В7 🟠 Разное ::date, SELECT * и cached plan, порядок выката, семантика статуса, пагинация, индекс, откат
Ж1–Ж4 🟡 Разное NULLS LAST, numeric, autovacuum, enum/CHECK, EXPLAIN-чеклист

Чтобы закрыть оставшиеся вопросы, пришлите: DDL четырёх таблиц с индексами, реальный запрос фильтрации по loyalty_status, ответ про семантику статуса (на момент заказа или текущий) и название миграционного инструмента с драйвером БД.

Похожие промпты

Программирование и Разработка

Ревью Python-кода: типизация и идиомы

Проводит ревью Python-кода: ошибки, которые проявятся в рантайме, аннотации типов, идиоматичность, обработка исключений и ресурсов — с правками «до/после».

Программирование и Разработка

Ревью pull request по diff

Проводит ревью изменений по diff и описанию PR: соответствует ли код заявленной цели, что сломается рядом, чего не хватает в тестах, — и пишет комментарии в формате для GitHub или GitLab.

Программирование и Разработка

Ревью Next.js-кода: App Router и Server Components

Проводит ревью Next.js-приложения на App Router: границы server/client компонентов, кеширование данных, водопады запросов и лишний клиентский бандл — с правками «до/после».

Программирование и Разработка

SEO-аудит Next.js-приложения

Проверяет Next.js-приложение по SEO-чеклисту: метаданные и canonical, рендеринг для ботов, sitemap/robots, Core Web Vitals — и выдаёт план правок по приоритету.