Tranzakciók
A dbx mögötti kemény követelmény: egy service-nek képesnek kell lennie több repositoryt hívni egyetlen adatbázis-tranzakcióban, anélkül hogy bármelyik repository tudna a tranzakciókról. Ez az oldal a mintát tárgyalja mélységében: a mechanika pgx-specifikus részletei a pgx oldalon, a bun-változat a bun oldalon.
A három építőelem
// 1. A kontrakt, amit a service fog (dbx csomag, driver-független):
type Transactor interface {
WithinTransaction(ctx context.Context, fn func(ctx context.Context) error) error
}
// 2. A query-felület, amit a repositoryk fognak (itt a pgx-változat):
type DBTX interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// 3. Az executor, ami a kettőt összeköti: minden hívás előbb tranzakciót
// keres a contextben; ha nincs, a poolon fut.
db := pg.NewDB(pool)
tx := pg.NewTransactor(pool)
A WithinTransaction tranzakciót nyit, a contextbe injektálja, és a módosított contexttel hívja az fn-t. Az fn-en belül minden repository-hívás ugyanazt a contextet kapja tovább. A pg.DB (vagy bunon a bunx.From/bunx.Conn resolver) kihalássza belőle a tranzakciót, és azon futtatja a query-t. A repositoryk szignatúrája nem változik; a tranzakció létezéséről csak az executor tud.
A PlaceOrder service
A minta legjobb példája a shop rendelésfeladása. Három írásnak kell atomikusan megtörténnie: a rendelés beszúrásának, a készlet csökkentésének és az orderPlaced event outbox-sorának. Ha bármelyik hibázik, egyiknek sem szabad megmaradnia: nem létezhet rendelés levont készlet nélkül, és nem mehet ki visszaigazoló e-mail egy soha nem rögzített rendelésről.
A teljes metódus a modul közös core/ csomagjában él (internal/modules/shop/core/), az api surface service-varrata innen hívja:
type OrderService struct {
orders *repository.OrderRepository
products *repository.ProductRepository
tx dbx.Transactor
dispatcher events.Dispatcher
}
func (s *OrderService) PlaceOrder(ctx context.Context, in PlaceOrderInput) (*Order, error) {
var order *Order
err := s.tx.WithinTransaction(ctx, func(ctx context.Context) error {
var err error
order, err = s.orders.Insert(ctx, in) // 1. rendelés
if err != nil {
return err
}
if err := s.products.DecrementStock(ctx, in.ProductID, in.Qty); err != nil {
return err // 2. készlet
}
return s.dispatcher.Dispatch(ctx, shopevents.OrderPlaced{ // 3. outbox-sor
OrderID: order.ID,
Email: in.Email,
})
})
if err != nil {
return nil, err
}
return order, nil
}
(A shopevents a modul saját event-csomagjának aliasa: internal/modules/shop/events, az add event generálja.) Mind a három hívás ugyanazt a pgx.Tx-et oldja fel a contextből:
- az
orders.Insertés aproducts.DecrementStockapg.DB-n keresztül; - a
dispatcheregyoutbox.NewDispatcher, amelynek store-ja szintén apg.DB-n ír. Az event ugyanabban a tranzakcióban kerül azoutbox_eventstáblába, mint az üzleti írások. Commitkor együtt válnak láthatóvá; rollbackkor az event sem létezik. Ez a tranzakciós outbox lényege.
Ha a DecrementStock insufficient_stock hibával tér vissza, a már lefutott Insert is visszagördül, és az event sosem kerül kézbesítésre. A hiba az errs láncon át problem+json válasszá képződik le.
Szemantika
A garanciák, amiket a dbx saját integrációs tesztjei rögzítenek (go test -tags=integration ./dbx/pg/ és ./dbx/bunx/, valós PostgreSQL ellen):
- Commit csak akkor, ha az
fnnil-t ad vissza. Bármilyen hiba rollback, a hiba változatlanul propagál a hívó felé. - Panic rollbackel és újradobódik. Egy handler-pánik nem hagyhat félbe tranzakciót.
- A rollback a lemondott contextet is túléli: a cleanup
context.WithoutCancel-lel fut, így egy kérés-timeout nem akadályozza meg a tranzakció rendezett visszagörgetését. - A beágyazott
WithinTransactioncsatlakozik a külső tranzakcióhoz: lapos nesting, savepoint nélkül. A commitot/rollbacket mindig a legkülső hívás birtokolja: ha a belső blokk sikeres, de a külső utána hibázik, a belső írások is visszagördülnek.
A lapos nesting gyakorlati következménye: egy service-metódus szabadon hívhat másik service-metódust, ami maga is WithinTransaction-t használ, a belső hívás észrevétlenül a külső tranzakció része lesz. Nem kell „tranzakción belül vagyunk-e már?" logikát írni.
WithinTransaction-ön kívül.Miért a context, és nem paraméter?
A klasszikus alternatíva a tranzakció kézi továbbadása: minden repository-metódus kap egy tx-paramétert, vagy két változatban létezik (Insert / InsertTx). Ez három helyen fáj:
- Minden szignatúra megfertőződik. A
ListProducts(ctx, params)helyettListProducts(ctx, tx, params), akkor is, ha az adott hívásnak sosem lesz tranzakciója. - A rétegek összecsúsznak. A service-nek driver-típust (
pgx.Tx) kellene importálnia és továbbadnia, pedig a service-réteg dolga az üzleti logika, nem a kapcsolat-menedzsment. - A keresztvágó kód kimarad. Az outbox dispatcher a kit kódja: nem tudhat a te repositoryjaid tranzakció-paraméteréről. A contextből viszont ugyanúgy kiolvassa a tranzakciót, mint a saját repóid.
A context-hordozás pontosan az a fajta implicit állapot, amire a context.Context való: kérés-szkópú, a hívási láncon lefelé utazó, keresztvágó adat. A Go standard library ugyanígy hordozza a trace-eket, a deadline-okat és a cancel-jelet.
Implementáció-függetlenség
A fenti service-kód egyetlen sora sem pgx-specifikus: a dbx.Transactor-tól és a saját repository-interfészeitől függ. Egy --db bun projektben ugyanez a metódus változatlanul fordul és fut. A main.go bunx.NewTransactor(bunDB)-t injektál, a repositoryk belseje pedig a bun resolvereket használja. A tranzakció-határ kijelölése a service-rétegé; hogy alatta mi hordozza a tranzakciót, az bekötési részlet.
Egy Transactor kapcsolatonként
Egy Transactor mindig egyetlen kapcsolatra érvényes: a WithinTransaction az általa becsomagolt poolon nyit tranzakciót, és a saját implementáció-specifikus kulcsa alatt injektálja a contextbe. Egy nevesített kapcsolathoz kötött repository más Transactor-t kap (a Dependencies <Név>Transactor mezőjét), mint a default: ha a default Transactor.WithinTransaction-jét egy nevesített kapcsolat repository-hívása köré teszed, az nem kerül ugyanabba a tranzakcióba; a saját kapcsolatán fut, összehangolás nélkül. Ez az is az oka, hogy egy kapcsolat connectora (pgx vagy bun) hívásonként nem változhat: a pgx.Tx és a bun.Tx két különböző típus, két különböző context-kulcs alatt, ezek keverése egy kapcsolaton belül épp azt tenné tönkre, amiért a Transactor létezik. Ha két repositorynak együtt kell commit-olnia vagy rollbackelnie, mindkettőnek ugyanazon kapcsolat Transactor-ja mögött kell állnia.
Használt patternek
Ez az oldal a Design patternek katalógus Unit of Work / tranzakció a contextben bejegyzésének teljes kifejtése: a dbx.Transactor interfész és a panic-safe rollback-logika ott, kódrészlettel.