Registrul în partidă dublă, explicat pentru programatori
O coloană de sold se strică din momentul în care o ating doi scriitori. Stochează mișcările, derivă soldul, blochează rândul în tranzacție și corectează greșelile prin stornare, nu prin editare.

Există o coloană numită balance în tabela de conturi. Este numărul pe care îl arată produsul, pe care îl spune supportul la telefon și pe care îl adună raportul lunar. În majoritatea sistemelor pe care le-am citit, este și greșit. Nu cu mult și nu în fiecare zi: greșit după retry-ul care a rulat de două ori, după cele două rambursări care au picat în aceeași secundă, după importul care a scris mișcările dar a sărit peste coloană.
Am reparat forma asta de bug mai mult de o dată, iar reparația nu a fost niciodată un UPDATE mai bun. Reparația a fost să nu mai tratăm numărul ca pe faptul în sine și să tratăm mișcările ca fapt. Contabilitatea funcționează așa de secole, iar principiul se așază pe o bază de date relațională aproape linie cu linie, odată ce lași vocabularul contabil deoparte.
Un sold stocat este un cache fără regulă de invalidare
O coloană de sold este date denormalizate. Nu e un păcat; orice sistem rapid ține ceva în cache. Un cache are nevoie de două lucruri ca să fie sigur: o regulă pentru când are voie să fie greșit și o cale de a-l reconstrui din sursă. Coloana de sold nu are de obicei niciuna, așa că se duce, și nimeni nu poate spune de când.
Cele patru feluri în care se duce sunt mereu aceleași.
- Scrierea nu e atomică. Inserezi mișcarea, apoi actualizezi soldul. Dacă procesul moare între cele două instrucțiuni sau a doua expiră, registrul și numărul se contrazic. O tranzacție în jurul ambelor rezolvă cazul acesta și doar cazul acesta.
- Doi scriitori citesc aceeași valoare. Două transferuri citesc fiecare 1.000 de lei, scad fiecare 100, scriu fiecare 900. Din cont au ieșit două sute de lei, iar coloana a înregistrat o sută.
- Trecutul își schimbă forma. O migrare, un backfill sau un raport corectat recalculează istoricul, iar coloana nu face parte din recalculare.
- O persoană o editează. Supportul rezolvă o reclamație punând în coloană suma pe care o așteaptă clientul. Dovada a ceea ce s-a întâmplat a dispărut, exact când ai nevoie de ea, când același client sună a doua oară.
Există o întrebare care desparte cele două modele. Întreabă de ce un cont are 4.213,17 lei. Dacă răspunsul este „pentru că așa scrie în coloană”, ai un număr. Dacă răspunsul este o listă de mișcări datate care însumează suma aceea, ai un registru și poți răspunde și la întrebarea următoare: care mișcare este greșită.
Debitul și creditul sunt o convenție de semn
Nu trebuie să înveți contabilitate ca să implementezi asta. Îți trebuie un tabel și o regulă.
O înregistrare are un cont, o tranzacție din care face parte, o sumă cu semn în cea mai mică unitate, o monedă și un moment în timp. Acesta este tot schema. Debit și credit sunt numele pe care le dau contabilii semnului pozitiv și celui negativ. Un debit crește un cont de activ sau de cheltuială; un credit îl scade. La pasive, capitaluri proprii și venituri semnele se întorc. Dacă propoziția asta este singura contabilitate pe care o înveți vreodată, e suficient.
Ia o factură de 5.000 de lei trimisă unui client. Două înregistrări, o singură tranzacție:
- creanțe, +500.000 de bani: clientul îți datorează bani, iar asta este un activ;
- venituri, -500.000 de bani: ai câștigat suma.
Când banii ajung:
- bancă, +500.000 de bani;
- creanțe, -500.000 de bani: clientul nu mai datorează nimic.
Soldul oricărui cont este SUM(amount) peste înregistrările lui. Nimic altceva. Sumele sunt numere întregi în unitatea cea mai mică, pentru că 0,1 + 0,2 în virgulă mobilă binară nu face 0,3, iar un registru greșit cu un ban este un registru în care nimeni nu are încredere. Ține moneda pe fiecare înregistrare, iar dacă o tranzacție convertește între monede, postează conversia ca o pereche separată de înregistrări, cu cursul pe care l-ai obținut efectiv, nu cu cel din știri.
Zero este o invariantă, nu un stil
Fiecare tranzacție trebuie să însumeze zero peste toate înregistrările ei. Regula asta face proiectarea auto-verificabilă. Înseamnă că un singur insert nu poate inventa bani: ca să apară ceva într-un cont, trebuie să dispară din altul sau să vină din exterior. O înregistrare fără pereche este un bug, iar baza de date îl poate refuza la commit printr-o constrângere amânată, înainte ca cineva să vadă un număr greșit pe ecran.
Banii vin din exterior, desigur. Un client plătește în contul tău bancar și nu există un cont intern pe care să creditezi. Perechea este un cont de tranzit, numit uneori cont de lume sau de compensare. Postezi bancă +X și tranzit -X când sosește extrasul, apoi muți suma din tranzit în creanța clientului când o potrivești. Soldul contului de tranzit devine astfel o listă de lucruri de făcut: tot ce stă acolo este bani primiți și încă neexplicați. Este un număr util de urmărit, iar o coloană de sold nu ți-l poate da niciodată.
Două verificări își au locul în jobul de noapte și în suita de teste. Nicio tranzacție nu însumează altceva decât zero. Și contul de tranzit este gol sau fiecare element din el are un responsabil și o vechime. Ambele sunt interogări ieftine pe tabela de înregistrări.
Doi scriitori, un rând
Soldul derivat elimină abaterea, dar nu elimină cursa. Dacă două cereri citesc un sold și apoi decid dacă un transfer este permis, amândouă pot decide că da. Decizia și scrierea trebuie să fie în aceeași tranzacție, iar rândul de care depind trebuie blocat.
În PostgreSQL, SELECT ... FOR UPDATE blochează rândurile selectate ca pentru actualizare și, în cuvintele documentației, „prevents them from being locked, modified or deleted by other transactions until the current transaction ends”. Blocajele la nivel de rând „block only writers and lockers to the same row”, deci citirile simple merg în continuare. Sub READ COMMITTED, o instrucțiune care așteaptă primește rândul actualizat după ce cealaltă tranzacție face commit; sub REPEATABLE READ primește o eroare, iar aplicația reîncearcă. Ambele comportamente sunt în regulă. Ce nu este în regulă este să citești fără blocaj și să speri.
Trei obiceiuri opresc lucrurile să ajungă în deadlock-uri și timeout-uri. Blochează rândurile într-o ordine stabilă, de obicei după id-ul contului, ca două transferuri între aceleași două conturi să nu le apuce în ordine opusă. Ține tranzacția doar pe lucrul cu baza de date: fără cereri HTTP, fără e-mailuri, fără publicare în coadă cât timp ții un blocaj. Setează un lock_timeout, ca o tranzacție blocată să cadă, nu să țină un rând un minut.
Un update condiționat face aceeași treabă cu mai puțin cod când regula este simplă: UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?, apoi verifici că s-a modificat exact un rând. Baza de date evaluează condiția și scrierea împreună. Este un tipar bun pentru un transfer între două conturi și încetează să fie suficient în momentul în care regula are nevoie de mai mult de un rând.
Dacă păstrezi un rând de sold ca cache, ține-l în aceeași tranzacție cu înregistrările și tratează-l ca recontruibil. SELECT SUM(amount) FROM entries WHERE account_id = ? trebuie să dea același număr după o reconstrucție, iar un job programat ar trebui să o demonstreze o dată pe zi. Un cache pe care îl poți verifica este o optimizare. Un cache pe care nu îl poți verifica este bugul despre care este articolul.
Corecțiile sunt rânduri noi
Un registru este doar-cu-adăugare. Când o înregistrare este greșită, nu o actualizezi și nu o ștergi. Postezi înregistrarea opusă, într-o tranzacție nouă, cu o referință către cea originală. Soldul ajunge unde trebuie, iar istoricul arată în continuare ce credeai în ziua în care credeai. Contabilitatea numește asta stornare, și de asta un registru poate trece printr-un audit, printr-o dispută sau printr-o schimbare de contabil.
Două detalii fac stornările sigure în practică. Dă fiecărei cereri o cheie de idempotență cu index unic, ca un apel HTTP reîncercat să posteze o tranzacție, nu două. Și notează motivul pe tranzacția de stornare: „taxare dublă, tichet 412” valorează mai mult peste un an decât un număr corect fără nicio explicație.
Plățile au nevoie de o stare în plus față de înregistrări. O plată cu cardul este autorizată înainte să fie decontată, iar un transfer SEPA poate fi returnat zile mai târziu. Modelază asta ca o tranzacție în așteptare care devine postată, nu ca o înregistrare pe care o editezi. Extrasul bancar este adevărul extern; până ajunge el, registrul este cea mai bună părere a ta și ar trebui să spună asta.
Cât costă
Înregistrările cresc, soldurile nu, deci SUM devine mai lent. Răspunsurile sunt obișnuite: un index pe (account_id, created_at), un snapshot lunar plus înregistrările de după el, înregistrări de închidere care rostogolesc o perioadă într-un sold de deschidere. Codul de raportare se lungește, pentru că un raport trebuie să spună acum dacă înseamnă „ca postat” sau „ca la extras”.
Acesta este schimbul. Plătești în rânduri și într-un strat de raportare mai atent și primești un sistem în care un număr greșit poate fi explicat în loc să fie suprascris. Pentru orice ține banii altora, a doua variantă nu este opțională.
Un exemplu concret
Am construit unul ca referință deschisă: Danube Bank, o aplicație FastAPI și MySQL cu o aplicație pentru client și o consolă separată pentru operator. Fiecare sold stă într-un registru în partidă dublă, transferurile sunt blocate la nivel de rând într-o tranzacție de bază de date, consola poate căuta în registru și poate storna o înregistrare, iar paisprezece teste automate verifică invarianții, inclusiv că înregistrările fiecărei tranzacții însumează zero. Este un demo, fără bani reali și fără date reale de client, cu licență MIT, așa că schema poate fi citită dacă vrei un punct de plecare.
Dacă repari asta într-un produs existent, ordinea care funcționează este:
- Adaugă tabela de înregistrări și scrie în ea, în aceeași tranzacție cu actualizarea soldului, înainte să schimbi orice altceva.
- Fă backfill cu un set de înregistrări pentru fiecare sold existent, cu dată și motiv, ca registrul să explice numărul, nu să îl înlocuiască.
- Adaugă verificarea de noapte că fiecare tranzacție însumează zero și pune o alertă pe ea.
- Mută citirile pe soldul derivat, apoi fă din coloană un cache reconstruibil sau elimină-o.
- Abia apoi schimbă interfața. Utilizatorii nu vor observa schema, dar vor observa un sold care nu se mai mișcă singur.
Întrebări frecvente
- Ce este contabilitatea în partidă dublă, pe scurt?
- Fiecare mișcare de bani se înregistrează de două ori: o dată ca debit și o dată ca credit, în două conturi diferite, cu sumele care se anulează reciproc. Rezultatul este că fiecare tranzacție însumează zero, iar soldul fiecărui cont poate fi recalculat oricând din propriul istoric. Într-o bază de date este un singur tabel de înregistrări, cu o sumă cu semn pe fiecare rând și regula ca fiecare tranzacție să însumeze zero.
- Am nevoie de cunoștințe de contabilitate ca să construiesc un registru?
- Îți trebuie două idei și atât: o sumă cu semn pe fiecare înregistrare și regula că fiecare tranzacție însumează zero. Debit și credit sunt doar numele pentru semnul pozitiv și cel negativ, iar care semn crește un cont depinde de faptul că este activ sau pasiv. Un contabil îți poate numi conturile; codul rămâne același.
- De ce să nu stochez pur și simplu soldul și să îl actualizez?
- Pentru că un update este o a doua scriere care poate eșua, poate intra în concurență sau poate fi sărită, iar odată ce e greșită nu ai cum să afli ce ar fi trebuit să fie. Un sold stocat este în regulă ca cache, dacă stă în aceeași tranzacție cu înregistrările și poate fi reconstruit din ele. Nu este în regulă ca singura dovadă a ceea ce s-a întâmplat, pentru că atunci numărul rămâne singura dovadă, iar numărul este exact lucrul care s-a stricat.
- Cum corectez o înregistrare greșită din registru?
- Postează înregistrarea opusă într-o tranzacție nouă și referă-te la cea originală. Nu actualiza și nu șterge niciodată înregistrarea, pentru că istoricul este ceea ce îți permite să explici un sold luni mai târziu și pentru că un rând șters nu poate fi arătat unui auditor sau unui client. Notează motivul pe stornare și folosește o cheie de idempotență, ca o reîncercare să nu posteze corecția de două ori.
- Soldul trebuie calculat la fiecare cerere?
- Nu neapărat. Însumarea înregistrărilor este corectă și simplă și este suficientă pentru un cont cu mii de mișcări. La milioane, ține un rând de sold sau un snapshot periodic, dar ține-l în aceeași tranzacție cu înregistrările și rulează un job care îl reconstruiește de la zero și compară rezultatul. Regula rămâne aceeași: înregistrările sunt adevărul, iar orice altceva trebuie să poată fi reprodus din ele.
- Ce tip de date folosesc pentru bani în baza de date?
- Un întreg în cea mai mică unitate, de exemplu bani pentru lei, sau un tip zecimal cu precizie fixă. Niciodată un tip cu virgulă mobilă, pentru că virgula mobilă binară nu poate reprezenta exact majoritatea fracțiilor zecimale, iar eroarea se acumulează pe mii de înregistrări. Ține codul monedei pe fiecare înregistrare și postează conversia valutară ca o pereche separată de înregistrări, la cursul pe care l-ai primit efectiv.


