Выпуск 45 · подкаст «Тысяча фичей»

#45: TigerBeetle: база данных не похожа на остальные

1:49:47
↓ скачать mp3

Александр Пахомов и Алексей Кладов (matklad — автор IntelliJ Rust и rust-analyzer, теперь разработчик TigerBeetle) подробно разбирают, чем TigerBeetle не похож на остальные СУБД: написана на `Zig`, без `SQL`-интерфейса, со статической аллокацией памяти и жёстко зашитой доменной моделью из аккаунтов и трансферов. По ходу — почему `OLTP` стоит вернуть к процессингу именно финансовых транзакций (а `SQL` назвать `OLGP`), как реплицированная state machine на консенсусе `VSR` переживает ненадёжные сеть и диск, как устроены чек-суммы, суперблок, батчинг и детерминированный компакшн `LSM`-деревьев, и почему системное программирование на самом деле простое — надо просто брать и писать код.

Главное

  • TigerBeetle специализирован под `OLTP` в узком смысле — процессинг финансовых транзакций, где нужна строгая сериализуемость; остальное (аутентификация, скоринг, аватарки) выносится в обычную `OLGP`-базу вроде Postgres.
  • Проблема, которую решает TigerBeetle, — контеншен: задачи, которые фундаментально не параллелятся (перевод денег с одного популярного счёта банка), где `COST` из статьи Фрэнка Макшерри «Scalability! But at what COST?» фактически бесконечен.
  • Клиенты никогда не пишут в TigerBeetle напрямую — stateless `API Gateway` собирает запросы конечных пользователей в батчи (до 8000 трансферов) и шлёт их одним сообщением; вся бизнес-логика double-entry booking живёт в гейтвее.
  • Ядро TigerBeetle — реплицированная state machine (кластер из 6 машин на консенсусе `VSR`), параметризованная бизнес-логикой; в `Zig` это выражается функциями, которые принимают тип и возвращают тип, что даёт почти 100%-й code reuse в детерминированном симуляторе.
  • TigerBeetle предполагает почти византийский диск: он может вернуть мусор или записать данные не по тому адресу, поэтому чтение блока идёт по паре «адрес + чек-сумма», а битый блок прозрачно чинится копией с кворума других реплик.
  • На диске лежит функциональная (persistent) структура данных: ничего не перезаписывается in-place, а `superblock` в четырёх копиях с последовательной записью и `fsync` даёт атомарность чекпоинта поверх неатомарной записи.
  • Три уровня батчинга (8000 трансферов на консенсус, компакшн каждые 32 сообщения, чекпоинт каждые 1024) амортизируют работу; детерминированный компакшн достигается процедурой резервирования блоков, дающей byte-for-byte идентичный диск на всех репликах.
  • TigerBeetle не использует `malloc`/`free` — вся память аллоцируется на старте, что заставляет весь код работать с фиксированным бюджетом; главный совет гостя — не читать, а брать и писать код: системное программирование очень простое.

В выпуске

  • Алексей КладовРазработчик TigerBeetle (распределённая БД для учёта финансовых транзакций на Zig); ранее — автор IntelliJ Rust и rust-analyzer, IDE-поддержки языка Rust. GitHub ↗ matklad.github.io ↗
Расшифровка

[00:00] Алексей: Вот эта мысль. Не надо ничего читать, не надо ничего смотреть, не надо быть экспертом. Надо, блин, брать и фигачить — и понимать, что мы во first principles. Потому что на самом деле это правда: программирование — ну, системное программирование, прикладное не знаю, но системное программирование — очень простое. Самое сложное, что вам нужно сделать в плане математики, — это, блин, поделить что-нибудь с остатком.

[00:24] Александр: Здорово! Меня зовут Саша Пахомов, и я инженер, который любит своё дело. Вы слушаете подкаст, в котором разработчик современной базы данных изучает, как они работают, и делится знаниями со слушателями. Сегодня у меня в гостях разработчик базы, которая совсем не похожа на остальные. Называется она TigerBeetle. Чем же эта система отличается от других?

[00:47] Александр: Она отличается от традиционных баз данных, например, тем, что написана на языке программирования Zig. В ней нет привычного нам SQL-интерфейса. Она аллоцирует оперативную память на старте и не просит у операционной системы дополнительной памяти во время работы. А ещё там фиксированная доменная модель: мы, как пользователи этой базы, не можем создать новую таблицу с произвольным набором полей. Все таблицы уже созданы и глубоко интегрированы в структуры данных.

[02:47] Александр: В гостях у меня Матклад — разработчик TigerBeetle, Rust IDE и rust-analyzer. Алексей — очень талантливый инженер, который делится уникальным опытом разработки базы данных на Zig. Мне очень понравился процесс записи этого выпуска; надеюсь, результат вам тоже понравится. Поехали!

[08:46] Александр: Получается интересная архитектура. Когда мы спрашиваем, какую базу данных выбрать для процессинга транзакций… А ведь процессинг в бизнесе — это не только перевод денег: рядом идёт какой-то скоринг, ну и куча всего, что настраивается вокруг того, что мы друг другу деньги переводим. И когда мы берём эту задачу целиком, довольно сложно ответить на вопрос. Потому что если мы отдадим Postgres задачу переводить деньги, нам, скорее всего, понадобится уровень изоляции serializable. То есть мы будем открывать транзакцию, лочить запись одного, запись второго, делать перевод, коммитить. И для Postgres это довольно дорого. А если взять просто нагруженную финансовую систему — строго говоря, деньги друг другу переводят довольно часто. Если брать масштаб какой-нибудь страны или вообще союза, это реально тяжёлая операция. Если мы будем лочить аккаунт какого-то банка на каждом переводе в Postgres, система просто физически не сможет это переварить. Я думаю, такую проблему можно решить как раз тем, что аккаунты и трансферы мы передаём TigerBeetle, потому что он реально быстро это делает — сильно быстрее, чем Postgres. А всё остальное вокруг делаем с помощью другой базы данных. И получается, что, отвечая на вопрос, какую базу выбрать для финансовой системы, мы говорим: а вообще-то она не одна. Вот здесь, где надо быстро, возьмём эту базу, всё остальное — здесь, а аналитику — где-нибудь в ClickHouse. И тогда получается прикольно. Вопрос у меня такой: как вообще твоё видение — как инженера или CEO этой базы данных — как она должна встраиваться? Что такое встраивание? Это отдельный пользовательский код, плагины или что?

[10:45] Алексей: Хорошо, я сейчас отвечу на этот вопрос, но сначала хочу дополнить то, что ты говорил. Ты сказал абсолютно всё правильно, может, даже лучше, чем я бы мог, но я хочу чуть-чуть акцентировать два слова. Первое — это serializable, сериализация, и то, что называется локами, мьютексами. То, что мы отдаём TigerBeetle, — это не то, что супер-ценное, и не то, что должно супербыстро процесситься. Это не совсем основной критерий. Критерий того, что в архитектуре делает TigerBeetle, — это то, где нам нужна сериализация, где нужна строгая гарантия, что все события происходят одно за другим в том порядке, в котором произошли в реальной жизни. А там, где мы можем сказать «окей, давайте эти две штуки запустим параллельно на двух разных машинах, и неважно, что между собой они будут не совсем консистентны», — всё это живёт в другой базе данных. Мы, кстати, со стороны TigerBeetle пытаемся родить такой термин: есть OLTP, Online Transaction Processing, и мы хотим отделить от него OLGP, Online General Processing, — вот это ваш SQL. И сказать, что нет, всё-таки Transaction Processing — это то, что делает TigerBeetle: процессинг именно финансовых транзакций, где вам нужна строгая сериализуемость.

[12:07] Алексей: Второй акцент — на локе. Тут даже половина проблемы в том, что да, вам нужен уровень изоляции serializable, и, соответственно, чтобы переместить деньги между двумя аккаунтами, эти два аккаунта надо залочить. Это было бы не так страшно, если бы переводы между аккаунтами были рандомными: если вы лочите два рандомных аккаунта и ещё два рандомных, скорее всего, это разные аккаунты, и транзакции можно исполнить параллельно. Но особенность именно финансовых транзакций в том, что есть небольшое множество аккаунтов, которые используются очень и очень часто. Условно, большинство переводов — это перевод между каким-то физическим лицом и банком. Банков у вас намного меньше, чем физлиц. Поэтому через локи будут сериализоваться все транзакции, которые трогают аккаунт банка, а он намного популярнее аккаунтов отдельных физических лиц. Вот это второй акцент. Ну а дальше, наверное, будем потихонечку углубляться, почему эти два констрейнта появляются в TigerBeetle. Но давай я сначала отвечу на вопрос, как TigerBeetle встраивается в архитектуру вашего приложения при наличии других баз данных.

[13:15] Алексей: Что такое TigerBeetle? Это кластер из шести машин, которые общаются друг с другом по сети и в идеале находятся в трёх разных дата-центрах: две машины в одном месте, две в другом, две ещё где-то в третьем. И логически эти шесть машин находятся уже в trusted-зоне вашего приложения. То есть, когда вы обращаетесь к TigerBeetle, вы обращаетесь к нему с данными, которые точно корректны, — да, мы хотим эту транзакцию исполнить, потому что она была корректно аутентифицирована. Это, кстати, тоже пример задачи, которая решается не TigerBeetle. Аутентификация — что транзакция действительно послана тем пользователем, который стоит в credit-аккаунте, — эту задачу мы можем решить параллельно. Соответственно, TigerBeetle никогда не говорит напрямую с, я не знаю, мобильным телефоном пользователя, который за что-то платит. TigerBeetle всегда говорит с так называемым API Gateway. И задача этого гейтвея — собрать много запросов от конечных пользователей, сагрегировать их в батчи по многу штук сразу и отправить этот батч в TigerBeetle.

[14:33] Алексей: Соответственно, в архитектуре этот API Gateway — штука stateless, раз. Два — их на самом деле много. У вас есть один класс TigerBeetle, потому что он отвечает за сериализацию данных во всей системе. И у вас, допустим, 16 гейтвеев, которые ещё и географически распределены по стране или по миру. Гейтвей говорит с TigerBeetle. Он же говорит с той самой OLGP-базой, которая хранит аватарки, информацию про SMS-токены, аутентификацию и так далее. Много транзакций приходят в этот гейтвей, он stateless определяет, какие из них пропускаем, а какие нет, пакует пачку транзакций в так называемый batch в prepare и отправляет этот batch в TigerBeetle. Задача TigerBeetle теперь — обработать не отдельную транзакцию, а сразу пачку из 8000 трансферов от гейтвея, и сделать это очень-очень быстро. Примерно такая у вас картинка: есть TigerBeetle, есть какой-то OLGP-стор — Postgres, Mongo, что-нибудь супершардированное, неважно, — и набор stateless-гейтвеев. Запросы идут в гейтвей, гейтвей спрашивает у General Purpose Database, должен запрос идти дальше или нет, и когда идёт approval, запрос отправляется вместе с пачкой других в TigerBeetle.

[16:00] Александр: А этот API Gateway пишет уже непосредственно пользователь — то есть бизнесовые разработчики, а не разработчики TigerBeetle?

[16:08] Алексей: Да, совершенно верно. Мы пока не занимаемся написанием своих гейтвеев. У нас есть в голове мысль, что, может, напишем какой-нибудь простенький гейтвей, условно, в который ты HTTP-запрос отправляешь, а он всё-таки говорит с TigerBeetle, — просто чтобы быстрее показать людям, как это всё использовать вместе. Но пока мы этим не занимаемся, и в долгосрочной перспективе, когда вы действительно интегрируете TigerBeetle в приложение, написанием гейтвея занимаетесь вы, потому что там как раз живёт вся бизнес-логика, которая, собственно, и есть double-entry booking.

[16:41] Александр: Прикольно, я понял. Мне очень понравилась идея — именно с инженерной точки зрения, как нужно решать такие задачи. Потому что всегда, когда мы, инженеры, сталкиваемся со сложной задачей, у нас есть какие-то существующие инструменты, и ты понимаешь: вот этот инструмент хороший, но он не идеально подходит под то, что ты хочешь, и он очень много лишнего делает. И это «много лишнего» инженеру-перфекционисту мешает, а иногда реально становится перформанс-боттлнеком, и тут уже приходится что-то делать. Думаю, ребятам, которые сейчас планируют строить архитектуру подобных приложений, будет полезно посмотреть на такой продукт, как TigerBeetle: он опенсорсный, его можно самому развернуть, посмотреть и вообще расширить своё инженерное сознание — что можно и так. Довольно нестандартный подход к решению подобных проблем. Есть что тебе ещё дополнить?

[17:40] Алексей: Да, мне всё время нравится развивать твои мысли, потому что ты как-то говоришь интереснее, чем я. Важный элемент: понятно, у нас есть некоторая физическая архитектура, но физическая архитектура — это всегда продукт команды, которая её пишет. И зачастую полезно подумать, как организовать код так, чтобы большой командой написать его недорого и быстро. В этом плане у TigerBeetle тоже интересное свойство. Представим, что мы не используем TigerBeetle: у нас есть один Postgres, который трещит и транзакции, и чьи-нибудь аватарки. Тогда возникает организационная проблема. У нас есть два супер-синьора-инженера, которые занимаются кодом, обрабатывающим транзакции, и, допустим, три джуниора, которые занимаются аватарками. И если джуниор случайно напишет какую-нибудь миграцию аватарок, которая положит сервер на полдня, включая его transaction processing, — это будет как-то неудобно. Поэтому мы хотим развести по разные стороны то, где действительно у нас бизнес, без которого мы существовать не можем, и дополнительный фичастый код, который может падать чуть больше. Это не обоснование того, почему TigerBeetle написан именно так, но это одно из свойств, которое вы получаете, когда покупаете такую архитектуру, где действительно два разных data store.

[19:03] Александр: Типа микросервис. Значит, TigerBeetle — это как микросервис, который занимается transaction processing.

[19:07] Алексей: Но он «микро» не в том плане, что очень маленький, а наоборот — в том, что задача обрабатывать транзакции очень быстро на самом деле очень сложная. Там нужно написать кучу умного кода, который хочется держать в стороне от вашей основной бизнес-логики про аватарки с котиками.

[19:22] Александр: Да, хорошая подметка. Я, пока ты говорил про микросервисы, тоже об этом думал, но пытался подобрать какой-то синоним. Не микросервис, а, может быть, микродомен — что-то такое, что решается лучше, чем если использовать что-либо другое.

[19:39] Алексей: Хороший синоним — это на самом деле Conway’s Law. Архитектура любого приложения повторяет архитектуру организации, которая это приложение писала. Как у вас команды друг с другом разговаривают, так и модули внутри приложения будут между собой разговаривать. Это соображение важно, когда мы начинаем понимать, что если у вас есть n людей, которые друг с другом разговаривают, то стоимость всей коммуникации — это n в квадрате. Соответственно, вам нужно придумать какую-то топологию, которая, с одной стороны, позволяет распространять информацию по всей организации, а с другой — держать стоимость коммуникации примерно линейной. Из этих соображений можно разрезать базу данных на две части.

[20:18] Александр: Мне ещё очень понравилось определение, которое ты назвал — OLGP, Online General Purpose Processing. Это классно, потому что меня всё время, когда я изучал базы данных, да и сейчас, смущало: при чём здесь слово transactional? Хоть убей, я всегда думал — ну вот транзакции, это просто действия с базой данных, почему они называются транзакциями? А сейчас я понял: вы как раз этим определением возвращаете транзакции в транзакции, что называется. И действительно, когда мы говорим OLTP в новом понимании, как декларирует TigerBeetle, — это действительно транзакционный процессинг: процессится транзакция, а всё остальное — general processing. Мне очень понравилось.

[21:00] Алексей: Надо спрашивать Йорана, потому что он не только бухгалтер, но и сильно лучше знает академическую литературу вокруг всего этого. Насколько я понимаю, смысл, что транзакция — это финансовая транзакция, как раз оригинальный. Это и есть смысл термина OLTP, когда он создавался в 80-х, 70-х, когда возникали проблемы баз данных. А вот OLGP — это как раз новая вещь, когда у нас появились компьютеры, достаточно большие, чтобы гонять SQL.

[21:27] Александр: Ну да, вернём транзакции в OLTP.

[21:30] Алексей: Всё верно. Make transactions great again.

[21:38] Александр: Круто. Я думаю, мы хорошо поговорили о том, какую нишу занимает TigerBeetle в индустрии — в плане бизнеса. А теперь мне бы хотелось в плане инженерном. Какие технологии TigerBeetle выбрал, чтобы это решать, и почему именно такие? В частности, можно начать со стека и снять тот вопрос: как так получается, что вроде бы захардкожены эти аккаунты и трансферы, а вот как будто бы я могу взять, форкнуть и пересобрать TigerBeetle с чем-то другим? Как такое вообще возможно? Давай про это поговорим.

[22:18] Алексей: Смотри, на самом деле это простая техника. Как мы пишем код, который как-то параметризуется? Мы говорим, что у нас код generic: есть какой-то алгоритм, у него есть какой-то параметр. Если мы пишем функциональную программу, наш параметр — это, скорее всего, пачка замыканий, получается так называемый template method. Если мы пишем на чём-то вроде C++, может быть, наш параметр — это класс-наследник, который наследуется от базового класса и оверрайдит какие-нибудь методы. А если мы пишем на чём-то плюс-минус похожем на Rust, наш параметр — это просто параметр типа, какой-нибудь T, у которого есть bounds. Значит, T — это бизнес-логика, и у нас получается некоторая структура базы данных, которая этой бизнес-логикой просто параметризована. Когда мы уже собираем непосредственно бинарь, когда у нас есть fn main, там уже ничего параметризованного быть не может — мы должны породить какой-то конкретный код. Соответственно, в нашу базу данных мы подставляем конкретную бизнес-логику для процессинга финансовых транзакций, и результат — наш бинарь. А если хотим подставить другую бизнес-логику — пожалуйста, пишем другой класс, который реализует интерфейс бизнес-логики. Что там можно трактовать, кроме транзакций? Ну, счётчик, каунтер. И делаем другой main, в который этот каунтер подставляется в нашу базу данных.

[23:41] Алексей: TigerBeetle написан на Zig, соответственно, всё это выражается средствами, которые есть в Zig. А в Zig всё очень просто: там даже нет типа́ generic-типов. В Zig есть только функции, но функция может принять в качестве параметра некоторый тип и вернуть тип. Если вы посмотрите на код TigerBeetle, то центральная сущность — это так называемая реплика. Как выглядит реплика? У вас есть функция, которая называется replica type. И этот replica type одним из параметров принимает так называемую state machine, которая является типом. Соответственно, когда вы кормите replica type state machine, он возвращает вам какую-то конкретную реплику, которая реализует вашу бизнес-логику. В main мы вызываем эту функцию replica type от конкретной state machine — от аккаунта — и получаем TigerBeetle, специализированный для подсчёта финансовых трансферов. То есть, если мы захотим сделать counter, мы напишем отдельную state machine counter и сделаем отдельный main с ней.

[24:48] Алексей: На самом деле мы уже так делаем. Одна из прикольных штук про TigerBeetle — это наш симулятор, который запускает сразу шесть виртуальных реплик в рамках одного треда на вашем лаптопе и тестирует, что консенсус работает корректно. Именно для самого консенсуса то, что у вас внутри какой-то аккаунт, не сильно важно. Можно взять state machine поменьше — и тогда всё будет сильно быстрее работать. Здесь специальная testing state machine, которая вообще никак не трогает диск, существует только in-memory, вроде бы. А может, и трогает, если честно, уже не помню. Эта state machine используется для быстрых тестов консенсуса, которые могут сказать, что окей, у вас сломался именно сам консенсус, а не сочетание консенсуса и double-entry accounting.

[25:37] Александр: Наверное, я могу предположить, что примерно так же реализованы замечательные презентации — там, где всё анимировано и визуально показано, как это работает. Там тоже используется, я думаю, этот симулятор или что-то очень близкое.

[25:54] Алексей: Да-да, там как раз наш код работает. Это не какой-то супер galaxy-brain programming trick. Это просто хорошая практика software engineering: если вы пишете код так, что у вас аккуратно прописаны все зависимости, — что, если вам нужно прочитать что-то с диска, вы не берёте и не говорите сразу read с этого файлового дескриптора, а как-то параметризуете свой код чем-то, что может читать файл с диска, — то потом оказывается, что, если вы хотите засунуть этот код в браузер и сверху нарисовать мультик, это всё делается очень просто, с почти стопроцентным code reuse. Так что, если честно, я не смотрел, как эти мультики внутри работают, не могу точно сказать, но по коду, с которым я работаю, совершенно понятно, что он generic, суперпараметризованный, он не делает никакого input-output самостоятельно, так что его можно куда угодно засовывать.

[26:47] Александр: Интересно. То есть, если говорить про эту параметризованную систему в отрыве от параметра state machine, которая процессит аккаунты и трансферы, можем ли мы сказать, что в целом TigerBeetle — это такая реплицированная машина состояний, которая может переходить из одного состояния в другое и делать это в согласованности с консенсусом?

[27:11] Алексей: Да. Тут важно обязательно ещё сформулировать, поверх чего TigerBeetle сидит. Абстрактно можно писать что угодно, но в какой-то момент вам захочется делать вывод, и ваши абстракции должны взаимодействовать с окружающим миром. Когда мы пишем параметризованный код, всё это взаимодействие вы выносите в отдельный трейт, или базовый класс, или куда угодно — в зависимости от продвинутости вашего языка. Но ключевой момент в том, что вы должны прописать какой-то неформальный контракт на то, как внешний мир должен себя вести, чтобы ваша абстракция сверху работала как нужно. TigerBeetle сидит поверх двух вещей. Поверх сети, которая умеет пересылать сообщения между двумя репликами. «Умеет» — это сильно сказано: она может какие-то сообщения дропать, какие-то посылать, может делать что угодно, кроме взламывания хеш-функций. И вторая компонента, поверх которой сидит TigerBeetle, — это диск. И вот интересный момент: в отличие от стандартной статьи про какой-нибудь консенсус, наш диск на самом деле очень плохой. Он тоже может вернуть какие-то мусорные данные после того, как вы на него что-то записали. Он может через какое-то время получить corruption, и у вас просто чек-сумма не сойдётся. Или есть ещё одна ошибка, которую мы моделируем: вы говорите диску «окей, запиши эти байты по адресу 92», а диск берёт и записывает их по адресу 1092, потому что там у него wires crossed физически или в firmware, — и он действительно записал не туда. Мы, насколько помню, в этом году в какой-то из файловых систем в Linux это вживую увидели, где нужные данные записываются не в то место. И вот поверх этой ненадёжной сети и nearly byzantine жёсткого диска консенсус гарантирует strict serializability. Он гарантирует, что вот у вас есть 6 компьютеров, и издалека они будут выглядеть как один компьютер, который все входящие запросы обрабатывает строго по порядку.

[29:22] Александр: А вопрос тогда такой, мне интересно. Ты выразил мысль, на чём TigerBeetle сидит, то есть как он взаимодействует с внешним миром. Тут вроде всё довольно понятно, за исключением того, что ты упомянул: диску вы тоже не совсем доверяете — с ним может произойти что угодно, битики флипнутся, адреса перепутаются, а как это чекается, обсудим попозже. Давай тогда про диск и про сеть. Когда TigerBeetle-абстракция декларирует, как она хочет взаимодействовать с сетью и диском, — это что-то вроде параметризованного типа в Zig, что-то, что генерируется во время компиляции, подставляется? Как это работает в том языке, на котором написан TigerBeetle?

[30:10] Алексей: Да, совершенно верно, это просто параметризованные типы, то есть в случае Zig — функции, которые принимают параметры типа и возвращают тип. В TigerBeetle этим двум сущностям, сети и диску, соответствуют структуры message bus и storage. Всё в TigerBeetle параметризовано этими message bus и storage. В реальной жизни вместо message bus и storage мы подсовываем io_uring, который общается с Linux, чтобы данные туда-сюда летали. А в нашем симуляторе вместо них подсовываем просто массив из байт и руками написанную сетку, которая перекидывает пакеты между N очередей. И, в общем-то, всё. Важно, что io_uring — это на Linux, а мы поддерживаем кросс-платформенность: и Windows, и Mac. Для этих систем мы, понятное дело, не можем использовать io_uring, потому что они, к сожалению, не Linux, — у нас там свой платформенно-специфичный код, который занимается всем этим IO. И получается, что эту параметризацию через input-output мы заставили заплатить за две разные фичи: фича номер один — держать код кросс-платформенным, фича номер два — уметь запускать детерминистичный симулятор.

[31:26] Александр: Я понял. А если говорить про сеть — там просто сокет или что? Во что это материализуется, когда мы уже скомпилировались?

[31:36] Алексей: Смотри. Во-первых, скажу чуть подробнее про модель нашей сети. Модель очень простая. Это обмен сообщениями, где максимальный размер сообщения, кажется, один мегабайт. Мы не завязываемся ни на семантику TCP, ни на семантику UDP, ни на семантику какого-нибудь QUIC. Просто обмен сообщениями. Как вы этими сообщениями обмениваетесь — это дело десятое. Можно, не знаю, написать реализацию, которая эти сообщения в виде флешки привязывает к голубю и посылает. Нормально будет. Таймауты, конечно, все полетят. С точки зрения того, как это сейчас физически реализовано, у нас, по сути, такая stop-gap имплементация: мы знаем, что хотим сделать сильно лучше, сильно больше, но там ещё пилить и пилить, поэтому пока минимальная штука, которая работает. А минимальная штука — просто TCP-соединение. То есть реплики звонят друг другу по TCP. Есть две реплики; важно, чтобы одна позвонила другой один раз и у вас не было двух разных коннекций. Мы каким-то образом понимаем, какую из двух коннекций оставлять, и дальше просто посылаем месседжи по этой штуке. Если в какой-то момент TCP-коннекция упала — ничего страшного, мы создаём её заново и посылаем сообщение заново.

[32:44] Алексей: И тоже важный момент: мы не думаем, что «ой, катастрофа, месседж не дошёл, надо что-то делать». Потому что у нас изначально есть assumption, что сеть ненадёжна. Соответственно, весь код, который работает поверх сети, работает с assumption, что сообщение может просто не дойти. И там есть все необходимые таймауты, чтобы присылать всю необходимую информацию, чтобы в конце концов, если хоть что-то хоть когда-то дошло, все реплики сошлись в своём видении мира. Соответственно, код самой сети суперпростой. Почему это не дописано, почему можно сделать намного лучше? Важная часть TCP — это congestion control: понимать, сколько данных можно засовывать в трубу, чтобы они дошли до другого конца и мы трубу не переполнили. А когда у вас кластер из шести машин, вы, скорее всего — это наша гипотеза, пока не проверяли, — можете сказать про congestion что-то большее, чем может сказать TCP. Потому что вы знаете все эти шесть машин, и, условно, машина A спрашивает у вас какое-то сообщение, а вы такие: «слушай, А, у меня сейчас завал, очень много задач, но вот есть машина Б, она сейчас в более хорошем положении, чем я, поэтому пусть Б тебе это сообщение пошлёт». Иными словами, вы можете контролировать congestion не на уровне связи между двумя нодами, а на уровне всего кластера. Это абсолютный vaporware, про который у нас есть куча идей и абсолютно ноль кода, потому что мы дойдём до этого когда-нибудь, когда дойдём. Но абстракция внутри уже поддерживает эту параметризацию по протоколу. Когда мы до этого дойдём, нам не придётся много переписывать, потому что у нас никто не знает, что мы разговариваем по TCP. Мы разговариваем по message bus, а у message bus очень простой интерфейс: послать сообщение и получить сообщение, и всё.

[34:48] Александр: Окей, хорошо. Мы описали, через какие абстракции TigerBeetle взаимодействует с сетью и диском, и теперь можем вернуться к изначальной задаче — написать свой каунтер вместо аккаунтов и трансферов. Мы хотим написать свой распределённый на 6 машин каунтер. Что мы будем делать?

[35:04] Алексей: Важно понимать, что, собственно, находится in scope этого каунтера. Консенсус — out of scope: сама эта дженеричная инфраструктура решит задачу консенсуса, и с точки зрения каунтера вам нужно будет просто применить последовательность операций подряд. Но что общая инфраструктура не совсем решает — это задача записи данных на диск: она всё-таки будет в скоупе конкретной state machine. С точки зрения того, как написан код, произвольная state machine может решить, что она совершенно по-другому работает со стораджем, чем, например, наша аккаунтная state machine. Но на самом деле у нас есть некоторая библиотека для работы со стораджем, которая реализует LSM. И, скорее всего, ваш каунтер, когда захочет материализовать своё состояние на диск, проще всего возьмёт нашу библиотеку для LSM и запишет каунтер в этом виде. Конечно, использовать LSM, чтобы сохранить каунтер, туповато, но, если вы этого не делаете, вам придётся написать нетривиальное количество кода, потому что тогда на вашей совести будет ещё и задача восстановления из бэкапа — база данных всегда может в этот момент упасть, и нужен какой-то код, который работает после того, как вы упали, и надо всё сделать заново.

[36:29] Алексей: Механизм, который реализуется на уровне этого VSR-фреймворка в TigerBeetle, — это то, что у нас на диске persistent data structure: ничего никогда не модифицируется in place, все новые данные записываются рядышком, в новые места. И периодически, когда происходит чекпоинт, указатель на новое корневое состояние записывается в специальный сектор на диске, который называется superblock. После того как запись произошла, если после этого мы крешнемся, мы стартанём в новый чекпоинт. Если крешнулись до этого — стартуем в старый чекпоинт и должны перепроиграть все сообщения, которые нам пришли за это время. Задачей сохранения сообщения занимается фреймворк VSR, у которого для этого есть write-ahead log, который не является частью state machine. Но задача переиграть сообщения так, чтобы не затереть нужные данные на диске, — она в скоупе state machine. Про что я говорю? Представьте, у нас есть чекпоинт. Вы что-то записали на диск, потом что-то освободили из предыдущего чекпоинта, решили, что окей, вот эта страница на диске нам больше не нужна. А потом такие: «ой, страница не нужна, а нам нужно записать что-то новое, давайте переиспользуем вот эту, которую только что освободили». Вот так сделать нельзя. Потому что если вы эту вроде бы свободную страничку перепишете на диске, потом крешнетесь и восстановитесь на предыдущем чекпоинте, то в предыдущем чекпоинте эта страничка на самом деле ещё очень нужна. Просто теперь в ней данные записаны из будущего, что вас сломает.

[38:04] Алексей: Поэтому в state machine должна быть хитрая логика: мы чуть-чуть отводим подальше операцию переиспользования страницы на диске. Сначала говорим, что страница отмечена как свободная. И только когда мы перешли границу чекпоинта, эти свободные странички можем аллоцировать. Получается картинка, немного похожая на то, что в геймдеве известно как двойная буферизация. Это тоже код, про который вам нужно думать, если вы делаете совсем кастомную state machine. Но если вы используете фреймворк для написания state machine, который есть в TigerBeetle, то там для вас всё написано, и у вас очень простой API. Если вы используете этот LSM-фреймворк, что вам нужно сделать? Реализовать две функции — prefetch и commit. В prefetch вы получаете пачку транзакций и должны прочитать с диска всю информацию, которая может понадобиться для исполнения этой пачки. Пока мы это обсуждаем, важно держать в голове мысль: TigerBeetle не работает с одним трансфером. Атомарная пачка работы, которую делает TigerBeetle, — это сразу 8000 атомарных транзакций. Соответственно, эта часть state machine получает 8000 переводов — из аккаунта A в B, из C в D и так далее. Вы на эти 8000 смотрите и читаете с диска, в худшем случае, 16000 аккаунтов, которые для этих трансферов важны. Это prefetch — просто объясните, что вам нужно с диска.

[39:39] Алексей: Дальше операция commit. Вы должны взять и исполнить бизнес-логику, но теперь все эти аккаунты у вас просто в памяти, в какой-то хеш-табличке. Вы пишете простые операции с хеш-табличкой: вот у вас есть трансфер, достали один аккаунт, достали второй, проверили, что денег хватает, поменяли суммы, записали обратно в хеш-табличку. И, в общем-то, всё. А задачу сохранения этого дифа снова на диск в формате LSM, который уворачивается от всех ужасных эффектов, возникающих, когда вы ломаетесь и восстанавливаетесь на предыдущем чекпоинте, решает уже сам LSM-фреймворк.

[39:59] Александр: Но, наверное, если мы говорим в контексте собственного аккаунта, скорее всего, аккаунт будет чем-то вроде counter-сущности, а трансфер — инкрементом, каким-то ивентом. Если совсем кастомизируем.

[40:25] Алексей: Да-да. И, собственно, к вопросу про то, насколько гибкий double-entry accounting. На самом деле, если вам нужен реально просто каунтер, то заведите аккаунт и посылайте против него трансфер размером 1. Такое работает очень быстро, и вам не нужно писать никакого кода — так что каунтер есть из коробки. Тут, наверное, более хороший пример того, чего у нас пока нет, — это key-value. General key-value store в TigerBeetle пока выразить нельзя. У нас есть всякие планы на эту тему, но они пока такие, планы-планы, так что для этого придётся писать свой код.

[40:56] Александр: Очень интересно. То есть изначально, когда я заходил в тему с TigerBeetle и изучал, у меня в голове было, что это просто узкоспециализированный код для процессинга финансовых транзакций. Это так и есть, но он ещё и расширяемый. Просто расширение происходит, грубо говоря, не в рантайме, а на уровне языка программирования Zig и его сущностей. И поэтому, если очень сильно захотеть, можно жёстко вбитую гвоздями структуру аккаунтов и трансферов под себя переделать, вплоть до того, чтобы вообще переназвать это всё. Единственное, что нельзя будет поменять — ну, одно из, — что у нас будет одна сущность, которая будет чем-то вроде аккаунта, но другим, и одна сущность трансфера, назовём её каким-нибудь ивентом. То есть больше сущностей в систему, наверное, ввести будет сложно?

[41:47] Алексей: Не-не-не, это абсолютно просто делается, можно вести хоть миллион сущностей, потому что эта кардинальность ничего не меняет. Storage у нас думает в терминах LSM-деревьев. Бизнес-логика написана в терминах аккаунтов и трансферов. Но один трансфер — это на самом деле несколько LSM-деревьев, потому что нам нужно одно дерево, чтобы просто сохранить трансферы, — там они будут отсортированы по ID самого трансфера. Но нам же будет интересно ещё и опираться на вопрос «а какие трансферы есть у этого аккаунта?», и для этого понадобится второе дерево, которое будет хранить пары «ID трансфера, ID аккаунта», отсортированные не по ID трансфера, а по ID аккаунта. Нам нужен индекс. Поэтому внутри у нас в любом случае уже написан код, который берёт какую-то high-level сущность и превращает её в пачку LSM-trees. И сам storage layer вообще не думает про то, что вот эти два дерева про трансферы, а вот эти — одно про аккаунт, другое про трансфер. Нет, там просто два дерева. Если вам хочется 10 разных типов сущностей — пожалуйста, у вас будет, соответственно, 100 разных LSM-деревьев, но всё будет работать плюс-минус точно так же.

[42:56] Александр: Тут, наверное, интересный вопрос не whether you can, but whether you should. Понятно, что сделать можно всё что угодно, а вопрос в том, когда не нужно этого делать. Когда можно просто взять Postgres?

[43:18] Алексей: На самом деле ответ такой: Postgres можно взять почти всегда. Важно понимать, что вам эта кастомная state machine даёт. И вот тут мы заходим в философию. Проблема, которую решает TigerBeetle, — это контеншен. То, вокруг чего построена наша архитектура, — это решение проблем, которые плохо параллелятся. Где, если вы вдруг начнёте вводить какие-то локи, чтобы растащить это на 4 разных процессора, окажется, что в каждый момент времени какой-то глобальный лок держится одним процессором так, что он не растаскивается. Задачам такого рода была посвящена замечательная статья Фрэнка Макшерри, которая называется «Scalability! But at what COST?», где COST — это акроним Configuration that Outperforms a Single Thread. Как много машин вам нужно, чтобы ваша суперпараллельная и скейлабельная система работала быстрее, чем один тред на макбуке? И там есть некоторый класс проблем. Какой может быть COST? Например, COST 2, когда два компьютера быстрее, чем один. Может быть COST 1000, когда вам нужна тысяча компьютеров, чтобы быть быстрее одного треда, но всё равно, если вы возьмёте 10 тысяч, это может быть в два раза быстрее. А есть такие проблемы, для которых этот COST на самом деле бесконечный: когда задача фундаментально не параллелится, и сколько бы машин вы на неё ни кидали, всё равно быстрее одного потока не справитесь. И, скорее всего, будете работать намного медленнее, потому что всё это дело ещё нужно координировать через весь ваш флот.

[44:54] Алексей: Для проблем, где COST бесконечен, работает архитектура TigerBeetle. И если вы понимаете, что вам нужно что-то сделать очень-очень быстро, но, чёрт возьми, задача никак не параллелится, — тогда да, берёте TigerBeetle, пишете свою state machine и вперёд. Если ваша задача параллелится, то, скорее всего, вам удобнее взять какую-нибудь шардированную базу данных, какой-нибудь BigTable, Dynamo, что-нибудь такое, и написать своё решение сверху — оно будет работать быстрее.

[45:25] Александр: Получается, если мы можем распараллелить и косты параллелизации в целом конечные и разумные, то давайте параллелить и не думать о том, как переписать TigerBeetle. Но если by design, фундаментально, задача такая, что не параллелится — например, у одного банка трансферы должны идти последовательно, потому что с каждым трансфером меняется, сколько у него денег, — и природой так сделано, что задача не параллелится, то сколько бы машин мы параллельно ни ставили, всё равно есть точка синхронизации, и она будет тормозить всю систему. Поэтому TigerBeetle, как я понимаю, так сильно акцентирует внимание на однопоточном процессинге, на максимально эффективном процессинге на одной машине — потому что задачи, которые он решает, как раз и должны решаться на одной машине, и лучше ты это не сделаешь. Но всё-таки с одной машиной есть проблема: эта машина может умереть. Что делать тогда?

[46:31] Алексей: Там на самом деле есть две интересные проблемы. Первое: то, что у вас есть точка синхронизации, не означает, что вся задача синхронная — возможно, какой-то параллелизм исполнить можно. Мы про это уже говорили: напоминаю, что интерфейс state machine — это prefetch и execute. В фазе prefetch вы можете все аккаунты для 8000 трансферов загружать с диска параллельно, concurrent. И только в фазе execute вам надо будет процессить их последовательно. Искусство в том, чтобы ужать вот эту сериализуемую часть до максимально узкого зёрнышка. Ну и второе — беда, что делать, если в вашу машину попал метеорит? Или, более определённо, что делать, если у вас сломался жёсткий диск и он вам возвращает какие-то странные нолики вместо тех данных, которые вы записали? Отсюда возникает идея консенсуса — что мы эту базу данных делаем распределённой. У нас есть шесть разных машин, и если вдруг одна из них сломалась, ничего страшного — осталось пять, которые могут работу продолжить. Чтобы они могли её продолжать, безусловно, надо, чтобы у них было одинаковое состояние. Это достигается, во-первых, за счёт протокола консенсуса, который даёт последовательность, в которой операции должны быть применены и которая будет одинаковой на всех репликах. А во-вторых, за счёт детерминизма: когда машина A что-то сделала, и машина Б сделала ту же самую операцию, исходя из тех же начальных данных, результат будет абсолютно идентичный.

[48:10] Алексей: И здесь мы идём чуть дальше, чем обычно идут такие системы с консенсусом из мира баз данных. Потому что у нас есть дополнительное мерзкое предположение: мы очень не любим диск, а диск в ответ не любит нас и может вернуть какую-нибудь фигню. Тут возникает естественное решение проблемы: если мы на машине прочитали что-то с диска, и у нас не сошлась чек-сумма, то вместо того, чтобы упасть, мы можем у пяти других машин попросить эти данные, чтобы чек-сумма сошлась. И второй большой компонент необычности TigerBeetle — да, у нас написан код, который прозрачно чинит локальный жёсткий диск, используя информацию с других машин.

[48:42] Алексей: Очень интересно посмотреть на код, который обрабатывает ошибки. У нас на чуть более высоком уровне, в гриде, есть API «прочитать блок с диска». И это IO, оно может зафейлиться. Поэтому вы бы ожидали, что плюс-минус любая база данных принимает какой-нибудь callback, потому что она асинхронная, и в этот callback вы засовываете либо данные, которые с диска прочитались и чек-сумма сошлась, либо какую-то ошибку, про которую нужно оповестить пользователя. Может быть, если совсем всё плохо и диск вернул какую-то хардварную ошибку, то давайте просто рестартнемся. Может быть, у вас в API этого error-path нет просто потому, что вы паникуете на самом низком уровне, когда файловая система возвращает ошибку. Но в TigerBeetle на самом деле не так. У нас API, в котором ошибки нет. И есть прямо гарантия, что да, eventually мы ваш callback позовём с теми данными, которые вы ищете, — с данными, чек-сумма которых совпадает с тем, что мы хотим. Забегая чуть вперёд, важно сказать: API в гриде «прочитать блок» принимает два параметра — адрес блока и чек-сумму. Чек-сумму вы знаете заранее. И это API просто гарантирует вам: окей, да, мы вам найдём блок с этой чек-суммой. Соответственно, весь код выше по стеку эти ошибки никак не обрабатывает — это просто код, написанный в предположении, что у нас совершенно идеальный сторидж, который всегда работает хорошо. Это очень сильно всё упрощает, потому что обработка ошибок — вообще говоря, сложная тема.

[49:28] Алексей: А внутри как эта штука работает? Мы читаем этот блок с нашего локального диска. Если чек-сумма сошлась — всё хорошо, отдаём это нашему коллеру. А если не сошлась — прозрачно просим кого-нибудь ещё в кластере вернуть нам этот блок с этой чек-суммой. И когда они нам его посылают, мы опять же сверяем чек-сумму. Если сошлась — окей, это наши данные, возвращаем. И коллер вообще не думает, откуда мы достали эти данные. Может, прочитали с диска, может, вытащили из сети, я не знаю. Может, там даже какая-нибудь супер-нейросеть есть, которая обратно взламывает чек-суммы. Что важно и очень прикольно: чтобы такая прозрачная починка локального диска, используя диски ваших соседей по кластеру, работала, важно, чтобы ваши диски были одинаковы. Если, условно, представить, что каждая реплика запускает свой LSM, и у каждой есть отдельный тред, который периодически компактит эти LSM-деревья, у нас будет ситуация, в которой логически данные на разных репликах одинаковы — то есть как набор key-values они равны. Но при этом физически, в качестве блоков на диске, они на самом деле будут разные, потому что, не знаю, в одном 10 таблиц на трёх уровнях, а на второй реплике — только 5 таблиц на одном уровне, потому что там всё закомпактилось максимально. И если у вас есть эта физическая разница, то приём, когда мы у другой реплики просим наш блок, не работает — физически этого блока нет, те самые логические данные представлены совершенно по-другому.

[52:10] Алексей: Вы можете и в этой ситуации, где нет физической предсказуемости диска, всё равно реализовать починку, но тогда вам придётся перетаскивать, скорее всего, целиком LSM-дерево, которое соответствует этим данным. Потому что если у вас, не знаю, подпортился блок в корне LSM, который описывает все остальные блоки этого дерева, — всё, game over, вам нужно это дерево выкидывать и тащить к себе новое с другой реплики. А в TigerBeetle всё аккуратно написано очень детерминистично: у нас детерминизм не только по тому, какие данные логически лежат на диске, но и по тому, какие физически лежат данные на диске. И мы знаем, что окей, если нам нужен блок с такой чек-суммой, то есть кворум других реплик, у которых есть ровно этот же самый блок, и можно надеяться, что оно рано или поздно до нас дойдёт. Конечно, может быть ситуация, когда у вас совсем всё плохо с диском и данные подпортились абсолютно на всех репликах. Запустили транзакцию, консенсус отработал, честно проверил, что она записана на трёх разных дисках, а дальше вам очень не повезло: три микромолнии, которые на каждом из этих трёх дисков эту транзакцию поломали. В этом случае наш код, который будет читать этот диск, просто зависнет. И кластер будет корректно отказываться принимать все остальные запросы — скажет, что окей, у меня потерялись данные, я не могу дальше прогрессировать, я вот здесь просто сижу.

[53:52] Александр: Про чек-суммы, наверное, такой вопрос. Ты говоришь, что когда мы читаем с диска некоторый блок, помимо его адреса мы передаём ещё и чек-сумму. Вопрос: откуда мы её берём?

[54:05] Алексей: Да, это хороший вопрос. Я поэтому отвечу на другой вопрос. Если вы думаете, что окей, диск возвращает какую-то фигню, и надо, наверное, что-то делать с чек-суммами, то естественное решение такое: давайте мы на диск запишем блок данных, а потом запишем чек-сумму этого блока. Когда вы блок с диска читаете, вы прочитали блок, потом прочитали чек-сумму, сверили — и если она не сошлась, говорите «окей, ошибка». Важно понимать, что это на самом деле не всегда хорошо работает. Ровно из-за той ошибки, про которую я говорил: иногда диск может подложить вам очень большую свинью — записать те данные, которые вы сказали записать, но в другое место. Вы читаете блок, чек-сумма там правильная, но это всё равно не те данные, которые вы ищете. Важно, чтобы чек-сумма была где-то out of band — чтобы когда вы что-то с диска читаете, вы уже знаете, чек-сумма чего там есть. Ну окей, вы же ещё адрес блока на диске туда обвязываете — вот откуда вы взяли адрес, оттуда и берёте чек-сумму.

[55:10] Алексей: Полезно это чуть-чуть раскрутить. Ещё раз. На диске мы, по сути, храним персистентную структуру данных. Персистентную в обоих смыслах: и в том, что она действительно лежит на non-volatile storage, и в том, что она функциональная — вместо того чтобы модифицировать её in place, мы создаём просто её новую копию, но эта копия структурно использует какие-то элементы старой, поэтому переписывать всё не нужно. Как такие структуры работают? За счёт указателей. Когда у нас, не знаю, персистентное дерево — в ноде хранятся указатели на левого и правого ребёнка. А в случае, когда это структура на диске, в качестве указателя мы используем просто пару из чек-суммы и адреса.

[55:59] Алексей: Давайте уже дойдём до дна. Есть у нас LSM-дерево. Оно в итоге хранит таблички. Что такое табличка? Логически табличка — это отсортированный массив. Физически в TigerBeetle табличка — это так называемый index-блок и так называемый data-блок. Data-блок — очень просто: это буквально отсортированный массив значений, и всё. Как он в памяти лежит, так мы его на диск и положили. Что такое index-блок? Это индекс на нескольких data-блоках. Для каждого data-блока в index-блоке мы записываем минимальный ключ в этом data-блоке, его адрес и его чек-сумму. Соответственно, когда мы хотим что-то найти в табличке, идём в index-блок, делаем бинарный поиск по этим триплам «минимальное значение, адрес, чек-сумма», находим адрес и чек-сумму — и вот она, наша чек-сумма. Дальше берём эту чек-сумму и читаем data-блок. Возникает вопрос: окей, а как мы находим index-блок? Ну хорошо, index-блок тоже характеризуется адресом и чек-суммой, и эта чек-сумма находится в другом блоке, который является частью так называемого manifest-лога, где manifest-лог — это, по сути, односвязный список всех таблиц. Где-то в середине этого списка лежит наша табличка. Как мы находим этот блок? Так как это связанный список, в предыдущем блоке у нас есть указатель и чек-сумма на текущий блок. В итоге всё это развёртывается до головного блока нашего manifest-лога.

[57:30] Алексей: Возникает естественный вопрос: окей, а головной-то блок как мы найдём? И если так продолжать дальше, понятно, что где-то это всё должно закончиться. Представьте: вот вы реплика, вы крешнулись, рестартуете. У вас есть какой-то файл на диске с вашими данными, и вам нужно его прочитать. Как мы знаем, когда мы что-то читаем с диска, нам важно знать чек-сумму этой штуки. Но, блин, вы только что крешнулись, у вас вообще нет никакого состояния! Может быть, этот диск только что отформатирован. А может быть, этот кластер был в продакшене миллионы лет на каком-нибудь «Вояджере-128». В этом единственном месте мы ломаем наш закон, что чек-суммы всегда хранятся отдельно. У нас есть так называемый superblock, который хранит в себе root pointer на все эти замечательные функциональные структуры данных. И в superblock чек-сумма хранится прямо внутри самого superblock. Но! Мы superblock пишем в четырёх экземплярах. Поэтому, когда реплика стартует, она читает сразу четыре копии superblock. Из этих четырёх копий, скорее всего, мы сможем корректно идентифицировать тот superblock, который на самом деле нужен. Ну или в крайнем случае сказать, что, блин, что-то тут совсем нигде никак не сходится, data-файл полностью закорраптился, и я вообще отказываюсь запускаться, вместо этого выключусь — с сообщением об ошибке и кодом возврата минус один.

[58:56] Александр: Круто, ты прям всё понятно объяснил, я даже понял. Хотел какой-то уточняющий вопрос задать по ходу, но ты рассказал так, что в итоге всё сошлось в голове. Наверное, такой вопрос. Получается, что у нас указатели и чек-суммы хранятся рядом в этой index-структуре, и это означает, что… А, отсортированы они по указателям — то есть индексом в этом индексе что является?

[59:22] Алексей: Нет-нет-нет. Они отсортированы по самим values, которые хранятся в нашем LSM. На самом деле физически на диске это ещё хранится как struct of arrays. У вас на диске как бы три параллельных массива: массив значений, массив адресов и массив чек-сумм. То есть вы в массиве значений делаете binary search, а потом просто по этому индексу достаёте элементы из массива адресов и из массива чек-сумм.

[59:52] Алексей: Мне хочется ещё один вопрос задать, а ты его вроде не задаёшь. Про superblock с четырьмя копиями. Он на самом деле решает ещё одну проблему. Вот я, когда ещё не работал с базами данных, когда просто пытался понять, как оно устроено, мне всё время казалось, что мне что-то недорассказывают. Я не понимал, а где происходит атомарность. Ну вот в какой-то момент мы говорим, что окей, реплика записывает сообщение на диск: если записалось — говорим «да», если нет — «нет». Ну она же его как-то не сразу записывает, а атомарно, даже по байтику записывает. И вот здесь у нас в середине свет выключился. Как мы понимаем, полностью записалось или нет? На самом деле superblock за счёт четырёх копий решает и проблему атомарности. Когда реплика доходит до чекпоинта и хочет записать новый superblock, она сериализует четыре записи в четырёх копиях. То есть сначала переписываем первую копию, потом вторую, потом третью, потом четвёртую. А при старте мы берём максимальный чекпоинт, у которого есть хотя бы две копии. То есть, если вы записали первую копию, а потом крешнулись при записи второй, — окей, во второй копии у вас какой-то мусор, у которого не сходится чек-сумма, потому что у вас torn write. Но у вас осталось две копии с предыдущей чек-суммой, на которые вы можете стартовать. Вот так можно за счёт копий сварить атомарность из чего-то неатомарного.

[1:01:11] Александр: Супер, что ты про это рассказал. Как раз вопрос, который я забыл спросить, был про то, а почему четыре, откуда это число берётся? А когда ты сейчас раскрутил, стало понятно. Если у нас четыре сущности записаны на диск, и разобрать поподробнее, это довольно ключевой момент: мы начинаем записывать, и эти четыре блока логически представляют одну и ту же сущность. И вот как сказать, что мы логически сущность записали атомарно? Потому что действительно, посередине записи, пока байтики из оперативной памяти идут на диск, пока бегут по проводам, может просто электричество… ну, пропасть. Допустим, рассмотрим ситуацию: мы записали один блок, три остальных старые. Упали где-то после записи, но до записи следующего. Мы поднимаемся, смотрим — у нас есть три одинаковых блока, причём в один из них закладывается ещё и вероятность, что диск может наврать и что-то сломается, тогда у нас будет два нормальных блока. И мы всё равно читаем два старых, один старый сломался, а четвёртый, получается, мы записали, но не нашли его копии, поэтому берём два старых и говорим, что вот наш чекпоинт.

[1:02:25] Александр: Кстати, а если мы начинаем записывать второй блок, ломаемся, восстанавливаемся: у нас первый блок — новый, второй блок покорраптился, третий и четвёртый остаются два нормальных, но здесь не закладывается вероятность того, что с ними что-то может пойти не так?

[1:02:42] Алексей: Хороший вопрос. Ну, на самом деле, я, наверное, на 80% уверен, что число 4 назвал правильно, и только на 50% уверен, что правильно описал критерии, по которым восстанавливаться — что мы смотрим на кворум из двух блоков. На самом деле у нас на эту тему есть неразрешённый вопрос, кто-то тоже где-то в бэклоге написал: а почему 4? Может быть, мы смотрим только на один, может, нам хватает одного корректного блока. Кстати, сейчас начинаю думать, что, может, у нас всё-таки 6 — нет, вроде 4, но, может, мы в какой-то момент до 6 отправим. Тут тоже важно понимать: когда у вас что-то с диском начинает работать не то, в какой-то момент вы должны сказать «окей, это слишком много фолтов, здесь что-то потеряется, я не готов». Например, что может убить TigerBeetle? Если вы делаете две записи — запись, потом fsync, потом запись, потом fsync, — и тем не менее каким-то магическим образом оба этих райта просто потерялись, их не произошло. Вот в этой ситуации TigerBeetle может сказать «окей, ваши данные закоммичены», а потом «ой, знаете, я передумал, ваши данные не закоммичены», что, конечно, будет плохо.

[1:03:46] Алексей: Какие у вас есть варианты ошибок при работе с диском? Один вариант — вы действительно просто выключили электричество, споткнулись о провод, и у вас какие-то in-progress записи не дошли. Это даже не ошибка, просто факт жизни, оно так есть. Сверху этого у вас есть ещё вероятность того, что какой-то конкретный блок будет закорраптлен просто потому, что там bit rot будет. И мы должны выживать, этот дополнительный corruption мы должны переживать. Ну и, может быть, если вам ещё и так не повезло, что это произошло с superblock — и вот именно тогда, когда вы делаете чекпоинт, — то, может быть, тогда вы сломаетесь, и эта реплика скажет «окей, я не работаю, всё, я сломалась». Тогда придётся пяти остальным репликам из шести брать и продолжать работать, потому что даже если одна реплика полностью сломается — ничего страшного, у нас есть ещё пять штучек. Ну а дальше уже оператор, может быть, как-то переконфигурирует кластер, добавит новую реплику. Правда, для этого надо реконфигурацию писать, которую я вот хотел в этом году написать, и что-то всё у меня никак не сойдётся.

[1:04:52] Александр: Окей, хорошо. Как ты сказал, давай до дна опустимся немножко и от него начнём отталкиваться или посмотрим по сторонам, потому что мы сейчас говорим про чек-суммы, про LSM-дерево. Ребят, кто немножко поплыл, — я ссылочку на выпуск подкаста про LSM-дерево обязательно оставлю. А для тех, кто в теме и немножко не понимает: эти чекпоинты откуда берутся?

[1:05:20] Алексей: Они берутся с того, что у нас есть некоторый процесс компакшена у этой структуры данных LSM, и этот процесс компакшена необычный. То есть не такой, как мы можем прочитать в большинстве литературы про LSM, потому что он немножечко распределённый и знает про консенсус — как ты сказал, эти блоки должны быть одинаковые везде, иначе не сойдутся инварианты.

[1:05:46] Алексей: Ну, он не то чтобы знает про консенсус, он скорее просто не может делать вещи, которые ломают детерминизм. Давай ещё раз соберём общую картинку. TigerBeetle решает задачи, где есть точка синхронизации. Но то, что у нас есть точка синхронизации, не означает, что вся задача синхронна. Поэтому первый архитектурный трюк TigerBeetle — что у нас один поток. Ха-ха-ха, 2024 год, мы пишем программу, которая использует одно из ваших 24 ядер. Второй архитектурный трюк — это так называемый batching. Это идея сделать 10 одинаковых вещей за раз быстрее, чем одну вещь, потом ещё одну, потом ещё одну. У Гётца Грефе, который придумал и Volcano, и Monet, есть замечательная презентация на эту тему про vectorized interpreters — как эта идея проявляется в Python и NumPy, где вы на Python написали какой-то маленький скриптик, который на самом деле заставляет перемножать матрицы какой-то очень сильно оптимизированный BLAS, потому что код на Python очень сильно тупит, но на Python вы исполняете очень мало. Вы говорите: окей, вот эти 10 миллионов чисел, пожалуйста, перемножьте. А потом перемножением занимается код на C.

[1:07:20] Алексей: Есть метафора, что всё это на самом деле похоже на общественный транспорт. Можно посадить 10 людей в 10 машин, и это как бы очень много. А можно посадить 100 людей в один автобус. И у автобуса очень большой throughput. Вот это вторая большая архитектурная мысль TigerBeetle — давайте мы всё будем группировать. Первый уровень группировки: когда транзакция приходит в API-код, API Gateway не отправляет этот один несчастный трансфер на 10 рублей в TigerBeetle. Он ждёт, пока ещё 8000 людей отправят 8000 трансферов, потом упаковывает их все в одно сообщение и отправляет одно сообщение с 8000 трансферов в TigerBeetle. Интересно, что TigerBeetle запускает консенсус один раз сразу для всех 8000 трансферов. Так у нас получается амортизация консенсуса. Целиком бачим — и можем отправлять в state machine.

[1:08:16] Алексей: Дальше, второй уровень бачинга, где мы доходим до compaction и чекпоинта. На самом деле, когда state machine обработает эти 8000 трансферов, она ничего не пишет на диск — просто сохраняет результаты в память. И результаты находятся в памяти на протяжении так называемого compaction interval, количества бачей. Он, если не ошибаюсь, равен 32. То есть 32 сообщения вам пришли, вы получаете 32 бача, и за эти 32 сообщения вы ничего не записали на диск — весь диф хранится в памяти. Номер сообщения делить на 32 равно нулю — очень прямолинейно. После этих 32 сообщений вы говорите жёсткому диску: окей, мы достаточно много данных саккумулировали в памяти, давайте теперь за один бач всё это дело запишем на диск. Мы саккумулировали на 8000 консенсус, дальше на 32 — самортизировали compaction. И пока ваша state machine в памяти процессит следующие 32 пачки сообщений, предыдущие 32 пачки пишутся на диск в виде новых LSM-деревьев. Вот так работает compaction в TigerBeetle. Он работает как заводной апельсин: каждые 32 пачки мы говорим — окей, вот это будет compaction. К концу следующей пачки из 32 наш предыдущий compaction round закончился, и мы готовы принимать на диск следующую пачку.

[1:09:45] Алексей: Дальше. Когда мы записываем новые деревья на диск, мы не обновляем сразу наш superblock. Он показывает на старый чекпоинт. И если, условно, мы обработали 5 больших бачей, сломались, начинаем заново и забываем, что те 5 больших бачей уже на диск записали, — то начнём те же данные обрабатывать заново. Прикольно, что мы на диск будем записывать ровно те же самые данные с той же самой чек-суммой, будем писать в тот же самый offset на диске. Отсюда детерминизм и возникает. Когда мы хотим сделать чекпоинт, у нас есть ещё раз третий уровень батчинга. Если не ошибаюсь, чекпоинт происходит каждые 1024 мини-бача. Мини-бачи мы объединяем в бачи по 32 для compaction. Соответственно, 1024 разделить на 32 будет сколько-то там. Не умею считать быстро в уме. В общем, столько-то этих compaction-интервалов — и мы говорим: окей, мы саккумулировали достаточно много на диске, давайте теперь атомарно поменяем наш superblock. И это мы тоже хотим амортизировать, потому что замена superblock медленная: нам нужно записать его на диск четыре раза, и мы эти записи делаем последовательно, между каждой из них у нас fsync.

[1:11:07] Алексей: На самом деле у нас тупая модель: мы просто делаем fsync после каждой записи, чтобы лишний раз не думать, где мы с fsync, а где без. И вместо этого думаем про то, чтобы не писать на диск тогда, когда не нужно. То есть не то что мы там как-то хитро учим LSM думать про консенсус, чтобы у нас появился детерминистический data-файл. Нет, у нас просто изначально всё написано так, что всё по нотам расписано и происходит в строго определённом порядке.

[1:11:42] Алексей: Там есть один тонкий момент, про который я хочу рассказать, но я, наверное, здесь сделаю паузу, чтобы понять, где я потерял слушателей.

[1:11:53] Александр: Я думаю, ты их не потерял. Мне в целом очень понравилось, что ты зашёл с самого верха — про бачирование транзакций, про то, что потом с этим происходит. Хотел дополнить такую мысль: вся эта система TigerBeetle почти на 100% нацелена на оптимизацию именно throughput, то есть количества транзакций, которые мы за время можем переварить, а не на отклик одной конкретной транзакции. Потому что если бы мы хотели как можно быстрее получить результат по одной транзакции, мы бы вряд ли хотели, чтобы она бачевалась с другими — учитывая, что другие 79% батча уже могут быть готовы, а вот последнее что-то, сеть моргает, не дошла, и все эти 79% ждут. Вряд ли этого хотим. А с точки зрения throughput всё так амортизируется, что в итоге система процессит очень большое количество таких транзакций. Кстати, если ты знаешь — есть какие-то последние новости с полей про перформанс? Сколько в целом система TigerBeetle транзакций может запроцессить, скажем, в секунду?

[1:12:49] Алексей: Слушай, давай начну с неприятного последнего вопроса. Точно не вот так. Пока у нас нет какой-то супер-бенчмарки, где мы меримся с другими базами данных, что вот мы точно всех быстрее. Потому что мы зарелизились пару месяцев назад, у нас ещё не написано всё, что мы хотим написать. Ситуация такая: архитектура у нас такая, которая позволит performance заанлочить, но чтобы прям до конца заанлочить, нужно написать кучу оптимизаций. Это у нас пока ещё в плане. Поэтому, когда мы говорим про performance, мы говорим, что у нас есть архитектура, и гипотетически, когда мы всё напишем, всё будет правильно. Какой performance прямо сейчас — ну, у нас, естественно, есть внутренние метрики, но они такие, просто для нас, чтобы мы случайно не написали код, который в 10 раз всё замедляет. Пока это не стоит мерить.

[1:13:41] Алексей: Дальше, про ряд с throughput и latency. Абсолютно верно: задача TigerBeetle — не дать абсолютно минимальную latency на каждую транзакцию, которая только может быть. Действительно, батчинг фундаментально говорит, что какое-то время транзакции просто висят, пока не образуется батч. Но нужно понимать, что отношение между latency и throughput не совсем пропорциональное. Потому что если у вас throughput уже снаружи миллион транзакций в секунду просто посылают, этот throughput нужно принять. И если у вас система, которая начинает не справляться с этим throughput, то у вас и latency улетают в бесконечность — потому что вы просто не дропаете запросы на землю и обработать их вместе не сможете. Поэтому когда мы начинаем батчевать, у нас на самом деле latency может чуть-чуть измениться в лучшую сторону, просто потому что мы меньше всего работы затрачиваем на весь наш батч. Вариант с тем, что мы хотим каждую транзакцию обрабатывать мгновенно, работает только тогда, когда у нас одна транзакция в систему одновременно и приходит.

[1:14:52] Алексей: Ну и второй момент. Помимо общего трейд-оффа между throughput и latency, есть ещё простой инжиниринг. У вас может быть одинаковый трейд-офф между throughput и latency, но может быть система, у которой и throughput, и latency на самом деле лучше. И в этом плане у нас много всяких прикольных фишечек, которые, естественно, и latency хотят улучшить. Например, консенсус. В консенсусе, когда реплика получила сообщение, она должна распропагейтить его по другим репликам, и когда набрался кворум, она должна это сообщение закоммитить, но ещё хорошо бы записать его себе на диск. В нашем консенсусе мы не делаем разницы между тем, что сообщение было записано на наш диск, и тем, что оно было записано на чей-то другой диск. В частности, может быть такое: окей, у основной, главной реплики, у primary, что-то не то с диском, он начинает тупить. Ничего страшного, потому что в этот момент primary не будет дожидаться, пока её запись осядет на диске. Она получит три acknowledgement от перов, поймёт, что на чьих-то дисках эти данные есть, и после этого отправит клиенту сообщение.

[1:16:00] Алексей: Опять же, к вопросу про то, что если есть сериализация, то начинается, что у вас всё на свете сериализовано. В консенсусе обычно, когда сообщение записывается, мы говорим: окей, вот у нас есть сообщение, мы его разбродкастили, получили на него acknowledgement, бродкастим следующее сообщение. Мы делаем не так. Мы гарантируем, что у нас commit происходит in order, последовательно, но при этом фаза репликации, фаза prepare, у нас абсолютно параллельна. Когда у вас есть один batch и второй batch, до того как первый batch закончил реплицироваться, второй batch уже начинает реплицироваться. Так что, скорее всего, это будет работать не со скоростью «консенсус плюс execution», а за максимум от них обоих.

[1:16:45] Алексей: Там есть ещё, на самом деле, прям совсем демонический план, который очень хочется написать, потому что он очень красивый, но который мы пока не сделали. Мы на самом деле даже можем не ждать консенсуса. Смотрите, нам пришёл batch. Мы должны его по-хорошему исполнить, но перед тем, как исполнить, нам нужно его разреплицировать. Потому что если мы сейчас упадём, и потом будет какой-нибудь view change в консенсусе, и кто-нибудь другое сообщение на это место заткнёт, то это сообщение, может быть, и не выживет. Но тем не менее, что можно делать? Начать исполнять это сообщение, как только мы его получили, — просто предположив, что, скорее всего, мы его заапрувим. И единственное, что мы не можем делать, — послать клиенту ответ «да, чувак, вот результат твоего запроса». Мы можем просто послание ответа задержать до того момента, когда получим acknowledgement от трёх других ребят, что да, сообщение записано. И тогда уже готовый ответ посылаем. Пока такого мы не написали, это только в планах.

[1:17:46] Алексей: Но, в общем, да, есть в архитектуре тоже такой момент. Когда у вас есть распределённая система, когда у вас есть шесть нод, причина, по которой она у вас есть, — это надёжность. Вы хотите защитить себя от краша каждой отдельно взятой ноды и дать возможность сделать failover. Но если у вас уже есть распределённая система, было бы глупо не использовать соображения из tail-at-scale — было бы глупо не разреплицировать запрос по нескольким копиям и не взять быстрейший из ответов, а брать слабейший. Ровно эти же соображения частично мотивируют нашу идею с тем, что мы думаем, что диск может падать. Потому что окей, если вы хотите сделать надёжный диск, вы можете в один компьютер поставить много дисков, объединить их в RAID и сказать «вот здесь у нас диск супернадёжный». Но, блин, у вас и так уже шесть нод работает, и у каждой из них есть жёсткий диск, — почему бы просто не воспользоваться всей этой redundancy, которая у вас есть? Так что в плане redundancy мы, безусловно, оптимизируем throughput, но там, где можем бесплатно ещё и redundancy уменьшить, естественно, его уменьшаем. И хочется верить, что в итоге мы дойдём до состояния, где и redundancy, и throughput будет больше, чем надо.

[1:19:05] Алексей: Поэтому это ключевой момент, куда мы с нашими performance-таргетами целимся. Мы хотим целиться не на максимальный перформанс, который нужен сейчас, а на перформанс, который просто никому не нужен, потому что он и так, блин, слишком быстрый. Потому что тогда, возможно, откроются какие-нибудь интересные новые приложения для транзакций — если транзакции можно так быстро трекать, почему бы всё на свете не сделать транзакциями?

[1:19:29] Александр: Да, интересно очень. Мне прям нравится, куда мы идём. Ты хотел что-то ещё рассказать, но решил остановиться — сказал, хочу узнать, не потерял ли я слушателей.

[1:19:47] Александр: Спасибо, потому что, не знаю, как слушатель, я себя точно потерял. Ну, примерно мы поняли, как работает compaction. У нас есть 32 диапазона, и каждые 32 препера мы берём и делаем compaction. Что такое compaction? У нас есть LSM-дерево, в нём куча уровней, а мы должны про какие-то уровни сказать: окей, давайте возьмём две таблички с этого уровня, смерджим их и запишем на какой-нибудь следующий уровень.

[1:20:00] Алексей: У нас в одном дереве, если не ошибаюсь, шесть уровней, а деревьев много, потому что у нас два доменных объекта, и, как я уже объяснял, один объект вроде аккаунта или трансфера — это на самом деле много LSM-деревьев, потому что нужны индексы. Я сейчас в голове не вспомню, сколько у нас там деревьев, но их, в общем, десятки. И процедура compaction должна быть детерминистична. Давайте зумин в этот compaction — как он работает? Мы выбрали две таблички на каком-то уровне, который хотим смерджить. Читаем их, мерджим, удаляя тумбстоуны, как обычный compaction работает, заполняем результат. Когда в результате появился осмысленный кусок данных, мы хотим его записать на диск. Значит, нам нужно найти какой-то свободный блок на диске — с учётом того эффекта, что если мы блок освободили в этом чекпоинте, то на самом деле он как бы свободен, но использовать его нельзя, потому что он может понадобиться при восстановлении. И нам важно, чтобы этот блок был всегда один и тот же между всеми репликами.

[1:21:16] Алексей: И это на самом деле очень плохо, потому что наивно кажется, что это заставляет нас все компакшены запускать последовательно. Смотрите, у нас здесь 10 деревьев, мы хотим их покомпактить. И у нас есть какая-то пачка свободных блоков. Если мы просто конкурентно запустим эти 10 компакций, то они будут конкурентно говорить: окей, мне нужен новый блок, дай мне новый блок; потом вторая говорит — мне нужен новый блок. А на другой реплике, может быть, наоборот, вторая чуть более или менее работает по каким-то причинам, первая закончится быстрее. То есть, если мы просто будем выдавать первый свободный блок каждой компакции, которой нужен свободный блок, мы будем получать недетерминированный жёсткий диск. Кстати, тут прикольно: у нас будут одинаковые данные, просто в разных местах записанные, но даже это не совсем так, потому что мы же ещё будем чек-суммы от всего этого дела брать. И если мы записываем просто разные адреса в наш index-блок, то там будет и разная чек-сумма, и вообще у нас просто будет другой диск.

[1:22:19] Алексей: Интересный момент про то, как понять, где нам всё-таки нужен sequencing point, а что можно делать параллельно. У нас это работает так. На эти 32 препера, на границе, мы хотим запустить компакцию. Мы синхронно смотрим, что вообще хотим закомпактить. Хотим закомпактить деревья 3, 5 и 8. А дальше в карте этих компакций говорим: окей, компакция, сколько блоков на диске тебе может понадобиться прямо сейчас? Дай мне верхнюю границу. И, например, первая компакция говорит: окей, мне нужно 10 блоков максимум. Вторая — 100. Третья — 13. И это всё ещё как бы отсутствие конкурентности, всё ещё один логический порядок. Мы берём и резервируем все эти блоки, говорим: окей, первая компакция — твои свободные блоки с нулевого по десятый. Вторая компакция — твои с десятого по стодесятый. Третья — со стодесятого по стодвадцатьтретий. После того как мы всё это провернули, запускаем все три компакции параллельно. И дальше каждая аллоцирует блоки из своего бюджета вне зависимости от их физического интерливинга. В результате картина аллокаций получается одинаковая, и у нас получается byte-for-byte identical disk.

[1:23:30] Алексей: Ещё момент: мы хотим и детерминированный результат, и очень сильно всё делать параллельно и конкурентно. Параллельно и детерминированно — это вроде бы антонимы, но оказывается, если вы делаете эту процедуру резервирования, то это работает. Да, важный момент: это именно процедура резервирования. То есть компакция номер один вначале резервирует 10, а потом, может быть, использовала только 6 из них. В конце компакции она говорит: окей, я просила 10, понадобилось только 6, вот эти 4, пожалуйста, закинь обратно в пул свободных блоков.

[1:24:00] Александр: Эти блоки с нулевого по десятый, ты сказал — это значит, что они на диске друг за другом располагаются или это просто их…

[1:24:06] Алексей: Да-да, они на диске располагаются друг за другом. Как вообще устроен диск TigerBeetle? Давай начнём с простого, но ещё не до конца факта. Мы используем только один файл. Обычно базы данных любят использовать много файлов. В SQLite есть, например, отдельный файл write-ahead journal. В RocksDB вроде бы каждый SST table — отдельный файл. У нас TigerBeetle — только один файл. И это опять же к вопросу, с чего мы начинали, про диск и network, про то, что мы просим от нижележащей системы. Если у вас база данных работает с такими файлами, то вам от нижележащей системы нужна файловая система — операции сделать один файл, сделать второй файл. У нас в TigerBeetle намного более простая система: мы говорим, что нам нужен просто, по сути, блок-девайс. И то, что мы пока не делаем, но что long-term план: в продакшене вы просто показываете TigerBeetle на физический диск — не на файловую систему, а просто на физический диск. И оно должно работать, потому что да, это всё, что мы требуем.

[1:25:09] Алексей: Так вот, у нас один data-файл. Data-файл состоит из так называемого superblock и так называемого грида. Superblock состоит из этих четырёх копий. Грид предельно прямолинейный, он выглядит ровно так, как кажется: это массив из блоков, где каждый блок — это полмегабайта. Соответственно, i-й блок хранится в позиции «superblock плюс полмегабайта умножить на i плюс 1». Вот этот «плюс 1» тоже прикольный маленький момент: у нас блоки в гриде нумеруются с единицы, у нас нет нулевого блока. И это специальный трюк, который мы много где используем. Чем плох блок с номером 0? Тем, что, знаете, системные программисты очень любят там memset в 0 что-нибудь сделать. Или смапали какую-нибудь память операционной системы, и она по умолчанию занулённая. И вот этот 0 всегда вызывает лёгкое ощущение неопределённости: вы на 99% уверены, что если вот это 0, то да, наверное, действительно 0. Но есть какой-то 1%, что ой, а может, это кто-то просто читает неинициализированную память, и на самом деле этот 0 — не 0, а кто-то забыл тут записать. Поэтому одно из маленьких правил в TigerBeetle: мы стараемся делать физический 0, то есть просто нулевые байты, некорректными данными. Чтобы это сразу какой-то ассерт триггерило и говорило: ой, блин, это стопудово неинициализированные данные. Соответственно, блоки мы нумеруем с единички, и если вы где-то видите address == 0, то это стопудово какой-то очень сильный баг.

[1:26:43] Александр: Окей, и получается, что так называемый блочный девайс, условно, весь процесс распределения этих блоков, очистки, их перемещения и так далее берёт на себя TigerBeetle, правильно?

[1:26:55] Алексей: Да, конкретно LSM.

[1:26:57] Александр: И получается, что даже когда мы зарезервировали какие-то блоки по максимуму, и не все из них использовались, то на каком-то следующем этапе компакции эта фрагментация уйдёт, блоки перераспределятся, и это будет тоже детерминистично, правильно?

[1:27:13] Алексей: Да, но тут есть два подвопроса. Первое: по сути, TigerBeetle — это файловая система. Мы делаем всё то же, что делала бы нормальная файловая система. И, собственно, дизайн про superblock, если не ошибаюсь, приходит из DFS. Второй вопрос — про то, что мы будем делать с фрагментацией. И тут я снова буду чуть-чуть извиняться и говорить: знаете, ребята, мы тут зарелизились два месяца назад, у нас планы огромные, но пока не всё написано. Одна из вещей, которая написана не очень хорошо, — это то, что мы пока не очень эффективно используем пространство на диске. У нас достаточно много пустоты иногда возникает в табличках, в блоках; естественно, есть миллионы всяких планов про то, как это делать лучше.

[1:27:53] Алексей: Один из планов — собственно, как мы делаем дефрагментацию. И здесь на самом деле очень простой подход. Чтобы его рассказать — как в принципе мы всё это делаем, как вообще балансировать блоки на диске, как понять, какие блоки свободные, какие нет? У нас есть структура, которая называется free set, которая является просто битсетом заполненных блоков. Там ужасный флип логики: она называется free set, но на самом деле трекает заполненные блоки. И когда мы хотим что-то аллоцировать, мы на самом деле аллоцируем самый ранний незаполненный блок. Мы просто смотрим на этот битсет, ищем в нём первый бит, который не засечен, и говорим: окей, вот это тебе твой аллоцированный блок. Так как у нас есть процедура резервирования, на самом деле мы аллоцируем целые бачи незаполненных блоков.

[1:28:41] Алексей: Соответственно, за счёт того, что мы зарезервировали, но потом освободили, у нас после одного раунда компакции в конце data-файла получается такой небольшой некрасивый, не очень эффективно использованный кусочек, где мы большую часть блоков не используем. Но опять же, на следующем раунде компакции мы все эти неиспользованные блоки опять же попилим на куски и дадим их каким-то компакциям. И каждая компакция будет аллоцировать из начала этого свободного региона. То есть, условно, первый блок компакции, которой достаётся первая резервация, — это гарантированно будет первый свободный блок во всём data-файле. Поэтому у нас есть естественный процесс, при условии что data-файл растёт: при этом условии фрагментация на самом деле ограничена в объёме. Если вы начнёте делать data-файл, который сокращается, то тогда будет плохо: файл на диске может не уменьшаться, несмотря на то, что вы из него кучу всего поудаляли, потому что у нас нет процедуры, которая делает compaction и дефрагментацию просто для того, чтобы плотнее упаковать данные на диске. Тоже пока в планах.

[1:29:56] Александр: Понятно, окей, хорошо, закопались довольно неплохо. Мне понравилось, что ты сказал: TigerBeetle — это, по сути, файловая система. Можно вначале прям вынести хайлайтом.

[1:30:13] Александр: Слушай, мы уже довольно много поговорили, мне очень интересно про всё это, но вопрос в том, что, во-первых, время не хочется много тратить, и второе — у слушателей просто голова вскипит от такого количества хардкорной информации, потому что мы сейчас говорим про около-системное программирование, а подкаст у меня для прикладных программистов. Представляешь, как им будет тяжело. Но мы уже перед этим говорили и про SIMD, и про JIT, и про LSM-деревья, поэтому нормально. А есть ли что-то, что тебе хотелось рассказать, а мы не затронули? Кажется, что почти по всем основным фишкам TigerBeetle мы прошлись.

[1:30:52] Алексей: Две большие темы, которые бы не затронули. Первое — почему TigerBeetle написан на Zig, и про это я на самом деле не хочу говорить, потому что я про это уже много тысяч раз везде говорил, писал блокпосты. Вторая тема — про которую я тоже много говорил, но про это надо прямо говорить, говорить, говорить — это механизм статической аллокации. Что в TigerBeetle мы, по сути, в нашем адресном пространстве не имеем реализацию функции malloc и, соответственно, функции free. У нас нет кода, который аллоцирует вектор где-то в куче и потом его опустошает, освобождает. Весь код написан в таком стиле, что в нашей функции main, функции init, мы аллоцируем сколько-то памяти. Количество памяти, которое мы аллоцируем, естественно, зависит от аргументов командной строки, и эта аллокация очень тупая. По сути, просто делаем mmap страниц от операционной системы, кидаем их в арену, там нет какого-то хитрого аллокатора, который должен думать про фрагментацию памяти, потому что, блин, мы никогда больше память освобождать не будем.

[1:31:53] Алексей: Так вот, мы всё это проаллоцировали, и после этого наш аллокатор просто забывает. Весь остальной код, который в системе работает, вынужден работать с тем фиксированным количеством памяти, которое было проаллоцировано на старте, и у него физически нет даже доступа к аллокатору, чтобы где-то на каком-то hot path что-то чуть-чуть подаллоцировать, потому что просто нельзя. И это, пожалуй, самый удивительный факт про TigerBeetle именно как про инженерный продукт. Потому что казалось бы, ну ладно, окей, сборка мусора — допустим, вот пришёл Rust, показал, что можно без сборщика мусора, но всё равно в segfault не наступать. Но вот то, что можно что-то такое большое и сложное написать вообще без глобального аллокатора, — это на самом деле достаточно удивительно, потому что TigerBeetle на самом деле не embedded-система. Мы там очень много всего делаем, очень много конкурентности, у нас там много сети, много диска, какая-то маленькая криптография, куча хитрых алгоритмов. И тем не менее оказывается, что, в общем-то, куча не нужна.

[1:32:53] Алексей: Не уверен, что готов сейчас прямо подробно рассказывать, как это работает, потому что это отдельная тема — именно про программирование TigerBeetle и как оно всё внутри устроено. Потому что мозги у меня у самого тоже уже кипят. Но я хочу это отметить как такой чекбокс, интересное свойство именно имплементации. И, возможно, если это действительно любопытно, стоит пойти посмотреть source code, прочитать всякие наши блокпосты, посмотреть на YouTube какие-то из наших видео — у меня там есть целая серия про то, как всё внутри подробно устроено.

[1:33:26] Алексей: И вторую мысль, которую хочу сказать про то, что TigerBeetle — это хардкорное системное программирование. На самом деле две мысли. Первая: я новичок в базах. Моя экспертиза — это написание компиляторов языка программирования Rust. Я это сделал два раза, и, видимо, всё, что я умею. Интересно, что тем не менее мне, как не эксперту, войти в TigerBeetle оказалось очень легко. Потому что это на самом деле интересное свойство именно системного программирования — оно очень-очень простое. Когда я открываю какой-нибудь нормальный код на Rust, я досмотрю какой-нибудь Tokio, gRPC, миллионы библиотек, какие-то странные форматы — и там надо очень-очень долго во всё это вникать. Когда я открываю код TigerBeetle и смотрю в какой-то подобный момент — а где находится блок с номером i на диске? — там код в стиле, ну, давайте возьмём offset и прибавим к нему i, умноженное на размер блока. Это какой-то очень простой, очень бейби-код, который можно просто читать, и там никаких абстрактных средств, всё понятно.

[1:34:28] Алексей: Поэтому, если кто-то думает про себя «я прикладной программист, а вовсе не системный», — возможно, вы ещё более сильный программист, чем все системные программисты. Потому что системное программирование на самом деле очень простое. Можно открывать код, читать и, в общем-то, понимать, как оно работает. Так что, если то, что я говорю про TigerBeetle, кажется интересным, я категорически рекомендую всем просто пойти читать исходники. Потому что всё открыто, всё достаточно readable, и фиг с тем, что там Zig, а не Rust, оно всё равно достаточно похоже на стандартные языки программирования, можно понять, что происходит. Никакого сильно большого заума там нигде нет. Кроме одного места, где мы как раз эти два несчастных объекта в тысячу LSM-деревьев превращаем, — там очень большой заум через метапрограммирование. Но всё остальное прям вообще понимается на ура.

[1:35:18] Александр: Кстати, да, мне было интересно, как же это всё в дерево превращается. Но оставим это для тех, кто хочет посмотреть в код. И я лично сам тоже посмотрю. Я уже и форкнул себе замечательную кодовую базу, прям изучаю. И мой следующий язык программирования, который я буду изучать, — это Zig. Просто чтобы лучше понимать, как написан TigerBeetle. Мысль про системных программистов мне очень понравилась, потому что действительно, когда смотришь, как написаны какие-то бизнесовые приложения, которые делают какую-то штуку, там столько всего используется, что продраться через всё это и понять, как оно реально реализовано, — просто огромное требуется умственное усилие, которого у меня часто не хватает. И я просто сдаюсь, понимаю, что не могу. Хотя, казалось бы, по факту делается не так уж и много.

[1:36:10] Александр: Вот мы сейчас проговорили про TigerBeetle, про базу данных. Да, у неё небольшая бизнесовая модель, там всего две сущности, но ими можно покрыть очень много задач. И мы сейчас поговорили и не продирались через тонну абстракций. То есть мы не говорили высокоуровнево, а потом перескакивали. Как мы обсуждали, так оно действительно в коде и написано — там минимальное количество абстракций. И это меня очень сильно подкупает, потому что мне кажется, что если уж и надо что-то сокращать в программировании и уменьшать сложность, то нужно делать это в коде. Потому что сложность внешнего мира мы можем сокращать до какого-то предела: вот есть задача, она фундаментально решается так, фундаментально нужно делать репликацию, делать структуры данных, которые на запись быстро работают, потому что компьютеры так работают. А дальше что мы можем упрощать? Код, который мы пишем. И без того сложные штуки писать с помощью очень сложного кода может быть и прикольно в какой-то момент, но потом оказывается, что вносить правки, дорабатывать и вообще понимать, как этот код работает, очень тяжело. Поэтому TigerBeetle — ещё и пример того, как может быть написана сложная, с одной стороны, система, но простым языком.

[1:37:26] Алексей: Тут ещё стоит добавить, что иногда сложность можно очень хорошо инкапсулировать. И опять же, TigerBeetle здесь пример. Наш сторидж и наш консенсус фундаментально на самом деле очень сложные. Потому что консенсус сам по себе, как протокол, непростой. Сторидж, особенно если вы говорите, что там могут быть ошибки, тоже очень непростой. Алгоритмы компакции LSM — там просто много всего можно написать. Но прикольно, что вы можете всё это взять, написать один раз, упаковать и сказать: окей, вот эта штука теперь работает как один компьютер, и она просто делает double-entry accounting. И вам с точки зрения программиста не нужно понимать, как всё это внутри устроено. Вам нужно понимать простой высокоуровневый API, и вам нужно понимать, что да, эта штука действительно гарантирует тот уровень надёжности данных, который заявлен.

[1:38:13] Александр: Да, то есть инкапсуляция сложности — это тоже прикольная мысль.

[1:38:24] Александр: Я предлагаю заканчивать, потому что и у нас уже голова немного подкипела — не знаю, у меня уж точно, а у слушателей, я думаю, тоже. У меня есть такая традиция: я с талантливыми инженерами, когда общаюсь, всегда спрашиваю — помимо всех тех материалов, которые ты уже назвал (и твой блог, и TigerBeetle-канал, всё это мы, естественно, прикрепим, будет в ссылочке), есть ли у тебя что-то, что ты бы мог посоветовать программистам, нашим слушателям — что почитать, посмотреть, помимо кодовой базы TigerBeetle, и в какую сторону развиваться? Какой будет личный рекомендейшен?

[1:39:02] Алексей: О, да, есть у меня личный рекомендейшен, и моя личная рекомендация — просто писать. Вот это то, что я в своей карьере понял. Так получилось в основном за счёт удачи, что я какими-то прикольными проектами вроде TigerBeetle и rust-analyzer пофигачил, и это такой продакшн-продакшн, хардкорные вещи, что да, люди этим реально пользуются. Но до того, как я стал условно успешным программистом, я был бедным студентом. И как бедный студент, понятное дело, мне никто не давал писать TigerBeetle — я просто брал на коленках какие-то свои поделия и писал. Ну там типа «ой, а как работает рейтрейсер? давайте я напишу рейтрейсер». «Ой, а как работает компилятор? давайте я напишу компилятор». И вот, как человек из системного программирования, мысль, которую я хочу сказать: продакшн-код выглядит абсолютно точно так же, как всякие маленькие поделия и игрушки, просто он написан чуть с большим количеством усилий и чуть с большим количеством фичей. Поэтому, безусловно, главный совет — не читать что-то, а взять и написать. Не нужно написать свой маленький продакшн — нужно написать базу данных, которая хорошая; нужно написать максимально ужасный код, который тем не менее заставит вас что-то подумать.

[1:40:15] Алексей: И на самом деле про TigerBeetle тут есть прям даже совсем интересная история — про то, как я вообще в TigerBeetle оказался. Она из двух компонентов. Первая компонента про то, что у меня есть блог, который тоже можно почитать, и через этот блог познакомиться с людьми, которые работали в TigerBeetle, которые мне сказали «ой, а Лёша не хочет на него посмотреть?». А второй момент — как-то так получилось, что примерно тогда, когда я занимался TigerBeetle, я писал маленький свой хобби-проект, просто чтобы понять, как работает консенсус. И так получилось, что этот проект был архитектурно очень близок к TigerBeetle. На одном из первых созвонов с командой, когда мне объяснили, что такое TigerBeetle, я такой: «ой, блин, а у вас есть симулятор? ой, а у меня тоже симулятор есть, давайте я вам покажу». И вот показал свою игрушку, и все такие: «блин, о, да, круто, ой, а у тебя есть минимизация? ой, а у нас минимизации нет». Кстати, в TigerBeetle до сих пор нет минимизации в симуляторе, в моём маленьком поделии она есть, я вот всё время хочу её написать.

[1:41:06] Алексей: Короче, да, вот эта мысль. Не надо ничего читать, не надо ничего смотреть, не надо быть экспертом. Надо, блин, брать и фигачить, понимая, что мы во first principles. Потому что на самом деле это правда: программирование — ну, системное программирование, прикладное не знаю, но системное программирование — очень простое. Самое сложное, что вам нужно сделать в плане математики, — это, блин, поделить что-нибудь с остатком. Поэтому, пожалуйста, пишите, пишите, пишите код. Не знаю, насколько это inspirational, но это самое лучшее, что я сейчас могу родить.

[1:41:33] Александр: Это на самом деле отличная мысль, и ты первый на моём подкасте, кто прям так её озвучил — не то чтобы у меня было много гостей, но действительно, сам факт того, что чтобы программировать, надо программировать, сейчас иногда как-то теряется среди всего, что нас окружает. Как будто бы надо читать кучу книг, следовать каким-то практикам, что-то знать, что-то уметь. А по факту 95% всего, что нам нужно уметь и делать, — это просто писать код. И это как — знаешь, у меня есть такая аналогия, которая чем больше я программирую, тем больше у меня откликается, — что программирование очень похоже на какой-то спорт. Если мы реально хотим быть software-инженерами, писать код и строить системы, ты должен постоянно держать себя в форме. Чтобы играть в футбол, как Криштиану Роналду, ты должен уметь высоко прыгать, быстро бегать. Чтобы быстро бегать, надо бегать и делать это каждый день. Ты не можешь лежать с книжкой, читать про бег и потом побить мировой рекорд. Так не бывает. Мне кажется, с программированием очень похоже, да и, наверное, с большинством таких работ — надо просто делать, набивать мышцу, и вот тогда будет получаться. Не знаю, согласен ли ты с этим?

[1:42:45] Алексей: Я бы, наверное, уточнил. Потому что чтобы быть прям как Криштиану Роналду, вам действительно нужно фигачить очень много всяких конкретных штук. Но чтобы стать просто системным программистом, войти в позицию, с которой вы можете потом расти как системный программист, я не думаю, что тут основное — именно количество фигачения, которое вы туда уделяете. Важно бегать и думать про то, как бегать. То есть ещё раз, вот мой совет: вы напишите свою маленькую базу данных. Это не для того, чтобы вы научились писать свою базу данных — и первый раз напишете её за месяц, а второй за неделю. Это полезный скилл, который поможет в работе, но это не совсем то, где, мне кажется, основное вэлью. А основное вэлью захода в том, что, когда вы напишете её один раз, вы поймёте, как она внутри работает. Вам станет понятно, как эта система работает изнутри. И когда вы напишете 10 разных маленьких штуковин, окажется, что вы знаете плюс-минус 90% приёмов, которые используются во всяком программировании.

[1:43:46] Алексей: То есть, опять же, я за собой замечаю, что я как программист часто решаю задачи, и часто мои решения какие-то элегантные, красивые, хорошие. Но 90% этих решений, про которые мне кажется «блин, как клёво, что я это придумал», на самом деле не какое-то from-scratch решение, что я сидел, долго думал, что-то придумал. На самом деле я просто вспоминаю, как я эту же проблему решил когда-то давно. И вот это нарабатывание именно библиотеки паттернов, что ли, — это то, на чём следует концентрироваться, а не просто на тренинге процедуры программирования. Тут важно концентрироваться, наверное, на широте. Написать не 10 баз данных, а написать базу данных, компилятор, какой-нибудь, я не знаю, фреймворк для джаваскрипта по производству HTML — все должны это написать. И тогда у вас будет такой большой кругозор, который позволит любую новую проблему смэтчить с какой-то существующей, которую вы уже решали.

[1:44:46] Алексей: И вот тут ещё такой маленький персональный анекдот — почему это так эффективно, почему, собственно, не прочитать книжку про то, как кто-то писал базу данных, где все эти решения написаны? Ну вот по моему опыту, пока до тебя самого какая-то простая мысль не дойдёт, ты её сам не поймёшь. Я вот помню, у меня такое было со статьёй Джона Кармака про inline code — когда стоит выносить код в отдельную функцию, а когда стоит фигачить его прямо там, где ты бы эту функцию вызывал. Помню, что я работал над rust-analyzer, и в какой-то момент написал конкретно просто rule of thumb в style guide, когда надо выносить функцию, когда не надо. Мне показалось, что я прям микрооткрытие сделал, я очень радовался. Через ещё пару месяцев я перечитывал в n-й раз эту самую статью про inline code, и, к своему стыду, понял, что вот то, что я типа придумал для style guide rust-analyzer, оно там было написано. Но просто первые три раза, когда я читал эту статью, я типа дакал: да, мне казалось всё очевидно, но у меня не было вот этого понимания.

[1:45:52] Алексей: Очень просто убедить себя, что ты понял что-то. Но действительно понять, осознать и додать это в свой словарный запас — намного сложнее. И вот самый простой способ — это просто встать на грабли. Потому что когда ты своими ногами встал на грабли, и они тебе по голове отдали, тогда ты уже на всю жизнь запомнил.

[1:46:09] Александр: Да, и лучше вставать на грабли в своём проекте, а не в продакшене. Очень супер. Это прямо настолько откликается тому, как я всё это вижу и воспринимаю, что ты, наверное, сейчас последнюю каплю мотивации в мой кувшин накапнул. Потому что я всё время хотел стрим на YouTube «пишем свою базу данных или компилятор» и сидел, выбирал — а что, а что? Думаю, начну с базы данных, а компилятор будет вторым. Спасибо тебе, слушателям. Коммитмент публичный, ребята: скоро будут стримы на YouTube, будем писать базу данных. Осталось выбрать язык программирования.

[1:46:43] Алексей: Ну ты же хотел Zig выучить. Мне кажется, тут ответ очевиден.

[1:46:46] Александр: Ну Zig, но я хочу понять Rust. Я просто на нём писал всякие маленькие прикольные штучки — не прикольные, наоборот, штучки, а типа там Advent of Code, на LeetCode что-то решал.

[1:46:56] Алексей: О, слушай. Тогда у меня к тебе прям челленджи есть. Во-первых, это точно Rust, потому что я бы вообще не рекомендовал людям программировать на Zig, C++ и C, если они не знают Rust. Потому что там можно, короче, насадить ужасных багов. Я понимаю, что у тебя запрограммированный Zig, и мне очень помогает то, что у меня в голове работает мой borrow checker, который мне говорит: блин, Алексей, тут variable is already borrowed. Я понимаю, что так делать нельзя. Конечно, без травмы от работы с Rust я бы так хорошо с Zig не работал.

[1:47:29] Алексей: Но один вопрос, который меня очень сильно интересует. Вот у TigerBeetle есть архитектурный constraint, что мы ничего не аллоцируем после старта. И этот constraint очень хорошо ложится на Zig — там просто API стандартных коллекций так и устроен, что вы пропихиваете аллокатор во все методы, которые могут аллоцировать. Соответственно, если вы аллокатор после этого убрали, то всё хорошо работает. И вот вопрос, который меня мучает: а насколько ужасно это будет писать на Rust? У меня есть гипотеза, что можно на Rust написать систему с такими же свойствами, что и на Zig — когда аллокация есть, но только на старте, — но при этом это всё будет достаточно ужасно выглядеть. А может быть, нет. А может быть, на самом деле да, это ошибка, что мы пишем TigerBeetle на Zig, и версия на Rust была бы намного проще. Поэтому мне было бы очень прикольно посмотреть на какое-нибудь маленькое поделие, которое что-то делает с сетью, что-то делает с жёстким диском, написано на Rust, не обмазано unsafe с ног до головы, и при этом не аллоцирует после старта.

[1:48:39] Александр: Ну, это, конечно, такой челлендж. Как выучить Rust очень-очень хорошо — потому что я не знаю, как это сделать. Слушай, у меня первый челлендж был написать на Rust просто нормальное дерево, бинарное, потому что структура данных с указателем на Rust — это уже нормальный такой челлендж.

[1:48:57] Алексей: Пробуй напиши там linked list.

[1:48:59] Александр: Но вообще говоря, ты мне хорошую идею подкинул по поводу статической аллокации. Вряд ли, наверное, стоит первой такой штукой на Rust делать. Но мини-базу данных — 100%. Если вы это слушаете, ребята, то всё, я наступил в эту лужу и закоммитился публично. Спасибо тебе большое за классный совет.

[1:49:20] Алексей: Спасибо за то, что согласился.

[1:49:21] Александр: Всем спасибо и до скорых встреч. Пока-пока!