4. THE DANGERS OF CONCURRENCY
Before tackling the subject of locks,
it is important to understand concurrency. Database concurrency ensures
that when multiple operations are occurring at once, the final result
is still in agreement — that they concur. This agreement typically
depends on a set of rules and constraints that coordinate the behaviors
of transactions, making sure that different operations will play nicely
together.
Having considered the attributes of your
transactions that you need to protect, the following sections consider
the types of things that can happen if you let transactions have a
free-for-all environment — one where all the different transactions
don’t regard each other’s boundaries, where isolation is completely
ignored. Later, you’ll look at the various isolation levels in more
detail, but in this section if you look closely you’ll often see the
isolation level is set in the scripts.
The problems described next only occur when
multiple sessions are occurring at once in your database. This is
typical behavior, I’m sure, but it’s worth noting that in a
single-session environment, these problems won’t happen.
4.1 Lost Updates
A lost update occurs when two processes
read the same data and then try to update the data with a different
value. Consider a scenario in which you and your partner have the
romantic notion of a joint bank account. On pay day, your respective
employers both deposit your salaries into the joint account. To perform
the update, each process reads the data. At the time of the payments,
all is well in the world and you have an outstanding balance of
$10,000. Each process therefore reads $10,000 as its starting point.
Your employer attempts to update the $10,000 figure with your monthly
salary of $2,000, but at the same time your partner’s employer updates
the sum with his or her salary of $4,000. Your partner’s salary is
added just before yours, updating the $10,000 balance to $14,000. Your
payment then runs and updates the $10,000 balance to $12,000. A look at
the ATM shows $12,000. The first update has been lost, and even worse,
it represented the bigger update!
This situation is one that the SQL Server
platform handles automatically, regardless of the isolation level.
However, database developers can introduce this behavior themselves by
performing an update in two steps, rather than one. Consider this
example (code file Ch6LostUpdates.sql):
/* SESSION 1*/
USE AdventureWorks2012;
DECLARE @SafetyStockLevel int = 0
,@Uplift int = 5;
BEGIN TRAN;
SELECT @SafetyStockLevel = SafetyStockLevel
FROM Production.Product
WHERE ProductID = 1;
SET @SafetyStockLevel = @SafetyStockLevel = @Uplift;
WAITFOR DELAY '00:00:05.000';
UPDATE Production.Product
SET SafetyStockLevel = @SafetyStockLevel
WHERE ProductID = 1;
SELECT SafetyStockLevel
FROM Production.Product
WHERE ProductID = 1;
COMMIT TRAN;
Does it look OK? The developer has
wrapped the read and the write in an explicit transaction, but all this
scenario needs is for some concurrent activity and a lost update will
occur. The WAITFOR is only present to make it easier to detonate the code. In a separate session, have the following code ready:
/* SESSION 2*/
USE AdventureWorks2012;
DECLARE @SafetyStockLevel int = 0
,@Uplift int = 100;
BEGIN TRAN;
SELECT @SafetyStockLevel = SafetyStockLevel
FROM Production.Product
WHERE ProductID = 1;
SET @SafetyStockLevel = @SafetyStockLevel + @Uplift;
UPDATE Production.Product
SET SafetyStockLevel = @SafetyStockLevel
WHERE ProductID = 1;
SELECT SafetyStockLevel
FROM Production.Product
WHERE ProductID = 1;
COMMIT TRAN;
Now run Session 1; and then as soon as
you have executed it, click over to Session 2 and execute that code.
Session 2 should come back almost immediately showing that the
transaction has raised the safety stock level from 1,000 to 1,100 (see Figure 1).
If you return to Session 1, you should now be able to see that this
transaction has also completed, except that the Safety Stock Level has
gone from 1,000 to 1,005 (see Figure 2). The design of the transaction is flawed, causing an update to be lost.
What caused this loss? The developer wrote the
transaction in such a way that both sessions are able to read the data
and store the stock level in a variable. Consequently, when the update
is made, both transactions start with the same value. This is a
situation that should be avoided through more careful coding. Even
raising the isolation level does not resolve this particular problem,
which should be addressed by performing the addition as part of the
update operation, as shown here:
UPDATE Production.Product
SET SafetyStockLevel += @Uplift
WHERE ProductID = 1;
We know you are all too smart to code
your transactions in a way that could allow lost updates, but it does
show what can happen when insufficient consideration is given to the
transaction design. Interestingly, SQL Server enables the syntax to
support this behavior using the NOLOCK hint, although it is largely ignored.