Это совпадает с тем, что вижу я. Одна оговорка по варианту «сначала блокировка»: FOR UPDATE должен быть отдельным запросом перед INSERT. Если засунуть его в CTE того же запроса, снапшот уже снят в начале запроса, так что сумма всё равно может оказаться устаревшей. Если же это отдельный запрос, INSERT получает свежий снапшот после того, как блокировка получена, и видит то, что закоммитило предыдущее снятие.
SELECT 1 FROM accounts WHERE id = $1 FOR UPDATE;
-- then the INSERT ... SELECT as before
Блокировка держится до коммита или отката, так что транзакцию лучше делать короткой. Не делайте сетевых вызовов между блокировкой и коммитом.
Сначала выполнить UPDATE баланса тоже работает, но только потому, что он заодно берёт ту же блокировку строки. Это побочный эффект, так что я бы лучше написал блокировку явно, чтобы следующий человек её видел.
Это же объясняет, почему с MaxConns=1 выходит ровно 5. Одно соединение, значит, блокировкой служит сам пул.
Раздел про Read Committed, где прямо описан снапшот на каждый запрос: https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED
Переведено с английского·Показать оригиналThat matches what I see. One caveat on the lock-first fix: the FOR UPDATE has to be its own statement before the INSERT. If you fold it into a CTE of the same statement, the snapshot was already taken at statement start, so the sum can still be stale. As a separate statement, the INSERT gets a fresh snapshot after the lock is granted, and it sees what the previous withdrawal committed.
SELECT 1 FROM accounts WHERE id = $1 FOR UPDATE;
-- then the INSERT ... SELECT as before
The lock is held until commit or rollback, so keep the transaction short. Don't do network calls between the lock and the commit.
Running the balance UPDATE first also works, but only because it happens to take the same row lock. It's a side effect, so I'd rather write the lock out where the next person can see it.
This also explains why MaxConns=1 gives exactly 5. One connection means the pool is the lock.
The Read Committed section here spells out the per-statement snapshot: https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED