Опасный запрос — не первый. Это тот же запрос, который приходит снова, потому что отправитель не услышал первый ответ или услышал и всё равно повторил. Платёжные провайдеры так делают. Очереди так делают. Браузер так делает, когда человек нажимает кнопку дважды. Если система считает вторую копию новой работой, вы спишете дважды, отправите дважды или запишете две строки книги на одну ставку.
Исходите из дубля
Я проектирую обработчик так, чтобы вторая доставка была успехом, который ничего не делает. Первая делает работу и сохраняет ключ: id события провайдера или ключ, который прислал клиент, уникальный в базе. Вторая находит ключ и возвращает тот же результат. Без второго списания. Без второго письма. Без второй строки.
Ограничение уникальности — та часть, которой я доверяю. Проверка в коде приложения за миг до insert проигрывает гонку, когда два worker просыпаются вместе. База эту гонку не проигрывает. Проигравший получает конфликт и превращает его в «уже сделано».
| Первый приход | Второй приход | |
|---|---|---|
| Поиск | Ключ новый | Ключ уже сохранён |
| Писать | Одна строка, один побочный эффект | Новой строки нет |
| Ответить | Принято | Тот же, уже принятый |
| Если процесс умрёт на полпути | Исходящий ящик всё ещё должен сообщение | Второго оно не должно |
Сообщение уходит вместе с записью, или не уходит
Отправить письмо или сообщение очереди внутри запроса и до commit — так получается сообщение о транзакции, которая откатилась. Я пишу исходящее сообщение в той же транзакции, что и деловую строку, а отдельный worker его публикует. Если процесс умирает, строка и сообщение либо оба на месте, либо обоих нет. Worker может повторить публикацию. Обработчик не должен повторять списание.
Аннулирование — новая проводка, не правка
Когда что-то нужно отменить, я не перезаписываю исходную строку. Компенсирующая запись говорит, что изменилось, и оставляет первую строку читаемой. Через неделю всё ещё можно ответить, во что верили в 14:02. Правка строки отвечает только на то, во что верят сейчас, и без свидетеля.
Если повтор может заставить операцию случиться дважды, операция не закончена.
Назовите ключ до того, как писать обработчик
Ключ — это не «хеш всего запроса», если вы не проверили, что отправитель меняет между повторами. Платёжный провайдер пришлёт тот же id события с новой меткой времени доставки. Если метка внутри ключа, вторая доставка выглядит новой, и вы построили баг, который хотели предотвратить. Я беру id, который отправитель обещает стабильным. Если не обещает, я требую, чтобы вызывающий прислал свой, и отвергаю вызов, который приходит без него.
Потом я записываю неудачу туда же, куда и успех. Конфликт для отправителя — не страница ошибки. Это ожидаемый ответ на повтор. Отправитель должен иметь возможность остановиться. Если я отвечу 500, будет третья попытка и четвёртая, и инцидент станет очередью одинаковой работы.
Я не считаю это готовым, потому что счастливый путь вернул 200. Тест отправляет ту же нагрузку ещё раз и проверяет один побочный эффект. Потом убивает worker между commit и публикацией и проверяет, что сообщение всё ещё уходит, один раз. Эти два теста короче инцидента, который они заменяют. Я начинаю оттуда.
