System Design Cases
Idempotency
Концепт-урок Idempotency: как делать retry безопасными. Показывает 4 сценария — наивный POST с двойным charge, спасение через Idempotency-Key + Redis dedup store, conditional update (compare-and-set с version), и counter increment trap с op_id deduplication. Топология: mobile client, payment API, idempotency store (Redis), payments ledger (Postgres), card processor.
Идемпотентность мутаций без двойного side effect
Идемпотентность — свойство наблюдаемого результата повторного того же намерения. Она не означает, что код выполнится один раз, сеть доставит сообщение один раз или любой POST безопасно повторять.
Контракт ключа
Клиент создаёт key один раз на логическую операцию. Сервер атомарно хранит tenant, key, canonical fingerprint запроса, state и стабильный response. Минимальная state machine:
| State | Значение | Допустимое действие повтора |
|---|---|---|
| PENDING | один владелец исполняет операцию | ждать ограниченно или вернуть in-progress |
| SUCCEEDED | terminal result сохранён | вернуть тот же status/body |
| FAILED_FINAL | повтор не изменит исход | вернуть сохранённую ошибку |
| UNKNOWN | внешний эффект мог произойти | reconciliation, не blind retry |
Unique constraint на tenant + key выбирает одного владельца. Fingerprint защищает от случайного использования ключа для другой суммы, объекта или команды. TTL — продуктовая политика, а не часть определения идемпотентности; он должен покрывать максимальное окно повторов и задержанных сообщений.
Сценарии
После сохранённого SUCCEEDED повтор читает authoritative row и не вызывает provider. Redis допустим как cache terminal response, но не как единственный источник истины.
Два одновременных запроса — главный race, который не ловит обычная проверка «GET key, затем SET». Нужны unique insert/compare-and-set и явная политика ожидания.
Тот же ключ с другим fingerprint — 409 или аналогичная client error. Возвращать старый charge для новой суммы опасно.
Timeout после отправки во внешний PSP означает неизвестный исход. Локальная БД и PSP не делят одну транзакцию: сначала сохраняется durable intent, provider получает тот же key, затем reconciler запрашивает исход по key/operation id. Только доказанный not-found разрешает новую попытку по правилам provider.
Локальный и внешний side effect
Локальную мутацию и idempotency row можно коммитить одной DB transaction. Для внешнего эффекта применяют state machine, provider idempotency key, outbox/worker, reconciliation и audit trail. Фраза «exactly once» без границ обычно скрывает повторное выполнение или неизвестный исход.
HTTP PUT и DELETE идемпотентны по семантике RFC 9110; POST — нет по умолчанию. If-Match полезен против lost update, но не заменяет dedup key для внешнего charge.
Проверка реализации
- crash после provider success, но до local save;
- два concurrent duplicate;
- key reuse с другим body;
- delayed retry после истечения обычного request timeout;
- provider lookup unavailable;
- reconciler lease expires mid-flight;
- PII не входит в лог ключа/response без необходимости.
Связанные темы
Первичные источники
- RFC 9110: https://www.rfc-editor.org/rfc/rfc9110.html
- Stripe idempotent requests: https://docs.stripe.com/api/idempotent_requests
- AWS Builders Library, Making retries safe: https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/