Startseite / Blog / Technik
TechnikAppend-only-Ledger für geteilte Ausgaben
Ein Saldo sollte eine Projektion sein, die man verwerfen und neu aufbauen kann, und das ist die Mechanik, die das sicher macht: Nullsummen-Journale, Bearbeitungen, die Korrekturen buchen, und eine Outbox, die in Commit-Reihenfolge zustellt.
Ein Saldo, den man hochzählt, ist ein Saldo, der abdriftet. Dimesum zählt niemals einen hoch. Jede Ausgabe bucht ein doppeltes Journal, dessen Buchungen sich exakt auf null summieren, und die Zahl auf deinem Gruppenbildschirm ist eine Projektion über diese Buchungen, die wir mit einem einzigen Befehl löschen und neu aufbauen können.
Diese Aussage lässt sich prüfen. Wenn Geld sich ausschließlich über ein Append-only-Journal bewegt, ist ein falscher Saldo kein Rätsel mehr, sondern eine Abfrage, die wir ausführen können.
Salden sind eine Projektion, keine Zahl, die man hochzählt
Das Ledger von Dimesum besitzt drei Tabellen: journals, postings und eine balances-Projektion, die nach Gruppe, Mitglied und Währung geschlüsselt ist. Die Buchungen tragen die Wahrheit. Eine Buchung ist positiv, wenn ein Mitglied Geld in die Gruppe eingebracht hat, und negativ, wenn es Wert verbraucht hat, sodass ein Saldo ein schlichtes SUM(amount_minor) ist. Die Projektion existiert für Geschwindigkeit, nicht für Autorität: Sie wird innerhalb der eigenen Transaktion des Journals geschrieben, und weil die Buchungen vollständig sind, bleibt sie entbehrlich.
Zwei Schichten sichern dieselbe Null, und keine genügt allein
In Go weigert sich buildPostings, eine Transaktion zu öffnen, solange die Buchungsmenge sich nicht auf null summiert. In Postgres prüft ein aufgeschobener Constraint-Trigger beim Commit erneut SUM(amount_minor) = 0 je Journal, weil Buchungen zeilenweise eingefügt werden und eine Prüfung je Zeile die erste Buchung jedes Journals zurückweisen würde. Das Assert fängt einen Fehler im Rechner ab. Der Trigger fängt einen Schreiber ab, der es nie aufgerufen hat.
Eine dritte Schicht ist eine Berechtigung. UPDATE, DELETE und TRUNCATE sind der Rolle ledger_app auf beiden Tabellen entzogen, sodass fehlerhafter Code die Historie nicht umschreiben kann, selbst wenn er es versucht.
| Fehlerklasse | Hochgezählte Salden-Tabelle | Append-only-Ledger |
|---|---|---|
| Schuldner belastet, Zahler nie gutgeschrieben | Für immer stillschweigend falsch | Nullsummen-Prüfung schlägt fehl, Schreibvorgang abgelehnt |
| Ein Anteil ändert sich, die Summe nicht | Salden driften leise ab | Das Journal kann nicht unausgeglichen committen |
| "Warum beträgt mein Saldo 412 €?" | Nicht beantwortbar | Jeder Cent lässt sich zu einem Journal zurückverfolgen |
| Ein Hotfix in der Produktion | Nicht nachverfolgte Mutation | Der einzige Weg ist ein neues, auditiertes Journal |
Eine Bearbeitung bucht eine Stornierung, niemals ein UPDATE
Das Bearbeiten einer Ausgabe überschreibt nichts an der alten Version. Das Ledger verarbeitet ein expense.amended-Ereignis und bucht zwei Journale in einer einzigen Transaktion: eine EXPENSE_REVERSAL, deren Buchungen die exakte Position-für-Position-Negation der ersetzten Version sind, und danach eine frische EXPENSE für die neue. Eine Löschung endet nach der Stornierung.
Die eine Transaktion ist ebenso wichtig wie die zwei Journale. Würde die Stornierung allein committen, schuldete eine Gruppe kurzzeitig nichts für eine Ausgabe, die sie weiterhin schuldet.
Beide Idempotenzschlüssel werden aus der Version abgeleitet statt neu erzeugt: expense:<id>:v<n> für die Version und der Schlüssel der vorherigen Version plus :reversal für die Negation. journals.idempotency_key ist UNIQUE, sodass ein erneut zugestelltes Ereignis seine Journale bereits vorfindet und nichts tut. Die Ableitung ist es, die eine At-least-once-Zustellung sicher macht: Ein erneuter Versuch berechnet denselben Schlüssel, den der erste Versuch verwendet hat.
Eine Wassermarke in Commit-Reihenfolge hält eine Änderung hinter ihrer Ausgabe
Jedes Ereignis, das Dimesum veröffentlicht, wird in derselben Transaktion wie der fachliche Schreibvorgang in eine outbox-Tabelle geschrieben. Committet die Ausgabe, existiert die Ankündigung. Wird sie zurückgerollt, gilt das auch für die Ankündigung. Das Ledger erfährt nie von einer Ausgabe, die nicht existiert.
Die Zustellreihenfolge ist die schwierigere Hälfte. Die Ids sind UUIDv7 und sortieren nach Zeit, aber sie kodieren, wann die Id erzeugt wurde, nicht, wann ihre Transaktion committet hat. Ein Relay, das in Id-Reihenfolge liest, kann eine Transaktion überspringen, die eine frühere Id erhalten und später committet hat, sodass eine Änderung die Ausgabe überholt, die sie ändert.
Die Lösung besteht aus einer Spalte und einem Prädikat. Jede outbox-Zeile trägt inserted_xid xid8 DEFAULT pg_current_xact_id(), und das Relay liest nur Zeilen WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), sortiert nach dieser xid (siehe die PostgreSQL-Funktionen für Transaktions-Ids). Ein kausal späteres Ereignis trägt immer eine spätere xid, weil es die frühere Zeile lesen musste, um zu existieren.
Der Änderungs-Consumer nimmt diese Reihenfolge nicht auf Treu und Glauben hin. Eine Änderung, deren Vorgänger kein Journal hat, wird erneut zugestellt, solange sie jung ist, und für einen Menschen geparkt, sobald eine Frist von zwei Minuten überschritten ist.
Geld besteht aus int64-Untereinheiten, und der Exponent ist nicht immer zwei
Jeder Betrag ist eine int64-Zählung von Untereinheiten plus ein ISO 4217-Code, sodass 1.234,56 Rupien {Minor: 123456, Currency: "INR"} sind. Ganzzahlen sind konstruktionsbedingt exakt, nicht aus Disziplin: Kein darstellbarer Wert ist ein halber Cent, sodass keine Operation stillschweigend einen erzeugen kann. Der Python-Sidecar liest seinen eigenen AST und lässt einen Test fehlschlagen, wenn das Wort float in seinem Betragsmodul auftaucht.
Anzunehmen, die Untereinheit sei ein Hundertstel, ist die Falle darunter. JPY hat gar keine und KWD hat drei Dezimalstellen, sodass ein Kurs, der zwischen Haupteinheiten notiert und auf Untereinheiten angewendet wird, um eine Zehnerpotenz falsch ist. Nehmen wir 2.000 Yen zu 0,58: Das naive Produkt ist 1160, was sich als 11,60 Rupien liest, obwohl die Antwort 1.160 lautet.
WRITE_OFF ist ein fünfter Journaltyp, weil seine Buchungen denen einer Begleichung gleichen
Eine Abschreibung bucht dieselben zwei Positionen wie eine Begleichung: Der Schuldner steigt, der Gläubiger sinkt, um denselben Betrag. Sie in SETTLEMENT zu falten, wurde genau aus dem Grund verworfen, aus dem die Formen übereinstimmen. "Asha hat dir 500 € gezahlt" und "du hast Asha 500 € erlassen" sind verschiedene Tatsachen, und ein Feed, der sie vermengt, würde sagen, jemand habe gezahlt, obwohl es niemand tat.
So kam WRITE_OFF am 2026-08-21 zum Journaltyp-CHECK hinzu, mit eigenen Positionen. Sie zu benennen war der halbe Zweck, denn eine Abschreibung, die SETTLE_PAY wiederverwendet, würde jede Abfrage "wie viel wurde tatsächlich gezahlt" stillschweigend falsch machen.
| Journaltyp | Gebucht wann | Positionen |
|---|---|---|
EXPENSE | Erstellt, oder eine neue Version ersetzt eine | PAID, SHARE |
EXPENSE_REVERSAL | Bearbeitet oder gelöscht | Die Positionen des vorherigen Journals, negiert |
SETTLEMENT | Eine Rückzahlung wird behauptet | SETTLE_PAY, SETTLE_RECV |
SETTLEMENT_REVERSAL | Die Gegenpartei bestreitet sie | Beide Begleichungs-Positionen, negiert |
WRITE_OFF | Ein Gläubiger gibt eine Forderung auf | WRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED |
Zwei Unterbefehle machen aus den Invarianten einen Cronjob
evenly verify-ledger beweist die Invarianten über das gesamte Schema erneut und beendet sich bei jedem Treffer mit einem Wert ungleich null. Sein Bericht hat vier Felder, und jedes soll leer sein: Journale, die sich nicht auf null summieren, Gruppen, die sich nicht auf null summieren, Projektionszeilen, die von einem neu berechneten SUM(postings) abweichen, und geparkte Ereignisse, die auf einen Menschen warten. Alle Scans teilen sich einen Repeatable-Read-Snapshot, sodass ein Journal, das mitten in der Prüfung eintrifft, keine Abweichung vortäuschen kann.
Jeder Scan gruppiert nach Währung ebenso wie nach Id, und der Fehler, den das vermeidet, ist ein falsch Negatives. Eine Buchung von 500 Rupien und eine von minus 500 Yen summieren sich auf null, wenn eine Abfrage die Währungsspalte ignoriert, sodass ein doppelt korruptes Ledger sauber aussähe.
evenly rebuild-balances [group-id] ist die Reparatur und die Übung. Es leert die Projektion und berechnet jede Zeile aus den Buchungen neu und stempelt jede mit dem letzten Journal, das dieses Mitglied bewegt hat, so wie es der Live-Schreibvorgang tut. Zeile-für-Zeile-Gleichheit ist die Eigenschaft: Wenn ein Neuaufbau und die Live-Projektion nicht übereinstimmen, hat das Ledger recht.
Erst verifizieren, dann neu aufbauen. Der Bericht nennt für jede abdriftende Zeile den gespeicherten Saldo und den neu berechneten, und ein Neuaufbau überschreibt den gespeicherten Wert, sodass ein Neuaufbau zuerst die Beweise zerstört.
Mach den Saldo ableitbar, und Abdriften wird zur Abfrage
Ein Append-only-Ledger verdient sein zweites Journal nur, wenn du es beweisen kannst, also baue den Prüfer und den Neuaufbau vor dem Feature, das sie braucht. Ein Neuaufbau, den niemand ausgeführt hat, ist eine Hoffnung, kein Notausgang. Wähle diese Woche deine riskanteste Geldprojektion, schreibe die Abfrage, die sie aus der Quelle neu berechnet, und lass dich benachrichtigen, wenn die beiden nicht übereinstimmen.
Häufige Fragen
Was ist ein Append-only-Ledger in einer App zum Aufteilen von Ausgaben?
Ein Append-only-Ledger erfasst jedes Geldereignis als Journal von Buchungen, die sich auf null summieren, und aktualisiert oder löscht nie eines. In Dimesum hängen eine Ausgabe, eine Bearbeitung, eine Begleichung, ein Widerspruch und eine Abschreibung jeweils ein neues Journal an. Salden werden dann durch Summieren der Buchungen abgeleitet, sodass sich jeder Cent auf das Ereignis zurückführen lässt, das ihn bewegt hat.
Wie behandelt ein Append-only-Ledger eine bearbeitete Ausgabe?
Eine Bearbeitung bucht zwei Journale in einer Transaktion: eine EXPENSE_REVERSAL, die das Original Position für Position negiert, und danach eine neue EXPENSE, die die Ersatzversion trägt. Nichts wird umgeschrieben, und eine Löschung bucht nur die Stornierung. Beide Journale nehmen Idempotenzschlüssel, die aus der Ausgabenversion abgeleitet sind, sodass ein erneut zugestelltes Ereignis sie bereits vorfindet und nichts ändert.
Warum sollte man Geldbeträge als Ganzzahlen statt als Floats speichern?
Ganzzahlige Untereinheiten sind konstruktionsbedingt exakt, während binäres Gleitkomma 0,1 nicht exakt darstellen kann und über die Historie einer Gruppe abdriftet. Dimesum speichert jeden Betrag als int64-Zählung von Untereinheiten plus einen ISO-4217-Code. Der Exponent kommt aus der Währung: JPY hat gar keine Untereinheit, sodass die Annahme eines Hundertstels ein Fehler um den Faktor 100 ist.
Wie stellt man sicher, dass Salden bei geteilten Ausgaben nicht abgedriftet sind?
Führe evenly verify-ledger aus, das die Invarianten über das gesamte Schema erneut beweist und bei jedem Treffer mit einem Wert ungleich null endet. Es meldet Journale, die sich nicht auf null summieren, Gruppen, die sich nicht auf null summieren, Projektionszeilen, die einem neu berechneten SUM(postings) widersprechen, und geparkte Ereignisse. Richte es nächtlich als Cronjob ein und repariere eine fehlerhafte Projektion mit evenly rebuild-balances.
Warum braucht eine Abschreibung einen eigenen Journaltyp?
Eine Abschreibung bucht Positionen, die mit denen einer Begleichung identisch sind, was genau der Grund ist, warum sich der Journaltyp unterscheiden musste. Nur dieses eine Wort trennt "Asha hat dir 500 € gezahlt" von "du hast Asha 500 € erlassen", und ein Feed, der beides vermengt, würde sagen, jemand habe gezahlt, obwohl es niemand tat. Seine Positionen heißen WRITE_OFF_FORGIVEN und WRITE_OFF_GRANTED, damit Zahlungsabfragen korrekt bleiben.
Beliebte Beiträge
- Warum das Ändern einer Ausgabe die Aufteilung neu setzt8 Min. Lesezeit
- Sechs Geldfehler bei Ausgaben in mehreren Währungen8 Min. Lesezeit
- Abrechnen: Gruppenausgaben mit wenigen Überweisungen5 Min. Lesezeit
- Rechnung aufteilen, wenn ein Gericht nicht geteilt wurde9 Min. Lesezeit
- Miete fair mit Mitbewohnern teilen6 Min. Lesezeit