booblik
DocsResearch

booblik — архитектурный research

booblik — брокер сообщений на Kotlin/JVM с хранением в append-only логе: топик режется на партиции, партиция — на сегменты, потребитель читает по числовому оффсету и сам помнит, где остановился. Ниша ровно одна и она не «замена Kafka»: это брокер, который целиком помещается в голову одного человека и в один процесс без ZooKeeper, KRaft-кворума и координатора групп. Всё, что делает Kafka эксплуатируемой в кластере, здесь сознательно отсутствует — а всё, что делает лог быстрым, воспроизводится и замеряется.

Документ фиксирует проверенные факты (прочитанное в исходниках и артефактах или полученное прогоном), принятые решения и риски. Непроверенное названо гипотезой.

Исходный замысел — source-draft. Он не совпадает с тем, что здесь: две его центральные посылки при проверке оказались перевёрнутыми (§1.1, §1.3), и это разобрано в разделе 2.


1. Проверенные факты

1.1 Kafka пишет лог не через mmap. Через mmap у неё только индекс

Драфт говорит: «вдохновлено архитектурой Kafka» и тут же — «Write Path: MappedByteBuffer». Проверено по исходникам Kafka (ветка trunk, чтение кода):

ФактГде проверено
Лог-сегмент пишется через FileChannel, MappedByteBuffer в файле не встречается ни разуclients/src/main/java/org/apache/kafka/common/record/internal/FileRecords.javaprivate final FileChannel channel;, append(MemoryRecords), channel.force(true)
Индексный файл — наоборот, целиком мапитсяstorage/src/main/java/org/apache/kafka/storage/internals/log/AbstractIndex.javaprivate volatile MappedByteBuffer mmap;, raf.getChannel().map(READ_WRITE, 0, length)
Kafka вручную снимает маппинг индекса через Unsafe, не дожидаясь GCтам же — ByteBufferUnmapper.unmap(file.getAbsolutePath(), mmap) в forceUnmap(), затем mmap = null

Следствие 1. Формулировка «как в Kafka, поэтому mmap» неверна в обе стороны: у Kafka mmap стоит там, где доступ случайный и объём мал (индекс), и не стоит там, где доступ строго последовательный и объём велик (лог). Это ровно противоположно драфту.

Следствие 2. «mmap быстрее» перестаёт быть посылкой и становится вопросом, у которого есть цифра. Отсюда решение Р1: реализованы обе записи за одним интерфейсом, и выбор делает бенчмарк.

Следствие 3. Ручной unmap в Kafka — не микрооптимизация, а обход отсутствующего в JDK API (§1.5). У нас на JDK 25 он не нужен, и это единственная причина, по которой маппинг здесь сделан не через MappedByteBuffer.

1.2 Zero-copy живёт в транспортном слое, и TLS его отменяет целиком

ФактГде проверено
Хранилище само по себе transferTo не зовёт — оно отдаёт байты абстракции каналаFileRecords.writeTo(TransferableChannel, int, int)destChannel.transferFrom(channel, position, count)
На нешифрованном соединении это ровно sendfilePlaintextTransportLayer.java:213return fileChannel.transferTo(position, count, socketChannel);
На TLS-соединении zero-copy нет: байты читаются в буфер и шифруютсяSslTransportLayer.java:1003 — цикл вокруг fileChannel.read(fileChannelBuffer)

Следствие. Zero-copy и шифрование канала взаимоисключающи — не «медленнее», а физически несовместимы: шифровать можно только то, к чему прикоснулся процессор. Если TLS когда-нибудь понадобится, это отдельный путь чтения, а не флаг. Пока TLS вне скоупа (см. фичу feature-append-and-fetch, раздел 6).

1.3 Публичный API Ktor Network не отдаёт SocketChannel

Драфт закрывает этот вопрос скобкой: «для прямого доступа к NIO-каналу потребуется извлечь underlying channel». Проверено по API-дампу и исходникам Ktor 3.5.2:

ФактГде проверено
io.ktor.network.sockets.Socket — это ABoundSocket + AConnectedSocket + ReadWriteSocket + CoroutineScope; из методов только attachForReading(ByteChannel) / attachForWriting(ByteChannel)ktor-network/api/ktor-network.api
Реализация, у которой есть канал, — internalktor-network/jvm/src/io/ktor/network/sockets/SocketImpl.ktinternal class SocketImpl<out S : SocketChannel>(override val channel: S, …); NIOSocketImpl тоже internal
Лазейка всё же есть: io.ktor.network.selector.Selectableпубличный интерфейс с getChannel(): SelectableChannelktor-network/api/ktor-network.api
И JVM-сокет Ktor действительно им являетсяktor-network/common/src/io/ktor/network/sockets/SocketBase.ktinternal abstract class SocketBase(…) : ReadWriteSocket, SelectableBase(), а SelectableBase : Selectable

Следствие 1. Написать (socket as Selectable).channel as SocketChannel можно: код компилируется против публичного интерфейса и на JVM работает. Но держится он на том, что SocketBase наследует SelectableBase — на детали реализации internal-класса, которую никто не обещал сохранять. Первый же рефакторинг сокетов в Ktor ломает это в рантайме, а не при компиляции.

Следствие 2. Взять Ktor «ради удобных корутин-сессий» и одновременно zero-copy — нельзя без этой ставки. Отсюда решение Р3: сетевой слой свой, на ServerSocketChannel.

1.4 transferTo отдаёт меньше, чем просят, и это норма

ФактГде проверено
Метод «may or may not transfer all of the requested bytes»javadoc java.nio.channels.FileChannel.transferTo
Один вызов ограничен Integer.MAX_VALUE в самом JDKsun.nio.ch.FileChannelImpl (clamp), JDK-8271308
sendfile в Linux ограничен 2^31-1 независимо от разрядностиJDK-4770025 и обсуждение в LKML
На неблокирующем сокете с полным буфером отправки вызов вернёт 0javadoc + практика NIO
Дисковый ввод-вывод внутри sendfile блокирует, даже если сокет неблокирующийобсуждение «sendfile to nonblocking socket», LKML

Следствие 1. Отправка сегмента — цикл по готовности сокета, а не один вызов. Возврат нуля — это «подожди селектор», а не «конец данных»; спутать их — это busy-loop на 100 % CPU, и выглядит он как «брокер быстрый».

Следствие 2. Последний пункт — причина, по которой поток, дергающий transferTo, нельзя считать неблокирующим. Для виртуальных потоков это означает пиннинг несущего потока; для корутин — занятый поток диспетчера. Это учтено в Р3.

1.5 MappedByteBuffer упирается в 2 ГБ и не умеет освобождаться

ФактГде проверено
Маппинг ограничен 2 ГБ, потому что буфер индексируется intJDK-6347833 («Enhance MappedByteBuffer to support sizes >2GB»), открыт
Поддерживаемого способа снять маппинг нет — регион уходит, когда GC доберётся до буфераJDK-4724038 («Add unmap method to MappedByteBuffer»)
FileChannel.map(mode, offset, size, Arena) возвращает MemorySegment без потолка 2 ГБ и с детерминированным освобождением по Arena.close()javadoc java.lang.foreign.MemorySegment / FileChannel.map (JDK 21+, FFM финализирован в 22)

Следствие. Брокер удаляет отставшие сегменты по расписанию — то есть управляет ровно тем временем жизни, которое MappedByteBuffer отдаёт на усмотрение GC. Kafka обходит это Unsafe-ом (§1.1); на тулчейне JDK 25 обходить нечего. Поэтому маппинг здесь — FFM, а не MappedByteBuffer. Реализация: MappedSegmentWriter.

1.6 value class в generic-позиции боксится — и именно там, где драфт обещал ноль аллокаций

ФактГде проверено
«If an inline class type is used in a generic position, then its boxed type will be used»KEEP, proposals/inline-classes.md; то же в документации Kotlin

Следствие. Индекс из драфта — ConcurrentSkipListMap<Offset, Position> — аллоцирует боксированный ключ и боксированное значение на каждое сообщение, плюс узел дерева. Раздел 2 драфта ради экономии на GC заводит value-классы, а раздел «Milestone 2» отдаёт эту экономию обратно с процентами, в самом горячем месте. Отсюда решение Р2.

1.7 FFM проверяет выравнивание там, где MappedByteBuffer не проверял (найдено прогоном)

Первый же прогон тестов упал:

java.lang.IllegalArgumentException: Target offset 133 is incompatible with
alignment constraint 4 (of I4) for segment MemorySegment{ kind: mapped, … }

ValueLayout.JAVA_INT несёт требование выравнивания на 4 байта, и FFM его применяет. Записи переменной длины начинаются где придётся, так что большинство длинных префиксов не выровнены. MappedByteBuffer.putInt(int, int) этого никогда не проверял — то есть при дословном переносе кода драфта на FFM ошибка появляется на ровном месте.

Следствие. На неровных смещениях нужен JAVA_INT_UNALIGNED. Зафиксировано в комментарии у места записи, потому что через полгода это выглядит как случайно выбранная константа.

1.8 Бенчмарк-метод не может возвращать value class (найдено прогоном)

Benchmark function name is not a valid Java identifier:
"…SegmentAppendBenchmark.append-SgWxkiU" (declared as "…append")

Kotlin манглит имя функции, возвращающей value class; генератор JMH внутри kotlinx-benchmark такое имя отвергает. Возвращать что-то метод обязан — иначе JMH сочтёт вызов мёртвым кодом и выкинет его.

Следствие. Бенчмарки отдают сырой Long, а не Offset. Мелочь, но она стоит одного непонятного падения сборки на каждого, кто напишет следующий бенчмарк.

1.9 msync слабее fsync на macOS и равносилен ему на Linux — это про API, а не про ФС (M-24)

M0 показал барьер через маппинг в 63 раза дешевле барьера через FileChannel и оставил вопрос «дешевле за счёт чего» открытым. Ответ получен прямым экспериментом (DurabilityProbe): если msync действительно сделал данные долговечными, то следующий за ним fsync не найдёт работы и будет почти бесплатным.

Замер (32 МиБ грязных страниц на раунд, медиана 9 раундов)Время
fsync (путь FileChannel)9,33 мс
msync (путь маппинга)16,45 мс
msync, затем fsync по тому же файлу31,04 мс
— из них хвостовой fsync8,55 мс = 92 % от голого fsync

Поправка (замер 10 + чтение исходников). Разница между платформами есть, но она не про файловую систему — она про то, какие системные вызовы делаются. Разбор по источникам:

ФактГде проверено
На Linux msync(MS_SYNC) вызывает vfs_fsync_range(file, fstart, fend, 1)ту же функцию, через которую идёт fsyncядро, msync.c:96
На macOS fsync не доводит данные до накопителя: «the drive itself may not physically write the data to the platters», для этого нужен F_FULLFSYNCman 2 fsync, Darwin
JDK на macOS зовёт именно F_FULLFSYNC, откатываясь на fsync только для нелокальных ФСFileDispatcherImpl.c:51result = fcntl(fd, F_FULLFSYNC);
На macOS msync до F_FULLFSYNC не доходитследует из двух предыдущих + замер
Хостfsync (JDK force())msyncхвостовой fsyncвывод
macOS / APFS8,81 мс — это F_FULLFSYNC13,96 мс6,95 мс = 79 %msync слабее
WSL2 / ext411,01 мс17,14 мс0,21 мс = 2 %равносильны

Следствие для переносимости. Вывод «на Linux равносильны, на macOS нет» держится на исходниках ядра и JDK, а не на одном прогоне, поэтому оговорка про WSL2 (§1.12) его не задевает: обе стороны сравнения идут через один и тот же стек хранения.

Следствие неожиданное. Для вопросов долговечности mac — более надёжный стенд, чем WSL2-машина. Там force() — это F_FULLFSYNC, и известно, что он значит; в WSL2 fsync упирается в виртуальный диск, и дойдёт ли сброс до пластин — вопрос к Hyper-V, а не к ext4.

Следствие 1. На APFS msync возвращает управление, когда работа fsync ещё не сделана. Это не барьер долговечности. Поэтому MappedSegmentWriter.force() теперь зовёт оба, и FORCED означает одно и то же на обоих путях записи.

Следствие 2. Заодно объясняется, откуда в M0 взялись «63 раза»: msync стоит пропорционально объёму грязных страниц (на одной маленькой записи — копейки), а fsync — это барьер с почти постоянной ценой. Сравнивались не два способа сделать одно и то же, а барьер и не-барьер. На одинаковом объёме данных msync оказывается дороже.

1.10 Восстановление упиралось в системные вызовы, а не в диск (измерено, M-23)

Первый вариант recover читал по 4 байта на запись — то есть один read на запись. На логе 256 МиБ из 128-байтных записей это 1,9 млн системных вызовов и 174 МиБ/с: столетний лог на 100 ГиБ открывался бы десять минут, и открытый вопрос 1 («нужен ли индексный файл») выглядел решённым в пользу «нужен».

После перехода на чтение по 1 МиБ:

ВариантСкорость восстановления
read на запись174 МиБ/с = 5,9 с на ГиБ
буфер 1 МиБ4900 МиБ/с = 0,21 с на ГиБ

Следствие. Ускорение в 28 раз, и оба режима записи восстанавливаются одинаково. Лог на 100 ГиБ открывается за ~21 с. Индексный файл не нужен — открытый вопрос 1 закрыт замером, а не гипотезой. Заводить второй формат файла имело бы смысл, только если бы упёрлись в диск; упирались в syscall.

1.11 На длинной дистанции на macOS маппинг быстрее в среднем и хуже в худшем (M-26)

Риск 1 был сформулирован как предсказание: короткий замер на свежем каталоге — самый выгодный для маппинга случай, а на дистанции он должен просесть, причём ступенькой. SustainedWriteProbe пишет минуту, объёмом в несколько раз больше ОЗУ машины, и печатает пропускную способность посекундно.

РежимСреднееМедианаХудшая секундаЛучшая секундаРазброс
MAPPED798 102 зап/с606 570128 1501 973 63715,4×
FILE_CHANNEL245 280 зап/с250 266203 998254 7361,2×

Поправка (замер 10), и она слабее предыдущей. В WSL2 качелей нет вовсе: 1,69 млн записей в секунду при разбросе 1,1× и худшей секунде 1,61 млн, против 1,30 млн у FileChannel.

Подтверждено на настоящем ext4 (замер 12.7, M-46). Разброс 1,1×, худшая секунда 1 924 500 записей в секунду — в 4,0 раза выше лучшей секунды FileChannel (486 372). То есть качели были свойством APFS, а не пути записи: на носителе, куда брокер поедет, планировать по худшей секунде для маппинга можно, и она всё равно вчетверо лучше всего, что даёт второй путь. Риск 1 закрыт — с единственной оговоркой, что стенд KVM, а не bare-metal (§1.15).

Следствие 1 (на macOS). Маппинг быстрее втрое в среднем — и его худшая секунда хуже, чем худшая секунда FileChannel. Предсказание про деградацию подтвердилось; форма оказалась не ступенькой, а качелями: writeback идёт пачками, и писатель то обгоняет его, то ждёт.

Следствие 2. Разброс 15× — это не косметика, а невозможность планировать. Брокер, чья пропускная способность в течение минуты гуляет на порядок, требует очередей и таймаутов, рассчитанных на худшую секунду; тогда среднее перестаёт иметь значение.

Следствие 3. И короткий замер врёт: JMH на свежем сегменте даёт 8,4 млн записей в секунду при килобайте, длинный — 0,8 млн. Разница в десять раз, и она не в пользу коротких замеров.

1.12 WSL2 — не bare-metal Linux, и для этого проекта разница существенна

Замеры «на Linux» сняты внутри WSL2. Проверено на самой машине:

ФактГде проверено
Ядро — сборка Microsoft, не ванильное/proc/version: 6.6.87.2-microsoft-standard-WSL2
Корень — ext4 на виртуальном диске: SCSI-устройство внутри VHDX-файла поверх NTFSfindmnt: /dev/sdd ext4; /sys/block/sdd/device -> 0:0:0:3
Планировщик ввода-вывода отключён, кэш записи объявлен volatile/sys/block/sdd/queue/scheduler = [none], write_cache = write back
Память отдаётся динамически хостом24,6 ГБ по /proc/meminfo, но это не выделенная ОЗУ

Следствие 1. Любое абсолютное число оттуда — число WSL2, а не Linux. Особенно всё, что упирается в диск: под ext4 лежит ещё один кэш и ещё одна файловая система.

Следствие 2. Дойдёт ли fsync до физического носителя, решает Hyper-V, а не ext4. Поэтому про долговечность WSL2 не свидетельствует вообще — см. §1.9.

Следствие 3. Относительные сравнения внутри одного прогона (два пути записи, два транспорта, две глубины конвейера) остаются осмысленными: обе стороны идут через один стек. Именно они и составляют выводы M3, M4 и §1.9.

Что было бы честным стендом: bare-metal Linux с настоящим NVMe. Такого под рукой нет, и до него все числа — верхняя оценка «как оно может быть», а не «как оно есть». Задача M-46.

1.13 transferTo становится sendfile не всегда, и разницы не видно по результату (M-63)

ФактГде проверено
Прямой путь (sendfile / copy_file_range) берётся только для цели типа FileChannelImpl или SocketChannelImpl; для всего остального — UNSUPPORTED_CASEsun/nio/ch/FileChannelImpl.java, transferToDirectInternal
Дальше пробуется маппинг, а затем transferToArbitraryChannel — обычный цикл read/write через кучевой буфер 8 КиБтам же, transferToArbitraryChannel: ByteBuffer.allocate(c)

Следствие 1. Все три пути возвращают одинаковые байты. Корректность их не различает, поэтому подмена zero-copy на копирование не проявляется ничем, кроме графика пропускной способности через несколько месяцев. Отсюда тест на предусловие: соединение обязано отдавать в transferTo настоящий SocketChannel. Обернуть сокет декоратором — метрики, сжатие, TLS — и прямой путь тихо исчезает.

Следствие 2. Наблюдать сам системный вызов изнутри JVM не удалось. Попытка вывести путь из пула direct-буферов провалилась: transferToArbitraryChannel берёт буфер из кучи, так что сигнала, на котором держалась идея, не существует. Трассировщика нет ни на маке, ни на WSL-машине (strace отсутствует, ptrace_scope = 1). Записано, чтобы никто не строил это заново.

1.14 Версии инструментов

Проверено по maven-metadata.xml в Maven Central и в Gradle Plugin Portal, 2026-08-10:

АртефактВерсияПримечание
com.pinterest.ktlint:ktlint-cli1.8.0последняя; требование проекта
org.jlleitschuh.gradle.ktlint14.2.0последняя; плагин только запускает CLI указанной версии
org.jetbrains.kotlin2.4.10последний stable (2.4.20-Beta2 — бета)
kotlinx-coroutines-core1.11.0
kotlinx-benchmark0.4.17обёртка над JMH
org.hdrhistogram:HdrHistogram2.2.2перцентили для нагрузочного стенда
io.ktor:ktor-network3.5.2по нему проверялся §1.3
Gradle9.6.1выбор автора; последний — 9.7.0
JDK тулчейна25выбор автора; нужен для FFM-маппинга (§1.5)

1.15 Арендованный стенд M-46: честный fsync, но не bare-metal — и /tmp в памяти

Две машины Hetzner в nbg1, проверено на них самих 2026-08-10:

ФактГде проверено
4 ядра AMD EPYC-Milan, 15 ГиБ ОЗУ, Ubuntu 26.04, ядро 7.0.0-29-genericnproc, free -g, /etc/os-release, uname -r
Гипервизор KVM; диск отдан как QEMU HARDDISK, а не как NVMe-неймспейсsystemd-detect-virt = kvm; /sys/block/sda/device/model
Диск локальный и не вращающийся; Volume не подключёнlsblk: единственный sda, ROTA=0; findmnt -T / = /dev/sda1 ext4
Кэш записи объявлен volatile, FUA не поддерживается, планировщик отключён/sys/block/sda/queue/{write_cache,fua,scheduler} = write back, 0, [none]
Барьеры настоящие: 4 КиБ с fdatasync на каждую запись — 1224 IOPS, то есть ≈ 800 мкс на запись с барьером, fdatasync p99 = 1074 мксfio --rw=write --bs=4k --direct=1 --fdatasync=1
Приватная сеть между машинами — 7,85 Гбит/с суммарно на 4 потоках (1,97 на одном)iperf3 -c 10.0.0.3 -t 10 -P 4

Следствие 1. Это первый стенд проекта, на котором про долговечность вообще можно говорить. fdatasync стоит сотни микросекунд — то есть до устройства он доходит, в отличие от WSL2, где это решал Hyper-V (§1.12). Оговорка остаётся одна и честная: под ext4 всё ещё KVM, и «до устройства» здесь означает «до того, что показывает гипервизор».

Следствие 2. /tmp на Ubuntu 26.04 — tmpfs, и это едва не испортило всю веху. Первый прогон пробы M-24 отсюда дал fsync за 0,01 мс после 32 МиБ грязных данных и напечатал вердикт про msync в обычном уверенном тоне. Числа были не шумом, а измерением памяти: Files.createTempDirectory без каталога кладёт в java.io.tmpdir, а тот здесь в ОЗУ. Ловится арифметикой — 32 МиБ за 10 мкс это 3,2 ТБ/с, носителя с такой скоростью не существует, — но полагаться на то, что кто-то каждый раз поделит в уме, нельзя. Отсюда MeasurementDir и отказ измеряющего кода работать на летучей ФС. Родня этой ловушки — синхронизация дерева в /mnt/c на WSL (§1.12) и подмена sendfile копированием (§1.13): все три не выглядят поломкой, все три выглядят числом.

Следствие 3. §1.9 подтверждён на настоящем ext4. После msync хвостовой fsync стоит 0,11 мс против 11,36 мс одиночного — 1 %, при 2 % на WSL2. То есть на ext4 msync барьером является, и вызов обоих в MappedSegmentWriter.force() стоит здесь примерно ничего, оставаясь обязательным на macOS.

Следствие 4. M-38 этим стендом решается — но не так, как предполагалось. Оценка «линка не хватит» исходила из того, что мерить будем пропускную способность: zero-copy по замеру 7 не даёт ничего ниже ≈ 1 ГиБ/с, а линк даёт 0,98 ГиБ/с. По пропускной способности так и вышло — разница 5,6 %. Но на процессоре брокера разрыв в 2,5–2,8 раза виден уже при 400 МиБ/с, вчетверо ниже потолка линка (замер 13). Ошибка была не в оценке линка, а в выборе оси.

1.16 Wildcard-привязка тихо уступает порт чужому процессу (M-64)

ФактГде проверено
JDK ставит SO_REUSEADDR на ServerSocketChannel по умолчанию на Unixjavadoc StandardSocketOptions.SO_REUSEADDR, поведение ServerSocketChannelImpl
С ним привязка к wildcard [::]:P проходит, даже когда другой процесс уже слушает [::1]:Pпрогон: bind удался при занятом [::1]:61200
Входящее соединение достаётся более специфичному слушателю, а не wildcardтот же прогон: select на нашем слушателе не показал ни одного соединения
Дескриптор закрытого, но зарегистрированного в селекторе канала не освобождается, пока селектор не снимет ключServerSocketChannelImpl.java:556if ((thread == 0) && !isRegistered())

Как это выглядит снаружи. Брокер стартует, печатает порт, не принимает ни одного соединения и не сообщает ни о чём: исключений нет, счётчики по нулям, поток селектора жив. Клиент видит обрыв там, где ждал ответ. Именно так проявлялась M-64: раз на ~15 тысяч соединений тест ловил EOF от брокера, который ничего не принял, — на машине разработчика в это время висел kubectl port-forward на 127.0.0.1:61200, и наш сервер иногда получал от ОС ровно этот порт.

Как отличить от гонки, не потратив день. Подпись коллизии — повторяющаяся пара портов. Две независимые поимки, сотни раундов друг от друга, в разных JVM, дали одинаковые 61201 → 61200; гонка так не выглядит. Второй признак — «свежее соединение прямо сейчас тоже не обслуживается»: он отделяет сломанный сервер от потерянного сокета.

Следствие. ServerConfig.bindAddress и ключ booblik.bind.address: заданный адрес превращает тихую кражу порта в отказ стартовать. Умолчание осталось wildcard — так брокер обычно и нужен, — но тесты теперь привязываются к 127.0.0.1 явно, потому что для них это чистая ловушка.

Чего это не отменяет. SO_REUSEADDR остаётся включённым: без него перезапуск брокера упирался бы в TIME_WAIT собственного порта. Мы платим за перезапуск возможностью коллизии, и это осознанный размен, а не недосмотр.

1.17 Клиент на KMP: браузер невозможен, а на native нет TLS

Вопрос: делать клиентскую библиотеку на Kotlin/JVM или на Kotlin Multiplatform. Сначала — чем клиент вообще привязан к JVM. Проверено grep по booblik-net/.../client и .../wire:

ПривязкаГде
SocketChannel, InetSocketAddress, StandardSocketOptionsBooblikClient, BooblikConnection
ByteBufferклиент и общий кодек wire — то есть переписывать пришлось бы и то, что делится с брокером
AtomicInteger, ConcurrentLinkedQueueсопоставление ответов по correlationId
Closeable, EOFExceptionвезде
java.util.zip.CRC32CResponseReader — клиент обязан проверять суммы, брокер на zero-copy-пути не может

Список короткий, и это само по себе результат: непереносимого в клиенте мало.

Дальше — что даёт замена. Проверено по артефактам Maven Central, ktor-network 3.5.2:

ФактГде проверено
Публикуется для jvm, всех native, js и wasm.module-метаданные 3.5.2
js/wasm-сокеты работают только под Node.jsв klib ktor-network-js: движок SocketEngine.web.kt зовёт createConnection/createServer из node:net, а node.net.js.kt содержит строку Module node:net is not available. Please verify that you are using Node.js
Селектор на web — заглушкатам же: NoopSelectorManager в SelectorManager.web.kt, строка not supported
UDP на web не поддержанстрока UDP sockets are unsupported on WASM/JS
TLS на native — заглушкав klib ktor-network-tls-linuxx64: строка not supported on Native platform.

Следствие 1. Браузерного клиента не будет никогда, и это не про Ktor. У браузера нет сырого TCP вовсе; js/wasm у Ktor — это Node. Значит «мультиплатформенный клиент» в лучшем случае означает JVM + Android + native + Node, но не веб. Для брокера, к которому ходят бэкенды, это почти тот же список, что уже покрыт JVM.

Следствие 2. На native не будет TLS. Сегодня TLS вне скоупа (§1.2: он и с zero-copy несовместим), но выбирать KMP «на вырост» и одновременно планировать TLS — значит выбрать конфигурацию, в которой одно исключает другое.

Следствие 3. CRC32C придётся написать руками, и это не формальность. Клиент проверяет сумму каждой записи — это единственное место, где её вообще можно проверить. Реализация JDK компилируется в инструкцию SSE4.2/ARMv8 (§SegmentWriter); табличная реализация на Kotlin будет на порядок медленнее, и заплатит за это каждый прочитанный байт.

1.18 Docker: профиль уже зашит, sparse-файлы выживают, а базовый образ — вопрос замера

ФактГде проверено
Стартовый скрипт дистрибутива несёт профиль рантайма в DEFAULT_JVM_OPTSbooblik-app/build/install/booblik-app/bin/booblik-app:205 — те же шесть флагов, что в brokerJvmArgs
Конфигурация уже читается из окружения (booblik.portBOOBLIK_PORT)BooblikConfig, §booblik-app
Sparse-файл переживает и слой overlay2, и том: 512 МиБ видимых, 4 КиБ занятых после записи байта в серединупрогон docker run на WSL, драйвер overlayfs: stat -c %b = 8 блоков по 512 Б и в /layer.dat, и на bind-mount
eclipse-temurin:25-jre существует, как и -alpine, -noble, -ubi10-minimalDocker Hub API

Следствие 1. Образ и замеры описывают один процесс, и это даром: профиль зашивает Gradle при installDist. Ломается это ровно в одном случае — если кто-нибудь переопределит JAVA_OPTS в Dockerfile; тогда поставляется не то, что измерено.

Следствие 2. Умолчание MAPPED образу не мешает. Опасение было конкретным: сегмент предразмечен на 512 МиБ, и если overlay2 не сохраняет разрежённость, свежий брокер с четырьмя партициями материализовал бы два гигабайта. Проверено — сохраняет, и в слое, и на томе. Остаётся косметика, уже описанная в booblik-core: ls и сигнализация по месту читают видимый размер.

Следствие 3. Здоровье контейнера теперь проверяемо по-настоящему. HTTP-эндпоинта нет и не будет, но с M-70 есть METADATA: healthcheck = подключиться и спросить метаданные. До METADATA единственным вариантом был TCP-connect, который у этого брокера не отличает живого от повешенного — принять соединение в бэклог ядро может и без участия процесса (это и была M-64).

1.19 Базовый образ: musl меньше, но он меняет рантайм под всеми числами

Размеры, Docker Hub, 2026-08-11, amd64+arm64 у всех:

ОбразРазмер
bellsoft/liberica-openjre-alpine-musl:2546 МиБ
bellsoft/liberica-openjre-alpine-musl:25-cds61 МиБ
eclipse-temurin:25-jre-alpine72 МиБ
bellsoft/liberica-openjdk-alpine-musl:2583 МиБ
eclipse-temurin:25-jre-noble102 МиБ
eclipse-temurin:25-jre120 МиБ

Liberica JRE на musl — самый маленький, вдвое меньше обычного Temurin JRE. И это не аргумент, которого достаточно, потому что вместе с образом меняется libc.

Что от смены libc заведомо не зависит: sendfile/transferTo, mmap и FFM-сегменты, интринсик CRC32C — всё это системные вызовы и инструкции процессора, а не libc.

Что зависит и чего не видно из документации: аллокатор. У musl malloc под многопоточной нагрузкой заметно слабее glibc. На нашем горячем пути записи это, скорее всего, неважно — куча JVM не ходит в malloc, а direct-буферов у нас 4 байта на сегмент при MaxDirectMemorySize=32M. Но «скорее всего» — это не то слово, которым в этом проекте закрывают вопросы.

Главное следствие, и оно методическое. Все семнадцать замеров сняты на glibc. Сменить базовый образ — значит поменять рантайм под каждым опубликованным числом, а правило 3 запрещает сравнивать прогоны разных хостов. То есть выбор базового образа здесь не вопрос вкуса и не вопрос размера: это замер, и он дешёвый — прогнать существующий набор внутри обоих образов на одной машине подряд. Пока он не сделан, -alpine-musl остаётся кандидатом, а не решением.

Отдельно про -cds: не нужен, и это считается, а не обсуждается. Замерено на WSL, 6 прогонов, первый отброшен как прогрев page cache: старт брокера до booblik listening на пустом логе — 149 мс (разброс 120–242). CDS ускоряет загрузку классов, то есть часть этих 149 мс, и в лучшем случае снимет с них десятки миллисекунд.

Сравнивать это надо с восстановлением: M-23 даёт 0,30 с на гигабайт лога. Гигабайтный лог — 300 мс, то есть уже вдвое дороже всего старта; стогигабайтный — полминуты. У брокера с настоящими данными старт определяется восстановлением, а не загрузкой классов, и CDS оптимизирует ту часть, которая не решает. Пятнадцать мегабайт образа за десятки миллисекунд раз в деплой — плохой размен.

Если старт когда-нибудь станет важен, механизм другой. В JDK 25 есть AOT-кэш Leyden (-XX:AOTCache, -XX:AOTMode, -XX:AOTConfiguration — проверено -XX:+PrintFlagsFinal), и он вытесняет CDS, кэшируя не только классы. Собрать его с наскока не удалось — AOTMode=create ответил Unable to use create AOT cache, — и разбираться я не стал: при 149 мс старта незачем.

1.20 Как это делают у Kafka и Confluent — проверено по их Dockerfile

Вопрос был: как упаковывают нагруженные системы этого класса. Ответ взят из исходников, а не из общих мест.

ФактГде проверено
Apache Kafka: база — eclipse-temurin:21-jre-alpine, и в неё доустанавливается bash и procps-ngapache/kafka:docker/jvm/Dockerfile
Запуск — шелл-скрипт: CMD ["/etc/kafka/docker/run"]; пользователь не root (appuser)там же
Собираются два CDS-архива (kafka.jsa, storage.jsa) — и не «прогревом классов», а полноценным прогоном: форматируется хранилище, поднимается брокер, создаётся топик, пишется и читается сообщение, всё под -XX:ArchiveClassesAtExitdocker/jvm/jsa_launch
Релизный tar проверяется GPG по ключам Apachedocker/jvm/Dockerfile
Есть отдельный вариант на GraalVM native-image поверх alpine:latestdocker/native/Dockerfile
Confluent: база — RedHat UBI 9 (registry.access.redhat.com/ubi9cp-base-java-micro), не root, запуск снова шелл-скриптом /etc/confluent/docker/runconfluentinc/kafka-images:kafka/Dockerfile.ubi9
Обе поставки живут на JDK 21, а не на новейшемте же файлы

Следствие 1. Distroless в этом классе не применяют, и причина техническая. Такие системы поставляют не только сервер, но и операторский инструментарий — kafka-topics.sh, kafka-console-consumer.sh, скрипт запуска, — а он требует оболочки. Kafka не просто мирится с ней, а доустанавливает bash в Alpine, где по умолчанию только busybox. У нас та же категория: точка входа и booblik-health — POSIX-скрипты, порождённые Gradle. Значит наш выбор не «мы не дотянули до distroless», а тот же выбор, что у них.

Следствие 2. База выбирается поддержкой и соответствием, а не размером. Apache берёт Alpine (маленькая, community), Confluent — UBI (контракты RedHat, ленты CVE, сертификация для корпоративных клиентов). Это две разные аудитории и два разных ответа при одинаковом софте внутри. Размер — довесок к решению, а не решение (сами числа отозваны в замере 18.2, отношение уцелело): между 197 и 514 МБ выбирают тогда, когда остальное уже сошлось.

Следствие 3. CDS у них есть, и это не противоречит нашему отказу (§1.19). У Kafka стартуют тысячи классов, брокер и отдельный инструмент хранилища; у нас весь старт — 149 мс, а восстановление лога стоит 0,30 с на гигабайт. Показателен не сам факт, а как они его делают: архив снимается с настоящего прогона с трафиком, а не с загрузки классов. Если старт когда-нибудь станет для нас важен, рецепт известен и проверен.

Следствие 4. Они на LTS, мы нет, и это осознанно. JDK 21 против нашего 25 — у нас причина записана (§1.5: FFM-маппинг без потолка 2 ГБ и с детерминированным освобождением). Цена тоже реальна: экосистема обкатана на 21, и базовые образы под 25 моложе. Это не повод менять решение, но повод помнить, что мы идём не по проторенной дорожке.

2. Решения

Р1. Оба пути записи реализованы, выбор делает бенчмарк (отклонение от драфта)

Драфт: запись только через MappedByteBuffer, «скорость ограничена только скоростью RAM». Решение: интерфейс SegmentWriter с двумя реализациями — FileChannelSegmentWriter и MappedSegmentWriter. Умолчание — FILE_CHANNEL, и оно меняется тогда, когда это скажет бенчмарк, а не раньше.

Почему:

  • посылка «Kafka так делает» ложная в буквальном смысле (§1.1): Kafka делает наоборот;
  • «ограничено скоростью RAM» верно только пока грязных страниц мало. Когда их становится много, запись упирается в фоновый сброс ядра, и характер деградации у mmap хуже: не замедление, а ступенька, потому что msync/writeback идёт большими пачками;
  • цена решения — два кодовых пути вместо одного и обязанность гонять бенчмарк на оба. Это цена, которую мы платим осознанно: она превращает архитектурный спор в число.

Умолчание — MAPPED (M-45). Решение принято после того, как M-60 закрыла последнее возражение, которое было не про скорость: без контрольных сумм у предразмеченного маппинга не было границы лога, и рваная запись там не детектировалась. С сумой она детектируется на обоих путях одинаково.

Подтверждено на двух уровнях, а не на одном:

УровеньFILE_CHANNELMAPPED
Слой хранения, 64 Б без сброса (Linux)2 465 25937 555 710
Долговечная запись (Linux)8161 298
Дистанция, минута (Linux)1 302 0351 694 363, без качелей
Сквозной путь, потолок (замер 11)937 301 зап/с1 289 201
Сквозной путь при 1 млн зап/с88 % нагрузки, p50 1 636 мс100 %, p50 0,202 мс

Последние две строки — то, чего раньше не было: всё предыдущее мерилось на хранилище, а между ним и клиентом лежат кодек, селектор, актор и сеть. Преимущество через них проходит.

Цена решения, записанная честно: сегмент занимает свою ёмкость на бумаге с первой записи, -Xmx перестаёт что-либо говорить о потреблении памяти процессом, а на машине разработчика (APFS) маппинг гуляет в 8,4 раза — то есть разработчик видит худший вариант, чем прод. Последнее подтвердилось с обеих сторон в M-46: на ext4 разброса нет вовсе (1,1×), так что дрожь на ноутбуке — свойство APFS, а не повод не ставить MAPPED в прод. Откат — одна строка конфигурации, и данные переживают смену в обе стороны (ModeMigrationTest).

История решения (оставлена намеренно). Три проверки, от которых оно зависело, были пройдены ещё в M-15, и тогда умолчание осталось FILE_CHANNEL, но уже по другому основанию:

Что проверялиИтог
Долговечная запись (M-24)Разницы нет: 251 против 254 записи в секунду. Всё превосходство маппинга здесь было артефактом msync, который барьером не является (§1.9)
Пропускная способность без долговечности (M-14)Маппинг быстрее в 21 раз на бурсте (4,34 млн против 209 тыс при батче 100)
Поведение на дистанции (M-26)Маппинг быстрее втрое в среднем, но гуляет в 15 раз, и его худшая секунда хуже худшей секунды FileChannel (§1.11)

Решающими тогда оказались первая и третья строки — и обе были сняты на macOS. Перепрогон на Linux (замер 10) перевернул первую, M-60 сняла довод о корректности, а замер 11 показал, что преимущество доживает до клиента. При этом выбор пути записи всё равно не главный рычаг: батчи дают 54–80 раз, путь записи — единицы.

Что было сказано в M0 и оказалось неверным. Тогда первое число выглядело как подтверждение драфта: маппинг в 41 раз быстрее, причём и с барьером, и без. Первая половина устояла, вторая рассыпалась целиком, когда у неё спросили «дешевле за счёт чего».

Р2. Индекс — разреженный, в LongArray, а не ConcurrentSkipListMap (отклонение от драфта)

Драфт, Milestone 2: ConcurrentSkipListMap<Offset, Position>, запись на каждое сообщение. Решение: SparseOffsetIndex — одна запись на каждые 4 КиБ лога, элемент — примитивный Long (относительный оффсет в старших 32 битах, позиция в младших), поиск двоичный, дальше — проход вперёд по префиксам длин.

Почему:

  • плотный индекс на value-классах аллоцирует бокс на ключ и бокс на значение на каждое сообщение (§1.6) — ровно та нагрузка на GC, ради отсутствия которой заведены value-классы;
  • 8 байт на запись при 64-байтных сообщениях — это +12 % к объёму данных, и это только массив, без узлов дерева;
  • 4 КиБ — интервал по умолчанию у Kafka (index.interval.bytes), и он подобран так, чтобы проход вперёд укладывался в страницу page cache, то есть не стоил ни одного лишнего обращения к диску;
  • цена: чтение по оффсету перестало быть O(log n) и стало O(log n) + проход. Проход ограничен интервалом и идёт по горячим страницам; если замер покажет обратное, интервал — параметр.

Р3. Сетевой слой свой, на ServerSocketChannel, а не Ktor Network (отклонение от драфта)

Драфт, §4: Ktor Network ради корутин-сессий, а SocketChannel «потребуется извлечь». Решение: свой acceptor. Ktor остаётся в проекте, но только как клиент для нагрузочного стенда, где zero-copy не нужен.

Почему:

  • извлечь его нечем: публичный API отдаёт ByteChannel, а не SocketChannel (§1.3). Рабочая лазейка — каст к Selectable — держится на наследовании внутри internal-класса и ломается в рантайме, а не на компиляции;
  • отвергнутая альтернатива: сделать этот каст и жить с ним. Отвергнута не из брезгливости, а потому что цена отказа — «брокер перестал работать после апгрейда Ktor», и цена эта платится в проде, а не в CI;
  • отвергнутая альтернатива вторая: блокирующие сокеты на виртуальных потоках JDK 25. Код проще всего, но transferTo блокирует несущий поток на дисковом вводе-выводе (§1.4), а файловый ввод-вывод виртуальные потоки не виртуализуют. Как опорная точка для бенчмарка вариант хорош, и в этом качестве он в бэклоге остаётся;
  • цена: селектор и разбор протокола пишем сами. Это M3–M4 целиком.

Поправка, найденная при M3. Решение верное, а обоснование — нет. Затевалось ради sendfile, а окупился транспорт: селектор против блокирующих сокетов на виртуальных потоках — плюс 21 % к потолку и кратно меньшая задержка на любой нагрузке (M-36), тогда как sendfile не даёт ничего до гигабайта в секунду и отыгрывает только у насыщения (M-35). То есть даже если бы Ktor отдавал SocketChannel, свой acceptor всё равно был бы оправдан — но аргументом «нужен свой контроль над готовностью», а не «нужен zero-copy». Числа — benchmarking, замеры 7 и 8.

Причина проигрыша виртуальных потоков известна заранее и подтвердилась: transferTo блокирует на дисковом вводе-выводе, а файловый ввод-вывод виртуальные потоки не виртуализуют (§1.4).

Поправка к поправке (M-38). «sendfile не даёт ничего до гигабайта в секунду» — свойство loopback, а не zero-copy: там ядро копирует в любом случае, поэтому копия, которую убирает sendfile, убиралась не полностью. На двух машинах через настоящую сеть zero-copy по-прежнему почти не добавляет пропускной способности (5,6 % у насыщения) и не меняет задержку — но стоит в 2,5–2,8 раза меньше процессора на тот же отданный байт, и это видно уже вчетверо ниже потолка линка (замер 13). Обоснование Р3 после этого держится на обоих аргументах, а не на одном: и на контроле над готовностью, и на цене отдачи байтов.

Практическое следствие — про SendfileTest (§1.13): цена обёртки над сокетом теперь известна, и это не «немного медленнее», а втрое больше процессора при побайтово одинаковом ответе.

Р4. Долговечность — явная политика, а не побочный эффект

Драфт про fsync не говорит ничего, а «ОС сама сбросит грязные страницы» звучит как гарантия. Решение: SegmentWriter.force() есть у обеих реализаций, политика вызова — параметр, и любая цифра пропускной способности обязана называть режим, в котором она снята.

Почему: без force оба пути измеряют скорость page cache, а не диска. Разница между режимами — порядки, поэтому цифра без указания режима не просто неточна, она не значит ничего. Это правило вынесено в benchmarking и вшито параметром в бенчмарк.

Р5. Гейт — одна команда, и бенчмарки в него не входят

./gradlew check = тесты + ktlintCheck во всех модулях. ktlint подключён в корневом build.gradle.kts на все подпроекты, версия CLI прибита к 1.8.0 явно.

Бенчмарки в check не входят — они идут минутами, и гейт, который никто не дожидается, перестаёт быть гейтом. Но модуль бенчмарков собирается обычным assemble, то есть сломанный бенчмарк ломает сборку. Некомпилируемый бенчмарк тихо сгнивает при первом же рефакторинге — и обнаруживается ровно тогда, когда цифра нужна.

Р6. Профиль рантайма — ограничение, а не настройка

Брокер проектируется под заданный профиль JVM:

-XX:+UseSerialGC -XX:ReservedCodeCacheSize=32M -XX:MaxDirectMemorySize=32M
-Xss256k -XX:MaxMetaspaceSize=80M -Xmx64M

Он задан один раз (brokerJvmArgs в корневом build.gradle.kts) и применяется и к тестам, и к форку бенчмарка — так нарушение вылезает в гейте, а не в проде, и так число из бенчмарка описывает ту JVM, которую собираются разворачивать.

Почему это решение, а не строчка конфига:

  • 64 МиБ кучи означают, что горячий путь не имеет права аллоцировать. Это перестаёт быть пожеланием и становится проверяемым свойством: тест, который начнёт мусорить, падает с OOM;
  • замер подтвердил, что цена профиля близка к нулю — семь строк из восьми в замере 1 не отличаются за пределами погрешности. То есть ограничение ничего не отняло, а гарантию дало;
  • но от маппинга он не защищает. FileChannel.map — ни куча, ни direct-buffer, поэтому MAPPED спокойно адресует 512 МиБ внутри 64-мегабайтной кучи. Приятно на практике и опасно как принцип: -Xmx здесь не является границей потребления памяти процессом, и rss брокера придётся ограничивать чем-то другим;
  • цена: -Xss256k закрывает дорогу рекурсивным разборам, а -XX:ReservedCodeCacheSize=32M ставит потолок на объём JIT-компилированного кода. Обе цены сейчас не жмут, но обе — это ограничения на будущий код, а не на текущий.

Проверяется, а не предполагается: флаги доезжают до измеряемого процесса потому, что JMH передаёт форку аргументы хостовой JVM. Это поведение JMH, и оно может измениться; отказ был бы тихим, поэтому RuntimeFootprint валит прогон при их отсутствии.

Р7. Код по-английски, документация по-русски

Открытый репозиторий: идентификаторы, KDoc и комментарии — английские, документация в docs/ и бэклог — русские. Правило заводится до первого коммита, потому что переводить потом дороже.


Р8. Клиент остаётся Kotlin/JVM, но выносится в свой модуль

Вопрос был: JVM или Multiplatform. Решение: JVM, и отдельный модуль booblik-client, куда переезжают клиент и общий кодек.

Почему не KMP:

  • браузера не будет никогда (§1.17): у него нет сырого TCP, а js/wasm у Ktor — это Node. Значит KMP покрыл бы JVM + Android + native + Node, а Android — это уже JVM. Реально добавляется native и Node, и под них сегодня нет ни одного потребителя;
  • на native нет TLS (§1.17, строка not supported on Native platform. в артефакте). Выбирать конфигурацию, в которой одна будущая хотелка исключает другую, — плохой способ «оставить возможность»;
  • CRC32C пришлось бы написать руками, потеряв интринсик. Клиент проверяет сумму каждой записи — это единственное место, где её вообще можно проверить, — и платил бы за переносимость каждым прочитанным байтом;
  • отвергнутая альтернатива: KMP сразу. Отвергнута не из-за сложности, а потому что цена платится сейчас и всей производительностью чтения, а выгода — гипотетическая.

Почему всё-таки отдельный модуль: непереносимого в клиенте оказалось мало и оно перечислимо (§1.17). Вынести его — дёшево, и это ровно та часть работы, которая понадобилась бы при переходе на KMP. То есть возможность сохраняется, а платить за неё сейчас не приходится.

Что заставит пересмотреть: появление настоящего потребителя не на JVM. Не «а вдруг», а конкретного: iOS-приложение, ходящее в брокер напрямую, или сервис на Kotlin/Native.

Р9. Базовый образ: bellsoft/liberica-openjre-alpine:25

Итог (замер 18.1, числа уточнены в 18.3): Alpine ради размера, glibc ради сравнимости с замерами, BellSoft ради сопровождения. Вдвое меньше temurin — 240 МБ против 514 на Linux (62 МБ сжатым), и ни одного доустановленного пакета. Размер образа между хостами не сравним, устойчиво только отношение.

Развилка, вокруг которой всё крутилось, оказалась ложной: у BellSoft на Alpine есть вариант с glibc (-alpine, 50 МиБ) рядом с musl (-alpine-musl, 46 МиБ) — проверено ldd внутри образов, libc.so.6 против libc.musl-x86_64.so.1. То есть размер и «тот же libc, на котором мерили» не противоречат друг другу, и платить libc за мегабайты не пришлось.

Ниже — рассуждение, которым это решалось, оно осталось верным.

bellsoft/liberica-openjre-alpine-musl:25 — 46 МиБ против 120 МиБ у eclipse-temurin:25-jre (§1.19). Соблазн очевиден, и решение — не выбирать до замера.

Причина в правиле 3, а не в осторожности. Все семнадцать замеров сняты на glibc; смена базового образа меняет libc под каждым опубликованным числом, а прогоны разных рантаймов сравнивать нельзя. Аллокатор musl под многопоточной нагрузкой слабее glibc — на нашем горячем пути это, вероятно, неважно (куча JVM не ходит в malloc, direct-буферов 4 байта на сегмент), но «вероятно» здесь не годится.

Замер дешёвый: прогнать существующий набор внутри обоих образов на одной машине подряд. До него -alpine-musl — кандидат, а не решение.

Дополнено после §1.20. Ориентиры класса выбирают базу не по размеру: Apache Kafka берёт Alpine, Confluent — UBI, и различает их аудитория (community против контрактов RedHat с лентами CVE), а не мегабайты. Для нас это значит, что вопрос «temurin или musl» решается не только замером 18, но и тем, кто и как быстро чинит уязвимости в базе. Distroless из рассмотрения выбывает по технике: в этом классе поставляют операторские скрипты, и Kafka специально доустанавливает bash в Alpine, где его нет. -cds отклонён отдельно и по счёту: старт брокера 149 мс, восстановление 0,30 с на гигабайт (§1.19).

Р10. Образ собирает готовый дистрибутив, а не компилирует его

Сборочная стадия в Dockerfile была и убрана. Она обещала независимость от машины собирающего, но это уже даёт обёртка Gradle: версия Gradle прибита, JDK 25 приезжает через foojay-резолвер. Взамен она стоила трёх вещей:

  • образ собирался из кода, не прошедшего гейт — внутри docker build не запускаются ни тесты, ни ktlint, ни проверка ABI;
  • в контекст приходилось копировать booblik-benchmark только потому, что Gradle требует существования всех модулей из settings.gradle.kts;
  • сборка зависела от сети сильнее, чем нужно: когда на сборочной машине лёг реестровый прокси, она упала, не прочитав ни строки.

Так же устроены образы Kafka и Confluent (§1.20): в образ кладётся собранный артефакт, компиляции внутри нет. Порядок гарантирует ci/docker-build.sh — гейт, installDist, потом docker build.

Отвергнутая альтернатива — Jib (плагин 3.5.4, jib-core обновлялся 2026-07-14, то есть проект живой). Он решает ту же проблему изящнее: образ собирает Gradle, демон Docker не нужен, слои разделены на зависимости и классы. Не взят из-за двух проверенных ограничений:

ОграничениеГде проверено
healthcheck не настраивается — слова нет в таблице параметров плагина вовсе, и HEALTHCHECK не входит в OCI-спецификацию образа, это расширение DockerREADME jib-gradle-plugin, полная таблица container.*: jvmFlags, entrypoint, args, user, volumes, ports, labels, environment
Нет RUN — значит нет ни adduser, ни chown; каталог данных на свежем именованном томе останется root-овымтам же: Jib не исполняет Dockerfile

Мы потеряли бы ровно то, что сделали в M-82 и M-84: проверку здоровья через METADATA внутри образа и владельца каталога данных. Обе потери безболезненны в Kubernetes — там HEALTHCHECK образа игнорируется в пользу probes, а владельца тома задаёт fsGroup, — и болезненны на пути docker run, которым человек знакомится с брокером впервые.

Условие пересмотра: поставка только под Kubernetes и несколько сервисов, которым нужен общий способ сборки образов. Тогда Jib выигрывает, и потери перестают быть потерями.

3. Риски и открытые вопросы

Риск 1 — сбылся на macOS и не сбылся на Linux (M-26, затем M-46). Опасение было: замер на свежем каталоге с пустым page cache — самый благоприятный для mmap случай из возможных. На APFS оно подтвердилось в худшей форме: разброс 15,4× и худшая секунда хуже, чем у FileChannel. На ext4 арендованной машины, где записано 7,6 объёма ОЗУ, разброса нет — 1,1×, — а худшая секунда маппинга вчетверо выше лучшей секунды FileChannel (замер 12.7). Закрыт. Осталась оговорка про KVM (§1.15), но она про абсолютные числа, а не про форму.

Риск 2 — сбылся наполовину (M-35). Опасение было: sendfile экономит копирование, но если время уходит в разбор протокола и планирование корутин, экономия утонет в шуме. Замер: до 1 ГиБ/с разницы нет вообщеZERO_COPY и HEAP совпадают в пределах шума. Разница появляется у насыщения: на 2 ГиБ/с p50 в девять раз ниже (0,80 против 7,16 мс), на 3 ГиБ/с плюс 8 % принятой нагрузки.

Пересматривать Р3 не пришлось, но его обоснование сместилось: см. поправку к Р3 ниже. Оговорка — замер по loopback, где ядро копирует в любом случае; на настоящей карте разрыв должен быть больше (M-38).

Риск 3 — подтверждён с большим запасом (M-14). Гипотеза была: батч выигрывает больше, чем mmap у FileChannel. Форма из драфта — подтверждение на каждую запись — даёт 80 592 записи в секунду; та же запись батчами по 100 даёт 4 335 482. 54 раза против 1,4 раза, которые даёт выбор пути записи при том же батче. Единица записи оказалась на порядок более важным решением, чем то, вокруг чего построен весь §3 драфта.

Риск 4 — сбылся, и сильнее, чем формулировался (замер 10). Опасение было: числа с macOS не переносятся на Linux, но выводы переносятся. Перепрогон показал, что не переносятся и выводы: «msync не барьер» верно на APFS и неверно на ext4 (§1.9), «маппинг гуляет на дистанции» верно на APFS и неверно на ext4 (§1.11). Оба вывода полгода выглядели как свойства кода.

Смягчение теперь такое: разработка на маке, решения — по Linux, потому что там брокер и работает. Прогон на Linux — одна команда (ci/wsl-run.sh), и у неё есть своя ловушка: сессия WSL стартует в /mnt/c/..., так что дерево уезжает на 9p поверх NTFS, всё собирается и запускается, а меряется не то. Скрипт такой путь отвергает.

Риск 5 — закрыт (M-60). Было: контрольных сумм нет, восстановление доверяет префиксу длины, разрыв внутри тела не детектируется. Теперь заголовок записи — [длина][crc32c], восстановление проверяет каждую запись и останавливается на первой несошедшейся, сохраняя всё до неё.

Где проверяется и где сознательно нет: восстановление — да; путь обычного чтения — да, у него байты и так в руках; zero-copy — нет и не может, он до байтов не дотрагивается, в этом весь смысл. Там проверяет клиент, потому что именно он в итоге держит байты. То есть сумма защищает диск, а не сеть.

Открытый вопрос 1 — закрыт замером (M-23). Индексный файл не нужен. Гипотеза («файл понадобится») не подтвердилась: проход упирался не в диск, а в системные вызовы — по одному read на запись. После чтения буферами по 1 МиБ восстановление идёт со скоростью 4 900 МиБ/с, то есть 0,21 с на гигабайт лога. Заводить второй формат файла было бы решением проблемы, которой нет.

Открытый вопрос 2 — закрыт (M-22). Восстановление реализовано и различает два пути записи: у FILE_CHANNEL границу лога задаёт длина файла, у MAPPED — нулевой префикс, потому что файл там предразмечен. Подробности — в booblik-core, раздел 8.

Риск 7 (новый, M8). Поставляется не то, что измерено. Профиль рантайма зашит в стартовый скрипт дистрибутива (§1.18), то есть образ по умолчанию описывает измеренный процесс. Ломается это двумя способами: JAVA_OPTS в Dockerfile перебивает профиль, и смена базового образа меняет libc (§1.19). Смягчение: RuntimeFootprint уже валит прогон без флагов — то же самое надо проверять в образе, а не только в бенчмарке; и выбор базового образа делается замером (Р9).

Риск 6 (новый, M3). Стенд меряет сам себя. Нагрузочный генератор живёт в одной JVM с брокером и делит с ним 64 МиБ кучи: пауза GC останавливает обоих, аллокации клиента приписываются брокеру. Смягчение: счётчики GC печатаются рядом с перцентилями, а не в сноске. Закрывается M-37.

Отдельно стоит запомнить, чем это уже обошлось: два бага в самом стенде выдавали правдоподобные числа. Отправитель спинил при ожидании меньше 2 мс — при восьми соединениях на восьмиядерной машине это восемь занятых ядер, и брокер планировался против собственного генератора нагрузки (p99 110 мс вместо 16 мс). И полезная нагрузка аллоцировалась на каждый запрос. Правило: прежде чем объяснять хвост свойствами брокера, проверь, что его не создаёт измеритель.

Открытый вопрос 3 — закрыт замером (M-120, замер 22), и гипотеза оказалась неверной по знаку. Спрашивалось: стоит ли держать группу открытой несколько миллисекунд, чтобы в неё набилось больше участников. Предсказывалось: при 64 продюсерах выигрыш мал, при 2–4 заметен.

Измерено: окно проигрывает везде — в 1,6–2,6 раза, и сильнее всего там, где ожидался наибольший выигрыш. Причина видна в пересчёте на операцию: добавка постоянна, около 1,1 мс при любом числе продюсеров, то есть окно не наполняется никогда. Гипотеза молча предполагала, что во время ожидания подойдут новые продюсеры; подходить некому — группа уже содержит всех, кто в полёте, а они ждут её же барьера.

Ручка groupWindowMillis добавлена и выключена по умолчанию: окно может окупиться только там, где продюсеры приходят независимо от подтверждений, а брокер этого знать не может. Величина проигрыша зависит от хоста (при барьере в 11 мс та же добавка стоила бы +10 % вместо +140 %), направление — нет.


4. Что делать дальше

Порядок работ и приёмка — BACKLOG.md. Закрыты M0, M1 и M2: хранилище целиком, включая роллинг сегментов, восстановление после рестарта, retention и актор записи. Сети по-прежнему нет.

Вопрос, от которого зависела форма всего остального, — единица записи — решён и оказался главным рычагом производительности в проекте: батч против подтверждения на сообщение даёт 54 раза, а выбор пути записи, вокруг которого построен весь §3 драфта, — полтора. (Вторая половина отозвана в M-46: полтора раза — это про FILE_CHANNEL, который перестаёт отвечать на рост батча. Маппинг отвечает до 58,6×, и там выбор пути даёт до 10 раз — замер 12.3. Первая половина в силе: батч остаётся главным рычагом у обоих путей.) Теперь можно проектировать протокол: сколько записей в одном PRODUCE и когда клиент вправе считать запись принятой, — ответы на это есть в числах, а не в предпочтениях.

Дальше M3 — сеть и zero-copy, и это первая веха, где выяснится, стоило ли решение Р3 своей цены: если FETCH через transferTo не выиграет у чтения в heap (риск 2), собственный сетевой слой затевался зря, и Р3 придётся пересматривать.

On this page

1. Проверенные факты1.1 Kafka пишет лог не через mmap. Через mmap у неё только индекс1.2 Zero-copy живёт в транспортном слое, и TLS его отменяет целиком1.3 Публичный API Ktor Network не отдаёт SocketChannel1.4 transferTo отдаёт меньше, чем просят, и это норма1.5 MappedByteBuffer упирается в 2 ГБ и не умеет освобождаться1.6 value class в generic-позиции боксится — и именно там, где драфт обещал ноль аллокаций1.7 FFM проверяет выравнивание там, где MappedByteBuffer не проверял (найдено прогоном)1.8 Бенчмарк-метод не может возвращать value class (найдено прогоном)1.9 msync слабее fsync на macOS и равносилен ему на Linux — это про API, а не про ФС (M-24)1.10 Восстановление упиралось в системные вызовы, а не в диск (измерено, M-23)1.11 На длинной дистанции на macOS маппинг быстрее в среднем и хуже в худшем (M-26)1.12 WSL2 — не bare-metal Linux, и для этого проекта разница существенна1.13 transferTo становится sendfile не всегда, и разницы не видно по результату (M-63)1.14 Версии инструментов1.15 Арендованный стенд M-46: честный fsync, но не bare-metal — и /tmp в памяти1.16 Wildcard-привязка тихо уступает порт чужому процессу (M-64)1.17 Клиент на KMP: браузер невозможен, а на native нет TLS1.18 Docker: профиль уже зашит, sparse-файлы выживают, а базовый образ — вопрос замера1.19 Базовый образ: musl меньше, но он меняет рантайм под всеми числами1.20 Как это делают у Kafka и Confluent — проверено по их Dockerfile2. РешенияР1. Оба пути записи реализованы, выбор делает бенчмарк (отклонение от драфта)Р2. Индекс — разреженный, в LongArray, а не ConcurrentSkipListMap (отклонение от драфта)Р3. Сетевой слой свой, на ServerSocketChannel, а не Ktor Network (отклонение от драфта)Р4. Долговечность — явная политика, а не побочный эффектР5. Гейт — одна команда, и бенчмарки в него не входятР6. Профиль рантайма — ограничение, а не настройкаР7. Код по-английски, документация по-русскиР8. Клиент остаётся Kotlin/JVM, но выносится в свой модульР9. Базовый образ: bellsoft/liberica-openjre-alpine:25Р10. Образ собирает готовый дистрибутив, а не компилирует его3. Риски и открытые вопросы4. Что делать дальше