Настройка производительности с помощью go-mssqldb

В этой статье приведены рекомендации по оптимизации производительности Go-приложений, использующих go-mssqldb драйвер с SQL Server.

Начните с самых значимых изменений

Большинству приложений не требуется настройка, специфичная для драйвера, с первого дня. Начните с этих шагов, прежде чем изменять размер пакета, повсюду добавлять подготовленные выражения или настраивать параметры массовых операций:

  1. Установите ограниченный размер пула соединений, соответствующий лимитам вашего сервера.
  2. Добавьте тайм-ауты контекста, чтобы заблокированные SQL-запросы не удерживали соединения занятыми и приложение не казалось зависшим под нагрузкой.
  3. Исправьте медленные запросы, отсутствующие индексы и ненужные круговые переходы в SQL Server.
  4. Оценивайте нагрузку до и после каждого изменения.

Для многих сервисов настройки пула соединений и структура запроса важнее, чем размер пакета или подготовка выражений.

Настройка пула соединений

Пул database/sql соединений — самый влиятельный рычаг производительности. Недостаточно подготовленные пулы приводят к тому, что горутины блокируют ожидание соединений, а чрезмерно подготовленные пулы тратят ресурсы сервера.

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

Tip

Закройте подготовленные выражения с помощью defer stmt.Close(), чтобы избежать расходования серверных дескрипторов подготовленных выражений.

Не готовьте каждое заявление по умолчанию. Больше всего они помогают, когда одна и та же инструкция многократно выполняется в часто выполняемом участке кода. Для разовых запросов QueryContext или ExecContext обычно это более просто и достаточно быстро.

Используйте массовое копирование для больших вставок

Отдельные INSERT инструкции медленно выполняются при больших объёмах данных. Массовое копирование передаёт данные непосредственно на сервер, обходя процессор запросов:

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

Подробнее см. раздел «Операции по массовому грузу».

Используйте варчар, когда это уместно

По умолчанию код отправляет 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 для измерения до и после оптимизации.
Контроль Включите хранилище запросов и используйте отчёты SSMS или Query Performance Insight.
Контроль Экспортировать db.Stats() в Prometheus или OpenTelemetry.

Операции с базами данных эталонов

Используйте Go's 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
}

Предупреждение

SET SHOWPLAN_XML ON влияет на всю связь. Всегда db.Conn(ctx) используйте для изоляции режима SHOWPLAN от выделенного соединения.

Используйте хранилище запросов для поиска медленных запросов

Бенчмарки Go и тайминг на стороне клиента показывают, сколько времени занимает запрос с точки зрения вашего приложения, но это число включает сетевую задержку, время выполнения сервера и обработку клиентов. хранилище запросов фиксирует планы выполнения и статистику во время выполнения на сервере, чтобы вы могли точно видеть, как SQL Server выполнял каждый запрос, как часто он запускался и как менялась его производительность со временем.

хранилище запросов особенно полезен для выявления отслеживания параметров, регрессий планов и запросов, потребляющих наибольшую часть ресурсов сервера. Включите его в базе данных, если он ещё не включён:

ALTER DATABASE [AdventureWorks2025] SET QUERY_STORE = ON;

После включения вы можете просматривать данные о производительности несколькими способами:

  • SQL Server Management Studio (SSMS): разверните вашу базу данных в обозревателе объектов, откройте папку Хранилище запросов и используйте встроенные отчёты, такие как Запросы с наибольшим потреблением ресурсов, Регрессировавшие запросы и Общее потребление ресурсов.
  • Azure portal: Для База данных SQL Azure откройте blade Query Performance Insight, чтобы увидеть наиболее ресурсоемкие запросы без установки инструментов.
  • Transact-SQL (T-SQL): Если вам нужен программный доступ, обращайтесь напрямую к представлениям каталога sys.query_store_runtime_stats и sys.query_store_plan из вашего приложения Go.

Tip

хранилище запросов сохраняет данные при перезапусках сервера, чтобы вы могли анализировать тенденции производительности в течение дней или недель. Используйте отчёт о регрессивных запросах , чтобы быстро выявлять замедленные запросы после изменения схемы или кода.

Используйте панель управления производительностью

Performance Dashboard — это встроенный отчёт SSMS, который предоставляет обзор состояния SQL Server в реальном времени. Щелкните правой кнопкой мыши экземпляр сервера в обозревателе объектов SSMS и выберите Отчеты>Стандартные отчеты>Панель мониторинга производительности.

На панели мониторинга отображаются следующие сведения:

  • Текущие задержки и узкие места.
  • Недавние ресурсоёмкие запросы.
  • Тенденции использования процессора, ввода-вывода и памяти.
  • Активные пользовательские запросы и заблокированные сессии.

Панель производительности полезна при разработке и нагрузочном тестировании, чтобы быстро выявлять проблемы без написания диагностических запросов.

Мониторинг серверных метрик из Go

Если вам нужно показать данные производительности SQL Server для системы мониторинга, такой как Prometheus или OpenTelemetry, из вашего приложения Go, запрашивайте динамические виды управления (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
}