🔄 Идемпотентность: Как не списать деньги дважды при ретраях Худшее, что может сделать ваш микросервис, это упасть с пятисоткой.
Худшее, что может сделать ваш микросервис, это упасть с пятисоткой. Нет, вру. Худшее, это тихо выполнить операцию дважды.
Все мы знаем, что сеть ненадежна. Поэтому мы оборачиваем 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