19. Observability БД

Введение

В сложной инженерной системе мониторинг отвечает на простой вопрос: «Что именно сломалось прямо сейчас?». Он зажигает красные индикаторы на приборной панели, фиксирует всплески задержек и шлет тревожные алерты дежурному инженеру (17. Мониторинг баз данных). Однако в мире современных распределенных микросервисов этого фундаментально недостаточно. Когда пользователь жалуется, что оформление заказа занимает 15 секунд вместо положенных 200 миллисекунд, классический мониторинг базы данных часто рапортует об идеальном здоровье: загрузка CPU — 12%, средний TPS стабилен, а средняя задержка запросов составляет комфортные 3 миллисекунды.

Здесь на сцену выходит наблюдаемость (Observability) — способность исследовать и понимать внутреннее состояние сложнейшей системы на основе анализа ее внешних сигналов. Observability позволяет инженеру отвечать на произвольные, заранее не сформулированные вопросы: «Почему именно у пользователя с идентификатором #94821 транзакция выполнялась 15 секунд? Какие именно SQL-операторы были вызваны в рамках его HTTP-запроса, сколько времени они ожидали освобождения блокировок и на какой реплике исполнялись?».

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

  1. Распределенная трассировка (Distributed Tracing) — сквозной путь транзакции от входного сетевого шлюза через горутины сервиса до конкретных строк СУБД.
  2. Структурированные логи (Structured Logging) — контекстные журналы событий с общими идентификаторами операций (trace_id).
  3. Агрегированные метрики (Metrics) — количественные временные ряды, связанные с трассировкой через механизм экземпляров (Exemplars).

В этой главе мы разберем, как в экосистеме Go построить эталонный конвейер наблюдаемости для database/sql, pgx и go-redis на базе стандарта OpenTelemetry (OTel), изучим скрытые накладные расходы трассировки на аппаратное обеспечение (Mechanical Sympathy) и научимся связывать клиентские спаны с серверными процессами СУБД без ущерба для кэша планов запросов.


Три столпа observability в контексте БД

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    App["Go-приложение<br>HTTP/gRPC"]:::network --> OTel["OpenTelemetry SDK<br>внутри процесса"]
    OTel --> Traces["Трассировка<br>спаны запросов к БД"]
    OTel --> Metrics["Метрики<br>латентность, ошибки"]
    OTel --> Logs["Логи<br>структурированные, trace_id"]
    Traces --> Backend["Jaeger / Tempo"]
    Metrics --> Prometheus["Prometheus<br>+ Grafana"]
    Logs --> Loki["Loki / ELK"]
    Backend --> Correlation["Корреляция trace_id<br>всех слоёв"]
    Prometheus --> Correlation
    Loki --> Correlation
  

Три столпа, объединенные общим контекстом сквозного идентификатора trace_id (W3C Trace Context), формируют единую непрерывную панораму:

  • Метрика фиксирует аномалию: скачок p99 latency на графике Grafana.
  • Связанный с метрикой Exemplar указывает точный trace_id конкретного аномального запроса.
  • Инженер в один клик переходит в распределенный трейс (Jaeger / Grafana Tempo), где видит водопад спанов и находит проблемный спан к базе данных длительностью 8 секунд.
  • По этому же trace_id извлекаются структурированные логи (Loki / ELK), содержащие точный текст SQL-оператора, параметры запроса, имя клиента и текст возникшей ошибки.

Трассировка запросов к БД

Трассировка — это направленный ациклический граф спанов (Span), представляющих отрезки времени выполнения операций. Для реляционных и NoSQL баз данных OpenTelemetry определяет семантическую спецификацию (Semantic Conventions) с обязательными атрибутами:

  • db.system — тип СУБД (postgresql, mysql, redis, mongodb).
  • db.name — целевая база данных.
  • db.statement — нормализованный текст SQL-запроса или команды.
  • db.user — имя пользователя, под которым выполнено подключение.
  • net.peer.name — сетевой адрес или хостнейм сервера базы данных.
  • net.transport — транспортный протокол (IP.TCP).

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

OpenTelemetry и Go-драйверы

Сообщество Go разработало высокопроизводительные библиотеки-обертки, внедряющие телеметрию в драйверы баз данных без переписывания бизнес-логики:

  • otelpgx — инструментация для высокоскоростного драйвера pgx, его пула pgxpool и соединений pgx.Conn. Работает на базе встроенного интерфейса хуков pgx.QueryTracer, перехватывая события Query, Exec, SendBatch и Prepare.
  • otelsql — обертка для стандартного интерфейса database/sql. Перехватывает вызовы db.Prepare, db.QueryContext, db.ExecContext, измеряя время на сетевых вызовах драйвера.
  • go-redis/extra/redisotel — хук для клиента go-redis, создающий спаны для каждой исполняемой команды или пайплайна.

Пример подключения трассировки к пулу pgxpool в современном Go-сервисе:

Go
package database

import (
    "context"

    "github.com/exaring/otelpgx"
    "github.com/jackc/pgx/v5/pgxpool"
    "go.opentelemetry.io/otel"
)

func initDB(ctx context.Context) (*pgxpool.Pool, error) {
    config, err := pgxpool.ParseConfig("postgres://user:pass@localhost:5432/orders_db")
    if err != nil {
        return nil, err
    }

    // Внедряем OpenTelemetry Tracer в конфигурацию pgxpool
    config.ConnConfig.Tracer = otelpgx.NewTracer(
        otelpgx.WithTracerProvider(otel.GetTracerProvider()),
        otelpgx.WithLogSQLStatement(true), // логировать нормализованный текст SQL
    )

    return pgxpool.NewWithConfig(ctx, config)
}

Под капотом библиотека otelpgx реализует интерфейс pgx.QueryTracer, определяющий два ключевых метода жизненного цикла: TraceQueryStart и TraceQueryEnd.

  • В момент вызова TraceQueryStart из переданного context.Context извлекается родительский контекст трассировки, создается новый дочерний спан с именем операции, проставляются семантические атрибуты СУБД, а обновленный контекст связывается с выполняемой операцией.
  • При завершении вызова метод TraceQueryEnd фиксирует финальную метку времени, вычисляет длительность выполнения, при наличии ошибки записывает статус codes.Error и завершает спан вызовом span.End().

Передача контекста трассировки в БД

Клиентская трассировка заканчивается на сетевом сокете вашего приложения. Но что, если мы хотим связать клиентский спан с серверным журналом PostgreSQL или утилитой pganalyze?

Для этого используется стандарт SQLCommenter — внедрение комментариев стандарта W3C Trace Context непосредственно в текст SQL-запроса:

SQL
/* traceparent='00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' */ SELECT * FROM users WHERE id=$1

Сервер PostgreSQL при логировании медленных операций (18. Slow query log) фиксирует этот комментарий целиком. Системы анализа логов (например, pganalyze или pgBadger) извлекают поле traceparent и сквозной trace_id, позволяя идеально связать серверный лог СУБД со спаном в Jaeger!


Логирование запросов: от slow query к каждому вызову

Журнал медленных запросов сервера СУБД (18. Slow query log) служит первой линией обороны. Однако для полноценной наблюдаемости этого мало: медленный запрос может быть симптомом, а причиной — сотни предшествующих ему микро-запросов.

PostgreSQL: auto_explain и log_statement

На стороне сервера PostgreSQL расширение auto_explain (подключаемое через параметр ядра shared_preload_libraries) способно автоматически логировать реальные планы выполнения запросов, время которых превысило порог auto_explain.log_min_duration. В сочетании с конфигурацией log_statement = 'all' и префиксом строки лога:

Text
log_line_prefix = '%m [%p] %q%u@%d '

администратор получает исчерпывающий протокол каждого выполненного запроса с указанием PID процесса %p, имени пользователя %u и базы данных %d.

Однако включение log_statement = 'all' на боевом высоконагруженном сервере абсолютно неприемлемо. Это порождает колоссальную дисковую нагрузку и способно быстро исчерпать IOPS. Инженерный стандарт состоит в выборочном клиентском логировании: приложение само решает, когда и что логировать, опираясь на контекст. Драйвер pgx позволяет подключить кастомный интерфейс pgx.Logger, направляя записи в системный логгер с обогащением метаданными.

Structured logging в Go

В современном Go де-факто стандартом является пакет log/slog. Он позволяет бесшовно обогащать логи идентификаторами трассировки:

Go
package repository

import (
    "context"
    "log/slog"
    "time"

    "go.opentelemetry.io/otel/trace"
)

func LogDatabaseOperation(ctx context.Context, query string, duration time.Duration, err error) {
    span := trace.SpanFromContext(ctx)
    traceID := span.SpanContext().TraceID().String()
    spanID := span.SpanContext().SpanID().String()

    logger := slog.Default().With(
        slog.String("trace_id", traceID),
        slog.String("span_id", spanID),
        slog.String("db.system", "postgresql"),
    )

    if err != nil {
        logger.Error("database query failed",
            slog.String("sql", query),
            slog.Float64("duration_ms", float64(duration.Microseconds())/1000.0),
            slog.String("error", err.Error()),
        )
        return
    }

    // Логируем медленные запросы на стороне Go-приложения
    if duration > 100*time.Millisecond {
        logger.Warn("slow database query detected",
            slog.String("sql", query),
            slog.Float64("duration_ms", float64(duration.Microseconds())/1000.0),
        )
    }
}

При отправке логов в Grafana Loki или ELK система автоматически индексирует метку trace_id, обеспечивая бесшовный переход от лога к трейсу и обратно.


Метрики как фундамент

Метрики остаются наиболее экономичным и быстрым фундаментом наблюдаемости: латенси запросов (P50/P95/P99), количество ошибок, размер пула соединений. OpenTelemetry SDK для Go может экспортировать метрики автоматически, если включён otelgrpc/otelhttp, а для БД используются соответствующие инструментации.

Критически важно связать агрегированные метрики с трассировкой через механизм Exemplars. Поддерживаемый в Prometheus и OpenTelemetry, Exemplar прикрепляет конкретный trace_id к точке на временном ряде метрики (например, к корзине гистограммы P99). Инженер, кликая на пик графика задержки в Grafana, мгновенно получает ссылку на реальный трейс именно того медленного запроса, который сформировал эту точку!


Mechanical Sympathy: цена полной наблюдаемости

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

Взглянем на цену полной наблюдаемости через призму архитектуры машины (Mechanical Sympathy):

  1. Аллокации спанов в памяти: Создание спана в Go требует динамического выделения структур: слайс атрибутов []attribute.KeyValue, буфер имени спана, временные метки. При 100 000 RPS к СУБД непрерывная генерация спанов создаст гигантское давление на Garbage Collector. Решение — вероятностное семплирование (Sampling):

    Go
    // Трассировать только 1% штатных запросов
    sampler := trace.ParentBased(trace.TraceIDRatioBased(0.01))
    
  2. Пакетная передача (Batching): Использование BatchSpanProcessor вместо синхронного SimpleSpanProcessor. Он аккумулирует спаны во внутреннем кольцевом буфере памяти и сбрасывает их в OTLP-коллектор пачками по расписанию (BatchTimeout, MaxQueueSize), минимизируя системные вызовы сокетов write() и переключения контекста ядра ОС.

  3. Асинхронность логов: Синхронная запись логов в файл или сокет — фатальная ошибка. Если диск или демон сбора логов зависнет, блокирующий системный вызов write() остановит горутину и заблокирует рабочий поток рантайма. Логирование обязано быть неблокирующим (через ring buffer или буферизованные логгеры вроде zerolog).
  4. Серверные лимиты СУБД: В PostgreSQL модуль pg_stat_statements агрегирует статистику в разделяемой хэш-таблице без дискового ввода-вывода. Однако расширения вроде pg_query_state потребляют разделяемую память. А включение log_statement=all на проде опасно и способно физически парализовать дисковую подсистему постоянной записью текстовых журналов и вызвать OOM. Поэтому серверная наблюдаемость обязана быть агрегированной.

Интеграция с инфраструктурой

Комплексный контур наблюдаемости баз данных объединяет следующие компоненты инфраструктуры:

  • Сбор метрик: СУБД-экспортеры postgres_exporter, redis_exporter собирают статистику ядра и передают ее в Prometheus или VictoriaMetrics.
  • Трассировка: Агенты OpenTelemetry Collector принимают спаны от Go-сервисов по протоколу gRPC/OTLP и маршрутизируют их в Jaeger или Grafana Tempo.
  • Логи: Агенты сбора (Promtail / Vector / Filebeat) агрегируют структурированные JSON-логи Go-сервисов и журналы самой СУБД (через log_destination = csvlog с автоматическим парсингом log_line_prefix) и направляют их в Loki или Elasticsearch/ELK.
  • Визуализация: Дашборд Grafana объединяет все три потока телеметрии на едином экране: от графика TPS и латенси до просмотра трейса и поиска контекстных логов.

Для Go-разработчика ключевая задача в инфраструктуре — настроить переменную окружения OTEL_EXPORTER_OTLP_ENDPOINT и гарантировать непрерывную передачу context.Context через всю цепочку вызовов от входящего сетевого сокета до драйвера хранилища.


Практические рекомендации при внедрении

  1. Начните с метрик — они предельно дешевы по ресурсам и дают немедленное понимание базового состояния системы. Подключите pg_stat_statements и Prometheus exporter.
  2. Включите трассировку с низким семплингом (например, TraceIDRatioBased(0.01) — 1% трафика). Используйте проверенные обертки otelsql или otelpgx.
  3. Логируйте только ошибки и медленные запросы на уровне приложения, обязательно обогащая их контекстом trace_id. Категорически избегайте log_statement = 'all' на сервере.
  4. Маскируйте чувствительные данные: персональные данные пользователей (PII), токены и пароли ни в коем случае не должны попадать в спаны и логи. В библиотеках трассировки используйте опцию маскирования параметров WithLogSQLStatement(false) или внедряйте кастомные фильтры санитайзинга.
  5. Объединяйте трейсы и логи через trace_id — наличие общего сквозного идентификатора сокращает среднее время расследования инцидента (MTTR) с нескольких часов до пары минут.

Ловушки и вопросы с собеседований


Итог

Observability баз данных — это не просто дань инженерной моде, а краеугольный камень надежности современного распределенного бэкенда. Объединяя распределенную трассировку OpenTelemetry, структурированные логи log/slog и метрики пулов соединений, мы превращаем черный ящик базы данных в прозрачный, математически предсказуемый механизм.

Мы научились измерять, логировать и оптимизировать поведение баз данных. Однако идеальная производительность бесполезна, если база данных уязвима для атак и утечек. В следующей главе мы переходим к фундаментальным принципам защиты данных: 20. Безопасность БД.