web-dev-qa-db-fra.com

Écrire un schéma bancaire simple: Comment dois-je garder mes soldes en synchronisation avec l'historique de leurs transactions?

J'écris le schéma d'une base de données bancaire simple. Voici les spécifications de base:

  • La base de données stockera les transactions contre un utilisateur et une devise.
  • Chaque utilisateur a un solde par devise, donc chaque solde est simplement la somme de toutes les transactions contre un utilisateur et une devise donnés.
  • Un solde ne peut pas être négatif.

L'application bancaire communiquera avec sa base de données exclusivement par le biais de procédures stockées.

Je m'attends à ce que cette base de données accepte des centaines de milliers de nouvelles transactions par jour, ainsi que des requêtes de solde d'un ordre de grandeur plus élevé. Pour servir les soldes très rapidement, je dois les pré-agréger. En même temps, je dois garantir qu'un solde ne contredit jamais son historique de transactions.

Mes options sont:

  1. Disposez d'une table balances distincte et effectuez l'une des opérations suivantes:

    1. Appliquez des transactions aux tables transactions et balances. Utilisez la logique TRANSACTION dans ma couche de procédures stockées pour garantir que les soldes et les transactions sont toujours synchronisés. (Pris en charge par Jack .)

    2. Appliquez les transactions à la table transactions et disposez d'un déclencheur qui met à jour la table balances pour moi avec le montant de la transaction.

    3. Appliquer des transactions à la table balances et avoir un déclencheur qui ajoute pour moi une nouvelle entrée dans la table transactions avec le montant de la transaction.

    Je dois compter sur des approches basées sur la sécurité pour m'assurer qu'aucune modification ne peut être apportée en dehors des procédures stockées. Sinon, par exemple, certains processus pourraient directement insérer une transaction dans la table transactions et sous le schéma 1.3 le solde pertinent serait désynchronisé.

  2. Avoir une vue indexée balances qui agrège les transactions de manière appropriée. Les soldes sont garantis par le moteur de stockage pour rester synchronisés avec leurs transactions, donc je n'ai pas besoin de compter sur des approches basées sur la sécurité pour le garantir. D'un autre côté, je ne peux plus imposer que les soldes soient non négatifs car les vues - même les vues indexées - ne peuvent pas avoir de contraintes CHECK. (Pris en charge par Denny .)

  3. Avoir juste une table transactions mais avec une colonne supplémentaire pour stocker le solde effectif juste après l'exécution de cette transaction. Ainsi, le dernier enregistrement de transaction pour un utilisateur et une devise contient également leur solde actuel. (Suggérée ci-dessous par Andrew ; variante proposée par garik .)

Lorsque j'ai abordé ce problème pour la première fois, j'ai lu cesdeux discussions et décidé de l'option 2. Pour référence, vous pouvez voir une implémentation de celui-ci ici .

  • Avez-vous conçu ou géré une base de données comme celle-ci avec un profil de charge élevé? Quelle a été votre solution à ce problème?

  • Pensez-vous que j'ai fait le bon choix de conception? Y a-t-il quelque chose que je devrais garder à l'esprit?

    Par exemple, je sais que les modifications de schéma de la table transactions nécessiteront que je reconstruise la vue balances. Même si j'archive des transactions pour garder la base de données petite (par exemple en les déplaçant ailleurs et en les remplaçant par des transactions récapitulatives), devoir reconstruire la vue de dizaines de millions de transactions à chaque mise à jour de schéma signifiera probablement beaucoup plus de temps d'arrêt par déploiement.

  • Si la vue indexée est la voie à suivre, comment puis-je garantir qu'aucun solde n'est négatif?


Archivage des transactions:

Permettez-moi d'élaborer un peu sur l'archivage des transactions et les "transactions récapitulatives" que j'ai mentionnées ci-dessus. Premièrement, l'archivage régulier sera une nécessité dans un système à charge élevée comme celui-ci. Je veux maintenir la cohérence entre les soldes et leurs historiques de transactions tout en permettant aux anciennes transactions d'être déplacées ailleurs. Pour ce faire, je remplacerai chaque lot de transactions archivées par un résumé de leurs montants par utilisateur et par devise.

Ainsi, par exemple, cette liste de transactions:

user_id    currency_id      amount    is_summary
------------------------------------------------
      3              1       10.60             0
      3              1      -55.00             0
      3              1      -12.12             0

est archivé et remplacé par ceci:

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

De cette façon, un solde avec des transactions archivées conserve un historique complet et cohérent des transactions.

60
Nick Chammas

Je ne suis pas familier avec la comptabilité, mais j'ai résolu des problèmes similaires dans des environnements de type inventaire. Je stocke les totaux cumulés dans la même ligne avec la transaction. J'utilise des contraintes, afin que mes données ne soient jamais fausses, même en cas de concurrence élevée. J'ai écrit la solution suivante à l'époque en 2009: :

Le calcul des totaux cumulés est notoirement lent, que vous le fassiez avec un curseur ou avec une jointure triangulaire. Il est très tentant de dénormaliser, de stocker les totaux en cours dans une colonne, surtout si vous la sélectionnez fréquemment. Cependant, comme d'habitude lorsque vous dénormalisez, vous devez garantir l'intégrité de vos données dénormalisées. Heureusement, vous pouvez garantir l'intégrité des totaux cumulés avec des contraintes - tant que toutes vos contraintes sont fiables, tous vos totaux cumulés sont corrects. De cette façon, vous pouvez facilement vous assurer que le solde actuel (totaux cumulés) n'est jamais négatif - l'application par d'autres méthodes peut également être très lente. Le script suivant illustre la technique.

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
17
A-K

Une approche légèrement différente (similaire à votre 2ème option ) à considérer est de n'avoir que la table des transactions, avec une définition de:

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

Vous pouvez également vouloir un ID/ordre de transaction, afin que vous puissiez gérer deux transactions avec la même date et améliorer votre requête de récupération.

Pour obtenir le solde actuel, il vous suffit d'obtenir le dernier enregistrement.

Méthodes pour obtenir le dernier enregistrement :

/* 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

Inconvénients:

  • Lors de l'insertion d'une transaction hors séquence (c'est-à-dire: pour corriger un problème/solde de départ incorrect), vous devrez peut-être mettre en cascade les mises à jour pour toutes les transactions suivantes.
  • Les transactions pour l'utilisateur/la devise devraient être sérialisées pour maintenir un équilibre précis.

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

Avantages:

  • Vous n'avez plus à gérer deux tables distinctes ...
  • Vous pouvez facilement valider le solde, et lorsque le solde est désynchronisé, vous pouvez identifier exactement quand il s'est déséquilibré lorsque l'historique des transactions devient auto-documenté.

Edit: Quelques exemples de requêtes sur la récupération du solde actuel et pour mettre en évidence con (Merci @Jack Douglas)

15
Andrew Bickerton

Ne pas autoriser les clients à avoir un solde inférieur à 0 est une règle commerciale (qui changerait rapidement car les frais pour des choses comme les traites sont la façon dont les banques font le plus d'argent). Vous voudrez gérer cela dans le traitement de l'application lorsque des lignes sont insérées dans l'historique des transactions. D'autant plus que vous pouvez vous retrouver avec certains clients ayant une protection contre les découverts et certains se voir facturer des frais et d'autres ne permettant pas la saisie de montants négatifs.

Jusqu'à présent, j'aime où vous allez avec cela, mais si c'est pour un projet réel (pas pour l'école), il faut beaucoup de réflexion sur les règles commerciales, etc. Une fois que vous avez un système bancaire en place et en cours d'exécution, il n'y a pas beaucoup de place pour la refonte car il existe des lois très spécifiques sur les personnes ayant accès à leur argent.

14
mrdenny

Après avoir lu ces deux discussions, j'ai décidé de l'option 2

Après avoir lu ces discussions aussi, je ne sais pas pourquoi vous avez choisi la solution DRI sur la plus sensible des autres options que vous décrivez:

Appliquer des transactions aux tables de transactions et de soldes. Utilisez la logique TRANSACTION dans ma couche de procédures stockées pour garantir que les soldes et les transactions sont toujours synchronisés.

Ce type de solution présente d'immenses avantages pratiques si vous avez le luxe de restreindre tous l'accès aux données via votre API transactionnelle. Vous perdez l'avantage très important de DRI, qui est que l'intégrité est garantie par la base de données, mais dans tout modèle de complexité suffisante , certaines règles métier ne pourront pas être appliquées par DRI .

Je vous conseille d'utiliser DRI si possible pour appliquer les règles métier sans trop plier votre modèle pour que cela soit possible:

Même si j'archive des transactions (par exemple en les déplaçant ailleurs et en les remplaçant par des transactions récapitulatives)

Dès que vous commencez à envisager de polluer votre modèle comme celui-ci, je pense que vous allez dans le domaine où les avantages de la DRI sont compensés par les difficultés que vous introduisez. Considérez par exemple qu'un bogue dans votre processus d'archivage pourrait en théorie entraîner votre règle d'or (qui équilibre toujours égal à la somme des transactions) à casse silencieusement avec une solution DRI .

Voici un résumé des avantages de l'approche transactionnelle tels que je les vois:

  • Nous devrions le faire de toute façon si possible. Quelle que soit la solution que vous choisissez pour ce problème particulier, elle vous donne plus de flexibilité de conception et de contrôle sur vos données. Tout accès devient alors "transactionnel" en termes de logique métier, plutôt qu'en termes de logique de base de données.
  • Vous pouvez garder votre modèle propre
  • Vous pouvez "appliquer" un éventail et une complexité beaucoup plus larges de règles métier (en notant que le concept d '"appliquer" est plus lâche qu'avec DRI)
  • Vous pouvez toujours utiliser DRI dans la mesure du possible pour donner au modèle une intégrité sous-jacente plus robuste - et cela peut agir comme un contrôle sur votre logique transactionnelle
  • La plupart des problèmes de performances qui vous troublent disparaîtront
  • L'introduction de nouvelles exigences peut être beaucoup plus facile - par exemple: des règles complexes pour les transactions litigieuses pourraient vous éloigner d'une approche DRI pure plus loin, ce qui signifie beaucoup d'efforts gaspillés
  • Le partitionnement ou l'archivage des données historiques devient beaucoup moins risqué et douloureux

--Éditer

Pour permettre l'archivage sans ajouter de complexité ou de risque, vous pouvez choisir de conserver les lignes récapitulatives dans un tableau récapitulatif distinct, généré en continu (emprunt à @Andrew et @Garik)

Par exemple, si les résumés sont mensuels:

  • chaque fois qu'il y a une transaction (via votre API) il y a une mise à jour ou une insertion correspondante dans le tableau récapitulatif
  • le tableau récapitulatif est jamais archivé, mais l'archivage des transactions devient aussi simple qu'une suppression (ou suppression d'une partition?)
  • chaque ligne du tableau récapitulatif comprend le "solde d'ouverture" et le "montant"
  • vérifier les contraintes telles que "solde d'ouverture" + "montant"> 0 et "solde d'ouverture"> 0 peuvent être appliquées au tableau récapitulatif
  • des lignes de résumé pourraient être insérées dans un lot mensuel pour faciliter le verrouillage de la dernière ligne de résumé (il y aurait toujours une ligne pour le mois en cours)

Pseudo.

L'idée principale est de stocker les enregistrements de solde et de transaction dans la même table. C'est arrivé historiquement, pensais-je. Donc, dans ce cas, nous pouvons obtenir l'équilibre simplement en localisant le dernier enregistrement résumé.

 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 

Une meilleure variante consiste à diminuer le nombre d'enregistrements récapitulatifs. Nous pouvons avoir un enregistrement de solde à la fin (et/ou au début) de la journée. Comme vous le savez, chaque banque a operational day pour l'ouvrir et la fermer pour effectuer quelques opérations récapitulatives pour cette journée. Il nous permet de calculer facilement intérêts en utilisant chaque enregistrement de solde journalier, par exemple:

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)

La chance.

6
garik

En fonction de vos besoins, l'option 1 semble la meilleure. Bien que ma conception n'autorise que les insertions dans la table des transactions. Et avoir le déclencheur sur la table des transactions, pour mettre à jour la table d'équilibre en temps réel. Vous pouvez utiliser des autorisations de base de données pour contrôler l'accès à ces tables.

Dans cette approche, le solde en temps réel est garanti d'être synchronisé avec la table des transactions. Et peu importe si des procédures stockées ou psql ou jdbc sont utilisées. Vous pouvez faire vérifier votre solde négatif si nécessaire. La performance ne sera pas un problème. Pour obtenir l'équilibre en temps réel, il s'agit d'une requête singleton.

L'archivage n'affectera pas cette approche. Vous pouvez également avoir un tableau récapitulatif hebdomadaire, mensuel et annuel si nécessaire pour des choses comme les rapports.

4
Elan Fisoc

Dans Oracle, vous pouvez le faire en utilisant uniquement la table des transactions avec une vue matérialisée rapidement actualisable qui fait l'agrégation pour former le solde. Vous définissez le déclencheur dans la vue matérialisée. Si la vue matérialisée est définie avec 'ON COMMIT', elle empêche efficacement l'ajout/la modification de données dans les tables de base. Le déclencheur détecte les données [in] valides et déclenche une exception, où il annule la transaction. Un bel exemple est ici http://www.sqlsnippets.com/en/topic-12896.html

Je ne connais pas sqlserver mais peut-être qu'il a une option similaire?

3
ik_zelf