У інтеграціях дублікати неминучі: провайдер повторює вебхук, бо не дочекався відповіді; джоба повторюється після тайм-ауту, хоча перша спроба насправді завершилася; користувач двічі натискає «Синхронізувати». Обробка має давати той самий результат незалежно від кількості повторів.
1. Унікальність на вході - журнал оброблених подій:
Schema::create('processed_events', function (Blueprint $table) {
$table->string('provider');
$table->string('event_id');
$table->timestamp('processed_at');
$table->primary(['provider', 'event_id']);
});
public function handle(): void
{
DB::transaction(function () {
$inserted = DB::table('processed_events')->insertOrIgnore([
'provider' => 'stripe',
'event_id' => $this->event['id'],
'processed_at' => now(),
]);
if ($inserted === 0) {
return; // уже оброблено
}
$this->applyPayment($this->event);
});
}
Унікальний ключ у базі - єдиний надійний захист від гонитви: перевірка «чи є запис» і вставка окремими запитами пропускають паралельні дублікати.
2. Ідемпотентні операції за природою:
updateOrCreate/upsertза зовнішнім ідентифікатором (external_id) замістьcreate;- «встановити статус
paid» замість «збільшити суму на X»; - переходи станів з перевіркою: «оплачене» не можна оплатити вдруге.
3. Унікальні джоби - щоб не ставити в чергу однакову роботу:
#[UniqueFor(600)]
final class SyncCustomer implements ShouldQueue, ShouldBeUnique
{
public function uniqueId(): string
{
return (string) $this->customerId;
}
}
Поки джоба для цього клієнта в черзі, нові такі ж відкидаються. Потребує кешу з атомарними блокуваннями і спільного кешу для всіх серверів.
4. Вихідні операції з побічними ефектами (створення платежу, відправка SMS) - ключ ідемпотентності в запиті до провайдера, стабільний для повторів тієї самої операції (Idempotency-Key: order-42-capture). Тоді повтор джоби після тайм-ауту не створить другий платіж.
5. Звірка (reconciliation) - останній рубіж: періодичне порівняння стану у вас і в провайдера (оплати за добу, залишки на складі), автоматичне виправлення розбіжностей і звіт про них. Ловить те, що загубилося попри всі механізми.
Типова помилка: покладатися на ShouldBeUnique як на захист від повторної обробки. Він не дає поставити дублікат у чергу, але не захищає від повторного виконання вже взятої джоби після збою воркера - для цього потрібні ідемпотентні операції й унікальні ключі в базі.