Penyetelan kinerja dengan go-mssqldb

Artikel ini memberikan panduan tentang mengoptimalkan performa aplikasi Go yang menggunakan go-mssqldb driver dengan SQL Server.

Mulailah dengan perubahan berdampak tertinggi

Sebagian besar aplikasi tidak memerlukan penyetelan khusus driver pada hari pertama. Mulailah dengan langkah-langkah ini sebelum Anda mengubah ukuran paket, menambahkan pernyataan yang disiapkan di mana saja, atau menyetel opsi massal:

  1. Tetapkan ukuran kumpulan koneksi terbatas yang cocok dengan batas server Anda.
  2. Tambahkan timeout konteks agar panggilan SQL yang terblokir tidak menahan koneksi dan membuat aplikasi tampak beku saat beban tinggi.
  3. Perbaiki kueri lambat, indeks yang hilang, dan perjalanan pulang pergi yang tidak perlu di SQL Server.
  4. Tolok ukur beban kerja sebelum dan sesudah setiap perubahan.

Untuk banyak layanan, pengaturan kumpulan koneksi dan bentuk kueri lebih penting daripada ukuran paket atau persiapan pernyataan.

Penyetelan kumpulan koneksi

Kumpulan database/sql koneksi adalah tuas kinerja yang paling berdampak. Pool yang kekurangan kapasitas menyebabkan goroutine terblokir saat menunggu koneksi, sementara pool yang kelebihan kapasitas memboroskan sumber daya server.

db.SetMaxOpenConns(25)     // Match your workload concurrency
db.SetMaxIdleConns(10)     // Keep warm connections ready
db.SetConnMaxLifetime(5 * time.Minute)  // Recycle connections periodically
db.SetConnMaxIdleTime(1 * time.Minute)  // Close stale idle connections

Pantau db.Stats().WaitCount dan db.Stats().WaitDuration untuk mendeteksi perselisihan kumpulan. Untuk detail selengkapnya, lihat Pengumpulan koneksi.

Meningkatkan ukuran paket

Ukuran paket TDS default adalah 4.096 byte. Untuk beban kerja yang mentransfer kumpulan hasil besar atau data massal, meningkatkan ukuran paket mengurangi jumlah perjalanan pulang pergi jaringan.

sqlserver://<user>:<password>@<server>?database=AdventureWorks2025&packet+size=16384

Kisaran yang berlaku: 512 hingga 32.767. Nilai 8.192 atau 16.384 umum untuk skenario throughput tinggi.

Pertahankan ukuran paket default kecuali data tolok ukur menunjukkan bahwa transfer jaringan mendominasi beban kerja. Untuk lalu lintas gaya OLTP dengan kueri dan baris kecil, paket yang lebih besar sering menambah kerumitan tanpa keuntungan yang berarti.

Gunakan pernyataan yang sudah disiapkan

Pernyataan yang disiapkan menghindari penguraian kueri berulang dan kompilasi rencana di server. Gunakan db.PrepareContext saat Anda menjalankan kueri yang sama berkali-kali dengan parameter yang berbeda:

stmt, err := db.PrepareContext(ctx,
    "SELECT TOP (1) FirstName + ' ' + LastName AS Name FROM Sales.vSalesPerson WHERE CountryRegionName = @p1")
if err != nil {
    log.Fatal(err)
}
defer stmt.Close()

for _, loc := range locations {
    var name string
    stmt.QueryRowContext(ctx, loc).Scan(&name)
}

Tip

Tutup prepared statement dengan menggunakan defer stmt.Close() agar tidak menggunakan handle prepared statement di sisi server.

Jangan menyiapkan setiap pernyataan secara default. Mereka paling membantu ketika pernyataan yang sama berjalan berulang kali di jalan panas. Untuk pertanyaan satu kali, QueryContext atau ExecContext biasanya lebih mudah dan cukup cepat.

Menggunakan salinan massal untuk sisipan besar

Pernyataan individual INSERT lambat untuk pemuatan data besar. Penyalinan massal menyalurkan data langsung ke server, tanpa melalui pemroses kueri:

stmt, err := txn.Prepare(mssql.CopyIn("MyTable", mssql.BulkOptions{
    Tablock:      true,
    RowsPerBatch: 5000,
}, "Col1", "Col2"))

Untuk detailnya, lihat Operasi massal.

Gunakan varchar bila sesuai

Secara default, kode mengirimkan string parameter sebagai nvarchar (Unicode). Jika kolom Anda menggunakan varchar, server mungkin melakukan konversi implisit dan melewati indeks. Gunakan mssql.VarChar untuk mengirim varchar parameter.

db.QueryContext(ctx, "SELECT * FROM Production.Product WHERE ProductNumber = @p1",
    mssql.VarChar("FR-R92B-58"))

Gunakan batas waktu konteks

Tetapkan batas waktu konteks pada setiap kueri untuk mencegah panggilan SQL yang terblokir menahan koneksi tetap terikat dan menghambat proses pemanggil.

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

rows, err := db.QueryContext(ctx, "SELECT * FROM LargeTable")

Tutup sumber daya segera

Buka *sql.Rows, *sql.Tx, dan *sql.Conn objek menyematkan koneksi dari kolam. Selalu tutup sesegera mungkin.

rows, err := db.QueryContext(ctx, query)
if err != nil {
    return err
}
defer rows.Close()

for rows.Next() {
    // process
}
return rows.Err()

Kurangi perjalanan pulang pergi

  • Gabungkan kueri ke dalam satu panggilan jika memungkinkan: "SELECT ...; SELECT ...;".
  • Gunakan OUTPUT klausa alih-alih panggilan terpisah SELECT SCOPE_IDENTITY() .
  • Gunakan parameter bernilai tabel untuk mengirim beberapa baris dalam satu panggilan alih-alih mengulang sisipan individual.

Daftar Periksa Kinerja

Area Recommendation
Kolam Tetapkan MaxOpenConns berdasarkan batas server dan konkurensi beban kerja.
Kolam Atur MaxIdleConns setidaknya setengah dari MaxOpenConns.
Network Ubah packet size hanya setelah membandingkan transfer besar atau beban massal.
Queries Gunakan pernyataan yang disiapkan hanya untuk pernyataan yang sering diulang.
Queries Gunakan mssql.VarChar untuk varchar kolom untuk menghindari konversi implisit.
Beban besar Gunakan salinan massal (mssql.CopyIn) untuk sisipan batch.
Resources Tutup Rows, Tx, dan Conn objek segera.
Batas Waktu Tetapkan tenggat waktu konteks pada semua kueri dan pernyataan.
Baca berat Gunakan ApplicationIntent=ReadOnly untuk replika baca.
Tolok ukur Gunakan testing.B untuk mengukur sebelum dan sesudah pengoptimalan.
Monitoring Aktifkan Query Store dan gunakan laporan SSMS atau Query Performance Insight.
Monitoring Ekspor db.Stats() ke Prometheus atau OpenTelemetry.

Uji tolok ukur operasi basis data

Gunakan Go testing.B untuk mengukur performa operasi database dan memvalidasi perubahan pengoptimalan:

func BenchmarkInsertSingle(b *testing.B) {
    db := setupDB(b)
    ctx := context.Background()

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _, err := db.ExecContext(ctx,
            "INSERT INTO BenchTable (Name) VALUES (@p1)",
            sql.Named("p1", fmt.Sprintf("bench-%d", i)))
        if err != nil {
            b.Fatal(err)
        }
    }
}

func BenchmarkInsertBulk(b *testing.B) {
    db := setupDB(b)
    ctx := context.Background()

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        txn, err := db.BeginTx(ctx, nil)
        if err != nil {
            b.Fatal(err)
        }
        stmt, err := txn.Prepare(mssql.CopyIn("BenchTable",
            mssql.BulkOptions{}, "Name"))
        if err != nil {
            b.Fatal(err)
        }
        for j := 0; j < 1000; j++ {
            if _, err := stmt.Exec(fmt.Sprintf("bench-%d-%d", i, j)); err != nil {
                b.Fatal(err)
            }
        }
        if _, err := stmt.Exec(); err != nil {
            b.Fatal(err)
        }
        stmt.Close()
        if err := txn.Commit(); err != nil {
            b.Fatal(err)
        }
    }
}

Jalankan tolok ukur dengan:

go test -bench=BenchmarkInsert -benchmem -count=5

Tip

Gunakan -count=5 atau lebih tinggi untuk mendapatkan hasil yang bermakna secara statistik. Gunakan benchstat untuk membandingkan hasil benchmark sebelum dan sesudah perubahan.

Gunakan perutean baca-saja

Jika lingkungan SQL Server Anda memiliki grup ketersediaan dengan replika sekunder yang dapat dibaca, arahkan kueri hanya baca ke replika sekunder dengan menetapkan ApplicationIntent=ReadOnly di string koneksi:

sqlserver://<user>:<password>@mylistener?database=AdventureWorks2025&ApplicationIntent=ReadOnly

Buat instans terpisah *sql.DB untuk beban kerja baca dan tulis:

writeDB, err := sql.Open("sqlserver",
    "sqlserver://<user>:<password>@mylistener?database=AdventureWorks2025")
if err != nil {
    log.Fatal(err)
}

readDB, err := sql.Open("sqlserver",
    "sqlserver://<user>:<password>@mylistener?database=AdventureWorks2025&ApplicationIntent=ReadOnly")
if err != nil {
    log.Fatal(err)
}

// Use readDB for reports, dashboards, and analytics.
// Use writeDB for inserts, updates, and deletes.

Analisis rencana kueri

Gunakan SET SHOWPLAN_XML untuk mengambil rencana eksekusi kueri tanpa menjalankannya. Metode ini membantu Anda mengidentifikasi pemindaian tabel, indeks yang hilang, dan operasi yang mahal:

func getQueryPlan(ctx context.Context, db *sql.DB, query string) (string, error) {
    // Use a dedicated connection so SHOWPLAN mode doesn't affect other queries.
    conn, err := db.Conn(ctx)
    if err != nil {
        return "", err
    }
    defer conn.Close()

    // Enable SHOWPLAN_XML mode.
    _, err = conn.ExecContext(ctx, "SET SHOWPLAN_XML ON")
    if err != nil {
        return "", err
    }

    var planXML string
    err = conn.QueryRowContext(ctx, query).Scan(&planXML)
    if err != nil {
        return "", err
    }

    // Disable SHOWPLAN_XML mode.
    _, _ = conn.ExecContext(ctx, "SET SHOWPLAN_XML OFF")

    return planXML, nil
}

Warning

SET SHOWPLAN_XML ON memengaruhi seluruh koneksi. Selalu gunakan db.Conn(ctx) untuk mengisolasi mode SHOWPLAN ke koneksi khusus.

Gunakan Query Store untuk menemukan kueri yang lambat

Tolok ukur Go dan waktu sisi klien memberi tahu Anda berapa lama waktu yang dibutuhkan kueri dari perspektif aplikasi Anda, tetapi angka tersebut menggabungkan latensi jaringan, waktu eksekusi server, dan pemrosesan klien. Query Store menangkap rencana eksekusi dan statistik runtime di server, sehingga Anda dapat melihat dengan tepat bagaimana SQL Server mengeksekusi setiap kueri, seberapa sering berjalan, dan bagaimana performanya berubah dari waktu ke waktu.

Query Store sangat berguna untuk mengidentifikasi pengendusan parameter, regresi rencana, dan kueri yang menghabiskan sumber daya server paling banyak. Aktifkan di database Anda jika belum diaktifkan:

ALTER DATABASE [AdventureWorks2025] SET QUERY_STORE = ON;

Setelah diaktifkan, Anda dapat meninjau data performa dengan beberapa cara:

  • SQL Server Management Studio (SSMS): Perluas database Anda di Object Explorer, buka folder Query Store, dan gunakan laporan bawaan seperti Kueri Konsumsi Sumber Daya Teratas, Kueri Regresi, dan Konsumsi Sumber Daya Keseluruhan.
  • Portal Azure: Untuk Azure SQL Database, buka bilah Wawasan Performa Kueri untuk melihat kueri yang menghabiskan sumber daya teratas tanpa menginstal alat apa pun.
  • Transact-SQL (T-SQL): Kueri tampilan katalog sys.query_store_runtime_stats dan sys.query_store_plan secara langsung dari aplikasi Go Anda jika Anda memerlukan akses secara terprogram.

Tip

Query Store mempertahankan data di seluruh server yang dimulai ulang, sehingga Anda dapat menganalisis tren performa selama berhari-hari atau berminggu-minggu. Gunakan laporan Kueri Regresi untuk menemukan kueri yang menjadi lebih lambat dengan cepat setelah perubahan skema atau kode.

Menggunakan Dasbor Performa

Dasbor Performa adalah laporan SSMS bawaan yang memberikan gambaran umum real-time tentang kesehatan SQL Server. Klik kanan instans server di SSMS Object Explorer dan pilih Laporan>Laporan Standar>Dasbor Performa.

Dasbor menunjukkan:

  • Waktu tunggu dan hambatan saat ini.
  • Pertanyaan mahal baru-baru ini.
  • Tren penggunaan CPU, I/O, dan memori.
  • Permintaan pengguna aktif dan sesi yang diblokir.

Dasbor Performa berguna selama pengembangan dan pengujian beban untuk menemukan masalah dengan cepat tanpa menulis kueri diagnostik apa pun.

Memantau metrik sisi server dari Go

Jika Anda perlu mengekspos data performa SQL Server ke sistem pemantauan seperti Prometheus atau OpenTelemetry dari aplikasi Go Anda, kueri tampilan manajemen dinamis (DMV) secara langsung:

Kueri paling mahal

Ambil 10 kueri teratas yang diberi peringkat berdasarkan waktu berlalu rata-rata:

rows, err := db.QueryContext(ctx, `
    SELECT TOP 10
        qs.total_elapsed_time / qs.execution_count AS avg_elapsed_us,
        qs.execution_count,
        qs.total_logical_reads / qs.execution_count AS avg_reads,
        SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
            ((CASE qs.statement_end_offset
                WHEN -1 THEN DATALENGTH(st.text)
                ELSE qs.statement_end_offset
            END - qs.statement_start_offset)/2)+1) AS query_text
    FROM sys.dm_exec_query_stats AS qs
    CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
    ORDER BY avg_elapsed_us DESC`)

Permintaan aktif saat ini

Cantumkan semua permintaan yang sedang dijalankan di server, tidak termasuk sesi Anda sendiri:

rows, err := db.QueryContext(ctx, `
    SELECT
        r.session_id,
        r.status,
        r.wait_type,
        r.cpu_time,
        r.logical_reads,
        t.text AS query_text
    FROM sys.dm_exec_requests AS r
    CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
    WHERE r.session_id != @@SPID`)

Pola hemat memori untuk hasil yang besar

Streaming baris tanpa akumulasi

Proses baris satu per satu alih-alih memuat seluruh kumpulan hasil ke dalam slice:

rows, err := db.QueryContext(ctx, "SELECT Id, Data FROM BigTable")
if err != nil {
    return err
}
defer rows.Close()

for rows.Next() {
    var id int
    var data string
    if err := rows.Scan(&id, &data); err != nil {
        return err
    }
    // Process immediately, don't append to a slice.
    process(id, data)
}
return rows.Err()

Pemrosesan batch dengan paginasi keyset

Pecahkan pemindaian tabel besar menjadi potongan-potongan yang dapat dikelola untuk membatasi penggunaan memori dan menghindari menahan koneksi untuk waktu yang lama:

func processBatched(ctx context.Context, db *sql.DB, batchSize int) error {
    var lastID int
    for {
        rows, err := db.QueryContext(ctx, `
            SELECT TOP(@batch) Id, Data FROM BigTable
            WHERE Id > @lastID ORDER BY Id`,
            sql.Named("batch", batchSize),
            sql.Named("lastID", lastID))
        if err != nil {
            return err
        }

        var count int
        for rows.Next() {
            var id int
            var data string
            if err := rows.Scan(&id, &data); err != nil {
                return err
            }
            process(id, data)
            lastID = id
            count++
        }
        if err := rows.Err(); err != nil {
            return err
        }
        rows.Close()

        if count < batchSize {
            break // No more rows.
        }
    }
    return nil
}