booblik
Docs

Как здесь меряют и что намеряли

Этот проект существует ради скорости, поэтому у него есть обязанность: каждая веха заканчивается замером, и число попадает сюда. Веха, изменившая горячий путь и не показавшая цифру, считается незакрытой — иначе «стало быстрее» превращается в мнение (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 байта на сегмент. Запас примерно на восемь миллионов сегментов.

Правила, без которых число ничего не значит

  1. Режим сброса называется всегда. Без force() меряется скорость page cache, с ним — диска. Разница ниже — четыре порядка. Цифра без указания режима не неточна, она бессмысленна (решение Р4).
  2. Профиль рантайма называется тоже. См. выше; числа под профилем и без него — разные числа.
  3. Сравнивать можно только прогоны одной сессии, идущие подряд. Не «на одном хосте» — этого мало: контрольный прогон в M-44 показал 37 % расхождения с той же конфигурацией из замера 8 на той же машине, просто в другой день. Файловая система, её заполненность, тепловой режим и page cache двигают числа сильнее, чем разница, которую обычно ищут. Шапка с хостом обязательна, но её недостаточно.
  4. quick — не для записи. Две конфигурации существуют, чтобы разделить «посмотреть, не сломал ли» и «зафиксировать».
  5. Нагрузку меряет только открытый цикл. Закрытый («отправил — дождался — отправил следующий») при замедлении брокера замедляется вместе с ним и просто перестаёт просить: очередь, которая образовалась бы перед настоящим брокером, не образуется, медленные образцы не берутся, перцентили выходят красивыми. Это coordinated omission, и probeLoad устроен так, чтобы её не допустить: время отсчитывается от момента, когда запрос был должен уйти.
  6. Стенд может жить в одной JVM с брокером, и тогда числа описывают пару. probeLoad так и устроен; счётчики GC печатаются рядом с перцентилями именно поэтому. Цена измерена в замере 16: 9,2× по медиане и 19 % потолка. Для чисел, которые идут в отчёт, берите probeRemoteLoad и отдельную машину; probeLoad остаётся удобной пробой «не сломал ли».
  7. Прежде чем объяснять хвост свойствами брокера, проверьте измеритель. В 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).
  8. 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_CHANNEL64 Б335 984 ± 27 644344 463 ± 13 786
нетFILE_CHANNEL1 КиБ309 817 ± 18 746323 247 ± 5 940
нетMAPPED64 Б13 907 934 ± 1 156 71114 769 674 ± 1 377 661
нетMAPPED1 КиБ1 854 553 ± 512 280 ⚠️4 574 411 ± 258 777 ⚠️
на каждую записьFILE_CHANNEL64 Б257 ± 7254 ± 8
на каждую записьFILE_CHANNEL1 КиБ245 ± 5249 ± 6
на каждую записьMAPPED64 Б16 123 ± 4 06916 773 ± 3 923
на каждую записьMAPPED1 КиБ4 135 ± 1 2554 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_CHANNEL64 Б213 859 ± 2 475335 984
нетFILE_CHANNEL1 КиБ196 845 ± 12 711309 817
нетMAPPED64 Б13 255 510 ± 336 27013 907 934
нетMAPPED1 КиБ8 884 828 ± 1 203 1901 854 553 ⚠️
на записьFILE_CHANNEL64 Б254 ± 11257
на записьFILE_CHANNEL1 КиБ252 ± 6245
на записьMAPPED64 Б251 ± 616 123
на записьMAPPED1 КиБ252 ± 64 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
WRITTENFILE_CHANNEL58 169 ± 1 985154 461 ± 9 790209 081 ± 6 619
WRITTENMAPPED80 592 ± 1 836692 554 ± 31 2504 335 482 ± 493 513
NONEFILE_CHANNEL207 793 ± 7 100217 159 ± 5 160212 588 ± 5 578
NONEMAPPED2 473 632 ± 156 6405 969 708 ± 63 3539 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_CHANNEL1253,8 ± 7,7254
FILE_CHANNEL8235,3 ± 12,11 882
FILE_CHANNEL64118,4 ± 4,87 581
MAPPED1243,6 ± 7,4244
MAPPED8245,5 ± 11,11 964
MAPPED64136,7 ± 7,98 747

Групповой коммит работает. 64 производителя получают 7 581 долговечную запись в секунду против 254 у одного — в 30 раз больше при том же диске. Потолок в 250 барьеров в секунду никуда не делся, просто один барьер теперь обслуживает десятки производителей.

Падение операций в секунду с 254 до 118 при 64 производителях — не деградация, а арифметика: за одну операцию успевают пройти примерно два барьера вместо одного, потому что не все 64 корутины успевают встать в очередь до того, как актор начнёт писать первую группу.


Замер 3 (M-26): минута записи, а не две секунды

SustainedWriteProbe, записи по 1 КиБ, retention удерживает 512 МиБ на диске, суммарно записано в 2,6 раза больше ОЗУ машины.

РежимСреднееМедианаХудшая секундаЛучшая секундаЛучшая/худшая
MAPPED798 102606 570128 1501 973 63715,4×
FILE_CHANNEL245 280250 266203 998254 7361,2×

Два вывода, и второй важнее первого:

  1. Короткий замер завышает маппинг в десять раз. 8,88 млн записей в секунду в замере 2.1 против 0,80 млн здесь. Свежий сегмент, пустой page cache и две секунды — самый выгодный для маппинга случай из возможных.
  2. Худшая секунда у маппинга хуже, чем у 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 с на точку.

Цель, зап/сДостигнутоЗаписей/сp50p99max
5 0005 001 (100 %)50 0050,60 мс3,8 мс12 мс
10 00010 001 (100 %)100 0050,26 мс5,4 мс17 мс
20 00020 001 (100 %)200 0050,36 мс54 мс60 мс
30 00026 649 (89 %)266 487956 мс1 973 мс2 012 мс
40 00026 813 (67 %)268 1292 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 с. Тот же путь, отличается только тем, как тело ответа попадает в сокет.

НагрузкаРежимДостигнутоp50p99
2 000 зап/с (125 МиБ/с)ZERO_COPY100 %1,04 мс2,9 мс
HEAP100 %0,98 мс3,4 мс
8 000 зап/с (500 МиБ/с)ZERO_COPY100 %0,31 мс0,57 мс
HEAP100 %0,29 мс0,56 мс
16 000 зап/с (1 ГиБ/с)ZERO_COPY100 %0,19 мс6,4 мс
HEAP100 %0,20 мс6,7 мс
32 000 зап/с (2 ГиБ/с)ZERO_COPY100 %0,80 мс174 мс
HEAP100 %7,16 мс312 мс
48 000 зап/с (3 ГиБ/с)ZERO_COPY41 8541 186 мс2 231 мс
HEAP38 8441 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).

НагрузкаТранспортДостигнутоp50p99
PRODUCE 20 000 зап/сSELECTOR20 000 (100 %)0,32 мс58 мс
VIRTUAL_THREADS20 001 (100 %)1,19 мс197 мс
PRODUCE 30 000 зап/сSELECTOR26 381 (88 %)911 мс2 088 мс
VIRTUAL_THREADS21 832 (73 %)3 056 мс4 370 мс
FETCH 32 000 зап/сSELECTOR31 997 (100 %)9,7 мс486 мс
VIRTUAL_THREADS31 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.

НагрузкаГлубинаДостигнутоp50p99
5 000 зап/с15 001 (100 %)0,70 мс2,2 мс
10245 001 (100 %)0,93 мс2,3 мс
10 000 зап/с110 001 (100 %)0,46 мс95,0 мс
102410 001 (100 %)0,49 мс9,4 мс
20 000 зап/с114 026 (70 %)2 649 мс4 454 мс
102417 231 (86 %)1 466 мс2 573 мс
30 000 зап/с113 227 (44 %)4 238 мс8 309 мс
416 879 (56 %)3 607 мс6 501 мс
1616 527 (55 %)3 372 мс6 686 мс
102417 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

Весь набор прогнан заново подряд, на двух машинах, одной ревизией кода.

maclinux
ПроцессорApple M1, 8 ядерx86_64, 20 ядер
Память16 ГиБ23 ГиБ
ФСAPFS, диск занят на 94 %ext4, занят на 4 %
Прочееноутбук, load average ≈ 2,5WSL2, простаивает
JDK25.0.225.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 не зовёт.

Хостfsyncmsyncхвостовой 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 не воспроизвелись — но это не то же самое, что «их нет».

ХостРежимСреднееХудшая секундаЛучшая/худшая
macMAPPED1 223 341247 4298,4×
macFILE_CHANNEL253 110231 5271,1×
linuxMAPPED1 694 3631 613 0631,1×
linuxFILE_CHANNEL1 302 0351 268 1881,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 быстрее, и по-разному

Замер (записей/с)maclinuxотношение
FILE_CHANNEL 64 Б, без сброса215 2062 465 25911,5×
MAPPED 64 Б, без сброса13 178 87337 555 7102,9×
MAPPED 1 КиБ, без сброса8 429 81811 697 8781,4×
FILE_CHANNEL, сброс на запись2538163,2×
MAPPED, сброс на запись2491 2985,2×

Разрыв в 11,5 раз на FILE_CHANNEL — это цена системного вызова, и она у macOS заметно выше. Отсюда же и то, что на маке маппинг выглядит в 61 раз лучше FileChannel, а на Linux всего в 15: бо́льшая часть «преимущества mmap» на маке была недостатком syscall'а.

10.3 Обратная инверсия: на латентности мак быстрее

PartitionWriter, батч 1, WRITTENmaclinux
FILE_CHANNEL65 63027 702
MAPPED88 05628 124

Круг «корутина → канал → актор → ответ» на маке в 2,4–3,1 раза дешевле. Меньше ядер и быстрее межъядерная синхронизация плюс отсутствие прослойки WSL2. Правило M-14 от этого только крепнет: на Linux батч по 100 даёт 80× против 45× на маке.

10.4 Сквозной путь

maclinux
Потолок PRODUCE268 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 → 417 977 → 20 00160 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 и на сквозном пути, где живёт вся аллокация сетевого слоя:

НагрузкаПрофильДостигнутоp50p99GC
100 000 зап/с64 МиБ, SerialGC100 000 (100 %)0,187 мс75,1 мс575 сборок, 234 мс (1,6 %)
4 ГиБ, G1100 000 (100 %)0,179 мс61,4 мс43 сборки, 37 мс (0,2 %)
200 000 зап/с64 МиБ, SerialGC125 199 (63 %)2 848 мс5 620 мс708 сборок, 425 мс (2,8 %)
4 ГиБ, G1129 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, один прогон подряд:

ЦельПуть записиДостигнутоЗаписей/сp50p99
100 000 зап/сFILE_CHANNEL88 454 (88 %)884 5361 636 мс1 902 мс
MAPPED100 000 (100 %)1 000 0050,202 мс105 мс
200 000 зап/сFILE_CHANNEL93 730 (47 %)937 3014 073 мс7 982 мс
MAPPED128 920 (64 %)1 289 2012 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 закрыла последнее возражение, которое было не про скорость. Хронология, потому что она объясняет больше, чем сам вывод:

КогдаУмолчаниеНа каком основании
M0FILE_CHANNELусловно, до трёх проверок
M-15FILE_CHANNELсравнение нечестное (у FileChannel системный вызов на запись, батчей ещё не было), гарантии msync неясны, дистанция не проверена
замер 10FILE_CHANNELпроизводительность перестала быть аргументом, остался один — про восстановление: у предразмеченного маппинга не было границы лога, и без контрольных сумм рваная запись не детектировалась
M-60контрольная сумма делает рваную запись видимой на обоих путях. Довод о корректности исчез
M-45MAPPEDи слой хранения (замер 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,5518,10
msync один (путь MAPPED)12,34 мс11,2320,50
msync + fsync на том же файле12,33 мс11,7919,33
из них хвостовой fsync0,11 мс0,100,18

Хвостовой fsync1 % от одиночного (на WSL2 было 2 %, на APFS — 79 %). Вывод §1.9 подтверждён на ext4, который ни в чьей виртуальной файловой системе не лежит: на Linux msync барьером является, второй вызов в MappedSegmentWriter.force() стоит здесь примерно ничего и остаётся обязательным на macOS.

12.2 Слой хранения: запись в сегмент

Записей в секунду, SegmentAppendBenchmark, ± — доверительный интервал 99,9 %.

СбросРазмер записиFILE_CHANNELMAPPEDОтношение
нет64 Б802 339 ± 42 59911 131 120 ± 224 34113,9×
нет1024 Б487 496 ± 16 5256 120 765 ± 128 78712,6×
на каждую запись64 Б1 659 ± 3202 769 ± 2371,7×
на каждую запись1024 Б1 327 ± 1962 298 ± 2881,7×

Под долговечностью пути больше не неразличимы. В замере 10 на WSL2 они сходились (251 против 254) — и это было следствием того, что msync там ничего не стоил в сравнении с общей ценой барьера. Здесь маппинг даёт 1,7× и в режиме со сбросом на каждую запись, причём разрыв одинаков на обоих размерах записи.

12.3 Актор записи: батч и политика подтверждения

PartitionWriterBenchmark, записей в секунду.

ПодтверждениеБатчFILE_CHANNELMAPPEDОтношение
WRITTEN138 852 ± 6 32941 433 ± 4 2091,07×
WRITTEN10234 136 ± 21 751401 772 ± 24 5301,7×
WRITTEN100556 062 ± 75 9082 427 785 ± 390 8164,4×
NONE1713 692 ± 49 4093 541 202 ± 1 263 9375,0×
NONE10785 428 ± 11 7777 022 573 ± 461 2218,9×
NONE100808 713 ± 11 4098 280 293 ± 572 45610,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_CHANNELMAPPEDОтношение
11 110 ± 2261 801 ± 3741,6×
81 018 ± 1881 618 ± 1381,6×
64619 ± 771 209 ± 652,0×

Обе кривые падают с ростом числа продюсеров, и это ожидаемо: под FORCED группа коммитится целиком, а чем больше участников, тем больше в группе ожидания. Важнее, что преимущество маппинга с ростом конкуренции растёт, а не тает.

12.5 M-23: восстановление

StartupProbe, 256 МиБ лога в 16 сегментах, три попытки подряд.

РежимПопытка 1Попытка 2Попытка 3На гигабайт лога
MAPPED1 546 МиБ/с3 316 МиБ/с3 413 МиБ/с0,30 с
FILE_CHANNEL2 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 с. Задержка отсчитывается от момента, когда запрос был должен уйти.

Цель, запросов/сРежимПринятоp50p99Записей/с
100 000MAPPED100 %8,3 мс494 мс997 351
100 000FILE_CHANNEL44 %4 420,8 мс8 262,8 мс444 799
2 000 000MAPPED6 %6 840,9 мс13 933,5 мс1 149 243
2 000 000FILE_CHANNEL2 %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 с.

РежимСреднееМедианаХудшая секундаЛучшая секундаЛучшая/худшаяЗаписано
MAPPED2 012 5982 014 6551 924 5002 058 9471,1×115,1 ГиБ (7,6× ОЗУ)
FILE_CHANNEL475 935473 587458 062486 3721,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: fdatasync p99 = 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
1ZERO_COPY396,8 МиБ/с4,47 с0,577 с4,114 мс
1HEAP396,8 МиБ/с12,59 с1,625 с6,087 мс
2 (обратный)HEAP396,8 МиБ/с12,70 с1,639 с5,988 мс
2 (обратный)ZERO_COPY396,8 МиБ/с4,56 с0,588 с4,358 мс
3HEAP396,8 МиБ/с11,11 с1,434 с5,960 мс
3ZERO_COPY396,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_COPY916,3 МиБ/с (7,16 Гбит/с)6,35 с0,355 с
HEAP867,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БезΔВне интервалов
WRITTEN1FILE_CHANNEL40 88329 131+40,3 %нет
WRITTEN10MAPPED382 062387 723−1,5 %нет
WRITTEN100MAPPED2 656 1112 562 455+3,7 %нет
NONE1MAPPED2 871 0803 115 117−7,8 %да
NONE10MAPPED8 031 7807 963 981+0,9 %нет
NONE100MAPPED10 153 18810 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 952147,618,4520,00
долгий FETCH, ожидание 2 с804,00,500,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)На выдачу
цикл for4 565 919 2190,22 нс
холодный flow { }183 547 3815,70 нс
он же плюс buffer()16 697 79359,64 нс

Машинерия Flow — 5,5 нс на выдачу. Канал сверху — ещё 54 нс. Размер батча на эти числа почти не влияет (16,4–16,8 млн выдач/с при батче от 1 до 100), и это ровно то, чего следовало ожидать: цена платится за выдачу, а не за запись.

Отсюда прямое следствие для API — то самое, ради которого RecordBatch отдаёт батч, а не запись:

Батчflow { } на записьс buffer() на запись
15,70 нс59,64 нс
100,57 нс5,96 нс
1000,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-jrebellsoft/liberica-openjre-alpine-musl:25
Размер образа514 МБ197 МБ
Принято, проход 183 464 зап/с74 137 зап/с
Принято, проход 277 222 зап/с76 733 зап/с
p50, проход 1 / 24 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:25libc.musl-x86_64.so.146 МиБ
bellsoft/liberica-openjre-alpine:25libc.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 отличается только строкой FROMadduser против useradd — Alpine это busybox):

БазаРазмер образаРазмер базы
eclipse-temurin:25-jre364 МБ360 МБ
bellsoft/liberica-openjre-alpine:25176 МБ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_CHANNELMAPPED
сброс на запись, 64 Б4 821 ± 2483 150 ± 35FILE_CHANNEL в 1,53×
сброс на запись, 1 КиБ4 436 ± 3873 123 ± 38FILE_CHANNEL в 1,42×
групповой коммит, 1 продюсер4 124 ± 2073 148 ± 32FILE_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() на весь маппинг + fdatasync0,133 мс0,129 мс
force() на записанную страницу + fdatasync0,136 мс0,131 мс
только fdatasync0,137 мс0,135 мс

Разница между путями не воспроизводится. Оба пути, собранные рядом в одном процессе, с теми же параметрами (1 ГиБ, записи по 64 Б, барьер на каждую):

Проходmappedchannel
10,119 мс0,104 мс
20,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, WRITTEN13,8×14,3×
батч 1 → 100, MAPPED, WRITTEN56,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 Три воркера, задел в очереди

Две пары прогонов подряд, в одной сессии.

Стратегия выбораПропускнаяПопыток на задачуПотеряноКруг p50p90p99
first, прогон 16,6/с1,3827,3 %0,49 мс1,052,30
first, прогон 27,2/с1,098,2 %0,42 мс0,855,47
random, прогон 17,2/с1,000 %0,56 мс1,131,91
random, прогон 27,2/с1,000 %0,51 мс1,372,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 на WSLdocker 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 Две пары подряд, вторая в обратном порядке

ПорядокСтратегияПропускнаяПопыток на задачуПотеряно
1first73,6/с1,043,7 %
1random73,7/с1,000,2 %
2 (обратный)random73,5/с1,000,2 %
2 (обратный)first73,6/с1,021,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 02MAPPED 02
1745411хуже в 1,81×1284501хуже в 2,56×
27354211,74×12575272,39×
46844211,63×11765212,26×
87643931,94×11115032,21×
646853791,81×6283751,68×

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

22.2 Почему — видно в пересчёте на операцию

MAPPED, миллисекунд на инвокацию:

Продюсеровбез окнас окномразница
10,782,00+1,22
20,801,90+1,10
40,851,92+1,07
80,901,99+1,09
641,592,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-conformanceprobe.kexe, 200 записей на вызывающего, топик из одной партиции, AckPolicy.WRITTEN, окно 5 мс, максимум пачки 100.

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

Вызывающихdirect, ждёт каждуюаккумулятор, ждёт каждуюаккумулятор, не ждётпоследняя к первой
159514021 69236,47×
85 9721 16013 3372,23×
6413 5226 77715 4601,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,2021,49 мс
21 6880,203,87 мс
32 0810,202,31 мс
41 9290,203,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мак: decoderLinux: readerLinux: decoder
64 Б20 656 412 ± 20 633 04417 786 450 ± 1 681 40650 157 072 ± 6 907 65552 975 207 ± 4 229 916
1 КиБ3 901 798 ± 1 057 6252 276 526 ± 275 9858 432 194 ± 772 8498 848 849 ± 739 349
8 КиБ450 167 ± 191 200292 157 ± 62 8851 051 799 ± 262 4651 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 49152 631 003 ± 10 039 611
1 КиБ7 905 260 ± 1 118 9537 914 373 ± 1 812 032
8 КиБ1 434 999 ± 121 5671 412 595 ± 129 793

Строки совпадают в пределах интервала на всех трёх размерах, чего и следовало ожидать: разница — один объект на отклик из 64 записей.

Заодно видно правило про сравнение прогонов. Тот же общий декодер на 8 КиБ дал 1 472 143 в первом прогоне и 1 412 595 во втором — 4 % врозь, тот же код, тот же хост, разница только во времени суток. Сравнивать имеет смысл колонки внутри одного прогона, и только их.

Стенд Linux: WSL2, 20 ядер, 23 ГиБ. Про долговечность он не свидетельствует вовсе (§1.12), но здесь ничего не касается диска — меряется арифметика над байтами в памяти.

On this page

Как запускатьПрофиль рантаймаПравила, без которых число ничего не значитЗамер 1 (M0, 2026-08-10): запись в сегментЧто из этого следуетЧто в этом замере неправильно, и это надо исправить к следующемуИтог для решения Р1Замер 2 (M2, 2026-08-10): всё вместе, после четырёх исправлений2.1 Запись в сегмент напрямую — верхняя граница всего остального2.2 M-14: чего стоит подтверждение на каждую запись2.3 Групповой коммитЗамер 3 (M-26): минута записи, а не две секундыЗамер 4 (M-24): msync и fsync — не одно и то жеЗамер 5 (M-23): скорость восстановленияЗамер 6 (M-34): сквозной путь по сокетуЗамер 7 (M-35): стоит ли zero-copy своих денегЗамер 8 (M-36): селектор против виртуальных потоковИтог для решения Р3: собственный сетевой слой оправдался, но не тем, чем ожидалосьЗамер 9 (M-44): чего стоит конвейеризацияКонтрольный прогон, который важнее самой таблицыЗамер 10 (2026-08-10, перепрогон на двух хостах): что оказалось свойством macOS10.1 Два вывода проекта оказались свойствами файловой системы10.2 Хранилище: Linux быстрее, и по-разному10.3 Обратная инверсия: на латентности мак быстрее10.4 Сквозной путь10.5 Во что обходится профиль на 20 ядрах10.6 Что это значит для решения Р1Замер 11 (M-45): переживает ли преимущество маппинга сокетИтог для решения Р1: умолчание — MAPPEDИтог: умолчание MAPPED (M-45)Что оставалось непроверенным на момент M-45: всё «линуксовое» было снято в WSL2, где ext4 лежит на виртуальном диске (§1.12). Проверено в M-46 — см. замер 12 ниже. Умолчание подтвердилось по всем осям, а два довода против маппинга из этой же таблицы («под долговечностью пути неразличимы», «худшая секунда у маппинга хуже») оказались свойствами файловых систем, на которых их измерили, и отозваны.Замер 12 (M-46, 2026-08-10): арендованная Linux-машина12.0 Прогон, который пришлось выбросить12.1 M-24: msync против fsync на настоящем ext412.2 Слой хранения: запись в сегмент12.3 Актор записи: батч и политика подтверждения12.4 Групповой коммит под долговечностью12.5 M-23: восстановление12.6 Сквозной путь по сокету12.7 M-26: минута записиИтог M-46: умолчание MAPPED подтверждено на железе, а два вывода проекта отозваныЗамер 13 (M-38, 2026-08-10): zero-copy через настоящую сеть13.1 При равной отданной нагрузке13.2 На потолкеИтог M-38: оговорка замера 7 подтвердилась, но выигрыш не в том, что мерилиЗамер 14 (M-75): чего стоит публикация highWatermarkЗамер 15 (M-74): что купил долгий FETCH и чего стоит подписка15.1 Холостой трафик догнавшего потребителя15.2 Чего стоит Flow против голого poll()не измереноЗамер 16 (M-37): чего стоило соседство стенда и брокера16.1 Стоимость Flow — по-прежнему не измерена, и причина другаяЗамер 17 (M-76): чего стоит Flow в подписке17.1 Почему прошлое сравнение было неверным17.2 Число, снятое там, где оно измеримоЗамер 18 (M-83): базовый образ — glibc против musl18.1 Чем это кончилось: размер без смены libc18.2 Перемер размеров: отношение уцелело, абсолютные числа — нет ⚠️ вывод отозван в 18.3Замер 19 (2026-08-11): чего не может измерить общий раннер19.1 Что показалось19.2 Что оказалось19.3 Вывод, и он не про маппинг19.4 Что осталось вернымЗамер 20 (M-103): чего стоит очередь без координатора20.1 Три воркера, задел в очереди20.2 Что оказалось измерением режима, а не протокола20.3 Тридцать воркеров — не измерено, и вот доказательство20.4 Что стоило этому замеру18.3 Отзыв отзыва: пара 240/514 верна, а мегабайты непереносимыЗамер 21 (M-105): очередь при тридцати воркерах21.1 Две пары подряд, вторая в обратном порядке21.2 Предсказание не подтвердилось, и это главное здесь21.3 Чего столкновения стоят, а чего нетЗамер 22 (M-120): окно группового коммита22.1 Окно проигрывает везде22.2 Почему — видно в пересчёте на операцию22.3 Что из этого следует и чего замер не показалЗамер 23. Аккумулятор Kotlin/Native: за что он платит и что покупает (M-134а)23.1 Аккумулятор с ожиданием каждой записи — худшее из трёх23.2 Без ожидания аккумулятор покупает то, чего иначе нет вовсе23.3 Что из этого следуетЗамер 24. Долгий FETCH у догнавшего потребителя (M-136)24.1 Цена опроса — три порядка, а не проценты24.2 Экономия не оплачена задержкой — это и проверялось24.3 Первый прогон после старта брокера измеряет прогревЗамер 25. Два декодера FETCH: во что обходится общий (M-140)25.1 До слияния: два стенда отвечают по-разному25.2 После слияния: обёртка не стоит ничего