编写一个简单的银行架构:如何使我的余额与他们的交易记录保持同步?


57

我正在为一个简单的银行数据库编写模式。基本规格如下:

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

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

我希望该数据库每天可以接受成千上万的新交易,并且可以平衡更高数量级的查询。要非常快地用完余额,我需要预先对其进行汇总。同时,我需要保证余额永远不会与其交易历史相矛盾。

我的选择是:

  1. 有一个单独的balances表,然后执行下列操作之一:

    1. 将交易应用到transactionsbalances表。TRANSACTION在存储过程层中使用逻辑,以确保余额和交易始终保持同步。(由Jack支持。)

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

    3. 将事务应用于balances表,并具有一个触发器,该触发器transactions为我在表中添加一个具有事务量的新条目。

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

  2. 有一个balances索引视图可以适当地汇总事务。存储引擎保证余额与事务保持同步,因此我不需要依靠基于安全性的方法来保证这一点。另一方面,由于视图-甚至是索引视图-都没有CHECK约束,因此我不能再将余额强制为非负数。(由Denny支持。)

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

当我第一次解决这个问题时,我阅读了 两个讨论并决定选择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

已归档并替换为:

user_id    currency_id      amount    is_summary
------------------------------------------------
      3              1      -56.52             1

这样,带有已归档事务的余额将保持完整且一致的事务历史记录。


1
如果选择选项2(我认为更干净),请查看pgcon.org/2008/schedule/attachments/…如何有效地实现“物化视图”。对于选项1,Haan和Koppelaars的“ 数据库专业人员应用数学 ”的第11章(不必担心标题)将有助于您了解如何有效地实现“转换约束”。第一个链接用于PostgreSQL,第二个链接用于Oracle,但是该技术应适用于任何合理的数据库系统。
jp

从理论上讲,您想做#3。进行“运行余额”的正确方法是为每个事务分配余额。确保可以使用序列号(首选)或时间戳确定地订购事务。您真的不应该“计算”出一个平衡。
pbreitenbach

Answers:


15

我不熟悉会计,但是我解决了库存型环境中的一些类似问题。我将运行总计与事务存储在同一行中。我正在使用约束,因此即使在高并发情况下,我的数据也不会出错。我在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

14

不允许客户的余额少于0是一项业务规则(随着超支之类的费用是银行赚钱的主要方式,这会迅速改变)。当将行插入到事务历史记录中时,您将需要在应用程序处理中处理此问题。尤其是当您最终获得一些具有透支保护的客户,有些客户需要收取一定的费用,而另一些客户则不允许输入负数。

到目前为止,我喜欢您要进行的操作,但是,如果这是针对实际项目(而不是学校)的,则需要在业务规则等方面进行大量思考,等等。一旦建立了银行系统,而且运行方面没有太多的重新设计的空间,因为关于人们有钱的人有非常具体的法律。


1
我可以看到为什么余额约束实际上应该是业务规则。该数据库仅提供事务服务,并且由用户决定如何处理它。
Nick Chammas

您如何看待Jack的评论,即使用这两个表可使开发人员在更改或实现业务逻辑时更具灵活性?另外,您是否对索引视图有直接的经验,可以验证或质疑这些问题
Nick Chammas

1
我不会说拥有两个表为您提供了移动灵活性,这是在实现业务逻辑。它确实为您提供了更大的数据归档灵活性。但是,作为一家银行(至少在美国是这样),您有法律规定您需要保留多少数据。您将要测试性能在顶部视图下的外观,并考虑到如果您拥有索引视图,则无法更改基础表的架构。只是要考虑的另一件事。
mrdenny

本文中提到的所有项目都是使用索引视图时的有效关注点。
mrdenny

1
需要说明的是:IMO事务性API为实现业务逻辑(没有两个表)提供了更大的灵活性。在这种情况下,我也赞成使用两个表(至少考虑到目前为止我们所拥有的信息),这是因为采用索引视图方法提出了折衷方案(例如,不能再使用DRI来执行balance> 0业务规则)
杰克·道格拉斯

13

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

CREATE TABLE Transaction (
      UserID              INT
    , CurrencyID          INT 
    , TransactionDate     DATETIME  
    , OpeningBalance      MONEY
    , TransactionAmount   MONEY
);

您可能还需要一个事务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

缺点:

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

    -- 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;

优点:

  • 您不再需要维护两个单独的表...
  • 您可以轻松地验证余额,并且当余额不同步时,您可以准确地确定余额何时从历史记录中自动记录下来。

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


3
SELECT TOP (1) ... ORDER BY TransactionDate DESC会是非常棘手的这样一种方式,SQL Server不经常扫描事务表来实现。亚历克斯·库兹涅佐夫(Alex Kuznetsov)在这里发布了一个解决方案,解决了类似的设计问题,从而完美地补充了这一答案。
Nick Chammas 2012年

2
+1我正在使用类似的方法。顺便说一句,我们需要非常小心,并确保我们的代码在并发工作负载下能够正常工作。
2012年

12

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

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

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

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

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

即使我正在归档交易(例如,将其移动到其他位置并用汇总交易替换)

当您开始考虑像这样污染模型时,我认为您正在进入一个领域,在此领域,DRI的好处已被引入的困难所抵消。例如,考虑一下您的归档过程中的错误理论上可能会导致您的黄金法则(余额始终等于事务之和)被DRI解决方案静默破坏

以下是我所看到的事务处理方法的优点的摘要:

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

- 编辑

为了允许归档而又不增加复杂性或风险,您可以选择将摘要行保留在连续生成的单独的摘要表中(从@Andrew和@Garik借用)

例如,如果汇总是每月一次:

  • 每次有交易(通过您的API)时,都会有相应的更新或插入摘要表中
  • 摘要表永远不会存档,但是归档事务变得非常简单,例如删除(或删除分区?)
  • 汇总表中的每一行都包含“期初余额”和“金额”
  • 可以将诸如“期初余额” +“金额”> 0和“期初余额”> 0之类的检查约束应用于汇总表
  • 可以按月批量插入摘要行,以更轻松地锁定最新的摘要行(当月总是有一行)

关于您的修改:因此,您建议在主余额表旁边同时使用此汇总表?然后,余额表是否有效地变为仅包含当月记录的汇总表(因为两者都将存储相同类型的数据)?如果我理解正确,那为什么不只用汇总表上的适当分区替换余额表呢?
Nick Chammas

抱歉,您还不清楚,我的意思是-放弃余额表,因为它始终是摘要表上获取当前余额的关键查询(Andrews建议AFAIK并非如此)。这样做的好处是,以前的余额计算变得更加容易,并且如果出现错误,对余额的审核记录也会更加清晰。
杰克·道格拉斯

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 

一个更好的变体是减少摘要记录的数量。我们可以在一天的结束(和/或开始)有一个余额记录。如您所知,每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)

运气。


4

根据您的要求,选项1看起来最好。尽管我的设计只允许在事务表中插入数据。并在事务表上有触发器,以更新实时余额表。您可以使用数据库权限来控制对这些表的访问。

通过这种方法,可以保证实时余额与事务表同步。而且,无论使用存储过程还是使用psql或jdbc,都没有关系。如果需要,可以进行负余额检查。性能不会成为问题。为了获得实时平衡,它是一个单例查询。

归档不会影响此方法。您也可以每周,每月,每年汇总表,例如报表。


3

在Oracle中,您可以只使用带有快速刷新的实现视图的事务表来执行此操作,以进行汇总以形成余额。您可以在实例化视图上定义触发器。如果将物化视图定义为“ ON COMMIT”,则它将有效地防止在基表中添加/修改数据。触发器检测到[无效]数据并引发异常,从而回滚该事务。一个很好的例子在这里http://www.sqlsnippets.com/en/topic-12896.html

我不知道sqlserver,但也许它有类似的选择?


2
Oracle中的实例化视图类似于SQL Server的“索引视图”,但是它们会自动刷新,而不是以显式管理的方式(例如Oracle的“ ON COMMIT”行为)刷新。见social.msdn.microsoft.com/Forums/fi-FI/transactsql/thread/...techembassy.blogspot.com/2007/01/...
GregW
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.