Append-only-hovedbok for eksakte delte saldoer
En saldo bør være en projeksjon du kan kaste og gjenoppbygge, og dette er maskineriet som gjør det trygt: nullsum-journaler, redigeringer som posterer korrigeringer, og en outbox som sender i commit-rekkefølge.
En saldo du inkrementerer er en saldo som drifter. Dimesum inkrementerer aldri en. Hver utgift posterer en dobbel-postering-journal hvis posteringer summerer til nøyaktig null, og tallet på gruppeskjermen din er en projeksjon over de posteringene som vi kan slette og gjenoppbygge med én kommando.
Du kan teste den påstanden. Når den eneste måten penger flyttes på er en append-only-journal, slutter en feil saldo å være et mysterium og blir en spørring vi kan kjøre.
Saldoer er en projeksjon, ikke et tall du inkrementerer
Dimesums hovedbok eier tre tabeller: journals, postings, og en balances-projeksjon nøklet på gruppe, medlem og valuta. Posteringene bærer sannheten. En postering er positiv når et medlem la penger inn i gruppen og negativ når de forbrukte verdi, så en saldo er en enkel SUM(amount_minor). Projeksjonen finnes for hastighet, ikke autoritet: den skrives inne i journalens egen transaksjon, og at posteringene er fullstendige gjør den kastbar.
To lag hevder den samme nullen, og ingen av dem er nok alene
I Go nekter buildPostings å åpne en transaksjon med mindre posteringssettet summerer til null. I Postgres re-sjekker en utsatt constraint-trigger SUM(amount_minor) = 0 per journal ved commit, fordi posteringer settes inn én rad om gangen og en sjekk per rad ville avvise hver journals første etappe. Assert-en fanger en feil i kalkulatoren. Triggeren fanger en skriver som aldri kalte den.
Et tredje lag er en grant. UPDATE, DELETE og TRUNCATE er tilbakekalt fra rollen ledger_app på begge tabellene, så buggy kode kan ikke skrive om historikk selv når den prøver.
| Feilklasse | Inkrementert balances-tabell | Append-only-hovedbok |
|---|---|---|
| Debitor belastet, betaler aldri kreditert | Stille feil for alltid | Nullsum-sjekk feiler, skriving avvist |
| En andel endres, totalen gjør ikke | Saldoer drifter stille | Journalen kan ikke committe i ubalanse |
| "Hvorfor er saldoen min 412 kr?" | Kan ikke besvares | Hver øre spores til en journal |
| En hotfix i produksjon | Usporet mutasjon | Den eneste veien er en ny, revidert journal |
En redigering posterer en oppheving, aldri en UPDATE
Å redigere en utgift skriver ingenting over den gamle versjonen. Hovedboken konsumerer én expense.amended-hendelse og posterer to journaler i én enkelt transaksjon: en EXPENSE_REVERSAL hvis posteringer er den nøyaktige etappe-for-etappe-opphevingen av versjonen som erstattes, deretter en fersk EXPENSE for den nye. En sletting stopper etter opphevingen.
Én transaksjon betyr like mye som de to journalene. Hvis opphevingen committet alene, ville en gruppe et kort øyeblikk skylde ingenting for en utgift den fortsatt skylder.
Begge idempotensnøklene er utledet fra versjonen i stedet for preget: expense:<id>:v<n> for versjonen, og forrige versjons nøkkel pluss :reversal for opphevingen. journals.idempotency_key er UNIQUE, så en hendelse som leveres på nytt finner journalene sine allerede der og gjør ingenting. Utledning er det som gjør at levering minst én gang er trygg: et nytt forsøk beregner nøkkelen det første forsøket brukte.
En commit-rekkefølge-watermark holder en endring bak sin utgift
Hver hendelse Dimesum publiserer skrives til en outbox-tabell i samme transaksjon som forretningsskrivingen. Hvis utgiften committer, finnes kunngjøringen. Hvis den rulles tilbake, gjør kunngjøringen det også. Hovedboken hører aldri om en utgift som ikke finnes.
Dispatch-rekkefølge er den vanskeligere halvdelen. Id-er er UUIDv7 og sorterer etter tid, men de koder når id-en ble preget, ikke når transaksjonen dens committet. Et relay som leser i id-rekkefølge kan hoppe forbi en transaksjon som tok en tidligere id og committet senere, så en endring forbigår utgiften den endrer.
Fiksen er én kolonne og ett predikat. Hver outbox-rad bærer inserted_xid xid8 DEFAULT pg_current_xact_id(), og relayet leser bare rader WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), sortert etter den xid-en (se PostgreSQLs funksjoner for transaksjons-id). En kausalt senere hendelse bærer alltid en senere xid, fordi den måtte lese den tidligere raden for å eksistere.
Endringskonsumenten tar ikke den rekkefølgen på tro. En endring hvis forgjenger ikke har noen journal leveres på nytt mens den er ung, og parkeres for et menneske når den er forbi en to-minutters frist.
Penger er int64 minste enheter, og eksponenten er ikke alltid to
Hvert beløp er et int64-antall av minste enheter pluss en ISO 4217-kode, så 1 234,56 kr er {Minor: 123456, Currency: "NOK"}. Heltall er eksakte av konstruksjon, ikke av disiplin: ingen representerbar verdi er en halv øre, så ingen operasjon kan stille produsere en. Python-sidecaren leser sitt eget AST og feiler en test hvis ordet float dukker opp i beløpsmodulen sin.
Å anta at den minste enheten er en hundredel er fellen under. JPY har ingen i det hele tatt og KWD har tre desimaler, så en kurs oppgitt mellom hovedenheter og anvendt på minste enheter er feil med en tierpotens. Ta 2 000 yen til 0,58: det naive produktet er 1160, som leses som 11,60 kr, når svaret er 1 160.
WRITE_OFF er en femte journaltype fordi posteringene matcher et oppgjørs
En avskrivning posterer de samme to etappene som et oppgjør gjør: debitor stiger, kreditor faller, med samme beløp. Å folde den inn i SETTLEMENT ble avvist av nøyaktig den grunnen formene matcher. "Asha betalte deg 500 kr" og "du ettergav Asha 500 kr" er ulike fakta, og en feed som blandet dem ville si at noen betalte når ingen gjorde det.
Så WRITE_OFF ble med i journaltype-CHECK-en 2026-08-21, med egne etapper. Å navngi dem var halve poenget, fordi en avskrivning som gjenbrukte SETTLE_PAY ville gjøre hver "hvor mye har faktisk blitt betalt"-spørring stille feil.
| Journaltype | Postert når | Etapper |
|---|---|---|
EXPENSE | Opprettet, eller en ny versjon erstatter en | PAID, SHARE |
EXPENSE_REVERSAL | Redigert eller slettet | Forrige journals etapper, opphevet |
SETTLEMENT | En tilbakebetaling hevdes | SETTLE_PAY, SETTLE_RECV |
SETTLEMENT_REVERSAL | Motparten bestrider det | Begge oppgjørsetapper, opphevet |
WRITE_OFF | En kreditor gir opp et krav | WRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED |
To underkommandoer gjør invariantene om til en cron-jobb
evenly verify-ledger beviser invariantene på nytt over hele skjemaet og avslutter med ikke-null ved ethvert treff. Rapporten dens har fire felt, og hvert eneste forventes å være tomt: journaler som ikke summerer til null, grupper som ikke summerer til null, projeksjonsrader som avviker fra en gjenberegnet SUM(postings), og parkerte hendelser som venter på et menneske. Alle skanninger deler ett repeatable-read-øyeblikksbilde, så en journal som lander midt i verifiseringen kan ikke fabrikkere et avvik.
Hver skanning grupperer etter valuta så vel som etter id, og feilen det unngår er en falsk negativ. En postering på 500 kr og en på minus 500 yen summerer til null når en spørring ignorerer valutakolonnen, så en dobbelt korrupt hovedbok ville leses som ren.
evenly rebuild-balances [group-id] er reparasjonen og øvelsen. Den tømmer projeksjonen og gjenberegner hver rad fra posteringer, og stempler hver med den siste journalen som flyttet det medlemmet, slik den live skrivingen gjør. Rad-for-rad-likhet er egenskapen: når en gjenoppbygging og den live projeksjonen er uenige, har hovedboken rett.
Verifiser, deretter gjenoppbygg. Rapporten navngir den lagrede saldoen og den gjenberegnede for hver driftende rad, og en gjenoppbygging overskriver den lagrede verdien, så å gjenoppbygge først ødelegger bevisene.
Gjør saldoen utledbar så blir drift en spørring
En append-only-hovedbok fortjener sin andre journal bare hvis du kan bevise den, så bygg verifikatoren og gjenoppbyggingen før funksjonen som trenger dem. En gjenoppbygging ingen har kjørt er et håp, ikke en nødutgang. Velg din mest risikable pengeprojeksjon denne uken, skriv spørringen som gjenberegner den fra kilden, og varsle deg selv når de to er uenige.
Vanlige spørsmål
Hva er en append-only-hovedbok i en app for å dele utgifter?
En append-only-hovedbok registrerer hver pengehendelse som en journal av posteringer som summerer til null, og oppdaterer eller sletter aldri en. I Dimesum legger en utgift, en redigering, et oppgjør, en tvist og en avskrivning hver til en ny journal. Saldoer utledes så ved å summere posteringer, så hver øre spores tilbake til hendelsen som flyttet den.
Hvordan håndterer en append-only-hovedbok en redigert utgift?
En redigering posterer to journaler i én transaksjon: en EXPENSE_REVERSAL som opphever originalen etappe for etappe, deretter en ny EXPENSE som bærer erstatningsversjonen. Ingenting skrives om, og en sletting posterer bare opphevingen. Begge journalene får idempotensnøkler utledet fra utgiftsversjonen, så en hendelse som leveres på nytt finner dem allerede der og endrer ingenting.
Hvorfor lagre penger som heltall i stedet for flyttall?
Heltall i minste myntenhet er eksakte av konstruksjon, mens binære flyttall ikke kan representere 0,1 eksakt og driver over en gruppes historikk. Dimesum lagrer hvert beløp som et int64-antall av minste enheter pluss en ISO 4217-kode. Eksponenten kommer fra valutaen: JPY har ingen minste enhet i det hele tatt, så å anta en hundredel er en 100x-feil.
Hvordan vet jeg at saldoene for delte utgifter ikke har driftet?
Kjør evenly verify-ledger, som beviser invariantene på nytt over hele skjemaet og avslutter med ikke-null ved ethvert treff. Den rapporterer journaler som ikke summerer til null, grupper som ikke summerer til null, projeksjonsrader som er uenige med en gjenberegnet SUM(postings), og parkerte hendelser. Legg den i cron nattlig, og reparer en dårlig projeksjon med evenly rebuild-balances.
Hvorfor trenger en avskrivning sin egen journaltype?
En avskrivning posterer etapper identiske med et oppgjørs, som er nettopp derfor journaltypen måtte være forskjellig. Bare det ordet skiller 'Asha betalte deg 500 kr' fra 'du ettergav Asha 500 kr', og en feed som blander dem ville si at noen betalte når ingen gjorde det. Etappene heter WRITE_OFF_FORGIVEN og WRITE_OFF_GRANTED så betalingsspørringer forblir korrekte.
Populære innlegg
- Hvorfor redigering må gjenta hele fordelingen8 min lesing
- Seks pengefeil i utgiftsdeling med flere valutaer8 min lesing
- Gjør opp: rydd felles utgifter på færre overføringer5 min lesing
- Slik deler du en regning når én rett ikke ble delt9 min lesing
- Slik deler du husleien rettferdig5 min lesing