> Серия «Что внутри?» — разбираем, как устроены популярные open source проекты: алгоритмы, структуры данных и решения, которые авторы принимали под капотом. Выпуск первый — bbolt.
Если вы когда-нибудь клали состояние в etcd (а вместе с ним — в Kubernetes), вы уже пользовались bbolt: это тот самый движок, на котором etcd хранит ключи и значения. bbolt — форк классического BoltDB, написанного Беном Джонсоном. Это очень маленький проект: на всю базу несколько тысяч строк Go без единой внешней зависимости. И при этом в нём спрятано сразу несколько фундаментальных идей: B+дерево на диске, mmap, copy-on-write, MVCC и транзакции.
Цель этого выпуска — не пересказ документации, а инженерный разбор: какие структуры данных выбраны, какие алгоритмы их обслуживают и какие компромиссы за ними стоят. Начнём с самого низа — с того, как файл превращается в «базу».
Одна идея: файл как страницы
bbolt хранит все данные в одном файле. Файл разрезан на страницы фиксированного размера (по умолчанию равен размеру страницы операционной системы, обычно 4096 байт). У каждой страницы есть номер — pgid — по сути индекс страницы в файле. Страница — это атомарная единица дискового ввода-вывода: движок читает страницы, создаёт страницы, освобождает страницы и никогда не работает с «частичными» данными мельче страницы.
Страницы бывают четырёх типов — строительные блоки всей базы:
| Тип | Назначение |
|---|---|
meta | служебные страницы с состоянием базы (их две) |
freelist | учёт освобождённых страниц |
branch | внутренние узлы B+дерева (указатели на детей) |
leaf | листья B+дерева (пары «ключ → значение») |
Схематично файл базы выглядит так:
┌─────────┬─────────┬───────────────┬───────────────────────────┬────────┐
│ meta 0 │ meta 1 │ freelist │ B+tree pages │ … │
│ pgid=0 │ pgid=1 │ pgid=2… │ branch и leaf │ рост │
└─────────┴─────────┴───────────────┴───────────────────────────┴────────┘
│ ▲
└── выбирается при открытии │ все ссылки — по pgidПервые две страницы файла всегда зарезервированы под мету, третья (и её overflow-продолжения) — под freelist. Всё остальное — страницы деревьев. Обратите внимание на главный принцип адресации: движок никогда не оперирует смещениями «от начала файла», только номерами страниц. Любая ссылка в структурах — это pgid, то есть смещение pgid × pageSize.
Чтение через mmap, запись через pwrite
Как bbolt читает страницы? Он не читает файл через буферы и системные вызовы на каждый ключ. Вместо этого при открытии база делает mmap — отображает файл в адресное пространство процесса. Вся база становится обычным байтовым массивом, а чтение страницы — это просто доступ к памяти по смещению:
Адресное пространство процесса Файл базы
┌──────────────────────────────┐ ┌───────────┐
│ [0] … mmap-регион │ ◄──────►│ meta │
│ страница k = data + k*4096│ │ freelist │
│ (прямое обращение к памяти)│ │ branch │
└──────────────────────────────┘ │ leaf │
└───────────┘
нет read()/lseek() на горячем пути чтенияПочему это быстро и безопасно:
- доступ к странице не делает системных вызовов — операционная система сама подгружает нужные страницы в page cache при первом обращении (page fault), дальше это обычная память;
- «грязные» страницы файла не нужно сбрасывать вручную — этим занимается ядро;
- читатель получает данные без копирования в промежуточный буфер.
А вот запись устроена принципиально иначе. bbolt не пишет прямо в mmap-регион. Причины две. Во-первых, запись в общий mmap без синхронизации могла бы показать другим нитям процесса частично записанные страницы. Во-вторых, для надёжности нужна управляемая точка, где мы можем сделать fsync. Поэтому изменённые страницы bbolt собирает в собственных буферах в памяти, а затем выгружает их в файл через pwrite — обычную запись по смещению pgid × pageSize. Ключевое слово здесь — «собирает»: движок не пишет страницу за страницей вперемешку с метаданными, а накапливает весь набор изменений и коммитит его одним актом (см. раздел про коммит).
Дальше появляется вопрос: если страницы перезаписываются «на месте», то как читатели, работающие в это время, не видят «кашу»? Ответ — copy-on-write, и он же решает проблему восстановления после падения. Но сначала посмотрим, как устроена сама страница.
Анатомия страницы: заголовок и элементы
Каждая страница начинается с заголовка фиксированной длины. В bbolt заголовок занимает 16 байт:
┌──────────┬─────────┬──────────┬──────────┐
│ id (pgid)│ flags │ count │ overflow │
│ 8 bytes │ uint16 │ uint16 │ uint32 │
└──────────┴─────────┴──────────┴──────────┘- id — номер страницы (для самопроверки при чтении);
- flags — тип:
branch,leaf,meta,freelist; - count — число элементов (для branch/leaf);
- overflow — сколько дополнительных страниц идёт сразу за этой (для больших значений).
Сразу после заголовка в leaf-странице лежит массив элементов, а за ним — область данных. Элемент leaf-страницы занимает 16 байт и содержит смещения, а не сами ключи/значения:
leaf page
┌───────┬────────┬───────┬───────┬─────────────┬──────────────────────────┐
│header │ elem[0]│ elem[1]│ … │ ключи и │ … в порядке следования │
│ 16 B │ elem[0]│ … │ │ значения │ │
└───────┴────────┴───────┴───────┴─────────────┴──────────────────────────┘
leaf element (16 байт):
┌─────────┬─────────┬─────────┬─────────┐
│ flags │ pos │ ksize │ vsize │
│ uint32 │ uint32 │ uint32 │ uint32 │
└─────────┴─────────┴─────────┴─────────┘
pos — смещение данных от начала страницы
ksize — длина ключа, vsize — длина значенияЭлемент branch-страницы устроен похоже, но вместо vsize хранит номер дочерней страницы:
branch element (16 байт):
┌─────────┬─────────┬──────────────┐
│ pos │ ksize │ pgid (uint64)│
│ uint32 │ uint32 │ → ребёнок │
└─────────┴─────────┴──────────────┘Именно эта структура делает файл «прозрачным»: зная раскладку, любой инструмент может прочитать базу без обращения к библиотеке. В репозитории bbolt есть утилита, печатающая дамп страниц, — формат полностью открыт, и это часть философии проекта.
B+дерево: значения только в листьях
Внутри база — это B+дерево. Разница между B-деревом и B+деревом принципиальна: значения хранятся только в листьях, а внутренние узлы содержат только ключи-разделители и указатели на детей.
Для дискового хранилища это выгодно по двум причинам.
Первая — плотность внутренних узлов. Раз в ветках нет значений, на одной странице помещается больше «направляющих» ключей, дерево получается ниже, и спуск к листу требует меньше чтений страниц. Для дерева высотой 3–4 достаточно, чтобы хранить миллионы ключей.
Вторая — упорядоченный обход. Листья связаны между собой в цепочку (каждый лист знает номер следующего), поэтому итерация по диапазону — это проход по связному списку листьев, а не повторные спуски в дерево.
┌────────────── branch (pgid=10) ──────────────┐
│ «go» «redis» │
▼ ▼ ▼
┌── branch (11) ──┐ ┌── branch (12) ──┐ leaf (13)
│ «c» «g» │ │ «k» «m» │
▼ ▼ ▼ ▼ ▼ ▼ ▼
leaf(14) leaf(15) leaf(16) leaf(17) leaf(18) leaf(19)
[a..c] [c..g] [g..k] [k..m] [m..r] [r..z]
└──────►──────►──────►──────►──────►──────► (связь листьев)Ключи сравниваются побайтово в лексикографическом порядке. Отсюда — очень важное практическое следствие: соседние по значению ключи лежат на соседних (или даже одной) страницах. Поэтому префиксная итерация вида «все ключи со стартовым префиксом» — это почти всегда последовательное чтение листьев, а не случайные чтения.
Поиск по шагам: спуск по дереву с бинарным поиском
Поиск ключа — классический алгоритм, и его полезно разложить по шагам, потому что на нём строится всё остальное.
- Начинаем с корневого pgid, который хранится в мете (для вложенных деревьев — в заголовке bucket).
- Читаем страницу по pgid. Если это ветка, элементы внутри неё отсортированы.
- Бинарным поиском ищем в ветке ключ-разделитель: находим последний элемент, чей ключ не больше искомого. Номер дочерней страницы этого элемента — следующий шаг.
- Повторяем, пока не дойдём до листа.
- В листе бинарным поиском ищем точный ключ. Нашли — возвращаем значение; не нашли — ключ отсутствует.
Схематично спуск к ключу redis по дереву выше:
root(10): binary search «redis» → идём направо (элемент «redis»)
└─ branch(12): binary search «redis» → средний ребёнок
└─ leaf(18): binary search «redis» → hit!Сложность: O(h × log₂(размер страницы)) чтений памяти — при глубине h≈3–4 это буквально три-четыре обращения к mmap-региону. Для сравнения: в классическом диске это означало бы три-четыре дисковых чтения; в bbolt они почти всегда попадают в page cache.
Курсор и итерация по диапазонам
Для обхода дерева у bbolt есть курсор. Курсор хранит текущий путь от корня до листа (стек узлов) и умеет:
Seek(key)— перейти к первому ключу>= key;First()/Last()— крайние точки;Next()/Prev()— двигаться по упорядоченному набору;SeekPrefixи сравнение префиксов в приложении — обходить диапазон.
Движение Next() реализовано без возврата к корню: если в текущем листе есть следующий элемент — берём его; если лист закончился — переходим по ссылке к следующему листу. Это даёт почти чистую последовательность чтений при обходе большого диапазона. Для prefix-сканирования «все ключи, начинающиеся с …» курсор Seek до первого ключа префикса и итерируется, пока ключ начинается с префикса — именно так рекомендуют строить «индексы по префиксу» поверх простого KV.
Вставка и split: разбор на числах
Теперь самое интересное — как дерево сохраняет инварианты при вставке. Рассмотрим вставку в заполненный лист. Лист вмещает, скажем, до 6 ключей (в реальности вместимость определяется FillPercent и размером страницы, но принцип тот же).
Было: лист полностью заполнен [1 2 3 4 5 6]. Вставляем 4.
- Движок читает страницу в память как узел и вставляет ключ:
[1 2 3 4 4 5 6]— теперь 7 ключей, это больше предела. - Узел рассыпается (
spill): содержимое режется на две части так, чтобы каждая поместилась в отдельную страницу и была заполнена примерно поровну. Получаем два листа:[1 2 3 4]и[4 5 6](разделитель — средний ключ). - Для двух новых листьев выделяются новые страницы из freelist (об этом ниже), а старые страницы помечаются свободными.
- Родительская ветка должна теперь указывать на два листа вместо одного: в неё вставляется ключ-разделитель и pgid второго листа. Если от этого родитель тоже переполнился — операция повторяется рекурсивно вверх по дереву.
- Если переполнился сам корень — корень разбивается, и появляется новый корень уровнем выше (дерево «растёт вверх»).
Схематично:
ДО вставки «4» (leaf заполнен)
[10]──────────────────────┐
branch ▼
leaf [1 2 3 5 6 7] ← сюда идёт «4»… и места нет
ПОСЛЕ spill
┌────────── branch ──────────┐
▼ ▼
leaf [1 2 3 4] leaf [4 5 6 7]
(новый pgid = N) (новый pgid = M, из freelist)
старый лист — свободенКлючевой момент: старый лист не «расширяется на месте» — вместо этого обе половинки записываются как новые страницы, а ссылка в родителе переводится на них. Это и есть тот самый copy-on-write.
COW-перестройка и почему читатели не ломаются
Посмотрим на последствия COW-подхода внимательнее. Когда изменяется путь в дереве, bbolt пересоздаёт все страницы этого пути от листа до корня. Предположим, вставляем ключ, и меняются лист L, ветка B и корень R:
ДО (все читатели смотрят на корень R)
R (pgid 100)
│
B (pgid 50)
│
L (pgid 30)
КОММИТ (новые страницы — из freelist)
R' (pgid 200) ← новый корень
│
B' (pgid 150) ← новая ветка
│
L' (pgid 140) ← новый лист
старые R,B,L → в pending, вернутся в freelist позжеЧто это даёт:
- Ни одна живая страница не меняется. Пока коммит не произошёл, файл вообще не тронут — все новые страницы живут в памяти.
- Читатель со «старым» корнем видит старую консистентную версию. Страницы 100/50/30 никто не трогает, они остаются валидными.
- Поэтому «мгновенный снапшот» ничего не стоит: достаточно запомнить pgid корня.
Отдельного внимания заслуживает судьба старых страниц. Их нельзя сразу отдать в переиспользование: вдруг какой-то читатель всё ещё обходит дерево по старому корню и дойдёт до страницы 30. Поэтому bbolt ведёт две очереди страниц: pending (освобождены, но ещё могут использоваться открытыми читающими транзакциями) и idle (готовы к выдаче). Страница попадает из pending в переиспользование только тогда, когда закрыты все читающие транзакции, начавшиеся до момента её освобождения. Это — тонкий, но решающий механизм корректного MVCC.
Удаление и rebalance
Удаление устроено зеркально вставке, но с одной важной особенностью. Когда из листа удаляется ключ, лист может стать слишком «пустым» — держать ради пары ключей целую страницу расточительно. Здесь работает rebalance:
- После удаления движок оценивает заполненность узла.
- Если узел стал слишком мелким, он пробует забрать ключи у соседа (чтобы оба стали плотнее) — это называется redistributio… в коде bbolt это часть rebalance-логики;
- либо слить узел с соседом и удалить разделитель из родителя;
- если опустел родитель — операция повторяется выше, вплоть до уменьшения высоты дерева.
Rebalance тоже выполняется через пересоздание страниц (COW): изменённые узлы распадаются и заново раскладываются по новым страницам при следующем spill. Итог — дерево всегда остаётся сбалансированным, без «дырявых» полупустых страниц в стабильном состоянии, а высота меняется логарифмически от числа ключей.
freelist: откуда берутся страницы
Раз страницы постоянно создаются и освобождаются, нужен учёт свободных мест. Этим занимается freelist. Он хранит номера свободных страниц и живёт на отдельной странице файла (в начале базы).
При коммите транзакции освобождённые страницы (старые версии COW-путей) передаются в freelist. При вставке движок запрашивает у freelist нужное число страниц:
freelist: [ 5 ] [ 8 9 10 ] [ 30 ] ← «прогоны» свободных диапазонов
запрос: дай 3 страницы подряд
→ отдаём run [8 9 10]
запрос: дай 1 страницу
→ отдаём [5]Алгоритм выделения работает с упорядоченными диапазонами (runs) свободных страниц. Это удобно по двум причинам:
- для больших значений нужны непрерывные куски страниц (см. overflow), а runs позволяют быстро найти подходящий;
- при открытии базы движок читает freelist, сортирует и склеивает соседние диапазоны — так со временем свободные страницы консолидируются в крупные куски.
Когда свободных страниц нет, движок «дотягивает» файл в конец: выдаёт страницы за пределами текущего конца файла и увеличивает его размер. Файл растёт только вперёд, но внутри активно переиспользует освободившееся место — типичная картина «файл большого размера, но занято внутри мало».
В современных версиях bbolt у freelist есть две реализации: на обычном массиве и на hash-карте диапазонов (FreelistType). Hashmap-вариант быстрее на больших базах с большим числом освобождений — полезная деталь, если вы профилируете записи.
Overflow-страницы: когда значение больше страницы
B+дерево рассчитано на маленькие ключи и значения, которые помещаются в одну страницу. А если значение — мегабайтный blob? Для этого существует механизм overflow:
- Листовая страница, чьё значение не влезает, резервирует
overflowдополнительных страниц. - В заголовке страницы пишется число overflow-страниц — по нему движок знает, сколько страниц подряд занимает значение.
- Значение «растекается» по цепочке следующих подряд страниц.
leaf (1 страница) + overflow: 3
┌────────┬──────────┬──────────────┬──────────────┬──────────────┐
│header │ element │ данные ключа │ значение │ продолжение│
│overflow=3 │ │ │ (часть 1) │ (части 2..4)│
└────────┴──────────┴──────────────┴──────────────┴──────────────┘Поскольку overflow-страницы должны идти подряд, freelist ищет непрерывный run нужной длины. Отсюда практическое правило: очень большие значения в bbolt — это нормально, но они занимают место непрерывным куском и медленнее копируются. Если вы храните в бакете огромные blob'ы, подумайте, не лучше ли хранить в bbolt только метаданные, а сами blob'ы — отдельными файлами.
Мета-страницы и восстановление после падения
Copy-on-write сам по себе ещё не даёт надёжности: если процесс упадёт в момент записи, файл может содержать смесь старых и новых страниц. Ответ bbolt — две мета-страницы в начале файла.
Каждая мета-страница — это снапшот состояния базы. В ней хранятся:
- магическое число и версия формата;
- размер страницы;
- pgid корневого дерева (точнее, корневого bucket);
- pgid начала freelist;
- номер последней завершённой транзакции (
txid); - контрольная сумма.
Мета-страницы пишутся по очереди: транзакция N пишет слот A, транзакция N+1 — слот B, N+2 — снова A и так далее.
Слот A (pgid 0) Слот B (pgid 1)
┌───────────────┐ ┌───────────────┐
│ txid=100 │ │ txid=101 │ ← самая свежая, валидная
│ root=… │ │ root=… │
│ checksum=ok │ │ checksum=ok │
└───────────────┘ └───────────────┘Последовательность коммита выглядит так:
- В памяти уже собраны все изменённые (новые) страницы и новая мета-страница с новым
txid. - bbolt выгружает изменённые страницы в файл через
pwrite(страницы данных пишутся до меты — это важно, иначе читатель увидит корень, указывающий на ещё не записанные страницы). - Пишется новая мета-страница в свой слот.
- Вызывается
fsync, чтобы всё гарантированно дошло до диска (если не отключено опциейNoSync).
Теперь разберём падение. Пусть процесс упал между шагами 2 и 3:
- Страницы данных, возможно, записаны, а новая мета — нет.
- При открытии база читает обе меты, проверяет контрольные суммы и выбирает валидную мету с наибольшим txid.
- Если слот с новым txid невалиден/недописан — база откатывается к предыдущей мете, которая указывает на старый, но полностью консистентный набор страниц.
- Страницы, выделенные под незавершённую транзакцию, при следующей сборке попадут в freelist (это безопасно, потому что на них никто не ссылается из выбранной меты).
То есть переживание падения — это не журнал операций (WAL), а атомарная замена указателя на версию. bbolt жертвует производительностью (страницы пишутся «вперёд», старое место не перезаписывается до сбора freelist), зато получает простоту и честные гарантии: «если Commit() вернул nil — данные на диске; если процесс упал — база либо до коммита, либо после, но никогда «посередине».
Именно поэтому fsync на каждую пишущую транзакцию по умолчанию включён: это цена гарантии на внезапное отключение питания, а не прихоть.
Транзакции и MVCC: много читателей, один писатель
Транзакции — то, ради чего bbolt и существует. Здесь он проявляет себя как настоящая embedded-база.
- Читающая транзакция получает указатель на актуальную мету — то есть на конкретный снапшот дерева. Пока транзакция открыта, она видит стабильную картину, даже если параллельно идут записи: страницы её снапшота не будут переиспользованы, пока транзакция не закроется (помним про pending-страницы).
- Пишущая транзакция ровно одна:
db.Updateдержит монопольную блокировку. Параллельных писателей в bbolt нет и не будет — это осознанный дизайн. Запись — это «взять снапшот, изменить, пересоздать затронутый путь, закоммитить».
Жизненный цикл транзакции:
Begin (read) ──► снапшот меты ──► читаем/итерируем ──► Commit/Rollback (ничего не писал)
Begin (write) ─► снапшот меты ─► изменения в памяти ─► commit:
│ (новые страницы, │ 1. pwrite изменённых страниц
│ старые → pending) │ 2. pwrite новой меты
▼ │ 3. fsync
читатели не Rollback ──────► просто выбросить изменения
затронуты (файл не тронут!)Следствия этого дизайна, которые инженер должен держать в голове:
- Долгая читающая транзакция мешает сбору freelist: пока она открыта, освобождённые страницы не возвращаются в оборот, и база может расти. Поэтому правило — держать read-транзакции короткими.
- Все изменения в памяти до коммита. Поэтому большие пишущие транзакции (десятки тысяч ключей в одном
Update) — плохая идея: они копят грязные страницы в памяти. Лучше бить на порции. - Rollback «бесплатный»: раз файл ещё не тронут, откат — это просто отказ от памяти.
Bucket'ы: вложенные деревья и inline
bbolt не имеет таблиц и SQL. Вместо них — bucket'ы: именованные пространства имён, каждое из которых технически является отдельным B+деревом. Верхний уровень — это дерево, в котором ключом является имя bucket'а, а значением — pgid корня дерева bucket'а:
root (meta.root)
│
├── bucket "meta:" ──► B+tree (ключ → значение)
│ │
│ └── subtree "users:" ──► B+tree (id → профиль)
│
└── bucket "sessions:" ──► B+tree (token → id)Buckets можно вкладывать произвольно — получается иерархия пространств имён, из которой растут все «модели данных» поверх bbolt. Для маленьких bucket'ов есть микрооптимизация inline bucket: если bucket настолько мал, что его содержимое помещается в страницу родителя, bbolt хранит его прямо внутри родительской страницы, не выделяя отдельную страницу и не делая лишний спуск в дерево. Для кучи крошечных вложенных структур это заметно снижает глубину и число чтений.
Практический паттерн, на котором выросла куча приложений:
- Верхний bucket — «тип данных» (
users,posts). - Ключ формируется так, чтобы порядок был полезным:
IDкак 8-байтовый big-endian (чтобы сортировка шла по числу), либо префикс вида2026-09-01/.... - Итерация префикса через курсор превращается в эффективный последовательный обход.
Путь одного Update: end-to-end
Соберём всё вместе на примере db.Update(func(tx) { b := tx.Bucket("users"); b.Put(key, val) }):
- Получаем монопольную блокировку писателя.
- Копируем текущую мету — это наш снапшот (root, freelist, txid+1).
- По имени bucket
usersнаходим его корень в верхнем дереве. - В дереве bucket'а спускаемся к листу, где должен жить ключ (бинарный поиск по веткам).
- Вставляем ключ в узел в памяти. Если узел переполнен — split.
- Пересоздаём изменённый путь вниз-вверх через spill, выделяя новые страницы из freelist (или рост файла). Старые страницы пути → pending.
- Обновляем root в нашей мете.
- Commit:
pwriteизменённых страниц →pwriteновой меты →fsync. - Снимаем блокировку. Старые страницы возвращаются в freelist, когда закроются старые читатели.
На всё это — от вызова до возврата — один короткий код-путь без сетевых вызовов, без фоновых потоков и без «демонов». В этом сила bbolt: вся сложность — в одном файле и в одном алгоритмическом каркасе.
Параметры и тюнинг для инженера
Пара вещей, которые стоит знать, если вы будете использовать bbolt в проде:
- PageSize — по умолчанию берётся размер страницы ОС (обычно 4096). Менять стоит, только если вы понимаете, зачем: большая страница = меньше глубина дерева, но больше «вес» каждого чтения и больше потерь на мелких ключах.
- FillPercent (по умолчанию 0.5) — насколько плотно упаковываются страницы при split. Меньше — меньше будущих split'ов, но больше «воздуха» в страницах. Больше — компактнее дерево, но чаще перестройки.
- NoSync — отключить
fsyncна коммит. Резко ускоряет запись ценой потери гарантии «данные на диске при внезапном отключении питания» (для временных/некритичных данных допустимо). - FreelistType —
arrayилиhashmap; на больших базах hashmap быстрее на операциях освобождения. - InitialMmapSize — размер mmap при открытии; если заранее известно, что база большая, можно избежать ранних переотображений.
- База целиком отображается в mmap — при очень больших базах (десятки гигабайт) учитывайте расход виртуальной памяти и стоимость переотображения при росте.
И несколько подводных камней:
- держите read-транзакции короткими, иначе freelist не собирается и файл растёт;
- не делайте гигантские write-транзакции — они копят грязные страницы в памяти;
- резервное копирование безопасно делать на согласованной read-транзакции/через специальные механизмы, а не простым копированием файла во время записи;
- bbolt — не распределённая система: один процесс-писатель на одном узле, без сети.
Когда выбирать bbolt
Резюмируем нишу, в которой bbolt почти без конкурентов:
- встроенное хранилище в одном файле — не нужен сервер и установка;
- сильные гарантии на одной машине: транзакции ACID, восстановление после падения, crash-safe без внешнего WAL;
- предсказуемые чтения и простая модель «ключ-значение»;
- минимум зависимостей и простой для чтения код.
Плата за простоту честная: один писатель, нет SQL и индексов, нет распределения, файл живёт в mmap. Если ваши данные — конфигурация, метаданные, состояние одного узла — bbolt часто оказывается правильным выбором. Именно поэтому его выбрали для etcd, а вместе с ним — для хранения состояния Kubernetes.
Что дальше
Мы разобрали, как bbolt решает главные задачи хранилища: представляет файл как страницы, поддерживает B+дерево с copy-on-write, переживает падения за счёт двух мета-страниц и даёт MVCC-чтения с одним писателем. Каждая из этих идей по отдельности встречается повсюду — в SQLite, в PostgreSQL, в журналирующих системах вроде Kafka. Теперь, встретив в документации «аллоцировал страницу» или «перезаписал мету», вы будете знать, что происходит под капотом.
В следующих выпусках серии «Что внутри?» посмотрим, как те же принципы доведены до предела в других системах — и почему, например, Кафка не теряет данные, а Redis выбирает лидера при сбое.