余額表的設計


原由

在開發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)保證無論何種情況下都檢查正確。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM