Transaksi dengan go-mssqldb

Transaksi mengelompokkan beberapa operasi ke dalam satu unit atom. Semua operasi berhasil (commit) atau tidak satu pun diterapkan (rollback). Artikel ini membahas cara menggunakan transaksi dengan go-mssqldb driver, termasuk tingkat isolasi, penanganan kesalahan, dan pola untuk aplikasi produksi.

Contoh dalam artikel ini menggunakan basis data sampel AdventureWorks2025. Contoh yang berorientasi pada penulisan menargetkan HumanResources.Department, Production.ProductCategory, Production.ProductSubcategory, dan Production.ProductInventory.

Memulai transaksi

Gunakan db.BeginTx untuk memulai transaksi. *sql.Tx yang dikembalikan mengunci satu koneksi dari pool selama transaksi berlangsung:

tx, err := db.BeginTx(ctx, nil) // nil uses the default isolation level
if err != nil {
    return err
}
defer tx.Rollback() // No-op if tx.Commit() succeeds first.

_, err = tx.ExecContext(ctx, "INSERT INTO HumanResources.Department (Name, GroupName) VALUES (@p1, @p2)",
    sql.Named("p1", name),
    sql.Named("p2", groupName))
if err != nil {
    return err
}

return tx.Commit()

Important

Selalu hubungi defer tx.Rollback() segera setelah BeginTx. Jika Commit() berhasil, yang ditangguhkan Rollback() adalah no-op. Jika ada kesalahan yang terjadi sebelumnya Commit(), yang ditangguhkan Rollback() memastikan transaksi tidak tetap terbuka dan membocorkan koneksi kembali ke kumpulan dalam status tidak dilakukan.

Tingkat isolasi

SQL Server mendukung beberapa tingkat isolasi yang mengontrol bagaimana transaksi bersamaan berinteraksi. Atur tingkat isolasi di:sql.TxOptions

tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelReadCommitted,
})

Perbandingan tingkat isolasi

Tingkat isolasi Bacaan kotor Pembacaan yang tidak dapat diulang Pembacaan fantom Dampak performa Gunakan ketika diperlukan
sql.LevelReadUncommitted Yes Yes Yes overhead paling rendah Perkiraan hitungan, dasbor pemantauan. Akurasi data tidak penting.
sql.LevelReadCommitted No Yes Yes Default. Bagus untuk sebagian besar beban kerja. Beban kerja OLTP umum. Titik awal default dan yang direkomendasikan.
sql.LevelRepeatableRead No No Yes Moderat. Menahan kunci lebih lama. Operasi baca harus menghasilkan nilai yang konsisten pada baris yang sama dalam transaksi.
sql.LevelSerializable No No No Tertinggi. Kunci rentang memblokir sisipan bersamaan. Transaksi keuangan, manajemen inventaris, di mana pun phantom reads tidak dapat diterima.
sql.LevelSnapshot No No No Menggunakan pembuatan versi baris di tempdb. Tidak ada pemblokiran. Beban kerja yang didominasi operasi baca dan memerlukan konsistensi pada satu titik waktu tertentu tanpa menghambat operasi tulis.

Note

sql.LevelSnapshot Memerlukan isolasi rekam jepret untuk diaktifkan pada database: ALTER DATABASE AdventureWorks2025 SET ALLOW_SNAPSHOT_ISOLATION ON.

Contoh: Baca yang dikomitkan vs. dapat diserialkan

Tentukan tingkat isolasi di sql.TxOptions:

// Read Committed (default) - suitable for most operations.
tx1, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelReadCommitted,
})

// Serializable - prevents phantom reads in financial calculations.
tx2, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelSerializable,
})

Perutean baca-saja

Driver tidak mendukung sql.TxOptions.ReadOnly. Jika Anda lulus ReadOnly: true, BeginTx mengembalikan kesalahan.

Untuk perutean baca-saja AlwaysOn, atur applicationintent=ReadOnly dalam string koneksi saat Anda membuka koneksi:

sqlserver://listener.example.com?database=AdventureWorks2025&applicationintent=ReadOnly

Ini adalah pengaturan tingkat koneksi. Ini dapat mengarahkan sesi yang memenuhi syarat ke node sekunder yang dapat dibaca, tetapi tidak menjadikan transaksi yang sudah ada bersifat hanya-baca. Gunakan koneksi baca-saja khusus atau kredensial hak istimewa paling rendah untuk beban kerja yang tidak boleh menulis data.

Penanganan kesalahan dalam transaksi

Tangani kesalahan di dalam transaksi dengan hati-hati. Ketika ada pernyataan yang gagal, transaksi harus dibatalkan sepenuhnya. Jangan mencoba melanjutkan dengan pernyataan lain setelah kesalahan:

func createCategory(ctx context.Context, db *sql.DB, category Category) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin transaction: %w", err)
    }
    defer tx.Rollback()

    var categoryId int64
    err = tx.QueryRowContext(ctx, `
        INSERT INTO Production.ProductCategory (Name)
        OUTPUT INSERTED.ProductCategoryID
        VALUES (@name)`,
        sql.Named("name", category.Name)).Scan(&categoryId)
    if err != nil {
        return fmt.Errorf("insert category: %w", err)
    }

    // Insert subcategories under the new category.
    for _, sub := range category.Subcategories {
        _, err = tx.ExecContext(ctx, `
            INSERT INTO Production.ProductSubcategory (ProductCategoryID, Name)
            VALUES (@catId, @name)`,
            sql.Named("catId", categoryId),
            sql.Named("name", sub.Name))
        if err != nil {
            return fmt.Errorf("insert subcategory %s: %w", sub.Name, err)
        }
    }

    if err = tx.Commit(); err != nil {
        return fmt.Errorf("commit category: %w", err)
    }
    return nil
}

Jika batch atau prosedur tersimpan berjalan dengan SET XACT_ABORT ON, perlakukan kesalahan pernyataan apa pun sebagai terminal untuk transaksi. Segera mundur dan jangan mencoba lebih banyak pernyataan atau Commit(). Rilis driver terbaru mendeteksi transaksi yang dibatalkan oleh server dan mengembalikan error alih-alih membiarkan commit parsial terjadi tanpa pemberitahuan.

Titik simpan

Titik simpan membuat titik pengembalian perantara di dalam transaksi. SQL Server mendukung savepoint secara asli. Karena paket database/sql milik Go tidak menyediakan savepoint secara langsung, eksekusi savepoint tersebut sebagai SQL mentah melalui transaksi:

func createCategoryWithOptionalSubcategory(ctx context.Context, db *sql.DB, category Category) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    err = tx.QueryRowContext(ctx,
        "INSERT INTO Production.ProductCategory (Name) OUTPUT INSERTED.ProductCategoryID VALUES (@p1)",
        sql.Named("p1", category.Name)).Scan(&category.Id)
    if err != nil {
        return err
    }

    // Try to add a subcategory. If it fails, roll back only the subcategory part.
    _, err = tx.ExecContext(ctx, "SAVE TRANSACTION AddSubcategory")
    if err != nil {
        return err
    }

    _, err = tx.ExecContext(ctx,
        "INSERT INTO Production.ProductSubcategory (ProductCategoryID, Name) VALUES (@p1, @p2)",
        sql.Named("p1", category.Id),
        sql.Named("p2", category.DefaultSubcategory))
    if err != nil {
        // Roll back only the subcategory; the category insert is preserved.
        _, rbErr := tx.ExecContext(ctx, "ROLLBACK TRANSACTION AddSubcategory")
        if rbErr != nil {
            return fmt.Errorf("rollback savepoint: %w (original: %w)", rbErr, err)
        }
        log.Printf("Subcategory %q failed, proceeding without it: %v",
            category.DefaultSubcategory, err)
    }

    return tx.Commit()
}

Note

SAVE TRANSACTION <name> membuat titik penyimpanan. ROLLBACK TRANSACTION <name> melakukan rollback ke savepoint tersebut tanpa mengakhiri transaksi terluar. ROLLBACK yang tidak memiliki nama membatalkan seluruh transaksi.

Penanganan kebuntuan

SQL Server menyelesaikan kebuntuan dengan mengakhiri salah satu transaksi yang bersaing (korban kebuntuan) dan mengembalikan kesalahan 1205. Transaksi yang dihentikan dikembalikan secara otomatis oleh server.

Mendeteksi dan mencoba kembali kebuntuan

Periksa kesalahan 1205 dan coba kembali seluruh transaksi dengan penundaan singkat:

import (
    "errors"
    "fmt"
    "time"

    "github.com/microsoft/go-mssqldb"
)

func isDeadlock(err error) bool {
    var mssqlErr mssql.Error
    return errors.As(err, &mssqlErr) && mssqlErr.Number == 1205
}

func withDeadlockRetry(ctx context.Context, db *sql.DB, maxRetries int,
    fn func(ctx context.Context, tx *sql.Tx) error) error {

    for attempt := 0; attempt < maxRetries; attempt++ {
        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            return err
        }

        err = fn(ctx, tx)
        if err != nil {
            tx.Rollback()
            if isDeadlock(err) && attempt < maxRetries-1 {
                // Wait briefly before retrying.
                delay := time.Duration(attempt+1) * 50 * time.Millisecond
                select {
                case <-ctx.Done():
                    return ctx.Err()
                case <-time.After(delay):
                }
                continue
            }
            return err
        }

        if err = tx.Commit(); err != nil {
            if isDeadlock(err) && attempt < maxRetries-1 {
                delay := time.Duration(attempt+1) * 50 * time.Millisecond
                select {
                case <-ctx.Done():
                    return ctx.Err()
                case <-time.After(delay):
                }
                continue
            }
            return err
        }
        return nil
    }
    return fmt.Errorf("transaction failed after %d deadlock retries", maxRetries)
}

Menggunakan pembungkus percobaan ulang kebuntuan

Teruskan fungsi transaksi ke wrapper percobaan ulang:

err := withDeadlockRetry(ctx, db, 3, func(ctx context.Context, tx *sql.Tx) error {
    _, err := tx.ExecContext(ctx,
        "UPDATE Production.ProductInventory SET Quantity = Quantity - @qty WHERE ProductID = @pid AND LocationID = @lid",
        sql.Named("qty", orderQty),
        sql.Named("pid", productId),
        sql.Named("lid", locationId))
    return err
})

Mengurangi kebuntuan

Strategy Bagaimana hal itu membantu
Mengakses tabel dalam urutan yang konsisten Ketika semua transaksi mengunci Tabel A sebelum Tabel B, kondisi saling tunggu melingkar tidak dapat terjadi.
Jaga agar transaksi tetap singkat Transaksi yang lebih singkat mempertahankan penguncian dalam waktu yang lebih singkat, sehingga mengurangi kemungkinan terjadinya konflik.
Gunakan tingkat isolasi terendah yang cukup ReadCommitted memegang lebih sedikit kunci daripada Serializable.
Menambahkan indeks yang sesuai Pembaruan yang menargetkan indeks mengunci lebih sedikit baris daripada pemindaian tabel.
Hindari interaksi pengguna selama transaksi Jangan pernah menunggu input pengguna antara BeginTx dan Commit.

Mencoba ulang adalah respons yang benar dalam kode aplikasi, tetapi kebuntuan berulang pada kueri yang sama menunjukkan masalah desain. Gunakan grafik deadlock SQL Server (yang ditangkap melalui Extended Events atau sesi system health) untuk mengidentifikasi pernyataan yang saling bersaing dan jenis kuncinya, lalu terapkan strategi dalam tabel sebelumnya. Untuk panduan lengkap tentang analisis dan pencegahan kebuntuan, lihat panduan tentang kebuntuan.

Transaksi dan penyematan koneksi

Transaksi menyematkan satu koneksi dari kumpulan hingga Commit() atau Rollback() dipanggil. Selama periode ini, tidak ada goroutine lain yang dapat menggunakan koneksi tersebut.

Implikasi:

  • Transaksi yang berjalan lama mengurangi ukuran kumpulan efektif. Jika Anda memiliki MaxOpenConns=25 dan 20 transaksi terbuka, hanya 5 koneksi yang tersedia untuk pekerjaan lain.
  • Rollback() yang terlupakan menyebabkan kebocoran koneksi secara permanen.
  • Pembatalan konteks pada konteks transaksi mengembalikan transaksi dan mengembalikan koneksi ke kumpulan.
// Set a deadline to prevent transactions from running indefinitely.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback()

Transaksi bersamaan

Setiap goroutine harus membuat transaksinya sendiri. Jangan pernah membagikan *sql.Tx antar-goroutine karena *sql.Tx tidak aman digunakan secara bersamaan:

// CORRECT: Each goroutine gets its own transaction.
var g errgroup.Group
for _, item := range items {
    item := item
    g.Go(func() error {
        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            return err
        }
        defer tx.Rollback()

        _, err = tx.ExecContext(ctx,
            "UPDATE Production.ProductInventory SET Quantity = Quantity - 1 WHERE ProductID = @p1 AND LocationID = @p2",
            sql.Named("p1", item.ProductId),
            sql.Named("p2", item.LocationId))
        if err != nil {
            return err
        }
        return tx.Commit()
    })
}
return g.Wait()

Transaksi terdistribusi

go-mssqldb driver tidak mendukung transaksi terdistribusi (transaksi XA atau yang setara dengan System.Transactions). Jika Anda perlu mengoordinasikan pekerjaan di beberapa database:

  • Gunakan pola saga dengan tindakan kompensasi.
  • Konsolidasikan operasi ke dalam satu database jika memungkinkan.
  • Gunakan server tertaut dengan BEGIN DISTRIBUTED TRANSACTION dari Transact-SQL (T-SQL) jika kedua database adalah SQL Server.

Daftar periksa transaksi

Area Recommendation
Keamanan pengembalian Selalu defer tx.Rollback() segera setelah BeginTx.
Tingkat isolasi Mulailah dengan ReadCommitted (default). Lakukan eskalasi hanya jika diperlukan.
Kebuntuan Tempatkan kode transaksi dalam loop percobaan ulang. Akses tabel dalam urutan yang konsisten.
Durasi Jaga agar transaksi sesingkat mungkin. Tetapkan batas waktu konteks.
Concurrency Jangan pernah membagikan *sql.Tx di antara goroutine.
Titik simpan Gunakan SAVE TRANSACTION dan ROLLBACK TRANSACTION <name> untuk pengembalian parsial.
Dampak kolam renang Pantau db.Stats().InUse untuk mendeteksi kebocoran koneksi dari transaksi yang tidak dilakukan.