Бенчмарки¶
Очередь в таблице базы данных стоит на каждом сообщении выборки и завершения, а крейт добавляет поверх свою работу: поток подписки, декодирование, диспетчеризацию, учёт завершений. Эта страница говорит, во что обходится эта работа. Точкой отсчёта служат те же выборка и завершение, написанные руками на sqlx.
Один процесс выполняет сценарий тремя способами. Сырой цикл sqlx написан руками: он
выполняет те же запросы, что брокер выполняет для той же таблицы, в том же порядке и на пуле с
теми же настройками. ruststream-sqlx - та же работа, написанная руками на этом крейте:
подключённый SqlxBroker, подписка, прочитанная как поток доставок с подтверждением каждой, или
издатель крейта, через который идёт публикация. Сервиса, обработчика и диспетчеризации здесь нет.
Сервис RustStream - приложение, которое пишет пользователь: таблица с #[derive(Inbox)],
обработчик с #[subscriber(..)] и RustStream::new(..).with_broker(SqlxBroker::new(pool), ..).
Отсюда две разницы. Крейт против сырого цикла - это то, во что обходятся собственные подписка или издатель крейта сверх запросов, которые они выполняют; за это число отвечает данный репозиторий. Сервис против сырого цикла - это то, что пользователь платит целиком. Промежуток между ними - это то, во что обходится рантайм поверх этого крейта.
Outbox - не брокер, а расширение, и измеряется он как расширение: одно приложение в трёх вариантах, которые разобраны в отдельном разделе.
У всех трёх общие таблица, строки, декодирование в тот же тип, рантайм tokio и сборка. Сырой цикл берёт запросы у того же диалекта, которым их строит брокер, поэтому циклы не могут разойтись. Процедура принадлежит самому фреймворку и описана на странице бенчмарков RustStream; здесь опубликовано то, что она дала на этой машине.
Числа¶
Сообщений в секунду на Postgres из стенда, на многопоточном рантайме. Лучший из трёх чередующихся раундов, в скобках медианный. Больше значит лучше.
Таблицы читаются в браузере из документа, который записал последний прогон, так что на этой странице нет копии, успевшей устареть.
Прогон заполняет свою таблицу, а затем выбирает её до конца. Число строк задаёт пробный прогон,
так что каждый измеряемый прогон длится не меньше пяти секунд, на какой бы машине его ни снимали.
Окно открывается на первой обработанной доставке и закрывается на последней, поэтому запуск
сервиса и открытие пула остаются за его пределами. Сценарий публикации не выбирает строки, а пишет
их, и его окно идёт от первой публикации до последней. Сценарий ответа отвечает на каждую доставку
во вторую таблицу через Routed, издатель подключённого брокера по умолчанию.
Разница с отметкой «неразличимо» меньше разброса между раундами любого из двух циклов. Это честный исход везде, где база данных стоит намного дороже диспетчеризации: каждое сообщение здесь требует хотя бы двух запросов и фиксации, и разница такого размера теряется внутри одного из них. Число ниже шума прогонов читалось бы как точность, которой никто не измерял, поэтому оно не публикуется.
Строка с отметкой «ограничено базой данных» - та, где сырой цикл хотя бы половину каждого сообщения ждал базу данных. Это сравнение обращений к серверу, которых сообщение стоит циклу, при задержке, которую намерил прогон, со временем, за которое прошло сообщение. Каждый запрос - это обращение к серверу, и каждое соединение, взятое из пула, тоже: пул sqlx проверяет соединение пингом, прежде чем его выдать. В такой строке всё, что лежит над сокетом, делает свою работу внутри ожидания, за которое цикл платил и так. Поэтому строка - настоящий результат и одновременно нижняя граница этих расходов, а не их измерение.
Машинная форма того же прогона, из которой сайт фреймворка строит сводную таблицу по брокерам, лежит
по адресу
benchmarks/results.json.
Пропускная способность¶
Заполненная таблица выбирается до конца на Postgres, MySQL и SQLite, на пуле из 16 соединений.
Сервис подключает обработчик с workers(n); один воркер - это обычное подключение, которое
обрабатывает доставку, прежде чем взять следующую. Сырой цикл запускает n задач на одном пуле, и
каждая из них - цикл, который выбирает строку, читает её и удаляет, пока выборка не вернётся
пустой: те же запросы и та же степень параллелизма. Цикл крейта запускает n задач на том же пуле,
и каждая - отдельный SqlxBroker, который читает одну подписку InboxQueue. Postgres и MySQL работают в форме блокировки
строк. SQLite работает в форме аренды, потому что блокировок строк у неё нет; базу данных в каждый
момент держит один писатель.
Каждый прогон сначала заполняет таблицу, а затем засекает выборку до последней обработанной строки. Прогон узнаёт о завершении, не опрашивая таблицу и не засыпая: каждая обработанная доставка уменьшает счётчик-защёлку, во всех трёх циклах одинаково, и последний отсчёт будит ожидающий прогон.
Выборки одной подписки идут по одной, каждая не больше одной строки, а для обработчика пакетов не
больше размера пакета. workers(n) выполняет обработчики и их завершения параллельно, но не
умножает выборки. С одним воркером три цикла выполняют одну и ту же работу в одном и том же
порядке. С несколькими каждая сырая задача и каждая подписка крейта выбирают строки сами, а одна
подписка сервиса выбирает их за всех своих воркеров. Поэтому колонка крейта рядом с сырой
показывает, во что обходится крейт при той же степени параллелизма, а разрыв от неё до сервиса на
четырёх и восьми воркерах - это выборка, а не диспетчеризация. Пакет забирает за одну выборку до своего размера строк, так что одна
выборка обслуживает столько же доставок.
Собственный код крейта¶
Третья таблица подсчитана, а не засечена по часам: инструкции под callgrind и выделения памяти под DHAT, для сервиса и рядом для сырого цикла. Каждый сценарий работает против Postgres из стенда на однопоточном рантайме. Производитель в другом потоке заполняет таблицу между запуском и выборкой, так что заполнение не попадает ни в одну из областей замера.
Считается всё, что выполняет поток сервиса: фреймворк, этот крейт и драйвер sqlx, который на этом потоке кодирует, отправляет, принимает и декодирует. Сервер базы данных - отдельный процесс, и он не считается, как и то, что ядро делает внутри системного вызова. Не считается и ожидание сервера рантаймом: подсчёт останавливается, пока планировщик паркуется на своём драйвере, который опрашивает сокет, будит ждущую задачу и продвигает колесо таймеров. Эта работа следует за временем ответа сервера, а не за кодом. В подсчёте она сдвигала число сырого цикла на десятки процентов между двумя прогонами одного и того же дерева.
Инструкции и выделения даны на сообщение в установившемся режиме: это наклон между прогоном на 1000 доставок и прогоном на 2000. Последняя колонка - то, во что один раз обошёлся запуск сервиса: открытие соединений пула, проверки брокера при старте и первая доставка. Числа абсолютные, собственная цена фреймворка входит в них; отдельно её публикует ядро на своей странице бенчмарков.
Сервис говорит с настоящим сервером, и число инструкций между прогонами всё равно плавает: от того,
успел ли ответ дойти к моменту, когда клиент его ждёт, зависит, сколько чтения sqlx и планирования
рантайма приходится на сообщение. За пять прогонов одного и того же дерева итог сценария сдвигался
примерно до 7%, а число на сообщение примерно до 15%. Сценарии, которые не обращаются к базе данных
(строки outbox поверх MemoryBroker без outbox), совпадают до инструкции. Число инструкций здесь
стоит читать с точностью до нескольких процентов, а не до цифры. Выделения памяти не плавают:
порог каждого сценария - наибольшее число, которого он достиг за пять полных прогонов, плюс запас в
0,1%. just bench-code падает, если выделений больше объявленного сценарием порога, а с
--baseline=main - ещё и на приросте инструкций больше десяти процентов, до которого один шум не
доходит. Эталон записывает
just bench-code --save-baseline=main на ветке main, а just bench-code --baseline=main
сравнивает с ним изменение. Упавший прогон печатает каждый превышенный порог, старое значение
рядом с новым. Запрос на слияние, который меняет цену, приводит свои числа.
Outbox как расширение¶
Outbox работает поверх любого брокера, поэтому его цена - это цена для обычного приложения
RustStream. Каждый сценарий - одно приложение в трёх вариантах. Без outbox - приложение,
которое пользователь пишет без него: ретранслятор отвечает на каждую команду ответом, приёмник
этот ответ потребляет, а сообщение, опубликованное не из обработчика, уходит прямо в брокер.
Outbox руками - то же приложение, в котором outbox написал сам сервис на голом sqlx: запись
вставляется перед публикацией, а в обработчике берётся в работу и отмечается, на тех же
соединениях, что берёт middleware крейта. Outbox ruststream-sqlx - то же приложение с outbox
этого крейта: реестр, два его слоя и повторная публикация при запуске; сообщение, опубликованное
не из обработчика, идёт через Outbox::wrap.
Outbox против варианта без него - это вся цена расширения: во что вообще обходится отслеживание сообщения. Outbox против ручного - собственные накладные расходы крейта: что его механизм добавляет к запросам, которые пользователь выполнил бы в любом случае. Неотслеживаемое сообщение проходит оба слоя, а в ручном outbox ничего не стоит, поэтому у этой строки среднего варианта нет.
Четыре сценария покрывают то, что делает outbox. Неотслеживаемое сообщение проходит путь туда и обратно через оба слоя. Отслеживаемая публикация уходит не из обработчика, так, как публикует обработчик HTTP-запроса. Отслеживаемая доставка доходит до приёмника, который берёт её запись в работу и отмечает. Отслеживаемый путь туда и обратно делает всё это сразу: ответ записывается, доставляется, берётся в работу и отмечается.
Замер по часам идёт через Redis Pub/Sub из стенда, через ruststream-fred, как работает пример
outbox этого крейта, и против Postgres из стенда. Отправитель публикует из рабочего потока и
держит неполученными не больше 1024 сообщений, потому что Pub/Sub выбрасывает то, что не помещается
в буфер подписки.
Отслеживаемый путь туда и обратно, в котором ретранслятор и приёмник подключены с workers(n), в
каждом из вариантов.
Подсчёт запускает те же приложения поверх MemoryBroker, поэтому транспорта в числе нет. В пути
находится одно сообщение за раз, так что ретранслятор и приёмник никогда не ждут соединения друг
друга.
Цена, которая остаётся¶
Крейт соблюдает правило семейства: на горячем пути нет выделений памяти, динамических вызовов, лишних атомарных операций и копий на сообщение сверх тех, что делает сам sqlx. То, что остаётся, перечислено здесь, каждое там, где оно применяется:
- Обработчик, который читает транзакцию своей доставки,
Tx, платит одним увеличением счётчика ссылок на доставку. Подписка, которая её не использует, не платит ничего. - Обработчик, который просит пул через
Ctx<keys::Pool>, платит одним увеличением счётчика ссылок на доставку. - Публикация через
Repositoryили маршрут читает флаг закрытия брокера: одна атомарная загрузка. - Публикация будит подписки своей таблицы в том же процессе: одна атомарная операция на каждую разбуженную подписку.
Routedищет имя каждого сообщения, одним поиском в хеш-таблице для точного имени, и делает один динамический вызов вPublishстроки.Repositoryназывает свою таблицу во время компиляции и не делает ни того, ни другого.- Подписка по имени над строкой, все события которой - собственные события крейта, читает строку по
её колонкам, как это делает
InboxQueue. Строка со своими событиями платит одной упакованной в кучу доставкой и одним упакованным future завершения на сообщение. - Outbox читает пул, заданный после запуска, через
OnceLock: одна атомарная загрузка на отслеживаемое сообщение. Пул, переданный при создании, ничего не стоит. Отслеживаемое сообщение несёт идентификатор своей записи в заголовке, и публикация выделяет память под его текст и под хранилище заголовков. - Тестовый переключатель outbox вырезается из рабочей сборки при компиляции.
- Формы блокировки строк и рекомендательной блокировки держат одно соединение пула на каждое сообщение или пакет в работе. Это цена блокировки, которую база данных снимает, когда её держатель умирает.
- Публикация не из обработчика, через издатель запущенного приложения, кодирует каждое сообщение в собственный буфер. Сырой цикл держит один буфер на все сообщения.
Машина¶
Флаги сборки публикуются вместе с числами, потому что они эти числа меняют. Бинарник, собранный с
-C target-cpu=native, даёт результат, который не воспроизвести на другой машине, поэтому рецепт
очищает переменную перед сборкой.
Чего эти числа не значат¶
Это одна таблица, маленькое тело JSON и сервер базы данных на локальной петле. Измеряется то, во что сообщение обходится внутри этого крейта, а не то, сколько способны пронести Postgres, MySQL или SQLite. Строку отсюда нельзя сравнивать со строкой, опубликованной для крейта брокера сообщений: очередь в базе данных выполняет на сообщение другую работу.
Окно прогона закрывается, когда засчитана последняя доставка, одинаково во всех трёх циклах. Каждый из них завершает доставку после того, как её засчитал, поэтому последнее завершение везде остаётся за пределами числа.
Стенд запускает все серверы в Docker в сети хоста той машины, на которой идёт бенчмарк, и без
гарантий сохранности: без fsync и без сброса журнала при фиксации. Измеряется цена сообщения, а
не диск под сервером. Сервис, который держит сохранность включённой, платит за неё, и платит
одинаково во всех циклах. Сервер на другой машине отвечает на другой вопрос и отвечает про сеть, а
не про этот крейт.
Числа - снимок одной машины в один день. Их переснимают вручную на машине, отданной одному этому замеру: разница, о которой говорит эта страница, меньше шума машины, которую делят с другими задачами.
Как повторить¶
Рецепт поднимает стенд из docker-compose.test.yml, прогоняет каждый сценарий и сетку пропускной
способности, гасит стенд и переписывает docs/benchmarks/results.json тем, что намерил. Это
занимает около получаса и требует машину целиком. RUSTSTREAM_BENCH_SECONDS задаёт наименьшую
длительность прогона по часам, RUSTSTREAM_BENCH_THROUGHPUT_ROWS - число строк прогона
пропускной способности, а RUSTSTREAM_BENCH_PAIRS - число раундов.
Рецепт поднимает тот же стенд, подсчитывает таблицу кода под valgrind, гасит стенд и переписывает
раздел code того же документа. Ему нужен valgrind. Рецепт сам устанавливает исполнитель
бенчмарков той версии, которую закрепляет Cargo.lock. Число первым аргументом задаёт другое
количество доставок на прогон, just bench-code 5000: цифры получаются устойчивее, а прогон идёт
дольше. Опубликованная таблица снята при значении по умолчанию, 1000.