Внутренний баланс пользователей, переводы между кошельками идут джобами Sidekiq. Если одновременно проходят перевод от А к Б и встречный от Б к А, раз в несколько дней ловлю:
PG::TRDeadlockDetected: ERROR: deadlock detected
DETAIL: Process 48211 waits for ShareLock on transaction 9120554; blocked by process 48207.
Process 48207 waits for ShareLock on transaction 9120551; blocked by process 48211.
Код перевода:
Wallet.transaction do
from = Wallet.lock.find(from_id)
to = Wallet.lock.find(to_id)
from.update!(balance: from.balance - amount)
to.update!(balance: to.balance + amount)
end
Ретрай джобы спасает, деньги не теряются, но хочу уйти от этого совсем. Rails 7.0, PostgreSQL 15.
Классика: две транзакции берут одни и те же две строки в разном порядке. Первая держит А и ждёт Б, вторая держит Б и ждёт А. Лечится тем, что блокировки всегда берутся в одном порядке, например по id:
Wallet.transaction do
wallets = Wallet.where(id: [from_id, to_id]).order(:id).lock.to_a
from = wallets.find { |w| w.id == from_id }
to = wallets.find { |w| w.id == to_id }
from.update!(balance: from.balance - amount)
to.update!(balance: to.balance + amount)
end
Получается SELECT ... ORDER BY id FOR UPDATE, строки блокируются по порядку, и встречный перевод просто подождёт первый.
Я бы ещё менял баланс не чтением и записью из Ruby, а одним UPDATE: Wallet.where(id: from_id).update_all(["balance = balance - ?", amount]). Блокировка держится меньше, окно для гонки тоже. Но порядок всё равно нужен, тут без вариантов.
Сделала сортировку по id и перенесла проверку баланса прямо в UPDATE, с условием balance >= amount: если обновилось ноль строк, значит денег не хватило, и транзакция откатывается. Две недели под нагрузкой, ни одного дедлока, раньше было по 3-4 в неделю. Ретраи оставила как страховку.
Сохранил. На Laravel ловил ровно то же самое, только там ещё и ретраев не было