簡単な銀行スキーマの作成:残高を取引履歴と同期させるにはどうすればよいですか?


57

単純な銀行データベースのスキーマを書いています。基本的な仕様は次のとおりです。

  • データベースは、ユーザーと通貨に対するトランザクションを保存します。
  • すべてのユーザーは通貨ごとに1つの残高を持っているため、各残高は特定のユーザーと通貨に対するすべてのトランザクションの合計です。
  • 残高をマイナスにすることはできません。

銀行のアプリケーションは、ストアドプロシージャを介してデータベースとのみ通信します。

このデータベースは、1日に数十万件の新しいトランザクションを受け入れ、さらに高いレベルでクエリのバランスを取ることを期待しています。残高を非常に迅速に提供するには、事前に集計する必要があります。同時に、残高が取引履歴と矛盾しないことを保証する必要があります。

私のオプションは次のとおりです。

  1. 別のbalancesテーブルを用意して、次のいずれかを実行します。

    1. トランザクションをテーブルtransactionsbalancesテーブルの両方に適用します。TRANSACTIONストアドプロシージャレイヤーのロジックを使用して、残高とトランザクションが常に同期されるようにします。(Jackによるサポート。)

    2. transactionsテーブルにトランザクションを適用balancesし、トランザクション量でテーブルを更新するトリガーを使用します。

    3. balancesテーブルにトランザクションを適用transactionsし、トランザクション量とともにテーブルに新しいエントリを追加するトリガーを使用します。

    ストアドプロシージャの外部で変更が行われないようにするには、セキュリティベースのアプローチに頼る必要があります。そうしないと、たとえば、一部のプロセスがtransactionsテーブルにトランザクションを直接挿入し、スキーム1.3の下で関連するバランスが同期しなくなる可能性があります。

  2. balancesトランザクションを適切に集約するインデックス付きビューを用意します。残高はトランザクションと同期するようにストレージエンジンによって保証されているため、これを保証するためにセキュリティベースのアプローチに依存する必要はありません。一方、ビュー(インデックス付きビューでも)にCHECK制約を設定することはできないため、バランスを負以外に強制することはできません。(Dennyによるサポート。)

  3. transactionsテーブルだけがありますが、そのトランザクションの実行直後に有効な残高を保存するための追加の列があります。したがって、ユーザーと通貨の最新のトランザクションレコードには、現在の残高も含まれます。(Andrewが以下に提案。garikが提案したバリアント。)

この問題に最初に取り組んだとき、私はこれら 2つの議論を読み、オプションを決定しました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'sおよびKoppelaarsのデータベース専門家向け応用数学の第11章(タイトルについては心配しないでください)は、「遷移制約」を効率的に実装する方法を理解するのに役立ちます。最初のリンクはPostgreSQL用で、2番目のリンクはOracle用ですが、この手法はあらゆる合理的なデータベースシステムで機能するはずです。
jp

理論的には、#3を実行します。「ランニングバランス」を行う正しい方法は、各トランザクションにバランスを割り当てることです。シリアルID(推奨)またはタイムスタンプを使用して、トランザクションを確実に順序付けできることを確認してください。実際には、ランニングバランスを「計算」することは想定されていません。
pbreitenbach

回答:


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
なぜ残高制約が実際にビジネスルールである必要があるのか​​がわかります。データベースはトランザクションサービスを提供しているだけであり、それをどう処理するかはユーザー次第です。
ニックチャマス

2つのテーブルを使用すると、開発者がビジネスロジックを変更または実装する際の柔軟性が高まるというJackのコメントについてどう思いますか?また、これらの懸念を検証または挑戦するインデックス付きビューの直接的な経験はありますか?
ニックチャマス

1
2つのテーブルを使用して柔軟性を高めることで、ビジネスロジックを実装するとは言いません。データをアーカイブする際の柔軟性が向上します。ただし、銀行(少なくとも米国)には、保持する必要のあるデータの量を規定する法律があります。一番上のビューでパフォーマンスがどのように見えるかをテストし、インデックス付きビューがある場合は、基になるテーブルのスキーマを変更できないことを考慮します。考えてみてください。
mrdenny

この記事に記載されているすべての項目は、インデックス付きビューを使用する際の重要な懸念事項です。
mrdenny

1
明確にするために:IMOのトランザクションAPIは、ビジネスロジック(2つのテーブルを持たない)をより柔軟に実装できます。この場合、インデックス付きビューアプローチで提案されたトレードオフのために、2つのテーブル(少なくともこれまでの情報が与えられている場合)を優先します(たとえば、DRIを使用してbalance> 0ビジネスを強制することはできません)ルール)
ジャックダグラス

13

考慮すべきわずかに異なるアプローチ(2番目のオプションに似ています)は、次の定義を持つトランザクションテーブルのみを持つことです。

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

また、同じ日付の2つのトランザクションを処理し、検索クエリを改善できるように、トランザクション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;

長所:

  • 2つの個別のテーブルを維持する必要はなくなりました...
  • 残高を簡単に検証できます。また、残高が同期しなくなった場合、取引履歴が自己文書化されるため、バランスが崩れたときを正確に特定できます。

編集:現在の残高の取得と詐欺を強調するためのいくつかのサンプルクエリ(ありがとう@ジャックダグラス)


3
SELECT TOP (1) ... ORDER BY TransactionDate DESCSQL Serverは常にトランザクションテーブルをスキャンしないような方法で実装するのは非常に難しいだろう。Alex Kuznetsovはこの答えを完全に補完する同様の設計問題に対する解決策をここに投稿しまし
ニックチャマス

2
+1同様のアプローチを使用しています。ところで、私たちは非常に注意する必要があり、コードが並行ワークロードの下で正しく動作することを確認する必要があります。
AK

12

これら2つの議論を読んだ後、オプション2に決めました

それらの議論も読んだので、あなたが概説した他の最も賢明なオプションよりもDRIソリューションを選んだ理由はわかりません。

トランザクションとトランザクションテーブルの両方にトランザクションを適用します。ストアドプロシージャレイヤーでTRANSACTIONロジックを使用して、残高とトランザクションが常に同期されるようにします。

トランザクションAPIを介したデータへのすべてのアクセスを制限する余裕がある場合、この種のソリューションには大きな実用上の利点があります。DRIの非常に重要な利点は失われます。つまり、データベースによって整合性が保証されますが、十分に複雑なモデルでは、DRIによって実施できないビジネスルールがいくつかあります

モデルを過度に曲げることなくビジネスルールを実施できるように、可能であればDRIを使用することをお勧めします。

トランザクションをアーカイブしている場合でも(たとえば、トランザクションを別の場所に移動し、サマリートランザクションに置き換えて)

このようにモデルを汚染することを検討し始めるとすぐに、DRIの利点が導入している困難に勝る領域に移動していると思います。たとえば、アーカイブプロセスのバグにより、理論上、ゴールデンルール(バランスは常にトランザクションの合計に等しい)がDRIソリューションで静か中断する可能性があることを考慮してください。

トランザクションアプローチの利点の概要を次に示します。

  • 可能な限りこれを行う必要があります。この特定の問題に対してどのようなソリューションを選択しても、設計の柔軟性が高まり、データを制御できます。すべてのアクセスは、データベースロジックの観点からではなく、ビジネスロジックの観点から「トランザクション」になります。
  • モデルをきれいに保つことができます
  • ビジネスルールのはるかに広い範囲と複雑さを「実施」できます(「実施」の概念はDRIよりも緩いものであることに注意してください)
  • 実用的な場合はいつでもDRIを使用して、モデルに、より堅牢な基本整合性を与えることができます。これは、トランザクションロジックのチェックとして機能できます。
  • 厄介なパフォーマンスの問題のほとんどは解消されます
  • 新しい要件の導入ははるかに簡単になります-たとえば:紛争のあるトランザクションの複雑なルールは、純粋なDRIアプローチからさらに遠ざかる可能性があり、多くの無駄な努力を意味します
  • 履歴データのパーティション化またはアーカイブは、リスクと痛みを大幅に軽減します

-編集

複雑さやリスクを追加せずにアーカイブできるようにするには、継続的に生成される個別のサマリーテーブルにサマリー行を保持することを選択できます(@Andrewと@Garikからの借用)

たとえば、要約が毎月の場合:

  • (APIを介して)トランザクションが発生するたびに、対応する更新またはサマリーテーブルへの挿入が行われます。
  • サマリーテーブルは決してアーカイブされませんが、トランザクションのアーカイブは単純に削除(またはパーティションの削除)になります。
  • サマリーテーブルの各行には、「期首残高」と「金額」が含まれます。
  • 「期首残高」+「金額」> 0および「期首残高」> 0などのチェック制約をサマリー表に適用できます。
  • サマリー行を毎月のバッチに挿入して、最新のサマリー行のロックを簡単にすることができます(常に今月の行があります)

編集について:では、この要約テーブルをメインの残高テーブルと一緒に提案しますか?残高表は、今月のレコードのみを持つ要約表になりますか(両方とも同じ種類のデータを保存するため)?正しく理解できた場合は、残高表をサマリー表の適切なパーティションに置き換えるだけではどうですか?
ニックチャマス

申し訳ありませんが、それは明確ではありません-現在の残高を取得するために常に要約テーブルでのキー検索であるため、残高テーブルを使用しませんでした(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 

より良いバリアントは、要約レコードの数を減らすことです。1日の終わり(および/または始まり)に1つの残高レコードを保持できます。ご存知のように、すべての銀行は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
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.