
Даже идеальная архитектура не спасёт от дублей запросов. Пользователь дважды нажал «Оплатить», мобильное приложение повторило запрос после временной потери сети, балансировщик сделал повтор при 502, фоновая задача запустилась повторно после перезапуска воркера, а интеграция с внешним провайдером отдала таймаут при успешном выполнении. Результат — двойные списания, дубли заказов, письма «платёж прошел/не прошел» вразнобой и разбитые метрики.
Идемпотентность — это свойство операции, при котором многократное выполнение с одним и тем же входом приводит к одному и тому же результату (без дополнительных побочных эффектов). В терминах API это означает: клиент может безопасно повторить запрос, а сервер вернёт тот же ответ и гарантирует отсутствие дублей в данных и платёжных движениях.
Создадим таблицу для фиксации идемпотентности. Ключевые моменты: уникальный индекс по (арендатор, операция, ключ), хранение хеша запроса, статуса и ответа.
-- Таблица ключей идемпотентности
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);
Пошаговая схема для POST + Idempotency-Key:
Ниже — минимальный, но рабочий пример обработчика создания платежа. Он демонстрирует «захват» ключа, детерминированный 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))
}
Примечания к примеру:
Проверка через 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, без дублей в БД
Идемпотентность — это не «доп-фича», а базовая гигиена платёжных и заказных систем. Реализуется она просто: один уникальный ключ, пара таблиц и чёткая дисциплина при сохранении ответа. Зато бизнес спит спокойнее, а разработчики перестают отвечать на письма про «двойное списание без причины».