Русский
Русский
English
Статистика
Реклама

Оптимизация работы с PostgreSQL в Go от 50 до 5000 RPS

Привет, меня зовут Иван, и я делаю Авито Доставку. Когда пользователь покупает товар сдоставкой, мы показываем ему список отделений служб доставки сценами. Цена доставки может меняться ототделения котделению. Мы смотрим наобласть карты, где покупатель ищет товар и информацию пообъявлению, например, координаты продавца, вес и размеры товара. И навыходе показываем человеку список отделений садресами и ценой доставки вкаждое изних.


Входе разработки калькулятора цены доставки возникла такая задача: есть структура базы данных PostgreSQL и запрос кней отсервиса наGo. Нужно заставить всё это работать достаточно быстро. Витоге нам удалось поднять пропускную способность сервиса с50 до5000RPS и выявить пару нюансов приобщении сервиса сбазой. Обэтом и пойдёт рассказ.



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


Структура базы данных


Структура базы данных


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


Объём данных такой:


  • send: ~400 тысяч записей;
  • receive: ~160 миллионов записей.

SQL-запрос


SELECT r.terminal_id,       r.lat, r.lon,       r.tariff_zone_id,       r.min_term, r.max_termFROM   receive rINNER JOIN (   SELECT DISTINCT ON (s.provider) provider, tariff_id, s.tag_from_id, Point(s.lat, s.lon) <-> Point (:seller_lat, :seller_lon) AS dist   FROM   send s   WHERE       s.lat BETWEEN :seller_leftbot_lat AND :seller_righttop_lat       AND s.lon BETWEEN :seller_leftbot_lon AND :seller_righttop_lon       AND :now BETWEEN s.active_from AND s.active_until       AND s.max_weight > :weight AND s.max_height > :height AND s.max_length > :length AND s.max_width > :width AND s.max_declared_cost > :declared_cost   ORDER  BY provider, dist   ) AS s USING (tag_from_id)WHERE   r.lat BETWEEN :buyer_leftbot_lat AND :buyer_righttop_lat   AND r.lon BETWEEN :buyer_leftbot_lon AND :buyer_righttop_lon   AND r.max_weight > :weight AND r.max_height > :height AND r.max_length > :length AND r.max_width > :width AND r.max_declared_cost > :declared_costLIMIT :limit;

Чтобы запрос работал быстро, нужно создать пару индексов:


CREATE INDEX send_idx ON send(lon, lat, active_from, active_until);CREATE INDEX receive_idx ON receive(tag_from_id, lon, lat);

Можно спорить, какие поля нужно включать виндексы, а какие нет. Эту комбинацию мы подобрали черезнагрузочные тесты: это некий sweet spot, когда индекс получается относительно лёгким и одновременно даёт нужную производительность.


Порядок полей виндексах тоже важен. Например, долготу(lon) есть смысл ставить виндексе впереди широты(lat): Россия вытянута вширотном направлении, и долгота оказывается более селективна.


Структура БД и SQL запрос.


Сервис наGo


Винтересах статьи сервис будет максимально упрощён. Он всего лишь:


  • разбирает входные данные;
  • формирует запрос и шлёт его вБД;
  • сериализует ответ базы вJSON и отдаёт его.

В реальности он ещё считает цену наоснове tariff_zone_id, но суть та же: основная нагрузка ложится набазу данных, вGo происходит минимум действий. Построен сервис наобычном Server изnet/http и использует одну горутину назапрос.


Архитектура решения


Искать подходящие отделения и считать цены доставки будем набэкенде. Дляповышения надёжности и поюридическим причинам тарифы и методику расчёта цены доставки мы хотим хранить усебя, а не ходить заценой кподрядчикам. Тарифы будем хранить вунифицированном виде длявсех служб доставки.


Для расчёта цены используем отдельный микросервис сосвоим хранилищем.


В качестве хранилища мы рассматривали Elasticsearch, MongoDB, Sphinx и PostgreSQL. По результатам исследования выбрали PostgreSQL: он закрывает наши потребности и приэтом существенно проще вподдержке длянашей компании, your mileage may vary.


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


Тестовый стенд



Сервис развёрнут вKubernetes натрёх подах по500Мб. База развёрнута вLXC-контейнере с4ядрами и 16Гб памяти. Вкачестве connection pooler используется pgbouncer, развёрнутый вконтейнере сбазой.


Это достаточно стандартный дляАвито сетап. Вреальном сервисе был бы ещё один pgbouncer, развёрнутый внутри каждого пода ссервисом.


План запроса


Посмотрим, как исполняется запрос кбазе данных:



Взапросе осталась сортировка оптимизировать её впоиск минимума Постгрес не стал. Это не очень хорошо, но нанаших данных максимум может сортироваться около ста записей, обычно от20 до50. Кажется, сэтим можно жить.


Основные же затраты идут напоиск поbtree-индексу побольшой таблице.


Результаты в лоб


Пора уже запустить тест.



50RPS / 314мс для99-го перцентиля


Уже нанизких RPS появляются подозрительные пики вграфике времени отклика. Это видно насреднем графике, повертикальной шкале время вмиллисекундах. 70RPS сервис не держит совсем. Надо это оптимизировать.


Подход к оптимизации


Оптимизация это цикл изнескольких шагов:


  1. Определить цели и индикаторы. Чего хотим и как будем измерять успех.
  2. Создать тестовые данные. Внашем случае заполнить базу и сгенерировать ленту запросов ксервису.
  3. Добиться полной нагрузки одного изресурсов, увидеть узкое место.
  4. Расширить узкое место.
  5. Повторять додостижения целей.

Наши цели 200RPS минимум, лучше 500RPS. Индикаторы пропускная способность сервиса и время отклика.


Тестовые данные это важно


Мы старались помаксимуму использовать реальные данные. Использовали их, где получалось, длянаполнения базы, добавив шум. Длясоздания ленты запросов собрали реальные запросы спрода. Где это было невозможно, использовали генератор. Было ясно, что любые предположения, не основанные нареальных данных, будут снижать надёжность результатов тестов.


Изначально мы использовали ленту в20тысяч запросов. И довольно быстро удалось добиться такого результата:



Тут сервис держит 1000RPS при52.5мс. Всё красиво, кроме скачков времени отклика, но давайте попробуем потестировать ту же конфигурацию наленте в150тысяч запросов:



Уже на200RPS сервис заваливается. Запросы отваливаются потаймауту, появляются 500-ки. Оказывается, предыдущий тест врёт примерно в10раз попропускной способности.


Похоже, дляленты в20тысяч запросов использовалась лишь часть данных втаблицах. PostgreSQL смог всё закэшировать и работал быстро. Дляленты в150тысяч требовалось больше данных изтаблиц, в кэш они не поместились, и быстродействие упало.


Получается, не уделив достаточно внимания входным данным, легко испортить всё нагрузочное тестирование.


Переезд на pgx/v4


Скачки времени отклика награфиках выше намекают наналичие проблем вподключении сервиса кбазе.


Досих пор мы использовали библиотеку pgx третьей версии и не ограничивали размер пула соединений. Если уж оптимизировать эту часть, то давайте переедем начетвёртую версию и уже наней будем всё настраивать. Тем более, пронеё есть много хороших отзывов.
Переехали, ограничили пул до10соединений. Пробуем:



Стало лучше, но принципиально ничего не изменилось. Вчём дело? Смотрим метрики pgbouncerа:



Синий число активных клиентских соединений (cl_active), красные точки число клиентских соединений, которым не досталось серверного соединения (cl_waiting, правая шкала, снимается раз в30секунд).


Видно, что число активных соединений поднагрузкой катастрофически падает. Как выяснилось, так проявлялся один избагов в pgx/v4. Как мы искали длянего решение, я уже рассказывал встатье пропочинку pgx.


Откат на pgx/v3


На тот момент баг вpgx/v4 еще не был исправлен, и мы воспользовались воркэраундом: откатились натретью версию и отключили отмены запросов.



Сильно лучше не стало, но самые хорошие новости ждали нас вметриках pgbouncer:



Число активных соединений растёт домаксимума поднагрузкой здесь пул ограничен до12 и не падает доконца теста.


С этого момента мы стали собирать ещё и результаты закрытых тестов. Закрытый тест это когда задаётся не скорость подачи запросов, а общее число запросов всистеме. Это более щадящий режим, но он удобнее дляотладки производительности, т.к. даёт меньше шума.



130RPS при20параллельных запросах


В метриках контейнера сбазой мы увидели полку воперациях ввода-вывода:



Жёлтое это число операций чтения (растёт вниз). Мы выбрали всю квоту контейнера наоперации чтения, то есть нам удалось, наконец-то нормально нагрузить базу.


Масштабируем базу по диску


Проверим, является ли узким местом диск. Увеличиваем квоту контейнера в8раз, смотрим:



Открытый тест: 500RPS / 109мс



Закрытый тест: 745RPS


В закрытом тесте пропускная способность выросла со130RPS до745 почти линейный рост. Значит, мы действительно упираемся вдиск.


Оценим предел вертикального масштабирования. Снимаем сконтейнера ограничение наоперации ввода-вывода вообще:



Открытый тест: 3000RPS / 602мс



Закрытый тест (20инстансов): 2980RPS / 62мс


Заметим, что закрытому тесту явно не хватает 20параллельных запросов, чтобы нагрузить сервис. Мы съели вообще весь диск навсём сервере сбазой:



Зелёное число операций чтения (растёт вниз)


Конечно, впродакшене так себя вести нельзя: придут злые девопсы и будут нас ругать. Вообще, упираться вдиск очень не хочется: его сложно масштабировать, мы будем мешать соседям посерверу, а они будут мешать нам.


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


Масштабируем базу по памяти


Смотрим размеры наших таблиц и индексов:


SELECT      pg_size_pretty( pg_total_relation_size('send')) send,      pg_size_pretty(pg_indexes_size('send')) send_indexes,      pg_size_pretty( pg_total_relation_size('receive')) receive,      pg_size_pretty(pg_indexes_size('receive')) receive_indexes;

Видим 21Гб данных и 6Гб индексов. Это существенно больше полезного объёма данных, но тут Постгресу виднее.


Нужно подобрать конфигурацию базы, которая могла бы вместить 27Гб впамяти. ВАвито используются несколько типовых конфигураций PostgreSQL: они хорошо изучены, а параметры вних согласуются друг сдругом. Никто не запрещает кастомизировать конфигурацию подпотребности конкретного сервиса, но начинать лучше содной изготовых конфигураций.


Смотрим список конфигураций и находим вот такую:



8ядер, 64Гб памяти, effective_cache_size 48Гб


effective_cache_size это не настоящий размер кэша, это просто параметр планировщика, чтобы тот представлял, насколько памяти ему рассчитывать. Можно здесь хоть петабайт поставить размер кэша не увеличится. Просто планировщик будет предпочитать алгоритмы, которые хорошо работают сданными впамяти. Но всё же этот параметр втиповой конфигурации наши DBA выбрали не случайно: он учитывает разные кэши, доступные базе, втом числе кэш операционной системы. Вобщем, надо пробовать:



Открытый тест: 4000RPS / 165мс



Закрытый тест (100инстансов): 5440RPS / 106мс



Операции чтения (зелёное) нануле, операции записи (жёлтое) внезначительных количествах


Диск больше не является узким местом и, прямо скажем, бездельничает.



Утилизация CPU полностью загружены все 8ядер


Теперь мы упираемся впроцессор. Это хорошо: масштабировать его относительно просто, а мешать мы никому не будем.


Единственное, что смущает, большая разница врезультатах открытого и закрытого тестов. Это может свидетельствовать опроблемах ссоединениями. Смотрим метрики pgbouncer, и точно:



Опять cl_waiting подскочил. Вэтот раз, правда, cl_acitve (жёлтое) не падает, а cl_waiting (красные точки, правая шкала) не поднимается выше 12.


Ну, это просто ошибка вконфигурации базы. Размер пула должен быть 24, именно такой пул выставлен всервисе. А настороне базы он остался равным 12. Исправляем, смотрим:



Открытый тест: 5000RPS / 140мс
Закрытый тест (100инстансов): 5440RPS / 94мс


Вот теперь хорошо. Взакрытом тесте результаты те же, а вот воткрытом пропускная способность выросла с4000 до5000RPS. Стоит отметить, что нет никакого смысла использовать больше соединений, чем размер пула БД: это лишь портит производительность. Впрочем, это наблюдение заслуживает более пристального изучения.


Куда делась 1000 RPS


Итак, превышение размера пула сервиса надразмером пула БД вдва раза ведёт кпотере 20% пропускной способности (с5000RPS до4000RPS). Почему? И почему взакрытом тесте разница не видна?


Давайте разберём, что вообще происходит, когда сервис выполняет запрос через pgx. Вот мы посылаем некий запрос:


rows, err := h.db.QueryContext(ctx, `SELECT 1`)

h.db это пул соединений. Внутри QueryContext происходит Pool.Acquire(), который захватывает конкретное соединение длявыполнения нашего запроса. Соединений навсех не хватает, требуется синхронизация, длячего используется sync.Cond:


func (p *Pool) Acquire(ctx context.Context) (*Resource, error) {  //...  p.cond.Wait()  //...

sync.Cond внутри это пара атомиков, мьютекс и очередь насвязном списке (см. notifyList, который используется подкапотом уsync.Cond), то есть издержки насинхронизацию здесь минимальны. Горутина просто записывает себя вконец очереди и паркуется, ожидая, пока рантайм её разбудит. Ктому же всё это происходит настороне сервиса, который не является узким местом внашем случае.


Теперь представим, что пул сервиса больше, чем пул базы данных. Уpgbouncerа появляются 24клиентских соединения, но только 12серверных. Он вынужден жонглировать клиентскими соединениями, подключая их ксерверным поочередно. Это дорогая операция, т.к. нужно менять состояние серверного соединения. Вчастности, установить новые переменные, что требует общения сбазой через сокет попротоколу. И всё это происходит, внашем случае, вконтейнере сбазой, то есть мультиплексирование отъедает ресурсы убазы, которая и так является узким местом.


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


Разница результатов открытого и закрытого тестов


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


Судя покоду find_server(), pgbouncer не стремится любой ценой подключить соединение ксерверному. Нашлось свободное серверное соединение хорошо, подключим. Нет придётся подождать. Этакая кооперативная многозадачность, вкоторой соединения не очень хотят кооперироваться.


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


Пока запросов всистеме мало, как взакрытом тесте, это роли не играет: запросам хватает таймаута, чтобы дождаться своей очереди. Воткрытом тесте запросов всистеме вразы больше, они выстраиваются вочередь назахват соединения вpgx и тратят там большую часть таймаута. Назахват серверного соединения вpgbouncer времени не остается. Происходит таймаут и 500-яошибка.


Но это лишь правдоподобные рассуждения, хорошо бы их проверить. Когда-нибудь потом.


Запуск на холодную


Мы добились хорошей производительности встационарном режиме засчёт размещения данных впамяти. Но как поведёт себя сервис вситуации, когда данных впамяти нет, например, послеаварии?


Перезагружаем сервис и сервер сбазой, смотрим:



Разогнались до5000RPS за10секунд, примерно заминуту домаксимума. Значит, никакие механизмы прогрева кэша нам не нужны, можно сразу подавать боевой трафик.


Переезд pgx/v4, попытка номер два


Раз уж появился фикс длячетвёртой версии, надо его попробовать вделе. Обновляем библиотеку:



Открытый тест: 5000RPS / 217мс, 5300RPS / 645мс
Закрытый тест: 5370RPS / 43мс


По производительности примерно то же самое, что и втретьей версии. Разница втом, как сервис деградирует призаведомо запредельной нагрузке. Счетвёртой версией это происходит медленнее.


Подбор размера пула в сервисе


Как мы увидели, ограничение размера пула сервиса оказывает весьма существенное влияние напроизводительность. Ксожалению, часто пул не ограничивают вообще. Из-заэтого сервис держит меньшую нагрузку, чем мог бы, возникают мистические таймауты под штатной нагрузкой, а внекоторых случаях может серьёзно деградировать производительность базы данных.


Общее число соединений есть смысл выбирать винтервале отчисла ядер, доступных базе, доограничения начисло серверных соединений уpgbouncer (каким его выбрать вопрос дляDBA).


При числе соединений меньше числа ядер база остаётся незагруженной, т.к. вПостгресе каждое соединение обслуживается отдельным процессом, который максимум может нагрузить одно ядро. Если соединений больше, чем максимальное число серверных соединений, начнётся мультиплексирование.


Конечно, эти соображения пригодны, когда нет долгих транзакций и медленных выборок избазы, которые надолго занимают соединение. Если они увас есть, стоит подумать, как отних избавиться. Кроме того, опасно именно переключение соединений поднагрузкой. Задачи, работающие вразное время, например, сервис спиковым трафиком днем и ночной крон, могут использовать полный пул каждая: мультиплексирования будет немного.


Не забываем


Закрывать результат


Это обсуждали множество раз, но тем не менее. Даже если вдокументации кбиблиотеке написано, что закрывать Rows необязательно, лучше всё же закрыть самому через defer.


rows, err := conn.Query(...)if err != nil {    return err}defer rows.Close()  // лучше закрыть принудительноfor rows.Next() {    // ...}if rows.Err() != nil {    return err}

Как только внутри цикла поrows.Next() случится паника или мы сами добавим туда выход изцикла rows останется незакрытым. Незакрытый результат это соединение, которое не может быть использовано другими запросами, но занимает место впуле. Его придется убивать потаймауту и заменять нановое, а это долго.


Быстрые транзакции


Применительно кpgbouncer: медленные транзакции забивают серверный пул.


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


Особенно опасны незакрытые транзакции, а это ошибка, которую вGo сделать довольно легко. Если pool_mode установлен вtransaction, как это сделано унас, то незакрытая транзакция надолго занимает собой соединение, внашем случае надва часа.


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


Keepalive


Keepalive это фича, которую не стоит включать бездумно. Она полезна, когда входящих соединений мало, например, если квам ходят другие ваши сервисы и вы можете контролировать, сколько соединений они создают. Прибесконтрольном создании соединений подвысокой нагрузкой усервиса окажется множество незакрытых соединений. Каждое изних отъест свои 2-4Кб, и будет плохо.


Проверять гипотезы практикой


В вопросах производительности запросто можно получить противоположный ожидаемому результат. Слишком много факторов вмешиваются впроцесс. Любое своё предположение желательно проверять нажелезе и сцифрами.


TL;DR, или выводы


  1. Прокачали сервис от50 до5000RPS, не применяя никакой особой магии.
  2. Мультиплексирование соединений вpgbouncerе поднагрузкой зло.
  3. Использовать всервисе пул большего размера, чем вбазе данных вредно.
  4. Выработать привычку делать транзакции быстрыми и закрывать результаты БД.

Благодарности


Кроме меня надзадачей работали коллеги изДоставки: Кирилл Любаев, Александр Кузнецов, Алексей Власов.


И огромное спасибо всем, кто помогал:
Андрею Аксёнову заидеи, что гео-индексы здесь не нужны, что инты рулят и вообще, проще лучше.
Павлу Андрееву, нашему DBA-инженеру, затерпение и оптимизацию настороне PostgreSQL.

Источник: habr.com
К списку статей
Опубликовано: 05.11.2020 20:23:57
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании авито

Высокая производительность

Postgresql

Программирование

Go

Pgbouncer

Pgx

Optimization

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru