Я уже рассказывал про сервис мониторинга запросов к PostgreSQL, для которого мы реализовали онлайн-коллектор серверных логов, чья основная задача — одновременно принимать потоки логов сразу с большого количества хостов, быстро их разбирать на строки, группировать в пакеты по определенным правилам, обрабатывать и записывать результат в PostgreSQL-хранилище.
![](https://habrastorage.org/webt/vk/dn/xj/vkdnxj5l8luuvnk31xevce4auae.png)
В нашем случае речь идет о нескольких сотнях серверов и миллионах запросов и планов, которые генерируют больше 100GB логов в день. Поэтому было совсем неудивительно, когда мы обнаружили, что львиная доля ресурсов тратится именно на две эти операции: разбор на строки и запись в базу.
Мы погрузились в недра профайлера и обнаружили некоторые особенности работы с
![](https://habrastorage.org/webt/od/uv/lm/oduvlmuc8wmu5obyljyygjnc8vw.png)
Большая часть процессорного времени тратилась на обработку входящего лог-потока, что вполне понятно. Но что было непонятно — ресурсоемкость примитивной «нарезки» входящего потока бинарных блоков на строки по
![](https://habrastorage.org/webt/dk/v4/tb/dkv4tbigt2rwylskt8x_iqzcurc.png)
Внимательный разработчик сразу заметит тут не особо эффективный побайтовый цикл по входящему буферу. Ну а поскольку строка может быть «разорвана» между соседними блоками, то присутствует и функционал «прицепления хвоста», оставшегося от предыдущего обработанного блока.
Беглый обзор имеющихся решений вывел нас на штатный модуль readline ровно с необходимым нам функционалом нарезки на строки:
![](https://habrastorage.org/webt/he/px/0v/hepx0v5do7wgj9osz0cwyhuljvg.png)
После его внедрения «нарезка» из топа профайлера ушла в глубину:
![](https://habrastorage.org/webt/jc/5x/et/jc5xetsqi24hpgggyg0yss6ggww.png)
Но, как выяснилось, внутри себя readline принудительно приводит строку в UTF-8, чего нельзя делать, если у записи лога (запроса, плана, текста ошибки) другая исходная кодировка.
Ведь даже на одном PostgreSQL-сервере может быть одновременно активно несколько баз, каждая из которых формирует вывод в общий серверный лог именно в своей оригинальной кодировке. В результате, обладатели баз на win-1251 (иногда удобно применять ради экономии дискового пространства, если «честный» многобайтовый UNICODE не нужен) смогли наблюдать у себя планы с примерно такими «русскими» названиями таблиц и индексов:
![](https://habrastorage.org/webt/mb/6w/ri/mb6wriic6gmbvaz0koz8vddn_gm.png)
Беда-беда… Придется все таки делать нарезку самостоятельно, но уже с оптимизациями типа
![](https://habrastorage.org/webt/ii/kf/h0/iikfh0v9mn2pyjd56eislxit69a.png)
Вроде все хорошо, в тестовом контуре нагрузка не выросла, win1251-названия починились, выкатываем на бой… Та-дам! CPU usage периодически пробивает потолок в 100%:
![](https://habrastorage.org/webt/0r/k_/3u/0rk_3ujn57ml6uf2qs5xiuztnto.png)
Как же так?.. Оказывается, во всем виноват
![](https://habrastorage.org/webt/j6/nx/hw/j6nxhw_xyt8rhrz9z9dlfbaz7fo.png)
Но ведь у нас склейка идет только при переходе строки через блок, а их не должно быть много — правда-правда?.. Ну, почти. Только вот иногда приходят «строки» из нескольких сотен 16KB-сегментов:
![](https://habrastorage.org/webt/iq/rk/54/iqrk54dnsrr9xhfgvawz5pptc90.png)
Спасибо коллегам-разработчикам, которые позаботились такое сгенерировать. Случается оно «редко, но метко», поэтому увидеть заранее в тестовом контуре не удалось.
Понятно, что подклейка несколько сотен раз к буферу на несколько мегабайт мелких кусочков — прямой путь в бездну переаллокаций памяти с выеданием ресурсов CPU, что мы и пронаблюдали. Так а давайте просто не будем клеить, пока строка не кончилась совсем. Просто будем складывать «кусочки» в массив, пока не настанет пора отдать всю строку «наружу»:
![](https://habrastorage.org/webt/5i/d8/ol/5id8olet5wr0mxddkn4u3ieuwu0.png)
Теперь нагрузка снова вернулась к показателям, которые давал readline.
Многие, кто писал на языках с динамическим выделением памяти, в курсе, что одна из наиболее неприятных «убийц производительности» — это фоновая активность «сборщика мусора» (Garbage Collector, GC), который сканирует созданные в памяти объекты и удаляет те, которые больше никому не требуются. Настигла эта неприятность и нас — в какой-то момент мы начали замечать, что активности GC как-то слишком уж много, и не к месту.
![](https://habrastorage.org/webt/hg/pp/ht/hgppht17ouvqyv9xdqv7wf4n5kq.png)
Традиционные «крутилки» не особо помогали… «Если ничего не помогает — сделай дамп!» И народная мудрость не подвела — мы увидели тучу Buffer по 8360 байт общим размером на 520MB…
![](https://habrastorage.org/webt/s7/ct/km/s7ctkmzfmorz0qhbsaw1v3aorpw.png)
Причем порождены они внутри CopyBinaryStream — ситуация начала проясняться…
Чтобы уменьшить объем передаваемого на БД трафика, мы используем бинарный формат COPY. Фактически, для каждой записи нужно в поток отправить буфер, состоящий из «кусочков» — количества полей в записи (2 байта) и дальше бинарного представления значения каждого из столбцов (4 байта на ID типа + данные).
Поскольку «суммарно» такая строка таблицы почти всегда имеет переменную длину, то сразу выделять буфер фиксированной длины — не вариант, переаллокации при нехватке размера легко «доедят» производительность, уже прошли выше. Значит, тоже стоит «клеить из кусочков» с помощью
Ну а раз у нас многие кусочки повторяются снова и снова (например, количество полей в записях одной и той же таблицы) — давайте будем просто их запоминать, а потом брать уже готовые, сгенерированные однократно при первом обращении. Исходя из COPY-формата вариантов достаточно немного — типовые кусочки бывают по 1, 2 или 4 байта:
![](https://habrastorage.org/webt/dx/32/c2/dx32c2pramljew4m2x2ztitewio.png)
И… бам, прилетели грабли!
![](https://habrastorage.org/webt/5e/vd/mx/5evdmx_4gln9ccxajf2weqv2z4w.png)
То есть да, каждый раз при создании Buffer по-умолчанию выделяется кусок памяти 8KB, чтобы создаваемые подряд небольшие буферы можно было складывать «рядом» в уже выделенной памяти. А наше выделение срабатывало «по требованию», и оказывалось совсем не «рядом» — поэтому каждый наш буфер на 1-2-4 байта физически занимал 8KB + заголовок — вот они, наши 520MB!
Хм… А зачем нам вообще ждать, пока потребуется тот или иной 1/2-байтовый буфер? С 4-байтовыми тема отдельная, но этих-то разных возможных вариантов всего 256 + 65536. Так давайте нагенерим их подряд все сразу! Заодно и условие существования из каждой проверки спилим — еще и работать станет быстрее, поскольку инициализация проводится только при старте процесса.
![](https://habrastorage.org/webt/fs/yq/ge/fsyqgetrrresipf9qmqpifnt-ku.png)
То есть кроме 1/2-байтовых буферов сразу инициализируем и самые ходовые значения (нижние 2 байта и -1) для 4-байтовых. И — это помогло, всего 10MB вместо 520MB!
![](https://habrastorage.org/webt/7l/7h/ot/7l7hotlahibczpu7nvmjsdxxo7a.png)
![](https://habrastorage.org/webt/vk/dn/xj/vkdnxj5l8luuvnk31xevce4auae.png)
В нашем случае речь идет о нескольких сотнях серверов и миллионах запросов и планов, которые генерируют больше 100GB логов в день. Поэтому было совсем неудивительно, когда мы обнаружили, что львиная доля ресурсов тратится именно на две эти операции: разбор на строки и запись в базу.
Мы погрузились в недра профайлера и обнаружили некоторые особенности работы с
Buffer
в Node.js, знание которых может сильно сэкономить ваше время и серверные ресурсы.Процессорная нагрузка
![](https://habrastorage.org/webt/od/uv/lm/oduvlmuc8wmu5obyljyygjnc8vw.png)
Большая часть процессорного времени тратилась на обработку входящего лог-потока, что вполне понятно. Но что было непонятно — ресурсоемкость примитивной «нарезки» входящего потока бинарных блоков на строки по
\r\n
:![](https://habrastorage.org/webt/dk/v4/tb/dkv4tbigt2rwylskt8x_iqzcurc.png)
Внимательный разработчик сразу заметит тут не особо эффективный побайтовый цикл по входящему буферу. Ну а поскольку строка может быть «разорвана» между соседними блоками, то присутствует и функционал «прицепления хвоста», оставшегося от предыдущего обработанного блока.
Пробуем readline
Беглый обзор имеющихся решений вывел нас на штатный модуль readline ровно с необходимым нам функционалом нарезки на строки:
![](https://habrastorage.org/webt/he/px/0v/hepx0v5do7wgj9osz0cwyhuljvg.png)
После его внедрения «нарезка» из топа профайлера ушла в глубину:
![](https://habrastorage.org/webt/jc/5x/et/jc5xetsqi24hpgggyg0yss6ggww.png)
Но, как выяснилось, внутри себя readline принудительно приводит строку в UTF-8, чего нельзя делать, если у записи лога (запроса, плана, текста ошибки) другая исходная кодировка.
Ведь даже на одном PostgreSQL-сервере может быть одновременно активно несколько баз, каждая из которых формирует вывод в общий серверный лог именно в своей оригинальной кодировке. В результате, обладатели баз на win-1251 (иногда удобно применять ради экономии дискового пространства, если «честный» многобайтовый UNICODE не нужен) смогли наблюдать у себя планы с примерно такими «русскими» названиями таблиц и индексов:
![](https://habrastorage.org/webt/mb/6w/ri/mb6wriic6gmbvaz0koz8vddn_gm.png)
Дорабатываем велосипед
Беда-беда… Придется все таки делать нарезку самостоятельно, но уже с оптимизациями типа
Buffer.indexOf()
вместо «побайтного» сканирования:![](https://habrastorage.org/webt/ii/kf/h0/iikfh0v9mn2pyjd56eislxit69a.png)
Вроде все хорошо, в тестовом контуре нагрузка не выросла, win1251-названия починились, выкатываем на бой… Та-дам! CPU usage периодически пробивает потолок в 100%:
![](https://habrastorage.org/webt/0r/k_/3u/0rk_3ujn57ml6uf2qs5xiuztnto.png)
Как же так?.. Оказывается, во всем виноват
Buffer.concat
, которым мы «подклеиваем хвост», оставшийся от предыдущего блока:![](https://habrastorage.org/webt/j6/nx/hw/j6nxhw_xyt8rhrz9z9dlfbaz7fo.png)
Но ведь у нас склейка идет только при переходе строки через блок, а их не должно быть много — правда-правда?.. Ну, почти. Только вот иногда приходят «строки» из нескольких сотен 16KB-сегментов:
![](https://habrastorage.org/webt/iq/rk/54/iqrk54dnsrr9xhfgvawz5pptc90.png)
Спасибо коллегам-разработчикам, которые позаботились такое сгенерировать. Случается оно «редко, но метко», поэтому увидеть заранее в тестовом контуре не удалось.
Понятно, что подклейка несколько сотен раз к буферу на несколько мегабайт мелких кусочков — прямой путь в бездну переаллокаций памяти с выеданием ресурсов CPU, что мы и пронаблюдали. Так а давайте просто не будем клеить, пока строка не кончилась совсем. Просто будем складывать «кусочки» в массив, пока не настанет пора отдать всю строку «наружу»:
![](https://habrastorage.org/webt/5i/d8/ol/5id8olet5wr0mxddkn4u3ieuwu0.png)
Теперь нагрузка снова вернулась к показателям, которые давал readline.
Потребление памяти
Многие, кто писал на языках с динамическим выделением памяти, в курсе, что одна из наиболее неприятных «убийц производительности» — это фоновая активность «сборщика мусора» (Garbage Collector, GC), который сканирует созданные в памяти объекты и удаляет те, которые больше никому не требуются. Настигла эта неприятность и нас — в какой-то момент мы начали замечать, что активности GC как-то слишком уж много, и не к месту.
![](https://habrastorage.org/webt/hg/pp/ht/hgppht17ouvqyv9xdqv7wf4n5kq.png)
Традиционные «крутилки» не особо помогали… «Если ничего не помогает — сделай дамп!» И народная мудрость не подвела — мы увидели тучу Buffer по 8360 байт общим размером на 520MB…
![](https://habrastorage.org/webt/s7/ct/km/s7ctkmzfmorz0qhbsaw1v3aorpw.png)
Причем порождены они внутри CopyBinaryStream — ситуация начала проясняться…
COPY… FROM STDIN WITH BINARY
Чтобы уменьшить объем передаваемого на БД трафика, мы используем бинарный формат COPY. Фактически, для каждой записи нужно в поток отправить буфер, состоящий из «кусочков» — количества полей в записи (2 байта) и дальше бинарного представления значения каждого из столбцов (4 байта на ID типа + данные).
Поскольку «суммарно» такая строка таблицы почти всегда имеет переменную длину, то сразу выделять буфер фиксированной длины — не вариант, переаллокации при нехватке размера легко «доедят» производительность, уже прошли выше. Значит, тоже стоит «клеить из кусочков» с помощью
Buffer.concat()
.memo
Ну а раз у нас многие кусочки повторяются снова и снова (например, количество полей в записях одной и той же таблицы) — давайте будем просто их запоминать, а потом брать уже готовые, сгенерированные однократно при первом обращении. Исходя из COPY-формата вариантов достаточно немного — типовые кусочки бывают по 1, 2 или 4 байта:
![](https://habrastorage.org/webt/dx/32/c2/dx32c2pramljew4m2x2ztitewio.png)
И… бам, прилетели грабли!
![](https://habrastorage.org/webt/5e/vd/mx/5evdmx_4gln9ccxajf2weqv2z4w.png)
То есть да, каждый раз при создании Buffer по-умолчанию выделяется кусок памяти 8KB, чтобы создаваемые подряд небольшие буферы можно было складывать «рядом» в уже выделенной памяти. А наше выделение срабатывало «по требованию», и оказывалось совсем не «рядом» — поэтому каждый наш буфер на 1-2-4 байта физически занимал 8KB + заголовок — вот они, наши 520MB!
smart memo
Хм… А зачем нам вообще ждать, пока потребуется тот или иной 1/2-байтовый буфер? С 4-байтовыми тема отдельная, но этих-то разных возможных вариантов всего 256 + 65536. Так давайте нагенерим их подряд все сразу! Заодно и условие существования из каждой проверки спилим — еще и работать станет быстрее, поскольку инициализация проводится только при старте процесса.
![](https://habrastorage.org/webt/fs/yq/ge/fsyqgetrrresipf9qmqpifnt-ku.png)
То есть кроме 1/2-байтовых буферов сразу инициализируем и самые ходовые значения (нижние 2 байта и -1) для 4-байтовых. И — это помогло, всего 10MB вместо 520MB!
![](https://habrastorage.org/webt/7l/7h/ot/7l7hotlahibczpu7nvmjsdxxo7a.png)