Какие есть методики для разделения и правильного функционирования параллельных операций начисления и списания
Что проверяют
Понимаете ли вы, что баланс живёт в базе, а сервис запущен в нескольких экземплярах, поэтому synchronized задачу не решает. Интервьюер ждёт разговора про блокировки в БД, идемпотентность и порядок захвата. Следом часто просят разобрать перевод между двумя счетами без дедлока.
Как отвечать
Сначала назовите угрозы: потерянное обновление (два потока прочитали 100 и оба записали своё), уход в минус при конкурентных списаниях, двойное списание при повторе запроса. Потом инструменты:
- атомарный условный UPDATE:
UPDATE account SET balance = balance - :sum WHERE id = :id AND balance >= :sum. Если обновилось 0 строк, денег не хватает. Проверка и изменение идут одной операцией, гонки нет; - пессимистическая блокировка:
SELECT ... FOR UPDATE, в Spring Data JPA через@Lock(LockModeType.PESSIMISTIC_WRITE). Надёжно при высокой конкуренции за одну строку, но транзакция держит блокировку дольше; - оптимистическая блокировка: поле
@Version, при конфликте Hibernate бросаетOptimisticLockException, операцию повторяют. Подходит, когда конфликты редки; - порядок захвата: при переводе блокируйте счета по возрастанию ID, тогда встречные переводы из A в B и из B в A не создадут дедлок;
- журнал проводок: баланс не меняют напрямую, а пишут неизменяемые записи в ledger по принципу двойной записи. Это даёт аудит и простое расследование спорных операций;
- идемпотентность: ключ операции с уникальным индексом, повтор того же запроса второй раз не спишет.
Уровень изоляции SERIALIZABLE тоже закрывает гонки, но ценой откатов и повторов транзакций. Суммы храните в BigDecimal или в копейках типа long.
Где спотыкаются
Предлагают synchronized или ReentrantLock. Они работают внутри одной JVM, а второй под сервиса о них ничего не знает.
Берут AtomicInteger для баланса и пишут проверку get() >= amount, а следом addAndGet(-amount). Между двумя вызовами другой поток успевает списать: атомарна каждая операция по отдельности, а не их пара. Нужен цикл с compareAndSet, а по-хорошему база.
Хранят деньги в double. Ошибки округления на копейках накапливаются, а в финансовом коде это уже инцидент.