Bagaimana SQL Server mengembalikan nilai baru dan nilai lama selama pemutakhiran?

8

Kami memiliki masalah, selama konkurensi tinggi, kueri yang mengembalikan hasil yang tidak masuk akal - mengakibatkan pelanggaran logika kueri yang dikeluarkan. Butuh beberapa saat untuk mereproduksi masalah ini. Saya telah berhasil menyaring masalah yang dapat direproduksi ke beberapa genggam T-SQL.

Catatan : Bagian dari sistem live yang memiliki masalah ini terdiri dari 5 tabel, 4 pemicu, 2 prosedur tersimpan, dan 2 tampilan. Saya telah menyederhanakan sistem nyata menjadi sesuatu yang jauh lebih mudah dikelola untuk pertanyaan yang diposting. Hal-hal telah dikupas, kolom dihapus, prosedur tersimpan dibuat sejajar, tampilan berubah menjadi ekspresi tabel umum, nilai kolom berubah. Ini semua jauh untuk mengatakan bahwa sementara yang berikut mereproduksi kesalahan, mungkin lebih sulit untuk dipahami. Anda harus berhenti bertanya-tanya mengapa ada sesuatu yang terstruktur seperti itu. Saya di sini mencoba mencari tahu mengapa kondisi kesalahan terjadi berulang dalam model mainan ini.

/*
The idea in this system is that people are able to take days off. 
We create a table to hold these *"allocations"*, 
and declare sample data that only **1** production operator 
is allowed to take time off:
*/
IF OBJECT_ID('Allocations') IS NOT NULL DROP TABLE Allocations
CREATE TABLE [dbo].[Allocations](
    JobName varchar(50) PRIMARY KEY NOT NULL,
    Available int NOT NULL
)
--Sample allocation; there is 1 avaialable slot for this job
INSERT INTO Allocations(JobName, Available)
VALUES ('Production Operator', 1);

/*
Then we open up the system to the world, and everyone puts in for time. 
We store these requests for time off as *"transactions"*. 
Two production operators requested time off. 
We create sample data, and note that one of the users 
created their transaction first (by earlier CreatedDate):
*/
IF OBJECT_ID('Transactions') IS NOT NULL DROP TABLE Transactions;
CREATE TABLE [dbo].[Transactions](
    TransactionID int NOT NULL PRIMARY KEY CLUSTERED,
    JobName varchar(50) NOT NULL,
    ApprovalStatus varchar(50) NOT NULL,
    CreatedDate datetime NOT NULL
)
--Two sample transactions
INSERT INTO Transactions (TransactionID, JobName, ApprovalStatus, CreatedDate)
VALUES (52625, 'Production Operator', 'Booked', '20140125 12:00:40.820');
INSERT INTO Transactions (TransactionID, JobName, ApprovalStatus, CreatedDate)
VALUES (60981, 'Production Operator', 'WaitingList', '20150125 12:19:44.717');

/*
The allocation, and two sample transactions are now in the database:
*/
--Show the sample data
SELECT * FROM Allocations
SELECT * FROM Transactions

Transaksi keduanya dimasukkan sebagai WaitingList. Selanjutnya kita memiliki tugas periodik yang berjalan, mencari slot kosong dan menabrak siapa pun di WaitingList menjadi status Tercatat.

Di jendela SSMS yang terpisah, kami memiliki prosedur tersimpan berulang yang disimulasikan:

/*
    Simulate recurring task that looks for empty slots, 
    and bumps someone on the waiting list into that slot.
*/
SET NOCOUNT ON;

--Reset the faulty row so we can continue testing
UPDATE Transactions SET ApprovalStatus = 'WaitingList'
WHERE TransactionID = 60981

--DBCC TRACEON(3604,1200,3916,-1) WITH NO_INFOMSGS

DECLARE @attempts int
SET @attempts = 0;

WHILE (@attempts < 1000000)
BEGIN
    SET @attempts = @attempts+1;

    /*
        The concept is that if someone is already "Booked", then they occupy an available slot.
        We compare the configured amount of allocations (e.g. 1) to how many slots are used.
        If there are any slots leftover, then find the **earliest** created transaction that 
        is currently on the WaitingList, and set them to Booked.
    */

    PRINT '=== Looking for someone to bump ==='
    WITH AvailableAllocations AS (
        SELECT 
            a.JobName,
            a.Available AS Allocations, 
            ISNULL(Booked.BookedCount, 0) AS BookedCount, 
            a.Available-ISNULL(Booked.BookedCount, 0) AS Available
        FROM Allocations a
            FULL OUTER JOIN (
                SELECT t.JobName, COUNT(*) AS BookedCount
                FROM Transactions t
                WHERE t.ApprovalStatus IN ('Booked') 
                GROUP BY t.JobName
            ) Booked
            ON a.JobName = Booked.JobName
        WHERE a.Available > 0
    )
    UPDATE Transactions SET ApprovalStatus = 'Booked'
    WHERE TransactionID = (
        SELECT TOP 1 t.TransactionID
        FROM AvailableAllocations aa
            INNER JOIN Transactions t
            ON aa.JobName = t.JobName
            AND t.ApprovalStatus = 'WaitingList'
        WHERE aa.Available > 0
        ORDER BY t.CreatedDate 
    )


    IF EXISTS(SELECT * FROM Transactions WHERE TransactionID = 60981 AND ApprovalStatus = 'Booked')
    begin
        --DBCC TRACEOFF(3604,1200,3916,-1) WITH NO_INFOMSGS
        RAISERROR('The later tranasction, that should never be booked, managed to get booked!', 16, 1)
        BREAK;
    END
END

Dan akhirnya jalankan ini di jendela koneksi SSMS ke-3. Ini mensimulasikan masalah konkurensi di mana transaksi sebelumnya berubah dari mengambil slot, menjadi berada di daftar tunggu:

/*
    Toggle the earlier transaction back to "WaitingList".
    This means there are two possibilies:
       a) the transaction is "Booked", meaning no slots are available. 
          Therefore nobody should get bumped into "Booked"
       b) the transaction is "WaitingList", 
          meaning 1 slot is open and both tranasctions are "WaitingList"
          The earliest transaction should then get "Booked" into the slot.

    There is no time when there is an open slot where the 
    first transaction shouldn't be the one to get it - he got there first.
*/
SET NOCOUNT ON;

--Reset the faulty row so we can continue testing
UPDATE Transactions SET ApprovalStatus = 'WaitingList'
WHERE TransactionID = 60981

DECLARE @attempts int
SET @attempts = 0;

WHILE (@attempts < 100000)
BEGIN
    SET @attempts = @attempts+1

    /*Flip the earlier transaction from Booked back to WaitingList
        Because it's now on the waiting list -> there is a free slot.
        Because there is a free slot -> a transaction can be booked.
        Because this is the earlier transaction -> it should always be chosen to be booked
    */
    --DBCC TRACEON(3604,1200,3916,-1) WITH NO_INFOMSGS

    PRINT '=== Putting the earlier created transaction on the waiting list ==='

    UPDATE Transactions
    SET ApprovalStatus = 'WaitingList'
    WHERE TransactionID = 52625

    --DBCC TRACEOFF(3604,1200,3916,-1) WITH NO_INFOMSGS

    IF EXISTS(SELECT * FROM Transactions WHERE TransactionID = 60981 AND ApprovalStatus = 'Booked')
    begin
        RAISERROR('The later tranasction, that should never be booked, managed to get booked!', 16, 1)
        BREAK;
    END
END

Secara konseptual, prosedur menabrak terus mencari slot kosong. Jika menemukannya, diperlukan transaksi paling awal yang ada di WaitingListdan tandai sebagai Booked.

Ketika diuji tanpa konkurensi, logikanya berfungsi. Kami memiliki dua transaksi:

  • 12:00 siang: Daftar Tunggu
  • 12.20 siang: Daftar Tunggu

Ada 1 alokasi, dan 0 transaksi dipesan, jadi kami menandai transaksi sebelumnya sebagai dipesan:

  • 12:00 siang: Dipesan
  • 12.20 siang: Daftar Tunggu

Lain kali tugas berjalan, sekarang ada 1 slot yang diambil - jadi tidak ada yang diperbarui.

Jika kami kemudian memperbarui transaksi pertama, dan memasukkannya ke WaitingList:

UPDATE Transactions SET ApprovalStatus='WaitingList'
WHERE TransactionID = 60981

Lalu kita kembali ke tempat kita mulai:

  • 12:00 siang: Daftar Tunggu
  • 12.20 siang: Daftar Tunggu

Catatan : Anda mungkin bertanya-tanya mengapa saya mengembalikan transaksi ke daftar tunggu. Itu adalah korban dari model mainan yang disederhanakan. Dalam sistem nyata transaksi bisa PendingApproval, yang juga menempati slot. Transaksi PendingApproval dimasukkan ke dalam daftar tunggu ketika disetujui. Tidak masalah. Jangan khawatir tentang itu.

Tetapi ketika saya memperkenalkan konkurensi, dengan memiliki jendela kedua yang secara konstan menempatkan transaksi pertama kembali ke daftar tunggu setelah dipesan, maka transaksi selanjutnya berhasil mendapatkan pemesanan:

  • 12:00 siang: Daftar Tunggu
  • 12.20 siang: Dipesan

Skrip tes mainan menangkap ini, dan berhenti mengulangi:

Msg 50000, Level 16, State 1, Line 41
The later tranasction, that should never be booked, managed to get booked!

Mengapa?

Pertanyaannya adalah, mengapa dalam model mainan ini, apakah kondisi bail-out ini dipicu?

Ada dua kemungkinan status untuk status persetujuan transaksi pertama:

  • Memesan : dalam hal ini slot ditempati, dan transaksi selanjutnya tidak dapat memilikinya
  • WaitingList : dalam hal ini ada satu slot kosong, dan dua transaksi yang menginginkannya. Tapi karena kita selalu selectyang tertua transaksi (yaitu ORDER BY CreatedDate) transaksi pertama harus mendapatkannya.

Saya pikir mungkin karena indeks lain

Saya mengetahui bahwa setelah UPDATE dimulai, dan data telah dimodifikasi, dimungkinkan untuk membaca nilai-nilai lama. Dalam kondisi awal:

  • Indeks berkerumun :Booked
  • Indeks non-cluster :Booked

Kemudian saya melakukan pembaruan, dan sementara simpul daun indeks berkerumun telah dimodifikasi, setiap indeks yang tidak berkerumun masih mengandung nilai asli dan masih tersedia untuk dibaca:

  • Indeks berkerumun (kunci eksklusif):Booked WaitingList
  • Indeks tidak berkerumun : (tidak dikunci)Booked

Tapi itu tidak menjelaskan masalah yang diamati. Ya transaksi tidak lagi Dipesan , artinya sekarang ada slot kosong. Tetapi perubahan itu belum dilakukan, masih dilakukan secara eksklusif. Jika prosedur menabrak berjalan, itu akan menjadi:

  • blok: jika opsi database isolasi snapshot tidak aktif
  • baca nilai lama (mis. Booked): jika isolasi snapshot aktif

Either way, pekerjaan menabrak tidak akan tahu ada slot kosong.

Jadi saya tidak tahu

Kami telah berjuang selama berhari-hari untuk mencari tahu bagaimana hasil yang tidak masuk akal ini bisa terjadi.

Anda mungkin tidak mengerti sistem aslinya, tetapi ada satu set skrip yang dapat direproduksi mainan. Mereka menyelamatkan ketika kasus yang tidak valid terdeteksi. Mengapa terdeteksi? Mengapa ini terjadi?

Pertanyaan Bonus

Bagaimana NASDAQ mengatasi ini? Bagaimana cara cavirtex? Bagaimana mtgox?

tl; dr

Ada tiga blok skrip. Masukkan mereka ke dalam 3 tab SSMS yang terpisah dan jalankan. Skrip ke-2 dan ke-3 akan memunculkan kesalahan. Bantu saya mencari tahu mengapa kesalahan itu muncul.

Ian Boyd
sumber
Mungkin ada hubungannya dengan tingkat isolasi transaksi. Tingkat isolasi apa yang Anda gunakan dalam sistem Anda?
cha
@cha Default (READ COMMITTED). Salin-rekatkan skrip dan Anda dapat mengonfirmasi bahwa itu benar-benar level default.
Ian Boyd
Ketika tab ke-3 Anda "Setel ulang baris yang salah", baris itu menjadi tersedia. Dengan demikian, tab ke-2 Anda dapat mengalokasikannya sebelum tab ke-3 menandai baris sebelumnya sebagai tersedia. Coba lakukan kedua modifikasi pada UPDATE di tab ke-3 Anda.
AK

Jawaban:

12

Tingkat READ COMMITTEDisolasi transaksi standar menjamin bahwa transaksi Anda tidak akan membaca data yang tidak dikomit. Itu tidak menjamin bahwa data apa pun yang Anda baca akan tetap sama jika Anda membacanya lagi (dibaca berulang) atau bahwa data baru tidak akan muncul (hantu).

Pertimbangan yang sama ini berlaku untuk beberapa akses data dalam pernyataan yang sama .

UPDATEPernyataan Anda menghasilkan rencana yang mengakses Transactionstabel lebih dari sekali, sehingga rentan terhadap efek yang disebabkan oleh pembacaan dan hantu yang tidak dapat diulang.

Akses berganda

Ada beberapa cara untuk rencana ini untuk menghasilkan hasil yang tidak Anda harapkan di bawah READ COMMITTEDisolasi.

Sebuah contoh

TransactionsAkses tabel pertama menemukan baris yang memiliki status WaitingList. Akses kedua menghitung jumlah entri (untuk pekerjaan yang sama) yang memiliki status Booked. Akses pertama hanya dapat mengembalikan transaksi nanti (yang sebelumnya Bookedpada saat ini). Ketika akses (penghitungan) kedua terjadi, transaksi sebelumnya telah diubah menjadi WaitingList. Karenanya baris berikutnya memenuhi syarat untuk pembaruan ke Bookedstatus.

Solusi

Ada beberapa cara untuk mengatur semantik isolasi untuk mendapatkan hasil yang Anda inginkan. Salah satu opsi adalah mengaktifkan READ_COMMITTED_SNAPSHOTuntuk basis data. Ini memberikan konsistensi pembacaan tingkat pernyataan untuk pernyataan yang berjalan pada tingkat isolasi default. Pembacaan dan hantu yang tidak dapat diulang tidak dimungkinkan dalam isolasi snapshot yang sudah dilakukan

Keterangan lain

Saya harus mengatakan bahwa saya tidak akan merancang skema atau permintaan dengan cara ini. Ada lebih banyak pekerjaan yang terlibat daripada yang seharusnya diperlukan untuk memenuhi persyaratan bisnis yang disebutkan. Mungkin ini sebagian merupakan hasil dari penyederhanaan dalam pertanyaan, dalam hal apa pun itu adalah pertanyaan terpisah.

Perilaku yang Anda lihat tidak mewakili bug dalam bentuk apa pun. Script menghasilkan hasil yang benar mengingat semantik isolasi yang diminta. Efek konkurensi seperti ini juga tidak terbatas pada paket yang mengakses data beberapa kali.

Tingkat isolasi yang dilakukan dengan komitmen menyediakan banyak jaminan lebih sedikit daripada yang biasanya diasumsikan. Sebagai contoh, melewatkan baris dan / atau membaca baris yang sama lebih dari satu kali sangat mungkin dilakukan.

Paul White 9
sumber
Saya mencoba mencari tahu urutan operasi yang menyebabkan hasil yang salah. Ini pertama kali INNERbergabung Transactionske Allocationsdidasarkan pada WaitingListstatus. Bergabung ini terjadi sebelum UPDATEmengambil IXatau Xmengunci. Karena transaksi pertama masih Booked, INNER JOINhanya menemukan transaksi nanti. Kemudian mengakses Transactionstabel lagi untuk melakukan LEFT OUTER JOINke hitungan slot yang tersedia. Pada saat ini transaksi pertama telah diperbarui WaitingList, yang berarti ada slot.
Ian Boyd
Sistem nyata memiliki tingkat kompleksitas ekstra. Misalnya JobNametidak (dan tidak bisa) disimpan dengan Transactiontetapi dengan Employee. Jadi Transactionsmengandung EmployeeID, dan kita harus bergabung. Alokasi yang tersedia juga ditentukan untuk Hari dan Pekerjaan . Jadi Allocationssebenarnya tabel (TransactionDate, JobName). Akhirnya, seseorang dapat melakukan beberapa transaksi untuk hari yang sama; yang harus hanya menempati 1 slot. Jadi sistem yang sebenarnya melakukan distinct-countby Employee,Job,Date. Mengabaikan semua itu, perubahan apa yang akan Anda lakukan pada mainan itu? Mungkin bisa diadopsi kembali.
Ian Boyd
2
@IanBoyd Re: komentar pertama, ya (kecuali itu bukan hasil yang salah). Re: komentar kedua, itu akan menjadi pekerjaan konsultasi :)
Paul White 9
2
@AlexKuznetsov Berdasarkan pengetahuan saya yang baru ditemukan, masalah liburan tiket Arnie / Carol dapat terjadi secara READ COMMITTEDterpisah. Pergi cek liburan jika ada tiket yang ditugaskan kepada saya. Jika cek Ticketstabel itu menggunakan indeks, akan keliru mengira bahwa tiket tidak ditugaskan kepada saya. Kemudian seseorang memberikan tiket kepada saya, dan pelatuk menggunakan indeks untuk berpikir saya belum berlibur. Hasil: tiket aktif diberikan kepada pengembang saat liburan. Dengan pengetahuan baru ini, saya ingin berbaring dan menangis; seluruh dunia saya dibatalkan, semua yang pernah saya tulis salah.
Ian Boyd
1
@IanBoyd ini sebabnya kami menggunakan kendala untuk menegakkan aturan seperti yang Anda miliki masalah. Kami telah mengganti pemicu terakhir dengan kendala lebih dari dua tahun lalu, dan kami menikmati integritas data kedap air sejak saat itu. Kami juga tidak lagi harus belajar dengan kunci detail yang sangat besar, tingkat isolasi, dll - kendala hanya berfungsi, selama Anda tidak menggunakan MERGE, tentu saja.
AK