dimesum

Forsiden / Blogg / Teknikk

Teknikk

Append-only-hovedbok for eksakte delte saldoer

· 8 min lesing ·

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.

Hva en append-only-hovedbok gjør umulig, sammenlignet med en inkrementert balances-tabell
FeilklasseInkrementert balances-tabellAppend-only-hovedbok
Debitor belastet, betaler aldri kreditertStille feil for alltidNullsum-sjekk feiler, skriving avvist
En andel endres, totalen gjør ikkeSaldoer drifter stilleJournalen kan ikke committe i ubalanse
"Hvorfor er saldoen min 412 kr?"Kan ikke besvaresHver øre spores til en journal
En hotfix i produksjonUsporet mutasjonDen 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.

En utgiftsredigering posterer en EXPENSE_REVERSAL som opphever versjon én, pluss en ny EXPENSE for versjon to, i én transaksjon; balances-projeksjonen er en sum over alle posteringene. én transaksjon EXPENSE expense:7c1:v1 4 posteringer sum = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal hver etappe opphevet sum = 0 EXPENSE expense:7c1:v2 5 posteringer sum = 0 balances-projeksjon = SUM(postings) per medlem, per valuta kastbar: evenly rebuild-balances tømmer den og spiller av hver postering på nytt
En redigering legger til: en oppheving navngir versjonen den opphever, og erstatningen posteres ved siden av den.

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.

Forretningsskrivingen og dens outbox-rad committer i én transaksjon; relayet sender så bare outbox-rader hvis inserted-transaksjons-id er under pg_snapshot_xmin-watermarken, i transaksjons-id-rekkefølge. én transaksjon INSERT utgiftsrad INSERT outbox-rad emne id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 skriver underveis hendelsesbuss hovedbok konsument pg_snapshot_xmin(pg_current_snapshot()). Rader over den ble skrevet av transaksjoner som har committet uten at noen eldre fortsatt kjører. Raden under venter på neste runde. Å sortere etter id ville sende 019a-1a8 først, så en endring kunne lande før utgiften den endrer. Å sortere etter inserted_xid kan ikke: en senere hendelse har en senere xid.
Outbox-raden committer med forretningsskrivingen, og relayet sender under watermarken i transaksjons-id-rekkefølge.

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.

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.

De fem journaltypene i Dimesum-hovedboken og etappene hver enkelt posterer
JournaltypePostert nårEtapper
EXPENSEOpprettet, eller en ny versjon erstatter enPAID, SHARE
EXPENSE_REVERSALRedigert eller slettetForrige journals etapper, opphevet
SETTLEMENTEn tilbakebetaling hevdesSETTLE_PAY, SETTLE_RECV
SETTLEMENT_REVERSALMotparten bestrider detBegge oppgjørsetapper, opphevet
WRITE_OFFEn kreditor gir opp et kravWRITE_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.

Kjør dem i denne rekkefølgen

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.