2

I noticed that my applications often write values to a database that depend on a former read operation. A common example is a bank account where a user could deposit money:

void deposit(amount) {
    balance = getAccountBalance()
    setAccountBalance(balance + amount)
}

I want to avoid a race condition if this method is called by two threads/clients/ATMs simultaneously like this where the account owner would lose money:

balance = getAccountBalance()       |
                                    | balance = getAccountBalance()
setAccountBalance(balance + amount) |
                                    | // balance2 = getAccountBalance() // theoretical
                                    | setAccountBalance(balance + amount)
                                    V

I often read that Repeatable Read or Serializable can solve this problem. Even the german Wikipedia article for Lost Updates states this. Translated to english:

The isolation level RR (Repeatable Read) is often mentioned as a solution to the lost update problem.

This SO answer suggests Serializable for a similar problem with INSERT after SELECT.

As far as I understood the idea - at the time the process on the right side tries to set the account balance, a (theoretical) reading operation wouldn't return the same balance anymore. Therefore the write operation is not allowed. And yes - if you read this popular SO answer, it actually sounds perfectly fitting:

under REPEATABLE READ the second SELECT is guaranteed to display at least the rows that were returned from the first SELECT unchanged. New rows may be added by a concurrent transaction in that one minute, but the existing rows cannot be deleted nor changed.

But then I wondered what "they cannot be deleted nor changed" actually means. What happens if you try to delete/change it anyway? Will you get an error? Or will your transaction wait until the first transaction finished and in the end also perform its update? This makes all the difference. In the second case you will still lose money.

And if you read the comments below it gets even worse, because there are other ways to meet the Repeatable Read conditions. For example a snapshot technology: A snapshot could be taken before the left side transaction writes its value and this allows to provide the original value if a second read occurs later in the right side transaction. See, for instance, the MySQL manual:

Consistent reads within the same transaction read the snapshot established by the first read

I came to the conclusion that restricting the transaction isolation level is probably the wrong tool to get rid of the race condition. If it solves the problem (for a specific DBMS), it's not due to the definition of Repeatable Read. Rather it's because of a specific implementation to fulfil the Repeatable Read conditions. For instance the usage of locks.

So, to me it looks like this: What you actually need to solve this issue is a locking mechanism. The fact that some DBMS use locks to implement Repeatable Read is exploited.

Is this assumption correct? Or do I have a wrong understanding of transaction isolation levels?


You might be annoyed, because this must be the millionth question about the topic. The problem is: The example bank account scenario is absolutely critical. Just there, where it should be absolutely clear what's going on, it seems to me as if there is so much misleading and contradictory information and misconceptions.

fishbone
  • 3,140
  • 2
  • 37
  • 50
  • For the specific bank situation, that's why they generally record the (banking) *transactions*. Separate transactions are recorded as separate rows and the balance itself may effectively be a computed value. – Damien_The_Unbeliever Mar 03 '22 at 10:09
  • The bank account is only an example. But anyway I wonder if you still want to compute the value if you have millions of transfers. Also, I don't say that the UPDATE-problem is not solveable. You can for instance implement "optimistic locks". I just want to confirm that high isolation levels are NOT an actual solution, although often propagated otherwise. – fishbone Mar 03 '22 at 10:44

3 Answers3

3

The problem here is that you are asking what Isolation Level, as defined by the SQL Standard, is needed to sort out a concurrency anomaly that is not part of this definition.

SQL Standard only defines how Isolation Levels (Read Uncommited, Read Commited, Repeatable Read, Serializable) map to Dirty Read, Non-Repeatable Read and Phantom Read anomalies. No mention of Lost-Update, so this - as you rightly pointed out - depends on how isolation levels are implemented by a specific DBMS.

Supposedly REPEATABLE_READ is enough to prevent Lost-Update on PostgreSQL and SERIALIZABLE is needed to prevent it on MySQL and Oracle.

Here are some interesting posts about Oracle and PostgreSQL/MySQL

Tomek Samcik
  • 526
  • 4
  • 7
  • I am shocked how many misinformation there is and how dangerous such problems like lost update are on the other hand. And then I suppose that most of the time devs actually falsely think they solved the problem, as those problems only appear rarely, although they actually created even bigger problems (e.g. dead locks) – fishbone Dec 21 '22 at 12:09
  • 1
    That's exactly true, very few people try to dig deeper to get a better understanding what guarantees are actually given and it's not easy to find out about those things. Most simply assume that transaction solves all concurrency issues that may ever occur. They may be surprised when they least expect it. – Tomek Samcik Dec 22 '22 at 13:17
0

Lost update is a transactional anomaly that occurs only if the transaction use optimistic locking. It will never happen in pessimistic locking.

  • Some RDBMS offers optimistic locking only, which is the case for Oracle Database and PostGreSQL
  • Some other RDBMS offers only pessimistic locking, which is the case of IBM DB2
  • And finally Microsoft SQL Server is able to alternately use optimistic or pessimistic locking depending on the user's choice, with a default behavior that is pessimistic

So the questions must be facing which RDBMS do you use and which type of locking do you have...

Some more informations...

Guaranteeing the successful completion of a transaction that performs writes is only possible if one starts by locking in exclusive mode, while maintaining the locks for the duration of the transaction by ensuring that the lock mode is pessimistic and not optimistic. Despite this, this technique will not prevent deadlocks...

The mathematician Edsger Dijkstra solved this last problem (Banker's algorithm) by showing that it is necessary, before starting to update the data (INSERT, UPDATE, DELETE...), set all the locks necessary to protect the data handled, which amounts to having only exclusive access to all the processing data... Dijkstra win the Turing award for this contribution to computer science !

In other words, having only one user who accesses the database! ...

To summerize...

Transactional anomalies and what does avoid when using an isolation level is given with the following table:

Isolation level and transactional anomalies

SQLpro
  • 3,994
  • 1
  • 6
  • 14
  • Well actually I indeed have a similar problem on SQL Server with unexpected INSERTs which are caused by two concurrent threads. I probably need to write another question. My question here is whether the isolation level is a solution at all. You're answer also sounds as if locking is the solution, not the isolation itself. Then I'd rather try to lock the resources at the first read, instead of hoping that a high isolation level will create the locks that I need. – fishbone Mar 03 '22 at 12:07
  • My answer to your remark as an edit to my original post... – SQLpro Mar 03 '22 at 12:58
  • So can you confirm that the isolation level is not the solution for the SELECT...UPDATE race condition problem, but rather the fact that the isolation levels are realized by using locking techniques? – fishbone Mar 04 '22 at 11:32
  • It's not clear what you mean by "optimistic", or how that would apply to REPEATABLE READ or SERIALIZABLE. – David Browne - Microsoft Mar 06 '22 at 17:14
  • https://stackoverflow.com/questions/129329/optimistic-vs-pessimistic-locking – SQLpro Mar 08 '22 at 08:41
0

In SQL Server both REPEATABLE READ and SERIALIZABLE will prevent the lost update by failing one transaction with a deadlock. In these isolation levels each session will take and hold a shared (S) lock on the target row during the initial SELECT. Then each session will try to get an exclusive (X) lock on the row to update it, causing a deadlock.

If you want to avoid the lost update without by having one session wait until the other has completed you must create a more exclusive lock before or during the initial select. The normal pattern for this is to add an UPDLOCK hint to the initial select to indicate a "select for update". And with "select for update" there's no reason to raise the transaction isolation level.

Oracle and PostgreSQL also have "select for update" syntax you can use.

David Browne - Microsoft
  • 80,331
  • 6
  • 39
  • 67
  • My actual problem is an INSERT depending on a SELECT. I'd like to block or fail a SELECT attempt from a concurrent transaction. I don't know how FOR UPDATE handels non (yet) existing entries. Afaik FOR UPDATE will lock rows or pages. But optimally I'd like to disallow/block concurrent SELECTs on the same key but allow SELECT for other keys (even if the rows do not exist yet). Also did you really mean "deadlock" or just a waiting lock? We have to avoid dead locked hanging threads by any means. Regarding my question - then I understand you confirm that isolation level is not the actual solution? – fishbone Mar 04 '22 at 11:29
  • Add HOLDLOCK to also take key range locks, which will create the U lock on empty key ranges to block the second SELECT. – David Browne - Microsoft Mar 04 '22 at 13:15
  • Lost update transational anomaly can only be avoided if the locking mode is pessimistic. In optimistic locking model, there is no silver bullet to avoid lost update transactional anomaly... – SQLpro Mar 06 '22 at 16:39
  • Sure there is. Client-side timestamp/rowversion is the most common. – David Browne - Microsoft Mar 06 '22 at 16:40