Go Veritabanı & Transaction'lar — Detaylı, Uzman Seviye Rehber

20 Ağustos 2026 · netologist · 24 dakika, 4914 kelime ·

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

  1. database/sql Zihinsel Modeli
  2. Bağlantılar & Connection Pooling
  3. Temel Sorgular: Query, QueryRow, Exec
  4. Prepared Statement’lar
  5. Transaction’lar: Temeller
  6. Context & Transaction Yaşam Döngüsü
  7. Isolation Level’lar Derinlemesine
  8. Kilitleme, Deadlock’lar & Retry Stratejileri
  9. Savepoint’ler & İç İçe (Nested) Transaction’lar
  10. Transaction Tasarım Pattern’leri
  11. Dağıtık Transaction’lar & Saga Pattern
  12. Hata Yönetimi Derinlemesine
  13. ORM’ler & Query Builder’lar: sqlx, GORM, ent, sqlc
  14. Transaction Testleri
  15. Performans & Gözlemlenebilirlik (Observability)
  16. Sık Yapılan Hatalar Kontrol Listesi
  17. 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

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:


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:

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:


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

AnomaliAçı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

LevelDirty ReadNon-Repeatable ReadPhantom Read
Read UncommittedMümkünMümkünMümkün
Read CommittedEngellenirMümkünMümkün
Repeatable ReadEngellenirEngellenirMümkün (DB’ye göre değişir)
SerializableEngellenirEngellenirEngellenir

PostgreSQL özellikleri

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

Level seçimi


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:

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ı:

  1. Jitter’lı exponential backoff, birçok transaction eşzamanlı çakıştığında oluşan “thundering herd” retry fırtınasını önler.
  2. 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.
  3. 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ü:

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):

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çKategoriTransaction API’siNotlar
database/sqlstdlibBeginTx / TxTam kontrol, en uzun (verbose) kod.
sqlxince (thin) extensiondb.Beginx() bir *sqlx.Tx dönerStructScan, named query ekler; transaction semantiği stdlib ile aynıdır.
sqlckod üreteciHerhangi bir DBTX arayüzüne bağlı *Queries üretirHam 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.
GORMtam ORMdb.Transaction(func(tx *gorm.DB) error {...})Hatada otomatik rollback yapan kullanışlı closure; savepoint’ler üzerinden nested transaction’ları destekler (tx.SavePoint("sp1")).
entkod üreten ORM (Facebook/Meta)client.Tx(ctx) bir tx-scoped client dönerGüç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)

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


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