it-swarm.it

Scrivere un semplice schema bancario: come devo sincronizzare i miei saldi con la cronologia delle loro transazioni?

Sto scrivendo lo schema per un semplice database bancario. Ecco le specifiche di base:

  • Il database memorizzerà le transazioni contro un utente e una valuta.
  • Ogni utente ha un saldo per valuta, quindi ogni saldo è semplicemente la somma di tutte le transazioni rispetto a un determinato utente e valuta.
  • Un saldo non può essere negativo.

L'applicazione bancaria comunicherà con il proprio database esclusivamente tramite procedure memorizzate.

Mi aspetto che questo database accetti centinaia di migliaia di nuove transazioni al giorno, oltre a bilanciare le query su un ordine di grandezza superiore. Per servire i saldi molto rapidamente ho bisogno di pre-aggregarli. Allo stesso tempo, devo garantire che un saldo non contraddica mai la sua cronologia delle transazioni.

Le mie opzioni sono:

  1. Avere una tabella balances separata ed eseguire una delle seguenti operazioni:

    1. Applica transazioni a entrambe le tabelle transactions e balances. Usa la logica TRANSACTION nel mio livello di procedura memorizzata per assicurarti che i saldi e le transazioni siano sempre sincronizzati. (Supportato da Jack .)

    2. Applica le transazioni alla tabella transactions e disponi di un trigger che aggiorna la tabella balances per me con l'importo della transazione.

    3. Applica le transazioni alla tabella balances e disponi di un trigger che aggiunge una nuova voce nella tabella transactions con l'importo della transazione.

    Devo fare affidamento su approcci basati sulla sicurezza per assicurarmi che non possano essere apportate modifiche al di fuori delle procedure memorizzate. Altrimenti, ad esempio, alcuni processi potrebbero inserire direttamente una transazione nella tabella transactions e nello schema 1.3 il saldo pertinente non sarebbe sincronizzato.

  2. Avere una vista indicizzata balances che aggrega le transazioni in modo appropriato. I saldi sono garantiti dal motore di archiviazione per rimanere sincronizzati con le loro transazioni, quindi non ho bisogno di fare affidamento su approcci basati sulla sicurezza per garantirlo. D'altra parte, non posso più far valere i saldi in modo non negativo poiché le viste - anche le viste indicizzate - non possono avere vincoli CHECK. (Supportato da Denny .)

  3. Avere solo una tabella transactions ma con una colonna aggiuntiva per memorizzare il saldo effettivo subito dopo l'esecuzione della transazione. Pertanto, l'ultimo record di transazione per un utente e una valuta contiene anche il saldo corrente. (Suggerito di seguito da Andrew ; variante proposta da garik .)

Quando ho affrontato per la prima volta questo problema, ho letto questidue discussioni e ho deciso sull'opzione 2. Per riferimento, puoi vedere un'implementazione semplice di esso qui .

  • Hai progettato o gestito un database come questo con un profilo di carico elevato? Qual è stata la tua soluzione a questo problema?

  • Pensi che ho fatto la scelta giusta per il design? C'è qualcosa che dovrei tenere a mente?

    Ad esempio, so che le modifiche dello schema alla tabella transactions richiederanno la ricostruzione della vista balances. Anche se sto archiviando le transazioni per mantenere piccolo il database (ad es. Spostandole altrove e sostituendole con transazioni di riepilogo), dover ricostruire la vista su decine di milioni di transazioni con ogni aggiornamento dello schema significherà probabilmente molto più downtime per distribuzione.

  • Se la vista indicizzata è la strada da percorrere, come posso garantire che nessun saldo sia negativo?


Transazioni di archiviazione:

Consentitemi di approfondire un po 'l'archiviazione delle transazioni e le "transazioni di riepilogo" che ho menzionato sopra. Innanzitutto, l'archiviazione regolare sarà necessaria in un sistema ad alto carico come questo. Voglio mantenere la coerenza tra i saldi e la cronologia delle loro transazioni, consentendo al contempo di spostare le vecchie transazioni altrove. Per fare ciò sostituirò ogni lotto di transazioni archiviate con un riepilogo degli importi per utente e valuta.

Quindi, ad esempio, questo elenco di transazioni:

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

viene archiviato e sostituito con questo:

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

In questo modo, un saldo con le transazioni archiviate mantiene una cronologia delle transazioni completa e coerente.

60
Nick Chammas

Non ho familiarità con la contabilità, ma ho risolto alcuni problemi simili in ambienti di tipo inventario. Memorizzo i totali correnti nella stessa riga della transazione. Sto usando i vincoli, in modo che i miei dati non siano mai sbagliati anche in caso di concorrenza elevata. Ho scritto la seguente soluzione allora nel 2009: :

Il calcolo dei totali correnti è notoriamente lento, sia che lo si faccia con un cursore che con un join triangolare. È molto allettante denormalizzare, archiviare i totali in esecuzione in una colonna, specialmente se lo selezioni frequentemente. Tuttavia, come al solito quando denormalizzi, devi garantire l'integrità dei tuoi dati denormalizzati. Fortunatamente, è possibile garantire l'integrità dei totali in esecuzione con vincoli - a condizione che tutti i vincoli siano attendibili, tutti i totali in esecuzione siano corretti. Inoltre, in questo modo puoi facilmente assicurarti che il saldo corrente (totali correnti) non sia mai negativo - l'applicazione con altri metodi può anche essere molto lenta. Il seguente script dimostra la tecnica.

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

Un approccio leggermente diverso (simile alla tua seconda opzione ) da considerare è quello di avere solo la tabella delle transazioni, con una definizione di:

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

Potrebbe anche essere necessario un ID transazione/Ordine, in modo da poter gestire due transazioni con la stessa data e migliorare la query di recupero.

Per ottenere il saldo corrente, tutto ciò che serve è l'ultimo record.

Metodi per ottenere l'ultimo record :

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

Contro:

  • Quando si inserisce una transazione fuori sequenza (ad es .: per correggere un problema/saldo iniziale errato), potrebbe essere necessario collegare in cascata gli aggiornamenti per tutte le transazioni successive.
  • Le transazioni per l'utente/valuta dovrebbero essere serializzate per mantenere un saldo accurato.

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

Pro:

  • Non è più necessario mantenere due tabelle separate ...
  • È possibile convalidare facilmente il saldo e quando il saldo non è più sincronizzato, è possibile identificare esattamente quando è uscito di colpo quando la cronologia delle transazioni diventa autocompattante.

Modifica: alcune query di esempio sul recupero del saldo corrente e sull'evidenziazione con (Grazie a Jack Douglas)

15
Andrew Bickerton

Non consentire ai clienti di avere un saldo inferiore a 0 è una regola aziendale (che cambierebbe rapidamente in quanto le commissioni per cose come l'over draft sono il modo in cui le banche fanno la maggior parte dei loro soldi). Ti consigliamo di gestirlo nell'elaborazione dell'applicazione quando le righe vengono inserite nella cronologia delle transazioni. Soprattutto perché si potrebbe finire con alcuni clienti che hanno una protezione da scoperto e alcuni che ricevono commissioni addebitate e altri che non consentono l'immissione di importi negativi.

Finora mi piace dove stai andando con questo, ma se questo è per un progetto reale (non a scuola) ci deve essere un sacco di pensieri messi nelle regole aziendali, ecc. Una volta che hai un sistema bancario attivo e in esecuzione non c'è molto spazio per la riprogettazione in quanto vi sono leggi molto specifiche sulle persone che hanno accesso al proprio denaro.

14
mrdenny

Dopo aver letto queste due discussioni, ho deciso sull'opzione 2

Avendo letto anche quelle discussioni, non sono sicuro del motivo per cui hai deciso la soluzione DRI sulla più sensata delle altre opzioni che descrivi:

Applicare le transazioni alle tabelle delle transazioni e dei saldi. Usa la logica TRANSAZIONE nel mio livello di procedura memorizzata per assicurarti che i saldi e le transazioni siano sempre sincronizzati.

Questo tipo di soluzione offre immensi vantaggi pratici se si ha il lusso di limitare l'accesso tutto ai dati tramite l'API transazionale. Si perde l'importantissimo vantaggio di DRI, ovvero che l'integrità è garantita dal database, ma in qualsiasi modello di sufficiente complessità ci saranno alcune regole aziendali che non possono essere applicate da DRI .

Consiglierei di utilizzare DRI ove possibile per far rispettare le regole di business senza piegare troppo il modello per renderlo possibile:

Anche se sto archiviando transazioni (ad es. Spostandole altrove e sostituendole con transazioni riepilogative)

Non appena inizi a considerare di inquinare il tuo modello in questo modo, penso che ti stai spostando nell'area in cui il beneficio di DRI è superato dalle difficoltà che stai introducendo. Si consideri ad esempio che un bug nel processo di archiviazione potrebbe in teoria causare la regola d'oro (che equilibra sempre uguale alla somma delle transazioni) a rompere in silenzio con una soluzione DRI .

Ecco un riassunto dei vantaggi dell'approccio transazionale come li vedo:

  • Dovremmo farlo comunque, se possibile. Qualunque sia la soluzione scelta per questo particolare problema, offre maggiore flessibilità di progettazione e controllo sui dati. Tutti gli accessi diventano quindi "transazionali" in termini di logica aziendale, piuttosto che in termini di logica del database.
  • Puoi mantenere pulito il tuo modello
  • Puoi "imporre" una gamma molto più ampia e una complessità delle regole aziendali (osservando che il concetto di "imporre" è più libero rispetto al DRI)
  • Puoi comunque usare DRI ovunque sia pratico per dare al modello un'integrità sottostante più solida - e questo può agire come un controllo sulla tua logica transazionale
  • La maggior parte dei problemi di prestazioni che ti preoccupano si dissolveranno
  • Introdurre nuovi requisiti può essere molto più semplice - ad esempio: regole complesse per le transazioni controverse potrebbero costringerti ad allontanarti da un approccio DRI puro più in basso, il che significa un grande sforzo sprecato
  • Il partizionamento o l'archiviazione di dati storici diventa molto meno rischioso e doloroso

--modificare

Per consentire l'archiviazione senza aggiungere complessità o rischio, è possibile scegliere di mantenere le righe di riepilogo in una tabella di riepilogo separata, generata in modo continuo (prendendo in prestito da @Andrew e @Garik)

Ad esempio, se i riepiloghi sono mensili:

  • ogni volta che viene eseguita una transazione (tramite l'API), viene visualizzato un aggiornamento corrispondente o inserito nella tabella di riepilogo
  • la tabella di riepilogo è mai archiviata, ma l'archiviazione delle transazioni diventa una semplice eliminazione (o l'eliminazione della partizione?)
  • ogni riga della tabella di riepilogo include "saldo di apertura" e "importo"
  • controllare i vincoli come "saldo iniziale" + "importo"> 0 e "saldo iniziale"> 0 possono essere applicati alla tabella di riepilogo
  • le righe di riepilogo potrebbero essere inserite in un batch mensile per rendere più semplice il blocco dell'ultima riga di riepilogo (sarebbe sempre presente una riga per il mese corrente)

Nick.

L'idea principale è l'archiviazione dei record di saldo e transazione nella stessa tabella. È successo storicamente, ho pensato. Quindi, in questo caso, possiamo ottenere l'equilibrio semplicemente individuando l'ultimo record di riepilogo.

 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 

Una variante migliore è la riduzione del numero di record di riepilogo. Possiamo avere un record di bilancio alla fine (e/o inizio) della giornata. Come sai ogni banca ha operational day per aprirlo e poi chiuderlo per eseguire alcune operazioni di riepilogo per questo giorno. Ci consente di calcolare facilmente interesse utilizzando il record di saldo giornaliero, ad esempio:

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)

Fortuna.

6
garik

In base alle tue esigenze, l'opzione 1 sembrerebbe la migliore. Anche se avrei il mio design per consentire solo inserimenti nella tabella delle transazioni. E avere il trigger sulla tabella delle transazioni, per aggiornare la tabella dei bilanci in tempo reale. È possibile utilizzare le autorizzazioni del database per controllare l'accesso a queste tabelle.

In questo approccio, il saldo in tempo reale è garantito per essere sincronizzato con la tabella delle transazioni. E non importa se vengono utilizzate procedure memorizzate o psql o jdbc. Puoi fare il controllo del tuo saldo negativo, se necessario. Le prestazioni non saranno un problema. Per ottenere il saldo in tempo reale, è una query singleton.

L'archiviazione non influirà su questo approccio. Puoi avere una tabella di riepilogo settimanale, mensile, annuale anche se necessario per cose come i rapporti.

4
Elan Fisoc

In Oracle è possibile farlo utilizzando solo la tabella delle transazioni con una vista materializzata ad aggiornamento rapido su di essa che esegue l'aggregazione per formare il saldo. È possibile definire il trigger nella vista materializzata. Se la vista materializzata è definita con 'ON COMMIT', impedisce efficacemente di aggiungere/modificare i dati nelle tabelle di base. Il trigger rileva i dati [in] validi e genera un'eccezione, dove esegue il rollback della transazione. Un bell'esempio è qui http://www.sqlsnippets.com/en/topic-12896.html

Non conosco sqlserver ma forse ha un'opzione simile?

3
ik_zelf