Portal
Spring Boot в продакшене: гонки данных, Outbox и пул соединений в платёжной системе

Spring Boot в продакшене: гонки данных, Outbox и пул соединений в платёжной системе

Блоки кода в статье оформлены как тёмная IDE — их можно выделить и скопировать; на диаграммах никакого текста кода нет, это просто иллюстрации.

На локальной машине всё работает как часы: юнит- и интеграционные тесты проходят зелёным, и даже при небольшом числе одновременных пользователей никаких проблем не видно. Но стоит системе выйти в продакшен и начать принимать тысячи параллельных реальных платёжных запросов, как на бэкенде начинают всплывать неожиданные каскадные сбои.

При переходе от монолита к распределённым микросервисам самое сложное обычно не бизнес-логика, а сетевые задержки, согласованность данных и управление ресурсами. В этой статье я разберу три серьёзные проблемы, с которыми мы столкнулись в реальном продакшене, и расскажу, как мы решили их в системе на Spring Boot.

1. Гонки состояний и стратегии блокировки: проблема двойного списания

Что происходит, если один и тот же пользователь из-за нестабильного интернета дважды подряд жмёт кнопку «Оплатить», или когда два разных микросервиса обращаются к балансу одного и того же пользователя буквально в одну и ту же миллисекунду?

Представим, что на балансе пользователя $100, и он отправляет два отдельных запроса по $50. Оба потока одновременно читают из базы данных одно и то же значение — $100. Оба проверяют условие (баланс >= 50), оба проходят проверку, и оба потока списывают по $50 и записывают результат обратно в базу. Итог: пользователь начинал со $100, купил товаров на все $100, но в базе почему-то остаётся $50 вместо $0 — это и есть проблема двойного списания.

Чтобы решить эту проблему, мы сравнили две основные стратегии блокировки в JPA:

  • Оптимистичная блокировка (@Version): добавляет в таблицу столбец с версией. При чтении ничего не блокируется, версия проверяется только при записи. Это очень быстро при небольшой нагрузке. Но когда тысячи параллельных запросов одновременно бьют по одному и тому же балансу, начинается лавина исключений OptimisticLockException, и большинство пользовательских запросов просто отклоняется.

  • Пессимистичная блокировка (SELECT FOR UPDATE): именно этот подход мы в итоге выбрали для финансовых транзакций. Добавив аннотацию @Lock(LockModeType.PESSIMISTIC_WRITE) в JPA-репозитории, мы обеспечиваем блокировку строки на уровне базы данных:

public interface WalletRepository extends JpaRepository<Wallet, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT w FROM Wallet w WHERE w.userId = :userId")
    Optional<Wallet> findByUserIdWithLock(@Param("userId") Long userId);
}

Как только первый поток завершает работу и коммитит транзакцию, все остальные потоки стоят в очереди на уровне базы данных до этого момента. В результате баланс обновляется точно и последовательно, без гонок.

Иллюстрация 1. Момент возникновения гонки состояний и механизм пессимистичной блокировки (SELECT FOR UPDATE).

2. Распределённые транзакции и паттерн Transactional Outbox

После успешного завершения платежа система должна уведомить другие сервисы (например, сервис уведомлений или выставления счетов), опубликовав событие PaymentCompletedEvent в Kafka. В самой первой версии мы допустили классическую ошибку:

@Transactional
public void processPayment(PaymentRequest request) {
    Wallet wallet = walletRepository.findByUserIdWithLock(request.getUserId());
    wallet.deduct(request.getAmount());
    walletRepository.save(wallet); // 1. Обновляем баланс в БД
    kafkaTemplate.send("payment-topic", new PaymentEvent(wallet.getId())); // 2. Отправляем сообщение в Kafka
}

Такой подход несёт в себе серьёзный риск — классическую проблему двойной записи (Dual-Write Problem):

  • Если транзакция в базе данных закоммитилась, а сообщение из-за сетевой ошибки не дошло до Kafka, платёж прошёл, но уведомление никогда не отправится.

  • Если сообщение в Kafka успешно ушло, а транзакция в базе данных сразу после этого упала и откатилась, деньги фактически не списаны, но другие сервисы уже уверены, что платёж прошёл.

Решение мы нашли, применив паттерн Transactional Outbox:

  • Вместо прямой публикации в Kafka мы записываем событие в отдельную outbox-таблицу в рамках той же транзакции с базой данных.

  • Отдельный асинхронный фоновый воркер (или Debezium CDC) читает новые записи из outbox-таблицы и надёжно доставляет их в Kafka (с гарантией доставки at-least-once).

  • После успешной доставки сообщения в Kafka соответствующая запись в outbox помечается как PROCESSED.

Иллюстрация 2. Паттерн Transactional Outbox — запись в outbox в рамках транзакции с БД и асинхронная пересылка в Kafka.

3. Пул соединений HikariCP и долгие транзакции

Одна из самых критичных проблем, с которой мы столкнулись при высокой нагрузке, — ошибка Connection is not available, request timed out after 30000ms.

Разобравшись в первопричине, мы выяснили, что разработчики вызывали внешний REST API банка прямо внутри метода, помеченного аннотацией @Transactional.

Пока идёт сетевой запрос к банку, соединение с базой данных остаётся занятым и удерживается этим потоком — оно не возвращается в пул. При высокой нагрузке все соединения в пуле HikariCP быстро оказываются заняты такими «зависшими» транзакциями, и новые запросы просто не могут получить свободное соединение, упираясь в таймаут.

Решение — разделить ответственность: вызов внешнего API нужно выносить за пределы транзакционного блока, а транзакцию с базой данных делать максимально короткой и охватывать только реальные операции с данными. Так соединения возвращаются в пул сразу же после завершения работы с базой, а не простаивают в ожидании ответа от внешней системы.

Нравится эта статья

Нравится 1 человеку понравилось

Поделиться статьёй

1 человек прочитал эту статью

Готовы начать учиться? Смотреть карьерные пути
Напишите нам в WhatsApp