原由
在開發ERP應用中,我們經常需要知道某個實體的當前數量,例如知道商品當前的庫存,或者科目的金額,或者某個客戶剩余的信用額度,所以這種需求是比較普遍的。
通常會設計兩張表,一張是流水賬表,有的稱明細表,或者日志表,用於記錄所有發生的事務記錄。一張是余額表,用於記錄各個實體當前最新的余額。
| 流水號 NO. |
商品編號 ProductId |
增加 Add |
減少 Reduce |
| 1 |
P1 |
3 |
|
| 2 |
P1 |
|
1 |
| 3 |
P2 |
5 |
|
| 商品編號 ProductId |
余額 Banlance |
| P1 |
2 |
| P2 |
5 |
如果有一張單據分別出貨P1和P2各一件,而另外一個單據也出貨P2和P1各一件,只是順序不同而已。所以可能出現下面的sql執行順序。
| 順序 Order |
用戶1 User 1 |
用戶2 User 2 |
狀態 Status |
| 1 |
begin tran |
begin tran |
OK |
| 2 |
INSERT INTO [Chronological] ([NO], [ProductId],[Reduce]) VALUES (4, ‘P1’, 1); |
INSERT INTO [Chronological] ([NO], [ProductId],[Reduce]) VALUES (5, ‘P2’, 1); |
OK |
| 3 |
Update [Banlance] Set Banlance = Banlance- 1 Where ProductId = ‘P1’ |
|
OK |
| 4 |
|
Update [Banlance] Set Banlance = Banlance- 1 Where ProductId = ‘P2’ |
OK |
| 5 |
Update [Banlance] Set Banlance = Banlance- 1 Where ProductId = ‘P2’ |
|
Wait… |
| 6 |
|
Update [Banlance] Set Banlance = Banlance- 1 Where ProductId = ‘P1’ |
Wait |
可以看出,當兩個用戶需要的資源(余額表)很容易產生死鎖,這是本文要關鍵解決的問題。
另外,實際情況還要更復雜,由於不能保證余額表一定存在對應的產品記錄,當Update Banlance表未影響任何行時,需要執行一個Insert 指令,你很容易想到的,另外一個用戶也正好執行了此產品的Update,也發現未影響任何行兵執行一個Insert指令,而后執行的insert將出現“數據重復”的異常。
解決方案一:順序的更新數據
第一種解決方案是在執行Update指令時,對要更新的數據進行排序。對於此案例,我們可以對ProductId進行排序,重新執行上面的SQL.
| 順序 Order |
用戶1 User 1 |
用戶2 User 2 |
狀態 Status |
| 1 |
begin tran |
begin tran |
OK |
| 2 |
INSERT INTO [Chronological] ([NO], [ProductId],[Reduce]) VALUES (4, ‘P1’, 1); |
INSERT INTO [Chronological] ([NO], [ProductId],[Reduce]) VALUES (5, ‘P1’, 1); |
OK |
| 3 |
Update [Banlance] Set Banlance = Banlance- 1 Where ProductId = ‘P1’ |
|
OK |
| 4 |
|
Update [Banlance] Set Banlance = Banlance- 1 Where ProductId = ‘P1’ |
Wait… |
| 5 |
Update [Banlance] Set Banlance = Banlance- 1 Where ProductId = ‘P2’ |
|
OK |
| 6 |
commit |
|
OK |
| 7 |
|
-- 指令 4 開始執行 |
OK |
| 8 |
|
Update [Banlance] Set Banlance = Banlance- 1 Where ProductId = ‘P2’ |
OK |
| 9 |
|
commit |
OK |
通過排序,我們很好的避免了死鎖問題。
實際情況是可能沒有對應的商品行,需要提前插入數據,讓我們看看沒有P1的情況。(為簡化示例,省去流水賬的SQL,改出庫為入庫,僅入庫P1)。
| 順序 Order |
用戶1 User 1 |
用戶2 User 2 |
狀態 Status |
| 1 |
begin tran |
begin tran |
OK |
|
|
… |
… |
|
| 2 |
Update [Banlance] Set Banlance = Banlance + 1 Where ProductId = ‘P1’ |
-- 沒有影響行 |
OK |
| 3 |
|
Update [Banlance] Set Banlance = Banlance + 1 Where ProductId = ‘P1’ -- 沒有影響行 |
OK |
| 4 |
Insert Into [Banlance]
(ProductId,Banlance)
Values(‘P1’,1);
|
|
OK |
| 5 |
|
Insert Into [Banlance]
(ProductId,Banlance)
Values(‘P1’,1);
|
Wait |
| 6 |
commit |
|
OK |
| 7 |
|
-- 指令 5 開始執行 -- 重復的鍵 |
Error |
| 8 |
|
-- 已存在記錄,改執行Update Update [Banlance] Set Banlance = Banlance + 1 Where ProductId = ‘P1’ |
OK
|
| 9 |
|
commit |
OK |
由於第二個insert語句插入相同主鍵的數據,所以出現等待,當用戶1提交后,可以發現用戶2執行的insert會失敗,改執行update語句。這樣也能解決問題,只是非常蹩腳。
如果你使用SQL Server 2008及其以后的版本,可以使用新的語法解決這個問題。
| 順序 Order |
用戶1 User 1 |
用戶2 User 2 |
狀態 Status |
| 1 |
begin tran |
begin tran |
OK |
|
|
… |
… |
|
| 2 |
MERGE [Banlance] AS target
USING (SELECT ‘P1’) AS source (ProductId)
ON (target.ProductId = source.ProductId)
WHEN MATCHED THEN
UPDATE SET Banlance = Banlance + 1
WHEN NOT MATCHED THEN
INSERT (ProductId, Banlance)
VALUES (‘P1’,1);
|
|
OK |
| 3 |
|
MERGE [Banlance] AS target
USING (SELECT ‘P1’) AS source (ProductId)
ON (target.ProductId = source.ProductId)
WHEN MATCHED THEN
UPDATE SET Banlance = Banlance + 1
WHEN NOT MATCHED THEN
INSERT (ProductId, Banlance)
VALUES (‘P1’,1);
|
Wait |
| 4 |
commit |
|
OK |
| 5 |
|
commit |
OK |
第一個用戶會插入數據,第二個用戶會執行更新操作。
解決方案二:廢棄余額表
第二個方案直接廢除余額表,這樣就從根本上避免了資源的死鎖問題。當然,沒有了余額表,要獲知當前余額,就需要稍微改動現在的流水表。
| 流水號 NO. |
期間 Period |
來源 Source |
商品編號 ProductId |
增加 Add |
減少 Reduce |
| 1 |
2 |
|
P1 |
10 |
|
| 2 |
2 |
|
P2 |
5 |
|
| 3 |
2 |
S1 |
P1 |
3 |
|
| 4 |
2 |
S2 |
P1 |
|
1 |
| 5 |
2 |
S1 |
P2 |
5 |
|
創建表的SQL如下:
CREATE TABLE [dbo].[Chronological] (
[NO] INT NOT NULL,
[Period] INT NOT NULL,
[Source] NVARCHAR (20) NOT NULL,
[ProductId] INT NOT NULL,
[Add] DECIMAL (18) NOT NULL,
[Reduce] DECIMAL (18) NOT NULL,
PRIMARY KEY CLUSTERED ([NO] ASC)
);
通過將相同商品編號的數據分組並sum,即可獲取其剩余的庫存,例如獲取P1的庫存。
select Top 1 ProductId,sum([Add]) - sum([Reduce]) from Chronological where ProductId = ’P1’ and Period = 2
group by ProductId ;
這里我們使用了區間(Period)來減少數據匯總對應的計算量。來源(Source)如果是空,那么表示此行是期初數據,他在結轉期間時創建。
如果你覺得這樣做還不夠,也可以設計一張流水歷史表,將所有非當前區間的數據放在流水歷史表,這樣當前表(Chronological)的數據量就比較小了,也不要區間(Period)字段了。
另外,如果你加入【發生日期】字段,將達到另外一個好處,可以查詢任意時間點的庫存了,而不必像之前余額表那樣僅能查詢當前庫存。
最后,如果你希望出庫時不允許負庫存怎么辦?由於Insert執行不能進行檢查,再下一條SQL會不會造成查詢的庫存錯誤的統計到別人尚未提交的庫存記錄呢?其實不必擔心,只要在查詢庫存時僅包括比自己記錄小的記錄即可。
| 順序 Order |
用戶1 User 1 |
用戶2 User 2 |
狀態 Status |
| 1 |
begin tran |
begin tran |
OK |
| 2 |
insert into [Chronological] ([NO],[Period],[Source],[ProductId],[Add],[Reduce]) --OUTPUT INSERTED.[NO] values(6,2,'S3',‘P2’,0,9); |
|
OK |
| 3 |
|
insert into [Chronological] ([NO],[Period],[Source],[ProductId],[Add],[Reduce]) --OUTPUT INSERTED.[NO] values(7,2,'S4',‘P2’,0,5); |
OK |
| 4 |
select TOP 1 ProductId,sum([Add]) - sum([Reduce]) from Chronological
with(NOLOCK)
where ProductId = ‘P1’ and Period = 2 and [NO] <= 6
group by ProductId ;
|
|
OK |
| 5 |
|
select TOP 1 ProductId,sum([Add]) - sum([Reduce]) from Chronological
with(NOLOCK)
where ProductId = ‘P1’ and Period = 2 and [NO] <= 7
group by ProductId ;
|
wait 或OK |
| 6 |
commit |
|
OK |
| 7 |
|
rollback |
OK |
注意的細節是我在select時加入了 with (NOLOCK),數據庫默認是沒有啟用“已提交快照讀”,所以在執行第5句話時會出現wait,從而在執行6后,訪問的是最新的結果。但如果數據庫啟用了“已提交快照讀”,那么第5句不會wait,且返回5這個不正確的結果。所以我在sql中加入了with(NOLOCK)保證無論何種情況下都檢查正確。

