Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Идемпотентность API и платежей: как убрать двойные списания и хаос в заказах

Разработка и технологии29 апреля 2026 г.
Дубли запросов в реальном мире неизбежны: потери пакетов, таймауты, повторные клики и ретраи интеграций. Рассказываю, как спроектировать идемпотентные API на практике, чтобы исключить двойные списания, путаницу в заказах и дорогие ручные разборы, — с примерами SQL и Go.
Идемпотентность API и платежей: как убрать двойные списания и хаос в заказах

Оглавление

  • Зачем нужна идемпотентность и где берутся дубли
  • Принципы: ключ, область действия, хранение и сроки
  • Модель данных и уникальные ограничения в PostgreSQL
  • HTTP-стратегии: POST с ключом и естественно идемпотентные методы
  • Поток обработки: как «захватить» ключ и вернуть тот же ответ
  • Пример кода: Go + PostgreSQL
  • Платежи: как не списать дважды и что делать с провайдерами
  • Грабли и тонкости
  • Мониторинг и операционные практики
  • Пошаговый план внедрения
  • Итог: как это влияет на бизнес-показатели

Зачем нужна идемпотентность и где берутся дубли

Даже идеальная архитектура не спасёт от дублей запросов. Пользователь дважды нажал «Оплатить», мобильное приложение повторило запрос после временной потери сети, балансировщик сделал повтор при 502, фоновая задача запустилась повторно после перезапуска воркера, а интеграция с внешним провайдером отдала таймаут при успешном выполнении. Результат — двойные списания, дубли заказов, письма «платёж прошел/не прошел» вразнобой и разбитые метрики.

Идемпотентность — это свойство операции, при котором многократное выполнение с одним и тем же входом приводит к одному и тому же результату (без дополнительных побочных эффектов). В терминах API это означает: клиент может безопасно повторить запрос, а сервер вернёт тот же ответ и гарантирует отсутствие дублей в данных и платёжных движениях.

Принципы: ключ, область действия, хранение и сроки

  • Ключ идемпотентности. Клиент генерирует случайный строковый идентификатор (например, UUID) и отправляет в заголовке Idempotency-Key. Один ключ — одна логическая операция.
  • Область действия. Ключ должен быть привязан к «пространству имён»: арендатор (merchant_id/организация), endpoint/операция, а иногда и к пользователю. Это исключает пересечения ключей между разными контекстами.
  • Фиксация запроса и ответа. Сервер хранит хеш тела запроса, статус выполнения и итоговый ответ. Повтор — это проверка существующей записи и возврат сохранённого результата.
  • Срок жизни. Ключи не вечны. TTL выбирают исходя из бизнес-процесса: для платежей — 24–72 часа, для создания черновиков — от часов до дней.
  • Конкурентность. Два одновременных запроса с одним ключом не должны «просочиться» оба. Используйте уникальные ограничения в БД и/или лёгкую блокировку на время выполнения.
  • Совместимость. При повторе с тем же ключом, но другим телом запроса — это конфликт. Возвращаем 409 Conflict, не делаем новых побочных эффектов.

Модель данных и уникальные ограничения в PostgreSQL

Создадим таблицу для фиксации идемпотентности. Ключевые моменты: уникальный индекс по (арендатор, операция, ключ), хранение хеша запроса, статуса и ответа.

-- Таблица ключей идемпотентности
CREATE TABLE IF NOT EXISTS idempotency_keys (
  id              BIGSERIAL PRIMARY KEY,
  tenant_id       BIGINT       NOT NULL,     -- арендатор/мерчант/организация
  endpoint        TEXT         NOT NULL,     -- логическое имя операции, напр. "POST:/v1/payments"
  idemp_key       TEXT         NOT NULL,     -- значение заголовка Idempotency-Key
  request_hash    BYTEA        NOT NULL,     -- SHA-256 тела запроса (или детерминированного подмножества полей)
  status          TEXT         NOT NULL CHECK (status IN ('in_progress','completed')),
  response_status INT,                       -- HTTP-статус сохранённого ответа
  response_headers JSONB       NOT NULL DEFAULT '{}'::jsonb,
  response_body   BYTEA,
  created_at      TIMESTAMPTZ  NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX IF NOT EXISTS ux_idempotency
  ON idempotency_keys (tenant_id, endpoint, idemp_key);

-- Триггер на updated_at (опционально)
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_touch_updated_at ON idempotency_keys;
CREATE TRIGGER trg_touch_updated_at
BEFORE UPDATE ON idempotency_keys
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();

Для денежных операций удобно ввести детерминированный business-id ресурса (например, payment_id = SHA-256(tenant_id + ':' + idemp_key)). Тогда сама бизнес-вставка становится идемпотентной за счёт уникального ключа.

-- Условная таблица платежей
CREATE TABLE IF NOT EXISTS payments (
  payment_id   TEXT PRIMARY KEY,             -- детерминирован из tenant_id + idemp_key
  tenant_id    BIGINT NOT NULL,
  amount_minor BIGINT NOT NULL CHECK (amount_minor > 0),
  currency     TEXT   NOT NULL,
  status       TEXT   NOT NULL CHECK (status IN ('new','authorized','captured','failed')),
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS ix_payments_tenant ON payments(tenant_id);

HTTP-стратегии: POST с ключом и естественно идемпотентные методы

  • Для создания ресурса с серверной генерацией идентификатора используйте POST + заголовок Idempotency-Key. Сервер хранит результат и возвращает его при повторах.
  • Для обновлений, где у клиента есть естественный идентификатор, используйте PUT/PATCH на конкретный ресурс (это операции по определению ближе к идемпотентным). Пример: PUT /carts/{id}/items, где итоговое состояние задаёт клиент.
  • Для «сильной» идемпотентности обязательно сохраняйте точный ответ (статус, заголовки, тело) и возвращайте его дословно для повторов одного ключа.

Поток обработки: как «захватить» ключ и вернуть тот же ответ

Пошаговая схема для POST + Idempotency-Key:

  1. Получить Idempotency-Key и tenant_id.
  2. Посчитать детерминированный хеш запроса (SHA-256 нормализованного JSON). Сортируйте поля, уберите незначащие атрибуты (например, пробелы), чтобы одинаковые по смыслу запросы давали одинаковый хеш.
  3. «Захватить» ключ вставкой в таблицу idempotency_keys. Если вставка прошла — вы «владелец» выполнения. Если конфликт — проверить существующую запись:
    • Если status=completed и хеш совпадает — вернуть сохранённый ответ.
    • Если status=in_progress и запись «свежая» — вернуть 409 Conflict или 425 Too Early с Retry-After.
    • Если status=in_progress, но запись «протухла» (старше TTL) — аккуратно перехватить выполнение через UPDATE с условием по updated_at.
  4. Выполнить бизнес-операцию идемпотентно (например, вставить payment с детерминированным payment_id). Внешние вызовы (платёжный провайдер) не держите внутри транзакции БД.
  5. Сохранить итоговый HTTP-ответ в idempotency_keys (status=completed, response_*).
  6. При любых повторах с тем же ключом — вернуть сохранённый ответ. При несовпадении хеша — 409 Conflict.

Пример кода: Go + PostgreSQL

Ниже — минимальный, но рабочий пример обработчика создания платежа. Он демонстрирует «захват» ключа, детерминированный payment_id и возврат сохранённого ответа. Для краткости опущены аутентификация и валидация, но код компилируем и готов к интеграции.

package main

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"io"
	"log"
	"net/http"
	"strings"
	"time"

	_ "github.com/lib/pq"
)

type PaymentRequest struct {
	AmountMinor int64  `json:"amount_minor"`
	Currency    string `json:"currency"`
}

type PaymentResponse struct {
	PaymentID   string `json:"payment_id"`
	Status      string `json:"status"`
	AmountMinor int64  `json:"amount_minor"`
	Currency    string `json:"currency"`
}

type Server struct {
	DB          *sql.DB
	TTLInProgress time.Duration
}

func (s *Server) handleCreatePayment(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	tenantID := int64(42) // пример: получили из контекста аутентификации
	endpoint := "POST:/v1/payments"
	key := r.Header.Get("Idempotency-Key")
	if key == "" {
		http.Error(w, "Idempotency-Key обязателен", http.StatusBadRequest)
		return
	}
	if len(key) > 200 {
		http.Error(w, "Idempotency-Key слишком длинный", http.StatusBadRequest)
		return
	}

	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "не удалось прочитать тело", http.StatusBadRequest)
		return
	}

	// Нормализация JSON: упрощённо — трим пробелы; в проде лучше стабильно маршалить
	norm := strings.TrimSpace(string(body))
	h := sha256.Sum256([]byte(norm))
	hashBytes := h[:]

	// Попытка «захватить» ключ
	var insertedID int64
	err = s.DB.QueryRowContext(ctx, `
		INSERT INTO idempotency_keys (tenant_id, endpoint, idemp_key, request_hash, status)
		VALUES ($1,$2,$3,$4,'in_progress')
		ON CONFLICT (tenant_id, endpoint, idemp_key) DO NOTHING
		RETURNING id;
	`, tenantID, endpoint, key, hashBytes).Scan(&insertedID)

	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		log.Println("insert idempotency error:", err)
		http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
		return
	}

	if insertedID == 0 {
		// Конфликт — запись уже есть
		var status string
		var reqHash []byte
		var respStatus sql.NullInt64
		var respHeaders []byte
		var respBody []byte
		var updatedAt time.Time
		err = s.DB.QueryRowContext(ctx, `
			SELECT status, request_hash, response_status, response_headers, response_body, updated_at
			FROM idempotency_keys
			WHERE tenant_id=$1 AND endpoint=$2 AND idemp_key=$3;
		`, tenantID, endpoint, key).Scan(&status, &reqHash, &respStatus, &respHeaders, &respBody, &updatedAt)
		if err != nil {
			log.Println("select idempotency error:", err)
			http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
			return
		}

		if !equalBytes(reqHash, hashBytes) {
			http.Error(w, "Idempotency-Key уже использован с другим запросом", http.StatusConflict)
			return
		}
		if status == "completed" && respStatus.Valid {
			// Вернём сохранённый ответ
			for k, v := range mustJSONToHeaders(respHeaders) {
				w.Header().Set(k, v)
			}
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(int(respStatus.Int64))
			_, _ = w.Write(respBody)
			return
		}
		// В работе. Свежая? Попросим повторить позже
		if time.Since(updatedAt) < s.TTLInProgress {
			w.Header().Set("Retry-After", "3")
			http.Error(w, "операция выполняется, повторите позже", http.StatusTooEarly) // 425
			return
		}
		// Протухла: перехватим выполнение
		res, err := s.DB.ExecContext(ctx, `
			UPDATE idempotency_keys
			SET updated_at=now()
			WHERE tenant_id=$1 AND endpoint=$2 AND idemp_key=$3 AND status='in_progress' AND updated_at=$4;
		`, tenantID, endpoint, key, updatedAt)
		if err != nil {
			log.Println("takeover update error:", err)
			http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
			return
		}
		rows, _ := res.RowsAffected()
		if rows == 0 {
			w.Header().Set("Retry-After", "2")
			http.Error(w, "операция выполняется, повторите позже", http.StatusTooEarly)
			return
		}
		// Падаем ниже и считаем, что теперь мы владельцы выполнения
	}

	// Декод тела
	var req PaymentRequest
	if err := json.Unmarshal([]byte(norm), &req); err != nil || req.AmountMinor <= 0 || req.Currency == "" {
		http.Error(w, "некорректный запрос", http.StatusBadRequest)
		return
	}

	// Детерминированный payment_id
	pid := deterministicPaymentID(tenantID, key)

	// Вставка (идемпотентно за счёт PK)
	_, err = s.DB.ExecContext(ctx, `
		INSERT INTO payments (payment_id, tenant_id, amount_minor, currency, status)
		VALUES ($1,$2,$3,$4,'new')
		ON CONFLICT (payment_id) DO NOTHING;
	`, pid, tenantID, req.AmountMinor, strings.ToUpper(req.Currency))
	if err != nil {
		log.Println("insert payment error:", err)
		http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
		return
	}

	// Здесь мог бы быть вызов платёжного провайдера. Для примера считаем, что платёж авторизован.
	_, err = s.DB.ExecContext(ctx, `UPDATE payments SET status='authorized' WHERE payment_id=$1;`, pid)
	if err != nil {
		log.Println("update payment error:", err)
		http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
		return
	}

	resp := PaymentResponse{PaymentID: pid, Status: "authorized", AmountMinor: req.AmountMinor, Currency: strings.ToUpper(req.Currency)}
	respBytes, _ := json.Marshal(resp)

	// Сохраним ответ
	headers := map[string]string{"Content-Type": "application/json"}
	headersJSON, _ := json.Marshal(headers)
	_, err = s.DB.ExecContext(ctx, `
		UPDATE idempotency_keys
		SET status='completed', response_status=$1, response_headers=$2::jsonb, response_body=$3
		WHERE tenant_id=$4 AND endpoint=$5 AND idemp_key=$6;
	`, http.StatusOK, string(headersJSON), respBytes, tenantID, endpoint, key)
	if err != nil {
		log.Println("update idempotency error:", err)
		// даже если не сохранили ответ, основной бизнес-эффект уже сделан — вернём текущий
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(respBytes)
}

func deterministicPaymentID(tenantID int64, key string) string {
	b := []byte(strings.Join([]string{intToString(tenantID), key}, ":"))
	s := sha256.Sum256(b)
	return hex.EncodeToString(s[:])
}

func intToString(v int64) string { return strconvFormatInt(v) }

func strconvFormatInt(v int64) string {
	// легковесная обёртка, чтобы не тянуть strconv в примере
	return fmtInt(v)
}

// минималистичная реализация без импорта fmt/strconv для компактности
func fmtInt(v int64) string {
	neg := v < 0
	if neg { v = -v }
	if v == 0 { if neg { return "-0" }; return "0" }
	buf := make([]byte, 0, 20)
	for v > 0 { buf = append(buf, byte('0'+v%10)); v /= 10 }
	// reverse
	for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 { buf[i], buf[j] = buf[j], buf[i] }
	if neg { return "-" + string(buf) }
	return string(buf)
}

func equalBytes(a, b []byte) bool {
	if len(a) != len(b) { return false }
	for i := range a { if a[i] != b[i] { return false } }
	return true
}

func mustJSONToHeaders(data []byte) map[string]string {
	res := map[string]string{}
	_ = json.Unmarshal(data, &res)
	return res
}

func main() {
	dsn := "postgres://user:password@localhost:5432/app?sslmode=disable"
	db, err := sql.Open("postgres", dsn)
	if err != nil { log.Fatal(err) }
	if err := db.Ping(); err != nil { log.Fatal(err) }

	s := &Server{DB: db, TTLInProgress: 30 * time.Second}
	http.HandleFunc("/v1/payments", s.handleCreatePayment)
	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Примечания к примеру:

  • Мы используем INSERT ... ON CONFLICT для «захвата» ключа без тяжёлых блокировок.
  • Ответ сохраняется и дословно возвращается при повторах.
  • Для простоты нормализация JSON минимальна. В рабочем проекте сериализуйте структуру с фиксированным порядком полей.

Проверка через curl:

curl -i -H "Idempotency-Key: 86c1b0c1-0a3e-4e2c-b4c7-7a2e7d" \
     -H "Content-Type: application/json" \
     -d '{"amount_minor": 19900, "currency": "RUB"}' \
     http://localhost:8080/v1/payments

# Повтор тем же ключом вернёт тот же payment_id и статус 200, без дублей в БД

Платежи: как не списать дважды и что делать с провайдерами

  • Детерминированный идентификатор. Привяжите бизнес-операцию к детерминированному ключу (payment_id из tenant_id + Idempotency-Key). Даже если сетевой слой даст дубли — в БД встанет одна строка.
  • Ключи для провайдера. Многие провайдеры поддерживают свой ключ идемпотентности (например, в заголовке). Пробрасывайте наш ключ — это дополнительно защищает от двойного списания на стороне провайдера.
  • Разделите авторизацию и списание. Для картовых операций сначала делайте hold (authorized), а затем capture. Это упрощает разруливание редких коллизий без потери денег.
  • Сверки. Введите ежедневную автоматическую сверку с отчётами провайдера: расхождения сигналят о редких сбоях, которые не поймает обычный мониторинг.

Грабли и тонкости

  • «Ровно один раз» — миф. В распределённых системах нет идеальной доставки «ровно один раз». Идемпотентность — это про «хоть сколько раз, но результат один и тот же».
  • Таймауты и вялые повторы. Клиент получил таймаут, но операция могла завершиться. Не начинайте новую, пока не проверите ключ.
  • Повтор с другим телом. Один и тот же ключ + иное тело запроса — это ошибка клиента. Возвращайте 409 Conflict и не делайте побочных эффектов.
  • Долгие операции. Если бизнес-операция занимает минуты, не держите БД-транзакции открытыми. Отмечайте статус in_progress и возвращайте 202 Accepted с ссылкой на статус-ресурс. Идемпотентность сохранится.
  • Очереди и фоновые задачи. Воркер может отработать задачу дважды после рестарта. Ключ идемпотентности храните в нагрузочном слое (БД/Redis) и используйте уникальные ограничения для бизнес-сущностей.
  • Хранение ответа. Не записывайте гигабайтные полезные нагрузки. Для тяжёлых ответов сохраняйте ссылку на ресурс + контрольную сумму.

Мониторинг и операционные практики

  • Метрики: число конфликтов по ключам, доля повторов, время выполнения «первого владельца», доля ответов из кэша идемпотентности.
  • Алёрты: рост 409/425, большое число «протухших» in_progress, рассинхронизация с провайдером платежей.
  • Логи: корреляция по Idempotency-Key и tenant_id — это ускоряет разбор инцидентов.
  • Дэшборд поддержки: по ключу находить платёж/заказ и видеть его состояние — снимает нагрузку с инженеров.

Пошаговый план внедрения

  1. Выберите критичные операции: платежи, создание заказа, применение купона, списание бонусов.
  2. Добавьте таблицу idempotency_keys и уникальные ограничения в БД. Определите TTL.
  3. Внедрите middleware: чтение Idempotency-Key, расчёт хеша, «захват» ключа, возврат сохранённого ответа.
  4. Сделайте бизнес-операции детерминированными: введите внешние идентификаторы (payment_id, order_id) как функцию от (tenant_id, Idempotency-Key).
  5. Пробросьте ключ к внешним провайдерам, если поддерживается.
  6. Прогоните интеграционные тесты с искусственными таймаутами и повторными запросами.
  7. Включите метрики и алёрты. Обучите поддержку пользоваться дэшбордом по ключам.

Итог: как это влияет на бизнес-показатели

  • Ноль двойных списаний и дублей заказов — меньше возвратов, штрафов и ручной обработки.
  • Предсказуемые ретраи клиентов — меньше 5xx и тикетов «а что случилось?», стабильнее конверсия.
  • Быстрые разборы инцидентов — лог по ключу + сохранённый ответ экономит часы инженеров.
  • Повышение доверия аудиторов: повтор запроса безопасен, финансовые движения атомарны и прослеживаемы.

Идемпотентность — это не «доп-фича», а базовая гигиена платёжных и заказных систем. Реализуется она просто: один уникальный ключ, пара таблиц и чёткая дисциплина при сохранении ответа. Зато бизнес спит спокойнее, а разработчики перестают отвечать на письма про «двойное списание без причины».


APIидемпотентностьплатежи