两个 SQL 事务是否可以在读取时交错?

Is it possible for two SQL transactions to interleave on read?

我正在尝试了解 ACID 属性以及它们如何影响我们对 ACID 数据库中并发性的看法。假设我有一个 table accountsaccount_idbalance 字段,并且我在数据库中有三行:

account_id | balance
-----------|--------
         1 | 100
         2 | 0
         3 | 0

现在假设我 运行 同时进行以下交易:

start transaction;
if (select balance from accounts where account_id = 1) >= 100 {
    update accounts set balance = 100 where account_id = 2;
    update accounts set balance = 0 where account_id = 1;
}
commit transaction;

start transaction;
if (select balance from accounts where account_id = 1) >= 100 {
    update accounts set balance = 100 where account_id = 3;
    update accounts set balance = 0 where account_id = 1;
}
commit transaction;

请注意,第一个更新帐户 2,第二个更新帐户 3。 table 是否有可能以以下状态结束:

account_id | balance
-----------|--------
         1 | 0
         2 | 100
         3 | 100

也就是说,余额有没有可能被双花了?假设我们正在使用 SQL 服务器。

我已经使用 SQL Server v14.0(使用 SSMS)测试了这个答案。它的工作,如果两个事务具有相同的配置:

第一笔交易(锁定一个,使用 waitfor 给我时间测试):

BEGIN TRAN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT * FROM accounts WITH (XLOCK, ROWLOCK) WHERE account_id = 1;
WAITFOR DELAY '00:00:10';
UPDATE accounts SET balance = 0 WHERE account_id = 1;
COMMIT TRAN;

第二个事务(在本例中锁定一个,只读):

BEGIN TRAN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT * FROM accounts WITH (XLOCK, ROWLOCK) WHERE account_id = 1;
COMMIT TRAN;

如果第一个还在运行,则等待第二个。也许您可以调整这些代码以供使用(问题未提供有关您正在使用的系统/语言的信息)。

这里的关键是:首先,设置隔离级别。其次,在 select 上设置 XLOCK(独占锁),另外,ROWLOCK 只锁定这些行,没有页面或 table.

希望这个回答对你有用。

Is it possible for two SQL transactions to interleave on read?

是的。

不同的 DBMS 可能使用不同的方法来处理并发。酸不是全部。 SQL 标准还定义了几个所谓的事务隔离级别。这些级别及其在特定 DBMS 中的实现(行版本控制或锁定)将定义您的示例中发生的情况。

默认情况下 SQL 服务器使用 READ COMMITTED transaction isolation level 没有行版本控制。

在这个级别很容易双花账户余额。

我在下面的代码中使用了这个帮助存储过程来在 SSMS 消息窗格中打印消息:

CREATE PROCEDURE [dbo].[DebugPrintMessage]
    @ParamMessage nvarchar(4000)
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    -- Escape the % symbol in the message, if it is there
    SET @ParamMessage = REPLACE(@ParamMessage, '%', '%%');

    -- Prepend message with the current timestamp to a second precision
    SET @ParamMessage = CONVERT(nvarchar(19), SYSDATETIME(), 121) + ' ' + @ParamMessage;

    RAISERROR (@ParamMessage, 0, 1) WITH NOWAIT;

    -- PRINT command does not send the message to the client until its buffer is full, or the batch ends
    -- RAISERROR () WITH NOWAIT sends a message immediately

END
GO

我在 SSMS 中打开了两个 windows/connections/sessions 并将您的代码放入每个 window 中。 (一个window更新ID=2,另一个ID=3,这里不再赘述)

EXEC dbo.DebugPrintMessage 'waiting to start';
-- round up the current time to the next 30 seconds
DECLARE @StartDateTime datetime2(0) = SYSDATETIME();
SET @StartDateTime = DATEADD(second, (DATEDIFF(second, '2020-01-01', @StartDateTime) / 30 + 1) * 30, '2020-01-01');
DECLARE @StartTimeString varchar(8);
SET @StartTimeString = CONVERT(varchar(8), @StartDateTime, 108);
WAITFOR TIME @StartTimeString;

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
--SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
--SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRANSACTION;
EXEC dbo.DebugPrintMessage 'began transaction';
IF (select balance from accounts where ID = 1) >= 100
BEGIN
    EXEC dbo.DebugPrintMessage 'waiting 2 sec';
    WAITFOR DELAY '00:00:02';

    EXEC dbo.DebugPrintMessage 'first update';
    UPDATE dbo.Accounts
    SET Balance = 100
--  WHERE ID = 2
    WHERE ID = 3
    ;

    EXEC dbo.DebugPrintMessage 'second update';
    UPDATE dbo.Accounts
    SET Balance = 0
    WHERE ID = 1
    ;
END
EXEC dbo.DebugPrintMessage 'waiting for review';
WAITFOR DELAY '00:01:00';
EXEC dbo.DebugPrintMessage 'committing';
COMMIT;

两笔交易都成功完成,最后两个账户的余额都是100。

输出显示了它是如何执行的:

会话 1

2021-03-29 19:12:13 waiting to start
2021-03-29 19:12:30 began transaction
2021-03-29 19:12:30 waiting 2 sec
2021-03-29 19:12:32 first update

(1 row affected)
2021-03-29 19:12:32 second update

(1 row affected)
2021-03-29 19:12:32 waiting for review
2021-03-29 19:13:32 committing

第 2 节

2021-03-29 19:12:11 waiting to start
2021-03-29 19:12:30 began transaction
2021-03-29 19:12:30 waiting 2 sec
2021-03-29 19:12:32 first update

(1 row affected)
2021-03-29 19:12:32 second update

(1 row affected)
2021-03-29 19:13:32 waiting for review
2021-03-29 19:14:32 committing

我们可以看到会话 1 在没有任何额外等待的情况下完成了两次更新。会话 2 与 S1 一起执行第一次更新,但在第二次更新时等待第一个会话提交(因为它试图更新 ID=1 的同一行)。 S2 仅在 S1 完成其事务后才继续。注意消息“second update”后的时间戳。


然后我尝试将事务隔离级别设置为 REPEATABLE READSERIALIZABLE 的示例。一项交易已完成,另一项交易已中止,并显示此消息:

Msg 1205, Level 13, State 51, Line 13 Transaction (Process ID 55) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.

因此,在这些更严格的事务隔离级别下,没有双花。

会话 1

2021-03-29 19:27:12 waiting to start
2021-03-29 19:27:30 began transaction
2021-03-29 19:27:30 waiting 2 sec
2021-03-29 19:27:32 first update

(1 row affected)
2021-03-29 19:27:32 second update

(1 row affected)
2021-03-29 19:27:32 waiting for review
2021-03-29 19:28:32 committing

第 2 节

2021-03-29 19:27:10 waiting to start
2021-03-29 19:27:30 began transaction
2021-03-29 19:27:30 waiting 2 sec
2021-03-29 19:27:32 first update

(1 row affected)
2021-03-29 19:27:32 second update
Msg 1205, Level 13, State 51, Line 27
Transaction (Process ID 55) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.

注意这里更严格的事务隔离级别并没有阻止两个事务读取同一行两次。 SELECT 在两个会话中都顺利完成。它并没有阻止他们更新不同的行。只有当它要更新与ID=1相同的行时,才会出现冲突。

一般来说,最好在更新另一个帐户的余额之前尝试用 balance = 0 更新 ID = 1。如果交换 UPDATE 语句,其中一个事务将更快中止并且回滚的工作会更少。

或者,在 SELECTsp_getapplock 上使用锁定提示或一些其他方法来避免并发。