Таймаут не означает, что операция завершилась неудачей: он лишь подтверждает, что клиент не получил ответ в установленный срок. Сервер мог уже создать заказ, платёжный провайдер мог принять списание, или асинхронный процесс мог продолжать выполняться. Если клиент выполняет повторную попытку без контроля, одно и то же бизнес-намерение может привести к дублирующим эффектам.
Идемпотентность в PHP превращает техническое повторение либо в запрос результата, либо в возврат уже полученного результата. Она не состоит в игнорировании всех дубликатов и не сводится к надежде, что пользователь не нажмёт кнопку дважды. Это явный контракт между клиентом, API, хранилищем и, когда применимо, внешними системами.
Проблема: ответ потерян, но эффект сохраняется

Рассмотрим конечную точку, подтверждающую покупку. Приложение валидирует запрос, регистрирует заказ, запрашивает списание и готовит ответ. Соединение обрывается непосредственно перед тем, как клиент его получит. При повторной отправке той же формы конечная точка не может определить по содержимому, что это та же покупка: два заказа с одинаковыми товарами могут быть разными и действительными намерениями.
Проблема также возникает при регистрации пользователей, начислении кредитов, выпуске документов, синхронизациях, webhook и административных действиях. Следует разделять три элемента:
- Бизнес-намерение: «я хочу подтвердить именно эту покупку».
- Технический запрос: HTTP-отправка с заголовками, телом и контекстом аутентификации.
- Попытка выполнения: каждая внутренняя обработка, повторная попытка из очереди или вызов провайдера.
Идемпотентный ключ идентифицирует намерение, а не HTTP-соединение и не каждую попытку сервера. Поэтому он должен переживать сетевые повторные попытки и, когда этого требует поток, перезапуски процесса.
Каким операциям нужна идемпотентность, а каким — нет
Отдавайте приоритет операциям, которые создают, подтверждают, списывают, отправляют, резервируют, уведомляют или изменяют ресурс со значимыми последствиями. Явными кандидатами являются POST /payments, подтверждение заказа или приём webhook. То же относится к задаче очереди, которая может быть доставлена более одного раза.
Чистому чтению обычно не нужен ключ идемпотентности. Обновление может иметь другую семантику: установка желаемого состояния, например PUT /profiles/42, может быть идемпотентной по своей конструкции, если одно и то же представление оставляет ресурс неизменным. Напротив, действие вроде «увеличить баланс» не становится идемпотентным только из-за использования определённого глагола.
Ключ также не следует использовать как замену другим правилам. Чтобы предотвратить два резервирования, конкурирующих за ограниченные запасы, нужны инварианты предметной области, контроль конкуренции и политика резервирования. Чтобы выполнить задачу ровно один раз в распределённой среде, фактическая доставка обычно происходит как минимум один раз; потребитель должен быть устойчив к дубликатам.
Проектирование ключа и постоянной записи
Клиент должен генерировать непрозрачный и достаточно непредсказуемый ключ в момент возникновения бизнес-намерения, сохранять его, пока возможны повторные попытки, и передавать, например, в Idempotency-Key. Если сервер генерирует его при каждом получении, он не сможет связать последующее повторение. Во внутренних потоках ключ может быть получен из стабильного идентификатора бизнес-события.
Его область действия должна включать пользователя или субъекта либо тенант и операцию. Одна и та же строка не должна конфликтовать между двумя учётными записями или между «создать заказ» и «оформить возврат». Определите срок хранения в соответствии с фактическим периодом повторных попыток и рисками предметной области. Слишком раннее удаление записи вновь открывает путь к дублированию; бессрочное хранение увеличивает стоимость и требует политики конфиденциальности и удаления.
Минимальная модель хранения включает:
- область безопасности или тенант, имя операции и идемпотентный ключ;
- криптографический хеш нормализованной полезной нагрузки;
- статус:
processing,completed,failedилиpending, когда внешнее подтверждение неопределённо; - код и тело ответа, которые будут возвращаться повторяемо;
- идентификаторы созданного ресурса, внутреннюю корреляцию и идентификатор внешнего провайдера;
- даты создания, обновления и истечения срока.
Хеш предотвращает важную ошибку: повторное использование одного ключа с разными данными. В такой ситуации отвечайте конфликтом и не обрабатывайте новую полезную нагрузку. Чтобы сравнение было надёжным, нормализуйте поля, порядок которых не имеет значения, и исключайте изменяющиеся метаданные, не являющиеся частью намерения.
PHP-поток: резервируйте до создания эффекта
Защита должна поддерживаться уникальным ограничением в базе данных для области действия, операции и ключа. Сначала выполнить запрос, а затем вставку недостаточно: два одновременных запроса могут увидеть отсутствие записи и продолжить одновременно.
Рекомендуемый поток — атомарно создать резервирование. Если вставка успешна, этот процесс становится первоначальным владельцем выполнения. При конфликте уникальности считывается существующая запись, проверяется хеш и выполняется действие согласно её статусу. Завершённый результат возвращает в точности сохранённый ответ; операция в процессе может вернуть ожидающий статус или ждать только ограниченный интервал перед повторным чтением.
begin transaction
insert idempotency_records(scope, operation, key, payload_hash, status)
values (?, 'create_order', ?, ?, 'processing')
-- уникальное ограничение определяет владельца
commit
if reservation_was_created:
result = execute_business_operation()
persist_completed_response(result)
else:
record = load_existing_record()
assert_same_payload_hash(record)
return replay_or_pending(record)Не держите транзакцию или блокировку строки открытыми во время медленного вызова провайдера. Это снижает пропускную способность и может привести к длительным блокировкам. Вместо этого резервируйте и подтверждайте локальный статус в коротких транзакциях. Если внешний эффект и локальная запись должны координироваться, дополнительно сохраните сообщение для отправки в транзакционной таблице и обрабатывайте его отдельно. Этот паттерн не устраняет повторные попытки, но позволяет восстановить незавершённую работу, не теряя зафиксированного намерения.
Конкуренция, таймауты и неопределённые состояния
Два запроса с одним ключом могут поступить с разницей в миллисекунды. Уникальное ограничение определяет, какой из них резервирует операцию. Второй не должен запускать другой внешний эффект. Он может вернуть 202, пока статус равен processing или pending, включая идентификатор для запроса результата; если контракт требует синхронного ответа, он может выполнить ограниченное ожидание и повторно прочитать запись.
Сбой до запуска какого-либо эффекта позволяет установить failed с воспроизводимой ошибкой. Однако таймаут при вызове внешней системы создаёт неопределённость: нельзя автоматически помечать операцию как неудачную или просто повторно отправлять распоряжение. Сохраните идентификатор отправленного запроса, если он существует, запросите информацию у провайдера, используя этот идентификатор, и сверяйте результат. Пока подтверждения нет, сохраняйте pending и сообщайте, что результат ещё не является окончательным.
Внешнему вызову также нужен стабильный идентификатор. Если провайдер поддерживает собственный идемпотентный ключ, передавайте ключ, связанный с тем же намерением. Если он его не поддерживает, используйте идентификаторы продавца, последующее чтение, периодическую сверку и операционные процедуры для неоднозначных случаев. Ни одна локальная транзакция не может сделать атомарными запись в базе данных и вызов независимого удалённого API.
Чего не решает ключ идемпотентности
Идемпотентность предотвращает повторение распознанного намерения; она не определяет, как отменить необратимый эффект. Физическая отправка, уже исполненный перевод или уведомление, просмотренное пользователем, могут требовать компенсации, отмены или ручного вмешательства. Проектируйте эти действия как явные бизнес-процессы с разрешениями, статусами и аудитом.
Также не путайте исправление с повторной попыткой. Если пользователь меняет адрес, сумму или товары после ошибки, возникает новое намерение и следует использовать новый ключ. Повторное использование прежнего ключа с другой полезной нагрузкой должно приводить к конфликту, а не молча обновлять исходную операцию.
Тестирование, наблюдаемость и чек-лист

Тестируйте не только счастливый путь. Прервите ответ после сохранения результата, параллельно повторите один и тот же ключ, перезапустите worker после резервирования записи и сымитируйте таймаут после отправки внешнего запроса. Убедитесь, что существует только один бизнес-ресурс, повторный ответ сохраняет тот же результат, а другая полезная нагрузка с тем же ключом не принимается.
Логируйте, не раскрывая чувствительные данные, ключ или полученный из него безопасный идентификатор, область действия, статус, корреляцию и внешний идентификатор. Метрики конфликтов ключей, операций, слишком долго находящихся в ожидании, и неразрешённых сверок помогают службам поддержки и эксплуатации отличать обычную повторную попытку от инцидента.
- Представляет ли ключ бизнес-намерение и имеет ли определённую область действия?
- Есть ли уникальное ограничение, предотвращающее два одновременных резервирования?
- Сравнивается ли хеш полезной нагрузки и отклоняются ли изменения намерения?
- Сохраняется ли ответ или результат, который можно согласованно повторить?
- Позволяют ли неопределённые состояния выполнить запрос и сверку до повторной попытки?
- Есть ли у каждого внешнего эффекта идентификатор, восстановление и операционная альтернатива?
- Протестированы ли дубликаты, сбои, повторные попытки очереди и реальная конкуренция?
При таком применении идемпотентность не обещает надёжности сети. Она делает неизбежные сбои управляемыми, трассируемыми и согласованными с бизнесом.



