dimesum

Etusivu / Blogi / Tekniikka

Tekniikka

Lisäävä kirjanpito pitää jaetut saldot täsmällisinä

· 7 min lukuaika ·

Saldo on projektio, jonka voi heittää pois ja rakentaa uudelleen, ja tämä koneisto tekee siitä turvallista: nollasummakirjaukset, korjauksina kirjatut muokkaukset ja commit-järjestyksessä lähettävä outbox.

Saldo, jota kasvatat, on saldo, joka ryömii. Dimesum ei koskaan kasvata sellaista. Jokainen kulu kirjaa kahdenkertaisen kirjauksen, jonka kirjausten summa on täsmälleen nolla, ja ryhmänäkymäsi luku on projektio noiden kirjausten yli, jonka voimme poistaa ja rakentaa uudelleen yhdellä komennolla.

Voit testata tämän väitteen. Kun ainoa tapa, jolla raha liikkuu, on lisäävä kirjaus, virheellinen saldo lakkaa olemasta arvoitus ja muuttuu kyselyksi, jonka voimme ajaa.

Saldot ovat projektio, eivät luku, jota kasvatat

Dimesumin kirjanpito omistaa kolme taulua: journals, postings ja balances-projektion, joka on avainnettu ryhmän, jäsenen ja valuutan mukaan. Kirjaukset kantavat totuuden. Kirjaus on positiivinen, kun jäsen laittoi rahaa ryhmään, ja negatiivinen, kun hän kulutti arvoa, joten saldo on pelkkä SUM(amount_minor). Projektio on olemassa nopeutta, ei auktoriteettia varten: se kirjoitetaan kirjauksen oman transaktion sisällä, ja kirjausten täydellisyys pitää sen hävitettävänä.

Kaksi kerrosta väittää samaa nollaa, eikä kumpikaan riitä yksin

Go-kielessä buildPostings kieltäytyy avaamasta transaktiota, ellei kirjausjoukon summa ole nolla. Postgresissa viivästetty rajoiteliipaisin tarkistaa uudelleen SUM(amount_minor) = 0 kirjausta kohti commitissa, koska kirjaukset lisätään rivi kerrallaan ja rivikohtainen tarkistus hylkäisi jokaisen kirjauksen ensimmäisen erän. Assert nappaa laskurin bugin. Liipaisin nappaa kirjoittajan, joka ei koskaan kutsunut sitä.

Kolmas kerros on käyttöoikeus. UPDATE, DELETE ja TRUNCATE on peruttu ledger_app-roolilta molemmissa tauluissa, joten viallinen koodi ei voi kirjoittaa historiaa uudelleen, vaikka yrittäisi.

Mitä lisäävä kirjanpito tekee mahdottomaksi verrattuna kasvatettavaan saldotauluun
BugiluokkaKasvatettava saldotauluLisäävä kirjanpito
Velallista veloitetaan, maksajaa ei koskaan hyvitetäHiljaisesti väärin ikuisestiNollasummatarkistus epäonnistuu, kirjoitus hylätään
Osuus muuttuu, kokonaissumma eiSaldot ryömivät hiljaaKirjaus ei voi commitata epätasapainossa
"Miksi saldoni on 412 euroa?"Ei vastattavissaJokainen sentti palautuu kirjaukseen
Pikakorjaus tuotannossaSeuraamaton muutosAinoa polku on uusi, auditoitu kirjaus

Muokkaus kirjaa mitätöinnin, ei koskaan UPDATE-komentoa

Kulun muokkaaminen ei kirjoita mitään vanhan version päälle. Kirjanpito kuluttaa yhden expense.amended-tapahtuman ja kirjaa kaksi kirjausta yhdessä transaktiossa: EXPENSE_REVERSAL:n, jonka kirjaukset ovat korvattavan version tarkka erä erältä -mitätöinti, ja sitten tuoreen EXPENSE:n uudelle versiolle. Poisto pysähtyy mitätöinnin jälkeen.

Yksi transaktio on yhtä tärkeä kuin kaksi kirjausta. Jos mitätöinti commitattaisiin yksin, ryhmä ei hetkellisesti olisi velkaa mitään kulusta, jonka se on yhä velkaa.

Kulun muokkaus kirjaa EXPENSE_REVERSAL:n, joka mitätöi version yksi, sekä uuden EXPENSE:n versiolle kaksi, yhdessä transaktiossa; balances-projektio on summa kaikkien kirjausten yli. yksi transaktio EXPENSE expense:7c1:v1 4 kirjausta summa = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal jokainen erä mitätöity summa = 0 EXPENSE expense:7c1:v2 5 kirjausta summa = 0 balances-projektio = SUM(postings) per jäsen, per valuutta hävitettävä: evenly rebuild-balances tyhjentää sen ja toistaa jokaisen kirjauksen
Muokkaus lisää: mitätöinti nimeää version, jonka se mitätöi, ja korvaava kirjataan sen viereen.

Molemmat idempotenssiavaimet johdetaan versiosta sen sijaan että ne luotaisiin: expense:<id>:v<n> versiolle, ja edellisen version avain plus :reversal mitätöinnille. journals.idempotency_key on UNIQUE, joten uudelleen toimitettu tapahtuma löytää kirjauksensa jo paikoiltaan eikä tee mitään. Johtaminen on se, mikä tekee vähintään kerran -toimituksesta turvallista: uudelleenyritys laskee saman avaimen, jota ensimmäinen yritys käytti.

Commit-järjestyksen vesileima pitää muokkauksen kulunsa takana

Jokainen tapahtuma, jonka Dimesum julkaisee, kirjoitetaan outbox-tauluun samassa transaktiossa kuin liiketoimintakirjoitus. Jos kulu commitataan, ilmoitus on olemassa. Jos se perutaan, niin peruuntuu ilmoituskin. Kirjanpito ei koskaan kuule kulusta, jota ei ole olemassa.

Lähetysjärjestys on vaikeampi puolisko. Tunnisteet ovat UUIDv7 ja järjestyvät ajan mukaan, mutta ne koodaavat sen, milloin tunniste luotiin, eivät sitä, milloin sen transaktio commitattiin. Rele, joka lukee tunnistejärjestyksessä, voi ohittaa transaktion, joka otti aiemman tunnisteen ja commitattiin myöhemmin, joten muokkaus ohittaa kulun, jota se muokkaa.

Liiketoimintakirjoitus ja sen outbox-rivi commitataan yhdessä transaktiossa; rele lähettää sitten vain outbox-rivit, joiden lisätty transaktiotunniste on pg_snapshot_xmin-vesileiman alapuolella, transaktiotunnisteen järjestyksessä. yksi transaktio INSERT expense-rivi INSERT outbox-rivi aihe id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 kirjoittaja kesken tapahtumaväylä kirjanpidon kuluttaja pg_snapshot_xmin(pg_current_snapshot()). Sen yläpuoliset rivit kirjoitettiin transaktioilla, jotka ovat commitanneet ilman että yhtään vanhempaa on enää käynnissä. Alapuolinen rivi odottaa seuraavaa kierrosta. Tunnisteen mukaan järjestäminen lähettäisi 019a-1a8:n ensin, joten muokkaus voisi saapua ennen kulua, jota se muokkaa. inserted_xid:n mukaan järjestäminen ei voi: myöhemmällä tapahtumalla on myöhempi xid.
Outbox-rivi commitataan liiketoimintakirjoituksen kanssa, ja rele lähettää vesileiman alapuolelta transaktiotunnisteen järjestyksessä.

Korjaus on yksi sarake ja yksi predikaatti. Jokainen outbox-rivi kantaa inserted_xid xid8 DEFAULT pg_current_xact_id():n, ja rele lukee vain rivit WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), järjestettynä tuon xidin mukaan (katso PostgreSQL transaction id functions). Kausaalisesti myöhemmällä tapahtumalla on aina myöhempi xid, koska sen oli luettava aiempi rivi ollakseen olemassa.

Muokkauskuluttaja ei luota tuohon järjestykseen sokeasti. Muokkaus, jonka edeltäjällä ei ole kirjausta, toimitetaan uudelleen niin kauan kuin se on nuori, ja pysäköidään ihmistä varten, kun kahden minuutin lisäaika on ohitettu.

Raha on int64-alayksiköitä, eikä eksponentti ole aina kaksi

Jokainen summa on int64-luku alayksiköitä plus ISO 4217 -koodi, joten 1,234.56 euroa on {Minor: 123456, Currency: "EUR"}. Kokonaisluvut ovat rakenteeltaan täsmällisiä, eivät kurin ansiosta: mikään esitettävä arvo ei ole puoli senttiä, joten mikään operaatio ei voi hiljaa tuottaa sellaista. Python-sivuvaunu lukee oman AST:nsa ja hylkää testin, jos sana float esiintyy sen summamoduulissa.

Oletus, että alayksikkö on sadasosa, on ansa tämän alla. JPY:llä ei ole sitä lainkaan ja KWD:llä on kolme desimaalia, joten pääyksiköiden välillä noteerattu kurssi, jota sovelletaan alayksiköihin, on väärä kymmenen potenssilla. Otetaan 2,000 jeniä kurssilla 0.58: naiivi tulo on 1160, joka luetaan 11.60 euroksi, kun vastaus on 1,160.

WRITE_OFF on viides kirjaustyyppi, koska sen kirjaukset vastaavat suorituksen kirjauksia

Saatavasta luopuminen kirjaa samat kaksi erää kuin suoritus: velallinen nousee, velkoja laskee, saman verran. Sen sulauttaminen SETTLEMENT:iin hylättiin juuri siksi, että muodot vastaavat toisiaan. "Asha maksoi sinulle 500 euroa" ja "annoit Ashalle anteeksi 500 euroa" ovat eri faktoja, ja syöte, joka sekoittaisi ne, väittäisi jonkun maksaneen, vaikka kukaan ei maksanut.

Niinpä WRITE_OFF liittyi kirjaustyypin CHECK:iin 2026-08-21, omilla erillään. Niiden nimeäminen oli puolet pointista, koska SETTLE_PAY:tä uudelleen käyttävä saatavasta luopuminen tekisi jokaisesta "kuinka paljon on todella maksettu" -kyselystä hiljaisesti väärän.

Dimesumin kirjanpidon viisi kirjaustyyppiä ja erät, jotka kukin kirjaa
KirjaustyyppiKirjataan kunErät
EXPENSELuotu, tai uusi versio korvaa vanhanPAID, SHARE
EXPENSE_REVERSALMuokattu tai poistettuEdellisen kirjauksen erät, mitätöityinä
SETTLEMENTTakaisinmaksu vahvistetaanSETTLE_PAY, SETTLE_RECV
SETTLEMENT_REVERSALVastapuoli kiistää senMolemmat suorituserät, mitätöityinä
WRITE_OFFVelkoja luopuu saatavastaWRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED

Kaksi alikomentoa muuttaa invariantit cron-työksi

evenly verify-ledger todistaa invariantit uudelleen koko skeeman yli ja päättyy nollasta poikkeavaan tilaan jokaisesta osumasta. Sen raportissa on neljä kenttää, ja jokaisen odotetaan olevan tyhjä: kirjaukset, joiden summa ei ole nolla, ryhmät, joiden summa ei ole nolla, projektiorivit, jotka poikkeavat uudelleen lasketusta SUM(postings):sta, sekä pysäköidyt tapahtumat, jotka odottavat ihmistä. Kaikki skannaukset jakavat yhden repeatable-read-tilannevedoksen, joten kesken varmistuksen saapuva kirjaus ei voi väärentää ristiriitaa.

Jokainen skannaus ryhmittelee valuutan sekä tunnisteen mukaan, ja virhe, jonka se välttää, on väärä negatiivinen. Kirjaus 500 euroa ja yksi miinus 500 jeniä summautuvat nollaan, kun kysely jättää valuuttasarakkeen huomiotta, joten kahdesti korruptoitunut kirjanpito luettaisiin puhtaana.

evenly rebuild-balances [group-id] on korjaus ja harjoitus. Se tyhjentää projektion ja laskee jokaisen rivin uudelleen kirjauksista, leimaten kunkin viimeisellä kirjauksella, joka liikutti kyseistä jäsentä, aivan kuten live-kirjoitus tekee. Rivi riviltä -yhtäläisyys on ominaisuus: kun uudelleenrakennus ja live-projektio ovat eri mieltä, kirjanpito on oikeassa.

Aja ne tässä järjestyksessä

Varmista, sitten rakenna uudelleen. Raportti nimeää tallennetun saldon ja uudelleen lasketun jokaiselle ryömivälle riville, ja uudelleenrakennus korvaa tallennetun arvon, joten uudelleenrakentaminen ensin tuhoaa todisteet.

Tee saldosta johdettava, niin ryömimisestä tulee kysely

Lisäävä kirjanpito ansaitsee toisen kirjauksensa vain jos voit todistaa sen, joten rakenna varmistin ja uudelleenrakennus ennen ominaisuutta, joka niitä tarvitsee. Uudelleenrakennus, jota kukaan ei ole ajanut, on toivo, ei varauloskäynti. Valitse riskialttein rahaprojektiosi tällä viikolla, kirjoita kysely, joka laskee sen uudelleen lähteestä, ja hälytä itsesi, kun nämä kaksi ovat eri mieltä.

Usein kysytyt kysymykset

Mikä on lisäävä kirjanpito kulujen jakosovelluksessa?

Lisäävä kirjanpito tallentaa jokaisen rahatapahtuman kirjauksina, joiden summa on nolla, eikä koskaan päivitä tai poista yhtäkään. Dimesumissa kulu, muokkaus, suoritus, kiista ja saatavasta luopuminen lisäävät kukin uuden kirjauksen. Saldot johdetaan sitten kirjausten summana, joten jokainen sentti palautuu tapahtumaan, joka sen liikutti.

Miten lisäävä kirjanpito käsittelee muokatun kulun?

Muokkaus kirjaa kaksi kirjausta samassa transaktiossa: EXPENSE_REVERSAL, joka mitätöi alkuperäisen erä erältä, ja sitten uusi EXPENSE, joka sisältää korvaavan version. Mitään ei kirjoiteta uudelleen, ja poisto kirjaa vain mitätöinnin. Molemmat kirjaukset saavat idempotenssiavaimet, jotka johdetaan kulun versiosta, joten uudelleen toimitettu tapahtuma löytää ne jo paikoiltaan eikä muuta mitään.

Miksi rahaa kannattaa tallentaa kokonaislukuina eikä liukulukuina?

Kokonaislukuina ilmaistut alayksiköt ovat rakenteeltaan täsmällisiä, kun taas binäärinen liukuluku ei voi esittää arvoa 0.1 täsmälleen ja ryömii ryhmän historian aikana. Dimesum tallentaa jokaisen summan int64-lukuna alayksiköitä sekä ISO 4217 -koodin. Eksponentti tulee valuutasta: JPY:llä ei ole alayksikköä lainkaan, joten sadasosan olettaminen on 100x virhe.

Mistä tietää, etteivät jaettujen kulujen saldot ole ryömineet?

Aja evenly verify-ledger, joka todistaa invariantit uudelleen koko skeeman yli ja päättyy nollasta poikkeavaan tilaan jokaisesta osumasta. Se raportoi kirjaukset, joiden summa ei ole nolla, ryhmät, joiden summa ei ole nolla, projektiorivit, jotka ovat ristiriidassa uudelleen lasketun SUM(postings):n kanssa, sekä pysäköidyt tapahtumat. Ajasta se öisin, ja korjaa virheellinen projektio komennolla evenly rebuild-balances.

Miksi saatavasta luopuminen tarvitsee oman kirjaustyypin?

Saatavasta luopuminen kirjaa täsmälleen samat erät kuin suoritus, ja juuri siksi kirjaustyypin oli oltava eri. Vain tuo sana erottaa lauseen 'Asha maksoi sinulle 500 euroa' lauseesta 'annoit Ashalle anteeksi 500 euroa', ja syöte, joka sekoittaisi ne, väittäisi jonkun maksaneen, vaikka kukaan ei maksanut. Sen erät on nimetty WRITE_OFF_FORGIVEN ja WRITE_OFF_GRANTED, jotta maksukyselyt pysyvät oikeina.