Неожиданное поведение приложения в отношении работы с базой приводит к войне между DBA и разработчиками: DBA кричат: «Ваше приложение роняет базу», разработчики — «Но ведь до этого всё работало!». Хуже всего, что DBA и разработчики не могут помочь друг другу: одни не знают про нюансы работы приложения и драйвера, другие не знают про особенности, связанные с инфраструктурой. Было бы неплохо такой ситуации избежать.
Надо понимать, часто недостаточно полистать go-database-sql.org. Лучше вооружиться чужим опытом. Еще лучше, если это будет опыт, полученный кровью и потерянными деньгами.
Меня зовут Рябинков Артемий и эта статья — вольная интерпретация моего доклада с конференции 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/pq —
pure Go Postgres driver for database/sql.
Этот драйвер долгое время оставался стандартом по умолчанию. Но на сегодняшний день он потерял свою актуальность и не развивается автором. - github.com/jackc/pgx —
PostgreSQL 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
достаточно тяжёлые, в итоге, драйвер может довести базу до критического состояния.
Вот момент, когда таких запросов насыпало на одну из наших баз:
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 работает).
В следующей мажорной версии драйвера pgx
– v4
, походов за 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'ов:
И benchmark пропускной способности с PgBouncer и без него.
В результате ваша инфраструктура примет такой вид:
Где Server
— процесс, обрабатывающий запросы пользователей. Этот процесс крутится в kubernetes
в 3-х экземплярах (как минимум). Отдельно, на железном сервере, стоит Postgres
, прикрытый PgBouncer'ом
. Сам PgBouncer
однопоточный, поэтому запускаем несколько баунсеров, трафик на которые балансируем, используя HAProxy
. В итоге получаем такую цепочку выполнения запроса в базу: пул соединений приложения → HAProxy → PgBouncer → Postgres
.
PgBouncer
умеет работать в трех режимах:
- Session pooling — каждой сессии выдается одно соединение и закрепляется за ней на всё время жизни.
- Transaction pooling — соединение живёт, пока работает транзакция. Как только транзакция завершилась,
PgBouncer
забирает это соединение и отдает другой транзакции. Этот режим позволяет очень хорошо утилизировать соединения. - Statement pooling — deprecated режим. Он был создан только для того, чтобы поддерживать PL/Proxy.
Можно посмотреть матрицу того, какие свойства в каждом режиме доступны. Мы выбираем Transaction Pooling, но у него есть ограничения по работе с Prepared Statements
.
Transaction Pooling + Prepared Statements
Давайте представим, что хотим подготовить запрос и потом его выполнить. В какой-то момент запускаем транзакцию, в которой отправляем запрос на Prepare, и получаем от базы данных идентификатор подготовленного запроса.
После, в любой другой момент времени, порождаем другую транзакцию. В ней обращаемся к базе и хотим по идентификатору выполнить запрос с заданными параметрами.
В режиме 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
выполнит команду и отменит текущий запрос в заданном процессе. Но текущим запросом будет уже не тот, который мы хотели отменить изначально. Из-за такого поведения при работе с Postgres
вместе с PgBouncer
безопаснее будет не отменять запрос на уровне драйвера. Для этого можно задать функцию CustomCancel
, которая не будет отменять запрос, даже если используется context.Context
.
cfg := pgx.ConnConfig{
...
CustomCancel: func(_ *pgx.Conn) error { return nil },
}
Чеклист по работе с Potgres
Вместо выводов решил сделать чеклист по работе с Postgres. Это должно помочь статье уложиться в голове.
- Используйте github.com/jackc/pgx как драйвер для работы с Postgres.
- Ограничивайте сверху размер пула соединений.
- Кешируйте
OIDs
или используйте pgx.ConnPool, если работаете сpgx
версии 3. - Собирайте метрики по пулу соединений, используя DB.Stats() или ConnPool.Stat().
- Логируйте происходящее в драйвере.
- Используйте режим Simple Query, чтобы избежать проблем с подготовкой запросов в транзакционном режиме
PgBouncer
. - Обновляйте
PgBouncer
до актуальной версии. - Будьте аккуратны с отменой запросов со стороны приложения.
Автор: Flaker