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

问题描述

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

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。 该表是否可能最终处于以下状态:

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

换句话说,余额是否有可能被双花?假设我们使用的是 sql 服务器。

解决方法

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

是的。

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

默认情况下,SQL Server 使用 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,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 中打开了两个窗口/连接/会话,并将您的代码放在每个窗口中。 (一个窗口更新了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 完成其事务后才继续。注意消息“第二次更新”后的时间戳。


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

消息 1205,级别 13,状态 51,第 13 行事务(进程 ID 55)是 与另一个进程在锁定资源上死锁并已被选择 作为僵局的受害者。重新运行事务。

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

会话 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 更新同一行时,才会出现冲突。

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

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

,

我已经用 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只锁定这些行,不锁定页或表。

我希望这个答案对你有用。