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: внешний вызов выполняется отдельно, а работа с БД помещается в короткую локальную транзакцию.