Как работать с Postgres в Go: практики, особенности, нюансы

Как работать с Postgres в Go: практики, особенности, нюансы 1

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

Надо понимать, часто недостаточно полистать go-database-sql.org. Лучше вооружиться чужим опытом. Еще лучше, если это будет опыт, полученный кровью и потерянными деньгами.

[embedded content]

Меня зовут Рябинков Артемий и эта статья — вольная интерпретация моего доклада с конференции Saints HighLoad 2019.

Минимально необходимую информацию о том, как в Go работать с любой SQL-подобной базой данных, вы сможете найти на go-database-sql.org. Если еще не читали — прочитайте.

sqlx

На мой взгляд, сила Go в простоте. И это выражается, например, в том, что в Go принято писать запросы на голом SQL (ORM не в чести). Это и преимущество, и источник дополнительных трудностей.

Поэтому, взяв стандартный пакет языка database/sql, вы захотите расширить его интерфейсы. Как только это произойдёт, взгляните на github.com/jmoiron/sqlx. Покажу несколько примеров, как это расширение может упростить вам жизнь.

Использование StructScan позволяет не перекладывать руками данные из столбцов в свойства структуры.

type Place struct {
    Country       string
    City          sql.NullString
    TelephoneCode int `db:"telcode"`
}

var p Place
err = rows.StructScan(&p)

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

p := Place{Country: "South Africa"}

sql := `.. WHERE country=:country`
rows, err := db.NamedQuery(sql, p)

Использование Get и Select позволяет избавиться от необходимости руками писать циклы, которые достают строки из базы.

var p Place
var pp []Place

// Get записывает в p данные из первой строки
err = db.Get(&p, ".. LIMIT 1")

// Select записывает в pp массив полученных строк.
err = db.Select(&pp, ".. WHERE telcode > ?", 50)

database/sql — это набор интерфейсов для работы с базой, а sqlx — их расширение. Чтобы эти интерфейсы работали, для них нужна реализация. Именно за реализацию и отвечают драйверы.

Наиболее популярные драйверы:

  • github.com/lib/pqpure Go Postgres driver for database/sql. Этот драйвер долгое время оставался стандартом по умолчанию. Но на сегодняшний день он потерял свою актуальность и не развивается автором.
  • github.com/jackc/pgxPostgreSQL driver and toolkit for Go. Сегодня лучше выбрать этот инструмент.

github.com/jackc/pgx — именно этот драйвер вы хотите использовать. Почему?

  • Активно поддерживается и развивается.
  • Может быть производительнее в случае использования без интерфейсов database/sql .
  • Поддержка более 60 типов PostgreSQL, которые PostgreSQL реализует вне стандарта SQL.
  • Возможность удобно реализовать логирование того, что происходит внутри драйвера.
  • У pgx человекопонятные ошибки, в то время как просто lib/pq бросает паники. Если не поймать панику, программа упадет. (Не стоит использовать паники в Go, это не то же самое, что исключения.)
  • С pgx у нас есть возможность независимо конфигурировать каждое соединение.
  • Есть поддержка протокола логической репликации PostgreSQL.

4KB

Типично мы пишем вот такой цикл, чтобы получать данные из базы:

rows, err := s.db.QueryContext(ctx, sql)

for rows.Next() {
    err = rows.Scan(...)
}

Внутри драйвера мы получаем данные, накапливая их в буфер размером 4KB. rows.Next() порождает поход в сеть и наполняет буфер. Если буфера не хватает, то мы идём в сеть за оставшимися данными. Больше походов в сеть – меньше скорость обработки. С другой стороны, так как предел буфера – 4KB, не забьём всю память процесса.

Но, конечно, хочется выкрутить объём буфера на максимум, чтобы уменьшить кол-во запросов в сеть и снизить latency нашего сервиса. Добавляем такую возможность и попробуем выяснить ожидаемое ускорение на синтетических тестах:

$ go test -v -run=XXX -bench=. -benchmem
goos: linux
goarch: amd64
pkg: github.com/furdarius/pgxexperiments/bufsize
BenchmarkBufferSize/4KB                    5     315763978 ns/op    53112832 B/op      12967 allocs/op
BenchmarkBufferSize/8KB                    5     300140961 ns/op    53082521 B/op       6479 allocs/op
BenchmarkBufferSize/16KB                   5     298477972 ns/op    52910489 B/op       3229 allocs/op
BenchmarkBufferSize/1MB                    5     299602670 ns/op    52848230 B/op         50 allocs/op
PASS
ok      github.com/furdarius/pgxexperiments/bufsize 10.964s

Видно, что большой разницы по скорости обработки нет. Почему так?

Оказывается, мы упираемся в размер буфера на отправку данных внутри самого Postgres. Этот буфер имеет жестко заданный размер в 8KB. Используя strace можно увидеть, что ОС возвращает 8192 байта в системном вызове read. А tcpdump это подтверждает размером пакетов.

Tom Lane (один из основных разработчиков ядра Postgres) это комментирует так:

Traditionally, at least, that was the size of pipe buffers in Unix machines, so in principle this is the most optimal chunk size for sending data across a Unix socket.

Andres Freund (разработчик Postgres от EnterpriseDB) считает, что буфер в 8KB не лучший вариант реализации на сегодняшний день и нужно тестировать поведение на других размерах и с другой конфигурацией сокета.

Отдельно надо вспомнить, что у PgBouncer тоже есть буфер и его размер можно конфигурировать параметром pkt_buf.

OIDs

Другая особенность драйвера pgx (v3): на каждую установку соединения он делает запрос в базу для получения информации об Object ID (OID).

Эти идентификаторы были добавлены в Postgres, чтобы уникально идентифицировать внутренние объекты: строки, таблицы, функции и т.д.

Драйвер использует знание об OIDs, чтобы понимать, из какого столбца базы в какой примитив языка складывать данные. Для этого pgx поддерживает такую таблицу (ключ – название типа, значение – Object ID)

map[string]Value{
    "_aclitem": 2,
    "_bool": 3,
    "_int4": 4,
    "_int8": 55,
    ...
}

Эта реализация приводит к тому, что драйвер на каждое устанавливаемое соединение с базой делает около трех запросов, чтобы сформировать таблицу с Object ID. При штатном режиме работы базы и приложения пул соединений в Go позволяет не порождать новые соединения к базе. А вот при малейшей деградации базы данных пул соединений на стороне приложения исчерпывается и кол-во порождаемых соединений на единицу времени в разы возрастает. Запросы для получения OIDs достаточно тяжёлые, в итоге, драйвер может довести базу до критического состояния.

Вот момент, когда таких запросов насыпало на одну из наших баз:

Как работать с Postgres в Go: практики, особенности, нюансы 1

15 транзакций в минуту в нормальном режиме, скачок до 6500 транзакций при деградации.

Что делать?

Первое и основное — ограничивайте сверху размер вашего пула.

Для database/sql это можно сделать функцией DB.SetMaxOpenConns. Если откажетесь от интерфейсов database/sql и будете использовать pgx.ConnPool (пул соединений, реализуемый самим драйвером), то в ConnPoolConfig можно указать MaxConnections (по умолчанию 5).

Кстати, при использовании pgx.ConnPool драйвер будет переиспользовать информацию о полученных OIDs и не будет на каждое новое соединение делать запросы к базе.

Если от database/sql отказываться не хочется, то можно кешировать информацию об OIDs самим.

github.com/jackc/pgx/stdlib.OpenDB(pgx.ConnConfig{
    CustomConnInfo: func(c *pgx.Conn) (*pgtype.ConnInfo, error) {
        cachedOids = // Кешируем OIDs при первом запросе.

        info := pgtype.NewConnInfo()
        info.InitializeDataTypes(cachedOids)

        return info, nil
    }
})

Это рабочий способ, но его использование может быть опасно при двух условиях:

  • вы используете enum или доменные типы в Postgres;
  • в случае отказа мастера вы переключаете приложение на реплику, которая наливается логической репликацией.

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

В мире Postgres для организации высокой доступности обычно используют физическую репликацию, которая побитово копирует инстансы базы, поэтому проблемы из-за кеширования OIDs редко можно встретить в дикой природе. (Но лучше уточнить у своих DBA, как у вас standby работает).

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

Мониторинг и логирование помогут заметить проблемы раньше, чем упадет база.

database/sql предоставляет метод DB.Stats(). Возвращаемый снепшот состояния даст вам понимание того, что происходит внутри драйвера.

type DBStats struct {
        MaxOpenConnections int

        // Pool Status
        OpenConnections int
        InUse           int
        Idle            int

        // Counters
        WaitCount         int64
        WaitDuration      time.Duration
        MaxIdleClosed     int64
        MaxLifetimeClosed int64
}

Если используете пул в pgx напрямую, то похожую информацию вам даст метод ConnPool.Stat():

type ConnPoolStat struct {
    MaxConnections       int
    CurrentConnections   int
    AvailableConnections int
}

Не менее важно логировать, и pgx позволяет это делать. Драйвер принимает интерфейс Logger, реализовав который, вы получаете все происходящие внутри драйвера события.

type Logger interface {
    // Log a message at the given level with data key/value pairs.
    // data may be nil.
    Log(level LogLevel, msg string, data map[string]interface{})
}

Скорее всего, вам даже не придется самому реализовывать этот интерфейс. В pgx из коробки есть набор адаптеров для наиболее популярных логеров, например uber-go/zap, sirupsen/logrus, rs/zerolog.

Почти всегда при работе с Postgres вы будете использовать connection pooler, и это будет PgBouncer (или odyssey – если вы Yandex).

Почему так, можно почитать в отличной статье brandur.org/postgres-connections. Если кратко, то при кол-ве клиентов больше 100 скорость обработки запросов начинает деградировать. Происходит это из-за особенностей реализации самого Postgres: запуск отдельного процесса на каждое соединение, механизм снятия снапшотов и использование общей памяти для взаимодействия — всё это влияет.

Вот benchmark различных реализаций connection pooler’ов:
Как работать с Postgres в Go: практики, особенности, нюансы 1

И benchmark пропускной способности с PgBouncer и без него.

Как работать с Postgres в Go: практики, особенности, нюансы 1

В результате ваша инфраструктура примет такой вид:

Как работать с Postgres в Go: практики, особенности, нюансы 1

Где Server — процесс, обрабатывающий запросы пользователей. Этот процесс крутится в kubernetes в 3-х экземплярах (как минимум). Отдельно, на железном сервере, стоит Postgres, прикрытый PgBouncer'ом. Сам PgBouncer однопоточный, поэтому запускаем несколько баунсеров, трафик на которые балансируем, используя HAProxy. В итоге получаем такую цепочку выполнения запроса в базу: пул соединений приложения → HAProxy → PgBouncer → Postgres.

PgBouncer умеет работать в трех режимах:

  • Session pooling — каждой сессии выдается одно соединение и закрепляется за ней на всё время жизни.
  • Transaction pooling — соединение живёт, пока работает транзакция. Как только транзакция завершилась, PgBouncer забирает это соединение и отдает другой транзакции. Этот режим позволяет очень хорошо утилизировать соединения.
  • Statement poolingdeprecated режим. Он был создан только для того, чтобы поддерживать PL/Proxy.

Можно посмотреть матрицу того, какие свойства в каждом режиме доступны. Мы выбираем Transaction Pooling, но у него есть ограничения по работе с Prepared Statements.

Transaction Pooling + Prepared Statements

Давайте представим, что хотим подготовить запрос и потом его выполнить. В какой-то момент запускаем транзакцию, в которой отправляем запрос на Prepare, и получаем от базы данных идентификатор подготовленного запроса.

Как работать с Postgres в Go: практики, особенности, нюансы 1

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

Как работать с Postgres в Go: практики, особенности, нюансы 1

В режиме Transaction Pooling две транзакции могут быть выполнены в разных соединениях, но Statement ID действителен только в рамках одного соединения. Получаем ошибку prepared statement does not exist при попытке выполнить запрос.

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

А теперь найдите Prepared Statements в таком коде:

sql := `select * from places where city = ?`
rows, err := s.db.Query(sql, city)

Вы его не увидите! Подготовка запроса будет неявно происходить внутри Query(). При этом и подготовка, и выполнение запроса будут происходить в разных транзакциях и мы в полной мере получим все то, что я описал выше.

Что делать?

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

Второй вариант — готовить запрос на стороне клиента. Этого не хочется делать по двум причинам:

  • Потенциальные SQL-уязвимости. Разработчик может забыть или некорректно сделать экранирование.
  • Экранирование параметров запроса приходится каждый раз писать руками.

Еще один вариант — явно оборачивать каждый запрос в транзакцию. Ведь пока транзакция живет, PgBouncer не забирает соединение. Это работает, но, кроме многословности в нашем коде, мы вдобавок получаем большее количество сетевых вызовов: Begin, Prepare, Execute, Commit. Итого 4 сетевых вызова на один запрос. Latency растёт.

Но хочется и безопасно, и удобно, и эффективно. И такой вариант есть! Можно явно указать драйверу, что хочешь использовать режим Simple Query. В этом режиме не будет подготовки и весь запрос пройдёт в одном сетевом вызове. При этом драйвер сам сделает экранирование каждого из параметров (standard_conforming_strings должен быть активирован на уровне базы или при установке соединения).

cfg := pgx.ConnConfig{
    ...
    RuntimeParams: map[string]string{
        "standard_conforming_strings": "on",
    },
    PreferSimpleProtocol: true,
}

Следующие проблемы связаны с отменой запросов на стороне приложения.

Взгляните на этот код. Где тут подводные камни?

rows, err := s.db.QueryContext(ctx, ...)

В языке Go есть метод контроля потока выполнения программы – context.Context. В этом коде мы передаём ctx драйверу, чтобы при закрытии контекста драйвер отменил запрос на уровне базы данных.

При этом ожидается, что мы сэкономим ресурсы, отменив запросы, ответ на которые никто не ждёт. Но при отмене запроса PgBouncer версии 1.7 в соединение отправляет информацию о том, что это соединение готово к использованию, и уже после этого возвращает его в пул. Такое поведение PgBouncer'а вводит в заблуждение драйвер, который при отправке следующего запроса мгновенно получает в ответ ReadyForQuery. В итоге мы ловим ошибки unexpected ReadyForQuery.

Начиная с PgBouncer версии 1.8 это поведение было исправлено. Используйте актуальную версию PgBouncer.

Отложенная отмена

Но самое интересное в том, как работает отмена запросов. Чтобы отменить запрос, нам нужно создать новое соединение с базой и запросить отмену. На каждое соединение Postgres создаёт отдельный процесс. Мы отправляем команду на отмену текущего запроса в конкретном процессе. Для этого создаём новое соединение и в нём передаём ID процесса (PID), интересующего нас. Но пока команда на отмену летит до базы, отменяемый запрос может завершиться самостоятельно.

Как работать с Postgres в Go: практики, особенности, нюансы 1

Postgres выполнит команду и отменит текущий запрос в заданном процессе. Но текущим запросом будет уже не тот, который мы хотели отменить изначально. Из-за такого поведения при работе с Postgres вместе с PgBouncer безопаснее будет не отменять запрос на уровне драйвера. Для этого можно задать функцию CustomCancel, которая не будет отменять запрос, даже если используется context.Context.

cfg := pgx.ConnConfig{
    ...
    CustomCancel: func(_ *pgx.Conn) error { return nil },
}

Вместо выводов решил сделать чеклист по работе с Postgres. Это должно помочь статье уложиться в голове.

  • Используйте github.com/jackc/pgx как драйвер для работы с Postgres.
  • Ограничивайте сверху размер пула соединений.
  • Кешируйте OIDs или используйте pgx.ConnPool, если работаете с pgx версии 3.
  • Собирайте метрики по пулу соединений, используя DB.Stats() или ConnPool.Stat().
  • Логируйте происходящее в драйвере.
  • Используйте режим Simple Query, чтобы избежать проблем с подготовкой запросов в транзакционном режиме PgBouncer.
  • Обновляйте PgBouncer до актуальной версии.
  • Будьте аккуратны с отменой запросов со стороны приложения.

Let’s block ads! (Why?)

Читайте также:

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *