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.java — private final FileChannel channel;, append(MemoryRecords), channel.force(true) |
| Индексный файл — наоборот, целиком мапится | storage/src/main/java/org/apache/kafka/storage/internals/log/AbstractIndex.java — private 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) |
На нешифрованном соединении это ровно sendfile | PlaintextTransportLayer.java:213 — return 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 |
Реализация, у которой есть канал, — internal | ktor-network/jvm/src/io/ktor/network/sockets/SocketImpl.kt — internal class SocketImpl<out S : SocketChannel>(override val channel: S, …); NIOSocketImpl тоже internal |
Лазейка всё же есть: io.ktor.network.selector.Selectable — публичный интерфейс с getChannel(): SelectableChannel | ktor-network/api/ktor-network.api |
| И JVM-сокет Ktor действительно им является | ktor-network/common/src/io/ktor/network/sockets/SocketBase.kt — internal 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 в самом JDK | sun.nio.ch.FileChannelImpl (clamp), JDK-8271308 |
sendfile в Linux ограничен 2^31-1 независимо от разрядности | JDK-4770025 и обсуждение в LKML |
| На неблокирующем сокете с полным буфером отправки вызов вернёт 0 | javadoc + практика NIO |
Дисковый ввод-вывод внутри sendfile блокирует, даже если сокет неблокирующий | обсуждение «sendfile to nonblocking socket», LKML |
Следствие 1. Отправка сегмента — цикл по готовности сокета, а не один вызов. Возврат нуля — это «подожди селектор», а не «конец данных»; спутать их — это busy-loop на 100 % CPU, и выглядит он как «брокер быстрый».
Следствие 2. Последний пункт — причина, по которой поток, дергающий transferTo, нельзя
считать неблокирующим. Для виртуальных потоков это означает пиннинг несущего потока; для
корутин — занятый поток диспетчера. Это учтено в Р3.
1.5 MappedByteBuffer упирается в 2 ГБ и не умеет освобождаться
| Факт | Где проверено |
|---|---|
Маппинг ограничен 2 ГБ, потому что буфер индексируется int | JDK-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 мс |
— из них хвостовой fsync | 8,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_FULLFSYNC | man 2 fsync, Darwin |
JDK на macOS зовёт именно F_FULLFSYNC, откатываясь на fsync только для нелокальных ФС | FileDispatcherImpl.c:51 — result = fcntl(fd, F_FULLFSYNC); |
На macOS msync до F_FULLFSYNC не доходит | следует из двух предыдущих + замер |
| Хост | fsync (JDK force()) | msync | хвостовой fsync | вывод |
|---|---|---|---|---|
| macOS / APFS | 8,81 мс — это F_FULLFSYNC | 13,96 мс | 6,95 мс = 79 % | msync слабее |
| WSL2 / ext4 | 11,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
пишет минуту, объёмом в несколько раз больше ОЗУ машины, и печатает пропускную способность
посекундно.
| Режим | Среднее | Медиана | Худшая секунда | Лучшая секунда | Разброс |
|---|---|---|---|---|---|
MAPPED | 798 102 зап/с | 606 570 | 128 150 | 1 973 637 | 15,4× |
FILE_CHANNEL | 245 280 зап/с | 250 266 | 203 998 | 254 736 | 1,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-файла поверх NTFS | findmnt: /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_CASE | sun/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-cli | 1.8.0 | последняя; требование проекта |
org.jlleitschuh.gradle.ktlint | 14.2.0 | последняя; плагин только запускает CLI указанной версии |
org.jetbrains.kotlin | 2.4.10 | последний stable (2.4.20-Beta2 — бета) |
kotlinx-coroutines-core | 1.11.0 | |
kotlinx-benchmark | 0.4.17 | обёртка над JMH |
org.hdrhistogram:HdrHistogram | 2.2.2 | перцентили для нагрузочного стенда |
io.ktor:ktor-network | 3.5.2 | по нему проверялся §1.3 |
| Gradle | 9.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-generic | nproc, 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 по умолчанию на Unix | javadoc StandardSocketOptions.SO_REUSEADDR, поведение ServerSocketChannelImpl |
С ним привязка к wildcard [::]:P проходит, даже когда другой процесс уже слушает [::1]:P | прогон: bind удался при занятом [::1]:61200 |
| Входящее соединение достаётся более специфичному слушателю, а не wildcard | тот же прогон: select на нашем слушателе не показал ни одного соединения |
| Дескриптор закрытого, но зарегистрированного в селекторе канала не освобождается, пока селектор не снимет ключ | ServerSocketChannelImpl.java:556 — if ((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, StandardSocketOptions | BooblikClient, BooblikConnection |
ByteBuffer | клиент и общий кодек wire — то есть переписывать пришлось бы и то, что делится с брокером |
AtomicInteger, ConcurrentLinkedQueue | сопоставление ответов по correlationId |
Closeable, EOFException | везде |
java.util.zip.CRC32C | ResponseReader — клиент обязан проверять суммы, брокер на 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_OPTS | booblik-app/build/install/booblik-app/bin/booblik-app:205 — те же шесть флагов, что в brokerJvmArgs |
Конфигурация уже читается из окружения (booblik.port → BOOBLIK_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-minimal | Docker 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:25 | 46 МиБ |
bellsoft/liberica-openjre-alpine-musl:25-cds | 61 МиБ |
eclipse-temurin:25-jre-alpine | 72 МиБ |
bellsoft/liberica-openjdk-alpine-musl:25 | 83 МиБ |
eclipse-temurin:25-jre-noble | 102 МиБ |
eclipse-temurin:25-jre | 120 МиБ |
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-ng | apache/kafka:docker/jvm/Dockerfile |
Запуск — шелл-скрипт: CMD ["/etc/kafka/docker/run"]; пользователь не root (appuser) | там же |
Собираются два CDS-архива (kafka.jsa, storage.jsa) — и не «прогревом классов», а полноценным прогоном: форматируется хранилище, поднимается брокер, создаётся топик, пишется и читается сообщение, всё под -XX:ArchiveClassesAtExit | docker/jvm/jsa_launch |
| Релизный tar проверяется GPG по ключам Apache | docker/jvm/Dockerfile |
Есть отдельный вариант на GraalVM native-image поверх alpine:latest | docker/native/Dockerfile |
Confluent: база — RedHat UBI 9 (registry.access.redhat.com/ubi9 → cp-base-java-micro), не root, запуск снова шелл-скриптом /etc/confluent/docker/run | confluentinc/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_CHANNEL | MAPPED |
|---|---|---|
| Слой хранения, 64 Б без сброса (Linux) | 2 465 259 | 37 555 710 |
| Долговечная запись (Linux) | 816 | 1 298 |
| Дистанция, минута (Linux) | 1 302 035 | 1 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-спецификацию образа, это расширение Docker | README 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 придётся пересматривать.