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

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

Если не обработать этот сценарий должным образом, с клиента спишут деньги дважды.

Что такое ключ идемпотентности

В данном контексте ключ идемпотентности (обычно это UUID) представляет собой конкретное намерение платежа. Клиент генерирует ключ идемпотентности, сохраняет его и отправляет вместе с запросом на сервер.

 Idempotency-Key: 8f14e45f-ceea-4e97-9e39-1b2f3a4c5d6e
 

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

Наивный подход и почему он не работает

Вот пример наивного способа обработки этого сценария:

 if (!IdempotencyKey::where('key', $key)->exists()) {
    $result = processCharge($request);
    IdempotencyKey::create(['key' => $key, 'response' => $result]);
}
 

Эта реализация может выглядеть правильной, но в ней есть серьезный  изъян.

Сервер может обрабатывать несколько запросов одновременно. Если два запроса поступают примерно в одно и то же время (что случается чаще, чем можно ожидать), оба могут выполнить  exists()  до того, как любой из них успеет сохранить ключ. В результате оба запроса обрабатываются одновременно, и с клиента списывают деньги дважды.

Решение: атомарная блокировка

Проверка и сохранение должны происходить как единый процесс, а не как два отдельных шага. Именно для этого предназначен метод  Cache::lock()  в Laravel.

 // Block for 5 seconds to let the first request finish processing, then reuse its result
Cache::lock('idempotency:' . $key, 10)->block(5, function () use ($key, $request) {
    $existing = IdempotencyKey::where('key', $key)->first();

    if ($existing) {
        return $existing->response;
    }

    $result = processCharge($request);

    IdempotencyKey::create(['key' => $key, 'response' => $result]);

    return $result;
});
 

В отличие от наивной реализации,  Cache::lock()  гарантирует, что несколько запросов не могут обрабатываться одновременно. Даже если разница составляет миллисекунды, запрос, поступивший первым, получает блокировку, а второй запрос не может быть обработан до завершения первого, что делает двойное списание полностью невозможным.

Сохранение ответа, а не только ключа

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

Срок действия

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

Большинство реализаций сохраняют TTL (время жизни) для каждого ключа. Например, Stripe использует TTL в 24 часа. Этого достаточно для реалистичных сценариев повтора, но достаточно мало, чтобы старые ключи не накапливались бесконечно.

Отличный способ реализации этой системы в Laravel — использование запланированной задачи (scheduled job), которая сканирует базу данных на предмет старых ненужных ключей идемпотентности и удаляет их.

Заключение

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

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

Правильная интеграция этой системы гарантирует, что тайм-аут никогда не заставит вас гадать, была ли транзакция успешной.