Why aggregate versioning

ПроблемаДва вызова прочитали один счёт, оба применили своё правило, оба сохранили. В базе осталось изменение того, кто записал вторым — первое исчезло, и ни одна транзакция об этом не узнала: оба `UPDATE` прошли успешно.

Тот же сервис, что в главах про DDD: счёт, его модули, порты и адаптеры. Домен здесь почти не меняется — у счёта появляется одно поле, и вместе с ним ответ на вопрос, кто победит, если два вызова придут разом.

  1. Шаг 1 из 8

    Два вызова, один счёт

    Справа сценарий оплаты — такой, каким его оставила глава про инфраструктуру: единица работы, репозиторий, публикация фактов.

    Пока его зовут по одному, он верен. Теперь два вызова разом: один принимает оплату, второй начисляет пеню — планировщик просрочки проснулся секундой раньше.

    Оба достали счёт в одном и том же состоянии. Оба применили своё правило к своей копии в памяти. Оба сохранили. В базе осталось то, что записали вторым: пеня затёрла оплату или оплата затёрла пеню — как повезёт.

    Это потерянное обновление, и транзакцией оно не лечится. Транзакции друг другу не помешали: каждая сделала ровно то, что просили, и обе закоммитились успешно. Нигде не упало, в логах чисто.

    Шаг 2 из 8

    Почему не запереть строку

    Первое, что приходит в голову: дописать FOR UPDATE к SELECT — и второй вызов будет ждать первого. Это пессимистичная блокировка, и она действительно работает.

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

    А главное — запирать удаётся только то, что происходит внутри одной транзакции. Человек открыл счёт, поправил срок и нажал «сохранить» через минуту: держать транзакцию минуту нельзя, и в момент его правки запирать уже нечего.

    Значит, нужно не запрещать состоянию меняться, а замечать, что оно изменилось.

    Шаг 3 из 8

    Версия у счёта

    Версия — это номер состояния, которое прочитали. Записывая, мы называем базе тот номер, что видели; если там уже другой, между нашим чтением и записью кто-то был.

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

    Правил предметной области версия не добавляет: Issue создаёт счёт с нулём, Version() только отдаёт, и никакого SetVersion нет — номер увеличивает тот, кто пишет. В дереве изменился и load.go: счёт возвращается из базы с тем номером, который там лежал.

    Шаг 4 из 8

    Конфликт — часть контракта порта

    ErrConflict объявлен рядом с портом, и это не мелочь: сценарий обязан отличать «не сохранилось, потому что счёт успели изменить» от «база упала». Первое — обычный ход событий, второе — авария.

    Домен при этом не узнал ни про SQL, ни про RowsAffected. Он сказал ровно то, что относится к предметной области: счёт мог измениться с момента, когда его прочитали, и тогда запись не состоится.

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

    Шаг 5 из 8

    Условие внутри записи

    UPDATE invoices SET ..., version = version + 1 WHERE number = $1 AND version = $2 — вот и вся оптимистичная блокировка. Условие и запись в одном запросе, поэтому между ними нельзя вклиниться.

    Интересна следующая строка: RowsAffected() == 0. Ноль обновлённых строк значит, что счёта с такой версией в базе нет. Для счёта, который мы только что прочитали, это означает одно: версия уже другая — значит, ErrConflict.

    Нулевая версия — счёт новый, его вставляют. Второе создание с тем же номером ловит уникальный индекс, и версия для этого не нужна.

    Чтение теперь тянет версию из той же строки, что и остальные поля: она часть прочитанного состояния, а не отдельный запрос.

    Шаг 6 из 8

    Повтор вместо ошибки

    Конфликт говорит: данные, на которых ты решал, устарели. Значит, правильный ответ — решить заново на свежих, а не отдать ошибку наружу.

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

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

    Попыток три, а не бесконечность: если конфликт приходит в третий раз, это уже не гонка двух вызовов, а нагрузка — и отвечать на неё надо иначе.

    Шаг 7 из 8

    Когда повторять нельзя

    Повтор уместен, когда решение принимает код: MarkPaid заново посмотрит на свежий счёт и сам скажет, что оплаченный второй раз не оплачивают.

    Он неуместен, когда решение принял человек, глядя на старое состояние. Тогда версия уезжает наружу вместе с данными: полем в проекции или заголовком ETag, — и возвращается с запросом на изменение. Не сошлась — человеку отвечают «счёт изменился, посмотрите заново», и это правильный ответ, а не ошибка.

    Разница не в механике, а в том, кто решал. Механика одна: номер прочитанного состояния, проверяемый в момент записи.

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

  2. Шаг 8 из 8

    Что получилось

    Щёлкни по файлу, чтобы прочитать.

    • domains/invoice/invoice.go — поле версии и Version().
    • domains/invoice/load.go — версия приезжает из базы вместе с данными.
    • domains/invoice/repository.goErrConflict рядом с портом.
    • infrastructure/postgres/invoices.go — условие по версии и RowsAffected.
    • applications/payment/pay.go — повтор попытки целиком.

    Домен вырос на одно поле и одну ошибку. Всё остальное — запись, которая проверяет, и сценарий, который умеет начать заново.

Листать шаги можно стрелками ← и →.