Ho operato partendo dal presupposto che una singola istruzione in SQL Server sia coerente
Questa ipotesi è sbagliata. Le due transazioni seguenti hanno una semantica di blocco identica:
STATEMENT
BEGIN TRAN; STATEMENT; COMMIT
Nessuna differenza. Le singole dichiarazioni e gli autocommit non cambiano nulla.
Quindi unire tutta la logica in un'unica affermazione non aiuta (se lo fa, è stato per caso perché il piano è cambiato).
Risolviamo il problema a portata di mano. SERIALIZABLE
risolverà l'incoerenza che stai vedendo perché garantisce che le tue transazioni si comportino come se fossero eseguite a thread singolo. Allo stesso modo, si comportano come se fossero eseguiti all'istante.
Avrai dei deadlock. Se sei d'accordo con un ciclo di tentativi, a questo punto hai finito.
Se vuoi investire più tempo, applica i suggerimenti di blocco per forzare l'accesso esclusivo ai dati rilevanti:
UPDATE Gifts -- U-locked anyway
SET GivenAway = 1
WHERE GiftID = (
SELECT TOP 1 GiftID
FROM Gifts WITH (UPDLOCK, HOLDLOCK) --this normally just S-locks.
WHERE g2.GivenAway = 0
AND (SELECT COUNT(*) FROM Gifts g2 WITH (UPDLOCK, HOLDLOCK) WHERE g2.GivenAway = 1) < 5
ORDER BY g2.GiftValue DESC
)
Ora vedrai una concorrenza ridotta. Potrebbe andare benissimo a seconda del tuo carico.
La natura stessa del tuo problema rende difficile raggiungere la concorrenza. Se hai bisogno di una soluzione, dovremmo applicare tecniche più invasive.
Puoi semplificare un po' l'AGGIORNAMENTO:
WITH g AS (
SELECT TOP 1 Gifts.*
FROM Gifts
WHERE g2.GivenAway = 0
AND (SELECT COUNT(*) FROM Gifts g2 WITH (UPDLOCK, HOLDLOCK) WHERE g2.GivenAway = 1) < 5
ORDER BY g2.GiftValue DESC
)
UPDATE g -- U-locked anyway
SET GivenAway = 1
Questo elimina un join non necessario.