SQL Server und RCSI

SQL Server

Als erstes möchte ich das ganze bei SQL Server ausprobieren. Dazu starten wir eine Instanz in Docker:

version: "3.8"

services:
  sqldata:
    image: mcr.microsoft.com/mssql/server:2017-latest
    environment:
      - SA_PASSWORD=Pass@word
      - ACCEPT_EULA=Y
    ports:
      - "5434:1433"

Danach erstellen wir eine Tabelle:

CREATE TABLE master.dbo.Persons (
  PersonID int NOT NULL PRIMARY KEY IDENTITY,
  LastName varchar(255) NOT NULL  
);

Anschließend fügen wir einen Datenatz ein:

INSERT INTO master.dbo.Persons (LastName) OUTPUT INSERTED.PersonID VALUES('Mustermann');

Macht man nun in Transaktion 1:

BEGIN TRANSACTION;

UPDATE master.dbo.Persons SET LastName = 'Neu' WHERE PersonId = 1;

Und in Transaktion 2:

SELECT * FROM master.dbo.Persons

so, wird Transaktion 2 blockieren.

Eine ausführliche Erklärung findet man hier: https://www.brentozar.com/archive/2013/01/implementing-snapshot-or-read-committed-snapshot-isolation-in-sql-server-a-guide/

Kontrollieren kann man es mit:

SELECT 
  tl.request_session_id,
  wt.blocking_session_id,
  OBJECT_NAME(p.object_id) AS TableName,
  resource_type,
  request_mode,
  request_type
FROM 
  sys.dm_tran_locks tl
JOIN 
  sys.partitions p ON tl.resource_associated_entity_id = p.hobt_id
LEFT JOIN 
  sys.dm_os_waiting_tasks wt ON tl.lock_owner_address = wt.resource_address

Was folgendes ausgibt:

request_session_id|blocking_session_id|TableName|resource_type|request_mode|request_type|
------------------+-------------------+---------+-------------+------------+------------+
                53|                   |Persons  |PAGE         |IX          |LOCK        |
                55|                   |Persons  |PAGE         |IS          |LOCK        |
                53|                   |Persons  |KEY          |X           |LOCK        |
                55|                 53|Persons  |KEY          |S           |LOCK        |

Die Lock compatibility sieht man hier. D.h. Exklsuive (X) blockiert alles.

DBCC USEROPTIONS;
Set Option             |Value         |
-----------------------+--------------+
...
isolation level        |read committed|

Snapshot Isolation in SQL Server

Dies erlaubt es, dass ein SELECT nicht blockiert und den Wert bekommt, der zum Beginn der Transaktion gültig war. Gut erklärt wird es in SQL Server Quickie #30 - Snapshot Isolation.

Transaction 1:

BEGIN TRANSACTION;

UPDATE master.dbo.Persons SET LastName = 'Neu' WHERE PersonId = 1;

SELECT * FROM master.dbo.Persons
PersonID|LastName  |
--------+----------+
       1|Neu       |
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;

BEGIN TRANSACTION;

SELECT * FROM master.dbo.Persons
PersonID|LastName  |
--------+----------+
       1|Mustermann|

Der alte Wert in den Versionstore in tempdb gespeichert.

Würde man nun Transaktion 1 commiten, so bleibt der Wert in Transaktion 2 gleich - Readstability.

Was ist dann aber RCSI?

Auch hier ist das Ziel, dass der Reader nicht blockiert wird.

  • Read Commited Snapshot Isolation (RCSI)

    • Ändert das default Isolation Level der ganzen Datenbank
    • Statement Level Consistency
  • Snapshot

    • Transaction level consistency

RCSI wird wie folgt eingeschaltet:

ALTER DATABASE my_database SET READ_COMMITTED_SNAPSHOT ON

Der Unterschied ist also, dass man gleich wie beim Snapshot keine Dirty-Reads hat - aber: wenn die 1. Transaktion commited, dann sieht man den neuen Wert plötzlich in Transaktion 2 - also keine Readstability. Eine genau Erklärung gibt es hier.

Erste Probleme

Blockieren ist straight forward - aber natürlich schlecht für die Performance. Folgendes Beispiel zeigt aber ein neues Problem:

ALTER DATABASE Test SET ALLOW_SNAPSHOT_ISOLATION ON  
GO

USE Test
GO

DROP TABLE IF EXISTS Test
GO

CREATE TABLE Test 
(
  Id int NOT NULL identity(1,1) PRIMARY KEY,
  BusinessDate date NOT NULL,	
  BusinessDateYear AS YEAR(BusinessDate) PERSISTED,
  BusinessKeyId int,
  BusinessKey AS CONCAT(YEAR(BusinessDate), '-', BusinessKeyId) PERSISTED	 
)
GO

CREATE OR ALTER TRIGGER TR_Test_GenerateBusinessKey
ON Test
AFTER INSERT
AS
WITH records AS
(
  SELECT
    COALESCE(BusinessKeyId,
      COUNT(CASE WHEN BusinessKeyId IS NULL THEN 1 END) OVER (PARTITION BY BusinessDateYear ORDER BY Id)
      + COALESCE(MAX(BusinessKeyId) OVER (PARTITION BY BusinessDateYear), 0)) AS GeneratedBusinessKeyId,
    Id,
    BusinessKeyId
  FROM Test 
)
UPDATE records
  SET records.BusinessKeyId = records.GeneratedBusinessKeyId
  FROM records INNER JOIN inserted 
  ON records.Id = inserted.Id;
GO

Ziel war es, dass pro Jahr ein Identifier gemacht wird - beginnend immer bei 1. Also 2023-1 bis 2023-n und dann wieder 2024-1 bis 2024-m.

Transaktion 1:

SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRANSACTION;
  INSERT INTO test (BusinessDate)
    SELECT ('2024-04-21') FROM GENERATE_SERIES(1, 10)

Transaktion 2:

SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRANSACTION;
  INSERT INTO Test (BusinessDate) VALUES
    ('2024-04-25'), ('2024-04-26'), ('2024-04-27');
  COMMIT
 GO

Transaktion 1:

COMMIT
GO

SELECT * FROM Test
Id | BusinessDate | BusinessDateYear | BusinessKeyId |	BusinessKey |
---+--------------+------------------+---------------+--------------|
1  | 2024-04-21	  | 2024             | 1             | 2024-1       |
2  | 2024-04-21	  | 2024             | 2             | 2024-2       |
3  | 2024-04-21	  | 2024             | 3             | 2024-3       |
4  | 2024-04-25	  | 2024             | 1             | 2024-1       |
5  | 2024-04-26	  | 2024             | 2             | 2024-2       |
6  | 2024-04-27	  | 2024             | 3             | 2024-3       |

Das ist natürlich falsch. Beheben kann man das Prolem aber sehr einfach:

WITH records AS
(
  SELECT
    COALESCE(BusinessKeyId, COUNT(CASE WHEN BusinessKeyId IS NULL THEN 1 END) OVER (PARTITION BY BusinessDateYear ORDER BY Id)
    + COALESCE(MAX(BusinessKeyId) OVER (PARTITION BY BusinessDateYear), 0)) AS GeneratedBusinessKeyId,
    Id,
    BusinessKeyId
  FROM Test 
  WITH(TABLOCK)
)

Dann Verhalten sich die Transaktionen so, als würde man sie sequentiell durchführen. Achtung: Das Ganze blockiert auch bei READ COMMITTED. Aber: Wenn man sich die Locking Tabelle anschaut, so wird nur blockiert, weil Transaktion A auf eine Row der Transaktion B einen Update-Lock machen will.

SQL Server uses another lock type, update (U) locks, during data modifications, acquiring them while searching for the rows that need to be updated.

Ich verstehe nicht ganz warum, da ja nur auf die einzufügenden upgedated wird.

Ebenfalls hilft:

CREATE UNIQUE INDEX IX_Test_BusinessDateYear_BusinessKeyId ON Test(BusinessDateYear, BusinessKeyId) WHERE BusinessKeyId IS NOT NULL;

Siehe auch https://sqlperformance.com/2014/04/t-sql-queries/the-serializable-isolation-level

PostgreSQL

Macht man folgendes in T1:

DROP TABLE IF EXISTS Persons CASCADE;

CREATE TABLE Persons (
  id BIGINT PRIMARY KEY,
  LastName varchar(255) NOT NULL  
);

INSERT INTO Persons (Id, LastName) VALUES(1, 'Mustermann');

Dann in T2:

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN TRANSACTION;
  SELECT * FROM persons

Sieht man Mustermann.

Macht man in T1:

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN TRANSACTION;
  UPDATE Persons SET LastName = 'Lutz' WHERE id = 1;

Kann man in T2 noch immer ein SELECT machen - es blockiert nicht:

SELECT * FROM persons

Man sieht noch immer Mustermann. Erst wenn man T1 commited, sieht man in T2 den neuen Namen (Repeatable Read geht nicht).

Siehe auch https://www.postgresql.org/files/developer/concurrency.pdf

Serializable

Wie man aus der Präsentation von Tom Lane entnehmen kann, funktioniert folgendes Beispiel in PostgreSQL nicht:

DROP TABLE IF EXISTS Foo;

CREATE TABLE Foo 
(
  id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,,
  balance int NOT NULL,
  accountid text NOT NULL
);

INSERT INTO Foo (balance, accountid) VALUES (600, 'checking'), (600, 'savings');

T1:

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRANSACTION;
  UPDATE Foo SET balance = 400 WHERE accountid = 'checking';
  SELECT SUM(balance) FROM Foo;

Ergibt 1000

T2:

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRANSACTION;
  UPDATE Foo SET balance = 400 WHERE accountid = 'savings';
  SELECT SUM(balance) FROM Foo;

Ergibt ebenfalls 1000 - also falsch, da inkonsistent.

Wie schaut es jetzt in SQL Server aus?

DROP TABLE IF EXISTS Foo
GO

CREATE TABLE Foo 
(
  Id int NOT NULL identity(1,1) PRIMARY KEY,
  balance int NOT NULL,
  accountid varchar(max) NOT NULL
)
GO

INSERT INTO FOo (balance, accountid) VALUES (600, 'checking'), (600, 'savings')
GO

T1:

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRANSACTION
  UPDATE Foo SET balance = 400 WHERE accountid = 'checking';
  SELECT SUM(balance) FROM Foo;

Ergibt 1000

T2:

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRANSACTION
  UPDATE Foo SET balance = 400 WHERE accountid = 'savings';
  SELECT SUM(balance) FROM Foo;

Blockiert! Nach dem Commit von T1 wird 800 ausgegeben - also korrekt.