awesome-everything EN
↑ Обратно к восхождению

Архитектура бэкенда

Конкурентность и архитектура кеша для идемпотентности на масштабе

Суть Наивный SELECT-then-INSERT не атомарен. Postgres ON CONFLICT или advisory lock предотвращают гонки. На высоком throughput Redis Cluster с fallback на Postgres — production-паттерн.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 16 min

Два запроса с одним idempotency key прилетают на сервер одновременно — реальный сценарий, когда мобильный клиент стреляет двумя retry до первого подтверждения. Наивный SELECT-then-INSERT пропускает оба. Оба списывают с клиента.

Гонка в наивной реализации

Частая первая реализация:

-- Не атомарно!
SELECT * FROM idempotency_keys WHERE key = $1;
-- Здесь время течёт. Второй запрос проскакивает.
INSERT INTO idempotency_keys (key, fingerprint, status, expires_at)
  VALUES ($1, $2, 'in_progress', NOW() + interval '24 hours');

Между SELECT и INSERT второй конкурентный запрос может выполнить тот же SELECT (не увидев строки), потом тоже INSERT. Оба успешны. Оба начинают обработку. Оба списывают с клиента.

Фикс 1: Postgres INSERT … ON CONFLICT DO NOTHING RETURNING

INSERT INTO idempotency_keys (key, fingerprint, status, expires_at)
  VALUES ($1, $2, 'in_progress', NOW() + interval '24 hours')
  ON CONFLICT (key) DO NOTHING
  RETURNING *;

Если RETURNING пустой, этот запрос проиграл гонку. Он перечитывает строку:

  • Если status=in_progress → вернуть 409 Conflict.
  • Если status=completed → вернуть кешированный ответ.

INSERT атомарен на уровне базы. Гонки нет.

Фикс 2: Postgres advisory lock

SELECT pg_advisory_xact_lock(hashtext($1));
-- Теперь безопасно делать SELECT-then-INSERT

pg_advisory_xact_lock держит session-level эксклюзивную блокировку на время транзакции. Только одно соединение может держать блокировку для данного хеша в один момент.

Компромисс: hashtext 64-битный — коллизии возможны на масштабе (~10⁻¹⁰ при 10M ключей/день). Приемлемо для большинства нагрузок; security-критичные пути используют полный unique-индекс. Блокировка должна держаться на весь запрос включая внешние API-вызовы (десятки-сотни миллисекунд) — следи за pg_locks при инцидентах.

ПодходАтомарен?Риск коллизииПримечания
SELECT then INSERTНетN/AНебезопасно — окно гонки
INSERT ON CONFLICT DO NOTHING RETURNINGДаНетРекомендован для большинства
pg_advisory_xact_lockДа10⁻¹⁰ (hash коллизия)Осторожно: pool при медленных внешних вызовах

Масштабирование кеша: Redis Cluster

Один Postgres primary упирается примерно в 5–10к записей/сек на commodity-железе. На 50к req/сек idempotency-запись становится узким местом.

Redis Cluster с SETNX:

SETNX idempotency:{key} {fingerprint}:{status} EX {ttl_seconds}

SETNX атомарен: set-if-not-exists. Redis Cluster шардирует по hash ключа, распределяя нагрузку по нодам. Каждая нода тянет ~50–100к SETNX/сек.

Риск durability: Redis по умолчанию делает async fsync. Crash в течение миллисекунд после записи может потерять запись. Для платёжного API это означает: ключ пропал, следующий retry обрабатывается как новый — потенциальное двойное списание.

Двухуровневый кеш: production-паттерн

Платёжные API с юридической ответственностью за двойные списания используют гибрид:

  1. Hot path: RedisSETNX по ключу. Если успех (новый ключ) — обрабатывать и писать в Postgres асинхронно через outbox. Если конфликт (существующий ключ) — читать Redis для кешированного ответа.
  2. Cold path: Postgres — авторитетная запись. Если Redis miss (редко, при crash) — провалиться в Postgres для восстановления авторитетного ответа.
Запрос → Redis SETNX
          ├─ Новый: обработать + записать в Postgres async + кешировать в Redis
          ├─ Конфликт: вернуть кешированный ответ из Redis
          └─ Redis miss: читать из Postgres → заполнить Redis → вернуть

Stripe и Square используют этот гибрид в production. Чистый Redis приемлем для нефинансовых нагрузок (регистрации, аналитические события), где редкий дубликат — шум в логах, а не compliance-событие.

Почему это работает

Почему не DynamoDB для idempotency cache? DynamoDB ConditionExpression обрабатывает атомарный insert-or-noop нативно и масштабируется горизонтально без шардирования. На 50к/сек стоимость ~$3к/месяц, p99 latency ~5–10 мс. Хорошо, если уже на AWS и нужен zero ops; дорого и добавляет latency иначе. Гибрид Redis + Postgres дешевле и быстрее на сравнимом масштабе.

Викторина

Два запроса с одним idempotency key прилетают одновременно. Наивный SELECT-then-INSERT запущен. Какой worst-case исход?

Викторина

На 50к запросов/сек почему Redis Cluster превосходит один Postgres primary для lookup idempotency key?

Викторина

Redis Cluster нода падает в миллисекундах после SETNX-записи. Ключ потерян. Каковы последствия для платёжного API, использующего Redis как единственное хранилище?

Вспомните перед уходом
  1. 01
    Почему SELECT-then-INSERT небезопасен для создания idempotency key, и каков правильный фикс на Postgres?
  2. 02
    Опиши архитектуру двухуровневого idempotency cache в production платёжных API.
  3. 03
    Каков компромисс pg_advisory_xact_lock vs ON CONFLICT для сериализации idempotency keys?
Итог

Конкурентное создание idempotency key требует атомарной операции. Postgres INSERT ... ON CONFLICT (key) DO NOTHING RETURNING * — стандартный фикс: один оператор без окна гонки. Запрос с пустым RETURNING проиграл гонку и перечитывает строку для 409 или replay. На throughput выше ~10к записей/сек Redis Cluster hot-path с SETNX справляется с нагрузкой; Postgres cold-path fallback обеспечивает durability для финансовых нагрузок. In-memory-only дедупликация никогда не допустима за load balancer.

Связанные уроки
встречается в179
Продолжить восхождение ↑Наблюдаемость, production-инциденты и дизайн для глобального масштаба
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.