Vì bạn đang sử dụng Chuỗi, bạn có thể sử dụng cùng chức năng GIÁ TRỊ TIẾP THEO - mà bạn đã có trong Ràng buộc mặc định trên Id
trường Khóa chính - để tạo Id
giá trị mới trước thời hạn. Tạo giá trị trước tiên có nghĩa là bạn không cần phải lo lắng về việc không có SCOPE_IDENTITY
, điều đó có nghĩa là bạn không cần OUTPUT
điều khoản hoặc thực hiện bổ sung SELECT
để có được giá trị mới; bạn sẽ có giá trị trước khi bạn thực hiện INSERT
và thậm chí bạn không cần phải gây rối với SET IDENTITY INSERT ON / OFF
:-)
Vì vậy, mà chăm sóc một phần của tình hình tổng thể. Phần khác đang xử lý vấn đề đồng thời của hai quy trình, cùng một lúc, không tìm thấy một hàng hiện có cho cùng một chuỗi chính xác và tiến hành INSERT
. Mối quan tâm là về việc tránh vi phạm ràng buộc duy nhất sẽ xảy ra.
Một cách để xử lý các loại vấn đề tương tranh này là buộc hoạt động cụ thể này phải là một luồng. Cách để làm điều đó là bằng cách sử dụng các khóa ứng dụng (hoạt động qua các phiên). Mặc dù hiệu quả, chúng có thể hơi nặng tay đối với một tình huống như thế này khi tần suất va chạm có lẽ khá thấp.
Cách khác để đối phó với các vụ va chạm là chấp nhận rằng đôi khi chúng sẽ xảy ra và xử lý chúng thay vì cố gắng tránh chúng. Sử dụng TRY...CATCH
cấu trúc, bạn có thể bẫy một lỗi cụ thể một cách hiệu quả (trong trường hợp này: "vi phạm ràng buộc duy nhất", Msg 2601) và thực hiện lại SELECT
để nhận Id
giá trị vì chúng ta biết rằng nó tồn tại do nằm trong CATCH
khối với cụ thể đó lỗi. Các lỗi khác có thể được xử lý theo cách điển hình RAISERROR
/ RETURN
hoặc THROW
.
Thiết lập thử nghiệm: Trình tự, bảng và chỉ mục duy nhất
USE [tempdb];
CREATE SEQUENCE dbo.MagicNumber
AS INT
START WITH 1
INCREMENT BY 1;
CREATE TABLE dbo.NameLookup
(
[Id] INT NOT NULL
CONSTRAINT [PK_NameLookup] PRIMARY KEY CLUSTERED
CONSTRAINT [DF_NameLookup_Id] DEFAULT (NEXT VALUE FOR dbo.MagicNumber),
[ItemName] NVARCHAR(50) NOT NULL
);
CREATE UNIQUE NONCLUSTERED INDEX [UIX_NameLookup_ItemName]
ON dbo.NameLookup ([ItemName]);
GO
Kiểm tra thiết lập: Thủ tục lưu trữ
CREATE PROCEDURE dbo.GetOrInsertName
(
@SomeName NVARCHAR(50),
@ID INT OUTPUT,
@TestRaceCondition BIT = 0
)
AS
SET NOCOUNT ON;
BEGIN TRY
SELECT @ID = nl.[Id]
FROM dbo.NameLookup nl
WHERE nl.[ItemName] = @SomeName
AND @TestRaceCondition = 0;
IF (@ID IS NULL)
BEGIN
SET @ID = NEXT VALUE FOR dbo.MagicNumber;
INSERT INTO dbo.NameLookup ([Id], [ItemName])
VALUES (@ID, @SomeName);
END;
END TRY
BEGIN CATCH
IF (ERROR_NUMBER() = 2601) -- "Cannot insert duplicate key row in object"
BEGIN
SELECT @ID = nl.[Id]
FROM dbo.NameLookup nl
WHERE nl.[ItemName] = @SomeName;
END;
ELSE
BEGIN
;THROW; -- SQL Server 2012 or newer
/*
DECLARE @ErrorNumber INT = ERROR_NUMBER(),
@ErrorMessage NVARCHAR(4000) = ERROR_MESSAGE();
RAISERROR(N'Msg %d: %s', 16, 1, @ErrorNumber, @ErrorMessage);
RETURN;
*/
END;
END CATCH;
GO
Các bài kiểm tra
DECLARE @ItemID INT;
EXEC dbo.GetOrInsertName
@SomeName = N'test1',
@ID = @ItemID OUTPUT;
SELECT @ItemID AS [ItemID];
GO
DECLARE @ItemID INT;
EXEC dbo.GetOrInsertName
@SomeName = N'test1',
@ID = @ItemID OUTPUT,
@TestRaceCondition = 1;
SELECT @ItemID AS [ItemID];
GO
Câu hỏi từ OP
Tại sao điều này tốt hơn MERGE
? Tôi sẽ không có được chức năng tương tự mà không cần TRY
sử dụng WHERE NOT EXISTS
mệnh đề?
MERGE
có nhiều "vấn đề" khác nhau (một số tài liệu tham khảo được liên kết trong câu trả lời của @ SqlZim vì vậy không cần phải sao chép thông tin đó ở đây). Và, không có khóa bổ sung trong phương pháp này (ít tranh chấp hơn), vì vậy nó sẽ tốt hơn về đồng thời. Theo cách tiếp cận này, bạn sẽ không bao giờ bị vi phạm ràng buộc duy nhất, tất cả mà không có HOLDLOCK
, v.v. Nó được đảm bảo khá nhiều để hoạt động.
Lý do đằng sau phương pháp này là:
- Nếu bạn có đủ thực thi quy trình này để bạn cần lo lắng về va chạm, thì bạn không muốn:
- thực hiện bất kỳ bước nào nhiều hơn cần thiết
- giữ khóa trên bất kỳ tài nguyên nào lâu hơn cần thiết
- Vì các va chạm chỉ có thể xảy ra khi các mục mới (các mục mới được gửi cùng lúc ), tần suất rơi vào
CATCH
khối ở vị trí đầu tiên sẽ khá thấp. Sẽ hợp lý hơn khi tối ưu hóa mã sẽ chạy 99% thời gian thay vì mã sẽ chạy 1% thời gian (trừ khi không có chi phí để tối ưu hóa cả hai, nhưng đó không phải là trường hợp ở đây).
Nhận xét từ câu trả lời của @ SqlZim (nhấn mạnh thêm)
Cá nhân tôi thích thử và điều chỉnh một giải pháp để tránh làm điều đó khi có thể . Trong trường hợp này, tôi không cảm thấy rằng sử dụng các khóa từ serializable
là một cách tiếp cận nặng tay và tôi sẽ tự tin rằng nó sẽ xử lý đồng thời cao.
Tôi sẽ đồng ý với câu đầu tiên này nếu nó được sửa đổi thành "và thận trọng". Chỉ vì một cái gì đó có thể về mặt kỹ thuật không có nghĩa là tình huống (nghĩa là trường hợp sử dụng dự định) sẽ được hưởng lợi từ nó.
Vấn đề tôi thấy với cách tiếp cận này là nó khóa nhiều hơn những gì đang được đề xuất. Điều quan trọng là phải đọc lại tài liệu được trích dẫn về "tuần tự hóa", cụ thể như sau (nhấn mạnh thêm):
- Các giao dịch khác không thể chèn các hàng mới với các giá trị chính sẽ nằm trong phạm vi các khóa được đọc bởi bất kỳ câu lệnh nào trong giao dịch hiện tại cho đến khi giao dịch hiện tại hoàn tất.
Bây giờ, đây là nhận xét trong mã ví dụ:
SELECT [Id]
FROM dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
Từ phẫu thuật có "phạm vi". Khóa được thực hiện không chỉ dựa trên giá trị @vName
, mà chính xác hơn là một phạm vi bắt đầu từvị trí nơi giá trị mới này sẽ đi (nghĩa là giữa các giá trị khóa hiện tại ở hai bên của giá trị mới phù hợp), nhưng không phải là giá trị chính nó. Có nghĩa là, các quy trình khác sẽ bị chặn không chèn các giá trị mới, tùy thuộc vào (các) giá trị hiện đang được tra cứu. Nếu việc tra cứu đang được thực hiện ở đầu phạm vi, thì việc chèn bất cứ thứ gì có thể chiếm vị trí đó sẽ bị chặn. Ví dụ: nếu các giá trị "a", "b" và "d" tồn tại, thì nếu một quá trình đang thực hiện CHỌN trên "f", thì sẽ không thể chèn các giá trị "g" hoặc thậm chí là "e" ( vì bất kỳ ai trong số họ sẽ đến ngay sau "d"). Nhưng, việc chèn một giá trị "c" sẽ có thể vì nó sẽ không được đặt trong phạm vi "dành riêng".
Ví dụ sau sẽ minh họa hành vi này:
(Trong tab truy vấn (tức là Phiên) # 1)
INSERT INTO dbo.NameLookup ([ItemName]) VALUES (N'test5');
BEGIN TRAN;
SELECT [Id]
FROM dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
WHERE ItemName = N'test8';
--ROLLBACK;
(Trong tab truy vấn (tức là Phiên) # 2)
EXEC dbo.NameLookup_getset_byName @vName = N'test4';
-- works just fine
EXEC dbo.NameLookup_getset_byName @vName = N'test9';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1
EXEC dbo.NameLookup_getset_byName @vName = N'test7';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1
EXEC dbo.NameLookup_getset_byName @vName = N's';
-- works just fine
EXEC dbo.NameLookup_getset_byName @vName = N'u';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1
Tương tự, nếu giá trị "C" tồn tại và giá trị "A" đang được chọn (và do đó bị khóa), thì bạn có thể chèn giá trị "D", nhưng không phải là giá trị của "B":
(Trong tab truy vấn (tức là Phiên) # 1)
INSERT INTO dbo.NameLookup ([ItemName]) VALUES (N'testC');
BEGIN TRAN
SELECT [Id]
FROM dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
WHERE ItemName = N'testA';
--ROLLBACK;
(Trong tab truy vấn (tức là Phiên) # 2)
EXEC dbo.NameLookup_getset_byName @vName = N'testD';
-- works just fine
EXEC dbo.NameLookup_getset_byName @vName = N'testB';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1
Công bằng mà nói, theo cách tiếp cận được đề xuất của tôi, khi có một ngoại lệ, sẽ có 4 mục trong Nhật ký giao dịch sẽ không xảy ra trong phương pháp "giao dịch tuần tự hóa" này. NHƯNG, như tôi đã nói ở trên, nếu ngoại lệ xảy ra 1% (hoặc thậm chí 5%) thời gian, điều đó ít ảnh hưởng hơn nhiều so với trường hợp rất có thể của CHỌN ban đầu tạm thời chặn các hoạt động INSERT.
Một vấn đề khác, mặc dù nhỏ, vấn đề với cách tiếp cận "giao dịch tuần tự + mệnh đề OUTPUT" này là OUTPUT
mệnh đề (trong cách sử dụng hiện tại) gửi dữ liệu trở lại dưới dạng tập kết quả. Một tập kết quả đòi hỏi nhiều chi phí hơn (có thể ở cả hai phía: trong SQL Server để quản lý con trỏ bên trong và trong lớp ứng dụng để quản lý đối tượng DataReader) hơn là một OUTPUT
tham số đơn giản . Cho rằng chúng ta chỉ đang xử lý một giá trị vô hướng duy nhất và giả định là tần suất thực thi cao, có thể thêm chi phí hoạt động của tập kết quả.
Mặc dù OUTPUT
mệnh đề có thể được sử dụng theo cách trả về một OUTPUT
tham số, nhưng sẽ yêu cầu các bước bổ sung để tạo một bảng tạm thời hoặc biến bảng, sau đó chọn giá trị từ biến bảng / bảng tạm thời đó vào OUTPUT
tham số.
Làm rõ thêm: Trả lời Phản hồi của @ SqlZim (câu trả lời được cập nhật) cho Phản hồi của tôi đối với Phản hồi của @ SqlZim (trong câu trả lời ban đầu) cho tuyên bố của tôi về đồng thời và hiệu suất ;-)
Xin lỗi nếu phần này dài một chút, nhưng tại thời điểm này, chúng tôi chỉ xem xét các sắc thái của hai cách tiếp cận.
Tôi tin rằng cách thông tin được trình bày có thể dẫn đến các giả định sai về số lượng khóa mà người ta có thể gặp phải khi sử dụng serializable
trong kịch bản như được trình bày trong câu hỏi ban đầu.
Vâng, tôi sẽ thừa nhận rằng tôi thiên vị, mặc dù công bằng:
- Không thể để con người không bị thiên vị, ít nhất là ở một mức độ nhỏ nào đó, và tôi cố gắng giữ nó ở mức tối thiểu,
- Ví dụ đưa ra rất đơn giản, nhưng đó là nhằm mục đích minh họa để truyền đạt hành vi mà không làm phức tạp nó. Ngụ ý tần suất quá mức không nhằm mục đích, mặc dù tôi hiểu rằng tôi cũng không nói rõ ràng bằng cách khác và nó có thể được đọc là ngụ ý một vấn đề lớn hơn thực tế tồn tại. Tôi sẽ cố gắng làm rõ điều đó dưới đây.
- Tôi cũng đã bao gồm một ví dụ về việc khóa một phạm vi giữa hai khóa hiện có (bộ thứ hai của các khối "tab truy vấn 1" và "tab truy vấn 2").
- Tôi đã tìm thấy (và tình nguyện) "chi phí ẩn" cho cách tiếp cận của mình, đó là bốn mục nhập Nhật ký bổ sung mỗi lần
INSERT
không thành công do vi phạm ràng buộc duy nhất. Tôi chưa thấy điều đó được đề cập trong bất kỳ câu trả lời / bài viết nào khác.
Về cách tiếp cận "JFDI" của @ gbn, bài đăng "Chủ nghĩa thực dụng xấu cho người chiến thắng" của Michael J. Swart, và bình luận của Aaron Bertrand về bài đăng của Michael (liên quan đến các bài kiểm tra của anh ấy cho thấy kịch bản nào đã làm giảm hiệu suất) và nhận xét của bạn về "sự thích nghi của Michael J" Sự thích nghi của Stewart về quy trình Thử bắt JFDI của @ gbn "nêu rõ:
Nếu bạn đang chèn các giá trị mới thường xuyên hơn so với việc chọn các giá trị hiện tại, thì điều này có thể hiệu quả hơn phiên bản của @ srutzky. Nếu không, tôi thích phiên bản của @ srutzky hơn phiên bản này.
Đối với cuộc thảo luận gbn / Michael / Aaron liên quan đến phương pháp "JFDI", sẽ không đúng khi đánh giá đề xuất của tôi với phương pháp "JFDI" của gbn. Do tính chất của hoạt động "Nhận hoặc Chèn", có một nhu cầu rõ ràng là phải thực hiện SELECT
để có được ID
giá trị cho các bản ghi hiện có. CHỌN này hoạt động như IF EXISTS
kiểm tra, làm cho cách tiếp cận này tương đương với biến thể "CheckTryCatch" của các thử nghiệm của Aaron. Mã được viết lại của Michael (và bản chuyển thể cuối cùng của bạn về sự thích ứng của Michael) cũng bao gồm một WHERE NOT EXISTS
kiểm tra tương tự trước tiên. Do đó, đề xuất của tôi (cùng với mã cuối cùng của Michael và sự điều chỉnh mã cuối cùng của anh ấy) sẽ không thực sự đạt được CATCH
khối đó thường xuyên. Nó chỉ có thể là tình huống trong đó hai phiên,ItemName
INSERT...SELECT
tại cùng một thời điểm sao cho cả hai phiên đều nhận được "đúng" cho WHERE NOT EXISTS
cùng một thời điểm và do đó cả hai đều cố gắng thực hiện INSERT
cùng một lúc. Kịch bản rất cụ thể đó xảy ra ít thường xuyên hơn nhiều so với việc chọn một cái hiện có ItemName
hoặc chèn một cái mới ItemName
khi không có quá trình nào khác đang cố gắng thực hiện vào cùng một thời điểm .
VỚI TẤT CẢ CÁC TRÊN TRÊN MIND: Tại sao tôi thích cách tiếp cận của mình?
Đầu tiên, chúng ta hãy xem những gì khóa diễn ra trong phương pháp "tuần tự hóa". Như đã đề cập ở trên, "phạm vi" bị khóa phụ thuộc vào các giá trị khóa hiện có ở hai bên của giá trị khóa mới sẽ phù hợp. Đầu hoặc cuối của phạm vi cũng có thể là đầu hoặc cuối của chỉ mục, nếu không có giá trị khóa hiện tại theo hướng đó. Giả sử chúng ta có chỉ mục và khóa sau ( ^
đại diện cho phần đầu của chỉ mục trong khi $
biểu thị phần cuối của chỉ mục):
Range #: |--- 1 ---|--- 2 ---|--- 3 ---|--- 4 ---|
Key Value: ^ C F J $
Nếu phiên 55 cố gắng chèn giá trị chính của:
A
, sau đó phạm vi # 1 (từ ^
đến C
) bị khóa: phiên 56 không thể chèn giá trị B
, ngay cả khi duy nhất và hợp lệ (chưa). Nhưng phiên 56 có thể chèn các giá trị của D
, G
và M
.
D
, sau đó phạm vi # 2 (từ C
đến F
) bị khóa: phiên 56 không thể chèn giá trị E
(chưa). Nhưng phiên 56 có thể chèn các giá trị của A
, G
và M
.
M
, sau đó phạm vi # 4 (từ J
đến $
) bị khóa: phiên 56 không thể chèn giá trị X
(chưa). Nhưng phiên 56 có thể chèn các giá trị của A
, D
và G
.
Khi nhiều giá trị khóa được thêm vào, phạm vi giữa các giá trị chính sẽ hẹp hơn, do đó giảm xác suất / tần suất của nhiều giá trị được chèn cùng lúc chiến đấu trên cùng một phạm vi. Phải thừa nhận rằng, đây không phải là một vấn đề lớn và may mắn thay, nó dường như là một vấn đề thực sự giảm dần theo thời gian.
Vấn đề với cách tiếp cận của tôi đã được mô tả ở trên: nó chỉ xảy ra khi hai phiên cố gắng chèn cùng một giá trị khóa cùng một lúc. Về mặt này, điều gì dẫn đến xác suất xảy ra cao hơn: hai giá trị khóa khác nhau nhưng gần nhau được thử cùng một lúc hoặc cùng một giá trị khóa được thử cùng một lúc? Tôi cho rằng câu trả lời nằm trong cấu trúc của ứng dụng khi thực hiện các thao tác chèn, nhưng nói chung tôi sẽ cho rằng nhiều khả năng hai giá trị khác nhau xảy ra để chia sẻ cùng một phạm vi sẽ được chèn vào. Nhưng cách duy nhất để thực sự biết là thử nghiệm cả hai trên hệ thống OP.
Tiếp theo, hãy xem xét hai kịch bản và cách mỗi phương pháp xử lý chúng:
Tất cả các yêu cầu dành cho các giá trị khóa duy nhất:
Trong trường hợp này, CATCH
khối trong đề xuất của tôi không bao giờ được nhập, do đó không có "vấn đề" (tức là 4 mục nhật ký tran và thời gian cần thiết để làm điều đó). Nhưng, theo cách tiếp cận "tuần tự hóa", ngay cả khi tất cả các phần chèn là duy nhất, sẽ luôn có một số tiềm năng để chặn các phần chèn khác trong cùng phạm vi (mặc dù không quá lâu).
Tần suất cao của các yêu cầu cho cùng một giá trị khóa cùng một lúc:
Trong trường hợp này - mức độ duy nhất rất thấp về các yêu cầu đến đối với các giá trị khóa không tồn tại - CATCH
khối trong đề xuất của tôi sẽ được nhập thường xuyên. Hiệu quả của việc này là mỗi lần chèn không thành công sẽ cần tự động khôi phục và ghi 4 mục vào Nhật ký giao dịch, đây là một hiệu suất nhẹ đạt được mỗi lần. Nhưng hoạt động tổng thể không bao giờ thất bại (ít nhất là không phải do điều này).
(Có một vấn đề với phiên bản trước của phương pháp "cập nhật" cho phép nó bị bế tắc. Một updlock
gợi ý đã được thêm vào để giải quyết vấn đề này và nó không còn bị bế tắc nữa.)NHƯNG, theo cách tiếp cận "tuần tự hóa" (ngay cả phiên bản cập nhật, tối ưu hóa), hoạt động sẽ bế tắc. Tại sao? Bởi vì serializable
hành vi chỉ ngăn các INSERT
hoạt động trong phạm vi đã được đọc và do đó bị khóa; nó không ngăn cản SELECT
hoạt động trên phạm vi đó.
Cách serializable
tiếp cận, trong trường hợp này, dường như không có chi phí bổ sung, và có thể thực hiện tốt hơn một chút so với những gì tôi đề xuất.
Cũng như nhiều / hầu hết các cuộc thảo luận liên quan đến hiệu suất, do có quá nhiều yếu tố có thể ảnh hưởng đến kết quả, cách duy nhất để thực sự có ý thức về cách một cái gì đó sẽ thực hiện là thử nó trong môi trường mục tiêu nơi nó sẽ chạy. Tại thời điểm đó, nó sẽ không phải là vấn đề quan điểm :).