Привет! Меня зовут Дима Вагин, я бэкенд-инженер в Авито. Сегодня расскажу, как мы работаем с БД PostgreSQL из Go. Покажу, какие библиотеки и пулеры соединений мы используем для доставки в код параметров подключения и как мы их настраиваем. А ещё расскажу про проблемы, к которым приводит отмена контекста, и о том, как мы с ними справляемся.
Доставка паролей и параметров подключения в продакшн
Прежде чем подключаться к PostgreSQL, нужно определиться, как безопасно и удобно передавать пароли от БД и параметры подключения.
Вот, как предлагает подключаться к PostgreSQL одна из первых ссылок в гугле:
package main
import (
"database/sql"
"fmt"
_ "github.com/lib/pq"
)
const (
host = "localhost"
port = 5432
user = "postgres"
password = "your-password"
dbname = "calhounio_demo"
)
func main() {
psqlInfo := fmt.Sprintf(format: "host=%s port=%d user=%s "+
"password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
db, err := sql.Open("postgres", psqlInfo)
if err != nil {
panic(err)
}
defer db.Close()
err = db.Ping()
if err != nil {
panic(err)
}
fmt.Println("Successfully connected!")
}
Тут сразу бросается в глаза ряд очевидных проблем: откуда брать пароли и параметры подключения? Просто закоммитить их в код нельзя: они меняются. Ещё есть не совсем очевидные проблемы: соединение с базой проверяется только единожды, и не экранируется пароль.
Мы используем простую обвязку, которую написали сами. Вот так выглядит подключение у нас в коде:
package main
import (
"fmt"
"go.avito.ru/gl/psql/v4"
)
func main() {
db, err := psql.Connect(psql.WithConnectionWaiting())
if err != nil {
panic(err)
}
defer db.Close()
fmt.Println("Successfully connected!")
}
Такая обвязка решает проблему с паролями и подключением:
Параметры в прод доставляются через переменные окружения.
Пароли автоматически подтягиваются из Vault. Разработчик без одобренного доступа их не видит.
Происходит настройка пула драйвера.
Производится проверка доступности соединения.
Ещё пароль нужно периодически менять, но если изменить его на лету, приложение перестанет работать. Поэтому наша обвязка использует два логина: когда приложение задеплоено под одним пользователем, у второго меняется пароль. При следующей выкатке используемый логин поменяется. Так происходит ротация паролей.
Локальный и серверный PgBouncer
В Авито микросервисная архитектура — у нас больше 1,5 тысяч микросервисов и 500 инстансов PostgreSQL, у каждого из которых есть по 2–3 реплики. Все бэкенд-приложения на Go связаны с БД по сети при помощи пулеров соединения PgBouncer:
У каждого Go-приложения есть локальный (клиентский) PgBouncer, а у каждого инстанса или реплики PostgreSQL ещё по одному — серверному.
Локальный PgBouncer вместе с кодом находятся внутри k8s пода. Это сделано, чтобы приложение и другие контейнеры, например, pgeon на схеме, могли не ходить за каждым коннектом к северному PgBouncer по сети.
Серверный PgBouncer находится в lxc-контейнере в одном из датацентров. Там же и другие приложения: например, которые собирают метрики с БД — metric-scrapper на схеме. Клиентский PgBouncer по сети соединён с серверным.
Подключение к PostgreSQL
Давайте рассмотрим на диаграммах последовательности, как происходит подключение к PostgreSQL из наших Go-приложений:
В приложении вызываем db.Query().
Клиентский пул драйвера подключается к локальному PgBouncer.
Локальный PgBouncer подключается к серверному PgBouncer.
Серверный PgBouncer подключается к PostgreSQL.
PostgreSQL форкает процесс и возвращает PID и секретную строчку aka secret key.
PID и секретная строчка доходят до пула драйвера.
Драйвер отправляет запрос, который по цепочке доходит до базы данных.
Результат возвращается в код, где мы через
rows.Scan()
иNext()
вычитываем данные.Вызываем
rows.Close()
, чтобы освободить ресурсы и вернуть коннект в пул драйвера.
Подключение, форк и возврат PID с секретной строчкой (шаги 2–6) занимают много времени и ресурсов. На схеме видно, что непосредственно выполнение запроса происходит гораздо быстрее. Поэтому шаги 2–6 выполняются только при первом запросе.
Во всех последующих запросах мы используем уже существующее подключение и тот же процесс. Так выглядит повторный запрос на диаграмме последовательности:
Важно не забыть шаг 9 — rows.Close(), потому что иначе коннект не вернётся в пул драйвера и не сможет использоваться для других запросов, и это приведёт к отказу сервиса, если пул ограничен.
func missingClose() {
age := 27
rows, err :- db.Queryx("SELECT name FROM users WHERE age-?", age)
if err != nil {
log.fatal(err)
}
// defer rows.Close()
names := make([]string, 0)
for rows.Next() {
var name string
if err :- rows.Scan(&name); err != nil {
log.fatal(err)
}
names = append(names, name)
}
// Check for errors from iterating over rows.
if err := rows.Err(); err != nil {
log.Fatal(err)
}
}
Чтобы избегать такой ошибки, мы используем линтер sqlclosecheck. Он проверяет код на наличие rows.Close()
и предупреждает об его отсутствии на этапе сборки. В коде выше линтер подскажет ошибку missing_close.go:3:24: Rows/Stmt was not closed
.
Настройки клиентской и серверной части pgbouncer, которые мы используем в Авито
PgBouncer на клиентской части
pool_mode = transaction. В этом режиме соединение возвращается в общий пул после завершения транзакции.
pool_size = 5. Одновременно можно выполнять не больше 5 транзакций. Этого обычно достаточно: 5 бэкендов могут за 1 секунду выполнить 5000 транзакций при длине транзакции в 1 мс.
query_wait_timeout = 10s. При попытке выполнить транзакцию придётся ждать, пока не освободится один из бэкендов. Если время ожидания превысит 10 секунд, приложение получит ошибку
query_wait_timeout
.max_client_conn = 200. Максимально разрешённое количество подключений. По умолчанию 100, мы выставляем 200.
client_idle_timeout = 7200. PgBouncer закрывает соединения, которые простаивают дольше 2 часов.
Пул драйвера
Размер — 5, по размеру пула локального PgBouncer:
db.SetMaxOpenConns(5)
db.SetMaxIdleConns(5)
Время жизни и простоя коннекта — 5 минут. Эти значения должны быть меньше
client_idle_timeout
в PgBouncer, иначе Go-драйвер может попытаться выполнить запрос на соединении, которое уже закрыто локальным PgBouncer.
db.SetConnMaxLifeTime(5 * time.Minute)
db.SetConnIdleTime(5 * time.Minute)
PgBouncer на серверной части
У серверного PgBouncer настройки почти такие же, как и у локального.
pool_mode = transaction
.pool_size = 10
.query_wait_timeout = 15s
.
Работа с транзакциями
При работе с транзакциями мы поняли, что чем она короче — тем эффективнее используется база данных. Расскажу, почему так происходит и как добиться максимальной эффективности.
Короче транзакция — лучше
Это обычный код на Go, который работает с транзакцией. Мы открываем транзакцию, делаем запрос и коммитим.
func tx() error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec("update t set age = age + 1 where user_id = $1", 10)
if err != nil {
return err
}
row := tx.QueryRow("select * from t where user_id = $1", 10)
if row.Err() != nil {
return err
}
// ...
_ = tx.Commit()
return nil
}
Это работа того же кода на диаграмме последовательности. Обратите внимание, что локального PgBouncer нет на схеме. На самом деле он есть, но не влияет на результат и усложняет схему.
Реальное время работы БД — это лишь маленькие промежутки времени, которые на диаграмме отмечены как «Выполнение запроса». Всё остальное время база простаивает: пул занят, и выполняться ничего больше не может. Более того, строки которые эта транзакция изменила - заблокированы, и параллельные транзакции которым тоже надо изменить эти строки вынуждены дожидаться её завершения.
Заблокированные строки и пул освободятся, только когда мы сделаем коммит. Значит, нужно сократить время между началом транзакции и коммитом.
Вывод: чем короче транзакция, тем лучше. При короткой транзакции БД меньше простаивает в бесполезном ожидании, а значит вы можете выполнять больше запросов в единицу времени.
Способы сократить транзакцию
Чтобы сделать транзакцию короче, мы используем:
returning
.Общие табличные выражения (CTE).
Хранимые процедуры.
Давайте немного перепишем код выше:
func tx() error {
row := tx.QueryRow(
"update t set age = age + 1 where user_id = $1 returning *", 10)
if row.Err() != nil {
return err
}
// .....
return nil
}
Как вы видите, транзакция стала значительно короче. Значит, можно выполнять больше запросов в единицу времени.
Новое соединение внутри транзакции
Довольно распространённая ошибка — попытка выделить ещё один коннект из пула внутри транзакции. В коде это выглядит так:
package main
func tx() error {
tx, err =: db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err - tx.Exec(“update t set age = age + 1 where user_id - $1”, 10)
if err != nil {
return err
}
// тут ошибочное использование db вместо tx
row := db.QueryRow(“select * from t where user_id = $1”, 10)
if row.Err() != nil {
return err
}
_ = tx.Commit()
return nil
}
Такая ошибка может приводить к deadlock. Транзакция не может завершиться, потому что нужно выполнить запрос. А он не обрабатывается, потому что пул занят и не может выделить ему коннект.
Попытки создать новое соединение внутри транзакции можно отслеживать статическим анализатором. Но только в теории — его пока никто не написал.
У меня есть самописный линтер который я реализовал на одном из внутренних хакатонов в Авито. Он проверяет простейшие случаи. Но профита от него в реальных сценариях мало. Вызов db.Query может оказаться глубоко внутри — например, на десятом уровне вложенности в каком-нибудь репозитории.
Без линтера приходится либо отсматривать, нет ли db.Query()
внутри транзакции, либо обкладывать всё тайм-аутами.
Отмена запроса при отмене контекста
Отмена запроса при отмене контекста — не самая очевидная штука в Golang, которая удивляет тех, кто раньше не работал из Go с PostgreSQL. Рассмотрим на конкретном примере.
func getSomeData(ctx context.Context) error {
rows, err := db.QueryContext(ctx, "select * from t")
if err != nil {
return err
}
defer rows.Close()
// ...
return nil
}
Здесь мы в db.QueryContext()
передаём контекст, который приходит из хэндлера. Если хэндлер отвалится по таймауту, контекст отменится, а это приведёт к отмене запроса.
Давайте разберёмся, почему так происходит, почему мы в итоге отключили этот механизм и как с ним лучше работать.
Почему отменяется запрос при отмене контекста
Возможность отменять запущенный запрос есть наверное в каждой СУБД, это не является чем-то особенным. Этот механизм широко применяется в десктопных клиентах, таких как PgAdmin3/4, psql и DataGrip. Но то, что он применяется ещё и в драйвере go, было большим сюрпризом.
Вот как работает механизм отмены запроса на диаграмме состояния:
Вызываем
db.Query()
.Отменяем контекст.
Драйвер создаёт новое подключение к базе данных.
Процесс СУБД форкается.
Через новое подключение отправляется cancel-запрос для остановки выполнения ранее запущенного запроса.
Посылается сигнал к оригинальному процессу по PID с запросом на завершение.
Завершённый процесс отправляет ошибку с сообщением об отмене запроса пользователем.
Подробнее о том, как работает этот механизм со стороны СУБД, можно почитать в официальной документации PostgreSQL.
Почему мы отключили отмену запросов
Мы обнаружили очень высокую нагрузку на БД в сервисе объявлений и долго не могли понять, в чём дело. У нас были только логи, заваленные сообщениями об отменённых запросах. Нормальных метрик в PgBouncer тогда ещё не было — они появились только в версии 1.18.0, которая вышла недавно. Хотя даже и добавленных метрик мало.
Оказалось, механизм отмены запроса создавал огромную нагрузку на базу данных. Из-за того, что каждый раз необходимо форкать процесс (шаг 4) и посылать дополнительный сигнал (шаг 5), вся эта процедура занимает много времени.
А ещё периодически отменялся не тот запрос. Поскольку изначально этот механизм был придуман для IDE, никакой проверки на то, нужный ли запрос убивается, нет. Поэтому в быстрых приложениях с высоким RPS эта функциональность работает плохо.
В итоге мы отключили механизм отмены запросов в критических сервисах.
Как обойти отмену запросов в pgx
В Авито мы используем pgx/v3 (deprecated с 2020 года) и pgx/v4. Вот, как мы справлялись с отменой запросов там.
В pgx/v3 можно отключить механизм отмены запросов с помощью пары строчек в коде:
pgx.Config.CustomCancel = func(_ *pgx.Conn) error {
return nil
}
В pgx/v4 нельзя отключить механизм отмены запросов. На GitHub по этому поводу есть Issue#679. Там в том числе отметились инженеры Reddit, Adjust, Авито.
Поэтому чтобы обойти отмену запросов в pgx/v4, мы прокидываем пустой или изменённый контекст в запросы:
func getSomeData(_ context.Context) error {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
rows, err := db.QueryContext(ctx, "select * from t")
if err != nil {
return err
}
defer rows.Close()
// ...
return nil
}
Советы по использованию отмены запросов
Вот, что мы выяснили в процессе работы с отменой запросов, и что советуем вам:
Используйте механизм отмены запросов для OLAP-нагрузки.
Если используете отмену запросов для OLTP-нагрузки, увеличивайте таймаут.
Следите за метриками и нагрузкой при использовании отмены запросов.
Отслеживайте Issues в библиотеках.
Предыдущая статья: Как развивать внутренние сообщества с пользой для компании и людей