Code Review: @Transactional + внешний HTTP вызов

27. Разделить внешний HTTP-вызов и транзакцию БД

Условие задачи:
Дан Spring-сервис, который сначала выполняет внешний HTTP-запрос, а затем сохраняет полученный результат в базу данных. Весь метод помечен @Transactional.

Нужно провести code review: оценить корректность транзакционной границы, обработку ошибок внешнего сервиса и возможные проблемы согласованности между HTTP-вызовом и записью в БД.

Код:

@Service
public class RestClient {

    @Transactional
    public void doWork() {
        var obj = restTemplate.postForObject(...);
        dbService.saveObj(obj);
    }
}

Спойлеры к решению

Подсказки
💡 Не стоит держать транзакцию БД открытой во время сетевого вызова.
💡 Транзакция БД не может откатить уже выполненный HTTP-запрос.
💡 Для HTTP-клиента нужны таймауты и понятная стратегия обработки ошибок.
💡 Если вынести @Transactional в другой метод того же класса, вспомни про self-invocation Spring AOP.
💡 При retry важно продумать идемпотентность внешней операции.

Решение

Проблемы:

  • транзакция охватывает внешний HTTP-вызов и может оставаться открытой значительно дольше необходимого;

  • скорость транзакции начинает зависеть от внешнего сервиса;

  • сетевой timeout увеличивает время жизни транзакции;

  • транзакция БД не обеспечивает атомарность между БД и внешним HTTP-сервисом;

  • если HTTP-вызов успешен, а сохранение в БД упало, внешний эффект уже нельзя откатить;

  • не показаны connect/read timeout для HTTP-клиента;

  • не определена обработка 4xx, 5xx, сетевых ошибок и timeout;

  • без идемпотентности retry для POST может повторить внешнюю операцию;

  • null или некорректный ответ внешнего сервиса нужно валидировать.

Как лучше исправить

Внешний вызов выполнить без транзакции, а транзакцию оставить только на операции с БД.

@Service
@RequiredArgsConstructor
public class RestClient {

    private final RemoteClient remoteClient;
    private final DbService dbService;

    public void doWork() {
        ResponseDto response = remoteClient.call();
        dbService.saveObj(response);
    }
}

Транзакционную границу можно разместить в отдельном Spring-бине:

@Service
@RequiredArgsConstructor
public class DbService {

    private final ObjRepository repository;

    @Transactional
    public void saveObj(ResponseDto response) {
        if (response == null) {
            throw new IllegalArgumentException(
                    "Response must not be null"
            );
        }

        repository.save(map(response));
    }
}

Это важно: вариант

public void doWork() {
    ResponseDto response = callRemote();
    save(response);
}

@Transactional
void save(ResponseDto response) {
    ...
}

в том же классе обычно не даст ожидаемой транзакции, потому что внутренний вызов save() обходит Spring proxy.

Для HTTP-клиента обязательно задать разумные connect/read timeout и отдельно обрабатывать сетевые ошибки и HTTP-ошибки.

Если внешний POST изменяет состояние, нужно учитывать сценарий:

HTTP успешно выполнен
сохранение в БД завершилось ошибкой
doWork() запускается повторно
HTTP-операция может выполниться второй раз

Поэтому для повторов нужен идемпотентный контракт внешнего API. Если используется Idempotency-Key, один и тот же логический запрос должен повторяться с тем же ключом — генерация нового UUID на каждую попытку проблему не решает.

Главная идея — не пытаться объединить HTTP и БД одной @Transactional: внешний вызов выполняется отдельно, а работа с БД помещается в короткую локальную транзакцию.