J'écris le schéma d'une base de données bancaire simple. Voici les spécifications de base:
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:
Disposez d'une table balances
distincte et effectuez l'une des opérations suivantes:
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 .)
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.
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é.
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 .)
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.
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
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:
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:
Edit: Quelques exemples de requêtes sur la récupération du solde actuel et pour mettre en évidence con (Merci @Jack Douglas)
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.
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:
--É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:
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.
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.
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?