Как повторные попытки, состояние гонки и дублирование задач могут стоить вам реальных денег
Ваша задача по обработке платежа отработала идеально.
С клиента списали средства. База данных обновилась. API вернул успешный ответ.
Затем воркер аварийно завершил работу.
Laravel сделал ровно то, что вы настроили: перезапустил задачу.
И с вашего клиента списали деньги во второй раз.
Поздравляем. Ваша очередь работает так, как и задумывалось. Ваша платежная система — нет.
Это одно из самых опасных заблуждений в бэкенд-разработке: успешное выполнение задачи и корректное выполнение бизнес-логики — это совершенно разные вещи.
Laravel предоставляет отличную систему очередей. Redis, Horizon, повторные попытки, стратегии экспоненциальной задержки, middleware для задач и уникальные задачи делают фоновую обработку относительно простой.
Но ни одна из этих функций автоматически не гарантирует, что финансовая операция произойдет ровно один раз.
И когда речь заходит о деньгах, это довольно важное различие.
Давайте разберем, что именно идет не так, почему стандартных решений Laravel недостаточно и как бы я подошел к проектированию бэкенда для обработки платежей, способного выдерживать повторное выполнение.
1. Проблема дублирования платежей на 10 000 долларов
Представьте себе платформу электронной коммерции, которая обрабатывает платежи асинхронно.
Клиент оформляет заказ на 500 долларов. Ваше приложение отправляет фоновую задачу:
ProcessPayment::dispatch($order->id);Воркер подхватывает её:
class ProcessPayment implements ShouldQueue
{
public function handle(PaymentGateway $gateway): void
{
$order = Order::findOrFail($this->orderId);
$gateway->charge(
customerId: $order->customer_id,
amount: $order->amount,
);
$order->update([
'status' => 'paid',
]);
}
}Всё выглядит вполне разумно.
До тех пор, пока вы не столкнетесь со сбоем в продакшене.
Рассмотрим следующую последовательность:
- Воркер отправляет запрос на платеж.
- Провайдер успешно списывает 500 долларов.
- Провайдер возвращает HTTP 200.
- Воркер падает до того, как успевает обновить базу данных.
- Laravel со временем делает задачу доступной для новой попытки.
- Другой воркер обрабатывает эту задачу.
- Запрос на платеж отправляется снова.
С клиента списывают 1000 долларов.
В базе данных вашего приложения по-прежнему содержится только один заказ.
У вашего бухгалтера появляется проблема. У вашего клиента — проблема покрупнее.
Самое интересное здесь то, что ни Laravel, ни платежный провайдер не сделали ничего заведомо неправильного.
Две независимые системы выполнили свои обязанности корректно, но ваше приложение не смогло синхронизировать их состояние.
Это проблема распределенных систем.
Ваша база данных и внешний платежный провайдер не могут участвовать в одной обычной транзакции базы данных.
Успешная внешняя операция может остаться незафиксированной локально.
И вот тут начинается самое интересное.
2. Повторные попытки — это не восстановление на уровне бизнеса
Повторные попытки необходимы.
Сети падают. API становятся недоступными. Соединения с базой данных исчезают. Контейнеры перезапускаются. Воркеры завершаются.
Без повторных попыток временные проблемы инфраструктуры превращались бы в перманентные сбои приложения.
Laravel делает настройку повторных попыток очень простой:
class ProcessPayment implements ShouldQueue
{
public int $tries = 5;
public int $timeout = 60;
public function backoff(): array
{
return [10, 30, 60, 120];
}
}Но здесь есть важное отличие:
Повторный запуск задачи означает выполнение ее кода заново. Это не значит продолжение с той самой инструкции, на которой остановилось предыдущее выполнение.
Laravel автоматически не знает, завершил ли внешний платежный провайдер операцию до того, как соединение было прервано.
Рассмотрим другой сбой:
Application
|
|---- Charge $500 -------------------->
| |
| Payment Provider
| |
|<---- Payment successful -------------|
|
X Worker crashes
|
|---- Job retried --------------------->
|
|---- Charge $500 again ------------->С точки зрения приложения результат первого запроса может быть неизвестен.
Таймаут не доказывает, что платеж не прошел.
Сброс соединения не доказывает, что провайдер не обработал запрос.
Именно поэтому слепо повторять финансовые операции опасно.
Правильная реакция на неоднозначный результат платежа — это вовсе не обязательно еще один запрос на списание. Иногда приложение должно сначала выяснить, что произошло с исходной операцией.
3. Иллюзия ShouldBeUnique
Обычный ответ на проблему дублирования задач звучит так:
«Просто реализуйте ShouldBeUnique ».
Laravel предоставляет этот механизм не просто так.
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
class ProcessPayment implements ShouldQueue, ShouldBeUnique
{
public function __construct(
public readonly int $orderId,
) {}
public function uniqueId(): string
{
return (string) $this->orderId;
}
}Laravel пытается предотвратить отправку нескольких экземпляров одной и той же уникальной задачи, пока удерживается блокировка уникальности.
Это полезно.
Но вот здесь разработчики иногда делают опасное предположение.
Уникальность задач — это не идемпотентность платежей.
Эти механизмы защищают от разных вещей.
ShouldBeUnique помогает контролировать отправку дубликатов задач. Это не гарантирует, что внешняя платежная операция никогда не будет повторена.
Рассмотрим следующие сценарии:
- Платежный провайдер успешно обрабатывает списание, но воркер падает до завершения задачи.
- Блокировка уникальности истекает.
- Другой эндпоинт приложения инициирует тот же платеж.
- Администратор вручную перезапускает операцию.
- Клиент отправляет один и тот же запрос дважды.
Вам необходима защита на уровне бизнес-операций.
Laravel также предоставляет middleware WithoutOverlapping , который помогает предотвратить параллельное выполнение для заданного ключа блокировки.
Но запрет двум воркерам обрабатывать один и тот же ресурс одновременно не эквивалентен гарантии того, что платеж произойдет только один раз.
Используйте эти механизмы по их прямому назначению.
Не превращайте блокировку очереди в стратегию обеспечения финансовой целостности.
4. Идемпотентность: бизнес-операция должна выдерживать повторы
Давайте представим важнейшее понятие в этом обсуждении — идемпотентность.
Операция является идемпотентной, если повторение одного и того же логического запроса не создает непреднамеренных побочных эффектов.
При обработке платежей тот же логический запрос на оплату не должен создавать новое списание просто потому, что приложение отправило его снова.
Первый шаг — присвоить каждой попытке платежа стабильный идентификатор.
final readonly class PaymentRequest
{
public function __construct(
public int $orderId,
public int $paymentAttemptId,
public int $amount,
public string $currency,
) {}
public function idempotencyKey(): string
{
return sprintf(
'order:%d:payment:%d',
$this->orderId,
$this->paymentAttemptId,
);
}
}Платежный шлюз получает этот идентификатор:
$result = $gateway->charge(
amount: $payment->amount,
currency: $payment->currency,
customerId: $payment->customerId,
idempotencyKey: $request->idempotencyKey(),
);Каждая повторная попытка той же логической операции должна использовать тот же ключ.
First attempt:
order:123:payment:1
Second attempt:
order:123:payment:1
Third attempt:
order:123:payment:1Провайдер сможет распознать эти запросы как повторения одной и той же операции, при условии, что его реализация идемпотентности поддерживает такое поведение.
Другая намеренная попытка платежа должна иметь другой ключ.
Например, клиент, меняющий способ оплаты после заведомо неудачной попытки, может инициировать новую логическую операцию.
Есть еще одна важная деталь: ключи идемпотентности должны сохраняться в базе данных, а не генерироваться случайно при каждом выполнении задачи.
Иначе ваш механизм повторов по сути будет создавать новый платежный запрос при каждой попытке.
Кроме того, имеет значение поведение внешнего провайдера. Периоды хранения ключей, валидация параметров, параллельные запросы и обработка ошибок различаются у разных провайдеров. Вы должны понимать эту семантику, прежде чем полагаться на нее.
Идемпотентность — это не волшебный HTTP-заголовок.
Это бизнес-инвариант, который должен быть реализован на протяжении всего жизненного цикла платежа.
5. Ваша база данных должна обеспечивать соблюдение бизнес-инвариантов
Внешняя идемпотентность важна, но ваша собственная база данных тоже нуждается в защите.
Представьте себе таблицу платежей:
CREATE TABLE payments (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL,
idempotency_key VARCHAR(255) NOT NULL,
status VARCHAR(30) NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
provider_transaction_id VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT payments_idempotency_key_unique
UNIQUE (idempotency_key)
);Уникальное ограничение здесь крайне важно.
Не полагайтесь исключительно на проверки на уровне приложения:
if (! Payment::where('idempotency_key', $key)->exists()) {
Payment::create([...]);
}Два параллельных воркера могут одновременно пройти проверку exists() до того, как любой из них вставит запись.
Это классическое состояние гонки.
База данных должна обеспечивать уникальность атомарно.
Более безопасный подход — использовать ограничение базы данных и соответствующим образом обрабатывать возникающее нарушение уникальности.
try {
$payment = Payment::create([
'order_id' => $order->id,
'idempotency_key' => $key,
'status' => 'pending',
'amount' => $order->amount,
]);
} catch (UniqueConstraintViolationException) {
$payment = Payment::where(
'idempotency_key',
$key
)->firstOrFail();
}Класс исключения и детали обработки должны соответствовать вашему драйверу базы данных и версии Laravel.
Еще одно важное правило:
Никогда не удерживайте транзакцию базы данных открытой в ожидании ответа от внешнего платежного API.
Вы не контролируете время ответа провайдера.
Долго выполняющиеся транзакции могут создавать ненужные блокировки, усиливать конкуренцию за ресурсы и ухудшать производительность базы данных.
Используйте короткие транзакции для установки состояния, выполняйте внешние операции вне транзакции и сохраняйте результат в другой короткой транзакции.
Ваша база данных должна защищать бизнес-инварианты. Ваш платежный провайдер должен защищать внешние операции оплаты. Ваше приложение должно координировать и то, и другое.
6. Разрыв между базой данных и очередью: где на помощь приходит Transactional Outbox
Теперь мы решили одну проблему и обнаружили другую.
Что произойдет, если запись о платеже успешно создана в PostgreSQL, но публикация соответствующего сообщения в Redis завершилась с ошибкой?
Рассмотрите такую реализацию:
DB::transaction(function () use ($order) {
$payment = Payment::create([
'order_id' => $order->id,
'status' => 'pending',
]);
ProcessPayment::dispatch($payment->id);
});Использование поведения after_commit в Laravel — хорошая практика. Она предотвращает обработку задач до того, как затронутая ими транзакция базы данных будет зафиксирована (commit).
Но это не делает коммит в базе данных и публикацию в Redis единой атомарной операцией.
По-прежнему существует окно для сбоя:
1. Database transaction commits.
2. Application attempts to publish a job.
3. Redis is unavailable.
4. No job reaches the queue.
5. Payment remains pending.Ваши бизнес-данные существуют.
Ваша фоновая операция — нет.
Это другой тип проблем с надежностью.
И он требует другого архитектурного решения.
Transactional Outbox
Идея проста:
Вместо прямой публикации события во внешний брокер сообщений, сохраните событие в таблице outbox в рамках той же транзакции базы данных, что и бизнес-операция.
Например:
DB::transaction(function () use ($order, $outboxService) {
$payment = Payment::create([
'order_id' => $order->id,
'status' => 'pending',
]);
$outboxService->store(
aggregateType: 'Payment',
aggregateId: (string) $payment->id,
eventType: 'payment.requested',
payload: [
'payment_id' => $payment->id,
'order_id' => $order->id,
],
);
});Теперь обе записи сохраняются атомарно.
Либо платеж и соответствующее ему событие outbox фиксируются, либо ни то, ни другое.
Отдельный воркер обрабатывает неопубликованные сообщения и отправляет их в настроенный пункт назначения.
Если Redis, RabbitMQ или другой брокер становится недоступным, сообщение остается в базе данных, и его можно будет отправить позже.
Это существенно меняет модель сбоев.
Вы больше не зависите от успешности публикации сообщения в тот самый момент, когда фиксируется ваша бизнес-транзакция.
Однако здесь есть важное ограничение.
Сам паблишер может успешно отправить сообщение и упасть до того, как отметит его как опубликованное.
Поэтому одно и то же сообщение может быть доставлено повторно.
Это ожидаемо.
Паттерн Outbox обеспечивает надежную публикацию за счет долговечного персистентного хранения и повторной отправки. Он не обеспечивает магическим образом доставку сообщений ровно один раз (exactly-once).
Консьюмеры по-прежнему должны быть идиопотентными.
Использование реализации для Laravel
Я создал пакет для Laravel с открытым исходным кодом, специально чтобы упростить интеграцию этой инфраструктуры в приложения на Laravel:
Пакет ориентирован на PHP 8.4+ и Laravel 12+.
Вместо того чтобы вручную реализовывать сохранение сообщений, координацию воркеров, повторные попытки и интеграцию с паблишером в каждом приложении, вы можете использовать готовую инфраструктуру пакета.
Установка:
composer require bugfix666/laravel-outboxОпубликуйте конфигурацию и миграции:
php artisan vendor:publish --tag=outbox-config
php artisan vendor:publish --tag=outbox-migrations
php artisan migrateСохраните событие в рамках вашей бизнес-транзакции:
use Bugfix666\LaravelOutbox\Services\OutboxService;
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($outboxService) {
$payment = Payment::create([
'amount' => 1000,
'currency' => 'USD',
'status' => 'pending',
]);
$outboxService->store(
aggregateType: 'Payment',
aggregateId: (string) $payment->id,
eventType: 'payment.requested',
payload: [
'payment_id' => $payment->id,
'amount' => $payment->amount,
],
);
});Запустите процессор outbox:
php artisan outbox:processПакет включает в себя:
- Атомарное сохранение бизнес-данных и сообщений outbox.
- Фоновую обработку с поддержкой батчей (пакетов).
- Блокировку воркеров для координации обработки.
- Настраиваемое поведение повторных попыток с экспоненциальной задержкой (backoff).
- Кастомные реализации паблишеров через контракт.
- События обработки и ошибок для мониторинга.
- Хранение и очистку обработанных сообщений.
- Хранение неудачных сообщений для проверки и ручного вмешательства.
Он также позволяет реализовать собственный паблишер для такой инфраструктуры, как RabbitMQ, Kafka или другие системы обмена сообщениями.
Важное архитектурное различие заключается в том, что пакет управляет надежной публикацией событий. Он не заменяет идиопотентность платежей, средства защиты на стороне провайдера или дедупликацию на уровне консьюмеров.
Эти обязанности остаются частью архитектуры приложения.
Я предпочитаю явную инфраструктуру невидимой магии. Когда что-то идет не так в финансовой системе, разработчики должны точно понимать, какая операция дала сбой, какое состояние было сохранено и что должно произойти дальше.
Таков инженерный принцип, лежащий в основе этой реализации.
Репозиторий: https://github.com/bugfix666/laravel-outbox
7. Проектирование полного пайплайна платежей
Давайте объединим эти концепции в единую архитектуру.
HTTP Request
|
v
Create Payment
|
v
Database Transaction
/ \
v v
Payment Outbox Event
\ /
\ /
v v
PostgreSQL
|
v
Outbox Worker
|
v
Message Publisher
|
v
Redis / Broker
|
v
Laravel Worker
|
v
Payment Service
|
v
Idempotent Gateway
|
v
Payment Provider
|
v
Persist Final State
|
v
ReconciliationКаждый компонент выполняет определенную задачу.
База данных
- Контролирует соблюдение бизнес-ограничений.
- Хранит состояние платежей.
- Атомарно сохраняет сообщения outbox.
Воркер Outbox
- Публикует сохраненные события.
- Повторяет неудачные публикации.
- Координирует параллельную обработку.
Брокер сообщений
- Распределяет работу между консьюмерами.
- Обеспечивает асинхронную доставку.
Воркер Laravel
- Выполняет фоновые операции.
- Обрабатывает повторные попытки и временные сбои.
Платежный сервис
- Использует стабильные ключи идемпотентности.
- Обрабатывает неопределенные результаты платежей.
- Отделяет попытки оплаты от попыток выполнения задач.
Сверка (Reconciliation)
- Обнаруживает расхождения между локальным состоянием и состоянием провайдера.
- Исследует платежи, застрявшие в статусе pending.
- Восстанавливается в ситуациях, когда результат внешней операции неизвестен.
Замечаете что-то?
Очередь — это всего лишь один из компонентов архитектуры.
Она не отвечает за гарантию финансовой корректности.
8. Что я бы действительно развернул в продакшене
Если бы я сегодня создавал бэкенд для обработки платежей на PHP 8.4, Laravel 12, PostgreSQL и Redis, я бы начал со следующего чек-листа.
Целостность платежей
- Каждая логическая операция оплаты имеет стабильный ключ идемпотентности.
- Ограничения базы данных обеспечивают уникальность.
- Попытки оплаты отделены от попыток в очереди.
- Внешние запросы на оплату используют поддерживаемую провайдером идемпотентность.
- Неоднозначные результаты сверяются, а не повторяются вслепую.
- Вебхуки провайдера аутентифицируются и обрабатываются идемпотентно.
Надежность очереди
- Политики повторных попыток и задержек явно настроены.
- Таймаут воркера и
retry_afterочереди настроены согласованно. - Внешние HTTP-запросы имеют таймауты соединения и ответа.
- Ошибочные задачи мониторятся.
- Воркеры очереди безопасно перезапускаются во время деплоя.
- Повторное выполнение рассматривается как нормальный сценарий сбоя.
База данных и обмен сообщениями
- Переходы бизнес-состояний валидируются.
- Транзакции базы данных короткие.
- Сообщения outbox сохраняются атомарно вместе с бизнес-изменениями.
- Публикация из outbox поддерживает повторные попытки.
- Консьюмеры идиопотентны.
- Неудачные сообщения можно проверить и восстановить.
- Сверка обнаруживает несоответствия между локальным состоянием и состоянием провайдера.
Наблюдаемость (Observability)
- У каждого платежа есть correlation ID.
- Запросы и ответы провайдера можно отследить.
- Повторяющиеся операции генерируют полезные логи и метрики.
- Статусы платежей могут сверяться автоматически.
- Настроены алерты для зависших платежей и аномального уровня ошибок.
И еще кое-что: не путайте успешное выполнение задачи в очереди с успешным платежом.
Ваш мониторинг должен отслеживать бизнес-результаты, а не просто работоспособность воркеров.
Зеленая панель Horizon не означает, что ваша платежная система в порядке.
Это значит, что у ваших воркеров, скорее всего, хороший день.
Выписки по банковским счетам ваших клиентов — гораздо более интересная метрика.
9. Хватит проектировать только для идеального сценария (happy path)
Распределенные системы дают сбои самым непредсказуемым образом.
Воркеры падают. Сети пропадают. Ответы приходят с запозданием. Сообщения доставляются больше одного раза. Внешние сервисы ведут себя неожиданно.
Вы не можете исключить абсолютно все сбои.
Но вы можете спроектировать систему так, чтобы повторение операции не нарушало ее корректность.
В этом и заключается разница между бэкендом, который работает на локальной машине, и тем, который может безопасно обрабатывать реальные деньги в продакшене.
Laravel предоставляет отличные инструменты:
- Очереди и воркеры.
- Механизмы повторных попыток (retry) и экспоненциальной задержки (backoff).
- Уникальные задачи (unique jobs) и атомарные блокировки (atomic locks).
- Транзакции базы данных.
- События и слушатели (events and listeners).
- Horizon для мониторинга очередей.
Но ответственность за архитектуру вокруг этих инструментов по-прежнему лежит на вас.
Не создавайте платежную систему, которая предполагает выполнение задач ровно один раз. Создайте ту, которая остается корректной, если они выполняются два, три или десять раз.
Потому что рано или поздно это произойдет.
И когда это случится, вы захотите, чтобы ваши клиенты ничего не заметили.
Ни повторного списания.
Ни потерянного платежа.
Ни извинительного письма от службы поддержки.
Только успешная транзакция.
Очередь имеет право на повторную попытку. Ваш платеж не должен пройти дважды.
От автора
Я создал laravel-outbox, чтобы добавить в современные приложения на Laravel явную и надежную публикацию событий, избавив каждый проект от необходимости заново изобретать ту же инфраструктуру.
Если вы работаете с Laravel 12, PHP 8.4+ и воркфлоу распределенных приложений, загляните в репозиторий. Контрибьюты и технические отзывы приветствуются.
Комментарии (0)
Пока нет комментариев — будьте первым.