Append-only-hovedbog for præcise delte balancer
En balance bør være en projektion, du kan smide væk og genopbygge, holdt sikker af nulsums-journaler, rettelser der bogføres, og en outbox der udsender i commit-rækkefølge.
En balance, du inkrementerer, er en balance, der driver. Dimesum inkrementerer aldrig nogen. Hver udgift bogfører en dobbelt-postering-journal, hvis posteringer summer til præcis nul, og tallet på din gruppeskærm er en projektion over de posteringer, som vi kan slette og genopbygge med én kommando.
Du kan afprøve den påstand. Når den eneste måde penge bevæger sig på er en append-only-journal, holder en forkert balance op med at være et mysterium og bliver en forespørgsel, vi kan køre.
Balancer er en projektion, ikke et tal du inkrementerer
Dimesums hovedbog ejer tre tabeller: journals, postings og en balances-projektion nøglet på gruppe, medlem og valuta. Posteringer bærer sandheden. En postering er positiv, når et medlem har lagt penge i gruppen, og negativ, når de har forbrugt værdi, så en balance er et enkelt SUM(amount_minor). Projektionen findes af hensyn til hastighed, ikke autoritet: den skrives inde i journalens egen transaktion, og at posteringerne er komplette holder den engangsbrugbar.
To lag hævder det samme nul, og ingen af dem er nok alene
I Go nægter buildPostings at åbne en transaktion, medmindre posteringssættet summer til nul. I Postgres gentjekker en udskudt constraint-trigger SUM(amount_minor) = 0 pr. journal ved commit, fordi posteringer indsættes én række ad gangen, og en tjek pr. række ville afvise hver journals første ben. Assert'en fanger en fejl i beregneren. Triggeren fanger en skriver, der aldrig kaldte den.
Et tredje lag er en grant. UPDATE, DELETE og TRUNCATE er tilbagekaldt fra rollen ledger_app på begge tabeller, så fejlbehæftet kode ikke kan omskrive historik, selv når den forsøger.
| Fejlklasse | Inkrementeret balancetabel | Append-only-hovedbog |
|---|---|---|
| Debitor debiteret, betaler aldrig krediteret | Stille forkert for altid | Nulsums-tjek fejler, skrivning afvist |
| En andel ændres, totalen gør ikke | Balancer driver stille | Journalen kan ikke committe i ubalance |
| "Hvorfor er min balance 412 kr?" | Ubesvarligt | Hver øre spores til en journal |
| En hotfix i produktion | Usporet mutation | Den eneste vej er en ny, revideret journal |
En redigering bogfører en ophævelse, aldrig en UPDATE
At redigere en udgift skriver intet hen over den gamle version. Hovedbogen forbruger én expense.amended-hændelse og bogfører to journaler i en enkelt transaktion: en EXPENSE_REVERSAL, hvis posteringer er den eksakte ben-for-ben-negation af den version, der erstattes, og derefter en frisk EXPENSE for den nye. En sletning stopper efter ophævelsen.
Én transaktion betyder lige så meget som de to journaler. Hvis ophævelsen committede alene, ville en gruppe et kort øjeblik skylde intet for en udgift, den stadig skylder.
Begge idempotensnøgler er udledt af versionen frem for udstedt: expense:<id>:v<n> for versionen, og den forrige versions nøgle plus :reversal for negationen. journals.idempotency_key er UNIQUE, så en genleveret hændelse finder sine journaler allerede der og gør intet. Udledning er det, der gør at-mindst-én-gang-levering sikker: et genforsøg beregner den nøgle, det første forsøg brugte.
Et vandmærke i commit-rækkefølge holder en ændring bag sin udgift
Hver hændelse Dimesum udgiver skrives til en outbox-tabel i samme transaktion som forretningsskrivningen. Hvis udgiften committer, findes annonceringen. Hvis den rulles tilbage, gør annonceringen det også. Hovedbogen hører aldrig om en udgift, der ikke findes.
Udsendelsesrækkefølgen er den sværere halvdel. Id'er er UUIDv7 og sorterer efter tid, men de indkoder hvornår id'et blev udstedt, ikke hvornår dets transaktion committede. Et relay der læser i id-rækkefølge kan springe forbi en transaktion, der tog et tidligere id og committede senere, så en ændring overhaler den udgift, den ændrer.
Rettelsen er én kolonne og ét prædikat. Hver outbox-række bærer inserted_xid xid8 DEFAULT pg_current_xact_id(), og relayet læser kun rækker WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), sorteret efter det xid (se PostgreSQLs funktioner til transaktions-id). En kausalt senere hændelse bærer altid et senere xid, fordi den var nødt til at læse den tidligere række for at eksistere.
Ændringsforbrugeren tager ikke den rækkefølge på tro. En ændring, hvis forgænger ingen journal har, genleveres mens den er ung, og parkeres til et menneske, når den er forbi en to-minutters frist.
Penge er int64-underenheder, og eksponenten er ikke altid to
Hvert beløb er en int64-optælling af underenheder plus en ISO 4217-kode, så 1,234.56 rupees er {Minor: 123456, Currency: "INR"}. Heltal er eksakte af konstruktion, ikke af disciplin: ingen repræsenterbar værdi er en halv øre, så ingen operation kan stille frembringe en. Python-sidevognen læser sin egen AST og lader en test fejle, hvis ordet float optræder i dens beløbsmodul.
At antage, at underenheden er en hundrededel, er fælden nedenunder. JPY har slet ingen, og KWD har tre decimaler, så en kurs noteret mellem hovedenheder og anvendt på underenheder er forkert med en tierpotens. Tag 2,000 yen til 0.58: det naive produkt er 1160, hvilket læses som 11.60 rupees, når svaret er 1,160.
WRITE_OFF er en femte journaltype, fordi dens posteringer matcher en afregnings
En afskrivning bogfører de samme to ben som en afregning gør: debitor stiger, kreditor falder, med det samme beløb. At folde den ind i SETTLEMENT blev afvist netop af den grund, at formerne matcher. "Asha betalte dig 500 kroner" og "du eftergav Asha 500 kroner" er forskellige fakta, og et feed der blandede dem sammen ville sige, at nogen betalte, når ingen gjorde.
Så WRITE_OFF kom med i journaltype-CHECK'en den 2026-08-21, med sine egne ben. At navngive dem var halvdelen af pointen, for en afskrivning der genbrugte SETTLE_PAY ville gøre enhver "hvor meget er faktisk betalt"-forespørgsel stille forkert.
| Journaltype | Bogført når | Ben |
|---|---|---|
EXPENSE | Oprettet, eller en ny version erstatter en | PAID, SHARE |
EXPENSE_REVERSAL | Redigeret eller slettet | Den forrige journals ben, negeret |
SETTLEMENT | En tilbagebetaling hævdes | SETTLE_PAY, SETTLE_RECV |
SETTLEMENT_REVERSAL | Modparten bestrider den | Begge afregningsben, negeret |
WRITE_OFF | En kreditor opgiver et krav | WRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED |
To underkommandoer gør invarianterne til et cron-job
evenly verify-ledger bevis-tjekker invarianterne over hele skemaet igen og afslutter med en værdi forskellig fra nul ved ethvert fund. Dens rapport har fire felter, og hvert enkelt forventes at være tomt: journaler der ikke summer til nul, grupper der ikke summer til nul, projektionsrækker der afviger fra en genberegnet SUM(postings), og parkerede hændelser der venter på et menneske. Alle scanninger deler ét repeatable-read-snapshot, så en journal der lander midt i en verify ikke kan fabrikere en uoverensstemmelse.
Hver scanning grupperer efter valuta såvel som efter id, og den fejl, det undgår, er et falsk negativ. En postering på 500 rupees og en på minus 500 yen summer til nul, når en forespørgsel ignorerer valutakolonnen, så en dobbelt korrupt hovedbog ville se ren ud.
evenly rebuild-balances [group-id] er både reparationen og øvelsen. Den rydder projektionen og genberegner hver række ud fra posteringer, idet den stempler hver med den seneste journal, der flyttede det medlem, ligesom den live skrivning gør. Række-for-række-lighed er egenskaben: når en genopbygning og den live projektion er uenige, har hovedbogen ret.
Verify, så rebuild. Rapporten navngiver den gemte balance og den genberegnede for hver drivende række, og en genopbygning overskriver den gemte værdi, så at genopbygge først ødelægger beviset.
Gør balancen udledelig, og drift bliver en forespørgsel
En append-only-hovedbog fortjener kun sin anden journal, hvis du kan bevise den, så byg verifikatoren og genopbygningen før den funktion, der har brug for dem. En genopbygning, ingen har kørt, er et håb, ikke en nødudgang. Vælg din mest risikable pengeprojektion i denne uge, skriv forespørgslen der genberegner den fra kilden, og tilkald dig selv, når de to er uenige.
Ofte stillede spørgsmål
Hvad er en append-only-hovedbog i en app til deling af udgifter?
En append-only-hovedbog registrerer hver pengehændelse som en journal af posteringer, der summer til nul, og opdaterer eller sletter aldrig nogen. I Dimesum tilføjer en udgift, en redigering, en afregning, en tvist og en afskrivning hver sin nye journal. Balancer udledes derefter ved at summere posteringer, så hver øre kan spores tilbage til den hændelse, der flyttede den.
Hvordan håndterer en append-only-hovedbog en redigeret udgift?
En redigering bogfører to journaler i én transaktion: en EXPENSE_REVERSAL der ophæver den oprindelige ben for ben, og derefter en ny EXPENSE med den erstattende version. Intet omskrives, og en sletning bogfører kun ophævelsen. Begge journaler får idempotensnøgler udledt af udgiftsversionen, så en genleveret hændelse finder dem allerede der og ændrer intet.
Hvorfor gemme penge som heltal i stedet for floats?
Heltals-underenheder er eksakte af konstruktion, mens binær flydende komma ikke kan repræsentere 0.1 eksakt og driver hen over en gruppes historik. Dimesum gemmer hvert beløb som en int64-optælling af underenheder plus en ISO 4217-kode. Eksponenten kommer fra valutaen: JPY har slet ingen underenhed, så at antage en hundrededel er en 100x fejl.
Hvordan ved man, at balancer for delte udgifter ikke er drevet?
Kør evenly verify-ledger, som bevis-tjekker invarianterne over hele skemaet igen og afslutter med en værdi forskellig fra nul ved ethvert fund. Den rapporterer journaler der ikke summer til nul, grupper der ikke summer til nul, projektionsrækker der er uenige med en genberegnet SUM(postings), og parkerede hændelser. Kør den som cron-job hver nat, og reparér en dårlig projektion med evenly rebuild-balances.
Hvorfor skal en afskrivning have sin egen journaltype?
En afskrivning bogfører ben identiske med en afregnings, hvilket netop er grunden til, at journaltypen måtte være forskellig. Kun det ord adskiller 'Asha betalte dig 500 kroner' fra 'du eftergav Asha 500 kroner', og et feed der blandede dem sammen ville sige, at nogen betalte, når ingen gjorde. Dens ben hedder WRITE_OFF_FORGIVEN og WRITE_OFF_GRANTED, så betalingsforespørgsler forbliver korrekte.
Populære indlæg
- Derfor skal en redigering angive fordelingen igen8 min læsning
- Seks pengefejl i udgiftsdeling på tværs af valutaer8 min læsning
- Gør op: ryd fælles udgifter med færre overførsler5 min læsning
- Del regningen når én ret ikke blev delt8 min læsning
- Del huslejen retfærdigt med dine bofæller5 min læsning