Как здесь меряют и что намеряли
Этот проект существует ради скорости, поэтому у него есть обязанность: каждая веха заканчивается замером, и число попадает сюда. Веха, изменившая горячий путь и не показавшая цифру, считается незакрытой — иначе «стало быстрее» превращается в мнение (BACKLOG, правило 2).
Как запускать
Полный прогон — цифру из него можно записывать:
./gradlew :booblik-benchmark:mainBenchmarkПорядок величины за минуту — в отчёт не годится, доверительный интервал там шире самой оценки:
./gradlew :booblik-benchmark:mainQuickBenchmarkПрофиль рантайма
Замер идёт под тем же профилем JVM, в котором брокер должен жить. Это ограничение, а не настройка производительности:
-XX:+UseSerialGC -XX:ReservedCodeCacheSize=32M -XX:MaxDirectMemorySize=32M
-Xss256k -XX:MaxMetaspaceSize=80M -Xmx64MЗадан один раз — brokerJvmArgs в корневом build.gradle.kts — и применяется и к тестам, и к
форку JMH, чтобы правка не доехала до одного и не доехала до другого.
Проверяется, а не предполагается. Флаги попадают в измеряемый процесс потому, что JMH
передаёт форку аргументы хостовой JVM. Это поведение JMH, а не наше, и оно может измениться
с версией — а отказ будет тихим: бенчмарк продолжит работать и выдаст правдоподобные числа для
JVM, которую никто не собирается разворачивать. Поэтому
RuntimeFootprint
валит прогон, если флагов нет, и печатает фактический список в шапке отчёта.
Два флага стоит держать в голове при чтении чисел:
-Xmx64Mи-XX:MaxDirectMemorySize=32Mне ограничивают маппинг. Память отFileChannel.map— ни куча, ни direct-buffer, поэтомуMAPPEDадресует 512-мегабайтный сегмент внутри 64-мегабайтной кучи. Обратная сторона: от слишком большого маппинга эти два флага не защищают вообще никак.-XX:MaxDirectMemorySize=32MограничиваетFileChannelSegmentWriter— у него один direct-буфер на 4 байта на сегмент. Запас примерно на восемь миллионов сегментов.
Правила, без которых число ничего не значит
- Режим сброса называется всегда. Без
force()меряется скорость page cache, с ним — диска. Разница ниже — четыре порядка. Цифра без указания режима не неточна, она бессмысленна (решение Р4). - Профиль рантайма называется тоже. См. выше; числа под профилем и без него — разные числа.
- Сравнивать можно только прогоны одной сессии, идущие подряд. Не «на одном хосте» — этого мало: контрольный прогон в M-44 показал 37 % расхождения с той же конфигурацией из замера 8 на той же машине, просто в другой день. Файловая система, её заполненность, тепловой режим и page cache двигают числа сильнее, чем разница, которую обычно ищут. Шапка с хостом обязательна, но её недостаточно.
quick— не для записи. Две конфигурации существуют, чтобы разделить «посмотреть, не сломал ли» и «зафиксировать».- Нагрузку меряет только открытый цикл. Закрытый («отправил — дождался — отправил следующий»)
при замедлении брокера замедляется вместе с ним и просто перестаёт просить: очередь, которая
образовалась бы перед настоящим брокером, не образуется, медленные образцы не берутся,
перцентили выходят красивыми. Это coordinated omission, и
probeLoadустроен так, чтобы её не допустить: время отсчитывается от момента, когда запрос был должен уйти. - Стенд может жить в одной JVM с брокером, и тогда числа описывают пару.
probeLoadтак и устроен; счётчики GC печатаются рядом с перцентилями именно поэтому. Цена измерена в замере 16: 9,2× по медиане и 19 % потолка. Для чисел, которые идут в отчёт, беритеprobeRemoteLoadи отдельную машину;probeLoadостаётся удобной пробой «не сломал ли». - Прежде чем объяснять хвост свойствами брокера, проверьте измеритель. В M3 стенд дважды
выдавал правдоподобные числа, будучи неправ (замер 6, врезка про спин).
7а. Носитель проверяется до числа, а не после. На Ubuntu 26.04
/tmp— tmpfs, и всё, что измеряющий код туда пишет, живёт в оперативной памяти:fsyncтам ничего не достигает. В M-46 проба M-24 на арендованной машине выдалаfsyncза 0,01 мс после 32 МиБ грязных данных и напечатала вердикт проmsyncкак ни в чём не бывало. Ловится арифметикой — 32 МиБ за 10 мкс это 3,2 ТБ/с, — но ловить это глазами каждый раз нельзя, поэтомуMeasurementDirпишет вbuild/measurementsи отказывается работать на летучей ФС, а каждая проба печатает носитель в шапке (# storage: ext4 on /dev/sda1). - CI не меряет производительность и не пытается. Джоба
benchmarkгоняет набор на арендованном раннере раз в неделю и проверяет только обвал: порог вci/benchmark-floor.shстоит на порядок ниже самого медленного из измеренных хостов. Причина — то же правило 3: каждый прогон CI идёт на другой машине, и любой порог в процентах либо срабатывал бы всегда, либо никогда. Отчёт сохраняется артефактом с паспортом хоста, и сравнивать его с прошлым прогоном CI нельзя.
Замер 1 (M0, 2026-08-10): запись в сегмент
Вопрос. Действительно ли запись через маппинг быстрее записи через FileChannel, как
утверждает драфт? Ресёрч §1.1 показал, что Kafka делает наоборот, и посылка перестала быть
очевидной.
Хост. Apple M1, 16 ГБ, macOS 27.0, APFS на внутреннем SSD (13 ГБ свободно), JDK 25.0.2,
Kotlin 2.4.10. Прогон: mainBenchmark, 5 прогревов + 10 итераций по 2 с, режим thrpt.
Профиль рантайма — целевой (см. выше), подтверждён RuntimeFootprint в шапке прогона.
Что меряется. Один LogSegment.append — кадрирование, запись, обновление разреженного
индекса. Один вызов = одна запись, батчей ещё нет.
Колонка «без профиля» — тот же прогон на JVM с умолчаниями. Она здесь не для сравнения путей записи, а чтобы ответить на отдельный вопрос: во что обходится сам профиль.
| Сброс | Режим | Размер | Записей в секунду (профиль) | Без профиля |
|---|---|---|---|---|
| нет | FILE_CHANNEL | 64 Б | 335 984 ± 27 644 | 344 463 ± 13 786 |
| нет | FILE_CHANNEL | 1 КиБ | 309 817 ± 18 746 | 323 247 ± 5 940 |
| нет | MAPPED | 64 Б | 13 907 934 ± 1 156 711 | 14 769 674 ± 1 377 661 |
| нет | MAPPED | 1 КиБ | 1 854 553 ± 512 280 ⚠️ | 4 574 411 ± 258 777 ⚠️ |
| на каждую запись | FILE_CHANNEL | 64 Б | 257 ± 7 | 254 ± 8 |
| на каждую запись | FILE_CHANNEL | 1 КиБ | 245 ± 5 | 249 ± 6 |
| на каждую запись | MAPPED | 64 Б | 16 123 ± 4 069 | 16 773 ± 3 923 |
| на каждую запись | MAPPED | 1 КиБ | 4 135 ± 1 255 | 4 725 ± 1 233 |
Профиль почти ничего не стоит — семь строк из восьми не отличаются за пределами погрешности.
Это ожидаемо и всё же стоило проверки: горячий путь записи не аллоцирует в куче, поэтому ни
размер кучи, ни выбор сборщика мусора ему безразличны. -Xmx64M как рабочее ограничение для
слоя хранения подтверждается.
Восьмая строка (⚠️) — не измерение записи, и её нельзя читать как «профиль замедлил втрое».
При килобайтных записях маппинг выдаёт порядка 2–4,5 ГБ/с, то есть гигабайтный сегмент кончается
за четверть секунды, и за двухсекундную итерацию происходит до десяти переоткрытий —
arena.close() на гигабайтный маппинг, новый map, и гигабайт грязных страниц, с которым ядру
надо что-то делать. Меряется здесь именно это, а не append; отсюда и погрешность 28 % против
3–8 % в остальных строках. Обе цифры в этой строке — мусор, просто разного размера.
Чинится сокращением итерации (см. ниже), задача M-26.
Что из этого следует
Маппинг быстрее в 41 раз на 64-байтных записях, и драфт в этом был прав. Но сравнение пока
нечестное, и понятно почему, если перевести в наносекунды: FILE_CHANNEL — это 3,0 мкс на
запись, то есть ровно цена одного системного вызова write; MAPPED — 72 нс, то есть цена
memcpy и ничего сверх. Мы сравниваем «системный вызов на каждую запись» с «записью в память».
У Kafka системного вызова на запись нет, потому что записи приходят батчами. Честное сравнение
будет в M-14, когда появится батчевая запись; ожидание — что разрыв схлопнется в разы, а не
в десятки раз.
На килобайтных записях сравнивать сейчас нечего: строка MAPPED там испорчена роллингом
сегмента (см. ⚠️ выше).
fsync стоит 3,9 мс и не зависит от размера записи. 257 и 245 записей в секунду при
64 байтах и при килобайте — это не пропускная способность, это частота барьеров. Отсюда прямое
архитектурное следствие: политика FORCED на каждую запись не является рабочим режимом ни при
каких обстоятельствах; долговечность придётся покупать групповым сбросом раз в N записей или
раз в T миллисекунд (задача M-12).
А вот msync за 62 мкс — подозрительно дёшево, и это не победа. 16 123 записи в секунду
против 257 — разница в 63 раза между двумя вызовами, которые оба называются «сбрось на диск».
Правдоподобное объяснение — что на APFS msync не даёт той же гарантии, что fsync.
Пока это не проверено (M-24), таблица выше не является аргументом в пользу того, чтобы
сделать MAPPED умолчанием. Число, которое меряет более слабую гарантию, нельзя сравнивать
с числом, которое меряет более сильную.
Разброс у MAPPED со сбросом — 25 %, против 3–8 % у остальных строк. Это не шум измерения,
а неравномерность writeback: страницы сбрасываются пачками, и итерация попадает то между
пачками, то в них.
Что в этом замере неправильно, и это надо исправить к следующему
- Сегмент переполняется прямо в итерации, и на килобайтных записях это съедает замер. При
13,9 млн записей в секунду по 68 байт за две секунды проходит около 1,9 ГБ — почти вдвое
больше гигабайтного сегмента, то есть пара переоткрытий за итерацию; на фоне миллионов вызовов
это доли процента. При килобайте всё иначе: до десяти переоткрытий, каждое — снятие и
постановка гигабайтного маппинга, и мерить
appendтам уже не получается. Правильный ответ — более короткая итерация, а не сегмент побольше: ёмкость упирается вInt.MAX_VALUEу обоих путей. - Ещё важнее — что при этом переписываются уже грязные страницы. Мы выдаём порядка гигабайта в секунду грязных страниц и обгоняем фоновый writeback ядра. На короткой дистанции это выглядит как скорость RAM; на длинной начнётся деградация, и характер её у маппинга хуже — ступенькой, а не плавно. Это риск 1 ресёрча, и проверяется он отдельной задачей M-26: прогон на объёме заведомо большем свободной памяти.
/tmpна почти полном диске (94 %). На APFS это не так больно, как на ext4, но фрагментация выделения при 13 ГБ свободного места — фактор, о котором честнее знать.
Итог для решения Р1
(Ниже — итог замера 1, оставленный как есть: он показывает, на каком основании умолчание стояло
на FILE_CHANNEL в начале. Актуальное решение — в конце документа.)
Умолчание SegmentMode остаётся FILE_CHANNEL — не потому, что он быстрее, а потому что
три вопроса ещё открыты: честное сравнение с батчами (M-14), эквивалентность гарантий msync
и fsync (M-24) и поведение за пределами свободной памяти (M-26). Умолчание меняется по
результатам, а не по этой таблице.
Замер 1 устарел в одной строке. После M-24 выяснилось, что
msyncбарьером долговечности не является, иMappedSegmentWriter.force()теперь делает ещё иfsync. Строки со сбросом в таблице выше описывают код, которого больше нет; актуальные — в замере 2. Строки без сброса остаются в силе, кромеMAPPED/1 КиБ (⚠️), которую починил M-27.
Замер 2 (M2, 2026-08-10): всё вместе, после четырёх исправлений
Хост и профиль — те же, что в замере 1. Прогон: mainBenchmark, 5 прогревов + 10 итераций
по 2 с. Между замерами 1 и 2 поменялось четыре вещи, и три из них — в коде: msync дополнен
fsync (M-24), восстановление забуферено (M-23), бенчмарк перестал мерить роллинг (M-27),
и появился актор с батчами (M1).
2.1 Запись в сегмент напрямую — верхняя граница всего остального
| Сброс | Режим | Размер | Записей в секунду | Было в замере 1 |
|---|---|---|---|---|
| нет | FILE_CHANNEL | 64 Б | 213 859 ± 2 475 | 335 984 |
| нет | FILE_CHANNEL | 1 КиБ | 196 845 ± 12 711 | 309 817 |
| нет | MAPPED | 64 Б | 13 255 510 ± 336 270 | 13 907 934 |
| нет | MAPPED | 1 КиБ | 8 884 828 ± 1 203 190 | 1 854 553 ⚠️ |
| на запись | FILE_CHANNEL | 64 Б | 254 ± 11 | 257 |
| на запись | FILE_CHANNEL | 1 КиБ | 252 ± 6 | 245 |
| на запись | MAPPED | 64 Б | 251 ± 6 | 16 123 |
| на запись | MAPPED | 1 КиБ | 252 ± 6 | 4 135 |
Главная строка здесь — предпоследняя. Долговечная запись через маппинг стоила 16 123 в
секунду и стоит 251. Ничего не замедлилось: раньше там вызывался msync, который, как показал
M-24, возвращает управление до того, как работа сделана. Преимущество mapped-пути в долговечном
режиме было целиком артефактом более слабой операции — под честным барьером два пути записи
неразличимы (251 против 254).
MAPPED/1 КиБ без сброса вырос в 4,8 раза — это M-27: строка наконец меряет append,
а не переоткрытие сегмента. Переработок сегмента в этой строке было 512, и они больше ничего
не стоят.
FILE_CHANNEL без сброса просел на треть против замера 1 — накладные расходы разреженного
индекса и восстановления, добавленные в M2. На фоне разрыва с mapped-путём это шум, но строку
стоит держать в поле зрения.
2.2 M-14: чего стоит подтверждение на каждую запись
Записей в секунду через актор, батчами по 1 / 10 / 100:
| Политика | Режим | Батч 1 | Батч 10 | Батч 100 |
|---|---|---|---|---|
WRITTEN | FILE_CHANNEL | 58 169 ± 1 985 | 154 461 ± 9 790 | 209 081 ± 6 619 |
WRITTEN | MAPPED | 80 592 ± 1 836 | 692 554 ± 31 250 | 4 335 482 ± 493 513 |
NONE | FILE_CHANNEL | 207 793 ± 7 100 | 217 159 ± 5 160 | 212 588 ± 5 578 |
NONE | MAPPED | 2 473 632 ± 156 640 | 5 969 708 ± 63 353 | 9 398 697 ± 374 296 |
Гипотеза риска 3 подтвердилась с большим запасом. Форма из драфта — подтверждение на каждую
запись — даёт 80 592 записи в секунду. Та же запись батчами по 100 даёт 4 335 482. Разница
в 54 раза, и это на порядок больше, чем всё, что даёт выбор между mmap и FileChannel
(1,4 раза при батче 1).
Строка NONE/FILE_CHANNEL плоская — 208…217 тысяч независимо от размера батча. Это и есть
потолок пути с системным вызовом на запись: батчи там амортизируют круг по каналу, но не write.
У mapped-пути амортизировать есть что, поэтому он растёт до самого конца.
Разрыв между WRITTEN и NONE — цена круга по мейлбоксу: при батче 100 это 4,34 млн против
9,40 млн, то есть подтверждение стоит примерно половину пропускной способности. Дальше его
уменьшать нечем — только увеличивать батч.
2.3 Групповой коммит
Одна операция = [producers] одновременных долговечных записей по одной штуке. Оценка — это операции в секунду, а записи в секунду получаются умножением на число производителей; вся суть замера в том, что второе растёт, пока первое падает.
| Режим | Производителей | Операций/с | = долговечных записей/с |
|---|---|---|---|
FILE_CHANNEL | 1 | 253,8 ± 7,7 | 254 |
FILE_CHANNEL | 8 | 235,3 ± 12,1 | 1 882 |
FILE_CHANNEL | 64 | 118,4 ± 4,8 | 7 581 |
MAPPED | 1 | 243,6 ± 7,4 | 244 |
MAPPED | 8 | 245,5 ± 11,1 | 1 964 |
MAPPED | 64 | 136,7 ± 7,9 | 8 747 |
Групповой коммит работает. 64 производителя получают 7 581 долговечную запись в секунду против 254 у одного — в 30 раз больше при том же диске. Потолок в 250 барьеров в секунду никуда не делся, просто один барьер теперь обслуживает десятки производителей.
Падение операций в секунду с 254 до 118 при 64 производителях — не деградация, а арифметика: за одну операцию успевают пройти примерно два барьера вместо одного, потому что не все 64 корутины успевают встать в очередь до того, как актор начнёт писать первую группу.
Замер 3 (M-26): минута записи, а не две секунды
SustainedWriteProbe, записи по 1 КиБ, retention удерживает 512 МиБ на диске, суммарно записано
в 2,6 раза больше ОЗУ машины.
| Режим | Среднее | Медиана | Худшая секунда | Лучшая секунда | Лучшая/худшая |
|---|---|---|---|---|---|
MAPPED | 798 102 | 606 570 | 128 150 | 1 973 637 | 15,4× |
FILE_CHANNEL | 245 280 | 250 266 | 203 998 | 254 736 | 1,2× |
Два вывода, и второй важнее первого:
- Короткий замер завышает маппинг в десять раз. 8,88 млн записей в секунду в замере 2.1 против 0,80 млн здесь. Свежий сегмент, пустой page cache и две секунды — самый выгодный для маппинга случай из возможных.
- Худшая секунда у маппинга хуже, чем у
FileChannel. 128 тыс против 204 тыс. Среднее у маппинга втрое выше, но очереди и таймауты в брокере считают по худшей секунде, а не по средней. Разброс 15× означает, что планировать по среднему нельзя.
Замер 4 (M-24): msync и fsync — не одно и то же
DurabilityProbe, 32 МиБ грязных страниц на раунд, медиана 9 раундов. Полная таблица и метод —
ресёрч §1.9. Короткий ответ: после msync последующий
fsync стоит 92 % от голого fsync — то есть работа не была сделана. На одинаковом объёме
данных msync вдобавок дороже (16,45 мс против 9,33 мс).
Замер 5 (M-23): скорость восстановления
StartupProbe, 256 МиБ лога в 16 сегментах.
| Вариант чтения | Скорость | На гигабайт лога |
|---|---|---|
read на запись | 174 МиБ/с | 5,9 с |
| буфер 1 МиБ | 4 900 МиБ/с | 0,21 с |
Оба режима записи восстанавливаются одинаково. Открытый вопрос 1 ресёрча — нужен ли индексный файл — закрыт: не нужен, лог на 100 ГиБ открывается за ~21 с.
Замер 6 (M-34): сквозной путь по сокету
probeLoad, 8 соединений, конвейеризация, открытый цикл — задержка считается от момента,
когда запрос был должен уйти, а не когда ушёл. Записи по 128 Б, батчи по 10, WRITTEN,
транспорт SELECTOR, 15 с на точку.
| Цель, зап/с | Достигнуто | Записей/с | p50 | p99 | max |
|---|---|---|---|---|---|
| 5 000 | 5 001 (100 %) | 50 005 | 0,60 мс | 3,8 мс | 12 мс |
| 10 000 | 10 001 (100 %) | 100 005 | 0,26 мс | 5,4 мс | 17 мс |
| 20 000 | 20 001 (100 %) | 200 005 | 0,36 мс | 54 мс | 60 мс |
| 30 000 | 26 649 (89 %) | 266 487 | 956 мс | 1 973 мс | 2 012 мс |
| 40 000 | 26 813 (67 %) | 268 129 | 2 743 мс | 5 197 мс | 5 260 мс |
Потолок — около 267 тысяч записей в секунду, и это ровно потолок хранилища: JMH мерил
FILE_CHANNEL в 213 тысяч записей в секунду на одиночных append (замер 2.1). Числа сходятся
в пределах разницы методик. Сетевой слой практически ничего не отнимает — брокер упирается
в лог, а не в сокет.
Колено между 20 и 30 тысячами запросов в секунду: до него p50 держится в десятых долях миллисекунды, после — уходит в секунды и предложенная нагрузка перестаёт приниматься. Ровно то, ради чего замер открытый: закрытый цикл на 40 тысячах просто «достиг бы» 27 тысяч и показал бы приличную задержку, потому что сам перестал бы просить.
Замер 7 (M-35): стоит ли zero-copy своих денег
FETCH по 64 КиБ, SELECTOR, 8 соединений, 15 с. Тот же путь, отличается только тем, как тело
ответа попадает в сокет.
| Нагрузка | Режим | Достигнуто | p50 | p99 |
|---|---|---|---|---|
| 2 000 зап/с (125 МиБ/с) | ZERO_COPY | 100 % | 1,04 мс | 2,9 мс |
HEAP | 100 % | 0,98 мс | 3,4 мс | |
| 8 000 зап/с (500 МиБ/с) | ZERO_COPY | 100 % | 0,31 мс | 0,57 мс |
HEAP | 100 % | 0,29 мс | 0,56 мс | |
| 16 000 зап/с (1 ГиБ/с) | ZERO_COPY | 100 % | 0,19 мс | 6,4 мс |
HEAP | 100 % | 0,20 мс | 6,7 мс | |
| 32 000 зап/с (2 ГиБ/с) | ZERO_COPY | 100 % | 0,80 мс | 174 мс |
HEAP | 100 % | 7,16 мс | 312 мс | |
| 48 000 зап/с (3 ГиБ/с) | ZERO_COPY | 41 854 | 1 186 мс | 2 231 мс |
HEAP | 38 844 | 1 653 мс | 3 192 мс |
До гигабайта в секунду разницы нет вообще. Не «маленькая» — её нет: столбцы совпадают в пределах шума прогона. Если бы работа остановилась на замере с 16 тысячами запросов в секунду, честным выводом было бы «zero-copy не окупается», и решение Р3 пришлось бы пересматривать.
Разница появляется у насыщения. На 2 ГиБ/с при одинаковой принятой нагрузке ZERO_COPY
держит p50 в девять раз ниже (0,80 против 7,16 мс); на 3 ГиБ/с он ещё и принимает на 8 % больше
запросов. То есть sendfile покупает не пропускную способность как таковую, а запас: точка,
где брокер перестаёт справляться, отодвигается.
Оговорка, без которой цифры вводят в заблуждение. Замер идёт по loopback, где ядро всё равно
копирует, — то есть та самая копия, которую sendfile убирает, здесь убирается не полностью.
На настоящей сетевой карте разрыв должен быть больше, а не меньше. Проверить это на реальной
сети — задача M-38.
Замер 8 (M-36): селектор против виртуальных потоков
Тот же протокольный код, разные транспорты (см. Connection).
| Нагрузка | Транспорт | Достигнуто | p50 | p99 |
|---|---|---|---|---|
| PRODUCE 20 000 зап/с | SELECTOR | 20 000 (100 %) | 0,32 мс | 58 мс |
VIRTUAL_THREADS | 20 001 (100 %) | 1,19 мс | 197 мс | |
| PRODUCE 30 000 зап/с | SELECTOR | 26 381 (88 %) | 911 мс | 2 088 мс |
VIRTUAL_THREADS | 21 832 (73 %) | 3 056 мс | 4 370 мс | |
| FETCH 32 000 зап/с | SELECTOR | 31 997 (100 %) | 9,7 мс | 486 мс |
VIRTUAL_THREADS | 31 717 (99 %) | 733 мс | 1 295 мс |
Селектор выигрывает и по потолку, и по задержке. На насыщении PRODUCE он принимает на 21 % больше (26,4 против 21,8 тысяч), а по задержке разрыв кратный везде.
Причина известна заранее и подтверждается: transferTo блокирует на дисковом вводе-выводе,
а файловый ввод-вывод виртуальные потоки не виртуализуют (ресёрч §1.4). Страничный промах внутри
sendfile пиннит несущий поток, и пул виртуальных потоков превращается в обычный пул, который
можно застопорить диском.
Итог для решения Р3: собственный сетевой слой оправдался, но не тем, чем ожидалось
Р3 обосновывалось так: Ktor не отдаёт SocketChannel, а он нужен для sendfile — значит пишем
свой acceptor. Проверка показала, что посылка верна, а главная выгода оказалась другой:
- выигрыш даёт транспорт, а не
sendfile. Селектор против виртуальных потоков — плюс 21 % к потолку и кратно меньшая задержка, причём на любой нагрузке; sendfileне даёт ничего до гигабайта в секунду и начинает отыгрывать только у насыщения: на 2 ГиБ/с в девять раз меньше p50, на 3 ГиБ/с плюс 8 % пропускной способности;- значит, если бы Ktor отдавал
SocketChannel, отказ от него всё равно был бы обоснован — но аргументом «нужен свой контроль над готовностью», а не «нужен zero-copy».
Риск 2 ресёрча («zero-copy окажется незаметным на фоне остального») сбылся частично: он незаметен ниже гигабайта в секунду и заметен выше. Пересматривать Р3 не нужно.
Замер 9 (M-44): чего стоит конвейеризация
probeLoad с ограничением числа запросов в полёте. Глубина 1 — это строгий «запрос-ответ»:
следующий запрос не уходит, пока не пришёл предыдущий ответ. 8 соединений, PRODUCE, батчи по 10.
| Нагрузка | Глубина | Достигнуто | p50 | p99 |
|---|---|---|---|---|
| 5 000 зап/с | 1 | 5 001 (100 %) | 0,70 мс | 2,2 мс |
| 1024 | 5 001 (100 %) | 0,93 мс | 2,3 мс | |
| 10 000 зап/с | 1 | 10 001 (100 %) | 0,46 мс | 95,0 мс |
| 1024 | 10 001 (100 %) | 0,49 мс | 9,4 мс | |
| 20 000 зап/с | 1 | 14 026 (70 %) | 2 649 мс | 4 454 мс |
| 1024 | 17 231 (86 %) | 1 466 мс | 2 573 мс | |
| 30 000 зап/с | 1 | 13 227 (44 %) | 4 238 мс | 8 309 мс |
| 4 | 16 879 (56 %) | 3 607 мс | 6 501 мс | |
| 16 | 16 527 (55 %) | 3 372 мс | 6 686 мс | |
| 1024 | 17 376 (58 %) | 3 563 мс | 6 707 мс |
Три разных ответа на трёх участках, и ни один из них не «конвейеризация ускоряет»:
- На низкой нагрузке она не нужна и слегка вредит. 0,70 против 0,93 мс: без очереди запрос проходит напрямую, а конвейер добавляет собственный путь через канал.
- На средней нагрузке она покупает хвост, а не пропускную способность. Обе глубины держат 10 тысяч запросов в секунду, но p99 у «запрос-ответ» — 95 мс против 9,4 мс. У клиента без запаса любая заминка брокера немедленно сдвигает следующий запрос: пропустить такт нечем.
- У насыщения она покупает пропускную способность — около 25–30 %. И почти всё это даёт глубина 4: 16 879 против 17 376 у глубины 1024. Дальше — плоско.
Вывод для клиента: конвейер нужен, но неглубокий. Держать тысячу запросов в полёте незачем — выигрыш кончается на единицах, а память и время ответа на отмену растут.
Контрольный прогон, который важнее самой таблицы
Та же конфигурация, что в замере 8 (30 000 зап/с, SELECTOR, сегмент 128 МиБ, без ограничения
глубины), в замере 8 дала 26 381 запрос в секунду, а здесь — 16 665. Разница 37 %,
и она не объясняется ни семафором, ни размером сегмента: контроль ставился именно чтобы это
исключить.
Значит, состояние машины между сессиями изменилось — диск, заполненный на 94 % и принявший за эти прогоны десятки гигабайт, тепловой режим, page cache. Отсюда правило, которое до сих пор было предположением, а теперь показано:
Сравнивать можно только прогоны одной сессии, идущие подряд. Числа внутри одной таблицы сопоставимы между собой; числа из разных замеров этого документа — нет, даже на одном хосте.
Поэтому таблица выше построена из прогонов, шедших один за другим, и выводы сделаны только по их относительным различиям. Потолок в 267 тысяч записей в секунду из замера 6 остаётся верным для того прогона и не является характеристикой брокера навсегда.
Замер 10 (2026-08-10, перепрогон на двух хостах): что оказалось свойством macOS
Весь набор прогнан заново подряд, на двух машинах, одной ревизией кода.
| mac | linux | |
|---|---|---|
| Процессор | Apple M1, 8 ядер | x86_64, 20 ядер |
| Память | 16 ГиБ | 23 ГиБ |
| ФС | APFS, диск занят на 94 % | ext4, занят на 4 % |
| Прочее | ноутбук, load average ≈ 2,5 | WSL2, простаивает |
| JDK | 25.0.2 | 25.0.4 (подтянут Gradle сам) |
Профиль рантайма одинаковый. Запуск на Linux — ci/wsl-run.sh; он сам находит
линуксовый $HOME, потому что сессия WSL стартует в /mnt/c/..., и первый прогон уехал именно
туда — на 9p поверх NTFS. Для проекта, который меряет диск, это не «немного медленнее», а другой
предмет измерения, поэтому скрипт теперь такой путь отвергает.
Ни один из двух хостов не является чистым стендом, и это надо держать в голове при чтении всего ниже.
Мак — рабочий ноутбук с диском, занятым на 94 %, и load average около 2,5; во время прогона
probeSustainedWriteон вообще перестал отвечать.«Linux» — это WSL2: ext4 на виртуальном диске поверх NTFS, ядро Microsoft, планировщик ввода-вывода отключён, память отдаётся хостом динамически (ресёрч §1.12). Абсолютные числа оттуда — числа WSL2. Про долговечность он не свидетельствует вовсе: дойдёт ли
fsyncдо носителя, решает Hyper-V.Что отсюда всё-таки следует: относительные сравнения внутри одного прогона — два пути записи, два транспорта, две глубины конвейера — идут через один и тот же стек с обеих сторон и потому осмысленны. Именно на них построены выводы. Настоящий стенд — bare-metal Linux с NVMe, задача M-46.
10.1 Два вывода проекта оказались свойствами файловой системы
M-24: msync равносилен fsync на Linux и слабее на macOS — и это свойство API, а не ФС.
Единственный вывод перепрогона, который не зависит от чистоты стенда: он подтверждён
исходниками с обеих сторон (ресёрч §1.9). На Linux msync(MS_SYNC) вызывает vfs_fsync_range —
ту же функцию, что и fsync (msync.c:96). На macOS fsync до пластин не доводит вовсе,
для этого нужен F_FULLFSYNC — и JDK зовёт именно его
(FileDispatcherImpl.c:51), а msync не зовёт.
| Хост | fsync | msync | хвостовой fsync после msync | вывод |
|---|---|---|---|---|
| mac (APFS) | 8,81 мс | 13,96 мс | 6,95 мс = 79 % | msync барьером не является |
| linux (ext4) | 11,01 мс | 17,14 мс | 0,21 мс = 2 % | msync уже сделал работу |
Это ровно риск 4 ресёрча, сбывшийся буквально: «вывод переносится, проценты — нет» оказалось
слишком мягкой формулировкой. Переносится не вывод, а метод. Решение кода при этом
подтвердилось с двух сторон: MappedSegmentWriter.force() зовёт оба вызова, и это обязательно
на APFS и почти бесплатно (0,21 мс) на ext4 — то есть портируемо.
M-26: качели маппинга в WSL2 не воспроизвелись — но это не то же самое, что «их нет».
| Хост | Режим | Среднее | Худшая секунда | Лучшая/худшая |
|---|---|---|---|---|
| mac | MAPPED | 1 223 341 | 247 429 | 8,4× |
| mac | FILE_CHANNEL | 253 110 | 231 527 | 1,1× |
| linux | MAPPED | 1 694 363 | 1 613 063 | 1,1× |
| linux | FILE_CHANNEL | 1 302 035 | 1 268 188 | 1,1× |
В WSL2 маппинг не гуляет вовсе и при этом на 30 % быстрее. Но подтвердить это исходниками нечем, а writeback — ровно то, что виртуальный диск и меняет: под ext4 там ещё один кэш и ещё одна файловая система. Поэтому риск 1 остаётся открытым, а не снятым: до bare-metal Linux (M-46) это наблюдение, а не вывод.
Подтвердилось (M-46, замер 12.7). На ext4 арендованной машины разброс тот же 1,1× при 2,01 млн записей в секунду, и записано 7,6 объёма ОЗУ, а не 2,6. Наблюдение стало выводом: качели на маке — свойство APFS. Риск 1 закрыт.
10.2 Хранилище: Linux быстрее, и по-разному
| Замер (записей/с) | mac | linux | отношение |
|---|---|---|---|
FILE_CHANNEL 64 Б, без сброса | 215 206 | 2 465 259 | 11,5× |
MAPPED 64 Б, без сброса | 13 178 873 | 37 555 710 | 2,9× |
MAPPED 1 КиБ, без сброса | 8 429 818 | 11 697 878 | 1,4× |
FILE_CHANNEL, сброс на запись | 253 | 816 | 3,2× |
MAPPED, сброс на запись | 249 | 1 298 | 5,2× |
Разрыв в 11,5 раз на FILE_CHANNEL — это цена системного вызова, и она у macOS заметно выше.
Отсюда же и то, что на маке маппинг выглядит в 61 раз лучше FileChannel, а на Linux всего
в 15: бо́льшая часть «преимущества mmap» на маке была недостатком syscall'а.
10.3 Обратная инверсия: на латентности мак быстрее
PartitionWriter, батч 1, WRITTEN | mac | linux |
|---|---|---|
FILE_CHANNEL | 65 630 | 27 702 |
MAPPED | 88 056 | 28 124 |
Круг «корутина → канал → актор → ответ» на маке в 2,4–3,1 раза дешевле. Меньше ядер и быстрее межъядерная синхронизация плюс отсутствие прослойки WSL2. Правило M-14 от этого только крепнет: на Linux батч по 100 даёт 80× против 45× на маке.
10.4 Сквозной путь
| mac | linux | |
|---|---|---|
| Потолок PRODUCE | 268 270 записей/с | 1 245 583 записей/с |
| При 1 млн записей/с | — | p50 0,187 мс, p99 48 мс |
| FETCH на 4 ГиБ/с | не проверялось | 100 % цели, p50 0,154 мс |
| Селектор против виртуальных потоков (PRODUCE у потолка) | 20 001 против 20 001, p99 109 против 140 мс | 100 000 против 84 415, p50 0,19 против 1 445 мс |
| Конвейер: глубина 1 → 4 | 17 977 → 20 001 | 60 380 → 100 001 |
Выводы M3 и M4 подтвердились на обеих машинах и на Linux стали резче: селектор обгоняет виртуальные потоки, конвейер глубины 4 забирает всё, что даёт конвейеризация.
Одна деталь видна только на маке: глубина 1024 даёт p50 232 мс против 2,0 мс у глубины 4 при той же принятой нагрузке. Глубокая очередь не ускоряет, а копит — ещё один довод за «конвейер нужен неглубокий».
M-35 на Linux: до 4 ГиБ/с обе реализации принимают 100 % нагрузки; ZERO_COPY выигрывает
хвостом (p99 16,4 против 27,2 мс). Насыщения не достигли — картина та же, что на маке, только
граница дальше.
10.5 Во что обходится профиль на 20 ядрах
Замер 1 отвечал на этот вопрос на маке и на слое хранения: там профиль оказался бесплатным. Повтор на Linux и на сквозном пути, где живёт вся аллокация сетевого слоя:
| Нагрузка | Профиль | Достигнуто | p50 | p99 | GC |
|---|---|---|---|---|---|
| 100 000 зап/с | 64 МиБ, SerialGC | 100 000 (100 %) | 0,187 мс | 75,1 мс | 575 сборок, 234 мс (1,6 %) |
| 4 ГиБ, G1 | 100 000 (100 %) | 0,179 мс | 61,4 мс | 43 сборки, 37 мс (0,2 %) | |
| 200 000 зап/с | 64 МиБ, SerialGC | 125 199 (63 %) | 2 848 мс | 5 620 мс | 708 сборок, 425 мс (2,8 %) |
| 4 ГиБ, G1 | 129 470 (65 %) | 2 527 мс | 5 302 мс | 55 сборок, 50 мс (0,3 %) |
Профиль стоит около 3 % потолка (125 199 против 129 470) и части хвоста (p99 75 против 61 мс при миллионе записей в секунду). Сборок в тринадцать раз больше, но суммарная пауза мала в обоих случаях: 234 мс за 15 секунд против 37 мс.
Считать это ценой брокера нельзя: генератор нагрузки живёт в той же JVM и аллоцирует наравне (риск 6, M-37), так что 3 % — оценка сверху для процесса целиком. Вывод прежний и теперь проверен там, где нагрузка настоящая: ограничение в 64 МиБ почти ничего не отнимает, а взамен даёт проверяемое свойство «горячий путь не аллоцирует».
Переопределяется одним свойством, и RuntimeFootprint печатает то, что реально получил:
./gradlew :booblik-benchmark:probeLoad -Pargs="PRODUCE SELECTOR ZERO_COPY 8 100000 15" \
-Pbooblik.jvmArgs="-Xmx4G -XX:+UseG1GC -Xss1M"10.6 Что это значит для решения Р1
Умолчание FILE_CHANNEL выбиралось по данным замера 2, снятым на маке. На Linux — то есть на
платформе, где брокеры и живут, — маппинг выигрывает по всем осям сразу:
- долговечная запись: 1 298 против 816 записей/с (на маке они были неразличимы);
- дистанция: 1,69 млн против 1,30 млн, и без качелей (на маке качели 8,4× были главным аргументом против);
- бурст: 37,6 млн против 2,5 млн.
Единственный оставшийся довод за FILE_CHANNEL — не про скорость: у него границу лога задаёт
длина файла, а у маппинга предразмеченный файл не даёт восстановлению ничего, кроме нулевого
префикса, и без контрольных сумм оборванная запись там детектируется хуже (риск 5, M-60).
Умолчание не менялось тем прогоном: производительность перестала быть аргументом в пользу
FILE_CHANNEL, но заменять довод о корректности на довод о скорости было бы ровно той ошибкой,
из-за которой msync полгода считался барьером. Решение было вынесено в M-45, после M-60 —
и принято там (см. итог в конце документа).
Замер 11 (M-45): переживает ли преимущество маппинга сокет
Всё, на чём стояло решение о пути записи, до сих пор мерилось на слое хранения. Между ним и
клиентом лежат кодек, селектор, актор и сеть, и любой из них мог оказаться узким местом, после
которого выбор пути перестаёт что-либо значить. Стенд к тому же брал FILE_CHANNEL жёстко —
то есть все сквозные числа проекта описывали брокер, которым после смены умолчания никто бы не
работал.
Linux (WSL2), 8 соединений, PRODUCE батчами по 10, один прогон подряд:
| Цель | Путь записи | Достигнуто | Записей/с | p50 | p99 |
|---|---|---|---|---|---|
| 100 000 зап/с | FILE_CHANNEL | 88 454 (88 %) | 884 536 | 1 636 мс | 1 902 мс |
MAPPED | 100 000 (100 %) | 1 000 005 | 0,202 мс | 105 мс | |
| 200 000 зап/с | FILE_CHANNEL | 93 730 (47 %) | 937 301 | 4 073 мс | 7 982 мс |
MAPPED | 128 920 (64 %) | 1 289 201 | 2 278 мс | 5 285 мс |
Переживает, и это не про проценты. На миллионе записей в секунду FILE_CHANNEL уже за
коленом — принимает 88 % нагрузки при задержке в полторы секунды, — а MAPPED держит всю
нагрузку при p50 в двести микросекунд. Разница не «на 13 % больше запросов», а «укладывается
или нет». Потолок выше на 37 % (1,29 млн против 0,94 млн).
Оговорка на месте: это WSL2 (§1.12), и абсолютные числа оттуда — числа WSL2. Но сравнение внутри одного прогона идёт через один и тот же стек с обеих сторон, а именно оно здесь и нужно.
Итог для решения Р1: умолчание — MAPPED
В замере 1 умолчание стояло на FILE_CHANNEL условно — до трёх проверок. Проверки прошли,
умолчание не поменялось, но основание у него теперь другое:
- под долговечностью два пути неразличимы (251 против 254 записи в секунду). Всё превосходство
маппинга в этом режиме было артефактом
msync; - без долговечности маппинг быстрее в 21 раз на бурсте (4,34 млн против 209 тыс) — и втрое на минутной дистанции, с разбросом в 15 раз;
- худшая секунда у маппинга хуже, а брокер провизионируется по худшей секунде;
- и выбор пути записи вообще не главный рычаг: батчи дают 54 раза, а выбор между путями —
1,4 раза при том же размере батча. (Отозвано в M-46: это свойство
FILE_CHANNEL, который перестаёт отвечать на рост батча. У маппинга батч даёт 58,6×, а выбор пути — до 10 раз; замер 12.3.)
MAPPED остаётся полноценным режимом и осознанным выбором для нагрузки, где среднее важнее
худшего случая. Умолчанием он не становится.
Итог: умолчание MAPPED (M-45)
Решение принято после того, как M-60 закрыла последнее возражение, которое было не про скорость. Хронология, потому что она объясняет больше, чем сам вывод:
| Когда | Умолчание | На каком основании |
|---|---|---|
| M0 | FILE_CHANNEL | условно, до трёх проверок |
| M-15 | FILE_CHANNEL | сравнение нечестное (у FileChannel системный вызов на запись, батчей ещё не было), гарантии msync неясны, дистанция не проверена |
| замер 10 | FILE_CHANNEL | производительность перестала быть аргументом, остался один — про восстановление: у предразмеченного маппинга не было границы лога, и без контрольных сумм рваная запись не детектировалась |
| M-60 | — | контрольная сумма делает рваную запись видимой на обоих путях. Довод о корректности исчез |
| M-45 | MAPPED | и слой хранения (замер 10), и сквозной путь (замер 11) в пользу маппинга по всем осям |
Что это меняет на практике, помимо скорости:
- Каждый сегмент занимает свою ёмкость на бумаге с первой же записи. Свежий брокер с четырьмя
партициями показывает четыре файла по 512 МиБ до того, как в нём появится хоть одна запись.
Файлы разрежённые, место не занято — но
lsи любая сигнализация по месту читают видимый размер. Проверяется тестомModeMigrationTest. -Xmxбольше не говорит ничего о потреблении памяти процессом. Память маппинга — ни куча, ни direct-buffer, так что профиль в 64 МиБ ограничивает всё, кроме самого крупного потребителя.- На машине разработчика будет хуже, чем в проде. На APFS маппинг гуляет в 8,4 раза на минутной дистанции (замер 10.1), на ext4 — не гуляет вовсе. Разработчик увидит худший из вариантов.
- Данные переживают смену умолчания в обе стороны — проверено
ModeMigrationTest. Откат наFILE_CHANNEL— одна строка в конфигурации, и он читает то, что записал маппинг.
Что оставалось непроверенным на момент M-45: всё «линуксовое» было снято в WSL2, где ext4 лежит на виртуальном диске (§1.12). Проверено в M-46 — см. замер 12 ниже. Умолчание подтвердилось по всем осям, а два довода против маппинга из этой же таблицы («под долговечностью пути неразличимы», «худшая секунда у маппинга хуже») оказались свойствами файловых систем, на которых их измерили, и отозваны.
Замер 12 (M-46, 2026-08-10): арендованная Linux-машина
Первый стенд проекта, который не WSL2 и не рабочий ноутбук. Паспорт — ресёрч §1.15: Hetzner
nbg1, 4 ядра AMD EPYC-Milan, 15 ГиБ, Ubuntu 26.04, ядро 7.0.0-29-generic, ext4 на локальном
sda, KVM. Барьеры настоящие: fio с fdatasync на каждую запись 4 КиБ даёт 1224 IOPS,
fdatasync p99 — 1074 мкс. JDK — дистрибутивный OpenJDK 25.0.3+9-2-26.04.2-Ubuntu.
Профиль рантайма штатный, машина ничем другим не занята.
12.0 Прогон, который пришлось выбросить
Первый probeDurability отсюда дал fsync 0,01 мс после 32 МиБ грязных данных и напечатал
вердикт про msync в обычном тоне. Это была не погрешность: /tmp на Ubuntu 26.04 — tmpfs, и
проба честно измерила оперативную память. Правило 7а и MeasurementDir появились отсюда;
дальше всё снято в build/measurements на ext4 on /dev/sda1, и носитель печатается в шапке.
12.1 M-24: msync против fsync на настоящем ext4
| Что | Медиана | Мин | Макс |
|---|---|---|---|
fsync один (путь FILE_CHANNEL) | 11,36 мс | 10,55 | 18,10 |
msync один (путь MAPPED) | 12,34 мс | 11,23 | 20,50 |
msync + fsync на том же файле | 12,33 мс | 11,79 | 19,33 |
из них хвостовой fsync | 0,11 мс | 0,10 | 0,18 |
Хвостовой fsync — 1 % от одиночного (на WSL2 было 2 %, на APFS — 79 %). Вывод §1.9
подтверждён на ext4, который ни в чьей виртуальной файловой системе не лежит: на Linux msync
барьером является, второй вызов в MappedSegmentWriter.force() стоит здесь примерно ничего и
остаётся обязательным на macOS.
12.2 Слой хранения: запись в сегмент
Записей в секунду, SegmentAppendBenchmark, ± — доверительный интервал 99,9 %.
| Сброс | Размер записи | FILE_CHANNEL | MAPPED | Отношение |
|---|---|---|---|---|
| нет | 64 Б | 802 339 ± 42 599 | 11 131 120 ± 224 341 | 13,9× |
| нет | 1024 Б | 487 496 ± 16 525 | 6 120 765 ± 128 787 | 12,6× |
| на каждую запись | 64 Б | 1 659 ± 320 | 2 769 ± 237 | 1,7× |
| на каждую запись | 1024 Б | 1 327 ± 196 | 2 298 ± 288 | 1,7× |
Под долговечностью пути больше не неразличимы. В замере 10 на WSL2 они сходились (251 против
254) — и это было следствием того, что msync там ничего не стоил в сравнении с общей ценой
барьера. Здесь маппинг даёт 1,7× и в режиме со сбросом на каждую запись, причём разрыв одинаков
на обоих размерах записи.
12.3 Актор записи: батч и политика подтверждения
PartitionWriterBenchmark, записей в секунду.
| Подтверждение | Батч | FILE_CHANNEL | MAPPED | Отношение |
|---|---|---|---|---|
WRITTEN | 1 | 38 852 ± 6 329 | 41 433 ± 4 209 | 1,07× |
WRITTEN | 10 | 234 136 ± 21 751 | 401 772 ± 24 530 | 1,7× |
WRITTEN | 100 | 556 062 ± 75 908 | 2 427 785 ± 390 816 | 4,4× |
NONE | 1 | 713 692 ± 49 409 | 3 541 202 ± 1 263 937 | 5,0× |
NONE | 10 | 785 428 ± 11 777 | 7 022 573 ± 461 221 | 8,9× |
NONE | 100 | 808 713 ± 11 409 | 8 280 293 ± 572 456 | 10,2× |
Единица записи по-прежнему главный рычаг, но уже не единственный. У FILE_CHANNEL батч
со 1 до 100 даёт 14,3× (WRITTEN), у MAPPED — 58,6×. Вывод риска 3 («батчи дают 54 раза,
выбор пути — полтора») верен для FILE_CHANNEL и неверен для маппинга на этом железе:
здесь выбор пути даёт до 10×, потому что FILE_CHANNEL упирается в системный вызов на запись
и перестаёт отвечать на увеличение батча — 785 тыс против 809 тыс между батчем 10 и 100.
12.4 Групповой коммит под долговечностью
GroupCommitBenchmark, коммитов в секунду при FORCED.
| Продюсеров | FILE_CHANNEL | MAPPED | Отношение |
|---|---|---|---|
| 1 | 1 110 ± 226 | 1 801 ± 374 | 1,6× |
| 8 | 1 018 ± 188 | 1 618 ± 138 | 1,6× |
| 64 | 619 ± 77 | 1 209 ± 65 | 2,0× |
Обе кривые падают с ростом числа продюсеров, и это ожидаемо: под FORCED группа коммитится
целиком, а чем больше участников, тем больше в группе ожидания. Важнее, что преимущество
маппинга с ростом конкуренции растёт, а не тает.
12.5 M-23: восстановление
StartupProbe, 256 МиБ лога в 16 сегментах, три попытки подряд.
| Режим | Попытка 1 | Попытка 2 | Попытка 3 | На гигабайт лога |
|---|---|---|---|---|
MAPPED | 1 546 МиБ/с | 3 316 МиБ/с | 3 413 МиБ/с | 0,30 с |
FILE_CHANNEL | 2 111 МиБ/с | 3 318 МиБ/с | 3 336 МиБ/с | 0,31 с |
Оба пути восстанавливаются одинаково — как и на маке (замер 5), где было 4 900 МиБ/с. Здесь медленнее, и это ожидаемо: там APFS на локальном SSD ноутбука, тут ext4 через KVM. Вывод, ради которого проба существует, не меняется: лог на 100 ГиБ открывается за полминуты, отдельный индексный файл не нужен.
Первая попытка всегда медленнее остальных — прогрев page cache, а не свойство режима.
Читать отсюда «FILE_CHANNEL восстанавливается быстрее» нельзя: на попытках 2 и 3 разница
0,5 %, то есть её нет.
Попутно видно то, чего проба не измеряет, но печатает: запись 1,85 млн записей заняла 0,4 с
через маппинг и 2,5 с через FileChannel — те же 6× разрыва, что и в 12.3, полученные
безо всякого JMH.
12.6 Сквозной путь по сокету
probeLoad, открытый цикл, 8 соединений, глубина конвейера 64, батчи по 10 записей на 128 Б,
15 с. Задержка отсчитывается от момента, когда запрос был должен уйти.
| Цель, запросов/с | Режим | Принято | p50 | p99 | Записей/с |
|---|---|---|---|---|---|
| 100 000 | MAPPED | 100 % | 8,3 мс | 494 мс | 997 351 |
| 100 000 | FILE_CHANNEL | 44 % | 4 420,8 мс | 8 262,8 мс | 444 799 |
| 2 000 000 | MAPPED | 6 % | 6 840,9 мс | 13 933,5 мс | 1 149 243 |
| 2 000 000 | FILE_CHANNEL | 2 % | 7 562,3 мс | 14 537,5 мс | 446 878 |
Потолок: 1 149 243 против 446 878 записей в секунду — 2,6×. На WSL2 (замер 11) тот же разрыв
был 1,37×. Заметен он и по тому, что FILE_CHANNEL упирается в одно и то же число — 445 и 447
тысяч — независимо от того, просят у него 100 тысяч запросов в секунду или два миллиона: он
насыщен уже на первой цели.
Разница с замером 11 — не «стало лучше», а другая машина. Там было 20 ядер, здесь 4, и стенд живёт в той же JVM (правило 6, задача M-37). На четырёх ядрах генератор отбирает у брокера заметную долю процессора, поэтому абсолютные задержки здесь хуже во всех строках: 8,3 мс на p50 против 0,202 мс на WSL2 при той же цели. Сравнивать между хостами эти числа нельзя; сравнивать два режима внутри этой таблицы — можно, и именно это она показывает.
M-37 из «хорошо бы» превращается в необходимое. Пока стенд и брокер делят четыре ядра и 64 МиБ кучи, сквозные числа с такой машины описывают пару «брокер + нагрузчик», а не брокер.
12.7 M-26: минута записи
SustainedWriteProbe, записи по 1 КиБ, retention удерживает лог на диске, 60 с.
| Режим | Среднее | Медиана | Худшая секунда | Лучшая секунда | Лучшая/худшая | Записано |
|---|---|---|---|---|---|---|
MAPPED | 2 012 598 | 2 014 655 | 1 924 500 | 2 058 947 | 1,1× | 115,1 ГиБ (7,6× ОЗУ) |
FILE_CHANNEL | 475 935 | 473 587 | 458 062 | 486 372 | 1,1× | 27,2 ГиБ (1,8× ОЗУ) |
Это отменяет главное возражение против маппинга. В замере 3 на APFS у маппинга разброс был
15,4×, а худшая секунда (128 тыс) — хуже, чем у FILE_CHANNEL (204 тыс); отсюда и был вывод
«брокер провизионируется по худшей секунде, поэтому умолчание не меняем». На настоящем ext4
разброса нет вовсе — 1,1×, ровно как у FILE_CHANNEL, — а худшая секунда маппинга
(1 924 500) в 4,0 раза лучше лучшей секунды FILE_CHANNEL (486 372).
То есть довод, который держал умолчание на FILE_CHANNEL до M-45, был свойством APFS, а не
свойством пути записи. На носителе, куда брокер поедет, планировать по худшей секунде для
маппинга не только можно — она всё равно вчетверо выше всего, что даёт второй путь.
Обе дистанции пройдены с запасом по объёму: маппинг записал 7,6 объёма ОЗУ машины, то есть writeback насыщался по-настоящему, а не работал в кэше.
Итог M-46: умолчание MAPPED подтверждено на железе, а два вывода проекта отозваны
Веха закрывает риск 1 и открытый вопрос «что из измеренного было свойством стенда». Все прежние числа снимались на рабочем ноутбуке (APFS) и в WSL2 (ext4 на VHDX поверх NTFS); здесь — ext4 на локальном диске машины, которая больше ничем не занята, с проверенно честными барьерами (§1.15).
Подтвердилось:
- §1.9 — на Linux
msyncявляется барьером. Хвостовойfsyncстоит 1 % от одиночного (12.1). Вызов обоих вMappedSegmentWriter.force()остаётся правильным: здесь он бесплатен, на macOS обязателен. - Умолчание
MAPPED(M-45). Маппинг быстрее по всем осям без исключения: 13,9× на записи в сегмент без сброса, 1,7× со сбросом на каждую запись, 4,2× в акторе подWRITTEN, 1,6–2,0× на групповом коммите подFORCED, 2,6× по потолку сквозного пути и 4,2× по худшей секунде на минутной дистанции. - Отдельный индексный файл не нужен (12.5): 0,30 с на гигабайт лога.
Отозвано:
- «Под долговечностью два пути неразличимы» (замер 10, WSL2: 251 против 254). На настоящем ext4 маппинг даёт 1,7× и в режиме сброса на каждую запись (12.2).
- «Худшая секунда у маппинга хуже, а брокер провизионируется по худшей секунде» (замер 3,
APFS). Это было свойством APFS: на ext4 разброс 1,1× вместо 15,4×, и худшая секунда маппинга
вчетверо лучше лучшей секунды
FILE_CHANNEL(12.7). - «Батчи дают 54 раза, выбор пути записи — полтора» (риск 3). Верно для
FILE_CHANNELи неверно для маппинга: тот отвечает на рост батча до 58,6×, тогда какFILE_CHANNELперестаёт отвечать между батчем 10 и 100 (12.3).
Осталось непроверенным:
- Это KVM, а не bare-metal. Барьеры честные (
fio:fdatasyncp99 = 1074 мкс), но «дошло до устройства» здесь означает «дошло до того, что показывает гипервизор». Для сравнения путей записи это неважно — обе стороны идут через один стек; для абсолютных чисел долговечности оговорка остаётся. - Сквозные числа описывают пару «брокер + нагрузчик» (12.6): на четырёх ядрах стенд в той же JVM отбирает у брокера заметную долю процессора. M-37 из желательной становится необходимой.
- M-38 этим стендом не решается. Zero-copy по замеру 7 не проявляется ниже ≈ 1 ГиБ/с, линк между машинами даёт 0,98 ГиБ/с и то суммарно на четырёх потоках. Нужна сеть от 10 Гбит/с, иначе результат неотличим от шума.
Замер 13 (M-38, 2026-08-10): zero-copy через настоящую сеть
Замер 7 сравнивал ZERO_COPY и HEAP по loopback и нашёл, что ниже гигабайта в секунду
разницы нет вовсе. Там же записана оговорка: по loopback ядро копирует в любом случае, поэтому
та самая копия, которую убирает sendfile, убирается не полностью, и на настоящей карте разрыв
должен быть больше. Здесь это проверено.
Стенд — две машины. Брокер на 10.0.0.2 (арендованная машина из §1.15, отдельный процесс,
штатный профиль JVM), генератор на 10.0.0.3. Между ними — 7,85 Гбит/с по iperf3, и это
существенно: провод здесь узкое место, а не брокер. FETCH по 1 МиБ, записи по 8 КиБ, 4 соединения,
глубина конвейера 64, 20 с. Лог — 1,6 ГиБ, целиком в page cache, так что диск из вопроса убран
намеренно: вопрос про копию, а не про чтение.
Процессор брокера читается из /proc на его собственном хосте, а не из процесса-генератора:
когда упирается провод, единственная оставшаяся ось — это процессор на отданный байт, и число,
которое одна машина выдумала бы про другую, не стоило бы ничего.
13.1 При равной отданной нагрузке
400 запросов в секунду по 1 МиБ — обе конфигурации принимают 100 % и отдают ровно одно и то же. Три прохода: первый, контрольный в обратном порядке (чтобы очерёдность и прогрев не выдавали себя за эффект) и ещё один.
| Проход | Режим | Отдано | ЦП брокера | ЦП на ГиБ | p50 |
|---|---|---|---|---|---|
| 1 | ZERO_COPY | 396,8 МиБ/с | 4,47 с | 0,577 с | 4,114 мс |
| 1 | HEAP | 396,8 МиБ/с | 12,59 с | 1,625 с | 6,087 мс |
| 2 (обратный) | HEAP | 396,8 МиБ/с | 12,70 с | 1,639 с | 5,988 мс |
| 2 (обратный) | ZERO_COPY | 396,8 МиБ/с | 4,56 с | 0,588 с | 4,358 мс |
| 3 | HEAP | 396,8 МиБ/с | 11,11 с | 1,434 с | 5,960 мс |
| 3 | ZERO_COPY | 396,8 МиБ/с | 4,38 с | 0,565 с | 5,657 мс |
HEAP стоит в 2,5–2,8 раза больше процессора за ту же работу. Байты те же, ответы те же,
разница только в том, трогал ли их процессор по дороге. Отношение держится во всех трёх проходах
(2,82×, 2,79×, 2,54×) и не зависит от порядка — это и есть результат замера.
А вот выигрыш по задержке заявлять нельзя. В первых двух проходах ZERO_COPY шёл по медиане
на 27–32 % лучше, в третьем — на 5 %, что внутри разброса. Три прохода одной сессии подряд
расходятся между собой сильнее, чем режимы в третьем проходе, поэтому честная формулировка:
на этой нагрузке разница по задержке не установлена. Она была бы легко «установлена» одним
прогоном, и это ровно тот случай, ради которого правило 3 существует.
13.2 На потолке
3 000 запросов в секунду по 1 МиБ — заведомо больше, чем пролезет в провод.
| Режим | Отдано | ЦП брокера | ЦП на ГиБ |
|---|---|---|---|
ZERO_COPY | 916,3 МиБ/с (7,16 Гбит/с) | 6,35 с | 0,355 с |
HEAP | 867,9 МиБ/с (6,78 Гбит/с) | 15,15 с | 0,894 с |
Оба режима упираются в провод, а не в брокер. iperf3 на этом линке даёт 7,85 Гбит/с;
ZERO_COPY доносит 7,16 Гбит/с полезной нагрузки, то есть 91 % от того, что вообще пролезает.
Поэтому по пропускной способности разрыв всего 5,6 % — не потому, что zero-copy мало даёт,
а потому, что упираться больше некуда.
Удельная цена падает у обоих с ростом нагрузки (0,577 → 0,355 у ZERO_COPY, 1,625 → 0,894
у HEAP): чем крупнее пачки, тем меньше пробуждений на байт. Отношение при этом держится —
2,5× на потолке против 2,8× на фиксированной нагрузке.
Итог M-38: оговорка замера 7 подтвердилась, но выигрыш не в том, что мерили
Предсказание сбылось, но не по той оси, на которую смотрел замер 7. Там сравнивали принятую нагрузку и перцентили — и по этим осям здесь почти ничего: 5,6 % пропускной способности на потолке, а по задержке разница между тремя проходами больше, чем между режимами, то есть не установлена вовсе. Настоящая разница — 2,5–2,8 раза процессора на тот же отданный байт, и замер 7 её не видел просто потому, что не мерил процессор.
Отсюда же — методическая поправка на будущее: у нагрузочного замера, упирающегося в провод, пропускная способность и перцентили перестают быть информативными. Обе конфигурации отдают ровно то, что пролезает, и выглядят одинаково; вся разница уходит в то, чего стенд не печатал.
Что это меняет:
- Поправка к решению Р3 уточняется. Записано было: «свой сетевой слой окупился транспортом,
а не
sendfile; zero-copy не даёт ничего ниже гигабайта в секунду». Первая половина в силе. Вторая неверна на настоящей сети: ниже гигабайта в секунду zero-copy не даёт пропускной способности, но отдаёт втрое больше процессора под другую работу. Для брокера, который одновременно принимает записи, это не мелочь. - Гигабитный линк ничего бы не показал. Стенд с 7,85 Гбит/с оказался достаточным ровно потому, что при 400 МиБ/с — вчетверо ниже потолка — разница уже видна на процессоре. Прежняя оценка «нужна сеть от 10 Гбит/с» исходила из того, что мерить будем пропускную способность; для процессора хватает гораздо меньшего.
SendfileTest(M-63) становится важнее, чем казался. Он проверяет предусловие — что вtransferToуходит настоящийSocketChannel, — и теперь известна цена его нарушения: обёртка над сокетом стоит втрое больше процессора и не меняет ни одного байта в ответе.
Чего этот замер не говорит. Данные лежали в page cache; с холодным логом к копии добавится чтение с диска, и доля копии в общей цене станет меньше. Провод здесь узкое место — на 25 Гбит/с картина по пропускной способности будет другой, по процессору на байт скорее нет. И стенд KVM (§1.15): часть сетевой обработки идёт через гипервизор.
Замер 14 (M-75): чего стоит публикация highWatermark
Долгий FETCH требует, чтобы писатель сообщал о продвижении лога, — а это запись на горячем пути, в единственном месте, которое проект защищает от лишней работы. Вопрос замера: сколько она стоит.
A/B в одном прогоне, mainWriterBenchmark, WSL-машина, подряд: та же конфигурация со строкой
watermark.value = log.nextOffset и без неё.
Мак для этого не годится, и это выяснилось первым. Там A/B дал разброс до ±40 % и разницу, меняющую знак: «убрал запись — стало вчетверо медленнее». Числа были не про правку, а про загруженный ноутбук. На WSL интервалы 2–8 %, и только после этого сравнение стало возможным. Правило 7 в действии: измеритель проверяется раньше предмета.
| Подтверждение | Батч | Режим | С watermark | Без | Δ | Вне интервалов |
|---|---|---|---|---|---|---|
WRITTEN | 1 | FILE_CHANNEL | 40 883 | 29 131 | +40,3 % | нет |
WRITTEN | 10 | MAPPED | 382 062 | 387 723 | −1,5 % | нет |
WRITTEN | 100 | MAPPED | 2 656 111 | 2 562 455 | +3,7 % | нет |
NONE | 1 | MAPPED | 2 871 080 | 3 115 117 | −7,8 % | да |
NONE | 10 | MAPPED | 8 031 780 | 7 963 981 | +0,9 % | нет |
NONE | 100 | MAPPED | 10 153 188 | 10 490 833 | −3,2 % | нет |
(Полные двенадцать строк — в отчёте прогона; здесь показаны крайние и та единственная, где разница вышла за интервалы.)
Стоимость — одна запись на групповой коммит, ≈ 27 нс. Считается из единственной строки, где эффект отделился от шума: 348,3 нс на коммит против 321,0 нс. Это ровно та конфигурация, где он и обязан был проявиться — коммит на каждую запись, без барьера, самый быстрый путь записи, то есть максимально возможная частота публикаций.
При батчах эффект исчезает, и это не удача, а арифметика. Публикация происходит раз на коммит, а не раз на запись: при батче 100 те же 27 нс приходятся на сотню записей — 0,27 нс на запись, что ниже разрешения стенда. Одиннадцать строк из двенадцати это и показывают.
Вывод для M-75: цена приемлема, гейта не требует. Единственный режим, где она видна, — коммит на каждую запись, а он и так противопоказан: M-14 показала, что батч стоит 54 раза. Платить 8 % в конфигурации, которая и без того в 54 раза хуже доступной, — не тот размен, ради которого стоит усложнять код условной публикацией «только когда есть ожидающие».
Оговорка. Одна выделяющаяся строка из двенадцати при доверительном интервале 99,9 % могла бы быть и случайностью (~1 %). Но она оказалась ровно там, куда её помещает теория, и величина совпадает с ценой volatile-записи. Отдельного подтверждения это не заменяет; если однажды понадобится, повторить надо на арендованной машине, а не на WSL.
Замер 15 (M-74): что купил долгий FETCH и чего стоит подписка
Две половины, и у них разная судьба: первая дала число, вторая дала отказ — и он такой же результат, как число.
15.1 Холостой трафик догнавшего потребителя
То, ради чего делалась M-75. Догнавшему потребителю нечего читать; весь вопрос в том, как часто он спрашивает. Восемь потребителей, 20 с, мак.
| Стратегия | Запросов за окно | В секунду | На потребителя | Предсказание |
|---|---|---|---|---|
| опрос каждые 50 мс | 2 952 | 147,6 | 18,45 | 20,00 |
| долгий FETCH, ожидание 2 с | 80 | 4,0 | 0,50 | 0,50 |
Измеренное сходится с арифметикой, и колонка «предсказание» в отчёте стоит именно для этого:
частота — это 1/интервал против 1/ожидание. Опрос слегка отстаёт от предсказанных 20/с, потому
что сам poll() занимает время сверх задержки; удержание совпало точно.
Отношение — это отношение интервала к ожиданию, а не свойство реализации. На замеренных настройках 37×; на поставляемом умолчании (опрос 50 мс против ожидания 30 с) это 600×.
Первый прогон был поставлен неправильно, и это стоит запомнить. При ожидании 30 с в десятисекундном окне не обновился ни один запрос: «ноль запросов» — правда, которая не говорит ничего о частоте, а отношение из неё получается делением на ноль. Ожидание должно быть короче окна, иначе меряется окно.
Чего это не отменяет. Догнавший потребитель не бесплатен и с долгим FETCH: он держит соединение на партицию. Он перестаёт быть источником трафика — это другой ресурс, и путать их не надо.
15.2 Чего стоит Flow против голого poll() — не измерено
Вопрос был: сколько стоит подписка против цикла вокруг Consumer.poll() на тех же записях.
Ответа нет, и вот почему.
| Проход | Мак | WSL |
|---|---|---|
| 1 (холодный) | +49,0 % | +49 % |
| 2 | −9,0 % | +22,6 % |
| 3 | −2,8 % | +1,1 % |
| 4 | +9,5 % | −38,2 % |
Первый проход — чистый прогрев JIT. Три чтения идут подряд в одной JVM: первое платит за компиляцию, последнее её наследует. Если бы проба печатала один проход — а она сначала так и делала, — выводом было бы «обёртка на 49 % быстрее того, что оборачивает», то есть заведомая бессмыслица, поданная как измерение.
Установившиеся проходы расходятся сильнее возможного эффекта — от −38 % до +23 %. Это не шум вокруг маленькой разницы, это отсутствие разрешения. Честный вывод: стенд не может ответить на этот вопрос, и выбирать из этих проходов удобный было бы подгонкой.
Упирается это в M-37. Стенд и брокер живут в одной JVM и делят кучу, планировщик и GC; при чтении миллионов записей в секунду планирование и составляет измеряемую величину. Пока они не разведены по процессам, разница в единицы процентов между двумя способами читать одни и те же байты неразличима. M-74 закрыта наполовину, и вторая половина — не «доделать пробу», а сделать M-37.
Замер 16 (M-37): чего стоило соседство стенда и брокера
Стенд и брокер жили в одной JVM с M3. Это записано в правиле 6 как оговорка ко всем сквозным числам; здесь она наконец измерена, а не оговорена.
A/B в одной сессии, две арендованные машины (§1.15), брокер отдельным процессом на 10.0.0.2,
генератор на 10.0.0.3, между ними 7,85 Гбит/с. Контроль — тот же probeLoad на той же машине
через час, со стендом внутри процесса брокера. PRODUCE, 8 соединений, батчи по 10 записей на 128 Б.
| Цель, запросов/с | Разнесённые процессы | Стенд в процессе брокера |
|---|---|---|
| 100 000 | принято 99 660 (100 %), p50 0,736 мс, p99 102,9 мс | принято 99 702 (100 %), p50 6,762 мс, p99 531,1 мс |
| 2 000 000 (потолок) | 140 475 зап/с = 1 404 750 записей/с | 117 669 зап/с = 1 176 689 записей/с |
Соседство стоило 9,2× по медиане и 19 % потолка. И это нижняя оценка: разнесённый вариант дополнительно тащит трафик через настоящую сеть, то есть платит за то, чего у контроля нет вовсе. Хвост показателен отдельно — p99 упал в 5,2 раза: конкуренция за процессор и общий GC бьют именно по хвосту, а сквозные числа всех прошлых вех измерялись с этим внутри.
Что это значит для замера 12.6. Числа там описывали пару «брокер + нагрузчик» на четырёх ядрах, как и было честно написано. Теперь известен масштаб поправки: медиана там завышена примерно на порядок. Пересчитывать старую таблицу не нужно и нельзя — сравнивать прогоны разных сессий запрещено правилом 3, — но читать её теперь следует с этим коэффициентом.
16.1 Стоимость Flow — по-прежнему не измерена, и причина другая
M-74 не смогла сравнить подписку с голым poll() и списала это на общую JVM. Разнесли —
не помогло:
| Проход | Flow против цикла |
|---|---|
| 1 | +119,8 % |
| 2 | +37,4 % |
| 3 | +8,9 % |
| 4 | +71,5 % |
Flow стабильно быстрее того, что оборачивает, во всех четырёх проходах. Обёртка так не
может, и разброс 9–120 % это подтверждает. Параметры запросов у обоих путей совпадают
(maxBytes 1 МиБ, ожидание 0), а установка у подписки дороже — лишнее соединение и
round-trip METADATA, — то есть она должна проигрывать, а не выигрывать.
Вывод не «подписка быстрее», а «проба сравнивает две программы, а не две абстракции».
Систематическая разница, которую нельзя объяснить, — это диагноз измерителя, а не результат.
Прежде чем спрашивать снова, пробу надо переделать так, чтобы оба пути отличались только
наличием Flow: одно и то же соединение, одна и та же установка, один и тот же цикл выборки.
Заведено как M-76.
Замер 17 (M-76): чего стоит Flow в подписке
Вопрос задавался дважды и дважды не получал ответа: сначала стенд делил JVM с брокером
(замер 15.2), потом их развели, и Flow вышел стабильно быстрее оборачиваемого цикла
(замер 16.1) — что невозможно и означало сломанный измеритель. M-76 чинила измеритель.
17.1 Почему прошлое сравнение было неверным
Диагноз оказался содержательнее числа. Подписка построена на callbackFlow, а он ставит канал
между циклом выборки и коллектором: следующий запрос уходит, пока предыдущий батч ещё разбирают.
Голый цикл строго последователен. То есть сравнивались не две абстракции, а последовательное
чтение против конвейерного — и подписка выигрывала за счёт конкурентности, которой у цикла нет.
Проба переделана в лестницу, где каждая ступень отличается от предыдущей одним: соединение одно и то же, открыто до начала замера, оффсеты и цикл выборки общие. После этого систематический перекос исчез — знак разницы стал скакать в обе стороны, что и подтвердило диагноз.
Но величина осталась ниже разрешения: на сетевом стенде разброс между проходами ±20 %, и он хоронит всё, что абстракция может стоить. Это честный ответ про практику — на скоростях, на которых работает брокер, обёртка не является ограничением, — но не ответ про обёртку.
17.2 Число, снятое там, где оно измеримо
Сокет убран, осталась форма: те же батчи, отданные тремя способами. JMH, WSL, 5 прогревов + 10 итераций. Счёт — на выдачу, не на запись.
| Способ | Выдач в секунду (батч 10) | На выдачу |
|---|---|---|
цикл for | 4 565 919 219 | 0,22 нс |
холодный flow { } | 183 547 381 | 5,70 нс |
он же плюс buffer() | 16 697 793 | 59,64 нс |
Машинерия Flow — 5,5 нс на выдачу. Канал сверху — ещё 54 нс. Размер батча на эти числа
почти не влияет (16,4–16,8 млн выдач/с при батче от 1 до 100), и это ровно то, чего следовало
ожидать: цена платится за выдачу, а не за запись.
Отсюда прямое следствие для API — то самое, ради которого RecordBatch
отдаёт батч, а не запись:
| Батч | flow { } на запись | с buffer() на запись |
|---|---|---|
| 1 | 5,70 нс | 59,64 нс |
| 10 | 0,57 нс | 5,96 нс |
| 100 | 0,057 нс | 0,60 нс |
Сравнивать это надо со сквозным путём: 712 нс на запись (замер 16, потолок 1 404 750
записей/с). При батче 100 холодный Flow — 0,008 % от него, с каналом — 0,08 %. Даже при выдаче
по одной записи канал стоит 8 % пути, а Flow — меньше процента.
Вот почему сетевая проба не могла это увидеть, и это не её недоделка: 5,7 нс против 712 нс — 0,8 %, а разброс стенда ±20 %. Величина была не «зарыта в шуме», а на два порядка меньше шума; никакая аккуратность в той пробе ответа бы не дала.
Оговорка про базовую строку. 0,22 нс на выдачу у цикла for — это примерно один такт, то есть
JIT почти наверняка свернул сложение размеров предпосчитанного списка. Читать эту строку как
«цикл стоит столько» нельзя; она годится только как ноль, относительно которого две другие
измерены. Абсолютные 5,70 и 59,64 нс от этого не страдают.
Замер 18 (M-83): базовый образ — glibc против musl
Решение Р9 говорило: базовый образ выбирается замером, а не размером. Вот замер.
Стенд. WSL, Docker overlayfs. Брокер — в контейнере, генератор probeRemoteLoad — на хосте,
то есть разные процессы (M-37). Образы отличаются ровно одной строкой — базой рантайма; сборочная
стадия общая. Прогоны чередуются: glibc, musl, glibc, musl.
eclipse-temurin:25-jre | bellsoft/liberica-openjre-alpine-musl:25 | |
|---|---|---|
| Размер образа | 514 МБ | 197 МБ |
| Принято, проход 1 | 83 464 зап/с | 74 137 зап/с |
| Принято, проход 2 | 77 222 зап/с | 76 733 зап/с |
| p50, проход 1 / 2 | 4 163 / 4 580 мс | 4 576 / 4 719 мс |
Образ меньше в 2,6 раза — отношение держится, а сами мегабайты отозваны в 18.2: перемер на другом хосте не воспроизвёл ни 514, ни 197. Таблица оставлена как есть, потому что вывод замера про производительность от этого не зависит.
По производительности они неразличимы. В первом проходе glibc впереди на 12,6 %, во втором — на 0,6 %. И главное: два прохода самого glibc расходятся на 8 %, то есть собственный разброс стенда того же порядка, что и разрыв между базами. Вывод «musl медленнее» отсюда не следует; вывод «они одинаковы» — тоже, следует только «здесь не различаются».
Умолчание остаётся glibc, и это суждение, а не измерение. «Неразличимы при разрешении 8 %» слабее, чем «измерено на этом рантайме», а все восемнадцать замеров проекта сняты на glibc. Ставить умолчанием рантайм, под которым не мерил никто, — ровно та подмена, от которой предостерегает риск 7. Musl доступен одной строкой:
docker build --build-arg RUNTIME_BASE=bellsoft/liberica-openjre-alpine-musl:25 .Что решило бы вопрос по-настоящему: тот же замер на арендованной паре, где разрешение было 2–8 % (замер 16), а не 8 % на WSL. Триста мегабайт образа — достаточный повод это сделать, когда машины снова будут.
Побочная находка, за которую пришлось заплатить сборкой. Dockerfile не переносится между
базами как есть: Alpine — busybox, там нет useradd, а у adduser другие флаги.
18.1 Чем это кончилось: размер без смены libc
Развилка «маленький musl против измеренного glibc» оказалась ложной. У BellSoft на Alpine есть оба варианта, и второй из них — glibc:
| Образ | Чем слинкована java | Размер базы |
|---|---|---|
bellsoft/liberica-openjre-alpine-musl:25 | libc.musl-x86_64.so.1 | 46 МиБ |
bellsoft/liberica-openjre-alpine:25 | libc.so.6 (glibc) | 50 МиБ |
Проверено ldd внутри обоих образов, а не по названию тега. Оба на Alpine 3.24 с busybox, в
котором есть sed и uname — всё, что нужно нашим скриптам запуска.
Итоговый образ — bellsoft/liberica-openjre-alpine:25, 240 МБ против 514 у temurin на Debian
(на Linux; на Docker Desktop те же образы читаются как 176 и 364 — см. 18.3, где разобрано,
почему это одно и то же). Размер уменьшился в 2,1 раза,
а libc остался тем, на котором сняты все замеры. Четыре мегабайта разницы с musl — плохая цена за то, чтобы поставляемый рантайм отличался от измеренного, тем более
что замер 18 не нашёл между ними разницы и в скорости.
Ничего не доустанавливается. Kafka в своём образе ставит bash, потому что её скрипты его
требуют (§1.20); наши — #!/bin/sh и обходятся busybox. Пакет «на всякий случай» — это лишняя
поверхность, которую потом кто-то обязан патчить.
18.2 Перемер размеров: отношение уцелело, абсолютные числа — нет ⚠️ вывод отозван в 18.3
Пара 240/514 из 18.1 снималась на WSL и попала оттуда в README, CLAUDE.md, ресёрч и бэклог.
При подготовке репозитория к публикации она не сошлась с базовыми образами: docker pull даёт
у bellsoft/liberica-openjre-alpine:25 172 МБ, а дистрибутив добавляет около четырёх, — то есть
240 неоткуда взять.
Перемер, macOS/Docker Desktop, обе сборки подряд из одного и того же build/install, обе под
--platform linux/amd64, Dockerfile отличается только строкой FROM (и adduser против
useradd — Alpine это busybox):
| База | Размер образа | Размер базы |
|---|---|---|
eclipse-temurin:25-jre | 364 МБ | 360 МБ |
bellsoft/liberica-openjre-alpine:25 | 176 МБ | 172 МБ |
Отношение 2,1× подтвердилось. Вывод про «отозванные абсолютные числа» неверен — см. 18.3. Здесь было написано, что 240, 514 и 197 «неоткуда взять», потому что база плюс дистрибутив дают меньше. Проверка базы делалась на том же хосте, где считалась арифметика, и не делалась на том, откуда пришли исходные числа. Они воспроизводятся там до мегабайта: размер образа просто не сравним между хостами.
Разница между архитектурами при этом невелика и целиком приходится на базу: arm64 — 154 МБ базы и 158 МБ образа против 172 и 176 на amd64. В документах остаётся amd64: это то, на чём брокер будет работать.
Что это не отменяет. Вывод 18.1 — «размер и тот же libc не противоречат друг другу» — держится
на ldd внутри образов, а не на мегабайтах, и от перемера не зависит.
Замер 19 (2026-08-11): чего не может измерить общий раннер
Первый прогон benchmark.yml на раннере GitHub закончился зелёным порогом и таблицей, которую
я прочитал неправильно. Разбор этого чтения полезнее самих чисел, поэтому записан целиком.
19.1 Что показалось
В отчёте, при сравнении внутри одного прогона (правило 3 такое сравнение разрешает):
| Конфигурация | FILE_CHANNEL | MAPPED | |
|---|---|---|---|
| сброс на запись, 64 Б | 4 821 ± 248 | 3 150 ± 35 | FILE_CHANNEL в 1,53× |
| сброс на запись, 1 КиБ | 4 436 ± 387 | 3 123 ± 38 | FILE_CHANNEL в 1,42× |
| групповой коммит, 1 продюсер | 4 124 ± 207 | 3 148 ± 32 | FILE_CHANNEL в 1,31× |
Это противоречит итогу M-46 — «маппинг быстрее по всем осям без исключения, 1,7× со сбросом
на каждую запись». Разница на операцию — 0,110 мс на 64 Б и 0,095 мс на килобайте, то есть
от размера записи не зависит, и это выглядело как механизм: MappedSegmentWriter.force()
зовёт msync и fsync, а FileChannelSegmentWriter — один fdatasync.
Гипотез было две, и у них разные последствия: либо барьер на этом хосте дёшев и второй вызов
перестал теряться в шуме (свойство машины), либо msync обходит весь маппинг, а бенчмарк маппит
1 ГиБ на сегмент (дефект нашего кода, чинится сужением диапазона).
19.2 Что оказалось
Обе гипотезы неверны. Проверено на том же классе раннера.
msync не зависит от размера маппинга и не стоит второго барьера. force() на всём маппинге,
на одной странице и вовсе без msync дают одно и то же:
| 1 ГиБ маппинг | 16 МиБ маппинг | |
|---|---|---|
force() на весь маппинг + fdatasync | 0,133 мс | 0,129 мс |
force() на записанную страницу + fdatasync | 0,136 мс | 0,131 мс |
только fdatasync | 0,137 мс | 0,135 мс |
Разница между путями не воспроизводится. Оба пути, собранные рядом в одном процессе, с теми же параметрами (1 ГиБ, записи по 64 Б, барьер на каждую):
| Проход | mapped | channel |
|---|---|---|
| 1 | 0,119 мс | 0,104 мс |
| 2 | 0,091 мс | 0,094 мс |
В первом проходе маппинг медленнее на 14 %, во втором — быстрее на 3 %. Разница между путями
меньше разницы между двумя проходами одной программы. Вариант без msync — 0,095 мс, то есть
неотличим; вариант без барьера вовсе — 0,004 мс, барьер и есть вся стоимость.
У хоста нет разрешения для этого вопроса. probeDurability, запущенная дважды на раннерах
одного класса:
| прогон 1 | прогон 2 | |
|---|---|---|
fsync один | 46,50 мс | 75,81 мс |
msync один | 68,26 мс | 40,54 мс |
| разброс мин–макс | 9,62 … 91,40 мс |
Знак перевернулся между прогонами. В host.txt раннера стоит rotational: 1.
19.3 Вывод, и он не про маппинг
Итог M-46 не оговаривается и не отзывается: CI-прогон не может высказаться о нём ни за,
ни против. Числа M-46 сняты на машине с барьерами по 11,36 мс, где 0,11 мс хвостового fsync
составляли 1 %; здесь барьер сам по себе — шум амплитудой в два раза.
Что здесь по-настоящему поучительно — узкий доверительный интервал JMH ничего не гарантирует.
±1 % у MAPPED означает, что десять итераций подряд легли одинаково, потому что состояние диска
не менялось эти двадцать секунд. Это не то же самое, что «число описывает код», и именно узость
интервала делает такую строку убедительной.
Что сделано. benchmark.yml переведён на mainCiBenchmark: из прогона убраны
GroupCommitBenchmark целиком и строки SegmentAppendBenchmark со сбросом на каждую запись.
Отчёт, который никто не может прочитать правильно, — приглашение к ошибке, а не данные.
Чего сделано не было, хотя предлагалось. Добавить probeDurability в регулярный прогон CI.
Идея была превратить арифметику в измерение; этот замер показал, что получилось бы регулярное
число с перевёрнутым знаком через раз.
19.4 Что осталось верным
Отношения, снятые в том же прогоне, воспроизводят выводы проекта близко к машине M-46:
| этот прогон | M-46 | |
|---|---|---|
батч 1 → 100, FILE_CHANNEL, WRITTEN | 13,8× | 14,3× |
батч 1 → 100, MAPPED, WRITTEN | 56,3× | 58,6× |
FILE_CHANNEL перестаёт отвечать на батч (NONE, 1 → 100) | 1,1× | подтверждено |
| маппинг без сброса, 64 Б | 15,3× | 13,9× |
Отношения переносятся между машинами, абсолютные значения — нет. Это лучшее, что можно было узнать про устойчивость выводов, и получено оно из прогона, который затевался ради одной строчки «ничего не рухнуло».
Замер 20 (M-103): чего стоит очередь без координатора
Очередь из M-102 берёт задачу кругом «записал заявку → дочитал лог до неё», а конфликты разрешает порядком записей. Вопрос замера: сколько стоит круг и сколько работы уходит в проигранные заявки.
Стенд назван, потому что он слабый. macOS, Docker Desktop (то есть Linux в виртуалке), брокер — нативный arm64-образ, а не опубликованный amd64: под эмуляцией мерился бы QEMU. Пять контейнеров: брокер, паблишер, три воркера. Задачи каждые 10 мс, работа над задачей 400 мс, то есть очередь всегда с заделом — три воркера дают потолок 7,5 задач/с.
Числа — разность двух снимков лога в окне 60 с, а не «сколько накопилось»: стартовые эффекты иначе попадают в результат, о чём ниже.
20.1 Три воркера, задел в очереди
Две пары прогонов подряд, в одной сессии.
| Стратегия выбора | Пропускная | Попыток на задачу | Потеряно | Круг p50 | p90 | p99 |
|---|---|---|---|---|---|---|
first, прогон 1 | 6,6/с | 1,38 | 27,3 % | 0,49 мс | 1,05 | 2,30 |
first, прогон 2 | 7,2/с | 1,09 | 8,2 % | 0,42 мс | 0,85 | 5,47 |
random, прогон 1 | 7,2/с | 1,00 | 0 % | 0,56 мс | 1,13 | 1,91 |
random, прогон 2 | 7,2/с | 1,00 | 0 % | 0,51 мс | 1,37 | 2,94 |
Круг стоит полмиллисекунды. Это не то, чем очередь дорога.
Дорога она столкновениями, и однострочная правка их убирает. Брать первую доступную задачу — очевидный выбор: все свободные воркеры видят одну и ту же голову очереди и пишут заявки одновременно. Брать случайную из доступных — то же самое количество кода, и на трёх воркерах с заделом столкновений не остаётся вовсе, а пропускная выходит на потолок.
random воспроизводится точно (1,00 и 0 % оба раза), first гуляет между 8 % и 27 % — доля
столкновений у него зависит от того, как легли фазы.
20.2 Что оказалось измерением режима, а не протокола
Первые прогоны давали 62 % и 91 % потерь, и это было правдой про другой режим. Задачи шли раз в 700 мс на три (и на тридцать) воркеров — очередь пуста, доступная задача обычно одна, и все бросаются на неё. Доля потерь тогда описывает отношение прихода задач к числу воркеров, а не протокол.
Отсюда же следствие: random в пустой очереди не помогает и не может — выбор случайного элемента
из списка длины один это first.
20.3 Тридцать воркеров — не измерено, и вот доказательство
Прогон с --scale worker=30 на этом стенде дал 0 задач за 60 секунд и p50 в 198 мс. Это
не свойство протокола: 31 контейнер на ноутбуке в виртуалке — не стенд для вопроса о конкуренции.
Строка остаётся незаполненной до арендованной пары (M-105).
20.4 Что стоило этому замеру
Четыре поломки, все — в измерителе, и все найдены тем, что число выглядело невозможным.
- Паблишер молчал. Первые прогоны шли на клиенте
0.1.2, где аккумулятор терял запись, если таймер сброса срабатывал в момент её прихода. Публикация задач вставала после первой, а события шли. Это оказался настоящий дефект клиента — тот же, что проект уже находил и чинил на серверной стороне (PartitionWriter.awaitCommand), — и он исправлен в0.1.3. - Два прогона разной длины. Сравнивались «сколько накопилось», а поднять 31 контейнер в первый раз дольше, чем во второй: вышло 1 задача против 1711. Отсюда окно и два снимка.
- Отрицательное число потерь.
попытки − завершённыене является числом проигравших: задача, взятая до окна и завершённая внутри, попадает в один счётчик и не попадает в другой. Считать надо выигравшие заявки, и это видно прямо при проигрывании лога. - Перепутанные поля.
grepотдаёт строки в порядке файла, а отчёт печатает попытки выше побед — два числа приехали местами, и потери снова вышли отрицательными. Теперь каждое поле берётся по имени, а измеритель отказывается печатать результат, где побед больше, чем попыток.
Общее у всех четырёх: невозможное число видно сразу, если его напечатать. Три из четырёх поймало
не рассуждение, а то, что на экране стояло -26.
18.3 Отзыв отзыва: пара 240/514 верна, а мегабайты непереносимы
Замер 18.2 объявил числа 240 и 514 невоспроизводимыми — «неоткуда взять», потому что база плюс дистрибутив дают 176. Это неверно, и проверено на том самом хосте, где числа снимались.
Тот же Dockerfile, тот же тег базы, та же архитектура amd64, WSL-машина (20 ядер, Docker 29.1.3):
docker images на WSL | docker images на маке | docker image inspect .Size на WSL | |
|---|---|---|---|
bellsoft/liberica-openjre-alpine:25 + дистрибутив | 240 МБ | 176 МБ | 62 МБ |
eclipse-temurin:25-jre + дистрибутив | 514 МБ | 364 МБ | — |
| отношение | 2,14× | 2,07× |
Исходная пара воспроизвелась до мегабайта. Ошибка была не в ней, а в моём предположении, что
размер образа — величина, сравнимая между хостами. Он таковой не является: docker images и
docker image inspect на одной и той же машине расходятся втрое (240 против 62 — второе
это суммарный размер сжатых слоёв, то есть то, что качается), а между хостами расходится и то,
что печатает docker images.
Что в силе: отношение 2,07–2,14× — оно устойчиво и на нём держится решение Р9.
Что отзывается — вывод 18.2, а не 18.1. Числа 240/514 возвращаются как корректные для Linux; числа 176/364 остаются корректными для Docker Desktop. Ни одна пара не «правильнее», и обе бесполезны без имени хоста рядом.
Как это вышло. Я увидел расхождение, построил правдоподобное объяснение («база плюс дистрибутив столько не дают»), проверил базу на том же хосте, где считал, и не проверил на том, откуда пришло исходное число. Ровно то, о чём написан замер 19, — только в обратную сторону: там я поверил узкому доверительному интервалу, здесь — арифметике, сошедшейся на одной машине из двух.
Правило, которое из этого следует: размер образа записывается с именем хоста и командой, которой он получен, или не записывается вовсе. Полезнее всего для README сжатый размер — это то, что человек скачает, и он от хоста не зависит.
Замер 21 (M-105): очередь при тридцати воркерах
Открытый вопрос замера 20: доля столкновений при тридцати воркерах. На ноутбуке он не разрешался (0 задач за 60 с), и был отложен до машины посильнее.
Стенд. Linux, 20 ядер, 23 ГиБ, только что перезагружена; брокер — опубликованный образ
ghcr.io/youndie/booblik:0.1.0. Соседи на машине есть и названы: четыре простаивающих контейнера
MongoDB чужого проекта, суммарно ~6 % одного ядра. Всё на одной машине, поэтому записывается
только отношение: воркеры делят процессор с брокером, а M-37 измерила, чего это стоит по задержке
(9,2×). Абсолютные задержки из этих прогонов не приводятся вовсе.
Задачи каждые 10 мс (100/с), работа над задачей 400 мс, окно 60 с. Потолок арифметический: 30 / 0,4 с = 75 задач/с.
21.1 Две пары подряд, вторая в обратном порядке
| Порядок | Стратегия | Пропускная | Попыток на задачу | Потеряно |
|---|---|---|---|---|
| 1 | first | 73,6/с | 1,04 | 3,7 % |
| 1 | random | 73,7/с | 1,00 | 0,2 % |
| 2 (обратный) | random | 73,5/с | 1,00 | 0,2 % |
| 2 (обратный) | first | 73,6/с | 1,02 | 1,6 % |
random воспроизводится точно: 1,00 попытки на задачу и 0,2 % в обоих прогонах. first гуляет
между 1,6 % и 3,7 %, но всегда хуже. Обратный порядок нужен был не для симметрии: в первой паре
first стартовал при load 0,59, а random при 2,22, и объяснение «дело в стартовой нагрузке»
надо было исключить. Во второй паре first шёл при более высокой нагрузке (5,59 против 4,56)
и дал лучшее число — значит направление задаёт не нагрузка.
21.2 Предсказание не подтвердилось, и это главное здесь
В комментарии к коду воркера было записано: «каждый простаивающий воркер видит одну и ту же задачу в голове очереди, поэтому попыток на задачу растёт к числу воркеров».
При тридцати воркерах с заделом попыток на задачу — 1,04, а не тридцать. Больше того, столкновений стало меньше, чем на трёх:
| Воркеров | Режим | first, потеряно | random, потеряно |
|---|---|---|---|
| 3 | очередь пуста | 62 % | (не помогает) |
| 3 | задел | 8–27 % | 0 % |
| 30 | задел | 1,6–3,7 % | 0,2 % |
Механизм, судя по числам, обратный написанному: работа расфазирует воркеров. При четырёхстах миллисекундах на задачу тридцать воркеров освобождаются в разные моменты и смотрят в голову очереди поодиночке. Столкновения возникают там, где воркеры синхронны, — а синхронны они, когда простаивают и разом просыпаются на приходящую задачу. Это же объясняет 62 % на пустой очереди и три воркера, попадающие в такт чаще тридцати.
Комментарий в коде исправлен по факту.
21.3 Чего столкновения стоят, а чего нет
На тридцати воркерах пропускная одинакова во всех четырёх прогонах (73,5–73,7/с) и равна арифметическому потолку. То есть на этом масштабе проигранные заявки стоят попыток, а не пропускной способности: система упирается в работу, а не в лог.
На трёх воркерах было иначе — там first давал 6,6/с против 7,2/с у random, то есть терялось
8 % потолка. Разница в том, что три воркера при 400 мс работы дают потолок 7,5/с, и круг по логу
занимает в их бюджете заметную долю.
Вывод для образца остаётся прежним, но по другой причине. Умолчание random оправдано: оно
даёт 1,00 попытки на задачу при любом из проверенных масштабов и не стоит ничего. А вот пугать
ростом столкновений с числом воркеров — неправильно, и это утверждение отозвано.
Замер 22 (M-120): окно группового коммита
Открытый вопрос 3 ресёрча: стоит ли держать группу открытой несколько миллисекунд, чтобы в неё успело набиться больше участников. Гипотеза была записана заранее: при 64 продюсерах выигрыш будет небольшим (группы и так крупные), а при 2–4 — заметным.
Стенд. Linux, 20 ядер, JMH, 5 прогревов + 10 итераций, обе оси в одном прогоне.
GroupCommitBenchmark, FORCED, одна запись на продюсера; счёт — инвокаций в секунду, где
инвокация это producers одновременных долговечных записей.
22.1 Окно проигрывает везде
Коммитов в секунду, groupWindowMillis 0 против 2:
| Продюсеров | FILE_CHANNEL 0 | 2 | MAPPED 0 | 2 | ||
|---|---|---|---|---|---|---|
| 1 | 745 | 411 | хуже в 1,81× | 1284 | 501 | хуже в 2,56× |
| 2 | 735 | 421 | 1,74× | 1257 | 527 | 2,39× |
| 4 | 684 | 421 | 1,63× | 1176 | 521 | 2,26× |
| 8 | 764 | 393 | 1,94× | 1111 | 503 | 2,21× |
| 64 | 685 | 379 | 1,81× | 628 | 375 | 1,68× |
Гипотеза неверна по знаку. Выигрыша нет нигде, и наибольший проигрыш там, где предсказывался наибольший выигрыш.
22.2 Почему — видно в пересчёте на операцию
MAPPED, миллисекунд на инвокацию:
| Продюсеров | без окна | с окном | разница |
|---|---|---|---|
| 1 | 0,78 | 2,00 | +1,22 |
| 2 | 0,80 | 1,90 | +1,10 |
| 4 | 0,85 | 1,92 | +1,07 |
| 8 | 0,90 | 1,99 | +1,09 |
| 64 | 1,59 | 2,67 | +1,08 |
Добавка постоянна — около 1,1 мс при любом числе продюсеров. Это не «мало набралось», это чистое ожидание: окно не наполняется никогда, потому что единственные, кто мог бы в него прийти, — это продюсеры, уже стоящие в этой же группе и ждущие её барьера.
Гипотеза молча предполагала, что во время ожидания подойдут новые. Подходить некому: группа уже содержит всех, кто есть в полёте.
22.3 Что из этого следует и чего замер не показал
Умолчание остаётся 0. Ручка добавлена и оставлена выключенной: решение о такой сделке зависит от того, приходят ли продюсеры независимо от подтверждений, а брокер этого знать не может.
Величина проигрыша зависит от хоста, направление — нет. Здесь барьер стоит 0,78–1,6 мс (WSL2, §1.12 — не честный барьер устройства), и +1,1 мс это +140 %. На арендованной машине барьер стоил 11,36 мс (замер 12.1), где та же добавка была бы +10 %. Знак не изменится: окно всегда добавляет ожидание и никогда не добавляет участников там, где участники синхронны.
Не измерено — открытый поток. Единственный режим, где окно может окупиться, — продюсеры,
приходящие по сети с частотой, не связанной с подтверждениями. GroupCommitBenchmark такого
не создаёт по построению, и это не его недостаток: вопрос был поставлен про группу, а проверяемым
оказался только замкнутый цикл. Ручка существует именно для этого случая и по умолчанию выключена.
Замер 23. Аккумулятор Kotlin/Native: за что он платит и что покупает (M-134а)
Стенд: WSL2, брокер в образе ghcr.io/youndie/booblik:0.2.0 нативным amd64 (не под эмуляцией),
проба booblik-native-conformance → probe.kexe, 200 записей на вызывающего, топик из одной
партиции, AckPolicy.WRITTEN, окно 5 мс, максимум пачки 100.
Три колонки, отличающиеся только обращением с аккумулятором. Поток на вызывающего в каждой,
иначе шестьдесят четыре вызывающих на одном потоке runBlocking встали бы в очередь, и замер
описывал бы очередь.
| Вызывающих | direct, ждёт каждую | аккумулятор, ждёт каждую | аккумулятор, не ждёт | последняя к первой |
|---|---|---|---|---|
| 1 | 595 | 140 | 21 692 | 36,47× |
| 8 | 5 972 | 1 160 | 13 337 | 2,23× |
| 64 | 13 522 | 6 777 | 15 460 | 1,14× |
23.1 Аккумулятор с ожиданием каждой записи — худшее из трёх
При одном вызывающем 595 против 140 записей в секунду: 1,7 мс против 7,1 мс на запись, то есть ровно круг плюс окно. И это не «мало набралось»: вызывающий ждёт свою запись перед следующей, поэтому в пачку физически не может попасть больше записей, чем есть вызывающих. Окно оплачивается за пачку, которая уже полна настолько, насколько вообще могла быть.
Тот же механизм, что в замере 22: наполнять окно некому, потому что единственные, кто мог бы, уже стоят в нём и ждут.
23.2 Без ожидания аккумулятор покупает то, чего иначе нет вовсе
21 692 записи в секунду против 595 — 36,47× при одном вызывающем. Соединение здесь блокирующее и конвейера не умеет, поэтому «direct, не ждёт» не существует как колонка: больше одной записи в полёте даёт только аккумулятор. Это и есть его назначение, и оно измеримо.
С ростом числа вызывающих преимущество тает — 2,23× на восьми и 1,14× на шестидесяти четырёх, — потому что догоняет не батчинг, а соединения: у прямой колонки их по одному на вызывающего, у аккумулятора одно на всех.
23.3 Что из этого следует
Ждёте оффсет каждой записи — не берите аккумулятор, берите produce со списком. Это
единственная рекомендация в проекте, где аккумулятор проигрывает, и она измерена, а не выведена.
Не ждёте — берите обязательно, особенно при малом числе вызывающих: там разница в тридцать шесть раз.
Абсолютные числа сравнимы только внутри одного прогона. Прямая колонка при одном вызывающем давала 1 388 и 595 в двух прогонах подряд на одном хосте; отношения внутри прогона устойчивы, абсолютные значения — нет. На маке под эмуляцией amd64 те же отношения были 0,33× / 0,67× / 0,70× для второй колонки — направление то же, величина другая, и это ровно тот случай, когда стенд свидетелем не является.
Замер 24. Долгий FETCH у догнавшего потребителя (M-136)
Стенд: мак, брокер в контейнере booblik:conformance через Docker Desktop, потребитель —
clients/go/cmd/probe, топик из одной партиции, окно замера 3 с, MaxWait = 5 с.
Про абсолютные значения этот стенд не свидетельствует: сеть здесь идёт через виртуальную
машину Docker Desktop. Осмысленно тут отношение и порядок величины.
| Прогон | опрос, MaxWait=0 | долгий FETCH, MaxWait=5 с | пробуждение на новой записи |
|---|---|---|---|
| 1 (первый после старта брокера) | 1 767 запросов/с | 0,20 | 21,49 мс |
| 2 | 1 688 | 0,20 | 3,87 мс |
| 3 | 2 081 | 0,20 | 2,31 мс |
| 4 | 1 929 | 0,20 | 3,54 мс |
24.1 Цена опроса — три порядка, а не проценты
Догнавший потребитель без ожидания делает около двух тысяч запросов в секунду, каждый из которых отвечает «ничего нет». С долгим FETCH — 0,20 запроса в секунду, то есть ровно один на пятисекундное окно: запрос держится всё отпущенное ему время и возвращается по таймауту. Разница в 9 500 раз, и она вся — запросы, на которые нечего ответить.
С замером 15.1 это не сравнивается напрямую, и сравнивать не надо. Там 18,45 против 0,50 запроса в секунду на потребителя — опрос с паузой в 50 мс против ожидания в 2 с, то есть 37×. Здесь опроса с паузой нет вовсе: клиент спрашивает снова сразу, как получил ответ, и три порядка берутся оттуда. Отношение в обоих случаях — это отношение интервала к ожиданию, а не свойство реализации; совпадает вывод, а не числа.
24.2 Экономия не оплачена задержкой — это и проверялось
Запрос, который держат пять секунд, мог бы отдавать новую запись по таймеру: тогда 9 500×
экономии стоили бы секунд задержки, и это была бы потеря под видом выигрыша. Измерено: 2,3–3,9 мс
от момента, когда клиент собрался писать, до момента, когда ждущий потребитель получил запись —
и в эти миллисекунды входит круг самой записи с AckPolicy.WRITTEN. Брокер будит ожидающего
по факту записи, а не по истечении окна.
24.3 Первый прогон после старта брокера измеряет прогрев
21,49 мс против 2,3–3,9 мс на трёх последующих — в шесть раз, и это то же самое, что уже ловилось в проекте: первый прогон описывает JIT и первое обращение к свежему сегменту, а не поведение системы. Одиночный замер здесь дал бы уверенное и неверное число.
Замер 25. Два декодера FETCH: во что обходится общий (M-140)
Вопрос был не «какой красивее». Декодеров стало два — ResponseReader.fetch в :booblik-client
на ByteBuffer и ResponseDecoder.fetch в :booblik-protocol на ByteArray, общий с
Kotlin/Native с M-138, — и держало их порознь предположение: сумму на JVM считает интринсик,
значит декодер, написанный ради общего кода, обойдётся дороже там, где чтение и происходит.
FetchDecodeBenchmark, отклик из 64 записей, ось — записи в секунду. Обе колонки проверяют каждую
сумму одним и тем же CRC32C.
25.1 До слияния: два стенда отвечают по-разному
| Размер записи | мак: reader | мак: decoder | Linux: reader | Linux: decoder |
|---|---|---|---|---|
| 64 Б | 20 656 412 ± 20 633 044 | 17 786 450 ± 1 681 406 | 50 157 072 ± 6 907 655 | 52 975 207 ± 4 229 916 |
| 1 КиБ | 3 901 798 ± 1 057 625 | 2 276 526 ± 275 985 | 8 432 194 ± 772 849 | 8 848 849 ± 739 349 |
| 8 КиБ | 450 167 ± 191 200 | 292 157 ± 62 885 | 1 051 799 ± 262 465 | 1 472 143 ± 154 147 |
Мак сказал «общий декодер в 1,7 раза медленнее». Linux сказал обратное. На Linux интервалы пересекаются на 64 Б и на 1 КиБ (то есть разницы нет), а на 8 КиБ общий декодер быстрее в 1,40 раза. На маке ни один вывод не опирается ни на что: посмотрите на первую строку — доверительный интервал равен самой оценке, ±100 %. Это стенд рассказывает о себе, а не о коде, и именно поэтому в правилах написано, что мак — черновой редактор, а не сборочный стенд.
Если бы решение принималось по маку, декодеры остались бы разными навсегда, и обоснованием стояло бы измеренное число.
25.2 После слияния: обёртка не стоит ничего
ResponseReader.fetch теперь зовёт общий декодер и перекладывает результат в FetchResult —
публичный тип библиотеки, который менять незачем.
| Размер записи | reader (обёртка) | decoder (напрямую) |
|---|---|---|
| 64 Б | 53 038 229 ± 4 755 491 | 52 631 003 ± 10 039 611 |
| 1 КиБ | 7 905 260 ± 1 118 953 | 7 914 373 ± 1 812 032 |
| 8 КиБ | 1 434 999 ± 121 567 | 1 412 595 ± 129 793 |
Строки совпадают в пределах интервала на всех трёх размерах, чего и следовало ожидать: разница — один объект на отклик из 64 записей.
Заодно видно правило про сравнение прогонов. Тот же общий декодер на 8 КиБ дал 1 472 143 в первом прогоне и 1 412 595 во втором — 4 % врозь, тот же код, тот же хост, разница только во времени суток. Сравнивать имеет смысл колонки внутри одного прогона, и только их.
Стенд Linux: WSL2, 20 ядер, 23 ГиБ. Про долговечность он не свидетельствует вовсе (§1.12), но здесь ничего не касается диска — меряется арифметика над байтами в памяти.