使用 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().WaitCountdb.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() 關閉已準備的陳述式,以避免耗用伺服器端預備陳述式控制代碼。

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

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

單個 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 varchar 欄位使用 mssql.VarChar,以避免隱式轉換。
大載荷 批次插入時使用批量副本(mssql.CopyIn)。
Resources 請及時關閉 RowsTxConn 物件。
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,開啟查詢效能洞察(Query Performance Insight)刀片,即可查看最耗資源的查詢,無需安裝任何工具。
  • Transact-SQL(T-SQL):如果你需要程式化存取,可以直接從 Go 應用程式查詢 sys.query_store_runtime_statssys.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
}