Go Veritabanı & Transaction'lar — Detaylı, Uzman Seviye Rehber
Kıdemli/principal seviye bir Go mühendisinin
database/sql, connection pooling, transaction semantiği, isolation level’lar, hata yönetimi, retry stratejileri, dağıtık (distributed) transaction’lar ve test yaklaşımları hakkında bilmesi gereken her şey.
İçindekiler
database/sqlZihinsel Modeli- Bağlantılar & Connection Pooling
- Temel Sorgular: Query, QueryRow, Exec
- Prepared Statement’lar
- Transaction’lar: Temeller
- Context & Transaction Yaşam Döngüsü
- Isolation Level’lar Derinlemesine
- Kilitleme, Deadlock’lar & Retry Stratejileri
- Savepoint’ler & İç İçe (Nested) Transaction’lar
- Transaction Tasarım Pattern’leri
- Dağıtık Transaction’lar & Saga Pattern
- Hata Yönetimi Derinlemesine
- ORM’ler & Query Builder’lar: sqlx, GORM, ent, sqlc
- Transaction Testleri
- Performans & Gözlemlenebilirlik (Observability)
- Sık Yapılan Hatalar Kontrol Listesi
- Production Seviyesinde Referans Implementasyon
1. database/sql Zihinsel Modeli
database/sql bir driver değildir. driver.Driver arayüzünü implemente eden bir sürücüyü (pgx, go-sql-driver/mysql, mattn/go-sqlite3 gibi) saran bir soyutlama katmanıdır. Bu ayrımı anlamak kritik önem taşır:
import (
"database/sql"
_ "github.com/lib/pq" // driver, init() ile kendini register eder
)
db, err := sql.Open("postgres", dsn)
Kritik nokta: sql.Open veritabanına bağlanmaz. Sadece DSN formatını doğrular ve bir *sql.DB struct’ı hazırlar. Gerçek bağlantı, ilk sorguda ya da açıkça db.Ping() çağrısında lazy olarak kurulur.
*sql.DB tek bir bağlantı değildir — bir connection pool‘dur. Bu, Go’nun veritabanı katmanıyla ilgili en çok yanlış anlaşılan gerçektir. sql.Tx veya sql.Conn ile açıkça sabitlemediğiniz sürece, her Query, Exec veya QueryRow çağrısı farklı bir alttaki bağlantıyı kullanabilir.
if err := db.Ping(); err != nil {
log.Fatalf("veritabanına ulaşılamıyor: %v", err)
}
Uygulama başlangıcında her zaman Ping (veya PingContext) çağırın — bozuk bir DSN’in, üç istek sonra gizemli bir runtime hatası olarak ortaya çıkmasını beklemeyin; erken ve net şekilde fail edin.
2. Bağlantılar & Connection Pooling
*sql.DB, pool’u ayarlamanız için dört ayar sunar. Bunları yanlış ayarlamak, production ortamlarındaki en yaygın olay (incident) nedenlerinden biridir (connection exhaustion, “too many connections” hataları, ya da soket sızıntısı).
db.SetMaxOpenConns(25) // toplam bağlantı sayısına (idle + kullanımda) sert üst sınır
db.SetMaxIdleConns(25) // pool'da boşta tutulan bağlantı sayısı
db.SetConnMaxLifetime(5 * time.Minute) // bağlantıları periyodik olarak zorla yenile
db.SetConnMaxIdleTime(2 * time.Minute) // bu süre boyunca boşta kalan bağlantıları kapat
Her ayarın neden önemli olduğu
SetMaxOpenConns: Bir üst sınır olmadan, bir trafik patlaması binlerce bağlantı açabilir ve veritabanınınmax_connectionslimitini (Postgres/MySQL’de genelde varsayılan 100–500) tüketerek aynı veritabanını paylaşan diğer tüm servisleri de çökertebilir. Genel kural:MaxOpenConns(instance başına) × uygulama instance sayısı, veritabanının bağlantı limitinin rahatlıkla altında kalmalıdır.SetMaxIdleConns: Bu değerMaxOpenConns‘tan düşükse, Go yoğun yük altında bağlantıları agresif şekilde açıp kapatır, bu da p99 gecikmesine TCP/TLS handshake gecikmesi ekler. Güçlü bir sebebiniz yoksa (örneğin idle bağlantıların ücretlendirildiği maliyet-hassas serverless ortamlar) geneldeMaxIdleConns == MaxOpenConnsyapın.SetConnMaxLifetime: Load balancer/proxy’lerin (PgBouncer, AWS RDS Proxy, cloud NAT timeout’ları gibi) arkasında sessizce düşürülen uzun ömürlü TCP bağlantılarına karşı korur. Tipik değer 3–30 dakikadır. Bu ayarlanmazsa, ara sıra tekrarlanması zordriver: bad connectionhataları görürsünüz.SetConnMaxIdleTime(Go 1.15+): Kullanılmadan boşta bekleyen bağlantıları kapatarak, aktif trafiği etkilemeden veritabanı tarafındaki kaynakları serbest bırakır.
Pool sağlığını teşhis etme
stats := db.Stats()
fmt.Printf("Open: %d, InUse: %d, Idle: %d, WaitCount: %d, WaitDuration: %s\n",
stats.OpenConnections, stats.InUse, stats.Idle,
stats.WaitCount, stats.WaitDuration)
Yük altında artan WaitCount/WaitDuration, MaxOpenConns değerinin throughput’unuz için çok düşük olduğunun klasik göstergesidir — bunu standart bir SRE dashboard metriği olarak Prometheus/Datadog’a export edin.
Boyutlandırma formülü (PostgreSQL’in kendi tavsiyesinden, genel kural)
connections = ((core_count * 2) + effective_spindle_count)
SSD tabanlı cloud veritabanları için yaygın bir başlangıç noktası, uygulama replikası başına max_open_conns = 2x CPU çekirdek sayısı olup, bu değer asla tahminle değil, yük testleri altında ampirik olarak ayarlanmalıdır.
3. Temel Sorgular: Query, QueryRow, Exec
// Exec: INSERT/UPDATE/DELETE/DDL için — satır dönmez
res, err := db.ExecContext(ctx, `UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amount, id)
if err != nil {
return fmt.Errorf("exec: %w", err)
}
rows, _ := res.RowsAffected()
// QueryRow: tam olarak bir satır beklenir
var balance int64
err = db.QueryRowContext(ctx, `SELECT balance FROM accounts WHERE id = $1`, id).Scan(&balance)
if errors.Is(err, sql.ErrNoRows) {
return ErrAccountNotFound
}
// Query: birden fazla satır — rows MUTLAKA kapatılmalı
rows, err := db.QueryContext(ctx, `SELECT id, balance FROM accounts WHERE owner = $1`, owner)
if err != nil {
return err
}
defer rows.Close() // kritik — rows'u kapatmamak, alttaki bağlantıyı sızdırır
for rows.Next() {
var id int64
var balance int64
if err := rows.Scan(&id, &balance); err != nil {
return err
}
// işleme
}
if err := rows.Err(); err != nil { // döngü hatasını kontrol et — sıkça unutulur!
return err
}
Kritik kural: Her *sql.Rows mutlaka kapatılmalı ve her döngüden sonra rows.Err() mutlaka kontrol edilmelidir. rows.Close() çağrısını atlamak (döngüden erken break ile çıksanız bile), bağlantıyı bozuk bir durumda pool’a geri sızdırır — bu bağlantı bir daha asla kullanılmaz, pool’unuzun efektif boyutunu sessizce küçültür, ta ki MaxOpenConns‘a çarpıp her şey bloke olana kadar.
Production kodunda her zaman *Context varyantlarını (QueryContext, ExecContext, QueryRowContext, BeginTx) kullanın — context’siz eski (legacy) varyantları asla kullanmayın. Bu, sorgu iptalinin doğru şekilde yayılmasını sağlar (örneğin bir HTTP isteği istemci tarafından iptal edildiğinde).
4. Prepared Statement’lar
stmt, err := db.PrepareContext(ctx, `INSERT INTO logs (msg, ts) VALUES ($1, $2)`)
if err != nil {
return err
}
defer stmt.Close()
for _, entry := range entries {
if _, err := stmt.ExecContext(ctx, entry.Msg, entry.Time); err != nil {
return err
}
}
İnce nokta: *sql.DB bir pool olduğu için, db üzerinde hazırlanan bir *sql.Stmt tek bir fiziksel bağlantıya bağlı değildir. Go, alt katmanda, hangi bağlantıyı kullanırsa kullansın statement’ı şeffaf şekilde yeniden hazırlar ve bağlantı başına prepared statement’ları dahili olarak cache’ler. Bu kullanışlıdır ama şu anlama gelir:
*sql.DBüzerindeki prepared statement’lar eşzamanlı (concurrent) kullanım için güvenlidir.- Bir transaction içinde statement hazırlamak (
tx.PrepareContext), o transaction’ın tek bağlantısına bağlıdır — bu, transaction içindeki yoğun döngüler (örneğin toplu insert) için doğru yaklaşımdır. - Çoğu modern driver (
pgx,go-sql-driver/mysql) zaten dahili olarak statement cache’lemesi yapar, bu yüzden tek seferlik sorgular için manuelPrepareçoğu zaman karmaşıklığa değmez — bunu gerçekten yoğun path’ler için saklayın (istek başına binlerce çalıştırma, toplu import gibi).
5. Transaction’lar: Temeller
Bir transaction, birden fazla ifadeyi tek bir atomik birim halinde gruplar — ya hepsi başarılı olur (commit) ya da hiçbiri olmaz (rollback), klasik ACID özelliklerini karşılayarak:
- Atomicity (Atomiklik) — ya hep ya hiç çalıştırma.
- Consistency (Tutarlılık) — veritabanı bir geçerli durumdan diğerine geçer (constraint’ler, trigger’lar, foreign key’ler zorlanır).
- Isolation (İzolasyon) — eşzamanlı transaction’lar birbirlerinin commit edilmemiş değişikliklerini görmez (derece isolation level’a bağlıdır — bkz. §7).
- Durability (Kalıcılık) — bir kez commit edildiğinde, değişiklikler çökmelere karşı hayatta kalır (WAL/redo log’lar aracılığıyla).
Kanonik Go pattern’i
func TransferFunds(ctx context.Context, db *sql.DB, fromID, toID int64, amount int64) error {
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelReadCommitted, // örtük yerine açık (explicit) tercih edilmeli
ReadOnly: false,
})
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
// defer + commit-sonrası-rollback-no-op pattern'i, panic'ler dahil
// her çıkış yolunda temizliği garanti etmenin idiomatik yoludur.
defer func() {
_ = tx.Rollback() // zaten commit edildiyse sql.ErrTxDone döner — güvenle yok sayılır
}()
res, err := tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1`,
amount, fromID)
if err != nil {
return fmt.Errorf("borçlandırma: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrInsufficientFunds // iş kuralı ihlal edildi — alacaklandırmadan önce çık
}
if _, err := tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance + $1 WHERE id = $2`,
amount, toID); err != nil {
return fmt.Errorf("alacaklandırma: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
defer tx.Rollback() neden her zaman güvenli?
tx.Commit() başarılı olduktan sonra transaction tamamlanmıştır. Sonrasında tx.Rollback() çağırmak sql.ErrTxDone döner ve deferred çağrı bunu sessizce yok sayar. Bu pattern, her erken dönüş, hata ya da panic’in rollback ile sonuçlanmasını garanti eder — her branch’te manuel olarak rollback yapmayı hatırlamanız gerekmez.
Aynı mantıksal işlemde db.Query ile tx.Query‘yi asla karıştırmayın
db.BeginTx çağırdıktan sonra, o transaction’a katılması gereken tüm ifadeler *sql.Tx nesnesi üzerinden çağrılmalı, *sql.DB üzerinden değil. Transaction ortasında db.Exec çağırmak, transaction’ın tamamen dışında, farklı bir pool bağlantısında çalışır — klasik, fark edilmesi zor bir bug.
6. Context & Transaction Yaşam Döngüsü
Transaction’lar, yaşam döngüleri boyunca pool’dan çekilmiş (checked out) bir veritabanı bağlantısını tutar. Bunun gerçek sonuçları vardır:
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
tx, err := db.BeginTx(ctx, nil)
Transaction açıkken ctx iptal edilirse (istemci bağlantıyı keserse, timeout tetiklenirse), Go’nun database/sql‘i transaction’ı otomatik olarak rollback eder ve bağlantıyı pool’a geri verir — iptali manuel olarak izlemenize gerek yoktur. Ancak:
- Asla bir transaction’ı başka bir servise yapılan ağ çağrısı, yavaş bir dış API çağrısı ya da kullanıcı girdisi boyunca açık tutmayın. Bir üçüncü taraf HTTP çağrısını kapsayan bir transaction, bu çağrı süresince bir bağlantıyı (ve çoğu zaman satır kilitlerini) tutar — yük altında connection pool’unuzu tüketip zincirleme kesintilere (cascading outage) yol açmanın klasik yoludur.
- Transaction’ları kısa ömürlü tutun: ihtiyacınız olanı çekin, bellekte hesaplayın, kısa ve öz bir transaction içinde yazın.
- Bir DB yazmasını dış bir çağrıyla koordine etmeniz gerekiyorsa, transaction’ı açık tutmak yerine outbox pattern‘i tercih edin (aynı transaction içinde bir
outboxtablosuna event yazın, sonra ayrı bir worker bunu asenkron olarak yayınlasın).
7. Isolation Level’lar Derinlemesine
sql.TxOptions.Isolation, bir isolation level talep etmenizi sağlar, ancak driver/DB yalnızca bir alt küme destekleyebilir ve desteklenmeyen level’lar genelde en yakın daha katı seviyeye sessizce yükseltilir (davranış driver’a özgüdür — her zaman veritabanınızın dokümantasyonunu kontrol edin).
const (
sql.LevelDefault
sql.LevelReadUncommitted
sql.LevelReadCommitted
sql.LevelWriteCommitted
sql.LevelRepeatableRead
sql.LevelSnapshot
sql.LevelSerializable
sql.LevelLinearizable
)
Üç klasik anomali
| Anomali | Açıklama |
|---|---|
| Dirty Read (Kirli Okuma) | Başka bir transaction’ın commit edilmemiş değişikliklerini okumak. |
| Non-Repeatable Read (Tekrarlanamayan Okuma) | Bir transaction içinde aynı satırı yeniden okumak, araya başka bir transaction’ın commit ettiği değişiklik girdiği için farklı değer verir. |
| Phantom Read (Hayalet Okuma) | Aynı aralık (range) sorgusunu tekrar çalıştırmak, başka bir transaction eşleşen satırlar ekleyip/sildiği için farklı bir satır kümesi döner. |
Isolation level matrisi
| Level | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | Mümkün | Mümkün | Mümkün |
| Read Committed | Engellenir | Mümkün | Mümkün |
| Repeatable Read | Engellenir | Engellenir | Mümkün (DB’ye göre değişir) |
| Serializable | Engellenir | Engellenir | Engellenir |
PostgreSQL özellikleri
- PostgreSQL gerçek anlamda “Read Uncommitted” implemente etmez — sessizce Read Committed gibi davranır (MVCC tasarımı gereği, Postgres’te dirty read hiçbir zaman mümkün değildir).
- Postgres’in Repeatable Read‘i aslında phantom read’leri de engeller (SQL standardının gerektirdiğinden daha katı olan “Snapshot Isolation"a daha yakındır).
- Postgres’in Serializable‘ı Serializable Snapshot Isolation (SSI) kullanır; bu, tehlikeli okuma/yazma bağımlılık döngülerini tespit eder ve bir transaction’ı
40001serialization failure hatasıyla iptal eder — uygulamanız bu hata koduna karşı retry yapmaya hazır olmalıdır.
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
// ... iş mantığı ...
err = tx.Commit()
var pgErr *pq.Error
if errors.As(err, &pgErr) && pgErr.Code == "40001" {
// serialization_failure — tüm transaction'ı baştan retry et
}
MySQL/InnoDB özellikleri
- Varsayılan isolation Repeatable Read‘dir ve (SQL standardının aksine) next-key locking sayesinde locking read’ler için phantom read’leri de engeller — ancak sıradan non-locking
SELECT‘ler, bazı edge case’lerde aynı transaction içinde ifadeler arasında yine de phantom görebilir. - MySQL’in
SELECT ... FOR UPDATEveSELECT ... FOR SHARE‘i açıkça satır/gap kilitleri alır.
Level seçimi
- Read Committed (Postgres ve çoğu sistemde varsayılan): çoğu CRUD işlemi için iyi bir varsayılan — ucuz, dirty read’leri önler.
- Repeatable Read / Snapshot: aynı transaction içinde önce okuyup sonra o okumaya dayanarak yazıyorsanız ve okumanın altınızda değişmeyeceğini garanti etmeniz gerekiyorsa kullanın (örneğin bir envanter sayısını azaltmadan önce okumak).
- Serializable: gerçekten doğruluk-kritik çok adımlı invariant’lar için kullanın (örneğin çifte rezervasyonu önleme, finansal defter/ledger) — ancak retry mantığı için bütçe ayırın, çünkü SSI abort’ları istisna değil, beklenen bir durumdur.
8. Kilitleme, Deadlock’lar & Retry Stratejileri
Optimistic vs. pessimistic locking
Pessimistic (kötümser) locking — baştan bir satır kilidi almak:
var balance int64
err := tx.QueryRowContext(ctx,
`SELECT balance FROM accounts WHERE id = $1 FOR UPDATE`, id).Scan(&balance)
FOR UPDATE, bu transaction commit/rollback edilene kadar diğer transaction’ların o satırı (FOR UPDATE/FOR SHARE ile) okumasını ya da yazmasını engeller. Bloke olmak kabul edilemezse FOR UPDATE NOWAIT (kilitliyse anında fail et) veya FOR UPDATE SKIP LOCKED (kilitli satırları atla — job queue’lar kurmak için son derece kullanışlı) kullanın.
// Job-queue pattern'i: SKIP LOCKED, birden fazla worker'ın birbirini
// bloke etmeden farklı job'ları güvenle çekmesini sağlar.
rows, err := tx.QueryContext(ctx, `
SELECT id, payload FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED`)
Optimistic (iyimser) locking — kilit tutulmaz; çakışmalar yazma anında bir versiyon kolonuyla tespit edilir:
res, err := tx.ExecContext(ctx,
`UPDATE accounts SET balance = $1, version = version + 1
WHERE id = $2 AND version = $3`,
newBalance, id, expectedVersion)
n, _ := res.RowsAffected()
if n == 0 {
return ErrOptimisticLockConflict // başka biri önce güncelledi — okumadan tekrar başla
}
Optimistic locking, düşük çekişme (contention), yüksek okuma yükü altında daha iyi ölçeklenir (iş mantığı hesaplaması sırasında kilit tutulmaz); pessimistic locking, retry’ların sık ve israf olacağı yüksek çekişme durumlarında daha güvenlidir.
Deadlock’lar
Bir deadlock, transaction A’nın B’nin istediği kilidi tutması ve B’nin de A’nın istediği kilidi tutması durumunda oluşur. Veritabanları bu döngüyü tespit eder ve bir transaction’ı iptal eder (“kurban” seçer), driver’a özgü bir hata döner:
- PostgreSQL: SQLSTATE
40P01(deadlock_detected) - MySQL/InnoDB: hata
1213(ER_LOCK_DEADLOCK)
Deadlock’lar, çakışan satırlara eşzamanlı yazıcıları olan herhangi bir sistemde normal, beklenen bir durumdur — kodunuz bunları fatal olarak değil, retry ile ele almalıdır.
Production seviyesinde bir retry wrapper’ı
package txutil
import (
"context"
"database/sql"
"errors"
"fmt"
"math/rand"
"time"
"github.com/jackc/pgconn"
)
// RetryableTxFunc, bir transaction içinde çalıştırılan iş birimidir.
type RetryableTxFunc func(ctx context.Context, tx *sql.Tx) error
// WithRetryableTx, fn'i bir transaction içinde çalıştırır ve serialization
// failure ile deadlock hatalarında exponential backoff + jitter ile otomatik retry yapar.
func WithRetryableTx(ctx context.Context, db *sql.DB, opts *sql.TxOptions, fn RetryableTxFunc) error {
const maxAttempts = 5
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<uint(attempt)) * 10 * time.Millisecond
jitter := time.Duration(rand.Int63n(int64(backoff)))
select {
case <-time.After(backoff + jitter):
case <-ctx.Done():
return ctx.Err()
}
}
err := runOnce(ctx, db, opts, fn)
if err == nil {
return nil
}
if !isRetryable(err) {
return err // kalıcı hata — deneme hakkını harcama
}
lastErr = err
}
return fmt.Errorf("%d retry denemesi aşıldı: %w", maxAttempts, lastErr)
}
func runOnce(ctx context.Context, db *sql.DB, opts *sql.TxOptions, fn RetryableTxFunc) (err error) {
tx, err := db.BeginTx(ctx, opts)
if err != nil {
return err
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p) // rollback'i garanti ettikten sonra panic'i tekrar fırlat
}
if err != nil {
_ = tx.Rollback()
return
}
err = tx.Commit()
}()
err = fn(ctx, tx)
return err
}
func isRetryable(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "40001", // serialization_failure
"40P01": // deadlock_detected
return true
}
}
return false
}
Kullanımı:
err := txutil.WithRetryableTx(ctx, db, &sql.TxOptions{Isolation: sql.LevelSerializable},
func(ctx context.Context, tx *sql.Tx) error {
return TransferFundsTx(ctx, tx, fromID, toID, amount)
})
Bu wrapper’daki temel tasarım kararları:
- Jitter’lı exponential backoff, birçok transaction eşzamanlı çakıştığında oluşan “thundering herd” retry fırtınasını önler.
- Retry edilebilir ile kalıcı hataların ayrıştırılması — bir unique constraint ihlali (
23505) retry edilmemelidir; retry etmek sonsuza kadar aynı şekilde başarısız olur. - Rollback sonrası panic’i tekrar fırlatmak, panic’lerin caller seviyesindeki recover/loglamaya ulaşmasını sağlarken, transaction’ın asla asılı (dangling) kalmamasını garanti eder.
9. Savepoint’ler & İç İçe (Nested) Transaction’lar
database/sql‘in nested transaction için yerleşik desteği yoktur — aynı *sql.DB üzerinde tekrar BeginTx çağırmak, pool’dan basitçe farklı bir bağlantı çeker ve iç içe değil, tamamen bağımsız yeni bir transaction oluşturur. Bu, sık karşılaşılan bir bug kaynağıdır.
Savepoint’ler, tek bir transaction içinde kısmi rollback için doğru mekanizmadır, ham SQL olarak verilir:
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
_, _ = tx.ExecContext(ctx, `INSERT INTO orders (...) VALUES (...)`)
_, err := tx.ExecContext(ctx, `SAVEPOINT sp1`)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO risky_step (...) VALUES (...)`); err != nil {
// sadece savepoint'e kadar rollback et — önceki INSERT hayatta kalır
_, _ = tx.ExecContext(ctx, `ROLLBACK TO SAVEPOINT sp1`)
} else {
_, _ = tx.ExecContext(ctx, `RELEASE SAVEPOINT sp1`)
}
return tx.Commit()
sqlx gibi kütüphaneler de (alttaki SQL semantiğine sadık kalmak için, bilinçli bir tercih olarak) yerleşik nested-transaction desteği eklemez, ancak üst seviye framework’ler (örneğin GORM’un tx.SavePoint()/tx.RollbackTo()‘su) bu pattern’i kolaylık için sarmalar.
10. Transaction Tasarım Pattern’leri
10.1 Repository + Unit of Work
*sql.Tx‘i katmanlar arasında taşımak oldukça uzun (verbose) kod gerektirir. Yaygın bir pattern, “sorgulanabilir bir şey” üzerinden soyutlama yapmaktır; böylece aynı repository kodu hem transaction içinde hem dışında çalışır:
// Querier arayüzü hem *sql.DB hem de *sql.Tx tarafından karşılanır.
type Querier interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
type AccountRepo struct {
db Querier
}
func (r *AccountRepo) Debit(ctx context.Context, id, amount int64) error {
_, err := r.db.ExecContext(ctx,
`UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amount, id)
return err
}
// UnitOfWork, birden fazla repository arasında bir transaction'ı koordine eder.
type UnitOfWork struct {
db *sql.DB
}
func (u *UnitOfWork) Execute(ctx context.Context, fn func(repos *Repos) error) error {
tx, err := u.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
repos := &Repos{
Accounts: &AccountRepo{db: tx},
Ledger: &LedgerRepo{db: tx},
}
if err := fn(repos); err != nil {
return err
}
return tx.Commit()
}
Bu size transaction’dan bağımsız (agnostic) repository kodu verir — aynı AccountRepo, ister *sql.DB (her ifadede otomatik commit) ister *sql.Tx (daha büyük bir unit of work’ün parçası) tarafından destekleniyor olsun çalışır.
10.2 Context’e gömülü transaction (örtük yayılım)
Bazı kod tabanları, her fonksiyon imzasından geçirmemek için aktif *sql.Tx‘i context.Context içine saklar:
type txKey struct{}
func WithTx(ctx context.Context, tx *sql.Tx) context.Context {
return context.WithValue(ctx, txKey{}, tx)
}
func TxFromContext(ctx context.Context, db *sql.DB) Querier {
if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok {
return tx
}
return db // pool'a geri dön
}
Bu kullanışlıdır ama tartışmalıdır — önemli bir yan etkiyi (hangi bağlantı, hangi transaction) görünmez bir context değeri arkasına gizler. Birçok kıdemli mühendis, tam da transaction sınırlarını fonksiyon imzalarında ve code review’da görünür kıldığı için açık (explicit) transaction geçirmeyi (10.1) tercih eder. Context’e gömmeyi tutumlu kullanın ve yaparsanız açıkça dokümante edin.
10.3 Outbox Pattern
Bir DB yazmasını “mesaj gönder” yan etkisiyle (Kafka, SQS, webhook) atomik olarak birleştirmek için, dış sistemi asla transaction içinden çağırmayın. Bunun yerine:
func PlaceOrder(ctx context.Context, tx *sql.Tx, order Order) error {
if _, err := tx.ExecContext(ctx, `INSERT INTO orders (...) VALUES (...)`, ...); err != nil {
return err
}
event, _ := json.Marshal(OrderPlacedEvent{OrderID: order.ID})
_, err := tx.ExecContext(ctx,
`INSERT INTO outbox (event_type, payload, created_at) VALUES ($1, $2, now())`,
"order.placed", event)
return err
}
Ayrı bir arka plan worker’ı outbox tablosunu poll eder, event’leri message broker’a yayınlar ve işlenmiş olarak işaretler — size DB transaction’ının tam atomiklik garantisini, hiçbir zaman bir ağ çağrısı boyunca transaction açık tutmadan sağlar.
11. Dağıtık Transaction’lar & Saga Pattern
İki Fazlı Commit (2PC)
database/sql‘in yerleşik 2PC desteği yoktur. Birden fazla veritabanı üzerinde gerçek dağıtık ACID transaction’lar, harici bir transaction koordinatörü gerektirir (örneğin XA transaction’lar). Modern Go mikroservis mimarilerinde nadiren kullanılır çünkü:
- Tüm katılımcıların XA protokolünü desteklemesini gerektirir.
- Koordinatör hatası, kaynakları süresiz olarak kilitli bırakabilir (“in-doubt” transaction’lar).
- Servisleri sıkı bir şekilde birbirine bağlar ve availability’yi zedeler (CAP teoremi ödünleşimleri).
Saga Pattern (pragmatik alternatif)
Servisler arasında yayılan tek bir atomik transaction yerine, bir saga, her biri sonraki bir adım başarısız olursa geri almak için karşılık gelen bir compensating (telafi edici) aksiyona sahip, yerel transaction’lar dizisidir.
type SagaStep struct {
Name string
Action func(ctx context.Context) error
Compensate func(ctx context.Context) error
}
func RunSaga(ctx context.Context, steps []SagaStep) error {
completed := make([]SagaStep, 0, len(steps))
for _, step := range steps {
if err := step.Action(ctx); err != nil {
// Ters sırada geri sar — başarılı olan her şeyi geri al.
for i := len(completed) - 1; i >= 0; i-- {
if cErr := completed[i].Compensate(ctx); cErr != nil {
// Telafi aksiyonlarının başarısız olması alarm gerektirir — bu
// noktada çoğunlukla manuel müdahale gerekir.
log.Printf("KRİTİK: %s için telafi başarısız: %v",
completed[i].Name, cErr)
}
}
return fmt.Errorf("saga %q adımında başarısız oldu: %w", step.Name, err)
}
completed = append(completed, step)
}
return nil
}
Örnek: bir seyahat rezervasyonu = uçak rezervasyonu + otel rezervasyonu + kart tahsilatı. Kart tahsilatı başarısız olursa, telafi aksiyonları otel rezervasyonunu ve uçak rezervasyonunu ters sırayla iptal eder.
Choreography (Koreografi) vs. Orchestration (Orkestrasyon):
- Orchestration (yukarıda gösterilen): merkezi bir koordinatör her servisi çağırır ve tam sırayı bilir — akıl yürütmesi ve debug edilmesi daha kolaydır, ancak koordinatör tek bir mantık noktasıdır (stateless/restartable yapıldığında illa tek hata noktası olmak zorunda değildir).
- Choreography: her servis, önceki servisin yayınladığı event’lere tepki verir (tamamen merkeziyetsiz), gevşek bağlılık (loose coupling) için daha iyidir ama izlenmesi ve debug edilmesi çok daha zordur — sürdürülebilir olması için güçlü gözlemlenebilirlik (distributed tracing) gerektirir.
Süreç ortasında çökmelere dayanması gereken saga’lar için, saga durumunu (mevcut adım, tamamlanan adımlar) bir veritabanı tablosunda kalıcı hale getirin; böylece devam eden bir saga, bir restart’tan sonra devam ettirilebilir — saga’nın kendisini kalıcı durum geçişlerine sahip bir state machine olarak ele alın.
12. Hata Yönetimi Derinlemesine
Bilmeniz gereken sentinel hatalar
sql.ErrNoRows // QueryRow.Scan hiçbir satır eşleşmediğinde döner
sql.ErrTxDone // zaten commit/rollback edilmiş bir Tx üzerinde işlem denenirse
sql.ErrConnDone // serbest bırakılmış bir *sql.Conn üzerinde işlem denenirse
err := db.QueryRowContext(ctx, query, id).Scan(&result)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound // domain hatasına çevir — sql.ErrNoRows'u asla yukarı sızdırma
case err != nil:
return nil, fmt.Errorf("hesap %d sorgulanırken: %w", id, err)
}
Kural: sql.ErrNoRows‘un veri erişim katmanınızdan asla kaçmasına izin vermeyin. Bu, database/sql‘in bir implementasyon detayıdır; caller’ların (özellikle HTTP handler’ların) SQL iç detaylarını bilmesine gerek kalmaması için bunu domain seviyesinde bir ErrNotFound‘a çevirin.
Driver’a özgü hata inceleme
Sabit ihlaller, deadlock’lar, bağlantı hataları gibi ince taneli işlemler için driver’a özgü hata tiplerini açmak üzere errors.As kullanın:
import "github.com/jackc/pgconn" // pgx driver
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23505": // unique_violation
return ErrDuplicateEntry
case "23503": // foreign_key_violation
return ErrInvalidReference
case "23514": // check_violation
return ErrConstraintViolation
case "40001":
return ErrSerializationFailure // caller retry etmeli
case "40P01":
return ErrDeadlockDetected // caller retry etmeli
}
}
lib/pq için (daha eski, bakım modundaki driver) eşdeğer tip *pq.Error‘dır. go-sql-driver/mysql için, sayısal .Number kodlarına sahip *mysql.MySQLError‘dır (örneğin 1062 mükerrer kayıt için, 1213 deadlock için).
Hataları context ile sarmalamak
Hataları her zaman %w ile ve yeniden üretmeye gerek kalmadan debug edebilecek yeterli context ile sarmalayın:
return fmt.Errorf("%d tutarı %d hesabından %d hesabına transfer edilirken: %w", amount, fromID, toID, err)
Bu, errors.Is/errors.As için hata zincirini korurken, operatörlere okunabilir, grep’lenebilir bir log satırı verir.
13. ORM’ler & Query Builder’lar: sqlx, GORM, ent, sqlc
| Araç | Kategori | Transaction API’si | Notlar |
|---|---|---|---|
database/sql | stdlib | BeginTx / Tx | Tam kontrol, en uzun (verbose) kod. |
sqlx | ince (thin) extension | db.Beginx() bir *sqlx.Tx döner | StructScan, named query ekler; transaction semantiği stdlib ile aynıdır. |
sqlc | kod üreteci | Herhangi bir DBTX arayüzüne bağlı *Queries üretir | Ham SQL’i siz yazarsınız; sqlc tip-güvenli Go kodu üretir. Transaction’lar için §10.1’deki Querier arayüzü pattern’iyle birleştirin. |
GORM | tam ORM | db.Transaction(func(tx *gorm.DB) error {...}) | Hatada otomatik rollback yapan kullanışlı closure; savepoint’ler üzerinden nested transaction’ları destekler (tx.SavePoint("sp1")). |
ent | kod üreten ORM (Facebook/Meta) | client.Tx(ctx) bir tx-scoped client döner | Güçlü tip güvenliği ve graf tabanlı şema; karmaşık domain modelleri için iyi. |
GORM transaction örneği
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&Account{}).Where("id = ?", fromID).
Update("balance", gorm.Expr("balance - ?", amount)).Error; err != nil {
return err // GORM, döndürülen herhangi bir hatada otomatik rollback yapar
}
if err := tx.Model(&Account{}).Where("id = ?", toID).
Update("balance", gorm.Expr("balance + ?", amount)).Error; err != nil {
return err
}
return nil // nil döndürülürse commit otomatik gerçekleşir
})
sqlc pattern’i
// sqlc tarafından üretilen Queries struct'ı bir db-veya-tx arayüzüne (DBTX) bağlıdır:
type DBTX interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func (s *Store) TransferTx(ctx context.Context, arg TransferParams) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := New(tx) // New(), DBTX'i sarmalar — burada, transaction'ı
if err := q.DecrementBalance(ctx, arg.FromID, arg.Amount); err != nil {
return err
}
if err := q.IncrementBalance(ctx, arg.ToID, arg.Amount); err != nil {
return err
}
return tx.Commit()
}
sqlc, yüksek performans gerektiren Go ekiplerinde popülerdir çünkü SQL’i SQL olarak tutar (query planları üzerinde tam kontrol, ORM’lerin “N+1” sürprizi yoktur) ve manuel Scan çağrılarının boilerplate’ini kod üretimiyle ortadan kaldırır.
14. Transaction Testleri
14.1 sqlmock ile birim (unit) testler
import "github.com/DATA-DOG/go-sqlmock"
func TestTransferFunds(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
mock.ExpectBegin()
mock.ExpectExec(`UPDATE accounts SET balance = balance - \$1`).
WithArgs(int64(100), int64(1)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE accounts SET balance = balance \+ \$1`).
WithArgs(int64(100), int64(2)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
err = TransferFunds(context.Background(), db, 1, 2, 100)
require.NoError(t, err)
require.NoError(t, mock.ExpectationsWereMet())
}
sqlmock, doğru SQL’in doğru sırada doğru argümanlarla gönderildiğini doğrulamak için harikadır, ama gerçek SQL doğruluğunu (yazım hataları, constraint mantığı) doğrulamaz — bu bir contract test’tir, integration test değil.
14.2 testcontainers-go ile integration testler
import "github.com/testcontainers/testcontainers-go/modules/postgres"
func setupTestDB(t *testing.T) *sql.DB {
ctx := context.Background()
container, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
)
require.NoError(t, err)
t.Cleanup(func() { _ = container.Terminate(ctx) })
connStr, err := container.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
db, err := sql.Open("postgres", connStr)
require.NoError(t, err)
runMigrations(t, db)
return db
}
Gerçek (container’lı) bir Postgres/MySQL instance’ına karşı yapılan gerçek integration testleri, mock’ların asla yakalayamayacağı şeyleri yakalar: constraint ihlalleri, gerçek isolation-level davranışı, deadlock’lar, DB dialektine özgü SQL syntax hataları.
14.3 Test izolasyon stratejisi: test başına transaction rollback
Integration testler için hızlı, güvenilir bir pattern: her testi kendi transaction’ına sarın ve sonunda rollback edin; böylece testler asla birbirine durum sızdırmaz ve açık temizliğe (cleanup) ihtiyaç duymaz:
func withTestTx(t *testing.T, db *sql.DB) *sql.Tx {
tx, err := db.Begin()
require.NoError(t, err)
t.Cleanup(func() { _ = tx.Rollback() })
return tx
}
Bu tx‘i (§10.1’deki Querier arayüzünüz olarak) test edilen koda geçirin — her test mükemmel izolasyonda çalışır ve rollback pratikte bedava’dır.
15. Performans & Gözlemlenebilirlik (Observability)
- Toplu (batch) insert’ler: bir transaction içinde tek satırlı insert’leri döngüyle çalıştırmak yerine çok satırlı
INSERT ... VALUES (...), (...), (...)veya driver’a özgü bulk copy (Postgres içinpgx.CopyFrom) kullanın — kat kat daha hızlıdır. - N+1 sorgulardan kaçının: item başına döngüyle sorgulamak yerine, ilişkili veriyi
JOIN‘lerle veyaWHERE id = ANY($1)toplu lookup’larla çekin. - Pool’unuzu enstrümante edin:
db.Stats()‘ı belirli aralıklarla (örneğin her 15 saniyede bir) metrik sisteminize export edin —WaitCount,WaitDuration,InUse,Idle, connection exhaustion olaylarına karşı ilk savunma hattınızdır. - Transaction sınırlarını trace edin: yavaş transaction’ların, sadece yavaş tekil sorgular değil, distributed trace’lerde görünür olması için
BeginTx/Commit‘i OpenTelemetry span’leriyle sarın. - Yavaş transaction’ları loglayın:
BeginTx‘tenCommit/Rollback‘e kadar geçen duvar saati (wall-clock) süresini izleyin ve aykırı değerleri (outlier) loglayın/alarma bağlayın — 30 saniye açık kalan bir transaction, büyük ihtimalle bir bug’dır (bir kilit için bekleme, transaction içinde kazara yapılan bir dış çağrı, ya da yavaş bir sorguya neden olan eksik bir index).
start := time.Now()
tx, err := db.BeginTx(ctx, nil)
defer func() {
if d := time.Since(start); d > 500*time.Millisecond {
log.Printf("UYARI: uzun süren transaction: %s", d)
}
}()
16. Sık Yapılan Hatalar Kontrol Listesi
-
rows.Close()‘u unutmak → connection pool sızıntısı. -
for rows.Next()döngüsünden sonrarows.Err()kontrolünü unutmak. - Aynı mantıksal işlemde
db.Execiletx.Exec‘i karıştırmak. -
SetMaxOpenConnsayarlamamak → yük altında sınırsız bağlantı artışı. -
SetConnMaxLifetimeayarlamamak → proxy/LB arkasında gizemli “bad connection” hataları. - Bir transaction’ı dış bir ağ çağrısı boyunca açık tutmak.
-
sql.ErrNoRows‘u açıkça ele almamak ve genel bir 500 hatası olarak sızdırmak. -
BeginTx‘in iç içe geçtiğini (nest ettiğini) varsaymak — geçmez; savepoint kullanın. - Retry edilebilir hataları (
40001,40P01, MySQL1213) backoff ile retry etmek yerine yok saymak. - İstek kapsamlı (request-scoped) DB çağrılarında, isteğin context’ini yaymak yerine
context.Background()kullanmak (iptal ve tracing’i kaybettirir). - Parametrize edilmiş sorgular (
$1,?) kullanmamak — ham string birleştirme bir SQL injection vektörüdür. -
BeginTxbaşarılı olduktan hemen sonradefer tx.Rollback()‘i unutmak — Go kod tabanlarındaki en yaygın tek transaction bug’ı.
17. Production Seviyesinde Referans Implementasyon
package accounts
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/jackc/pgconn"
)
var (
ErrAccountNotFound = errors.New("hesap bulunamadı")
ErrInsufficientFunds = errors.New("yetersiz bakiye")
)
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(2 * time.Minute)
return &Store{db: db}
}
func (s *Store) Transfer(ctx context.Context, fromID, toID int64, amount int64) (err error) {
if amount <= 0 {
return fmt.Errorf("geçersiz tutar: %d", amount)
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p)
}
if err != nil {
_ = tx.Rollback()
}
}()
// Aynı hesaptan yapılan eşzamanlı transferleri sıraya koymak için kaynak satırı kilitle.
var balance int64
err = tx.QueryRowContext(ctx,
`SELECT balance FROM accounts WHERE id = $1 FOR UPDATE`, fromID).Scan(&balance)
if errors.Is(err, sql.ErrNoRows) {
return ErrAccountNotFound
}
if err != nil {
return fmt.Errorf("kaynak hesabı kilitlerken: %w", err)
}
if balance < amount {
return ErrInsufficientFunds
}
if _, err = tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amount, fromID); err != nil {
return fmt.Errorf("borçlandırma: %w", err)
}
res, execErr := tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amount, toID)
if execErr != nil {
err = fmt.Errorf("alacaklandırma: %w", execErr)
return err
}
if n, _ := res.RowsAffected(); n == 0 {
err = ErrAccountNotFound
return err
}
if _, err = tx.ExecContext(ctx,
`INSERT INTO ledger (from_id, to_id, amount, created_at) VALUES ($1, $2, $3, now())`,
fromID, toID, amount); err != nil {
return fmt.Errorf("ledger yazımı: %w", err)
}
if err = tx.Commit(); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && (pgErr.Code == "40001" || pgErr.Code == "40P01") {
return fmt.Errorf("commit başarısız (retry edilebilir): %w", err)
}
return fmt.Errorf("commit: %w", err)
}
return nil
}
Bu referans implementasyon, tek bir yerde şunu gösterir: açık isolation level, kritik bir invariant için satır kilitleme, transaction ortasında iş kuralı doğrulaması, panic-güvenli deferred rollback, yapılandırılmış hata sarmalama, retry edilebilir hata sınıflandırması ve bakiye değişikliğiyle aynı atomik birimde yazılan kalıcı bir denetim izi (ledger tablosu).
İleri Okuma
- Go standart kütüphane dokümantasyonu:
database/sql,database/sql/driver - PostgreSQL dokümantasyonu: Transaction Isolation, Explicit Locking
- Gerçek veritabanları arasında isolation-level garantilerine dair Jepsen analizleri
- Martin Kleppmann’ın Designing Data-Intensive Applications kitabı — 7. Bölüm (Transactions), bu rehberdeki her şeyin tanımlayıcı teorik temelidir