🔄 Идемпотентность: Как не списать деньги дважды при ретраях — 9 июня 2026 г. в 04:55:05.965
🔄 Идемпотентность: Как не списать деньги дважды при ретраях Худшее, что может сделать ваш микросервис, это упасть с пятисоткой. Нет, вру. Худшее, это тихо выполнить операцию дважды. Все мы знаем, что сеть ненадежна. Поэтому мы оборачиваем HTTP-клиенты в ретраи (например, через hashicorp/go-retryablehttp). Но тут возникает классическая ловушка распределенных систем: Таймаут не означает, что запрос не выполнился. Представьте: клиент отправляет запрос "Списать 1000 рублей". Сервис списывает деньги, отправляет ответ 200 OK, но по пути сеть моргнула, и клиент отвалился по таймауту. Клиент делает ретрай. Сервис списывает еще 1000 рублей. Поздравляю, вы в новостях. Решение: Идемпотентность Операция называется идемпотентной, если многократное её выполнение дает тот же результат, что и однократное. Для GET или PUT запросов это работает из коробки. Для POST (создание ресурса, списание) нам нужен Idempotency-Key. Как это работает на практике: 1. Мобилка (или вызывающий сервис) генерирует уникальный ID для конкретной бизнес-операции (например, UUID v4) и передает его в заголовке Idempotency-Key. 2. Наш Go-сервис перед началом работы проверяет этот ключ. ❌ Как делать НЕ надо (Redis-ловушка): Многие новички сразу тянутся за Redis: записывают ключ туда, ставят TTL. Но что если Redis недоступен? Или ключи вытеснились из-за нехватки памяти (eviction)? Ваша консистентность рассыпается. ✅ Как надо (Атомарность БД): Идемпотентность должна жить там же, где и состояние (state) операции — в вашей реляционной базе. Используйте Unique Constraint в PostgreSQL: func (s *Service) Charge(ctx context.Context, userID int, amount float64, idempotencyKey string) error { // Пытаемся сохранить ключ в таблицу idempotency_keys // Таблица имеет UNIQUE индекс по полю key _, err := s.db.ExecContext(ctx, ` INSERT INTO idempotency_keys (key, status) VALUES ($1, 'processing') ON CONFLICT (key) DO NOTHING `, idempotencyKey) // Если ни одной строки не затронуто, значит ключ уже есть! // Мы можем вернуть старый результат (или ошибку "Уже в обработке") if err == nil && sqlResult.RowsAffected() == 0 { return ErrAlreadyProcessed } // ... здесь выполняем списание ... // Обновляем статус ключа на 'done' (в рамках транзакции списания) return nil } 🔥 Нюансы для Senior-ов: • Срок жизни: Ключи идемпотентности не должны жить вечно. Обычно хватает 24-48 часов. Настройте фоновую джобу или партиционирование таблиц (Table Partitioning в PG) для старых ключей, чтобы таблица не пухла до терабайтов. • Гонка ретраев (Thundering Herd): Если первый запрос завис, а клиент тут же послал второй (ретрай), второй запрос должен получить статус processing и подождать (или отвалиться с 409 Conflict), а не начинать работу параллельно. Доверяйте базе данных больше, чем сети. #golang #architecture #microservices #bestpractices #systemdesign 👉 @golang_lib

