Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Переходы состояний заказа: практика

Переходы состояний заказа: статусы, события и защита от гонок

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

Прямой ответ

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

Типичный набор статусов заказа:

Статус Значение Выходы
new Оформлен, ожидает оплаты pay, cancel
paid Оплачен, ожидает сборки ship, refund
shipped Отгружен deliver
delivered Доставлен завершающее
cancelled Отменён завершающее
refunded Возврат выполнен завершающее

Переход выполняется условным обновлением в транзакции:

UPDATE orders
   SET status = 'paid',
       paid_at = now(),
       updated_at = now()
 WHERE id = 1001
   AND status = 'new';

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

updated = cur.rowcount
if updated == 0:
    raise ConflictError("заказ уже в другом состоянии")

Без условия по текущему статусу два одновременных запроса оба выполнят переход, и второй перезапишет данные первого; о защите — в статье про транзакции и изоляцию.

Повторный запрос на тот же переход должен быть идемпотентным: повторная оплата не списывает деньги дважды. Ключ идемпотентности от клиента сохраняет результат первого выполнения; о механизме — в статье про идемпотентность API.

Журнал переходов хранит историю отдельно от текущего статуса:

CREATE TABLE order_transitions (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id    bigint NOT NULL,
    from_status text   NOT NULL,
    to_status   text   NOT NULL,
    event       text   NOT NULL,
    actor       text   NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now()
);

Текущий статус не показывает путь, а путь нужен при разборе обращений; о подходе — в статье про конечные автоматы.

Согласование статуса с внешними системами

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

Окно расхождения. Между списанием денег и записью статуса в базу проходит время. При сбое в этом окне деньги списаны, а статус остался прежним.

Источник истины. Для состояния платежа источником истины является платёжный провайдер, а не локальный статус. При расхождении статус приводится к данным провайдера.

Регулярная сверка. Задача по расписанию сравнивает локальные статусы с состоянием у провайдера и выявляет расхождения. Без сверки расхождение обнаруживается по обращению клиента.

Вебхуки и опрос. Провайдер может уведомлять об изменении состояния, но уведомление может не дойти. Опрос состояния дополняет уведомления, а не заменяется ими.

Частичное выполнение. Списание могло пройти частично либо повториться при сетевом сбое. Сверка проверяет сумму, а не только факт списания.

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

Практика: реализация переходов состояний

1. Разделяйте статус и событие. Событие «оплата прошла» переводит заказ из new в paid. Смешение приводит к статусам, описывающим действие вместо состояния.

2. Проверяйте текущее состояние в запросе. Условие по статусу делает гонку безопасной: один из одновременных переходов не выполнится.

3. Проверяйте число затронутых строк. Ноль затронутых строк означает, что состояние изменилось, и вызывающий код должен перечитать заказ.

4. Записывайте переход в журнал в той же транзакции. Отдельная запись после коммита может потеряться при сбое.

5. Делайте переход идемпотентным. Повтор того же события не должен выполнять побочное действие дважды.

6. Отклоняйте недопустимые переходы ошибкой. Возврат успеха при невыполненном переходе вводит клиента в заблуждение.

7. Обрабатывайте отмену явно. Переход в cancelled из shipped обычно недопустим и требует отдельного процесса возврата.

8. Фиксируйте завершающие состояния. Из delivered, cancelled и refunded выходов нет, и попытки перехода отклоняются.

Рекомендации:

  1. Не меняйте статус прямым обновлением поля. Все переходы проходят через одну функцию, иначе правила распределяются по коду.
  2. Храните время перехода. Дата оплаты и отгрузки нужна для расчётов и разбора обращений.
  3. Учитывайте частичную отмену. Отмена одного товара в заказе из трёх — отдельный сценарий, не сводимый к переходу всего заказа.
  4. Проверяйте согласованность с платежами. Статус заказа и состояние платежа могут разойтись при сбое, поэтому сверка выполняется регулярно.
  5. Логируйте отказы перехода. Отклонённый переход показывает ошибку вызывающего кода либо попытку недопустимой операции.

Практический сценарий: двойное списание при одновременном подтверждении оплаты

Контекст: пользователь дважды нажал кнопку подтверждения оплаты из-за задержки ответа. Деньги списались дважды, заказ перешёл в статус оплаченного один раз.

  1. Диагностика. Два запроса пришли с интервалом в 300 миллисекунд. Оба прочитали статус new, оба выполнили вызов платёжного провайдера, оба попытались обновить статус. Один из запросов перезаписал результат другого.
  2. Причина. Обновление статуса выполнялось без условия по текущему состоянию, а ключ идемпотентности платёжному провайдеру не передавался. Проверка статуса в коде не защищала от гонки, поскольку оба запроса прочитали значение до изменения.
  3. Действия. (а) Обновление статуса перевели на условный запрос с условием по текущему значению и проверкой числа затронутых строк; (б) ключ идемпотентности стали передавать платёжному провайдеру, чтобы повторный вызов возвращал прежний результат; (в) добавили журнал переходов с фиксацией события и участника; (г) добавили метрику на число отказов перехода для обнаружения подобных случаев; (д) добавили тест с двумя одновременными запросами.
  4. Результат. Повторный запрос возвращает результат первого выполнения без повторного списания, а второй переход отклоняется как недопустимый.

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

FAQ

Как отличить гонку от ошибки бизнес-логики? По числу затронутых строк при условном обновлении. Ноль строк означает, что состояние изменилось между чтением и записью.

Нужен ли журнал переходов, если статус хранится в заказе? Да. Текущий статус не показывает путь, а последовательность переходов нужна для разбора обращений и аудита.

Что делать, если статус заказа и состояние платежа разошлись? Выполнять регулярную сверку с платёжным провайдером. Расхождение устраняется по данным провайдера как источнику истины.

Как обрабатывать частичную отмену заказа? Как отдельный процесс на уровне позиций, а не переходом всего заказа. Статус заказа при этом отражает агрегированное состояние позиций.

Источники: PostgreSQL docs (уровни изоляции), Stripe API (Orders), теория конечных автоматов — ссылки в шапке.