Как защититься от двойного списания в многопоточном приложении?

Трата состоит из трех этапов. Чтение баланса пользователя, проверка хватает ли средств и собственно запись нового баланса. Запись и чтение баланса идет в бд. В моем случае постгрес.
Если в многопоточном приложении произойдет так что два потока прочитают баланс, проверят хватает ли средств на покупку и этот этап пройдет успешно для обоих, то далее последуют две записи, которые загонят юзера в минус. Какие есть варианты защиты от двойной траты?
Сам накидаю как думаю:
1 Сделать констрайнт на баланс юзера >0.
2 Поставить блокировку на чтение юзера на время работы первого успевшего процесса, второй пусть ждет. Есть проблема, что я не нашел блокировки строк, блокирующей на чтение. Если она есть, подскажите плиз

Какие еще есть варианты?

А вообще все гораздо сложнее т.к. баланс юзера расчитывается на лету и не хранится в юзере. Расчитывсется на основе истории его пополнений/расходов.

Доп вопрос. Как вообще лучше проектировать приложение, контролиоующее балансы пользователей? Писать баланс в юзера? Тогда как обеспечить консистентность на уровне базы? Писать триггеры при появлении/ изменении транзакции пересчитыаать баланс?
Или лучше каждый раз пересчитыапть из истории? У кого был опыт, поделитесь пожалуйста
И последний вопрос как писать тест таких случаев? Приложение на руби он рельсах
  • Вопрос задан
  • 946 просмотров
Решения вопроса 2
DevMan
@DevMan
Т - транзакции.
Ответ написан
@galaxy
Есть проблема, что я не нашел блокировки строк, блокирующей на чтение

SELECT FOR UPDATE
Ответ написан
Пригласить эксперта
Ответы на вопрос 6
@rPman
Блокировку во время траты уже сказали, но бывает что процесс может длиться достаточно долго, чтобы пользователь в соседнем окошке не смог совершить параллельно оплату (у него будет все висеть), поэтому блокировки реализуют программно

Добавь к аккаунту пользователя поле lockedBalance, в начале транзакции покупки добавляй к этому значению нужную сумму для траты (а по окончанию эту же вычитай как от сюда так и из общего баланса), соответственно итоговый баланс при проверке считай из разницы основного баланса и этого блокированного. Если транзакция сфейлится, это придется отслеживать, заблокированный баланс так же уменьшай на сумму сделки но не трогай общий.

более красиво, вместо одного поля, заводи специальную таблицу - текущие сделки, где в соответствии со статусом вычисляй этот заблокированный баланс (статусы сделка начата, сделка совершена или сделка обломилась), это актуально как раз на тот случай, когда сделка совершается достаточно долго чтобы не проводить ее в пределах транзакции базы данных (а то попадет пользователь на момент обслуживания к примеру перезапуск бд на обновление, и его транзакция пропадет), к тому же эта таблица у тебя уже точно есть, только статусов побольше добавить
Ответ написан
Комментировать
если проверка прошла, то обе выполнятся

Кто сказал что СУБД позволит выполнить обе транзакции с одними и теми же исходными данными?
Если обе транзакции начали исполняться параллельно, прочитали одни и те же данные, и пытаются их перезаписать, как СУБД будет себя вести? Позволит ли она вообще отработать обеим транзакциям? Или одна их них подождёт, пока не закончит работу другая? Вопрос гораздо интереснее, чем кажется. И, что самое главное, неглупые люди уже подумали над ним. Очень хорошо подумали.

В доках постгреса написано ещё лучше.

Или лучше каждый раз пересчитыапть из истории?

Запаритесь пересчитывать, это не масштабируется, сложность расчёта будет всё время расти. Если считаете, что можете накосячить с текущим балансом - сделайте возможность его пересчёта согласно истории пополнений/трат. Это называется денормализованными данными. Это один из тех случаев, когда оправдано применение хранимых процедур для актуализации таких данных. Т.е. вместо непосредственной записи одновременно и в историю пополнений/трат и в актуальный баланс прямо из приложения, вы вместо этого вызываете хранимую процедуру, которая атомарно как пишет новую операцию - это ваши основные данные - так и меняет нужным образом ваши денормализованные данные - т.е. ваш баланс. Заодно в этой же хранимке можно дополнительно проверить возможность списания. Это решение не очень хорошо масштабируется, и вообще хранимки это антипаттерн для современных модных-молодёжных распределённых приложений, но судя по вашим вопросам врядли вы отвечаете за разработку сервиса, где таких списаний десятки тысяч в секунду, так что вам хватит.

Вот на SO ещё предлагают много решений этой классической проблемы, ни одно из которых не является идеальным и лучшим для всех ситуаций.
Ответ написан
Комментировать
@Akina
Сетевой и системный админ, SQL-программист.
Трата состоит из трех этапов. Чтение баланса пользователя, проверка хватает ли средств и собственно запись нового баланса.

Странный подход. Достаточно на таблицу наложить ограничение неотрицательности баланса и безусловно выполнять операцию. Если средств недостаточно, ограничение сработает, сервер не выполнит изменения данных и вернёт ошибку. Один запрос, и никаких проблем с параллельным исполнением при правильно выбранном уровне изоляции по умолчанию.

баланс юзера расчитывается на лету и не хранится в юзере. Расчитывсется на основе истории его пополнений/расходов.

В этом случае - триггер, который посчитает актуальный баланс, проверит условие, и при недостатке средств сгенерирует ошибку. Хотя я бы всё же задумался о хранении актуального баланса, хотя бы на некий момент времени (скажем, на полночь, чтобы доподсчитывать только по операциям текущего дня, а не по всей истории). Редкие ошибки в нём, даже если их не удастся избежать, обойдутся дешевле, чем система, способная каждый раз считать баланс на лету и разводить конфликты.

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

Как раз уровень изоляции. При правильно выбранном уровне хрен чего второй прочитает, пока первый не завершит свою транзакцию и не отпустит ресурсы.
Ответ написан
@Vitsliputsli
Блокировки в базе - это всегда не очень хорошо, а с serializable производительность базы упадет в пол. Констрейнт как вариант, но поможет только для этой ситуации и получится, что логику перетащили в базу.
Самый оптимальный вариант, просто поделить работу между отдельными потоками, т.е. конкретного пользователя обслуживает конкретный поток и никакой другой, тогда гонки не будет в принципе. Но это если у вас архитектура позволяет.
Ответ написан
Комментировать
@sviato_slav
Добавь в таблицу поле version.
Делай все в транзакции (уровень не ниже - Read committed):
1. При выборке строки из базы сохрани значение version у себя в программе.
2. При апдейте делай так:
Update ... set balance = balance - сумма покупки, version=version + 1 where version = сохраненное значение версии.
3. Если запрос проходит успешно, то количество измененных строк = 1, если ничего не менялось = 0.
Все это называется - optimistic locking
Ответ написан
Комментировать
1) Использовать уровень изоляции транзакций: serializable
но производительность Update упадет, поэтому лучше делать замеры и смотреть, подходит или нет. Обычно Update-ов не так много как чтений.
Преимущество: не надо будет танцевать с бубном.

2) Сделать эту операцию через класс, который контролирует. Т.е. если мы делаем запрос второй раз, это класс смотрит, завершился ли первый.
А тут уже 2 варианта:
- ответить пользователю отказом (типо: "дождитесь окончания предыдущей операции")
- дождаться выполнения первой и приступить к выполнению второй. Но если после первой, баланс <0, то сказать об этом пользователю.
Ответ написан
Комментировать
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Войти через центр авторизации
Похожие вопросы