使用 go-mssqldb 進行效能調校

本文提供如何優化使用 go-mssqldb SQL Server 驅動程式的 Go 應用程式效能的指引。

從影響最大的變更開始

大多數應用程式在第一天就不需要針對驅動程式做專屬調校。 在更改資料包大小、在各處加入預備語句或調整批量選項前,先從以下步驟開始:

  1. 設定一個與伺服器限制相符的連線池大小。
  2. 為 context 設定逾時,避免遭阻塞的 SQL 呼叫占住連線,導致應用程式在高負載時看似卡住。
  3. 修正 SQL Server 中緩慢查詢、缺少索引及不必要的往返問題。
  4. 在每次變更前後,先做工作負載的基準測試。

對許多服務而言,連線池設定與查詢形狀比封包大小或語句準備更重要。

連線池調校

database/sql連線池是最具影響力的效能槓桿。 連線池配置不足會使 goroutine 因等待連線而阻塞,而連線池配置過多則會浪費伺服器資源。

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

監控 db.Stats().WaitCount 和 db.Stats().WaitDuration,以偵測集區爭用。 如需更多詳細資訊,請參閱 連線共用池。

增加封包大小

預設的 TDS 封包大小為 4,096 位元組。 對於傳輸大量結果集或大量資料的工作負載,增加封包大小可減少網路往返次數。

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

有效範圍:512至32,767。 8,192 或 16,384 的數值在高吞吐量情境中很常見。

除非基準數據顯示網路傳輸佔據了大部分工作負載,否則就保留預設封包大小。 對於查詢與列較小的 OLTP 流量,較大封包通常會增加複雜度,卻無法帶來實質的提升。

使用預先準備好的陳述

預先準備的語句避免了在伺服器上重複的查詢解析與編譯計畫。 當你多次執行相同查詢但參數不同時,請使用 db.PrepareContext :

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

小提示

請使用 defer stmt.Close() 關閉已準備的陳述式,以避免耗用伺服器端預備陳述式控制代碼。

不要預設準備每一份陳述。 當相同的陳述式在熱點路徑中反覆執行時,它們最能發揮作用。 對於單次查詢,QueryContext 或 ExecContext 通常更直接,而且速度也夠快。

對於大量插入作業,請使用批次複製

單個 INSERT 語句對於大量資料負載來說速度較慢。 批量複製會直接將資料串流到伺服器,繞過查詢處理器:

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

詳情請參見 「大宗作業」。

適當時使用varchar

預設情況下,程式碼會將 string 參數以 nvarchar(Unicode)傳送。 如果你的欄位使用 varchar,伺服器可能會進行隱含轉換並跳過索引。 使用 mssql.VarChar 傳送 varchar 參數。

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

使用上下文逾時

為每個查詢設定上下文截止日期,以防止被阻擋的 SQL 呼叫導致連線被釘釘或呼叫者停滯。

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

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

請及時關閉資源

打開 *sql.Rows、 *sql.Tx,物件 *sql.Conn 從池中釘住連接。 務必盡快關閉。

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

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

減少往返行程

  • 盡可能將批次查詢集中於單一呼叫中: "SELECT ...; SELECT ...;"。
  • 使用OUTPUT子句,而不要分開呼叫SELECT SCOPE_IDENTITY()。
  • 使用表格值參數 在單一呼叫中傳送多列,而非重複重複插入。

效能檢查清單

Area Recommendation
游泳池 根據伺服器限制和工作負載的並行程度來設定 MaxOpenConns。
游泳池 將 MaxIdleConns 設定為至少是 MaxOpenConns 的一半。
Network 只有在對大型傳輸或大量負載做基準測試後才更改 packet size 。
Queries 僅在經常重複的陳述時使用預先準備的陳述。
Queries 對 mssql.VarChar 欄位使用 varchar,以避免隱式轉換。
大載荷 批次插入時使用批量副本(mssql.CopyIn)。
Resources 請及時關閉 Rows、Tx 和 Conn 物件。
Timeouts 為所有查詢和陳述設定上下文截止期限。
讀取密集型 使用 ApplicationIntent=ReadOnly 作為讀取複本。
Benchmarks 使用 testing.B 來衡量最佳化前後的差異。
Monitoring 啟用 查詢存放區,並使用 SSMS 報告或 Query Performance Insight。
Monitoring 匯出 db.Stats() 到 Prometheus 或 OpenTelemetry。

基準資料庫運作

使用 Go testing.B 來衡量資料庫操作的效能並驗證優化變更:

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

使用以下項目執行基準測試:

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

小提示

使用 -count=5 或更高版本,才能獲得具統計意義的結果。 使用 benchstat 比較變更前後的基準測試結果。

使用唯讀路由

如果您的 SQL Server 環境具有含可讀取次要複本的可用性群組,請在連線字串中設定 ApplicationIntent=ReadOnly,將唯讀查詢路由至次要複本:

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

為讀寫工作負載建立獨立 *sql.DB 實例:

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.

查詢計畫分析

用 SET SHOWPLAN_XML 來取得查詢的執行計畫,但不執行該查詢。 此方法有助於辨識資料表掃描、缺失的索引及昂貴的操作:

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 影響整個連線。 請一律使用 db.Conn(ctx),將 SHOWPLAN 模式限制在專用連線中。

使用 查詢存放區 來尋找緩慢的查詢

Go 基準測試和用戶端時序告訴你,從應用程式角度來看查詢所需時間,但這個數字會結合網路延遲、伺服器執行時間和用戶端處理。 查詢存放區 會擷取伺服器上的執行計畫和執行時統計,讓你能精確看到 SQL Server 如何執行每個查詢、執行頻率,以及其效能隨時間的變化。

查詢存放區 特別適合辨識參數嗅探、計畫迴歸,以及消耗最多伺服器資源的查詢。 如果你的資料庫尚未啟用此功能,請啟用:

ALTER DATABASE [AdventureWorks2025] SET QUERY_STORE = ON;

啟用後,您可以透過多種方式檢視績效資料:

  • SQL Server Management Studio (SSMS):在 物件總管 展開資料庫,開啟 查詢存放區 資料夾,使用內建報告如「頂尖資源消耗查詢」、「回歸查詢」及「整體資源消耗」。
  • Azure 入口網站:對於 Azure SQL Database,開啟查詢效能洞察窗格,即可查看最耗資源的查詢,無需安裝任何工具。
  • Transact-SQL(T-SQL):如果你需要程式化存取,可以直接從 Go 應用程式查詢 sys.query_store_runtime_stats 和 sys.query_store_plan 目錄視圖。

小提示

查詢存放區 會持續保存伺服器重啟時的資料,因此你可以分析數天或數週的效能趨勢。 使用 查詢回歸 報告,即可快速找出在結構描述或程式碼變更後變得更慢的查詢。

使用績效儀表板

效能儀表板是內建的 SSMS 報告,提供 SQL Server 健康狀況的即時概覽。 在 SSMS 物件總管 中右鍵點擊伺服器實例,並選擇「報告>標準報告>效能儀表板」。

儀表板顯示:

  • 目前的等待與瓶頸。
  • 最近昂貴的查詢。
  • CPU、I/O 和記憶體使用趨勢。
  • 主動使用者請求與被封鎖的會話。

效能儀表板在開發與負載測試中非常有用,能快速發現問題,無需撰寫任何診斷查詢。

監控 Go 的伺服器端指標

如果你需要將 SQL Server 效能資料暴露給像 Prometheus 或 OpenTelemetry 這類監控系統,從你的 Go 應用程式中,請直接查詢動態管理視圖(DMV):

最昂貴的查詢

檢索依平均經過時間排名前十的查詢:

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

目前有效申請

列出伺服器上目前執行的所有請求,不包括你自己的工作階段:

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

大型結果的記憶體效率模式

以串流方式傳送資料列,而不累積資料

逐列處理資料,而不是將整個結果集載入到 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()

使用鍵集分頁的批次處理

將大型資料表掃描拆解成可管理的區塊,以減少記憶體使用並避免長時間保留連線:

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
}