Миграции помнят решения
Для кого. Ты прочитал статьи 0–17, и у тебя есть песочница
game-dayпосле статьи 17. Особенно пригодятся миграция, Flyway и правило «применённую миграцию не правят» (статья 3а),ddl-auto: validate(статья 3б) и ключ таблицы задач (статья 16).Что будет. Как читать папку миграций как историю решений. История одного ключа: что считать «той же задачей» и почему ключ меняют новой миграцией, а не правкой старой. Что Hibernate сверяет при старте, чего он не видит — и как самому сменить ключ в таблице, где уже лежат повторы.
Откуда статья. Раздел «Буткемп / Лаборатория» — о том, как пишут код команды Hammerhall: буткемп работает в открытых репозиториях, лаборатория — в закрытых. Серия «От системы к делу» — следующий шаг после «От Spring к системе»: как правила дела — сроки, деньги, обещания людям — живут в коде. Примеры — учебные: их можно повторить у себя.
Зачем читать миграции#
Открой задачу учебной CRM — сущность Task из статьи 17.
Десять полей, геттеры дописывает Lombok, и ни слова о ключе из статьи
16. А именно из-за него ошибочное закрытие задачи необратимо: две строки
с одинаковым видом, человеком, группой и сроком брони база не
пустит.
Почему в ключе срок брони, а не срок задачи, который стоит рядом? Почему вообще срок, а не просто «человек в группе»? Класс показывает, как устроено сейчас. Почему оно устроено именно так, в класс не помещается.
Ответ лежит в другой папке —
src/main/resources/db/migration. Там все изменения базы по
порядку (статья 3а), и каждый файл — решение, принятое однажды. Код —
снимок системы на сегодня. Миграции — её дневник: что меняли, в каком
порядке и, если автор не поленился, зачем.
Это последняя остановка серии. Лид, заметки менеджера, скидки, задачи, бронь — всё, о чём шли статьи 12–17, лежит строками в таблицах учебной CRM. Сегодня — как эти таблицы появились и что в них передумали.
Папку миграций учебной CRM клуба соберём из того, что о ней
рассказали статьи 12–17. Куски миграций — для чтения, выброшенное в них
помечено -- …. Свой код статьи — не Java, а три учебные
миграции в схеме example: таблица, новый столбец, новый
ключ. Ты прогонишь их подряд в отдельном контейнере Postgres и увидишь,
что встаёт, что падает и почему. Полные файлы — в конце статьи.
Часть 1. Папка как история решений
Семь файлов#
Папка миграций учебной CRM:
| файл | что решили | где об этом было |
|---|---|---|
V1__create_lead.sql |
лиды: карточка и статус | статья 13 |
V2__create_lead_note.sql |
заметки менеджера — отдельной таблицей | статья 17 |
V3__create_discount.sql |
скидки тарифов: процент или сумма — ровно одно | статья 14 |
V4__create_task.sql |
задачи менеджеру с двумя сроками и ключ от повторов | статьи 12 и 16 |
V5__rename_member_to_became_member.sql |
старым лидам — новое имя конечного статуса | статья 13 |
V6__task_dedup_by_group_and_until.sql |
новый ключ задач | часть 2 |
V7__add_task_hold_id.sql |
задаче — номер брони | статья 16 |
Прочитай список сверху вниз — и увидишь, как росла CRM: сначала лиды и разговор с ними, потом деньги, потом задачи. А V5 и V6 — не новое, а передуманное. Имя статуса обещало то, чего CRM не ведёт (статья 13), а ключ задач держал не то (часть 2). Код показывает только итог. Что передумали, видно лишь здесь.
Все таблицы учебной CRM — в схеме sales, где в статье 14
ты уже заводил таблицу скидок. В статьях 13 и 16 куски миграций шли без
схемы, для краткости; в файлах она названа явно, как велит статья
3а.
Когда и вместе с чем: git log по файлу
Файл говорит «что», а иногда и «почему». «Когда и вместе с чем» знает
git. Команда git log печатает историю коммитов, а путь
после -- оставляет только те, что трогали этот файл.
Попробуй в любом репозитории с историей. Подойдёт рабочий проект, чужой
проект на GitHub или песочница, если ты её коммитил:
git log --follow --format='%h %ad %s' --date=short \
-- src/main/resources/db/migration/V2__game_day_incident_severity.sql--format— что печатать: короткий номер коммита (%h), дату (%ad) и заголовок (%s);--date=short— дату без времени;--follow— идти по истории и сквозь переименования. Помнишь из статьи 3а: номер миграции берут свободный на момент слияния, и тот, кто вливает вторым, переименовывает свой файл. Без--followистория такого файла начнётся с переименования, а коммит, где он появился, выпадет.
Вывод — строка на коммит. Примерно так; номер, дату и заголовок ты увидишь свои:
4e1b9c2 2026-10-08 Журнал: серьёзность, подробности атаки и индексДальше — git show --stat <номер>: какие файлы
пришли тем же коммитом. Миграция редко приходит одна: рядом сущность,
которой нужен новый столбец, служба, тест. По соседям видно, ради чего
меняли базу.
Заголовок коммита и описание пул-реквеста иногда говорят, зачем. Иногда — нет: «правки», «fix по ревью». Поэтому главное «почему» пишут в самом файле — об этом часть 2.
💡 Коммитов у миграции больше одного — присмотрись к датам. Правки до слияния законны: пока пул-реквест не влит, файл живёт только в ветке (статья 3а). Правка после слияния — та самая, из-за которой приложение не стартует на базах, где миграция уже прошла.
Итог — \d, история —
миграции
Итог всех файлов показывает \d — команда
psql «опиши таблицу»: столбцы, умолчания, индексы,
ограничения. \d sales.task после V7 в разделе индексов
печатает:
Indexes:
"task_pkey" PRIMARY KEY, btree (id)
"ix_task_open_due" btree (due_at) WHERE done_at IS NULL
"task_dedup" UNIQUE CONSTRAINT, btree (kind, person_id, group_id, until_at)\d говорит, что есть. Что было до и почему стало так —
только миграции. Почему в последней строке нет due_at, хотя
ключ задумывали с ним, — часть 2.
Имя — тоже запись#
Имя файла — первое, что видно в папке, и Flyway его запоминает: в
таблице истории flyway_schema_history описание — это часть
имени после __, с пробелами вместо подчёркиваний (как
game day incident в статье 3а).
V6__task_dedup_by_group_and_until.sql останется в истории
каждой базы строкой task dedup by group and until.
Хорошее имя говорит, что решили:
create_task, rename_member_to_became_member,
task_dedup_by_group_and_until — по списку видно, какой файл
открыть. Плохое говорит только, что что-то было:
V6__fix.sql, V6__task_changes.sql. Через
полгода по нему не найдёшь ничего.
Отсюда правило: одна миграция — одно решение. Новый ключ задач и номер брони — два файла, V6 и V7: у каждого своё «почему», и каждое находится по имени. В рабочем проекте имена, номера и схему во всех миграциях проверяет отдельный тест правил. Об этом было в статье 3а.
Что видно в первых файлах#
V1__create_lead.sql, сокращено:
-- учебная CRM клуба: V1__create_lead.sql — сокращено
CREATE TABLE sales.lead (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- … имя, контакты, источник, канал, интерес, менеджер
status VARCHAR(32) NOT NULL DEFAULT 'NEW',
created_at TIMESTAMPTZ NOT NULL
);gen_random_uuid() ты видел в статье 3а: ключ придумает
база, если строку вставили SQL-ом, мимо Java. Это функция самого
Postgres, она встроена с 13-й версии.
⚠️ В интернете ты увидишь перед gen_random_uuid() строку
CREATE EXTENSION pgcrypto или функцию
uuid_generate_v4() из расширения uuid-ossp.
Это Postgres до 13-й версии: там UUID делали только расширения. У нас —
Postgres 17 из статьи 3а, расширение не нужно.
'NEW' в умолчании status — не просто слово,
а имя константы LeadStatus.NEW: на поле стоит
@Enumerated(EnumType.STRING) (статьи 9 и 13). База и Java
договорились о написании. Переименуешь константу — старые строки и
умолчание базы станут неправдой, и чинить их придётся миграцией данных.
Так и вышло с конечным статусом — вот V5 целиком:
-- учебная CRM клуба: V5__rename_member_to_became_member.sql
UPDATE sales.lead SET status = 'BECAME_MEMBER'
WHERE status = 'MEMBER';Миграция меняет не только устройство таблиц, но и строки в них: для
Flyway нет разницы, что в файле, он применит его по порядку и один раз.
В чужих папках встретишь и строки справочников, которые миграция
заливает сразу, — часто с хвостом ON CONFLICT … DO NOTHING:
«такая строка уже есть — не вставлять». Как он устроен, в рабочем
проекте разбирает мидл.
V4__create_task.sql заводит задачам, кроме ключа,
частичный индекс из статьи 9:
-- учебная CRM клуба: из V4__create_task.sql
CREATE INDEX ix_task_open_due ON sales.task (due_at) WHERE done_at IS NULL;В указателе только открытые задачи, по сроку задачи, — ровно то, что
спрашивает список на сегодня из статьи 12: done_at is null
и due_at раньше границы. Закрытых со временем большинство,
и держать их там незачем.
Часть 2. История одного ключа: V4 → V6
Зачем задачам ключ#
Опрос броней идёт каждые полчаса (статьи 15 и 16), а живая бронь держится сутки. Значит, одну и ту же бронь опрос видит много раз подряд. Бронь вроде той, что в статье 16, держится до 06.10 11:00. Если её не оплатят, опрос увидит её ровно 48 раз: с 11:15 понедельника до 10:45 вторника.
Служба броней на каждой строке ответа пробует завести задачу «довести до оплаты». Без защиты к утру у менеджера были бы десятки одинаковых задач об одном человеке.
Защита — ограничение уникальности
UNIQUE, тот самый ключ из статьи 16: правило таблицы «двух
строк с одинаковыми значениями в этих столбцах быть не может». Вторую
такую строку база не вставит и вернёт ошибку. Как первичный ключ из
статьи 3а, только это не адрес строки, а правило «одно и то же — один
раз». В учебной CRM он зовётся task_dedup — от
«дедупликации», отсечения повторов.
Почему правило в базе, а не проверка в коде перед вставкой? Проверку
можно забыть, обойти скриптом или вставкой руками в psql.
Правило базы держится при любом коде (статья 14) — последний рубеж из
статьи 3а.
Весь вопрос — какие столбцы взять. Набор столбцов в ключе и есть ответ на вопрос «что считать той же задачей».
V4: тот же вид, тот же человек, тот же срок задачи
Таблица задач появилась в V4 с таким ключом:
-- учебная CRM клуба: из V4__create_task.sql
CONSTRAINT task_dedup UNIQUE (kind, person_id, due_at)due_at — срок задачи, первый из двух сроков статьи 12.
Выглядит разумно: тот же вид задачи, тот же человек, тот же срок —
значит, повтор.
Теперь вспомни, какой срок у задачи по брони: «сейчас» — момент,
когда опрос её заметил (статья 12; в строителе из статьи 17 —
.dueAt(now)). Опрос в 11:15 заводит задачу со сроком 11:15.
Опрос в 11:45 видит ту же бронь — и заводит задачу со сроком 11:45. Ключ
сравнивает: вид тот же, человек тот же, срок другой — не повтор. В
таблице после двух опросов:
kind | due_at | until_at
------+------------------------+------------------------
HOLD | 2026-10-05 08:15:00+00 | 2026-10-06 08:00:00+00
HOLD | 2026-10-05 08:45:00+00 | 2026-10-06 08:00:00+00Время psql показывает здесь по UTC (статья 3а): 08:15 —
это 11:15 по Москве. Две строки об одной брони, а к утру их было бы под
полсотни. Ключ стоит — и не держит ничего: в нём момент действия, а он у
каждого опроса свой.
Другая крайность: ключ без срока
Тогда, может, срок из ключа выкинуть совсем?
UNIQUE (kind, person_id, group_id) значит «тот же вид
задачи на то же место», а место — человек в группе (статья 16). Повторы
опроса этот ключ отсечёт.
Но вспомни правило 1 из статьи 16: бронь сгорела, человек вернулся и забронировал место снова. Задача по старой брони закрыта, а закрытые строки ключ тоже считает (статья 16). Новая бронь, новый срок, новый разговор — а база отвечает:
ERROR: duplicate key value violates unique constraint "task_dedup"Строкой ниже, в DETAIL, база называет и сам ключ:
HOLD, номер человека, номер группы. Для службы такая ошибка
— обычный повтор: так она и узнаёт, что задача уже заведена (статья 16).
Новая бронь осталась бы без задачи, и никто бы этого не заметил.
Ключ ошибается в обе стороны. Слишком узкий, с моментом действия, — пропускает повторы. Слишком широкий — принимает другую задачу за повтор. Вторая ошибка опаснее: лишнюю задачу менеджер видит, пропавшую — нет.
V6: то же место и тот же срок брони
Ошибку V4 поймал тест опроса: два опроса подряд с одной и той же бронью — и в таблице две задачи. Задач по брони в базах тогда ещё не было: опрос броней не включали. Но сама V4 уже прошла везде — у разработчиков, на стенде, на боевом. Нужна новая миграция:
-- учебная CRM клуба: V6__task_dedup_by_group_and_until.sql
-- Ключ от повторов задач по брони: тот же вид, тот же человек, та же группа, тот же срок брони.
--
-- V4 держала UNIQUE (kind, person_id, due_at). Этого мало: due_at — срок ЗАДАЧИ, а у задачи
-- по брони он «сейчас» — момент опроса, который её заметил. Следующий опрос даёт новый due_at,
-- и ключ пропускал повтор на каждом опросе, раз в полчаса.
-- Срок из ключа не убираем, а берём срок самой брони — until_at: человек забронировал место
-- снова — срок новый — это ДРУГАЯ задача.
--
-- Задач по брони в таблице ещё нет — опрос броней не включали, — так что повторов, которые
-- помешали бы новому ключу, нет и чистить нечего.
--
-- V4 уже применена и не правится, поэтому — новая миграция.
ALTER TABLE sales.task DROP CONSTRAINT task_dedup;
ALTER TABLE sales.task
ADD CONSTRAINT task_dedup UNIQUE (kind, person_id, group_id, until_at);По строкам:
- в ключе — место и срок самой брони, второй срок статьи 12. Повтор опроса: всё то же — не пустит. Новая бронь того же места: срок другой — заведёт;
kindв ключе тоже не зря: у одной брони бывают задачи «довести до оплаты» и «место ушло» (статья 16), и различает их только вид;DROP CONSTRAINT, потомADD CONSTRAINT. Поменять столбцы у готового ограничения Postgres не умеет: старое снимают, новое ставят. Таблица и строки остаются. Пересоздать таблицу черезDROP TABLEиCREATE TABLEс новым ключом было бы короче, ноDROP TABLEудаляет и все задачи;- имя у нового ключа прежнее —
task_dedup. Имя ограничения база называет в ошибке. По нему код и тесты узнают, какое правило сработало, — какIncidentTableITв статье 3а узнаёт свою проверку. Назови V6 ключ иначе — и всё, что ждалоtask_dedup, перестало бы его узнавать.
Те же случаи на таблице после V6:
| случай | что делает ключ V6 |
|---|---|
| опрос в 11:45 снова видит бронь, задача по ней уже есть | не пускает: повтор |
| в тот же опрос — бронь того же человека в другой группе | заводит: группа другая |
| бронь сгорела — задача «место ушло» по той же брони | заводит: вид другой |
| задачу закрыли, а опрос снова видит ту же бронь | не пускает: закрытая строка держит ключ |
| человек забронировал место снова | заводит: срок брони другой |
На повторе опроса база отвечает той же строкой, что выше:
ERROR: duplicate key value violates unique constraint "task_dedup"Только в DETAIL теперь четыре столбца — вид, человек,
группа и срок брони 2026-10-06 08:00:00+00, по UTC.
Номер брони, hold_id, пришёл позже, своей миграцией V7 —
той, что в статье 16. В ключ его не добавляли: место и срок и так
различают бронь. У человека одно место в группе, и система клуба отдаёт
по нему одну строку (статья 16).
Почему новая миграция, а не правка V4
Последняя строка комментария V6 — правило из статьи 3а. Поправь ключ
прямо в V4, и Flyway сверит контрольную сумму файла с историей. Они не
сойдутся, и CRM не стартует на каждой базе, где V4 уже прошла.
flyway repair не спасёт: он перепишет сумму в истории, а
ключ на тех базах останется старым.
Коварно другое: на чистой базе, в тестах, правка прошла бы незаметно — поэтому её так легко сделать. Новый файл — единственный путь.
🔑 Что считать «тем же самым» — вопрос дела, а не базы. База держит
любой ключ одинаково строго, и неверный тоже. Прежде чем писать
UNIQUE, скажи словами: «две строки — одна и та же задача,
если…». Если в продолжении есть «когда сделали», «когда отправили»,
«когда завели» — насторожись: момент действия меняется каждый раз, а то,
о чём задача, — нет. И проверь обратное: не назовёт ли ключ «тем же
самым» две разные вещи.
💡 В рабочем проекте похожую ошибку однажды нашли в описании задачи на доработку: оно предлагало ключ со сроком задачи. Описание прочитали с вопросом «а что будет при следующем опросе?». Ключ построили на сроке предмета, закрыли это тестом, а описание потом поправили. Его тоже кто-то написал, и читать его так — та же работа, что ревью кода.
Комментарий — для того, кто придёт через полгода
Положи рядом V4 и V6. В V4 ни слова: почему ключ такой — догадывайся.
В V6 — что было, чем это плохо, что теперь значит «та же задача», почему
чистить нечего и почему файл новый. Кто через полгода увидит
until_at в ключе, не станет «упрощать» его обратно до
due_at: причина написана над строкой.
V5 тоже без слов, а причина у неё длинная — целая часть статьи 13:
имя «клиент» обещало состояние, которого CRM не ведёт. По одной строке
UPDATE её уже не восстановить.
С причиной написана и V7 из статьи 16: зачем хранить номер брони, если CRM не хранит копий? Затем, что потом его будет негде взять. А в рабочем проекте копию цены однажды убрали так же — новой миграцией и с комментарием прямо в ней (статья 14).
Что делает миграция, видно из SQL. Комментарий нужен для «почему так»
и «почему не иначе» — и для того, чего намеренно не сделали: строка
«чистить нечего» объясняет, почему в V6 нет DELETE.
Часть 3. Что знает база и чего не видит Java
Hibernate сверяет, а пишет только Flyway
В статье 3б ты поставил ddl-auto: validate: таблицы
создаёт только Flyway, а Hibernate при старте сверяет с ними классы. В
учебной CRM — так же. Почему не update из туториалов?
Таблицы меняли бы двое, и базы разъехались бы.
ddl-auto — что Hibernate делает со схемой при старте.
При validate он ничего не меняет, только сверяет;
документация Hibernate так и пишет: «validate the database schema
without changing it». К этому моменту Flyway уже применил миграции
(статья 3а), и Hibernate проходит по сущностям. По исходнику Hibernate 7
он проверяет:
- есть ли таблица у каждой сущности;
- есть ли столбец под каждое поле;
- подходит ли тип столбца к типу поля.
Не сошлось — приложение не стартует. Появись в Task поле
holdId раньше миграции V7 — и старт кончился бы той же
ошибкой, что опечатка в статье 3б:
Schema validation: missing column [hold_id] in table […].
⚠️ Стартовало — не значит «совпало»
«Стартовало — значит, схема совпала с кодом» — неправда. Сверка идёт
в одну сторону: от класса к базе. Лишний столбец, CHECK,
UNIQUE, индекс — всё, чего в классе нет,
validate не ищет. А у описанных полей не сравнивает
NOT NULL, длину строки и умолчания:
nullable = false, length = 32 над статусом лида (статья 13)
— пометка, которую validate с таблицей не сверяет.
В сущностях учебной CRM нет ни task_dedup, ни
discount_percent_or_amount. В Task из статьи
17 десять полей — и ни слова о ключе. Ты узнаешь о нём из миграций — или
из ошибки базы, когда правило сработает.
Отсюда же беда ручных правок. Кто-то на боевом руками добавил индекс
или CHECK «на минутку». Приложение стартует,
validate промолчит, а миграции о нём не знают. На стенде и
в тестах этого правила нет, и следующая миграция может о него
споткнуться только там, где его поставили. Поэтому устройство базы
меняют только миграцией (статья 3а).
🔑 Правило, которого нет в классе, держит одна база, — а почему оно такое, расскажут только миграции. Читаешь чужую систему — читай сущность вместе с её миграциями.
Часть 4. Сменить ключ самому#
Приглашения на день открытых дверей
Учебный пример, в учебной CRM такой таблицы нет. Клуб зовёт лидов на
день открытых дверей: ночной обход пишет каждому в один канал — на почту
или в мессенджер (контакты карточки лида из статьи 13). Первая миграция
заводит example.open_day_invite: кого позвали
(lead_id), на какой день (open_day_at), куда
написали (email или messenger), когда
отправили (sent_at). Целиком она — в «Пример целиком», а
здесь два правила:
CONSTRAINT open_day_invite_one_channel CHECK (
(email IS NOT NULL AND messenger IS NULL) OR
(email IS NULL AND messenger IS NOT NULL)),
CONSTRAINT open_day_invite_dedup UNIQUE (lead_id, sent_at)CHECK «ровно один из двух» записан так же, как «процент
или сумма» в таблице скидок статьи 14: два случая через OR,
в каждом одно есть, а другого нет. Оба пустые или оба заполнены — база
строку не пустит.
А ключ повторяет ошибку V4: в нём момент действия,
sent_at. Обход полагается на ключ: «что уже было, база не
пустит». А ключ пускает: у каждой ночи свой момент отправки. Того же
человека зовут на тот же день заново, и повторы копятся в таблице. Файл
data.sql — две такие ночи.
Новый столбец: умолчание для старых строк
Потом у каждой группы клуба появился свой день открытых дверей. Вторая миграция:
ALTER TABLE example.open_day_invite
ADD COLUMN group_code VARCHAR(64) NOT NULL DEFAULT 'all';
ALTER TABLE example.open_day_invite
ALTER COLUMN group_code DROP DEFAULT;NOT NULL без умолчания на таблице со строками не встанет
(ошибка contains null values из статьи 9). Умолчание
'all' нужно старым строкам: тогда день был общий для всех.
Новым оно вредно: забыл назвать группу — и приглашение тихо ушло бы на
«общий» день. DROP DEFAULT снимает умолчание для будущих
вставок, а старые строки своё 'all' сохраняют. Вставка без
группы теперь громко падает на NOT NULL.
Оставлять ли умолчание новым строкам, решают для каждого столбца
отдельно. В статье 9 'low' оставили: без него покраснел бы
тест со вставкой SQL-ом из статьи 3а. Здесь такого теста нет, а «общего
дня» у нового приглашения не бывает — умолчание снимаем.
💡 Умолчание-константу вроде 'all' база просто
запоминает (статья 9). Умолчание-функцию она вычисляет для каждой старой
строки заново:
ADD COLUMN new_id UUID NOT NULL DEFAULT gen_random_uuid()
раздаст строкам разные UUID. Так таблицу с данными переводят на новый
ключ шагами ALTER, не теряя ни строки.
Новый ключ — новой миграцией#
Третья миграция меняет ключ, как V6: «тот же человек, та же группа, тот же день». Первое, что хочется написать:
ALTER TABLE example.open_day_invite DROP CONSTRAINT open_day_invite_dedup;
ALTER TABLE example.open_day_invite
ADD CONSTRAINT open_day_invite_dedup UNIQUE (lead_id, group_code, open_day_at);⚠️ В тестах это пройдёт: база там пустая. А на таблице, где повторы уже лежат, — нет:
ERROR: could not create unique index "open_day_invite_dedup"Строкой ниже, в DETAIL, база называет сам повтор: лид,
которого звали дважды, группа all, день
2026-10-17 15:00:00+00 — is duplicated,
«встречается дважды». Новый ключ база ставит, только если таблица уже
ему подчиняется. Flyway выполняет каждую миграцию в своей транзакции. На
Postgres упавшая откатывается целиком, вместе с
DROP CONSTRAINT: старый ключ останется — но приложение не
стартует.
Значит, до нового ключа надо решить, что делать с повторами: какое приглашение настоящее? Оставляем первое — его человек получил раньше:
DELETE FROM example.open_day_invite AS later
USING example.open_day_invite AS earlier
WHERE later.lead_id = earlier.lead_id
AND later.group_code = earlier.group_code
AND later.open_day_at = earlier.open_day_at
AND later.sent_at > earlier.sent_at;DELETE … USING удаляет строки, для которых нашлась пара
в другой таблице. Здесь «другая» — та же самая под вторым именем:
AS later и AS earlier, как если бы список
сравнивали с его копией. Строка удаляется, если есть приглашение о том
же, отправленное раньше; у самого первого такого нет — оно остаётся.
Одинаковых sent_at у одного человека старый ключ не пускал,
так что среди повторов одного приглашения самое раннее всегда одно.
После DELETE новый ключ встаёт.
🔴 Миграция, которая удаляет строки, необратима. Миграции идут только вперёд: вернуть прежнюю версию приложения — не значит вернуть удалённые строки. Какие строки — повторы и какую оставить, решает клуб. Запиши это решение в комментарии миграции, как в V6. В рабочем проекте такое в одиночку не решают: удаление строк миграцией джун обсуждает с мидлом.
Проверь себя#
Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.
- В сущности есть поле, и непонятно, зачем оно. Где искать ответ и как
узнать, когда и вместе с чем оно появилось? Зачем
--follow? - Ключ V4
(kind, person_id, due_at)не держал повторов задач по брони. Почему? Что не так с ключом без срока(kind, person_id, group_id)— и почему эта ошибка опаснее? - Что значит «та же задача» по ключу V6? Зачем в нём
kindиuntil_at? - Почему V6 — новая миграция, а не правка V4? Что случилось бы после правки и почему в тестах этого не видно?
- Почему новый ключ называется так же, как старый, —
task_dedup? - Что проверяет Hibernate при
ddl-auto: validateи чего не проверяет? Откуда ты узнаешь оtask_dedup? - Зачем учебная вторая миграция сначала ставит умолчание, а потом
снимает его? Что было бы без
DROP DEFAULT? - Новый ключ прошёл тесты и упал на базе с данными. Почему? Что должна
сделать миграция до
ADD CONSTRAINTи кто решает, какие строки оставить?
Что дальше#
Это последняя статья серии «От системы к делу», серия закончена. Теперь ты умеешь открыть чужую работающую систему и прочитать в ней дело:
- два срока у одной задачи;
- статус, который ставит только событие;
- деньги в копейках с округлением итога;
- правило продаж в настройках;
- пропажу строки как знак оплаты;
- код, который дописал за тебя обработчик аннотаций;
- и историю схемы, записанную в её миграциях.
Дальше — чужие проекты. Открой любой открытый проект на Spring и прочитай его папку миграций от первого файла до последнего. У каждого файла спроси, что в нём решили и что передумали.
Первоисточники#
- Flyway: Versioned migrations — имя файла, порядок, контрольная сумма;
- Flyway: Migration transaction handling — каждая миграция в своей транзакции, упавшая откатывается;
- PostgreSQL
17: ALTER TABLE —
ADD COLUMNс умолчанием,DROP DEFAULT,ADDиDROP CONSTRAINT; - PostgreSQL 17:
DELETE —
USINGи вторая копия таблицы под своим именем; - Introduction
to Hibernate 7: Automatic schema export — что делают
validate,updateи остальные значения; - git log —
--follow,--formatи--date.
Пример целиком#
Учебный пример, в учебной CRM его нет. Песочница
game-day после статьи 17 — журнал в
example.gameday.journal со всем, что накопили статьи 1–10,
сторожа статьи 11, модель статей 12–17 в example.crm и её
тесты, Postgres в контейнере, охрана, Actuator и стенд — остаётся как
есть. Java в этой статье нет, в pom.xml ничего не
добавляй.
Что добавить: папку open-day в корне песочницы, рядом с
src, и в ней пять файлов ниже. Это не миграции журнала, и в
src/main/resources/db/migration их не клади: номера V1 и V2
там уже заняты, а двух миграций с одним номером Flyway не примет —
песочница перестанет стартовать (статья 3а). Сам Flyway папку
open-day не видит: он ищет миграции в
db/migration. Сторож статьи 11 ищет их там же.
Запускай файлы руками, в отдельном контейнере Postgres, а не в базе
журнала: учебные таблицы и повторы в game-day-db ни к чему.
Нужен запущенный Docker. Порядок — как у Flyway: по номерам, каждый файл
один раз. Между первой и второй миграцией идёт data.sql —
жизнь таблицы: повторы двух ночей, которые чинит третья миграция. Путь к
файлу — комментарием в первой строке.
-- open-day/V1__open_day_invite.sql
-- Приглашения на день открытых дверей: кого позвали, на какой день, куда написали.
-- Учебный пример статьи 18 — в учебной CRM клуба такой таблицы нет.
CREATE TABLE example.open_day_invite (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID NOT NULL,
open_day_at TIMESTAMPTZ NOT NULL,
email TEXT,
messenger TEXT,
sent_at TIMESTAMPTZ NOT NULL,
-- Пишем в один канал: почта или мессенджер, ровно один из двух.
CONSTRAINT open_day_invite_one_channel CHECK (
(email IS NOT NULL AND messenger IS NULL) OR
(email IS NULL AND messenger IS NOT NULL)),
-- Не звать человека дважды за один обход.
CONSTRAINT open_day_invite_dedup UNIQUE (lead_id, sent_at)
);-- open-day/data.sql
-- Жизнь между V1 и V2: ночной обход звал на один и тот же день две ночи подряд.
INSERT INTO example.open_day_invite (lead_id, open_day_at, email, messenger, sent_at) VALUES
('00000000-0000-4000-8000-000000000001', '2026-10-17T15:00:00Z',
'lead-1@example.org', NULL, '2026-10-09T23:00:00Z'),
('00000000-0000-4000-8000-000000000001', '2026-10-17T15:00:00Z',
'lead-1@example.org', NULL, '2026-10-10T23:00:00Z'),
('00000000-0000-4000-8000-000000000002', '2026-10-17T15:00:00Z',
NULL, '@lead_2', '2026-10-10T23:00:00Z');-- open-day/V2__open_day_invite_group.sql
-- Дни открытых дверей теперь у каждой группы свои: приглашение — на день группы.
-- Прежние приглашения звали на общий день, поэтому старым строкам — 'all'.
-- Новым умолчание не нужно: группу называет тот, кто приглашает, — поэтому его снимаем.
ALTER TABLE example.open_day_invite
ADD COLUMN group_code VARCHAR(64) NOT NULL DEFAULT 'all';
ALTER TABLE example.open_day_invite
ALTER COLUMN group_code DROP DEFAULT;-- open-day/V3__open_day_invite_dedup_by_day.sql
-- Ключ повтора: «этот человек, эта группа, этот день открытых дверей».
--
-- V1 держала UNIQUE (lead_id, sent_at). sent_at — когда отправили, а обход
-- отправляет каждую ночь заново: ключ пропускал повтор каждую ночь.
-- То же приглашение — тот же человек на тот же день той же группы,
-- когда бы мы ему ни писали.
--
-- Повторы уже лежат в таблице, и с ними новый ключ не встанет. Оставляем
-- первое приглашение — его человек получил раньше, — остальные удаляем.
--
-- V1 уже применена и не правится, поэтому — новая миграция.
DELETE FROM example.open_day_invite AS later
USING example.open_day_invite AS earlier
WHERE later.lead_id = earlier.lead_id
AND later.group_code = earlier.group_code
AND later.open_day_at = earlier.open_day_at
AND later.sent_at > earlier.sent_at;
ALTER TABLE example.open_day_invite DROP CONSTRAINT open_day_invite_dedup;
ALTER TABLE example.open_day_invite
ADD CONSTRAINT open_day_invite_dedup UNIQUE (lead_id, group_code, open_day_at);-- open-day/check.sql
-- Что держит схема после V3. Каждая вставка — свой случай; ошибки ожидаемы.
SELECT lead_id, group_code, open_day_at, sent_at FROM example.open_day_invite ORDER BY sent_at;
-- 1. Третья ночь, тот же день, та же группа — повтор.
INSERT INTO example.open_day_invite (lead_id, group_code, open_day_at, email, sent_at)
VALUES ('00000000-0000-4000-8000-000000000001', 'all', '2026-10-17T15:00:00Z',
'lead-1@example.org', '2026-10-11T23:00:00Z');
-- 2. Тот же человек, тот же час, но день другой группы — другое приглашение.
INSERT INTO example.open_day_invite (lead_id, group_code, open_day_at, email, sent_at)
VALUES ('00000000-0000-4000-8000-000000000001', 'morning-yoga', '2026-10-17T15:00:00Z',
'lead-1@example.org', '2026-10-11T23:00:00Z');
-- 3. Группа не названа — умолчания больше нет.
INSERT INTO example.open_day_invite (lead_id, open_day_at, email, sent_at)
VALUES ('00000000-0000-4000-8000-000000000002', '2026-10-24T15:00:00Z',
'lead-2@example.org', '2026-10-11T23:00:00Z');
-- 4. Оба канала сразу.
INSERT INTO example.open_day_invite (lead_id, group_code, open_day_at, email, messenger, sent_at)
VALUES ('00000000-0000-4000-8000-000000000002', 'morning-yoga', '2026-10-24T15:00:00Z',
'lead-2@example.org', '@lead_2', '2026-10-11T23:00:00Z');Запуск. Контейнер — как Postgres на минуту из статьи
3а, только с другим именем и без -p: порт наружу не
открываем, psql работает внутри контейнера, а порт 5432 у
тебя может занимать game-day-db. Пароль явно учебный:
docker run -d --rm --name open-day-db -e POSTGRES_PASSWORD=open-day-local postgres:17-alpineГотова ли база — спроси pg_isready, как в статье 6;
-h 127.0.0.1 — по той же причине: при первом запуске образ
ненадолго поднимает базу без сети. Через несколько секунд ответ кончится
на accepting connections:
docker exec open-day-db pg_isready -h 127.0.0.1 -U postgresЗавести схему — psql от имени пользователя
postgres (-U), одной командой
(-c):
docker exec open-day-db psql -U postgres -c "CREATE SCHEMA example"Дальше — из папки open-day, файлы по порядку:
V1__open_day_invite.sql, data.sql,
V2__open_day_invite_group.sql,
V3__open_day_invite_dedup_by_day.sql. Каждый — такой
командой, подставляя имя:
docker exec -i open-day-db psql -U postgres -v ON_ERROR_STOP=1 -1 -f - < V1__open_day_invite.sql< файл подаёт файл на вход команды, -i
передаёт этот вход в контейнер, -f - велит
psql читать SQL с него. -v ON_ERROR_STOP=1 —
остановиться на первой ошибке, -1 — весь файл одной
транзакцией, как у Flyway.
Проверки — без ON_ERROR_STOP, чтобы увидеть все
случаи:
docker exec -i open-day-db psql -U postgres -f - < check.sqlЧто должно получиться:
V1—CREATE TABLE;data.sql—INSERT 0 3;V2— два разаALTER TABLE;V3—DELETE 1и дваALTER TABLE.check.sql: две строки, обе с группойall; случай 1 —duplicate key value violates unique constraint "open_day_invite_dedup"; случай 2 —INSERT 0 1; случай 3 —null value in column "group_code"; случай 4 —violates check constraint "open_day_invite_one_channel".- Опыт с ошибкой:
docker stop open-day-db, подними контейнер заново и пройди тот же путь, удалив изV3весьDELETE … ;.V3упадёт сcould not create unique index, аdocker exec open-day-db psql -U postgres -c "\d example.open_day_invite"покажет старый ключ(lead_id, sent_at): транзакция откатилась целиком, вместе сDROP CONSTRAINT. - Опыт с умолчанием: снова пересоздай контейнер и пройди путь, убрав
из
V2второйALTER TABLE— тот, что сDROP DEFAULT. Вcheck.sqlслучай 3 пройдёт —INSERT 0 1, и приглашение тихо уйдёт на «общий» деньall. docker stop open-day-db— контейнер остановится и благодаря--rmудалится вместе с базой../mvnw verifyв песочнице — зелёный, как после статьи 17: тесты статей 12–17 —TodayListTest,FunnelNumbersTest,CheckoutTest,RublesTest,ReminderPointTest,PaidSweepTestиLeadCardTest— на месте, у сторожа статьи 11 по-прежнему 43 запуска, миграций журнала по-прежнему две: папкуopen-dayне видят ни Flyway, ни сторож.