编写一个简单的银行模式:我应该如何保持我的余额与他们的交易历史同步?

Nic*_*mas 70 sql-server-2008 database-design sql-server aggregate materialized-view

我正在为一个简单的银行数据库编写架构。以下是基本规格:

  • 数据库将存储针对用户和货币的交易。
  • 每个用户每种货币都有一个余额,因此每个余额只是针对给定用户和货币的所有交易的总和。
  • 余额不能为负。

银行应用程序将专门通过存储过程与其数据库通信。

我希望这个数据库每天接受数十万个新事务,以及更高数量级的平衡查询。为了非常快速地提供余额,我需要预先汇总它们。同时,我需要保证余额永远不会与其交易历史相矛盾。

我的选择是:

  1. 有一个单独的balances并执行以下操作之一:

    1. 将事务应用于transactionsbalances表。TRANSACTION在我的存储过程层使用逻辑来确保余额和交易始终同步。(由杰克支持。)

    2. 将交易应用到transactions表并有一个触发器,balances用交易金额为我更新表。

    3. 将交易应用到balances表中,并有一个触发器transactions为我在表中添加一个新条目,其中包含交易金额。

    我必须依靠基于安全的方法来确保在存储过程之外不能进行任何更改。否则,例如,某些进程可以直接将事务插入transactions表中,并且在该方案1.3下相关余额将不同步。

  2. 有一个balances索引视图,可以适当地聚合事务。存储引擎保证余额与其交易保持同步,因此我不需要依赖基于安全的方法来保证这一点。另一方面,我不能再强制余额为非负数,因为视图——甚至索引视图——不能有CHECK约束。(由丹尼支持。)

  3. 只有一个transactions表,但有一个额外的列来存储该交易执行后立即生效的余额。因此,用户和货币的最新交易记录也包含其当前余额。(下面由Andrew建议;由garik提出的变体。)

当我第一次解决这个问题时,我阅读了 两个讨论并决定了 option 2。作为参考,您可以在此处查看它的基本实现。

  • 您是否设计或管理过这样的具有高负载配置文件的数据库?你对这个问题的解决方案是什么?

  • 你认为我做出了正确的设计选择吗?有什么我应该记住的吗?

    例如,我知道对transactions表的架构更改需要我重建balances视图。即使我正在归档事务以保持数据库较小(例如,将它们移到其他地方并用摘要事务替换它们),每次架构更新时都必须重建数千万个事务的视图,这可能意味着每次部署的停机时间会显着增加。

  • 如果索引视图是要走的路,我如何保证没有余额为负?


归档交易:

让我详细说明一下存档交易和我上面提到的“摘要交易”。首先,在像这样的高负载系统中,定期存档将是必要的。我想保持余额与其交易历史之间的一致性,同时允许将旧交易转移到其他地方。为此,我将用每个用户和货币的金额摘要替换每批存档交易。

因此,例如,此交易列表:

user_id    currency_id      amount    is_summary
------------------------------------------------
      3              1       10.60             0
      3              1      -55.00             0
      3              1      -12.12             0
Run Code Online (Sandbox Code Playgroud)

已存档并替换为:

user_id    currency_id      amount    is_summary
------------------------------------------------
      3              1      -56.52             1
Run Code Online (Sandbox Code Playgroud)

通过这种方式,具有存档交易的余额可以保持完整且一致的交易历史记录。

And*_*ton 17

要考虑的一种稍微不同的方法(类似于您的第二个选项)是只有事务表,其定义为:

CREATE TABLE Transaction (
      UserID              INT
    , CurrencyID          INT 
    , TransactionDate     DATETIME  
    , OpeningBalance      MONEY
    , TransactionAmount   MONEY
);
Run Code Online (Sandbox Code Playgroud)

您可能还需要交易 ID/订单,以便您可以处理具有相同日期的两个交易并改进检索查询。

要获得当前余额,您只需要获得最后一条记录即可。

获取最后一条记录的方法

/* For a single User/Currency */
Select TOP 1 *
FROM dbo.Transaction
WHERE UserID = 3 and CurrencyID = 1
ORDER By TransactionDate desc

/* For multiple records ie: to put into a view (which you might want to index) */
SELECT
    C.*
FROM
    (SELECT 
        *, 
        ROW_NUMBER() OVER (
           PARTITION BY UserID, CurrencyID 
           ORDER BY TransactionDate DESC
        ) AS rnBalance 
    FROM Transaction) C
WHERE
    C.rnBalance = 1
ORDER BY
    C.UserID, C.CurrencyID
Run Code Online (Sandbox Code Playgroud)

缺点:

  • 当不按顺序插入交易时(即:纠正问题/不正确的起始余额),您可能需要对所有后续交易进行级联更新。
  • 用户/货币的交易需要序列化以保持准确的平衡。

    -- Example of getting the current balance and locking the 
    -- last record for that User/Currency.
    -- This lock will be freed after the Stored Procedure completes.
    SELECT TOP 1 @OldBalance = OpeningBalance + TransactionAmount  
    FROM dbo.Transaction with (rowlock, xlock)   
    WHERE UserID = 3 and CurrencyID = 1  
    ORDER By TransactionDate DESC;
    
    Run Code Online (Sandbox Code Playgroud)

优点:

  • 您不再需要维护两个单独的表...
  • 您可以轻松地验证余额,当余额不同步时,您可以准确地识别出它何时不正常,因为交易历史记录成为自我记录。

编辑:关于检索当前余额并突出显示骗局的一些示例查询(感谢@Jack Douglas)

  • `SELECT TOP (1) ... ORDER BY TransactionDate DESC` 实现起来非常棘手,因为 SQL Server 不会经常扫描事务表。Alex Kuznetsov 发布了 [a solution here](http://dba.stackexchange.com/a/19366/2660) 到一个类似的设计问题,完美地补充了这个答案。 (3认同)

A-K*_*A-K 17

我不熟悉会计,但我在库存类型的环境中解决了一些类似的问题。我将运行总计与事务存储在同一行中。我正在使用约束,因此即使在高并发下我的数据也永远不会出错。我在 2009 年写了以下解决方案

无论您是使用游标还是三角形连接,计算运行总计都非常缓慢。非规范化非常诱人,将运行总计存储在列中,特别是如果您经常选择它。但是,像往常一样,在进行非规范化时,您需要保证非规范化数据的完整性。幸运的是,您可以通过约束保证运行总计的完整性——只要您的所有约束都是可信的,您的所有运行总计都是正确的。此外,通过这种方式,您可以轻松确保当前余额(运行总计)永远不会为负 - 通过其他方法执行也可能非常缓慢。以下脚本演示了该技术。

CREATE TABLE Data.Inventory(InventoryID INT NOT NULL IDENTITY,
  ItemID INT NOT NULL,
  ChangeDate DATETIME NOT NULL,
  ChangeQty INT NOT NULL,
  TotalQty INT NOT NULL,
  PreviousChangeDate DATETIME NULL,
  PreviousTotalQty INT NULL,
  CONSTRAINT PK_Inventory PRIMARY KEY(ItemID, ChangeDate),
  CONSTRAINT UNQ_Inventory UNIQUE(ItemID, ChangeDate, TotalQty),
  CONSTRAINT UNQ_Inventory_Previous_Columns 
     UNIQUE(ItemID, PreviousChangeDate, PreviousTotalQty),
  CONSTRAINT FK_Inventory_Self FOREIGN KEY(ItemID, PreviousChangeDate, PreviousTotalQty)
    REFERENCES Data.Inventory(ItemID, ChangeDate, TotalQty),
  CONSTRAINT CHK_Inventory_Valid_TotalQty CHECK(
         TotalQty >= 0 
     AND (TotalQty = COALESCE(PreviousTotalQty, 0) + ChangeQty)
  ),
  CONSTRAINT CHK_Inventory_Valid_Dates_Sequence CHECK(PreviousChangeDate < ChangeDate),
  CONSTRAINT CHK_Inventory_Valid_Previous_Columns CHECK(
        (PreviousChangeDate IS NULL AND PreviousTotalQty IS NULL)
     OR (PreviousChangeDate IS NOT NULL AND PreviousTotalQty IS NOT NULL)
  )
);

-- beginning of inventory for item 1
INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)
VALUES(1, '20090101', 10, 10, NULL, NULL);

-- cannot begin the inventory for the second time for the same item 1
INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)
VALUES(1, '20090102', 10, 10, NULL, NULL);


Msg 2627, Level 14, State 1, Line 10

Violation of UNIQUE KEY constraint 'UNQ_Inventory_Previous_Columns'. 
Cannot insert duplicate key in object 'Data.Inventory'.

The statement has been terminated.


-- add more
DECLARE @ChangeQty INT;
SET @ChangeQty = 5;

INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)

SELECT TOP 1 ItemID, '20090103', @ChangeQty, TotalQty + @ChangeQty, ChangeDate, TotalQty
  FROM Data.Inventory
  WHERE ItemID = 1
  ORDER BY ChangeDate DESC;

SET @ChangeQty = 3;

INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)

SELECT TOP 1 ItemID, '20090104', @ChangeQty, TotalQty + @ChangeQty, ChangeDate, TotalQty
  FROM Data.Inventory
  WHERE ItemID = 1
  ORDER BY ChangeDate DESC;

SET @ChangeQty = -4;

INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)

SELECT TOP 1 ItemID, '20090105', @ChangeQty, TotalQty + @ChangeQty, ChangeDate, TotalQty
  FROM Data.Inventory
  WHERE ItemID = 1
  ORDER BY ChangeDate DESC;

-- try to violate chronological order
SET @ChangeQty = 5;

INSERT INTO Data.Inventory(ItemID,
  ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty)

SELECT TOP 1 ItemID, '20081231', @ChangeQty, TotalQty + @ChangeQty, ChangeDate, TotalQty
  FROM Data.Inventory
  WHERE ItemID = 1
  ORDER BY ChangeDate DESC;

Msg 547, Level 16, State 0, Line 4

The INSERT statement conflicted with the CHECK constraint 
"CHK_Inventory_Valid_Dates_Sequence". 
The conflict occurred in database "Test", table "Data.Inventory".

The statement has been terminated.

SELECT ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty
FROM Data.Inventory ORDER BY ChangeDate;

ChangeDate              ChangeQty   TotalQty    PreviousChangeDate      PreviousTotalQty
----------------------- ----------- ----------- ----------------------- -----
2009-01-01 00:00:00.000 10          10          NULL                    NULL
2009-01-03 00:00:00.000 5           15          2009-01-01 00:00:00.000 10
2009-01-04 00:00:00.000 3           18          2009-01-03 00:00:00.000 15
2009-01-05 00:00:00.000 -4          14          2009-01-04 00:00:00.000 18


-- try to change a single row, all updates must fail
UPDATE Data.Inventory SET ChangeQty = ChangeQty + 2 WHERE InventoryID = 3;
UPDATE Data.Inventory SET TotalQty = TotalQty + 2 WHERE InventoryID = 3;

-- try to delete not the last row, all deletes must fail
DELETE FROM Data.Inventory WHERE InventoryID = 1;
DELETE FROM Data.Inventory WHERE InventoryID = 3;

-- the right way to update
DECLARE @IncreaseQty INT;

SET @IncreaseQty = 2;

UPDATE Data.Inventory 
SET 
     ChangeQty = ChangeQty 
   + CASE 
        WHEN ItemID = 1 AND ChangeDate = '20090103' 
        THEN @IncreaseQty 
        ELSE 0 
     END,
  TotalQty = TotalQty + @IncreaseQty,
  PreviousTotalQty = PreviousTotalQty + 
     CASE 
        WHEN ItemID = 1 AND ChangeDate = '20090103' 
        THEN 0 
        ELSE @IncreaseQty 
     END
WHERE ItemID = 1 AND ChangeDate >= '20090103';

SELECT ChangeDate,
  ChangeQty,
  TotalQty,
  PreviousChangeDate,
  PreviousTotalQty
FROM Data.Inventory ORDER BY ChangeDate;

ChangeDate              ChangeQty   TotalQty    PreviousChangeDate      PreviousTotalQty
----------------------- ----------- ----------- ----------------------- ----------------
2009-01-01 00:00:00.000 10          10          NULL                    NULL
2009-01-03 00:00:00.000 7           17          2009-01-01 00:00:00.000 10
2009-01-04 00:00:00.000 3           20          2009-01-03 00:00:00.000 17
2009-01-05 00:00:00.000 -4          16          2009-01-04 00:00:00.000 20
Run Code Online (Sandbox Code Playgroud)


mrd*_*nny 14

不允许客户的余额低于 0 是一项商业规则(这会很快发生变化,因为像透支这样的费用是银行赚取大部分资金的方式)。当行被插入到事务历史中时,您需要在应用程序处理中处理这个问题。特别是因为您最终可能会遇到一些客户有透支保护和一些收取费用以及一些不允许输入负金额的情况。

到目前为止,我喜欢你的做法,但如果这是一个实际项目(而不是学校),则需要在业务规则等方面深思熟虑。一旦你建立了一个银行系统并且运行没有很大的重新设计空间,因为有关于人们可以使用他们的钱的非常具体的法律。


Jac*_*las 13

在阅读了这两个讨论后,我决定了选项 2

也阅读了这些讨论后,我不确定您为什么选择DRI解决方案而不是您概述的最明智的其他选项:

将交易应用于交易和余额表。在我的存储过程层中使用 TRANSACTION 逻辑来确保余额和交易始终同步。

如果您可以通过事务 API限制对数据的所有访问,这种解决方案具有巨大的实际好处。你失去了 DRI 的一个非常重要的好处,即完整性由数据库保证,但在任何足够复杂的模型中,都会有一些 DRI 无法执行的业务规则

我建议在可能的情况下使用 DRI 来强制执行业务规则,而不会过度弯曲您的模型以使其成为可能:

即使我正在归档交易(例如将它们移到其他地方并用汇总交易替换它们)

一旦你开始考虑像这样污染你的模型,我认为你正在进入一个 DRI 的好处被你引入的困难所抵消的领域。例如,考虑到归档过程中的错误在理论上可能会导致您的黄金法则(余额始终等于交易总和)在DRI 解决方案中悄然中断

以下是我所看到的事务方法的优点的总结:

  • 如果可能的话,无论如何我们都应该这样做。无论您为此特定问题选择何种解决方案,它都能为您提供更多的设计灵活性和对数据的控制。然后,所有访问就业务逻辑而言成为“事务性的”,而不仅仅是在数据库逻辑方面。
  • 您可以保持模型整洁
  • 您可以“执行”范围更广、更复杂的业务规则(请注意,“执行”的概念比 DRI 更宽松)
  • 您仍然可以在任何可行的情况下使用 DRI 来为模型提供更强大的底层完整性 - 这可以作为对您的事务逻辑的检查
  • 大多数困扰您的性能问题都会消失
  • 引入新要求会容易得多 - 例如:有争议交易的复杂规则可能会迫使您进一步远离纯 DRI 方法,这意味着大量浪费
  • 历史数据的分区或归档变得更不危险和痛苦

- 编辑

为了在不增加复杂性或风险的情况下进行存档,您可以选择将汇总行保留在一个单独的汇总表中,连续生成(从@Andrew 和@Garik 借用)

例如,如果摘要是每月的:

  • 每次有交易(通过您的 API)时,都会有相应的更新或插入到汇总表中
  • 汇总表永远不会归档,但归档事务变得像删除(或删除分区?)
  • 汇总表中的每一行包括“期初余额”和“金额”
  • 可以将'期初余额'+'金额'>0 和'期初余额'>0 等检查约束应用于汇总表
  • 可以将汇总行插入到每月批次中,以便更轻松地锁定最新的汇总行(当月总会有一行)


gar*_*rik 6

缺口。

主要思想是将余额和交易记录存储在同一个表中。我认为它发生在历史上。所以在这种情况下,我们可以通过定位最后一个摘要记录来获得平衡。

 id   user_id    currency_id      amount    is_summary (or record_type)
----------------------------------------------------
  1       3              1       10.60             0
  2       3              1       10.60             1    -- summary after transaction 1
  3       3              1      -55.00             0
  4       3              1      -44.40             1    -- summary after transactions 1 and 3
  5       3              1      -12.12             0
  6       3              1      -56.52             1    -- summary after transactions 1, 3 and 5 
Run Code Online (Sandbox Code Playgroud)

更好的变体是减少摘要记录的数量。我们可以在一天结束(和/或开始)有一个余额记录。如您所知,每家银行都operational day必须打开而不是关闭它才能为这一天做一些总结性操作。它使我们可以通过使用每日余额记录轻松计算利息,例如:

user_id    currency_id      amount    is_summary    oper_date
--------------------------------------------------------------
      3              1       10.60             0    01/01/2011 
      3              1      -55.00             0    01/01/2011
      3              1      -44.40             1    01/01/2011 -- summary at the end of day (01/01/2011)
      3              1      -12.12             0    01/02/2011
      3              1      -56.52             1    01/02/2011 -- summary at the end of day (01/02/2011)
Run Code Online (Sandbox Code Playgroud)

运气。