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 WaitingList
dan 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
select
yang tertua transaksi (yaituORDER 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.
sumber
Jawaban:
Tingkat
READ COMMITTED
isolasi 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 .
UPDATE
Pernyataan Anda menghasilkan rencana yang mengaksesTransactions
tabel lebih dari sekali, sehingga rentan terhadap efek yang disebabkan oleh pembacaan dan hantu yang tidak dapat diulang.Ada beberapa cara untuk rencana ini untuk menghasilkan hasil yang tidak Anda harapkan di bawah
READ COMMITTED
isolasi.Sebuah contoh
Transactions
Akses tabel pertama menemukan baris yang memiliki statusWaitingList
. Akses kedua menghitung jumlah entri (untuk pekerjaan yang sama) yang memiliki statusBooked
. Akses pertama hanya dapat mengembalikan transaksi nanti (yang sebelumnyaBooked
pada saat ini). Ketika akses (penghitungan) kedua terjadi, transaksi sebelumnya telah diubah menjadiWaitingList
. Karenanya baris berikutnya memenuhi syarat untuk pembaruan keBooked
status.Solusi
Ada beberapa cara untuk mengatur semantik isolasi untuk mendapatkan hasil yang Anda inginkan. Salah satu opsi adalah mengaktifkan
READ_COMMITTED_SNAPSHOT
untuk 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 dilakukanKeterangan 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.
sumber
INNER
bergabungTransactions
keAllocations
didasarkan padaWaitingList
status. Bergabung ini terjadi sebelumUPDATE
mengambilIX
atauX
mengunci. Karena transaksi pertama masihBooked
,INNER JOIN
hanya menemukan transaksi nanti. Kemudian mengaksesTransactions
tabel lagi untuk melakukanLEFT OUTER JOIN
ke hitungan slot yang tersedia. Pada saat ini transaksi pertama telah diperbaruiWaitingList
, yang berarti ada slot.JobName
tidak (dan tidak bisa) disimpan denganTransaction
tetapi denganEmployee
. JadiTransactions
mengandungEmployeeID
, dan kita harus bergabung. Alokasi yang tersedia juga ditentukan untuk Hari dan Pekerjaan . JadiAllocations
sebenarnya tabel (TransactionDate, JobName). Akhirnya, seseorang dapat melakukan beberapa transaksi untuk hari yang sama; yang harus hanya menempati 1 slot. Jadi sistem yang sebenarnya melakukandistinct-count
byEmployee,Job,Date
. Mengabaikan semua itu, perubahan apa yang akan Anda lakukan pada mainan itu? Mungkin bisa diadopsi kembali.READ COMMITTED
terpisah. Pergi cek liburan jika ada tiket yang ditugaskan kepada saya. Jika cekTickets
tabel 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.