Назад к блогу

Что внутри bbolt: как устроено самое простое KV-хранилище

Что внутри bbolt: как устроено самое простое KV-хранилище

> Серия «Что внутри?» — разбираем, как устроены популярные 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]
              └──────►──────►──────►──────►──────►──────►  (связь листьев)

Ключи сравниваются побайтово в лексикографическом порядке. Отсюда — очень важное практическое следствие: соседние по значению ключи лежат на соседних (или даже одной) страницах. Поэтому префиксная итерация вида «все ключи со стартовым префиксом» — это почти всегда последовательное чтение листьев, а не случайные чтения.

Поиск по шагам: спуск по дереву с бинарным поиском

Поиск ключа — классический алгоритм, и его полезно разложить по шагам, потому что на нём строится всё остальное.

  1. Начинаем с корневого pgid, который хранится в мете (для вложенных деревьев — в заголовке bucket).
  2. Читаем страницу по pgid. Если это ветка, элементы внутри неё отсортированы.
  3. Бинарным поиском ищем в ветке ключ-разделитель: находим последний элемент, чей ключ не больше искомого. Номер дочерней страницы этого элемента — следующий шаг.
  4. Повторяем, пока не дойдём до листа.
  5. В листе бинарным поиском ищем точный ключ. Нашли — возвращаем значение; не нашли — ключ отсутствует.

Схематично спуск к ключу 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. Движок читает страницу в память как узел и вставляет ключ: [1 2 3 4 4 5 6] — теперь 7 ключей, это больше предела.
  2. Узел рассыпается (spill): содержимое режется на две части так, чтобы каждая поместилась в отдельную страницу и была заполнена примерно поровну. Получаем два листа: [1 2 3 4] и [4 5 6] (разделитель — средний ключ).
  3. Для двух новых листьев выделяются новые страницы из freelist (об этом ниже), а старые страницы помечаются свободными.
  4. Родительская ветка должна теперь указывать на два листа вместо одного: в неё вставляется ключ-разделитель и pgid второго листа. Если от этого родитель тоже переполнился — операция повторяется рекурсивно вверх по дереву.
  5. Если переполнился сам корень — корень разбивается, и появляется новый корень уровнем выше (дерево «растёт вверх»).

Схематично:

ДО вставки «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:

  1. После удаления движок оценивает заполненность узла.
  2. Если узел стал слишком мелким, он пробует забрать ключи у соседа (чтобы оба стали плотнее) — это называется redistributio… в коде bbolt это часть rebalance-логики;
  3. либо слить узел с соседом и удалить разделитель из родителя;
  4. если опустел родитель — операция повторяется выше, вплоть до уменьшения высоты дерева.

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:

  1. Листовая страница, чьё значение не влезает, резервирует overflow дополнительных страниц.
  2. В заголовке страницы пишется число overflow-страниц — по нему движок знает, сколько страниц подряд занимает значение.
  3. Значение «растекается» по цепочке следующих подряд страниц.
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   │
└───────────────┘  └───────────────┘

Последовательность коммита выглядит так:

  1. В памяти уже собраны все изменённые (новые) страницы и новая мета-страница с новым txid.
  2. bbolt выгружает изменённые страницы в файл через pwrite (страницы данных пишутся до меты — это важно, иначе читатель увидит корень, указывающий на ещё не записанные страницы).
  3. Пишется новая мета-страница в свой слот.
  4. Вызывается 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 хранит его прямо внутри родительской страницы, не выделяя отдельную страницу и не делая лишний спуск в дерево. Для кучи крошечных вложенных структур это заметно снижает глубину и число чтений.

Практический паттерн, на котором выросла куча приложений:

  1. Верхний bucket — «тип данных» (users, posts).
  2. Ключ формируется так, чтобы порядок был полезным: ID как 8-байтовый big-endian (чтобы сортировка шла по числу), либо префикс вида 2026-09-01/....
  3. Итерация префикса через курсор превращается в эффективный последовательный обход.

Путь одного Update: end-to-end

Соберём всё вместе на примере db.Update(func(tx) { b := tx.Bucket("users"); b.Put(key, val) }):

  1. Получаем монопольную блокировку писателя.
  2. Копируем текущую мету — это наш снапшот (root, freelist, txid+1).
  3. По имени bucket users находим его корень в верхнем дереве.
  4. В дереве bucket'а спускаемся к листу, где должен жить ключ (бинарный поиск по веткам).
  5. Вставляем ключ в узел в памяти. Если узел переполнен — split.
  6. Пересоздаём изменённый путь вниз-вверх через spill, выделяя новые страницы из freelist (или рост файла). Старые страницы пути → pending.
  7. Обновляем root в нашей мете.
  8. Commit: pwrite изменённых страниц → pwrite новой меты → fsync.
  9. Снимаем блокировку. Старые страницы возвращаются в freelist, когда закроются старые читатели.

На всё это — от вызова до возврата — один короткий код-путь без сетевых вызовов, без фоновых потоков и без «демонов». В этом сила bbolt: вся сложность — в одном файле и в одном алгоритмическом каркасе.

Параметры и тюнинг для инженера

Пара вещей, которые стоит знать, если вы будете использовать bbolt в проде:

  • PageSize — по умолчанию берётся размер страницы ОС (обычно 4096). Менять стоит, только если вы понимаете, зачем: большая страница = меньше глубина дерева, но больше «вес» каждого чтения и больше потерь на мелких ключах.
  • FillPercent (по умолчанию 0.5) — насколько плотно упаковываются страницы при split. Меньше — меньше будущих split'ов, но больше «воздуха» в страницах. Больше — компактнее дерево, но чаще перестройки.
  • NoSync — отключить fsync на коммит. Резко ускоряет запись ценой потери гарантии «данные на диске при внезапном отключении питания» (для временных/некритичных данных допустимо).
  • FreelistTypearray или 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 выбирает лидера при сбое.

Источники

Похожее