12. Idempotency и БД
1. Что такое идемпотентность (в контексте БД)
Представьте кнопку вызова лифта на этаже. Вы подходите и нажимаете кнопку — она загорается мягким желтым светом, а реле на чердаке отправляет кабину к вам. Если вы от нетерпения нажмете на эту же кнопку еще десять раз подряд, десять лифтов не приедут одновременно, а трос не натянется в десять раз сильнее. Система перешла в состояние «вызов принят» при первом нажатии, а все последующие нажатия не породили никаких побочных эффектов. Это и есть физическая суть идемпотентности.
Идемпотентность — это фундаментальное математическое и архитектурное свойство операции: повторный вызов с теми же входными аргументами дает тот же результат и не приводит к дополнительным изменениям состояния системы.
В терминах реляционных и NoSQL баз данных это означает:
- Несколько повторных вызовов
INSERT/UPDATE/DELETEс одинаковыми параметрами эквивалентны ровно одному успешному выполнению; - Повторная обработка одного и того же сетевого запроса или брокерского сообщения не разрушает консистентность данных и не нарушает бизнес-инварианты.
В протоколе HTTP методы GET, HEAD, PUT, DELETE и OPTIONS по спецификации обязаны быть идемпотентными. Методы POST и PATCH по умолчанию не идемпотентны. Поэтому именно эндпоинты создания заказов, списания платежей и перевода средств разработчик обязан делать идемпотентными вручную на уровне приложения и СУБД.
2. Где идемпотентность критична для БД
Типовые сценарии в реальном продакшене:
- Платежные шлюзы и процессинг: повторный
POST /paymentsпри сетевом таймауте не должен списывать деньги с банковской карты клиента дважды. - Оформление заказов в E-commerce: случайный двойной клик пользователя по кнопке «Оформить заказ» не должен создавать два одинаковых заказа на складе.
- Асинхронная обработка сообщений (Kafka, RabbitMQ, NATS): брокер гарантирует доставку
at-least-once, то есть при ребалансировке партиций или сбое коммита смещения воркер получит одно и то же сообщение повторно. - Ретраи клиентских API и Service Mesh: Envoy, Istio или HTTP-клиенты на Go с включенным exponential backoff автоматически повторяют неудавшиеся запросы.
В распределённых системах повторы неизбежны. Идемпотентность — единственный надежный способ получить семантику «точно один раз» по эффекту (effectively once), даже если физический запрос или сообщение пришли десять раз.
3. Идемпотентность на уровне операций БД
3.1. Идемпотентные SQL-операции
Сравним две на первый взгляд похожие операции модификации данных:
UPDATE accounts SET balance = balance - 100 WHERE id = 1— не идемпотентно: каждый повторный запуск скрипта списывает очередные 100 единиц со счета.UPDATE accounts SET balance = 900 WHERE id = 1— идемпотентно: сколько бы раз запрос ни выполнялся, баланс останется равным 900.
Для операций вставки INSERT идемпотентность обеспечивается тремя механизмами:
- Уникальные ограничения таблицы (unique constraints, первичные ключи);
- Атомарные Upsert-операции (
INSERT … ON CONFLICT … DO UPDATE,MERGE); - Перехват и штатная обработка ошибок нарушения уникальности (duplicate key error).
3.2. Использование уникальных ограничений
Классический паттерн защиты от дубликатов на уровне реляционной модели:
- В таблице бизнес-сущности создается уникальный индекс по естественному или синтетическому ключу (например,
order_numberилиtransaction_id). - При
INSERTСУБД проверяет наличие ключа в B-дереве уникального индекса. - При конфликте дубликат либо игнорируется, либо возвращает существующую запись.
Пример для MySQL (ON DUPLICATE KEY UPDATE):
INSERT INTO orders (order_number, user_id, amount)
VALUES ('ORD-123', 42, 100.00)
ON DUPLICATE KEY UPDATE order_number = order_number; -- no-op, просто игнорируем повтор
Пример для PostgreSQL (ON CONFLICT DO NOTHING):
INSERT INTO orders (order_number, user_id, amount)
VALUES ('ORD-123', 42, 100.00)
ON CONFLICT (order_number) DO NOTHING;
3.3. Идемпотентность через транзакции
Для сложных бизнес-сценариев (изменение баланса, создание заказа, отправка нотификации) требуется строгая транзакционная граница:
- Все изменения выполняются внутри одной ACID-транзакции;
- Внутри транзакции сохраняется и проверяется уникальный ключ операции;
- При любой ошибке или конфликте транзакция откатывается через
ROLLBACK, не оставляя частичных изменений.
4. Паттерн Idempotency Key и таблица запросов
4.1. Общая идея
Клиент генерирует уникальный идентификатор операции — Idempotency-Key (обычно UUIDv4) — и передает его в HTTP-заголовке Idempotency-Key или в метаданных gRPC-вызова.
Сервер выполняет следующий алгоритм:
- Проверяет в таблице ключей идемпотентности, обрабатывался ли ранее запрос с таким ключом.
- Если ключ найден и обработка завершена — сервер возвращает сохраненный кэшированный ответ без повторного выполнения бизнес-логики.
- Если ключ не найден — сервер открывает транзакцию, регистрирует ключ, выполняет операцию, сохраняет результат и фиксирует транзакцию.
Этот паттерн стандартизирован IETF и используется передовыми финансовыми API, такими как Stripe, Adyen и PayPal.
4.2. Архитектура с БД
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
A["Client"]:::entry -->|"POST /pay<br>Idempotency-Key: UUID"| B["API Gateway"]
B --> C["DB: check idempotency_key"]
C -->|"found"| D["Return saved response"]
C -->|"not found"| E["Start transaction"]
E --> F["Insert idempotency_key + request_hash"]
F --> G["Business logic: INSERT/UPDATE"]
G --> H["Commit transaction"]
H --> I["Return response"]
Таблица идемпотентности (пример производственной структуры PostgreSQL):
CREATE TABLE idempotency_keys (
id BIGSERIAL PRIMARY KEY,
key VARCHAR(255) NOT NULL UNIQUE,
user_id BIGINT NOT NULL,
request_hash VARCHAR(64) NOT NULL, -- SHA-256 хеш от тела запроса для защиты от коллизий
response JSONB, -- сохраненный сериализованный ответ клиенту
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ -- опциональный TTL (например, 24 часа)
);
Ключевые инженерные аспекты:
- Колонка
keyзащищена ограничениемUNIQUE— конкурентная вторая вставка немедленно завершится ошибкой дублирования. - Поле
request_hashпроверяет, что клиент не отправил под тем же ключом совершенно другие параметры запроса (защита от багов клиента). - Поле
responseхранит точный HTTP-статус и тело ответа, что позволяет повторить ответ байт-в-байт.
4.3. Конкурентные запросы с одним ключом
Что происходит, если два одинаковых запроса с одним ключом приходят с интервалом в 1 миллисекунду?
sequenceDiagram
participant C1 as Клиент (Поток 1)
participant C2 as Клиент (Поток 2)
participant DB as СУБД (PostgreSQL)
C1->>DB: BEGIN
C1->>DB: INSERT INTO idempotency_keys (key, status='in_progress')
Note over DB: Запись успешно создана
C2->>DB: BEGIN
C2->>DB: INSERT INTO idempotency_keys (key, status='in_progress')
Note over DB: Ошибка: duplicate key value violates unique constraint!
C2->>DB: ROLLBACK
Note over C2: Возврат ошибки 409 Conflict или ожидание результата
C1->>DB: UPDATE orders / accounts ...
C1->>DB: UPDATE idempotency_keys SET status='completed', response='...'
C1->>DB: COMMIT
Важно: INSERT ключа и выполнение бизнес-операции должны быть объединены в единую транзакцию, чтобы ни один сбой сервера не оставил ключ в «подвешенном» состоянии без реальных данных.
5. Idempotent Consumer (сообщения, Kafka, RabbitMQ)
Для асинхронных потребителей событий применяется паттерн Idempotent Consumer (или Transactional Inbox):
- В базе данных создается таблица
processed_messages(илиinbox), хранящая уникальные идентификаторы сообщений брокера. - Внутри одной транзакции:
- Вставляется
message_idвprocessed_messages; - Обновляются доменные бизнес-таблицы.
- Вставляется
- Первичный ключ по
message_idгарантирует, что повторная доставка того же сообщения завершится откатом транзакции.
CREATE TABLE processed_messages (
message_id VARCHAR(255) PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Обработчик воркера:
BEGIN;
INSERT INTO processed_messages (message_id, processed_at)
VALUES ('msg-123', now());
-- Бизнес-логика: update accounts, create orders и т.д.
UPDATE accounts SET balance = balance + 500 WHERE id = 42;
COMMIT;
При повторной доставке сообщения с message_id = 'msg-123' попытка вставки вызовет нарушение первичного ключа, транзакция мгновенно откатится, а баланс останется неизменным.
6. Практические рекомендации по проектированию
6.1. Когда идемпотентность обязательна
- Любые финансовые операции (списания, пополнения, холдирование средств).
- Операции с внешними сайд-эффектами (отправка писем, SMS, фискализация чеков).
- Создание бизнес-ресурсов, где появление дублей критично (учетные записи, заказы, счета).
- Все обработчики сообщений из очередей и брокеров (Kafka, RabbitMQ).
6.2. Как проектировать API и БД
- Вводите заголовок
Idempotency-Keyдля всех критичных мутирующих запросов:- Генерация надежного UUIDv4 на стороне клиента.
- Хранение в БД с уникальным индексом.
- Проверка хэша тела запроса (
request_hash) и кэширование готового ответа.
- Создавайте уникальные ограничения на естественные ключи:
order_number,transaction_id,external_ref,payment_intent_id.- Это первый и самый надежный рубеж обороны.
- Используйте атомарные операции Upsert:
INSERT … ON CONFLICT … DO UPDATEв PostgreSQL;INSERT … ON DUPLICATE KEY UPDATEв MySQL;MERGEв Oracle и MS SQL.
- Связывайте ключ и операцию в единую транзакцию:
- Запись ключа, бизнес-модификации и фиксация должны быть неделимы.
- Настраивайте TTL для таблицы ключей идемпотентности:
- Ключи не должны копиться вечно; фоновый воркер или секционирование по дням удаляет ключи старше 24–72 часов.
7. Пример: идемпотентное создание заказа (PostgreSQL)
Рассмотрим комплексный пример на уровне схемы и логики приложения:
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
order_number VARCHAR(255) NOT NULL UNIQUE,
user_id BIGINT NOT NULL,
amount NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE idempotency_keys (
id BIGSERIAL PRIMARY KEY,
key VARCHAR(255) NOT NULL UNIQUE,
order_id BIGINT,
request_hash VARCHAR(64),
response JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
Транзакционная логика обработки на стороне сервера:
BEGIN;
-- 1. Регистрируем idempotency key и проверяем уникальность
INSERT INTO idempotency_keys (key, request_hash, created_at)
VALUES ('uuid-abc-123', 'hash-of-body', now())
RETURNING id INTO _key_id;
-- 2. Создаем бизнес-сущность заказа
INSERT INTO orders (order_number, user_id, amount, created_at)
VALUES ('ORD-123', 42, 100.00, now())
RETURNING id INTO _order_id;
-- 3. Связываем ключ с созданным заказом и сохраняем JSON-ответ
UPDATE idempotency_keys
SET order_id = _order_id,
response = jsonb_build_object('order_id', _order_id, 'status', 'created')
WHERE id = _key_id;
COMMIT;
При повторном вызове с тем же ключом:
- Шаг 1 падает с ошибкой
duplicate key value violates unique constraint "idempotency_keys_key_key"; - Обработчик перехватывает ошибку, выполняет
SELECT response FROM idempotency_keys WHERE key = 'uuid-abc-123'и сразу отдает клиенту сохраненный ответ{"order_id": ..., "status": "created"}с кодом HTTP 200 OK.
8. Связь с CAP и распределёнными системами
В распределённых системах идемпотентность — один из главных инструментов парирования сетевой нестабильности:
- Безопасное выполнение автоматических ретраев при сетевых таймаутах;
- Нейтрализация дубликатов при доставке сообщений брокерами;
- Корректное восстановление бизнес-процессов после аварийных перезапусков микросервисов.
Идемпотентность не устраняет фундаментальные компромиссы теоремы CAP, но переводит неизбежные сетевые повторы из категории катастрофических аварий в штатный режим работы.
В следующей статье мы перейдем к методам масштабирования базы данных, когда поток транзакций превышает физические возможности одного сервера: 13. Highload базы данных.