🌐 Раздел 6 · Вопрос #7

Почему GET и DELETE идемпотентны

Методы GET и DELETE решают противоположные задачи (чтение и удаление), но оба обладают фундаментальным свойством протокола HTTP — идемпотентностью.


🟢 Junior Level

Методы GET и DELETE решают противоположные задачи (чтение и удаление), но оба обладают фундаментальным свойством протокола HTTP — идемпотентностью.

Это означает, что повторение одного и того же запроса оставляет сервер в том же целевом состоянии, что и однократный запуск.

1. Почему GET идемпотентен

Метод GET предназначен исключительно для получения (чтения) представления ресурса. Он не изменяет состояние данных в базе данных:

GET /api/v1/users/42 HTTP/1.1  -> 200 OK (данные пользователя)
GET /api/v1/users/42 HTTP/1.1  -> 200 OK (те же самые данные)

Поскольку GET является безопасным (Safe) методом, его повторный вызов $N$ раз оставляет данные нетронутыми ($f(x) = x$).

2. Почему DELETE идемпотентен

Метод DELETE переводит целевой ресурс в конечное состояние: «ресурс в системе отсутствует».

// 1-й запрос: ресурс был в БД, мы его удалили
DELETE /api/v1/users/42 HTTP/1.1  -> 204 No Content

// 2-й запрос (повтор из-за сбоя сети): ресурс уже отсутствует
DELETE /api/v1/users/42 HTTP/1.1  -> 404 Not Found (или 204 No Content)

Ключевой вывод: И после первого вызова, и после десятого вызова ресурс users/42 в системе отсутствует. Итоговое состояние сервера идентично, что строго удовлетворяет определению идемпотентности.


🟡 Middle Level

Разные коды ответа при DELETE и идемпотентность

Один из самых частых вопросов на технических интервью:

«Если первый вызов DELETE возвращает 200 OK или 204 No Content, а второй возвращает 404 Not Found, почему метод считается идемпотентным? Ведь коды ответа разные!»

Объяснение по стандарту RFC 9110 (раздел 9.2.2): Идемпотентность определяется исключительно эффектом, оказываемым на состояние сервера (side effects on the server state), а не статус-кодом HTTP-ответа.

Существует две распространенные стратегии ответа на повторный DELETE:

  1. Строгая REST-семантика: Первый вызов возвращает 204 No Content, повторные вызовы — 404 Not Found (клиенту сообщается, что ресурса уже нет).
  2. Тихая идемпотентность (Silent Idempotency): Всегда возвращать 204 No Content, даже если ресурса уже не было (клиент просил, чтобы ресурса не было — его нет, цель достигнута).

Обе стратегии полностью идемпотентны.

Технические побочные эффекты при GET (Access Logs, Metrics, Cache)

В спецификации HTTP термин «Safe method» означает отсутствие предполагаемых клиентом мутаций целевого ресурса. Однако GET запрос имеет полное право:

  • Добавлять записи в журнал доступа (access.log) и аудит.
  • Инкрементировать счетчики в Prometheus / StatsD.
  • Прогревать кэш в Redis или CDN.

Эти инфраструктурные действия не меняют семантическое состояние бизнес-сущности и не нарушают контракт безопасности.

Мягкое удаление (Soft Delete)

В корпоративных приложениях физическое удаление (DELETE FROM table) часто заменяется логическим:

UPDATE users SET is_deleted = true, deleted_at = NOW() WHERE id = 42;

Даже при мягком удалении метод DELETE остается идемпотентным: сколько бы раз клиент ни отправлял запрос, запись сохранит статус is_deleted = true.


🔴 Senior Level

Идемпотентность DELETE в Event-Driven Architecture (Kafka / Outbox)

В микросервисных системах удаление сущности обычно сопровождается публикацией доменного события: UserDeletedEvent(userId=42).

Если клиент из-за сетевого сбоя повторяет запрос DELETE /api/v1/users/42, наивная реализация может отправить второе событие в Kafka. Это приведет к дублированию событий в downstream-сервисах (биллинг, нотификации).

Реализация с Transactional Outbox в Spring Boot 3

package com.example.service;

import com.example.entity.OutboxEvent;
import com.example.entity.User;
import com.example.repository.OutboxRepository;
import com.example.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Service
public class UserService {

    private final UserRepository userRepository;
    private final OutboxRepository outboxRepository;

    public UserService(UserRepository userRepository, OutboxRepository outboxRepository) {
        this.userRepository = userRepository;
        this.outboxRepository = outboxRepository;
    }

    @Transactional
    public boolean deleteUser(Long userId) {
        Optional<User> userOpt = userRepository.findById(userId);

        if (userOpt.isEmpty()) {
            // Ресурс уже удален!
            // НЕ генерируем повторное событие в Outbox, сохраняя идемпотентность событий
            return false;
        }

        User user = userOpt.get();
        userRepository.delete(user);

        // Публикуем событие ровно один раз
        OutboxEvent event = new OutboxEvent(
                "USER_EVENTS",
                String.valueOf(userId),
                "UserDeleted",
                "{\"userId\":" + userId + "}"
        );
        outboxRepository.save(event);

        return true;
    }
}
package com.example.controller;

import com.example.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        boolean wasDeleted = userService.deleteUser(id);
        if (wasDeleted) {
            return ResponseEntity.noContent().build(); // 204 No Content
        }
        // При повторном запросе возвращаем 404
        return ResponseEntity.notFound().build(); // 404 Not Found
    }
}

Условный DELETE с Optimistic Locking (ETag / If-Match)

По спецификации RFC 9110 для метода DELETE можно применять заголовок If-Match:

DELETE /api/v1/documents/100 HTTP/1.1
If-Match: "v2-revision-etag"

Сервер проверяет, что документ удаляется именно в той версии, которую видел пользователь. Если другой пользователь параллельно отредактировал документ (версия стала v3), сервер вернет 412 Precondition Failed, защищая данные от случайного удаления модифицированного документа.

Пакетное удаление (Bulk Delete): как не сломать REST

Распространенная анти-паттерн ошибка: создание метода POST /api/v1/users/delete-batch. Использование POST лишает операцию идемпотентности на уровне протокола.

Корректные REST-подходы:

  1. Query-параметры: DELETE /api/v1/users?ids=1,2,3,4 (метод DELETE сохраняет идемпотентность).
  2. Подчиненный ресурс задач: Если удаление миллионов записей занимает минуты, используется асинхронный паттерн:
    • POST /api/v1/deletion-jobs -> 202 Accepted + заголовок Location: /api/v1/deletion-jobs/789.
    • Клиент опрашивает статус задачи через идемпотентный GET /api/v1/deletion-jobs/789.

🎯 Шпаргалка для интервью

30-секундный ответ

  • GET идемпотентен, потому что является безопасным (Safe) методом: он семантически предназначен только для чтения и не мутирует состояние бизнес-ресурсов. Повторение запроса $N$ раз оставляет базу данных в исходном состоянии.
  • DELETE идемпотентен, потому что приводит ресурс к детерминированному целевому состоянию: «ресурс удален». Первый вызов удаляет объект, все повторные вызовы констатируют его отсутствие.
  • Разница в статус-кодах (204 No Content при первом вызове и 404 Not Found при повторном) не нарушает идемпотентность, так как стандарт RFC 9110 требует неизменности состояния сервера, а не кодов ответов.

4 каверзных вопроса с ответами

  1. Если GET /articles/15 увеличивает счетчик просмотров в БД, перестает ли он быть Safe и идемпотентным? Ответ: С точки зрения спецификации RFC 9110 §9.2.1, вспомогательные технические эффекты (логирование, счетчики аналитики, кэширование) не лишают метод статуса Safe, если они не меняют представление целевого ресурса и не влияют на бизнес-логику управления ресурсом. Однако размещать критическую бизнес-логику (например, списание баланса) в GET категорически запрещено.

  2. Почему опасно делать операции удаления через GET (например, GET /users/1/delete)? Ответ: Браузеры, веб-краулеры (поисковые роботы Google) и прокси-серверы считают GET безопасным методом. Поисковый робот или браузерный механизм предзагрузки ссылок (prefetching) перейдет по этой ссылке и удалит данные без ведома пользователя.

  3. Что делать в Event-Driven архитектуре, если DELETE вызывается повторно? Ответ: Использовать проверку существования ресурса перед публикацией события (или Transactional Outbox). Повторный DELETE должен вернуть клиенту 404 Not Found (или 204), но не должен генерировать дублирующее событие EntityDeleted в Kafka/RabbitMQ.

  4. Как реализовать идемпотентное пакетное удаление в REST API? Ответ: Использовать DELETE /api/v1/items?id=1&id=2&id=3. Использование POST /api/v1/items/batch-delete семантически неидемпотентно и лишает клиент возможности безопасного автоматического повтора при сбоях сети.

Красные флаги (чего категорически нельзя говорить)

  • ❌ «DELETE не идемпотентен, потому что возвращает 404 вместо 200/204 при повторе» (идемпотентность оценивается по состоянию БД, а не кодам ответов).
  • ❌ «GET запросы запрещено логировать, потому что логирование меняет состояние сервера» (логирование и сбор метрик прямо разрешены RFC 9110).
  • ❌ «Для пакетного удаления нужно всегда делать POST» (для идемпотентного пакетного удаления используется DELETE со списком ID в query).
  • ❌ «Soft Delete делает метод DELETE неидемпотентным» (наоборот, повторный UPDATE одного и того же флага is_deleted = true абсолютно идемпотентен).

Связанные темы