De fleste utviklere lærer ACID tidlig. De fleste lærer også senere at ACID ikke er hele historien.

Det er ingen selvmotsigelse. Det er et omfangsproblem.

ACID er en kontrakt for en enkelt databasetransaksjon på ett enkelt databasesystem. Pengebevegelse er en kontrakt på tvers av tid, nettverk, tjenester, mennesker, regulatorer og angripere. Produksjonsgrad korrekthet lever over databasen. Den bruker databasen. Den tilber den ikke.

Denne artikkelen er både en praktisk og teoretisk guide. Den dekker ACID. Den dekker samtidighet. Den dekker sikkerhet. Og den fokuserer på det ene designet som gjentatte ganger vinner i finans: hovedboken.


I. Invarianter først, verktøy etterpå

Korrekthet betyr at invariantene dine holder. Ikke "databasen committet."

I betalinger og bank ser typiske invarianter slik ut:

  • Penger kan ikke oppstå eller forsvinne inne i systemet ditt.
  • Hver bokført overføring er balansert.
  • En bokført postering redigeres aldri. Den motregnes kun av en senere postering.
  • En transaksjon behandles høyst en gang, selv om forespørsler gjentas.
  • "Tilgjengelige midler" går aldri under policygrenser.
  • Enhver saldo kan forklares fra et revisjonsspor.

Legg merke til hva som mangler. Ingen invariant sier "vi brukte serializable isolation." Isolasjon er et verktøy. Invarianter er målet. Den innrammingen gjør ACID enklere å resonnere om. Den gjør det også enklere å innrømme hvor ACID slutter.


II. Hva ACID garanterer -- og hvor det slutter

ACID er fortsatt essensielt. Men det er smalere enn mange team antar.

Atomisitet. Alle skrivinger i en transaksjon committes eller ingen gjør det. "Ingen delvis oppdatering"-garantien. Ikke-forhandlbart for hovedbokføring.

Konsistens. Begrensninger du definerer håndteres -- fremmednøkler, sjekker, unike begrensninger, triggere. Dette er "databaseregelkorrekthet," ikke "forretningskorrekthet." Forskjellen betyr mer enn de fleste arkitekter innrømmer.

Isolasjon. Samtidige transaksjoner oppfører seg som om de kjørte med definerte interferensregler. Reglene avhenger av isolasjonsnivået. Mange produksjonsstandard er svakere enn utviklere tror (Berenson et al., 1995).

Holdbarhet. Committede data overlever krasj. Innenfor antakelsene om lagring og replikering.

Nå, gapet. ACID garanterer ikke korrekthet på tvers av flere tjenester, cacher, lesereplikaer, meldingsbrokere, gjenforsøk, tidsavbrudd eller delvise nettverksfeil. Det garanterer heller ikke forretningsinvariantene dine -- med mindre du koder dem inn, og koder dem inn på en måte samtidighet ikke kan bryte.

Det er den vanskelige delen.

Isolasjonsnivåer handler om anomalier, ikke følelser

SQL-standarden definerer isolasjonsnivåer etter hvilke anomalier som tillates. Den klassiske artikkelen som ryddet opp i rotet er Berenson et al.s A Critique of ANSI SQL Isolation Levels (1995). Gammel. Fortsatt verdt å lese. Fordi feilmodusene er tidløse.

Anomaliene du bør frykte i pengekode:

  • Lost update: To debiteringer leser samme saldo og begge lykkes.
  • Write skew: To transaksjoner opprettholder hver sine begrensninger lokalt, men bryter en global invariant sammen.
  • Phantoms: En områdeforespørsel endrer seg under deg, og bryter "sjekk-så-handle"-logikk.
  • Read skew: En transaksjon leser relaterte rader fra forskjellige tidspunkter.

Snapshot isolation (SI) er særlig forrædersk. Den er attraktiv. Den skalerer. Men SI kan tillate write skew -- to transaksjoner ser hver sin verden der en begrensning holder, begge committer ikke-konflikterende skrivinger, og den globale invarianten brytes (Berenson et al., 1995).

Hvis du vil ha den enkle mentale modellen: serialiserbarhet. Effekten tilsvarer en eller annen seriell rekkefølge. PostgreSQL dokumenterer dette tydelig. Men serializable koster noe. Så høyskala-systemer kjører ikke alltid "serializable overalt." De gjør i stedet serialiserbarhet små. De reduserer kappestridsdomenet.

Dette er begynnelsen på hovedbok-tenkning.


III. Hovedbok-trekket: Utledet tilstand over muterbar tilstand

Mange systemer starter med en accounts.balance-kolonne. Så gjør de: les saldo, sjekk nok midler, skriv ny saldo.

Dette er fabrikken for samtidighetsfeil. Det kan lappes med låser, gjenforsøk, nøye SQL. Men kjernemodellen er skjør.

En hovedbok-modell snur retningen:

  • Du lagrer uforanderlige posteringer.
  • Du beregner saldoer som en sum av posteringer.
  • Du behandler det beregnede resultatet som utledet tilstand.

Tre egenskaper kommer umiddelbart: et revisjonsspor som standard, muligheten til å gjenbygge utledet tilstand etter feil, og muligheten til å resonnere om korrekthet over tid.

En reell bankgrad hovedbok er vanligvis dobbel bokføring. Dette er ikke tradisjon. Dette er algebra. Griffins "immutable bank"-artikkel sier det rett ut: journalposter har linjeposter, og summen av debiteringer er lik summen av krediteringer (Griffin, 2022). Den invarianten forhindrer "tapte penger." Den forhindrer ikke "feil mottaker." Men den garanterer bevaring inne i systemet.

TigerBeetle, en moderne finansiell hovedbok-database, uttrykker den samme invarianten som en førsteklasses primitiv. Annen implementasjon. Samme matematikk.

Hovedbok-anatomi: kontoer, journalposter, posteringer

Tre kjernebegreper:

  • Konto: en beholder for verdi i en valuta eller aktivatype.
  • Journalpost: en transaksjonspost som bærer metadata.
  • Postering (linjepost): en debitering eller kreditering på en konto, knyttet til en journalpost.

Minimalt relasjonsskjema:

CREATE TABLE accounts (
  account_id      BIGINT PRIMARY KEY,
  currency        CHAR(3) NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  closed_at       TIMESTAMPTZ
);

CREATE TABLE journal_entries ( entry_id UUID PRIMARY KEY, idempotency_key TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), posted_at TIMESTAMPTZ, status TEXT NOT NULL CHECK (status IN ('PENDING','POSTED','REVERSED','FAILED')), type TEXT NOT NULL, meta JSONB NOT NULL DEFAULT '{}' );

CREATE TABLE postings ( posting_id BIGSERIAL PRIMARY KEY, entry_id UUID NOT NULL REFERENCES journal_entries(entry_id), account_id BIGINT NOT NULL REFERENCES accounts(account_id), side TEXT NOT NULL CHECK (side IN ('DEBIT','CREDIT')), amount_minor BIGINT NOT NULL CHECK (amount_minor > 0), currency CHAR(3) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );

CREATE INDEX postings_account_time ON postings(account_id, created_at); CREATE INDEX postings_entry ON postings(entry_id);

Invarianten: for hver POSTED journalpost, per valuta, sum(debiteringer) = sum(krediteringer). Håndhev dette i en lagret prosedyre for bokføring eller med utsatte begrensninger. Ikke stol på utviklerdisiplin alene.

Du beskytter ikke lenger "et saldotall." Du beskytter en bevaringslov.


IV. Samtidighetskontroll: Gjør serialiserbarhet små

Å bokføre en journalpost med tilhørende posteringer må være atomisk. Det er her ACID skinner. En transaksjon: dedupliser via idempotency key, valider forretningsregler, legg til posteringer, merk posten som bokført, send en outbox-hendelse. Hvis den committer, er hovedboken oppdatert. Hvis den ruller tilbake, skjedde ingenting.

Så langt er dette "ACID gjort riktig." Men samtidighet er der systemer faller fra hverandre.

En global serializable database er dyr. Men de fleste pengeinvarianter er lokale -- per konto, per kontopar, per "tilgjengelig" vs "ventende" beholder. Så du håndhever seriell oppførsel ved å låse minimalt med rader.

Tilnærming A: Deterministiske pessimistiske låser

Når du flytter verdi fra konto A til konto B, lås begge i sortert rekkefølge for å unngå vranglåer:

BEGIN;

-- Låsrekkefølge etter account_id for å forhindre vranglåser SELECT 1 FROM accounts WHERE account_id = LEAST($from, $to) FOR UPDATE; SELECT 1 FROM accounts WHERE account_id = GREATEST($from, $to) FOR UPDATE;

-- Sett inn post (idempotent) INSERT INTO journal_entries(entry_id, idempotency_key, status, type, meta) VALUES ($entry_id, $idem, 'PENDING', 'TRANSFER', $meta) ON CONFLICT (idempotency_key) DO UPDATE SET entry_id = journal_entries.entry_id RETURNING entry_id;

-- Legg til posteringer (balansert) INSERT INTO postings(entry_id, account_id, side, amount_minor, currency) VALUES ($entry_id, $from, 'DEBIT', $amt, $cur), ($entry_id, $to, 'CREDIT', $amt, $cur);

UPDATE journal_entries SET status = 'POSTED', posted_at = now() WHERE entry_id = $entry_id;

COMMIT;

Enkelt. Kraftfullt. Dette gir deg seriell kjøring per konto selv på Read Committed. Radlåser er et grovt instrument som forhindrer write skew. PostgreSQL tilbyr også serializable via SSI -- men selv da, planlegg for gjenforsøk.

Tilnærming B: Optimistisk samtidighetskontroll med gjenforsøk

Mange distribuerte databaser bruker serializable med gjenforsøk som standard. CockroachDB kjører serializable som standard og viser gjenforsøksfeil. FoundationDB tilbyr streng serialiserbarhet med optimistisk samtidighetskontroll.

Kostnaden er operasjonell. Applikasjonen din må prøve på nytt på en trygg måte. Operasjonene dine må være idempotente. Klientene dine må tåle latensspiker under kappestrid.

Velg en: du låser og blokkerer, eller du kjører optimistisk og prøver på nytt. Begge kan være korrekte. Begge har kostnader. Hvis du ikke kan prøve på nytt på en trygg måte, lat ikke som du kan. Bruk låser.

Snapshot isolation-fellen

Hvis invarianten din avhenger av et predikat -- ikke en enkelt rad -- så kan snapshot isolation bryte den stille. To transaksjoner ser hver sin verden der en begrensning holder, begge committer ikke-konflikterende skrivinger, og den globale invarianten knuses.

Tre løsninger:

  1. Lås predikatintervallet.
  2. Kjør ekte serializable.
  3. Kod invarianten som en enkelt-rad "port" du kan låse.

Hovedboksystemer bruker ofte det tredje alternativet. De tvinger invarianter inn i et lite låsbart domene. Ports & Grittners SSI-artikkel beskriver hvordan PostgreSQL oppdager farlige strukturer for å forhindre anomalier under SERIALIZABLE.


V. Oppgjør, reserver og tofase-mønsteret

Intern bokføring bør være skarp. Eksternt oppgjør er rotete.

Griffin skiller eksplisitt hovedboken fra et "transactor"-lag som sporer tilstander som instruksjon, reservert, feilet, fullført (Griffin, 2022). Det mønsteret er vanlig. Det eksisterer fordi nettverk feiler. Betalingsskinner får tidsavbrudd. Motparter er uenige. Mennesker sender klager.

Så du modellerer to distinkte ting:

  • Hovedbokføring: intern regnskapssannhet.
  • Oppgjørsflyt: ekstern virkelighetskonvergens.

Ikke bokfør endelige hovedbokposter for eksterne overføringer før du vet hva de betyr. Bruk ventende reserver i stedet.

[Internt]                       [Eksternt]
  +-----------------------+       +------------------------+
  |  Hovedbok (uforandr.) |       |  Oppgjørsflyt         |
  |  - journalposter      |<----->|  - INSTRUERT           |
  |  - posteringer        |       |  - RESERVERT           |
  |  - kontosaldoer       |       |  - FULLFØRT / FEILET  |
  +-----------------------+       +------------------------+

TigerBeetle kaller dette "pending transfers." Griffin kaller det "held" funds. Kortnettverk kaller det autorisasjon vs fangst. Forskjellige navn. Samme konsept.

En praktisk modell: tilgjengelig (brukbart nå) og ventende (reservert for en pågående handling). En kortautorisasjon flytter verdi fra tilgjengelig til ventende. En fangst flytter ventende til endelig bokført. En annullering flytter ventende tilbake til tilgjengelig.

Dette har to fordeler: du forhindrer overforbruk mens eksternt oppgjør er usikkert, og du kan vise ærlig UX. "Ventende" er ikke en feil. Det er ærlighet.


VI. Utenfor enkeltdatabasen: Sagaer, outboxer, idempotens

Du kan bygge et distribuert tofase-commit-system. Du kan også bygge en distribuert vranglåmaskin.

De fleste moderne arkitekturer unngår distribuerte transaksjoner på tvers av tjenester. De bruker lokale transaksjoner pluss arbeidsflyter.

Mønster 1: Saga

En saga er en sekvens av lokale transaksjoner. Hvert steg committer lokalt og utløser neste steg via meldinger. Hvis et steg feiler, kjører kompenserende transaksjoner (Microservices.io, Saga Pattern). Sagaer er ikke "eventuell konsistens-håndvifting." De er eksplisitte. De er reviderbare. De krever sterk idempotensdisiplin.

Mønster 2: Transaksjonell outbox

Dual-write-problemet er reelt. Du oppdaterer databasen. Du publiserer til Kafka. En lykkes. En feiler. Hva nå? Confluent beskriver denne feilmodusen tydelig.

Løsningen er den transaksjonelle outboxen: i samme databasetransaksjon som forretningsskrivingen, sett inn en "hendelse" i en outbox-tabell. En separat publiserer leser outboxen og publiserer pålitelig. Hvis databasen committer, publiseres meldingen til slutt. Hvis databasen ruller tilbake, eksisterer ingen melding.

Dette gjør "distribuert atomisitet" om til "lokal atomisitet + pålitelig videresending." Det er kjedelig. Derfor fungerer det.

Skrivesti (enkelt databasetransaksjon)
  +-----------------------------------------+
  | 1. Sett inn journalpost                 |
  | 2. Sett inn balanserte posteringer       |
  | 3. Oppdater mellomlagret saldo          |
  | 4. Sett inn outbox-hendelse             |
  | 5. Merk post som BOKFØRT               |
  +-----------------------------------------+
           |
           v
  Outbox-leser (asynkron)
  +-----------------------------------------+
  | Les usendte hendelser -> Publiser       |
  | Merk hendelser som sendt                |
  +-----------------------------------------+

Idempotens over eksakt-en-gang

"Eksakt en gang" ende-til-ende-levering er sjelden. Nettverk dupliserer. Klienter prøver på nytt. Brokære leverer om igjen. Tidsavbrudd gir tvetydige utfall.

Bygg rundt en sterkere og mer oppnåelig egenskap: idempotent prosessering. Krev en idempotency key for hvert klientforsett. Lagre den med en unik begrensning. Returner det forrige resultatet ved gjenforsøk.

Hvis du bare gjør en ting etter å ha lest denne artikkelen, gjør dette: legg en unik begrensning på idempotency_key, behandle duplikater som normalt, og gjør handlere trygge å kjøre to ganger. Du vil forhindre et sjokkerende antall "dobbel debitering"-hendelser.


VII. Lesestier, projeksjoner og avstemming

Skrivinger er ofte sterkt konsistente. Lesinger er det ofte ikke. Hvis du leser fra en replika, kan du se foreldet data. Selv liten forsinkelse kan bryte brukerforventninger. AWS-dokumentasjon bemerker at replikaforsinkelse kan variere med skrivelast.

For penge-UX, definer en policy: for "saldo akkurat nå," les fra skriveren. Eller bruk konsistenskontroller på sesjonsnivå. Eller tilby "per tidsstempel"-semantikk. Ikke bland stille. Den verste feilen er en feil som ser ut som svindel.

Ytelse uten løgn

En ren hovedbok sier: beregn saldo ved å summere posteringer. Korrekt. Også dyrt i skala. Så du legger til projeksjoner:

Lag 1 -- Uforanderlig hovedbok. Posteringer er append-only. De er systemets offisielle kilde.

Lag 2 -- Mellomlagrede saldoer. Vedlikehold account_balances som en utledet tabell. Oppdater den i samme transaksjon som posteringer. Behandle den som en mellomlagring.

Lag 3 -- Kontrollpunkter. Lagre periodiske øyeblikksbilder av saldoer. Daglig er vanlig. Dette akselererer "saldo per tidspunkt T."

Lag 4 -- Avstemming. Beregn saldoer på nytt fra posteringer. Sammenlign med mellomlagrede saldoer. Varsle ved avvik. Reparer ved avspilling.

Avstemming er ikke valgfritt i seriose systemer. Det er sikkerhetsnettet ditt. Og det er feilsøkingssuperkraften din. Jepsen-stil testing og virkelig hendelseshistorikk viser at databaser kan oppføre seg overraskende under feil. Hovedboken din pluss avstemming er hvordan du oppdager og gjenoppretter fra de overraskelsene.


VIII. Sikkerhet: Trusselmodellen er ikke bare hackere

I betalinger inkluderer sikkerhet eksterne angripere, interne feil, innsider-misbruk, operasjonelle uhell, datakorrupsjon og tvisthåndtering. En hovedbok hjelper fordi den er append-only og forklarbar. Men du trenger fortsatt kontroller.

Minste privilegium overalt. Bruk separate databaseroller: en bokføringsrolle som kan sette inn posteringer og journalposter, en leserolle som kan sporere men ikke mutere, og en adminrolle som er sjelden og revidert.

Uforanderlighet gjennom policy og mekanisme. Ikke tillat UPDATE eller DELETE på posteringer i normale stier. Bruk databasetillatelser til å håndheve. Griffin foretrekker eksplisitt å skrive en reverserende journalpost fremfor å omskrive historikk (Griffin, 2022). Det er den korrekte regnskapstilnærmingen. Det er også den korrekte sikkerhetstilnærmingen.

Reviderbarhet og tvisthåndtering. Raske betalinger produserer tvister. De trenger prosesser, tidsfrister og kompensasjonsregler. RBI har publisert rammeverk for behandlingstid og kundekompensasjon for mislykkede transaksjoner. Verdensbanken har drøftet tvisteløsning i hurtigbetalingssystemer. Disse reglene presser arkitekturen i en retning: du trenger tilstander, sporbarhet og bevis på hva som skjedde. En hovedbok er bevisgrunnlaget ditt.

API-sikkerhet og misbruksmotstand. Sett hastighetsgrenser på statussjekker. Struper saldooppdateringer. Gjør endepunkter idempotente. Avvis misbrukende gjenforsøksstormer. Korrekthet og sikkerhet overlapper her. De fleste svindler starter som en kappestridstilstand pluss sosial manipulering.


IX. Blåkopien: En hovedbok-basert betalingstjeneste

Her er en arkitektur du kan shippe.

+-----------+
                    |  API GW   |
                    +-----+-----+
                          |
                  +-------v--------+
                  | Betalingstjen. |
                  +-------+--------+
                          |
         +----------------+----------------+
         |                                 |
  +------v-------+                 +-------v--------+
  | Hovedbok-DB  |                 |   Oppgjoers-   |
  | (PostgreSQL) |                 |   flyter       |
  |              |                 |                |
  | accounts     |                 | INSTRUERT      |
  | journal_ent  |<--------------->| RESERVERT      |
  | postings     |                 | FULLFØRT      |
  | outbox_evts  |                 | FEILET         |
  | acct_balances|                 +----------------+
  +--------------+
         |
  +------v-------+
  | Outbox-leser |---> Meldingsbrokaer ---> Konsumenter
  +--------------+                         (idempotente)

Skrivesti: intern overføring. Valider input. Start transaksjon. Lås kontoer i deterministisk rekkefølge. Sett inn journalpost med idempotency key. Sett inn balanserte posteringer. Oppdater mellomlagrede saldoer. Sett inn outbox-hendelse. Merk post som bokført. Commit.

Skrivesti: ekstern betaling. Opprett flytrrad. Reserver midler (hold). Kall ekstern betalingsskinne. Ved bekreftelse, bokfør endelig journalpost. Ved feil, frigi reserve. Avstem alltid asynkront.

Samtidighetsstrategi. Velg en: pessimistiske låser per konto for forutsigbar oppførsel, eller serializable + gjenforsøk hvis plattformen din støtter det og koden din er trygg for gjenforsøk. PostgreSQLs SSI-design er godt dokumentert. Distribuerte databaser som Spanner tilbyr enda sterkere egenskaper, men med betydelig systemkompleksitet. Ikke velg basert på ideologi. Velg basert på operasjonell virkelighet.


Hvorfor hovedbøker består

Hovedbok-tilnærmingen vinner fordi den gir deg egenskaper du ikke kan bolte på i ettertid.

Forklarbarhet. Hver saldoendring har en grunn. Du kan svare "hvorfor endret dette seg" fra posteringer.

Gjenopprettbarhet. Shipet en feil? Spill av på nytt. Mistet en mellomlagring? Gjenbygg.

Kontrollert samtidighet. Lås de riktige tingene. Isoler invarianter.

Trygge reverseringer. Reverser ved motregning, ikke omskriving. Det er nøyaktig det revisorer vil ha.

Arbeidsflytintegrasjon. Reserver, oppgjørstilstander, tvister og tilbakeføringer blir additive lag. De forurenser ikke hovedbokens sannhet.

ACID gir deg en korrekt commit-grense. Det gir deg ikke korrekte penger. Pengesikkerhet kommer fra en dobbel bokføringshovedbok som bevarer verdi, nøye avgrenset samtidighetskontroll, idempotens som en førsteklasses regel, flytilstander for ekstern usikkerhet, outbox-basert hendelsespublisering, avstemming som en normal operasjon, og sikkerhetskontroller som antar feil og misbruk.

Dette er den stille hemmeligheten bak finansiell programvare. Det handler mindre om heroiske algoritmer. Det handler mer om disiplinerte invarianter. Det er ingeniørkunst som respekterer universets favorittegel: ting feiler.

Og det er derfor hovedbøker består. Ikke fordi de er gamle. Fordi de er vanskelige å jukse med.


Referanser

  1. Berenson, H. et al. (1995). A Critique of ANSI SQL Isolation Levels. ACM SIGMOD. PDF
  2. PostgreSQL Documentation. Transaction Isolation. postgresql.org
  3. Ports, D. & Grittner, K. (2012). Serializable Snapshot Isolation in PostgreSQL. VLDB. PDF
  4. Griffin Bank. (2022). Building an Immutable Bank. griffin.com
  5. TigerBeetle. Financial Transactions Database. tigerbeetle.com
  6. Richardson, C. Saga Pattern. Microservices.io. microservices.io
  7. Richardson, C. Transactional Outbox. Microservices.io. microservices.io
  8. Confluent. Why Dual Writes Are a Bad Idea. confluent.io
  9. Reserve Bank of India. Turn Around Time and Customer Compensation. rbi.org.in
  10. World Bank. Fast Payment Systems. worldbank.org
  11. Kingsbury, K. Jepsen: Distributed Systems Safety Research. jepsen.io
  12. AWS. Working with Read Replicas. docs.aws.amazon.com