Go baze podataka: projektiranje transakcijske pouzdanosti s bazenima veza, vremenskim ograničenjima i sigurnim migracijama
Kvar baze rijetko počinje dramatičnim prekidom rada. Češće raste latencija, zahtjevi dulje zadržavaju veze, skup se zasićuje, a ponovni pokušaji umnažaju opterećenje. Transakcija koja je u razvoju izgledala savršeno ispravno postaje dio povratne sprege u produkciji.
Ovaj vodič izrađuje malu Go uslugu za prijenose koja premješta cjelobrojne valutne jedinice između računa. Koristi PostgreSQL transakcije, determinističko zaključavanje redaka, ključeve idempotentnosti, ograničeni skup veza, odvojene rokove za veze i upite te migracije kojima se upravlja u izdanjima. Rezultat je namjerno sažet, ali njegove granice pouzdanosti su stvarne.
Arhitektura i ugovor o pouzdanosti
HTTP usluga sluša na 127.0.0.1:8080 i povezuje se s PostgreSQL-om na 127.0.0.1:5433. Prijenos tereti jedan račun, odobrava drugi i bilježi svoj ključ idempotentnosti u jednoj transakciji.
Važna jamstva su:
- Stanja računa i status prijenosa potvrđuju se atomarno.
- Istodobni prijenosi zaključavaju račune uzlaznim redoslijedom ID-a, čime se smanjuje rizik od zastoja.
- Ponovljeni ključ idempotentnosti vraća uspjeh samo kada se njegov sadržaj podudara s izvornim zahtjevom.
- Skup ima strogo ograničenje veličine; dohvaćanje i upotreba veze ograničeni su kontekstom zahtjeva.
- Uspostavljanje veze, izvršavanje SQL-a, stjecanje zaključavanja, HTTP čitanja i gašenje imaju zasebne vremenske proračune.
- Migracije se pokreću kao korak izdanja, nikada oportunistički iz svake instance aplikacije.
To ne čini vanjsko plaćanje ili obavijest izvršenima točno jednom. Ako kasniji radnik šalje nuspojave, mora koristiti outbox ili drugu granicu idempotentnog učinka i prihvatiti isporuku barem jednom.
Preduvjeti i raspored projekta
Koristite Go 1.22 ili noviji, Docker ili ekvivalentnu PostgreSQL instalaciju te PostgreSQL klijentske alate. Jedina ovisnost aplikacije je github.com/jackc/pgx/v5 iz kompatibilne linije v5.7.x. Naredbe za migracije koriste github.com/golang-migrate/migrate/v4 verzije v4.17.1.
ledger/
├── go.mod
├── main.go
└── migrations/
└── 000001_init.up.sql
Stvorite razvojnu bazu podataka. Objavljeni port ograničen je na lokalnu petlju; dev-only namjerno nije produkcijska vjerodajnica.
docker run --name ledger-db \
-e POSTGRES_PASSWORD=dev-only \
-e POSTGRES_DB=ledger \
-p 127.0.0.1:5433:5432 \
-d postgres:16
mkdir -p ledger/migrations
cd ledger
go mod init example.com/ledger
go get github.com/jackc/pgx/[email protected]
go install -tags postgres github.com/golang-migrate/migrate/v4/cmd/[email protected]
Izradite shemu vođenu ograničenjima
Novac pohranjujte kao cjelobrojne manje jedinice, a ne kao vrijednosti s pomičnim zarezom. Ograničenja baze podataka ostaju vrijedna čak i kada Go provjerava iste uvjete: štite invarijantu od skripti za održavanje, budućih usluga i programerskih pogrešaka.
-- migrations/000001_init.up.sql
BEGIN;
CREATE TABLE accounts (
id bigint PRIMARY KEY,
balance bigint NOT NULL CHECK (balance >= 0),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE transfer_requests (
idempotency_key text PRIMARY KEY,
from_id bigint NOT NULL REFERENCES accounts(id),
to_id bigint NOT NULL REFERENCES accounts(id),
amount bigint NOT NULL CHECK (amount > 0),
status text NOT NULL CHECK (status IN ('pending', 'completed')),
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
COMMIT;
Instalirajte migracije uz izričita operativna ograničenja. Vremensko ograničenje veze obuhvaća uspostavljanje veze s PostgreSQL-om; ne ograničava naredbe migracije. PostgreSQL-ovi lock_timeout i statement_timeout pružaju ta zasebna ograničenja.
export DATABASE_URL='postgres://postgres:[email protected]:5433/ledger?sslmode=disable'
PGCONNECT_TIMEOUT=3 \
PGOPTIONS='-c lock_timeout=2s -c statement_timeout=15min' \
migrate -path migrations -database "$DATABASE_URL" up
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
INSERT INTO accounts (id, balance)
VALUES (1, 10000), (2, 10000);
SQL
Implementirajte transakciju i njezina vremenska ograničenja
Dvosekundni kontekst aplikacije ograničava čekanje na skup i izvršavanje SQL-a. Unutar PostgreSQL-a vremensko ograničenje naredbe nešto je kraće, dok stjecanje zaključavanja dobiva samo 500 milisekundi. Takav redoslijed ostavlja vrijeme za prevođenje kvara i vraćanje odgovora.
Ograničenje skupa je kontrola povratnog pritiska, a ne cilj performansi. Postavite ga ispod kapaciteta veza baze podataka nakon što rezervirate prostor za migracije, administraciju, replike i druge usluge.
// main.go
package main
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
errFunds = errors.New("insufficient funds")
errKey = errors.New("idempotency key reused with different payload")
errAcct = errors.New("account not found")
)
type request struct {
FromID int64 `json:"from_id"`
ToID int64 `json:"to_id"`
Amount int64 `json:"amount"`
}
type app struct{ db *pgxpool.Pool }
func (a *app) transfer(ctx context.Context, key string, r request) error {
tx, err := a.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted})
if err != nil {
return err
}
defer tx.Rollback(context.Background())
if _, err = tx.Exec(ctx, "SET LOCAL statement_timeout = '1500ms'"); err != nil {
return err
}
if _, err = tx.Exec(ctx, "SET LOCAL lock_timeout = '500ms'"); err != nil {
return err
}
tag, err := tx.Exec(ctx, `
INSERT INTO transfer_requests
(idempotency_key, from_id, to_id, amount, status)
VALUES ($1, $2, $3, $4, 'pending')
ON CONFLICT DO NOTHING`,
key, r.FromID, r.ToID, r.Amount)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
var from, to, amount int64
var status string
err = tx.QueryRow(ctx, `
SELECT from_id, to_id, amount, status
FROM transfer_requests
WHERE idempotency_key = $1`, key).
Scan(&from, &to, &amount, &status)
if err != nil {
return err
}
if from != r.FromID || to != r.ToID || amount != r.Amount {
return errKey
}
if status != "completed" {
return errors.New("transfer has unexpected state")
}
return tx.Commit(ctx)
}
rows, err := tx.Query(ctx, `
SELECT id FROM accounts
WHERE id IN ($1, $2)
ORDER BY id
FOR UPDATE`, r.FromID, r.ToID)
if err != nil {
return err
}
count := 0
for rows.Next() {
count++
}
rows.Close()
if err = rows.Err(); err != nil {
return err
}
if count != 2 {
return errAcct
}
tag, err = tx.Exec(ctx, `
UPDATE accounts
SET balance = balance - $1, updated_at = now()
WHERE id = $2 AND balance >= $1`, r.Amount, r.FromID)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return errFunds
}
if _, err = tx.Exec(ctx, `
UPDATE accounts
SET balance = balance + $1, updated_at = now()
WHERE id = $2`, r.Amount, r.ToID); err != nil {
return err
}
if _, err = tx.Exec(ctx, `
UPDATE transfer_requests
SET status = 'completed', completed_at = now()
WHERE idempotency_key = $1`, key); err != nil {
return err
}
return tx.Commit(ctx)
}
func (a *app) handleTransfer(w http.ResponseWriter, req *http.Request) {
req.Body = http.MaxBytesReader(w, req.Body, 4096)
var in request
dec := json.NewDecoder(req.Body)
dec.DisallowUnknownFields()
if dec.Decode(&in) != nil || in.FromID <= 0 || in.ToID <= 0 ||
in.FromID == in.ToID || in.Amount <= 0 || in.Amount > 1_000_000_000_000 {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
key := req.Header.Get("Idempotency-Key")
if len(key) < 8 || len(key) > 128 {
http.Error(w, "invalid Idempotency-Key", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second)
defer cancel()
start := time.Now()
err := a.transfer(ctx, key, in)
slog.Info("transfer", "key", key, "duration", time.Since(start), "error", err)
switch {
case err == nil:
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "completed"})
case errors.Is(err, errFunds), errors.Is(err, errKey), errors.Is(err, errAcct):
http.Error(w, err.Error(), http.StatusConflict)
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "database deadline exceeded", http.StatusGatewayTimeout)
default:
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
cfg, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
if err != nil {
panic(err)
}
cfg.MaxConns = 20
cfg.MinConns = 2
cfg.MaxConnLifetime = 30 * time.Minute
cfg.MaxConnIdleTime = 5 * time.Minute
cfg.HealthCheckPeriod = time.Minute
cfg.ConnConfig.ConnectTimeout = 3 * time.Second
db, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
panic(err)
}
defer db.Close()
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
err = db.Ping(pingCtx)
cancel()
if err != nil {
panic(err)
}
a := &app{db: db}
mux := http.NewServeMux()
mux.HandleFunc("POST /transfers", a.handleTransfer)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
healthCtx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
if err := db.Ping(healthCtx); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusNoContent)
})
srv := &http.Server{
Addr: "127.0.0.1:8080",
Handler: mux,
ReadHeaderTimeout: 2 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server stopped", "error", err)
stop()
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("shutdown", "error", err)
}
}
Testirajte uspjeh, ponavljanje i vraćanje
Pokrenite uslugu, pošaljite isti zahtjev dvaput, a zatim provjerite stanja računa. Oba poziva trebaju prijaviti dovršetak, ali promjena stanja mora se dogoditi samo jednom.
DATABASE_URL="$DATABASE_URL" go run .
curl --fail-with-body -X POST http://127.0.0.1:8080/transfers \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: transfer-demo-0001' \
-d '{"from_id":1,"to_id":2,"amount":1250}'
curl --fail-with-body -X POST http://127.0.0.1:8080/transfers \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: transfer-demo-0001' \
-d '{"from_id":1,"to_id":2,"amount":1250}'
psql "$DATABASE_URL" -c \
'SELECT id, balance FROM accounts ORDER BY id;'
Očekivana stanja su 8750 i 11250. Ponovno upotrijebite ključ s drugim iznosom i očekujte HTTP 409. Zatražite više od raspoloživog stanja i potvrdite da ni jedan račun ni transfer_requests ne zadržavaju djelomičnu promjenu. Za testiranje sukoba pokrenite istodobne prijenose u oba smjera; determinističko zaključavanje trebalo bi spriječiti uobičajene zastoje, dok vremensko ograničenje zaključavanja drži patološka čekanja konačnima.
Migrirajte bez pretvaranja implementacije u incident s zaključavanjem
Za postojeću tablicu izbjegavajte kombiniranje dugog popunjavanja unatrag, novog ograničenja i uvođenja aplikacije u jednoj migraciji. Koristite proširivanje i sažimanje:
- Dodajte nullable stupac ili kompatibilnu strukturu tablice uz kratko vremensko ograničenje zaključavanja.
- Implementirajte kod koji razumije i staru i novu reprezentaciju.
- Popunite podatke u malim, potvrđenim skupinama s kursorom koji se može ponovno pokrenuti.
- Zasebno dodajte provjeru valjanosti; PostgreSQL-ova ograničenja
NOT VALIDmogu, gdje je primjenjivo, smanjiti početni prozor blokiranja. - Potvrdite valjanost, prebacite čitanja i uklonite zastarjele strukture u kasnijem izdanju.
Velike indekse izgradite s pomoću CREATE INDEX CONCURRENTLY u migraciji koja nije omotana s BEGIN. Dodijelite joj vlastito izdašno, ali konačno vremensko ograničenje naredbe. Ako migrate prijavi prljavu verziju, pregledajte učinke baze podataka i migracije prije upotrebe force; prisiljavanje samo mijenja metapodatke i ne popravlja djelomično primijenjeni DDL.
Sigurnost, vidljivost i performanse
Produkcijske vjerodajnice trebale bi pripadati ulozi za prijavu koja ima samo potrebne ovlasti nad tablicama i sekvencama. Koristite TLS kada baza podataka prelazi nepouzdanu mrežu, učitajte tajne iz datoteke okruženja čitljive samo za roota ili upravitelja tajni te nikada ne bilježite URL-ove baze podataka ni tijela zahtjeva. PostgreSQL držite zaštićenim vatrozidom od javnih sučelja.
JSON zapis već bilježi latenciju i klasu kvara. Dodajte metrike za dohvaćene veze skupa, neaktivne veze, trajanje dohvaćanja, vremenska ograničenja upita, vremenska ograničenja zaključavanja, vraćanja transakcija i HTTP statusne kodove. Upozoravanje na vrijeme čekanja skupa često je korisnije od praćenja samo broja aktivnih veza.
Nemojte automatski ponavljati svaku pogrešku baze podataka. Ponovni pokušaj prikladan je samo za prolazne, klasificirane kvarove poput sukoba serijalizacije, a trebaju mu jitter, malo ograničenje broja pokušaja i izvorni rok zahtjeva. Idempotentnost čini ponovni pokušaj cijelog zahtjeva sigurnijim; ne čini neograničeni pritisak ponovnih pokušaja bezopasnim.
Implementirajte kao ograničeni sustav
Izgradite statički binarni program tamo gdje je podržan, instalirajte ga u izričit direktorij aplikacije i pokrenite ga kao neprivilegirani systemd korisnik. Administrator mora stvoriti servisni račun i zaštititi /etc/ledger/ledger.env dozvolom 0600.
CGO_ENABLED=0 go build -trimpath -o ledger .
sudo install -d -o root -g root -m 0755 /opt/ledger
sudo install -o root -g root -m 0755 ./ledger /opt/ledger/ledger
# Release job: migrate first, then restart only after success.
PGCONNECT_TIMEOUT=3 \
PGOPTIONS='-c lock_timeout=2s -c statement_timeout=15min' \
migrate -path migrations -database "$DATABASE_URL" up
sudo systemctl restart ledger.service
curl --fail http://127.0.0.1:8080/healthz
Postavite ojačani obrnuti proxy ispred za TLS, autentikaciju, ograničenja brzine zahtjeva i javnu izloženost. Držite aplikaciju vezanu za lokalnu petlju, osim ako mrežni dizajn izričito ne zahtijeva drukčije. Tijekom postupne implementacije osigurajte da vrijeme predviđeno za gašenje premašuje dvosekundni rok baze podataka i peterosekundno vremensko ograničenje HTTP pisanja.
Završni kontrolni popis za provjeru
- Maksimum skupa uklapa se u rezervirani proračun veza PostgreSQL-a.
- Vremenska ograničenja uspostavljanja veze, zahtjeva, naredbe, zaključavanja i gašenja konfigurirana su neovisno.
- Redci računa zaključavaju se determinističkim redoslijedom.
- Ključevi idempotentnosti vezani su uz potpuni sadržaj operacije.
- Neuspješni prijenosi ne ostavljaju terećenje, odobrenje ni potvrđeni zapis na čekanju.
- Duplicirani zahtjevi mijenjaju stanja samo jednom.
- Migracije se pokreću jednom po izdanju uz ograničena zaključavanja i pregledane kvarove.
- Baza podataka je privatna, vjerodajnice imaju najmanje potrebne ovlasti, a tajne nisu u zapisima.
- Čekanje na skup, latencija upita, vremenska ograničenja, vraćanja i odgovori s pogreškom su vidljivi.
Pouzdan kod baze podataka nije definiran samo transakcijom sretnog puta. To je koreografija oko te transakcije: koliko dugo smije čekati, koliko se drugih procesa smije natjecati, što ponovni pokušaj znači, kako promjene sheme koegzistiraju s aktivnim prometom i kako gašenje prekida rad. Učinite te granice izričitima i baza podataka postaje kontrolirani dio sustava umjesto njegovog najzagonetnijeg pojačivača kvarova.