Работаем с PostgreSQL в Go. Опыт Авито

Привет! Меня зовут Дима Вагин, я бэкенд-инженер в Авито. Сегодня расскажу, как мы работаем с БД PostgreSQL из Go. Покажу, какие библиотеки и пулеры соединений мы используем для доставки в код параметров подключения и как мы их настраиваем. А ещё расскажу про проблемы, к которым приводит отмена контекста, и о том, как мы с ними справляемся.

a13ee424a75adc176adf747f60151559.jpg

Доставка паролей и параметров подключения в продакшн

Прежде чем подключаться к 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:

a697826441e9264f92b94aec7ab61392.png

У каждого Go-приложения есть локальный (клиентский) PgBouncer, а у каждого инстанса или реплики PostgreSQL ещё по одному — серверному. 

Локальный PgBouncer вместе с кодом находятся внутри k8s пода. Это сделано, чтобы приложение и другие контейнеры, например, pgeon на схеме, могли не ходить за каждым коннектом к северному PgBouncer по сети. 

9b6c7d80674844ed29432763814270d3.png

Серверный PgBouncer находится в lxc-контейнере в одном из датацентров. Там же и другие приложения: например, которые собирают метрики с БД — metric-scrapper на схеме. Клиентский PgBouncer по сети соединён с серверным.

Подключение к PostgreSQL

Давайте рассмотрим на диаграммах последовательности, как происходит подключение к PostgreSQL из наших Go-приложений:

  1. В приложении вызываем db.Query ().

  2. Клиентский пул драйвера подключается к локальному PgBouncer.

  3. Локальный PgBouncer подключается к серверному PgBouncer.

  4. Серверный PgBouncer подключается к PostgreSQL.

  5. PostgreSQL форкает процесс и возвращает PID и секретную строчку aka secret key. 

  6. PID и секретная строчка доходят до пула драйвера. 

  7. Драйвер отправляет запрос, который по цепочке доходит до базы данных.  

  8. Результат возвращается в код, где мы через rows.Scan() и Next() вычитываем данные. 

  9. Вызываем rows.Close(), чтобы освободить ресурсы и вернуть коннект в пул драйвера.

28e883709d6137ad33ceb2c6d74c4581.png

Подключение, форк и возврат PID с секретной строчкой (шаги 2–6) занимают много времени и ресурсов. На схеме видно, что непосредственно выполнение запроса происходит гораздо быстрее. Поэтому шаги 2–6 выполняются только при первом запросе.

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

68ddbbba712ee87d78aabc2ecc9dd385.png

Важно не забыть шаг 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 часов.

Пул драйвера

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 настройки почти такие же, как и у локального. 

Работа с транзакциями

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

Короче транзакция — лучше

Это обычный код на 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 нет на схеме. На самом деле он есть, но не влияет на результат и усложняет схему.

221d47d5b68d395cd74ae4b453e58806.png

Реальное время работы БД — это лишь маленькие промежутки времени, которые на диаграмме отмечены как «Выполнение запроса». Всё остальное время база простаивает: пул занят, и выполняться ничего больше не может. Более того, строки которые эта транзакция изменила — заблокированы, и параллельные транзакции которым тоже надо изменить эти строки вынуждены дожидаться её завершения.

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

Вывод: чем короче транзакция, тем лучше. При короткой транзакции БД меньше простаивает в бесполезном ожидании, а значит вы можете выполнять больше запросов в единицу времени. 

Способы сократить транзакцию

Чтобы сделать транзакцию короче, мы используем:

Давайте немного перепишем код выше:

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, было большим сюрпризом.  

Вот как работает механизм отмены запроса на диаграмме состояния:

  1. Вызываем db.Query().

  2. Отменяем контекст.

  3. Драйвер создаёт новое подключение к базе данных. 

  4. Процесс СУБД форкается.

  5. Через новое подключение отправляется cancel-запрос для остановки выполнения ранее запущенного запроса.

  6. Посылается сигнал к оригинальному процессу по PID с запросом на завершение.

  7. Завершённый процесс отправляет ошибку с сообщением об отмене запроса пользователем.

44e6abe69583713aa46c03172cc7eeae.png

Подробнее о том, как работает этот механизм со стороны СУБД, можно почитать в официальной документации 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
}

Советы по использованию отмены запросов

Вот, что мы выяснили в процессе работы с отменой запросов, и что советуем вам:

  1. Используйте механизм отмены запросов для OLAP-нагрузки.

  2. Если используете отмену запросов для OLTP-нагрузки, увеличивайте таймаут.

  3. Следите за метриками и нагрузкой при использовании отмены запросов.

  4. Отслеживайте Issues в библиотеках. 

Предыдущая статья: Как развивать внутренние сообщества с пользой для компании и людей

© Habrahabr.ru