Как я собрал на чистом Ruby мультиплексор поверх pipeline mode из libpq, обогнал классический пул соединений на 27% при вдвое меньшем числе коннектов — а потом обнаружил, что почти весь оставшийся CPU вообще не мой.

Откуда взялась идея

Всё началось с арифметики, которая не сходилась.

У меня было приложение на Falcon. Falcon работает на файберах, а значит, я могу спокойно держать две тысячи штук в полёте — собственно, ради этого всё и затевалось. И каждый из них хочет поговорить с Postgres.

С классическим пулом соединений математика жестокая и простая. Соединение занято эксклюзивно на всё время запроса. Запрос на localhost занимает, скажем, 200 микросекунд, из которых микросекунд сорок — реальная работа сервера, а остальное — round trip: syscall, сокет, ядро, сокет, syscall. То есть соединение большую часть жизни ждёт пакет. Если у вас 2000 файберов и 20 соединений, то 1980 файберов стоят в очереди за коннектами, которые в любую отдельно взятую микросекунду в основном простаивают.

Стандартный ответ — «увеличьте пул». Но каждое соединение с Postgres — это форкнутый бэкенд‑процесс со своей памятью. Переход с 20 коннектов на хост до 200 умножает и потребление памяти Postgres, и переключения контекста — ради решения проблемы, которая на самом деле не про пропускную способность. Она про задержку, которую вы ни с чем не совмещаете.

Второй стандартный ответ — pgbouncer. Он реально помогает: мультиплексирует много клиентских соединений на малое число серверных. Но мультиплексирует он на уровне транзакции и не убирает ни одного round trip. Файбер № 1 по‑прежнему отправляет запрос и ждёт ответа, прежде чем запрос файбера № 2 попадёт в это соединение. Провод в промежутке всё так же простаивает.

А потом я прочитал документацию libpq про pipeline mode, который существует с PostgreSQL 14 и которым, насколько я мог судить, в Ruby не пользовался ровным счётом никто.

Pipeline mode позволяет отправить запрос № 2 до того, как придёт ответ на запрос № 1. Несколько операций расширенного протокола в полёте на одном соединении, результаты возвращаются строго по порядку. И арифметика внезапно переворачивается: одно соединение может нести дюжину параллельных запросов, а round trip перестаёт быть мёртвым временем — он перекрывается с остальными одиннадцатью.

Это ровно та форма, которую имеет Async‑приложение. Много независимых файберов, у каждого маленький независимый запрос, и все готовы подождать кооперативно. Рантайм и протокол были созданы друг для друга, и никто их не познакомил.

Так появился pg_pipeline.

Что не подошло

connection_pool и пул ActiveRecord. Оба корректны, и оба — ровно та модель, от которой я пытался уйти: одно соединение, один вызывающий, эксклюзивно на всё время. Пайплайнить они не могут, потому что в API просто нет места, где можно выразить «вот тебе ещё запрос, не жди первый».

pgbouncer. Настоящая инфраструктура, настоящая польза, не та ось. Он уменьшает количество бэкендов, которые вам нужны; он ничего не делает с тем, какую долю времени соединение проводит в ожидании. Плюс это ещё один процесс, ещё один конфиг и ещё одна штука, которая разбудит вас в три ночи.

Обёртки в духе async-postgres. Они делают сокет file‑aware, так что блокирующий запрос отдаёт управление реактору, а не блокирует тред. Это нужно и хорошо, и ruby-pg теперь умеет это нативно. Но это по‑прежнему один запрос на соединение за раз.

Написать на C. Очевидный «серьёзный» ответ — C‑расширение, которое напрямую дёргает PQsendQueryParams/PQgetResult. Я отказался от этой идеи ещё до первой строчки кода и ниже защищу это решение цифрами — потому что оно оказалось одной из немногих архитектурных ставок, которую профайлер в итоге оправдал.

Мне нужен был control plane: чистый Ruby, вся работа с проводом остаётся внутри libpq, а гем умеет держать несколько юнитов в полёте на соединение и раздавать результаты правильным файберам.

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

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

  • Только control plane. Ноль строк C. Протоколом занимается libpq. Гем занимается маршрутизацией, порядком и пробуждением файберов. Если гем однажды окажется узким местом — это информация, а не провал.

  • Мультиплексирование включается для операции, а не глобально. Файберы делят соединение только для тех операций, которые не могут протечь состоянием друг в друга. Всё остальное получает эксклюзивный коннект.

  • Один Sync на юнит, и это не обсуждается. Подробнее ниже. Стоит одного лишнего протокольного сообщения на запрос и покупает всю историю с изоляцией ошибок.

  • Никакой отмены на мультиплексированном пути. CancelRequest в Postgres отменяет запрос в голове соединения, а на мультиплексированном коннекте вы не знаете, чей он. Значит — никакой отмены. Лучше написать это в доках, чем отгрузить пользователю грабли.

  • Никогда не будить ждущий файбер изнутри цикла соединения. Пробуждения откладываются до планировщика. Файбер, поднятый посреди drain‑цикла, может заново войти в драйвер, а реентерантность в месте, которое на полпути демультиплексирует протокольный поток, — это не тот баг, который вы хотите отлаживать в два часа ночи.

  • Публичный API остаётся скучным. db.query(sql, params). Если мультиплексирование видно вызывающему — я провалился.

Как это выглядит

Вот и вся поверхность. Интересное — всё то, чего второй вызов не даст сделать на разделяемом соединении.

Архитектура в одном абзаце

Каждое соединение — это ConnectionDriver, которым владеет ровно один файбер (owner loop), разгребающий очередь событий. Второй файбер ждёт на сокете и постит :readable. Когда вызывающий отправляет запрос, тот попадает в ограниченную очередь и постится событие :requests (схлопнутое по флагу, так что сотня submit'ов между двумя тактами реактора порождает одно событие). Owner прокачивает всё, что накопилось: PQsendQueryParams на каждый запрос, потом PQsendPipelineSync, потом один PQflush на весь батч. По :readable он дренирует: PQisBusy / PQgetResult, пока соединение не заблокировалось бы, сопоставляя результаты с запросами в порядке FIFO и закрывая каждый на его Sync‑границе. Ждущие файберы паркуются через Fiber.scheduler.block и будятся через unblock, который Async‑планировщик откладывает на следующий такт реактора.

Технические находки

1. Почему Sync обязан быть на каждый юнит и почему это цена всей затеи

В pipeline mode сообщение Sync — это барьер. Всё между двумя Sync'ами — одна область ошибки: если запрос падает, каждая последующая операция до следующего Sync отбрасывается с PGRES_PIPELINE_ABORTED.

Такое поведение идеально, если батч принадлежит одному вызывающему — именно оно делает неявную транзакцию неявной. И оно катастрофично, если батч принадлежит файберам, которые друг о друге никогда не слышали. Опечатка в SQL файбера № 7 молча убила бы запросы файберов с № 8 по № 20.

Значит: один Sync на юнит. Это стоит лишнего раунда бухгалтерии в протоколе и одного дополнительного результата PGRES_PIPELINE_SYNC на запрос. В итоговом профиле send_pipeline_sync — это 0,14% CPU. Погрешность округления в обмен на свойство, которое вообще делает мультиплексирование безопасным.

Отсюда выпадает приятный инвариант, который позже стал моей любимой проверкой на вменяемость: завершённый юнит даёт ровно три протокольных результата — данные, nil‑границу запроса и Sync. Когда я наконец повесил на это счётчик под трафиком, похожим на продовый, он показал results per unit = 3.000. Не 2,998. Ровно три. Мало что успокаивает так, как протокольная модель, которая сходится в целых числах.

2. Гард на нейтральность сессии — и день, когда выяснилось, что он дороже запроса

Если файберы делят соединение, то SET search_path от одного из них отравляет всех остальных. Поэтому разделяемый путь закрыт гардом: внутрь пускаются только session‑neutral операции. SELECT/INSERT/UPDATE/DELETE/MERGE/VALUES/WITH, никаких множественных стейтментов, никакого set_config, никакого currval, никаких сессионных advisory‑локов, никакого SELECT ... INTO TEMP.

Сделать это правильно означает, что нельзя просто прогнать регуляркой сырой SQL: точка с запятой внутри строкового литерала — не разделитель стейтментов, а set_config( внутри комментария — не вызов. Поэтому гард маскирует: он идёт по SQL, забивает пробелами строковые литералы, закавыченные идентификаторы, комментарии и dollar‑quoted тела, а потом валидирует скелет.

Проход маскирования был написан ради корректности и никогда не измерялся. Когда я его наконец измерил, одна некэшированная SQL‑строка стоила 53 микросекунды и 331 аллокацию.

Для контекста: на 30 тысячах запросов в секунду весь бюджет CPU на запрос — около 33 микросекунд. Валидация запроса стоила дороже его выполнения.

Виноваты были две строки. Маскер делал source.byteslice(index, 2) на каждый байт, чтобы подсмотреть двухсимвольные токены, — свежая двухбайтовая String на каждый байт входа. А проверка на множественные стейтменты делала code.each_char.with_index, то есть один объект String на символ, и собирала позиции всех точек с запятой, хотя ей всегда нужна была только первая.

Починка помогла. Но настоящее исправление пришло, когда я заметил кое‑что про сам маскер:

Для SQL, в котором нет ни кавычки, ни комментария, ни dollar‑quoted тела, маскирование — тождественное преобразование.

То есть: практически для любого параметризованного запроса, который реально отправляет приложение, весь дорогой проход выдаёт побайтово идентичную копию своего входа. Обнаружить это одной регуляркой стоит 0,3 микросекунды. Маскер стоит 39.

Тут есть тонкость, из‑за которой это единственное безопасное сокращение, и её стоит проговорить, потому что я едва не ошибся в другую сторону. Нельзя сначала проверить запрещённые паттерны против сырого SQL и пропустить маскирование, если ничего не совпало: маскирование заменяет символы пробелами, а пробелы могут создать совпадение, которого не было. nextval/* c /('s') не матчится на \bnextval\s\(, а nextval ('s') — матчится. Маскирование монотонно не в ту сторону. Единственный корректный пропуск — доказать, что маскировать нечего.

Итоговые цифры, проверенные против старой реализации на 6138 дифференциальных кейсах, включая 3000 зафазженных входов, ноль расхождений:

некэшированная проверка гарда:  53,6 µs -> 2,25 µs
аллокации:                         331 -> 4

И ещё одна вещь, на которую я забил: на 2048 записях кэш вердиктов делал cache.clear. Не вытеснение — полную зачистку, так что каждый последующий стейтмент разом оплачивал полный пересчёт. Теперь это одноэлементное FIFO‑вытеснение. Вот в этом и разница между кэшем и периодическим обрывом производительности.

3. Отложенные пробуждения, или: не резюмьте файбер изнутри протокольного цикла

Первая версия будила ждущие файберы через Async::Notification на каждый запрос. Оно работало, и оно аллоцировало координационный объект на запрос, и оно светилось в каждом профиле.

Замена — напрямую Fiber.scheduler.block / unblock, где токеном‑блокировщиком служит сам объект запроса. Планировщик Async кладёт разблокированный файбер в ready‑список селектора, а не передаёт ему управление немедленно, — и это ровно то свойство, которое нужно драйверу: drain‑цикл может закрыть десять запросов подряд, и ни один из их файберов не побежит (и, возможно, не войдёт заново в драйвер) до конца цикла.

Это единственный инвариант, на который опирается весь дизайн, и его стоит явно проговорить в собственной документации, если вы строите что‑то похожее: внутри циклов pump и drain ничто не yield'ит. Каждый вызов libpq — это биндинг с префиксом sync_, который никогда не трогает планировщик; каждое пробуждение отложено. Нарушьте инвариант — и получите гонку по состоянию libpq, которая проявится как испорченный поток результатов сильно позже того изменения, которое её вызвало.

4. Археология аллокаций, включая два мифа, которые пришлось убить

Я пошёл искать объекты, аллоцируемые на запрос. Часть находок оказалась настоящей.

Ключевые аргументы через Class#new аллоцируют Hash. Это меня удивило. Ключевые аргументы обычного руби‑метода не аллоцируют ничего — компилятор передаёт их через стек. Но Class#new — это C‑функция, и проброс ключевых аргументов через неё материализует Hash. Request.new(sql: sql, params: params) — это два объекта; позиционный конструктор — один.

Request.new(sql:, params:)   2,00 объекта/вызов
positional / allocate+init   1,00 объекта/вызов

Выражение в значении аргумента по умолчанию вычисляется на каждом вызове. def query(sql, params = []) аллоцирует свежий Array каждый раз, когда вы зовёте метод без параметров. А потом snapshot_params мапил этот пустой Array в другой пустой Array. Два объекта на беспараметрический запрос, оба — чистые потери, оба лечатся замороженной константой.

Количество ivar'ов у объекта меняет его размерный класс. Двенадцать переменных экземпляра кладут Request в пул на 160 байт. Восемь — в пул на 80.

12 ivars -> 160 байт
 8 ivars ->  80 байт
 3 ivars ->  40 байт

А часть того, что я «нашёл», оказалась неправдой — и это более полезная половина раздела, потому что оба мифа широко тиражируются.

Миф: when CONSTANT_ARRAY аллоцирует. Не аллоцирует. Я намерил ноль на Ruby 3.2, мне сказали, что в 3.4 всё иначе, и вместо обмена бенчмарками я скачал исходники 3.4.3. В compile.c, в ветке NODE_SPLAT функции when_splat_vals, эмитится splatarray Qfalse, а в vm_insnhelper.c vm_splat_array с флагом false возвращает тот же массив без дублирования. Ноль аллокаций в обеих версиях. (А вот что действительно аллоцирует, так это when [1, 2, 3] с инлайн‑литералом — там duparray.)

Выписывать статусы явно всё равно стоит, но совсем по другой причине: when *CONST обходит массив через дженерик‑путь === и в 2–4 раза медленнее, чем многозначный when. Правильный вывод, неправильный механизм — а если вы отгрузите неправильный механизм в чейнджлог, то потом убьёте день на охоту за выигрышем в аллокациях, которого там никогда и не было.

Миф: Array#shift — это O(n), поэтому горячей очереди нужен ring buffer. CRuby реализует shift сдвигом указателя. Замер на установившемся shift+push:

size=4      77 нс/оп
size=64     75 нс/оп
size=256    80 нс/оп
size=1024   81 нс/оп

Ровно. Ring buffer купил бы мне новый класс ошибок на единицу в обмен на ничего.

Тем же способом я убил и одну собственную идею. Я был уверен, что схлопывание восьми регулярок запрещённых паттернов в один Regexp.union будет быстрее. Оно в 2,3 раза медленнее — Onigmo не оптимизирует восьмиходовую альтернацию. Сработал же односимвольный префильтр: каждый паттерн заякорен либо на (, либо на слово into, так что SQL, где нет ни того ни другого, может пропустить все восемь. 1,43 µs → 0,23 µs.

5. Сортируйте ветки when по частоте, а не по значимости в протоколе

Case‑выражение в drain‑цикле было написано в том порядке, в котором статусы перечислены в документации протокола. То есть статус, который реально происходит, — PGRES_TUPLES_OK — проверялся седьмым.

текущий порядок,    TUPLES_OK: 208 нс    SYNC:  61 нс   -> 269 нс/юнит
успех первым:                   60 нс          114 нс   -> 174 нс/юнит
TUPLES_OK отдельно,
затем SYNC, затем остальное:    61 нс           84 нс   -> 145 нс/юнит

Бесплатно — и переживёт ревью только если оставить комментарий, объясняющий, почему константный массив и инлайн‑список намеренно дублируют друг друга.

6. Метрика, которая переосмыслила всё

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

  • units_per_readable — завершённых запросов на одно пробуждение реактора.

  • flush_calls_per_unit — явных флашей на запрос.

Первое отвечает на единственный вопрос, который вообще имеет значение для пайплайнящей библиотеки: пайплайн реально заполняется? Если там 1.0 — каждый запрос платит полное ожидание сокета плюс круг планировщика, пайплайн не делает ничего, и никакая микрооптимизация Ruby не сдвинет пропускную способность. Если там 8 — одно пробуждение амортизировано на восемь запросов, и вы в том режиме, ради которого всё проектировалось.

Под насыщенным бенчмарком там 7,9, а flush_calls_per_unit — 0,167, то есть примерно шесть запросов на флаш.

Хочу честно рассказать, как я этой цифрой воспользовался, — потому что я воспользовался ею, чтобы публично ошибиться. PQflush занимал 2% общего CPU, крупнейший одиночный вызов libpq в профиле, и я уверенно поставил диагноз: «один флаш на запрос, надо коалесцировать отправки перед флашем». Замер сказал обратное: флаши уже батчились шесть к одному, а механизм коалесцирования, который я собирался строить, уже существовал — флаг request_event_pending делал ровно это, слоем выше, и именно поэтому отношение равнялось 0,167, а не 1,0.

Настоящее объяснение пришло из деления доли CPU на количество вызовов:

sync_flush          10,14 µs/вызов
consume_input        4,96 µs/вызов
send_query_params    0,50 µs/вызов
is_busy              0,31 µs/вызов
sync_get_result      0,16 µs/вызов
send_pipeline_sync   0,10 µs/вызов

Всё, что не является системным вызовом, стоит доли микросекунды. Весь оставшийся вес libpq — это два syscall'а: запись на ~10 µs и чтение на ~5 µs. (Десять микросекунд на запись 800 байт — это loopback TCP внутри контейнера: путь приёма исполняется в контексте отправителя, плюс seccomp и spectre‑митигации на каждом входе в ядро.)

Что полностью переформулирует задачу оптимизации. Стоимость драйвера пропорциональна пробуждениям реактора, а не запросам. Руби‑код между этими пробуждениями уже дешевле, чем системные вызовы, которые его обрамляют. Это совсем другое предложение, нежели «надо бы срезать аллокации».

Честная история про производительность

Бенчмарк: Falcon, 4 форка, эндпоинт, который делает один индексный SELECT по случайному id и возвращает JSON. oha, 60 секунд, конкурентность 1000, профайлер не подключён. Pipeline‑вариант: 4 соединения на воркер, 16 всего. Direct‑вариант: классический пул, 8 на воркер, 32 всего.

                        pipeline      direct
запросов/сек              19 775      15 603     +26,7%
запросов за 60 с       1 187 493     937 071
соединений                    16          32
средняя задержка         50,5 мс     64,0 мс
p50                      47,1 мс     59,9 мс
p90                      75,0 мс     86,6 мс
p99                     108,3 мс    120,7 мс

Вдвое меньше соединений, на 27% больше пропускной способности, и каждый перцентиль вплоть до p99 лучше. Последнее важнее, чем цифра пропускной способности: это не случай размена задержки на объём — та же работа завершается быстрее и её при этом больше.

Профиль CPU объясняет, откуда это берётся. Direct‑вариант тратит 12,8% своего CPU внутри exec_params — один жирный блокирующий вызов, в котором файбер сидит на round trip'е:

                        pipeline    direct
CPU в libpq                 7,1%     14,0%
CPU в async/реакторе       15,4%     18,0%
CPU в геме                  8,6%         —
весь путь до БД (гем+pg)   15,8%     14,0%

Pipeline‑вариант размазывает ту же работу по более мелким async‑вызовам общей суммой 7,1%, тратит примерно на 1,6 пункта больше на весь путь до БД (руби‑оркестрация — это цена) и возвращает это с процентами, потому что файберы не припаркованы на сокете, а реактору меньше работы (15,4% против 18,0%).

Размер выигрыша зависит от того, сколько работы приходится на запрос. Под профайлером, который добавляет CPU каждому запросу, и на меньшей конкурентности разрыв сжимался до +9,5%. Без профайлера на конкурентности 1000 — это +26,7%.

Это направление — самый интересный продуктовый факт, который у меня есть: чем тяжелее нагрузка, тем больше выигрывает пайплайнинг. Что логично: преимущество — это перекрытое ожидание, и чем больше есть с чем перекрывать, тем больше можно выиграть. Эндпоинт «один запрос — один JSON» близок к худшему случаю для такого дизайна, и он всё равно выигрывает 27%.

Есть одна цифра, которая идёт в другую сторону, и это честная цена дизайна:

p99.9                    168,7 мс    226,5 мс
p99.99                 1 181,6 мс    516,0 мс
самый медленный        1 604,9 мс    570,1 мс

До p99.9 пайплайн впереди. В последней сотой доле процента он в два‑три раза хуже. Это head‑of‑line blocking, видимый на продовых данных: результаты на пайплайненном соединении приходят по порядку, так что один невезучий медленный запрос держит всё, что стоит за ним в этом соединении. У классического пула такой связности нет — медленный запрос блокирует ровно одного вызывающего.

Я предпочту это напечатать, а не спрятать. Это размен, на который идёт архитектура, он задокументирован, и именно поэтому README советует понизить лимит in‑flight или взять отдельный клиент, если вы мешаете быстрые и медленные запросы на одном соединении.

И всё вышесказанное — на localhost, где round trip измеряется микросекундами. На round trip'е в 10 мс — а именно так выглядит «база в другой зоне доступности» — преимущество уже не 27%. Оно на порядок больше, потому что десять миллисекунд ожидания теперь несут десятки запросов вместо одного.

Где на самом деле потолок (спойлер: не в моём геме)

Вот часть, которую я не ожидал, что буду писать.

После всей оптимизации выше гем занимает 8,6% CPU процесса. Не 8,6% какой‑то подсистемы работы с БД — 8,6% всего, что делает приложение. Даже если бы я переписал его на вручную вылизанном ассемблере и он стал бы бесплатным, потолок был бы +9% пропускной способности.

Куда же уходит остальное? Я профилировал аллокации, и ответ однозначен:

атрибуция аллокаций (% сэмплов аллокаций, pipeline-вариант)

строковые операции над заголовками
  (downcase/split/partition/match/byteslice)                   58,2%
фрейминг HTTP/1                                                 6,2%
Class#new / Array.new                                           5,9%
rack env + Headers#                                             5,7%
PG::Result#each + Enumerable#first                              5,2%
ContentEncoding + Zlib                                          2,7%
JSON                                                            1,8%
pg_pipeline (всё целиком)                                       1,1%

58% всех объектов, которые аллоцирует это приложение, — это манипуляции строками HTTP‑заголовков. Нагрузочный генератор шлёт одни и те же заголовки на каждом запросе, а стек парсит их заново с нуля каждый раз: String#downcase — 11,2%, String#split — 10,3%, Enumerable#partition — 6,7%, Regexp#match — 6,3%.

221 объект на запрос. Тринадцать‑шестнадцать из них — мои.

Профиль CPU рассказывает ту же историю с другой стороны. Protocol::HTTP::ContentEncoding присутствует в стеке в 42,6% сэмплов; путь чтения запроса — в 39,3%. EPoll#io_write — запись HTTP‑ответов — это 7,1%, сравнимо со всем libpq. Zlib — 7,9% self time, чуть больше, чем весь мой гем, и с этим связана отдельная история (см. ниже).

Я буду точен в том, что это значит и чего не значит, потому что вывод здесь не «Falcon медленный», и я не хочу, чтобы меня так процитировали. Falcon делает реальную работу: парсинг HTTP действительно тяжёл по строкам, и за это платит любой сервер на любом языке. Цифры говорят кое‑что более узкое и более полезное:

На эндпоинте с маленьким payload'ом и высоким RPS сервер приложений доминирует в бюджете CPU настолько, что оптимизация драйвера БД — это шум измерения.

На 20 тысячах RPS с запросом в 100 микросекунд рабочая нагрузка — это HTTP‑слой, а база данных — сноска. Это не критика сервера; это констатация того, какие из моих оптимизаций вообще могли иметь значение.

Отсюда же следует конкретное правило для всех, кто занимается подобным: вы не можете измерить драйвер БД через HTTP‑бенчмарк. Изменение, стоящее 0,5% CPU гема, — это 0,04% процесса. Никакое количество прогонов его не проявит. Я доказал это себе трудным путём: отгружал корректные оптимизации, гонял бенчмарк и трижды подряд получал «в пределах шума». Правильный ответ — два бенчмарка: один через HTTP для продуктовых утверждений, второй вообще без HTTP для решений о коде, где меряются CPU‑секунды на миллион запросов, а не запросы в секунду.

Грабли, на которые я наступил

Ваш профайлер прятал сборщик мусора

В каждом профиле, который я снимал, стояло ignore_gc: true. Разумный дефолт: время GC, размазанное по всем фреймам, делает атрибуцию бесполезной. Он же означал, что вся моя работа по аллокациям была невидима для инструмента, которым я её оценивал.

Когда я наконец это выключил: GC — 8% CPU. Не те ~1%, которые позволили бы закрыть тему, и не 15%, которые сделали бы это главным событием.

Уточняющий замер оказался лучше заголовка. Фиксированные 200 000 запросов на одном воркере:

                        pipeline    direct
время GC                3425 мс    3878 мс
GC на запрос            17,1 µs    19,4 µs
мажорных сборок               6         13
аллокаций/запрос            221        217

Pipeline‑вариант тратит меньше абсолютного времени GC, хотя аллоцирует незначительно больше. А его доля GC выше (8,18% против 7,39%) исключительно потому, что он сжал знаменатель: те же 200 000 запросов он сделал за 25,7 секунды вместо 30,9.

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

Gzip, который вы выключили, всё ещё включён

Обвязка бенчмарка вырезала HTTP_ACCEPT_ENCODING из Rack env, метаданные говорили gzip_disabled: true, а каждый профиль показывал Zlib на 8% CPU.

Две причины, и на обе легко напороться. Protocol::HTTP::ContentEncoding — это middleware снаружи Rack‑адаптера: он читает настоящие заголовки запроса, а не env, который отредактировала ваша middleware. А oha построен на reqwest, который сам добавляет Accept-Encoding: gzip независимо от того, передавали вы заголовок или нет.

Однострочник, который закрывает вопрос:

curl -sD- -o /dev/null http://host:3000/endpoint | grep -i content-encoding

Проверяйте отрицание. «Я это выключил» — гипотеза о системе, как и всё остальное, во что вы верите про свой бенчмарк.

nil.frozen? — true, и 1.frozen? тоже

Мой худший баг за этот цикл, и он прекрасен.

Конструктор на горячем пути должен был доверять тому, что вызывающий уже заморозил SQL‑строку, и я написал то, что выглядело как защитное утверждение:

nil.frozen? — это true. 1.frozen? — это true. :sym.frozen? — это true. Так что Request.build(nil, params) радостно сохранял nil в качестве SQL, тогда как соседний конструктор с ключевыми аргументами сохранил бы "". Над этим методом у меня стоял комментарий, утверждавший, что он теперь „безопасен для любого места вызова“.“»

В проде оно ни разу не выстрелило, потому что единственный вызывающий всегда передавал замороженную String. Спеки не поймали, потому что я протестировал обе ветки тернарника — двумя разными строками. Branch coverage — это не domain coverage, а проверка типа, спрятавшаяся внутри проверки на frozen, — ровно та форма бага, с которой юнит‑тесты справляются хуже всего.

Ваш фаззер тестирует только то, что вы предположили

Я переписал выбор драйвера, чтобы избавиться от аллокации, заменив (rr + offset) % size на «вычесть, если больше». Корректно для rr < size, что гарантирует пул.

Метод публичный. И он некорректен для rr >= size: индексируется за конец массива и вызывает метод на nil.

Я его фаззил. Две тысячи случайных пулов, случайные нагрузки, случайные курсоры — сгенерированные как rand(0...size). Мой фаззер унаследовал ровно то предположение, которое сломала оптимизация. Всплыло это только когда я сел писать спеку и потянулся за rr=7, size=1 как за очевидно дурацким входом.

Генерируйте входные данные, которые исключают ваши инварианты. Именно там инвариант либо держится, либо оказывается привычкой.

Оптимизируйте байт, а не объект

Профайлеры аллокаций считают объекты. Частота минорного GC определяется израсходованными слотами, а объект в 160 байт берётся из другого размерного пула, нежели объект в 40. Профиль объектов, говорящий «1,1% аллокаций», и GC, говорящий «ты забил nursery», отвечают на разные вопросы. Стоит помнить, прежде чем заключать, что жирный объект дёшев, раз он такой один.

Чем это не является

Это не пулер соединений. Он не сокращает количество ваших бэкендов так, как это делает pgbouncer. Он повышает утилизацию тех соединений, которые у вас есть.

Никакой отмены на разделяемом пути. Postgres отменяет запрос в голове соединения, а на мультиплексированном коннекте это чей‑то чужой запрос. Таймаут возвращает управление вашему файберу; юнит остаётся в пайплайне, пока сервер на него не ответит.

Head‑of‑line blocking реален, и дизайн надо строить вокруг него. Результаты идут FIFO, и это видно в экстремальном хвосте бенчмарка: лучше классического пула везде до p99.9 и в два‑три раза хуже за ним. Один медленный запрос задерживает всё, что стоит за ним в этом соединении, а маршрутизация балансирует по глубине очереди, а не по ожидаемой стоимости. Меры противодействия скучны и эффективны: понизьте лимит in‑flight, либо отдайте медленным запросам отдельный клиент, либо гоняйте их на закреплённой сессии.

Не для session‑heavy нагрузок. Если ваше приложение — это в основном SET, временные таблицы, LISTEN и длинные транзакции, то почти всё уйдёт на эксклюзивные соединения, и вы не приобретёте ничего, кроме зависимости.

libpq ≥ 17 рекомендуется, но не обязателен. Ниже 17 нет PQsendPipelineSync, так что PQpipelineSync связывает барьер с флашем, и драйвер не может батчить записи через всплеск отправок. Корректность идентична, а амортизация round trip'ов — то есть весь смысл пайплайнинга — работает и на libpq 14. Ограничена только локальная пропускная способность. Драйвер это детектит и один раз говорит об этом при старте.

По‑прежнему ноль строк на C. Я неоднократно испытывал соблазн. Решил профиль: PQisBusy и PQgetResult вместе — это 1,9% CPU, и нативный drain‑цикл мог бы отыграть половину. Один процент — в обмен на build‑матрицу C‑расширения, его поверхность memory safety и его отлаживаемость, в геме, чья главная фишка в том, что он control plane. Измерение не просто не оправдало расширение — оно задним числом оправдало ограничение, которое я записал ещё до того, как у меня появились хоть какие‑то измерения.

В заключение

Я собирался сделать доступ к Postgres быстрее в файберном приложении. Это получилось: +27% пропускной способности на самом невыгодном из возможных эндпоинтов, вдвое меньше соединений, лучшая задержка на каждом перцентиле до p99 — и куда больший отрыв по мере роста задержки до базы.

Но то, что я на самом деле заберу с собой, — методологическое, и оно слегка отрезвляет.

Почти каждую оптимизацию, в которой я был уверен, пришлось проверять, и треть из них оказалась неверной. when *CONST не аллоцирует. Array#shift не O(n). Regexp.union медленнее цикла. Флаш происходил не на каждый запрос. Коалесцирование, которое я предлагал построить, уже существовало. Проверка на frozen, которую я написал ради безопасности метода, заставила его принимать nil в качестве SQL. Проход маскирования, который я ни разу не измерил, стоил дороже запроса, который он валидировал.

Выжившие выжили потому, что рядом стояла цифра, способная их убить, — и в четырёх случаях убившая.

А потом профайлер показал совсем в другую сторону: на 58% аллокаций в парсинге HTTP‑заголовков, на gzip, который я был уверен, что выключил, на два syscall'а, которые стоят дороже всего моего Ruby. Самое ценное, что я построил за весь этот цикл, — не оптимизация. Это units_per_readable — число, которое говорит вам, делает ли вообще хоть что‑нибудь тот механизм, который вы оптимизируете.

Если вы строите что‑то в этой области: добавьте это число первым. Иначе потратите силы на то, чтобы сделать плоский профиль ещё более плоским.

PS (9 августа): всё написанное выше делалось с жёсткой зависимостью от async. С тех пор я эту зависимость вынул — pg_pipeline 0.3.0+ разговаривает с Fiber::Scheduler напрямую (block / unblock / io_wait), так что теперь он работает на Itsi и на всём остальном, что реализует этот интерфейс, а не только на Async. Тот же протокол, те же гарантии, на одну обязательно запущенную вещь меньше. Подробности в CHANGELOG.

🔗 github.com/roman‑haidarov/pg_pipeline 📖 DESIGN.md — ограничения и почему каждое из них не подлежит обсуждению


Оригинал: Pipelining Postgres from Ruby fibers — and finding out I was optimizing the wrong 8%