Нужно ли что-то делать чтобы избежать потери операций при одновременном начислении и оплате в базе данных
Что проверяют
Знаете ли вы про потерянное обновление и понимаете ли, что связка «прочитал остаток, посчитал в коде, записал» ломается при первой же параллельной операции. Смотрят и на инструменты: транзакции, уровни изоляции, блокировки, идемпотентность.
Как отвечать
Делать нужно, само по себе это не работает. Уровень READ COMMITTED, стоящий по умолчанию в PostgreSQL и Oracle, от потерянного обновления не защищает: две транзакции прочитают один и тот же остаток, и вторая запись затрёт первую. Рабочие варианты:
- атомарный апдейт:
update accounts set balance = balance + ? where id = ?, база сама выстроит конкурирующие изменения строки в очередь; - пессимистическая блокировка:
select ... for update, в JPA этоLockModeType.PESSIMISTIC_WRITE, если считать всё же нужно в коде; - оптимистическая блокировка: поле
@Version, при конфликте прилетаетOptimisticLockExceptionи операция повторяется; - ограничение в схеме:
check (balance >= 0)не даёт уйти в минус, даже когда в коде ошибка.
Поверх этого нужна идемпотентность: у операции есть внешний идентификатор с уникальным индексом, поэтому повторная доставка запроса не начислит дважды. А если важен разбор расхождений, остаток считается по журналу операций, и поле с балансом становится кэшем, а не единственной правдой.
Где спотыкаются
Предлагают SERIALIZABLE как универсальное решение. Он действительно закрывает проблему, но платой становятся откаты: приложение обязано ловить ошибку сериализации и повторять транзакцию, а этого кода обычно нет.
Считают, что достаточно навесить @Transactional. Транзакция даёт атомарность и откат, но не мешает двум параллельным чтениям увидеть одинаковый остаток. Атомарность и изоляция это разные свойства.
Держат блокировку на время внешнего вызова: заблокировали строку и пошли в платёжный шлюз. Строка занята секундами вместо миллисекунд, а если при переводе между счетами блокировки берутся в разном порядке, транзакции встают в дедлок.