Хабрахабр

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

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

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

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

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

sqlx

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

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

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

type Place struct { Country string City sql.NullString TelephoneCode int `db:"telcode"`
} var p Place
err = rows.StructScan(&p)

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

p := Place 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(...)
}

rows. Внутри драйвера мы получаем данные, накапливая их в буфер размером 4KB. Если буфера не хватает, то мы идём в сеть за оставшимися данными. 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

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

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

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, чтобы уникально идентифицировать внутренние объекты: строки, таблицы, функции и т.д.

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

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

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

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

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

Что делать?

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

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

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

Если от 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 становятся невалидными.

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

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

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

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

type DBStats struct { MaxOpenConnections int // Pool Status OpenConnections int InUse int Idle int // Counters WaitCount int64 WaitDuration time.Duration MaxIdleClosed int64 MaxLifetimeClosed int64
}

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

type ConnPoolStat struct { MaxConnections int CurrentConnections int AvailableConnections int
}

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

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).

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

Вот benchmark различных реализаций connection pooler'ов:

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

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

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

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

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

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

Transaction Pooling + Prepared Statements

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

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

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

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

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

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

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

Что делать?

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

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

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

Ведь пока транзакция живет, PgBouncer не забирает соединение. Еще один вариант — явно оборачивать каждый запрос в транзакцию. Итого 4 сетевых вызова на один запрос. Это работает, но, кроме многословности в нашем коде, мы вдобавок получаем большее количество сетевых вызовов: Begin, Prepare, Execute, Commit. 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, ...)

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

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

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

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

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

Но текущим запросом будет уже не тот, который мы хотели отменить изначально. Postgres выполнит команду и отменит текущий запрос в заданном процессе. Для этого можно задать функцию CustomCancel, которая не будет отменять запрос, даже если используется context. Из-за такого поведения при работе с Postgres вместе с PgBouncer безопаснее будет не отменять запрос на уровне драйвера. 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 до актуальной версии.
  • Будьте аккуратны с отменой запросов со стороны приложения.
Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть