使用go-mssqldb进行性能调优

本文提供了关于如何优化使用go-mssqldbSQL Server驱动的Go应用性能的指导。

从影响最大的变更开始

大多数应用在第一天就不需要针对驱动单元进行特定调校。 在更改数据包大小、在各处添加预备账单或调整批量选项之前,先从以下步骤开始:

  1. 设置一个与服务器限制相匹配的有限制连接池大小。
  2. 设置上下文超时机制,避免被阻塞的 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)
}

Tip

使用 defer stmt.Close() 关闭预处理语句,以避免占用服务器端预处理语句句柄。

不要默认预处理每条语句。 当同一条语句在热点路径中重复运行时,它们的帮助最大。 对于一次性查询,QueryContextExecContext 通常更直接,而且速度也足够快。

对于大型插页,使用批量副本

单个 INSERT 语句对于大数据负载来说速度较慢。 批量复制将数据直接流向服务器,绕过查询处理器:

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

详情请参见 批量操作

适时使用varchar

默认情况下,代码以 string (Unicode) 格式发送参数 nvarchar 。 如果你的列使用 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 在优化前后进行测量。
监测 启用 查询存储,并使用 SSMS 报告或 Query Performance Insight。
监测 导出 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

Tip

使用 -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 数据库,打开查询性能洞察(Query Performance Insight)刀片,无需安装任何工具即可查看最耗资源的查询。
  • Transact-SQL (T-SQL):如果需要以编程方式访问,可以直接在 Go 应用程序中查询 sys.query_store_runtime_statssys.query_store_plan 目录视图。

Tip

查询存储 会在服务器重启期间持续保存数据,因此你可以分析几天或几周内的性能趋势。 利用 回归查询 报告快速发现在模式或代码变更后变慢的查询。

使用绩效仪表盘

性能仪表盘是内置的 SSMS 报告,提供 SQL Server 健康状况的实时概览。 在SSMS 对象资源管理器中右键点击服务器实例,选择“报告>标准报告>性能仪表盘”。

仪表板显示:

  • 当前的等待情况和瓶颈。
  • 最近的高开销查询。
  • CPU、I/O和内存使用趋势。
  • 活跃用户请求和被阻断的会话。

性能仪表盘在开发和负载测试中非常有用,可以快速发现问题,无需编写任何诊断查询。

通过Go监控服务器端指标

如果你需要通过 Go 应用向 Prometheus 或 OpenTelemetry 等监控系统暴露 SQL Server 性能数据,直接查询动态管理视图(DMV):

最昂贵的查询

检索按平均经过时间排名的前10个查询:

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

大型结果集的节省内存模式

流式处理行而不累积

一次只处理一行,而不是将整个结果集加载到切片中:

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
}