Mert Özen Her Satırda Daha İleri
Backend Go ile Backend — #11

Go'dan SQL Server ve PostgreSQL'e: database/sql, go-mssqldb, Connection Pool, Transaction ve sqlc

Mert Özen 15 Kas 2026 21 dk 3 görüntülenme
Go'dan SQL Server ve PostgreSQL'e: database/sql, go-mssqldb, Connection Pool, Transaction ve sqlc

Go'da veritabanına database/sql ile bağlanılır: sql.Open bir connection değil connection pool döndürür, driver blank import ile kaydolur, sipariş ve stok düşümü BeginTx, defer Rollback ve Commit ile tek transaction'da yapılır. Bookstore'un siparişleri go-mssqldb ile SQL Server'da: pool ayarları, koşullu UPDATE ile lost update'i önlemek, Scanner/Valuer ile decimal'dan kuruşa, rows.Close sızıntısı, datetime2 saat dilimi tuzağı, pgx ve sqlc'nin SQL Server durumu.

Sürüm notu: Bu yazıdaki kodlar Go 1.27.4 ile derlenip çalıştırılmıştır; module'ün go satırı 1.26.0'dır ve kod Go 1.26.11 ile de derlenir. Veritabanı tarafında SQL Server 2025 ve github.com/microsoft/go-mssqldb v1.11.2, PostgreSQL örneğinde PostgreSQL 18 ve github.com/jackc/pgx/v5 v5.13.0 kullanıldı. sqlc (v1.31.1) bölümündeki örnekler çalıştırılmadı.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Go API'si bu yazıda siparişleri ilk kez SQL Server'a yazıyor. Orders tablosundaki CreatedAt kolonu datetime2(3); Go tarafında o.CreatedAt bir time.Time ve time.Now() ile dolduruluyor. İlk sürümde insert şöyleydi:

_, err = tx.ExecContext(ctx, `
    INSERT INTO dbo.Orders (Id, CustomerId, Status, CreatedAt, UpdatedAt, PaidAt)
    VALUES (@id, @customer, @status, @created, @updated, @paid)`,
    sql.Named("id", o.ID), sql.Named("customer", o.CustomerID), sql.Named("status", int(o.Status)),
    sql.Named("created", o.CreatedAt), sql.Named("updated", o.UpdatedAt), sql.Named("paid", o.PaidAt))

Sunucu Europe/Istanbul (UTC+3) saat diliminde çalışıyor. Siparişi verip hemen geri okuyunca iki cevaptaki createdAt aynı mı olur? (Aşağıda iki cevabın sadece createdAt alanı var.)

POST /api/orders         "createdAt":"2026-11-15T02:08:48.821561Z"
GET  /api/orders/ORD-1001 "createdAt":"2026-11-15T05:08:48.822Z"

Üç saat fark var. go-mssqldb bir time.Time'ı datetimeoffset olarak gönderiyor (driver'ın README'sinde de böyle yazıyor). SQL Server bu değeri datetime2 kolonuna yazarken offset'i atıyor ve saati olduğu gibi bırakıyor: 05:08, +03:00 bilgisi olmadan kaydediliyor. Geri okurken driver offset'siz datetime2'yi UTC kabul ediyor ve 05:08Z dönüyor. Hata mesajı yok, sadece yanlış veri var. Çözüm, saati veritabanına yazmadan önce UTC() ile çevirmek; snapshot'taki Save bunu yapıyor. Milisaniyeden sonrasının kaybolması ise datetime2(3)'ün hassasiyeti. Bu yazıda database/sql'i, connection pool'u, transaction'ı ve 04'ten beri hiç çalışmamış sqlstore'u gerçek bir SQL Server'a karşı çalıştırmayı göreceğiz.

database/sql, Driver ve Connection Pool

database/sql standart kütüphanede bir soyutlama, .NET'in temel veritabanı erişim katmanının (ADO.NET) karşılığı; SQL Server, PostgreSQL ya da SQLite ile konuşan kod driver'larda. ADO.NET'teki DbConnection/DbCommand ayrımına benziyor, ama bir fark var: Go'da connection açıp kapatmazsın. sql.Open bir *sql.DB döndürür ve bu bir connection değil, connection pool'dur. Driver'ı programa almak için package'ı sadece yan etkisi için import ediyorsun; init fonksiyonu kendini "sqlserver" adıyla kaydediyor:

import (
    "context"
    "database/sql"
    "log"
    "net/http"
    "os"
    "time"

    _ "github.com/microsoft/go-mssqldb" // "sqlserver" driver'ını kaydeder

    "bookstore/internal/catalog"
    "bookstore/internal/httpapi"
    "bookstore/internal/orders"
    "bookstore/internal/orders/memstore"
    "bookstore/internal/orders/sqlstore"
)

main, BOOKSTORE_DB ortam değişkeni varsa sqlstore'u, yoksa 08'deki memstore'u kullanıyor. Pool'u kuran fonksiyon şu:

// openDB pool'u bütün process için bir kez kurar, server'ın cevap verdiğini
// kontrol eder ve tabloları oluşturur.
func openDB(dsn string, books []catalog.Book) (*sql.DB, error) {
    db, err := sql.Open("sqlserver", dsn) // henüz connection yok, sadece pool
    if err != nil {
        return nil, err
    }
    db.SetMaxOpenConns(20)                  // varsayılan 0: sınır yok
    db.SetMaxIdleConns(10)                  // varsayılan 2
    db.SetConnMaxLifetime(30 * time.Minute) // varsayılan 0: connection'lar süresiz yaşar
    db.SetConnMaxIdleTime(5 * time.Minute)  // varsayılan 0: idle connection'lar tutulur

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := db.PingContext(ctx); err != nil { // ilk gerçek connection
        db.Close()
        return nil, err
    }
    if err := sqlstore.EnsureSchema(ctx, db, books); err != nil {
        db.Close()
        return nil, err
    }
    return db, nil
}

Yorumlardaki varsayılanları Go 1.27.4'ün database/sql/sql.go dosyasından okudum: defaultMaxIdleConns = 2, açık connection sınırı yok ve connection'lar süresiz yaşıyor. Varsayılan iki idle connection, saniyede yüzlerce istek alan bir API için az: yük geldiğinde pool sürekli yeni connection açıp fazlasını kapatıyor. Açık connection sınırının olmaması da veritabanını korumuyor; trafik artınca her goroutine kendi connection'ını açabiliyor. SetConnMaxLifetime, load balancer arkasındaki ya da failover yapan bir SQL Server'da eski connection'ların bir süre sonra yenilenmesini sağlıyor. Sayıların doğrusu ölçümle bulunur; db.Stats() pool'un o anki durumunu (InUse, Idle, WaitCount, WaitDuration) veriyor ve aşağıdaki connection sızıntısı örneğinde bunu kullanacağız.

sql.Open veritabanına bağlanmaz, sadece DSN'i (Data Source Name; bağlantı bilgisini tutan metin) doğrular. İlk gerçek bağlantı PingContext'te kuruluyor; yanlış şifre ya da kapalı bir server burada ortaya çıkıyor, ilk HTTP isteğinde değil. Bağlantı bilgisi koda yazılmıyor; ortam değişkeninden okunuyor:

export BOOKSTORE_DB='sqlserver://sa:<password>@localhost:1433?database=BookstoreDb&encrypt=true&TrustServerCertificate=true&app+name=bookstore-api'
go run ./cmd/bookstore-api
2026/11/15 05:11:28 orders are stored in SQL Server
2026/11/15 05:11:28 bookstore-api listening on http://127.0.0.1:52710

Windows'ta PowerShell ile aynı satır $env:BOOKSTORE_DB = '...' şeklinde yazılıyor (bu satırı çalıştırmadım). TrustServerCertificate=true yalnızca kendi makinendeki container için; production'da server sertifikası doğrulanmalı. Bookstore'un .NET tarafında aynı rolü Dapper serisinin "Async ve Connection Yönetimi" yazısındaki tek SqlConnection factory'si ve ADO.NET'in kendi pool'u üstleniyordu. Go'da ise pool *sql.DB'nin kendisi; process başına bir tane açılır ve her yere o geçirilir.

Şema, Scanner ve Valuer: decimal'dan Money'ye

Go API'si için BookstoreDb'de üç tablo ve bir sequence kullanıyoruz. .NET tarafının Entity Framework Core (EF Core) migration'larıyla kurduğu şema daha geniş; burada sadece sipariş akışının ihtiyacı olan kısım var. Şema bir SQL dosyasında duruyor ve //go:embed ile binary'ye gömülüyor (embed'i 18. yazıda ayrıntılı göreceğiz):

IF OBJECT_ID(N'dbo.Books', N'U') IS NULL
CREATE TABLE dbo.Books (
    Isbn   char(13)       NOT NULL CONSTRAINT PK_Books PRIMARY KEY,
    Title  nvarchar(200)  NOT NULL,
    Author nvarchar(200)  NOT NULL,
    Price  decimal(10, 2) NOT NULL,
    Stock  int            NOT NULL CONSTRAINT CK_Books_Stock CHECK (Stock >= 0)
);

IF OBJECT_ID(N'dbo.Orders', N'U') IS NULL
CREATE TABLE dbo.Orders (
    Id         varchar(20)  NOT NULL CONSTRAINT PK_Orders PRIMARY KEY,
    CustomerId nvarchar(64) NOT NULL,
    Status     tinyint      NOT NULL,
    CreatedAt  datetime2(3) NOT NULL,
    UpdatedAt  datetime2(3) NOT NULL,
    PaidAt     datetime2(3) NULL
);

IF OBJECT_ID(N'dbo.OrderItems', N'U') IS NULL
CREATE TABLE dbo.OrderItems (
    OrderId    varchar(20)    NOT NULL CONSTRAINT FK_OrderItems_Orders REFERENCES dbo.Orders (Id),
    LineNumber int            NOT NULL,
    Isbn       char(13)       NOT NULL CONSTRAINT FK_OrderItems_Books REFERENCES dbo.Books (Isbn),
    Title      nvarchar(200)  NOT NULL,
    UnitPrice  decimal(10, 2) NOT NULL,
    Quantity   int            NOT NULL CONSTRAINT CK_OrderItems_Quantity CHECK (Quantity > 0),
    CONSTRAINT PK_OrderItems PRIMARY KEY (OrderId, LineNumber)
);

IF OBJECT_ID(N'dbo.OrderNumbers', N'SO') IS NULL
CREATE SEQUENCE dbo.OrderNumbers AS int START WITH 1001;

Fiyatı veritabanında decimal(10,2) tuttum; raporlar ve .NET tarafı fiyatı bu şekilde görmeli. Ama Go tarafında 03'ten beri fiyatlar Money int64 cinsinden kuruş. İlk denemede Scan(&it.UnitPrice) şu hatayı verdi:

sql: Scan error on column index 0, name "": converting driver.Value type []uint8 ("349.90") to a int64: invalid syntax

go-mssqldb decimal kolonları metin olarak ([]byte("349.90")) döndürüyor, çünkü Go'da kayıpsız bir decimal tipi yok. database/sql bu metni tam sayıya çevirmeye çalışıyor ve noktada takılıyor. Çözüm, Dapper serisinin "Type Handler'ları ve Custom Mapping" yazısındaki type handler'ın Go karşılığı: tipe sql.Scanner ve driver.Valuer interface'lerini eklemek. İkisi de tek method'luk interface; 04'te gördüğümüz gibi Money bunları adlarını anmadan karşılıyor:

// ParseMoney "349.90", "349.9" ya da "349" gibi ondalık bir tutarı
// float64'e hiç çevirmeden okur.
func ParseMoney(s string) (Money, error) {
    whole, frac, hasDot := strings.Cut(s, ".")
    neg := strings.HasPrefix(whole, "-")
    whole = strings.TrimPrefix(whole, "-")
    if whole == "" || len(frac) > 2 || (hasDot && frac == "") || !digits(whole) || !digits(frac) {
        return 0, fmt.Errorf("%q: %w", s, ErrInvalidMoney)
    }
    n, err := strconv.ParseInt(whole+(frac + "00")[:2], 10, 64)
    if err != nil {
        return 0, fmt.Errorf("%q: %w", s, ErrInvalidMoney)
    }
    if neg {
        n = -n
    }
    return Money(n), nil
}
// Scan, sql.Scanner'ı karşılar. SQL Server driver'ları decimal kolonları
// metin ("349.90") olarak döndürür; database/sql bunu tam sayı tipine çeviremez.
func (m *Money) Scan(src any) error {
    switch v := src.(type) {
    case []byte:
        return m.parseInto(string(v))
    case string:
        return m.parseInto(v)
    case int64:
        *m = Money(v * 100)
        return nil
    default:
        return fmt.Errorf("scan %T into Money: %w", src, ErrInvalidMoney)
    }
}
// Value, driver.Valuer'ı karşılar: tutar veritabanına ondalık metin olarak
// gider, böylece decimal(10,2) kolonu onu yuvarlamadan alır.
func (m Money) Value() (driver.Value, error) {
    return m.Decimal(), nil
}

ParseMoney metni float64'e hiç çevirmeden kuruşa çeviriyor; 10'daki 1248.6999999999998 sorunu burada yaşanmıyor. Value ise parametreyi "349.90" metni olarak gönderiyor ve SQL Server onu decimal(10,2)'ye kayıpsız çeviriyor. Bu iki method sayesinde sqlstore'un hiçbir yerinde fiyat dönüşümü yok: sql.Named("price", it.UnitPrice) yazıp geçiyoruz.

Transaction: Sipariş, Kalemler ve Stok Tek Seferde

Bir sipariş üç şeyi birlikte değiştiriyor: Orders'a bir satır, OrderItems'a her kalem için bir satır ve Books.Stock'tan düşülen adetler. Biri yazılıp diğeri yazılmazsa ya stoktan düşülmüş ama kaydı olmayan bir sipariş ya da stoğu düşülmemiş bir sipariş kalır. Bu yüzden hepsi tek transaction'da:

// Save siparişi ve kalemlerini ekler ve adetleri stoktan düşer;
// hepsi tek transaction'da: ya her şey kaydedilir ya hiçbir şey.
func (s *Store) Save(ctx context.Context, o *orders.Order) error {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin: %w", err)
    }
    defer tx.Rollback() // Commit'ten sonra sql.ErrTxDone döner ve bir şey yapmaz

    _, err = tx.ExecContext(ctx, `
        INSERT INTO dbo.Orders (Id, CustomerId, Status, CreatedAt, UpdatedAt, PaidAt)
        VALUES (@id, @customer, @status, @created, @updated, @paid)`,
        sql.Named("id", o.ID), sql.Named("customer", o.CustomerID), sql.Named("status", int(o.Status)),
        sql.Named("created", o.CreatedAt.UTC()), sql.Named("updated", o.UpdatedAt.UTC()), sql.Named("paid", utc(o.PaidAt)))
    if err != nil {
        return fmt.Errorf("insert order: %w", err)
    }

    // Stoğu ISBN sırasıyla güncelle; böylece aynı kitapları isteyen iki sipariş
    // satırları hep aynı sırayla kilitler ve birbirini deadlock'a sokamaz.
    byISBN := slices.SortedFunc(slices.Values(o.Items), func(a, b orders.Item) int {
        return cmp.Compare(a.ISBN, b.ISBN)
    })
    for _, it := range byISBN {
        res, err := tx.ExecContext(ctx, `
            UPDATE dbo.Books SET Stock = Stock - @qty
            WHERE Isbn = @isbn AND Stock >= @qty`,
            sql.Named("qty", it.Quantity), sql.Named("isbn", it.ISBN))
        if err != nil {
            return fmt.Errorf("take %s from stock: %w", it.ISBN, err)
        }
        if n, err := res.RowsAffected(); err != nil {
            return fmt.Errorf("take %s from stock: %w", it.ISBN, err)
        } else if n == 0 {
            return fmt.Errorf("isbn %s: %d requested: %w", it.ISBN, it.Quantity, orders.ErrOutOfStock)
        }
    }

    for i, it := range o.Items {
        _, err := tx.ExecContext(ctx, `
            INSERT INTO dbo.OrderItems (OrderId, LineNumber, Isbn, Title, UnitPrice, Quantity)
            VALUES (@order, @line, @isbn, @title, @price, @qty)`,
            sql.Named("order", o.ID), sql.Named("line", i+1), sql.Named("isbn", it.ISBN),
            sql.Named("title", it.Title), sql.Named("price", it.UnitPrice), sql.Named("qty", it.Quantity))
        if err != nil {
            return fmt.Errorf("insert item %d: %w", i+1, err)
        }
    }
    return tx.Commit()
}

Kalıp Go'da hep aynı: BeginTx, hemen ardından defer tx.Rollback(), en sonda tx.Commit(). Hangi return'den çıkılırsa çıkılsın transaction kapanıyor; commit edildiyse Rollback sadece sql.ErrTxDone döndürüyor ve bir şey yapmıyor. BeginTx'e verilen context iptal olursa, örneğin istemci bağlantıyı kapatırsa, database/sql transaction'ı kendisi geri alıyor. Handler'da r.Context()'i service'e, service'in de bu fonksiyona geçirmesinin sebebi bu: 08'deki context zinciri burada veritabanına kadar uzanıyor. Dapper serisinin "Transaction" yazısındaki using var tx = connection.BeginTransaction() kalıbının karşılığı defer.

Parametreler sql.Named ile @id, @qty gibi adlarla gidiyor; go-mssqldb sıralı @p1, @p2 biçimini de kabul ediyor. Stok düşen UPDATE'in koşulunda Stock >= @qty var ve etkilenen satır sayısı sıfırsa fonksiyon orders.ErrOutOfStock'u sarıp dönüyor. Bunu bir sonraki bölümde ayrıca konuşacağız; önce geri almanın çalıştığını görelim. Veritabanında Persuasion'ın stoğu 0, Dune'unki 10. İki kalemli bir sipariş:

curl -i -X POST -H 'Content-Type: application/json' -H 'X-Request-ID: rollback-demo' \
  --data '{"customerId":"alan","items":[{"isbn":"9780441172719","quantity":1},{"isbn":"9780141439686","quantity":1}]}' \
  http://127.0.0.1:52710/api/orders
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
X-Request-Id: rollback-demo
Content-Length: 224

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"detail":"save order ORD-1043: isbn 9780141439686: 1 requested: out of stock","instance":"/api/orders","traceId":"rollback-demo"}
Isbn Stock
---- -----
9780141439686 0
9780441172719 10

Dune'un stoğu 10'da kaldı, Orders'a da satır eklenmedi. Sonraki sipariş ORD-1044 numarasını aldı; 1043 boşta kaldı, çünkü SQL Server'da sequence'tan alınan numara rollback ile geri verilmiyor. Sipariş numarasında boşluk bir sorun değil, ama muhasebe "her numara bir fatura" bekliyorsa bunu önceden konuşmak gerekiyor.

Sequence'ın gelme sebebi şu: 08'den beri sipariş numarasını Service'teki bir sayaç veriyordu ve sayaç her process'le 1000'den başlıyordu. Siparişler veritabanında kalıcı olunca, server'ı yeniden başlattıktan sonraki ilk sipariş yine ORD-1001 olacak ve primary key ihlaliyle 500 dönecekti. Numara üretmek artık depolamanın işi, bu yüzden Repository interface'ine bir method ekledim:

// Repository, Service'in depolamadan beklediği şeydir. Tüketicisinin yanında
// tanımlanır ve başka yerde, adı hiç anılmadan implement edilir.
type Repository interface {
    // NextID yeni bir sipariş ID'si döndürür. Sayaç depolamanın elinde; depolama
    // bir veritabanıysa ID'ler restart'tan sonra da benzersiz kalır.
    NextID(ctx context.Context) (string, error)
    // Save yeni bir siparişi kaydeder. Stoğu takip eden bir depolama, bir kalem
    // karşılanamıyorsa ErrOutOfStock'u saran bir hata döner ve hiçbir şey kaydetmez.
    Save(ctx context.Context, o *Order) error
    ByID(ctx context.Context, id string) (*Order, error)
}
// NextID, OrderNumbers sequence'ından bir sonraki numarayı alır.
func (s *Store) NextID(ctx context.Context) (string, error) {
    var n int
    if err := s.db.QueryRowContext(ctx, `SELECT NEXT VALUE FOR dbo.OrderNumbers`).Scan(&n); err != nil {
        return "", fmt.Errorf("next order id: %w", err)
    }
    return fmt.Sprintf("ORD-%d", n), nil
}

memstore aynı method'u eski sayaçla karşılıyor; Service'teki mutex de gereksiz hâle geldiği için kalktı. Server'ı yeniden başlatınca bir sonraki sipariş Location: /api/orders/ORD-1002 ile döndü ve GET /api/orders/ORD-1001 restart'tan önceki siparişi getirdi. 10'un sonunda söylediğimiz "siparişler restart'ta kayboluyor" sorunu böylece kapandı.

Aynı Anda 20 Sipariş: Lost Update ve Koşullu UPDATE

Persuasion'dan 3 kopya kaldı ve 20 müşteri aynı anda birer tane istiyor. labs/11-oversell 20 goroutine'i aynı anda çalıştırıyor, önce de 20 connection'ı açıyor ki istekler gerçekten üst üste binsin. İlk sürüm, stoğu okuyup Go'da kontrol eden ve yeni değeri geri yazan yaygın bir yaklaşım:

// placeNaive stoğu okur, Go'da kontrol eder ve yeni değeri geri yazar.
// İki transaction aynı değeri okuyabilir ve sonra yazan kazanır.
func placeNaive(db *sql.DB) func(context.Context, string) error {
    return func(ctx context.Context, _ string) error {
        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            return err
        }
        defer tx.Rollback()
        var stock int
        if err := tx.QueryRowContext(ctx, `SELECT Stock FROM dbo.Books WHERE Isbn = @p1`, persuasion).Scan(&stock); err != nil {
            return err
        }
        if stock < 1 {
            return orders.ErrOutOfStock
        }
        time.Sleep(20 * time.Millisecond) // örneğin ödeme servisine bir çağrı
        if _, err := tx.ExecContext(ctx, `UPDATE dbo.Books SET Stock = @p1 WHERE Isbn = @p2`, stock-1, persuasion); err != nil {
            return err
        }
        return tx.Commit()
    }
}
$ go run ./labs/11-oversell naive
placed 20, out of stock 0, failed 0, stock left 2
$ go run ./labs/11-oversell
placed 3, out of stock 17, failed 0, stock left 0

Naive sürüm 3 kopya için 20 sipariş aldı ve stokta yine 2 görünüyor. Bu klasik lost update: SQL Server'ın varsayılan isolation level'ında (READ COMMITTED; sadece commit edilmiş veriyi okuyan seviye) SELECT okuduğu satırı kilitli tutmuyor, iki transaction aynı 3'ü okuyor ve ikisi de 2 yazıyor. Aradaki 20 ms'lik time.Sleep pencereyi genişletmek için; gerçek bir ödeme servisi çağrısı aynı pencereyi açar. sqlstore'un yolu ise kontrolü ve yazmayı tek bir UPDATE ... WHERE Stock >= @qty cümlesinde yapıyor. SQL Server satırı güncellerken kilitliyor, ikinci transaction bekliyor ve sırası geldiğinde koşul artık sağlanmıyorsa 0 satır etkiliyor. Sonuç: 3 sipariş, 17 tane 409.

11-lost-update-tr.png

İki not. Birincisi, Service.Place'teki stok kontrolü hâlâ memory'deki kataloğa bakıyor; bu yazıda o kontrol sadece hızlı bir ön eleme, asıl karar veritabanındaki koşullu UPDATE'te. Kataloğun okuma tarafı da veritabanına taşınınca bu ikilik kalkacak (aşağıdaki ★★★ alıştırması). İkincisi, Save stokları ISBN (kitaplara verilen uluslararası standart numara) sırasına göre düşüyor. Biri Dune+Emma, diğeri Emma+Dune isteyen iki sipariş satırları farklı sırayla kilitlerse birbirini bekleyip deadlock'a girebilir; SQL Server biri için 1205 hatası döndürür. Sabit sıra bu ihtimali kaldırıyor. Isolation level'ların ve kilitlerin ayrıntısı SQL Server Derinlemesine serisinin "Isolation Level'lar ve Row Versioning" ve "Deadlock'lar" yazılarında; teorisi ilerideki Concurrency Teorisi serisinde.

Okuma: QueryContext, rows.Close, rows.Err ve NULL

Siparişi okuyan method iki sorgu yapıyor: başlık için QueryRowContext, kalemler için QueryContext:

// ByID bir siparişi kalemleriyle yükler. Her çağrı yeni bir *Order kurar,
// böylece iki istek asla aynı değeri paylaşmaz.
func (s *Store) ByID(ctx context.Context, id string) (*orders.Order, error) {
    o := &orders.Order{}
    err := s.db.QueryRowContext(ctx, `
        SELECT Id, CustomerId, Status, CreatedAt, UpdatedAt, PaidAt
        FROM dbo.Orders WHERE Id = @id`, sql.Named("id", id)).
        Scan(&o.ID, &o.CustomerID, &o.Status, &o.CreatedAt, &o.UpdatedAt, &o.PaidAt)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("id %s: %w", id, orders.ErrNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("load order %s: %w", id, err)
    }

    rows, err := s.db.QueryContext(ctx, `
        SELECT Isbn, Title, UnitPrice, Quantity
        FROM dbo.OrderItems WHERE OrderId = @id ORDER BY LineNumber`, sql.Named("id", id))
    if err != nil {
        return nil, fmt.Errorf("load items of %s: %w", id, err)
    }
    defer rows.Close() // connection'ı pool'a geri verir
    for rows.Next() {
        var it orders.Item
        if err := rows.Scan(&it.ISBN, &it.Title, &it.UnitPrice, &it.Quantity); err != nil {
            return nil, fmt.Errorf("scan item of %s: %w", id, err)
        }
        o.Items = append(o.Items, it)
    }
    if err := rows.Err(); err != nil { // döngüyü erken bitiren bir hata
        return nil, fmt.Errorf("read items of %s: %w", id, err)
    }
    return o, nil
}

QueryRowContext satır yoksa hatayı Scan'de sql.ErrNoRows olarak veriyor; onu domain'in orders.ErrNotFound'una çeviriyoruz ve handler 05'ten beri yaptığı gibi 404 dönüyor. PaidAt kolonu NULL olabiliyor; Order.PaidAt'i *time.Time yaptım ve Scan(&o.PaidAt) NULL için nil, değer için yeni bir pointer yazıyor. Pointer istemediğin yerde sql.NullTime, sql.NullString ya da Go 1.22'den beri generic sql.Null[T] var; Valid field'ı değerin gelip gelmediğini söylüyor. NULL gelebilen bir kolonu düz string'e taramak ise çalışma zamanında hata veriyor.

rows ile çalışırken iki kural var. defer rows.Close() connection'ı pool'a geri veriyor ve rows.Err() döngüyü erken bitiren hatayı söylüyor: rows.Next() false döndüğünde bunun sonuç bittiği için mi yoksa bağlantı koptuğu için mi olduğunu ancak rows.Err()'e bakarak anlarsın. Close'u unutmanın bedelini bir lab'da ölçtüm. Pool iki connection'la sınırlı, sorgu sadece ilk satırı okuyup çıkıyor:

// firstTitle sadece ilk satırı okur. rows.Close olmadan sonucun geri kalanı
// connection'da kalır ve connection pool'a hiç geri dönmez.
func firstTitle(ctx context.Context, db *sql.DB, closeRows bool) error {
    rows, err := db.QueryContext(ctx, `SELECT Title FROM dbo.Books ORDER BY Title`)
    if err != nil {
        return err
    }
    if closeRows {
        defer rows.Close()
    }
    if rows.Next() {
        var title string
        return rows.Scan(&title)
    }
    return rows.Err()
}
$ go run ./labs/11-rowsleak
query 1: err=<nil> after 100ms | open=1 inUse=1 idle=0 waitCount=0
query 2: err=<nil> after 100ms | open=2 inUse=2 idle=0 waitCount=0
query 3: err=context deadline exceeded after 2s | open=2 inUse=2 idle=0 waitCount=1
$ go run ./labs/11-rowsleak fixed
query 1: err=<nil> after 100ms | open=1 inUse=0 idle=1 waitCount=0
query 2: err=<nil> after 0s | open=1 inUse=0 idle=1 waitCount=0
query 3: err=<nil> after 0s | open=1 inUse=0 idle=1 waitCount=0

Kapatılmayan iki rows iki connection'ı da tuttu ve üçüncü sorgu timeout'a kadar bekledi. Lab'ın ilk iki sorgusu context.Background() kullanıyor, yani bir background job gibi. Bunu bilerek yaptım: ilk denememde her sorguya 2 saniyelik bir context verip hemen cancel() çağırıyordum ve sızıntı hiç görünmedi. database/sql, context iptal olunca açık rows'u kendisi kapatıyor. HTTP handler'larında r.Context() istek bitince iptal edildiği için sızıntı isteğin süresiyle sınırlı kalıyor; kalıcı sızıntı iptal edilmeyen context'lerde oluyor. Yine de buna güvenme, her QueryContext'in hemen altına defer rows.Close() yaz.

Parameterized Query ve SQL Injection

Parametre kullanmak sadece okunabilirlik meselesi değil. labs/11-injection aynı aramayı iki şekilde yapıyor:

func main() {
    db, err := sql.Open("sqlserver", os.Getenv("BOOKSTORE_DB"))
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
    ctx := context.Background()

    input := "x' OR 1=1 --" // saldırganın arama kutusuna yazdığı

    unsafe := "SELECT Title FROM dbo.Books WHERE Title LIKE '%" + input + "%'"
    fmt.Println("concatenated:", titles(ctx, db, unsafe))

    safe := "SELECT Title FROM dbo.Books WHERE Title LIKE '%' + @q + '%'"
    fmt.Println("parameter:   ", titles(ctx, db, safe, sql.Named("q", input)))
}
concatenated: [Middlemarch Emma Sense and Sensibility Persuasion Dune The Hobbit]
parameter:    []

Birleştirilmiş sorguda kullanıcının yazdığı tırnak string'i kapatıyor, OR 1=1 her satırı seçiyor ve -- geri kalanı yoruma çeviriyor; bütün katalog dönüyor. Parametreli sorguda aynı metin sadece bir değer: başlığında x' OR 1=1 -- geçen kitap yok. go-mssqldb parametreleri sp_executesql ile ayrı gönderiyor, SQL metni hiç değişmiyor. Kural Dapper serisinin "Parameter'lar ve SQL Injection" yazısındakiyle aynı. Go'ya özgü tuzak fmt.Sprintf: "... WHERE Title = '%s'" yazmak kolay ve go vet bunu yakalamıyor. Tablo ya da kolon adı gibi parametre olamayan parçalar dinamikse, onları sabit bir izinli listeden seç.

PostgreSQL, pgx ve sqlc

PostgreSQL'de Go dünyasının fiilî standardı jackc/pgx. İki şekilde kullanılabiliyor: pgx/v5/stdlib ile database/sql driver'ı olarak, ya da kendi interface'iyle. pgx'in dokümanı, uygulama sadece PostgreSQL kullanıyorsa doğrudan pgx interface'ini öneriyor; COPY, LISTEN/NOTIFY ve PostgreSQL tiplerini daha iyi destekliyor. Aynı stok düşümü pgx ile şöyle (lab kendi PostgreSQL 18 container'ımda çalıştı):

for i := 1; i <= 4; i++ {
    var left int
    // @name yerine $1, $2; RETURNING yeni değeri aynı round trip'te getirir.
    err := pool.QueryRow(ctx, `
        UPDATE books SET stock = stock - $1
        WHERE isbn = $2 AND stock >= $1
        RETURNING stock`, 1, "9780141439686").Scan(&left)
    if errors.Is(err, pgx.ErrNoRows) {
        fmt.Printf("order %d: out of stock\n", i)
        continue
    }
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("order %d: placed, %d left\n", i, left)
}

rows, _ := pool.Query(ctx, `SELECT isbn, title, stock FROM books`)
books, err := pgx.CollectRows(rows, pgx.RowToStructByPos[book]) // rows'u bizim yerimize kapatır
if err != nil {
    log.Fatal(err)
}
fmt.Printf("%+v\n", books)
order 1: placed, 2 left
order 2: placed, 1 left
order 3: placed, 0 left
order 4: out of stock
[{ISBN:9780141439686 Title:Persuasion Stock:0}]

Farklar küçük ama önemli: parametreler @name değil $1, $2; RETURNING güncellenen değeri aynı round trip'te getiriyor; *pgxpool.Pool *sql.DB'nin yerini alıyor. pgx.CollectRows ve RowToStructByPos generic fonksiyonlar; satırları struct'lara çeviriyor ve rows'u kendisi kapatıyor. PostgreSQL'in kendisi sitedeki PostgreSQL serisinde.

Dapper alışkanlığı olan biri için en yakın Go deneyimi sqlc: SQL'i sen yazıyorsun, -- name: GetOrder :one gibi bir yorumla işaretliyorsun, sqlc generate şemayı ve sorguyu okuyup tip güvenli Go fonksiyonları ve struct'lar üretiyor. Kolon adı yanlışsa hata derleme öncesinde çıkıyor, Dapper'daki gibi çalışma zamanında değil. Ama bir kısıt var: son sürüm v1.31.1'in motorları PostgreSQL, MySQL ve SQLite; kaynak koddaki internal/engine klasöründe SQL Server yok. Geliştirme dalında bir mssql motoru var ve dokümanın güncel sayfası SQL Server'ı beta olarak listeliyor, ama henüz bir sürüme girmedi (Kasım 2026). Bookstore SQL Server kullandığı için bu yazıda sqlc'yi çalıştırmadım; SQL Server'da bugün seçenek elle yazılmış database/sql ya da kolonları struct field'larına adla map eden sqlx.

GORM (Go'da bu alanda en yaygın kütüphane) gibi ORM'ler (Object-Relational Mapping; tabloları nesnelere eşleyen araçlar) de var ve Go'da EF Core'a en çok benzeyen şey onlar. Go topluluğunun bunlara mesafeli olmasının sebebi dilin kendisiyle ilgili: LINQ (Language Integrated Query; C#'ın dile gömülü sorgu yapısı) gibi derleyicinin anladığı bir sorgu dili ve expression tree'ler yok, bu yüzden ORM sorguları string'lere ve reflection'a dayanıyor. Hatalar derleme yerine çalışma zamanında çıkıyor. EF Core serisinin "EF Core ve Mimari" yazısındaki Dapper'a geçme gerekçeleri Go'da varsayılan tercih hâline geliyor.

Üretimde Dikkat

EnsureSchema öğretmek için yeterli, ama production'da şemayı uygulama açılışında değiştirmek riskli: iki instance aynı anda açılırsa ikisi de aynı DDL'i (Data Definition Language; şemayı değiştiren CREATE/ALTER komutları) çalıştırır. Go'da golang-migrate ve goose gibi migration araçları yaygın; .NET tarafında EF Core migration'ları ya da DbUp kullanılıyorsa Go servisi şemaya dokunmamalı, şemanın tek sahibi olmalı.

Pool sayılarını tek instance için değil, toplam için düşün. SetMaxOpenConns(20) olan bir servisi Kubernetes'te 10 kopyaya çıkarırsan veritabanı 200 connection görür. SQL Server'ın sınırı geniş, ama Azure SQL gibi servislerde katmana göre bir sınır var. db.Stats()'taki WaitCount ve WaitDuration'ı metrik olarak dışarı vermek (13. yazıda ve Observability serisinde) bir isteğin veritabanını mı yoksa pool'u mu beklediğini ayırmanın en kolay yolu.

Timeout'u context belirliyor. Handler'ın context'i istek bitince iptal oluyor, ama bir sorgunun kendi sınırı yok; yavaş bir sorgu 09'daki WriteTimeout'a kadar connection'ı tutabilir. Kritik sorgularda context.WithTimeout ile ayrı bir sınır ver. Bu yazıdaki süre ölçümleri emülasyonla çalışan bir container'dan; mutlak hız için değil, karşılaştırma için.

Deadlock (1205) ve bağlantı kopması gibi geçici hatalarda transaction'ı baştan çalıştırmak gerekir. Save tek fonksiyon olduğu için retry'ı onun etrafına koymak kolay; go-mssqldb'nin mssql.Error tipindeki Number field'ını errors.As ile okuyabilirsin. Aynı sipariş iki kez kaydedilmesin diye retry'ı ancak hatanın commit'ten önce oluştuğunu bildiğin durumlarda yap.

Sık Düşülen Tuzaklar

Her istekte sql.Open. Pool her seferinde baştan kurulur, TLS (Transport Layer Security; bağlantıyı şifreleyen protokol) handshake'i ve login her istekte tekrarlanır, Close unutulursa connection'lar birikir. *sql.DB'yi main'de bir kez aç ve geçir.

rows.Close'u ya da rows.Err'i unutmak. İlki connection sızdırıyor, ikincisi yarım sonucu tam sanmana yol açıyor.

Okuyup Go'da kontrol edip yazmak. Lab'da gördüğümüz lost update; kontrolü ve yazmayı tek bir koşullu UPDATE'te birleştir ya da satırı kilitle.

Saat dilimini sürücüye bırakmak. time.Time'ı datetime2'ye yazmadan önce UTC()'ye çevir ya da kolonu datetimeoffset yap.

Transaction içinde tx yerine db kullanmak. s.db.ExecContext pool'dan başka bir connection alır ve transaction'ın dışında çalışır; rollback onu geri almaz.

Küçük Bir Deneme

★☆☆ BOOKSTORE_DB'yi kendi SQL Server'ına göstererek API'yi çalıştır, bir sipariş ver ve server'ı yeniden başlat. GET /api/orders/{id} siparişi hâlâ getiriyor mu? Aynı denemeyi BOOKSTORE_DB olmadan yap.

★★☆ sqlstore.Save'de stok UPDATE'ini tx.ExecContext yerine s.db.ExecContext ile yap ve rollback örneğini tekrar çalıştır. Dune'un stoğuna ne oldu? Sonra geri al.

★★★ orders.Catalog interface'ine context ekle (ByISBN(ctx, isbn)) ve kitapları Books tablosundan okuyan bir sqlstore.Catalog yaz. main'de veritabanı varsa onu kullan. Artık GET /api/books stoğu doğru gösteriyor mu?

Bu yazıdaki her şeyi elle çalıştırıp çıktısına baktık; bir sonraki değişiklikte aynısını tekrar yapmak istemeyiz. Bir sonraki yazıda Go'nun testing package'ı geliyor: stok kuralı için table-driven test, Repository'nin fake'i ve Service.now ile sabit saat, httptest ile handler testi, 06'da söz verdiğimiz generic kod benchmark'ları, fuzzing, 08'deki synctest'in devamı ve CI'da go test -race ./....