Ответ
В распределённых системах, которые я проектировал, такая ситуация — классический сценарий частичного отказа. Клиент получает таймаут или разрыв соединения, в то время как сервер уже обработал запрос (например, списал деньги, создал заказ). Решение строится на двух принципах: идемпотентность операций и механизм повторов (retry) на стороне клиента.
1. Проектирование идемпотентного API:
Все небезопасные операции (POST, PUT, DELETE) должны быть идемпотентными. Я добиваюсь этого, требуя от клиента отправлять уникальный идемпотентный ключ (Idempotency-Key) в заголовке запроса (например, X-Idempotency-Key: <uuid>). Сервер обязан проверять этот ключ.
2. Реализация на стороне сервера (бэкенд + кэш): При получении запроса с таким ключом:
- Сервер проверяет, не обработан ли уже запрос с этим ключом, выполняя
GETв быстром хранилище (например, Redis). - Если результат есть — он немедленно возвращается.
- Если нет — запрос обрабатывается, а его результат сохраняется в Redis с TTL (например, 24 часа) на случай повторной отправки.
Пример middleware на Go для Gin framework:
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
"net/http"
)
func Idempotency(redisClient *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
// Проверяем только для небезопасных методов
if c.Request.Method == http.MethodPost || c.Request.Method == http.MethodPut || c.Request.Method == http.MethodPatch {
idempotencyKey := c.GetHeader("X-Idempotency-Key")
if idempotencyKey == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Idempotency-Key header required"})
return
}
// Пробуем получить сохранённый ответ
cachedResponse, err := redisClient.Get(c, "idempotency:"+idempotencyKey).Result()
if err == nil {
// Если нашли, отдаём сохранённый ответ и завершаем обработку
c.Data(http.StatusOK, "application/json", []byte(cachedResponse))
c.Abort()
return
}
// Если ключа нет, обрабатываем запрос и перехватываем ответ
c.Next()
// После обработки сохраняем успешный ответ (статус 2xx) в Redis
status := c.Writer.Status()
if status >= 200 && status < 300 {
// Здесь нужно захватить тело ответа (требует дополнительного буферизации writer)
// Для примера опустим эту логику
redisClient.Set(c, "idempotency:"+idempotencyKey, "serialized_response", 24*time.Hour)
}
} else {
c.Next()
}
}
}
3. Стратегия повторов на стороне клиента:
Клиентское приложение (или API-гейтвей) должно использовать экспоненциальную задержку (exponential backoff) при повторах и отправлять тот же идемпотентный ключ. Например, использовать библиотеку retry в сочетании с axios в Node.js.
4. Инфраструктурные гарантии:
- Балансировщики нагрузки (Nginx/HAProxy): Настраиваю правильные таймауты (
proxy_read_timeout,proxy_connect_timeout), чтобы они превышали время обработки на бэкенде. - Мониторинг: Настраиваю алерты на высокий уровень 5xx ошибок и таймаутов, которые могут указывать на проблему. Такой подход гарантирует, что даже в условиях нестабильной сети или временной недоступности сервиса не произойдёт дублирования критических операций.