Хранение данных

Дескрипторы и связи блоков

BlockHandle и BlockMeta — метаинформация блока с флагами наличия компонентов и статуса, кэш дескрипторов в памяти, обновление флагов слиянием в RocksDB, индекс ключевых блоков и связи между соседними блоками цепочки (Prev1/Prev2/Next1/Next2) с ожиданием Next1.

Часть читающих методов хранилища блоков — данные, доказательство и диф очереди — принимает не идентификатор блока, а его дескриптор. У остальных адресация другая. Ожидание блока по-прежнему адресует его обычным идентификатором. Поиск по мастер-блоку находит блок по одному лишь seqno, сканируя диапазон ключей: идентификатор для этого не нужен. Постраничный список вовсе не адресует конкретный блок, а отдаёт страницу идентификаторов. Дескриптор не входит в само хранилище блоков: он живёт в соседней подсистеме, хранилище дескрипторов блоков, и несёт метаинформацию о блоке (какие его компоненты уже сохранены и каков статус блока).

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

Дескриптор блока и его метаинформация

Хранилище дескрипторов блоков состоит из двух частей: таблиц RocksDB и кэша дескрипторов в памяти. Другого состояния у него нет. Экземпляр создаётся один раз при открытии хранилища узла и дальше раздаётся всем потребителям.

Дескриптор (BlockHandle) — обёртка над разделяемой ссылкой на неизменяемый идентификатор блока, изменяемую метаинформацию и три независимые блокировки на компоненты блока: данные, доказательство и диф очереди. У него есть и слабая форма: она не удерживает дескриптор в памяти сама по себе, а лишь пытается восстановить сильную ссылку на него. Это удаётся, только пока дескриптор жив хотя бы у одного другого держателя. Если все держатели уже отпустили дескриптор, объект уничтожен и восстановить сильную ссылку не получится.

Метаинформация блока (BlockMeta) упакована в одно атомарное 64-битное слово: старшие 32 бита несут флаги блока (см. ниже), младшие 32 бита — seqno мастер-блока, которым блок «привязан» к мастерчейну. Время создания блока хранится отдельным неизменяемым полем. В RocksDB эта метаинформация записывается ровно 12 байтами.

Флаги блока

BlockFlags — набор флагов, упакованных в старшие 32 бита слова метаинформации:

ФлагЧто означает
HAS_DATAсохранены данные блока
HAS_PROOFсохранено доказательство блока
HAS_QUEUE_DIFFсохранён диф очереди
HAS_ALL_BLOCK_PARTSсоставной флаг: все три компонента сохранены
HAS_STATEсохранено состояние шарда
HAS_PERSISTENT_SHARD_STATEсохранено устойчивое состояние шарда
HAS_PERSISTENT_QUEUE_STATEсохранено устойчивое состояние очереди
HAS_VIRTUAL_STATEсохранено виртуальное состояние
HAS_NEXT_1 / HAS_NEXT_2известна связь со следующим блоком (одним, или обоими при разделении шарда)
HAS_PREV_1 / HAS_PREV_2известна связь с предыдущим блоком (одним, или обоими при слиянии шардов)
IS_COMMITTEDблок зафиксирован
IS_KEY_BLOCKблок ключевой
IS_PERSISTENTпо блоку сохранено устойчивое состояние
IS_REMOVEDблок вычищен сборкой мусора
IS_ZEROSTATEблок — нулевое состояние
SKIP_STATES_GC / SKIP_STATES_GC_FINISHEDзащита состояния от сборки мусора начата / завершена
SKIP_BLOCKS_GC / SKIP_BLOCKS_GC_FINISHEDзащита данных блока от сборки мусора начата / завершена

Флаги — единственный «журнал состояния» блока в узле: по ним читающее API решает, отдавать ли компонент, а сборка мусора решает, можно ли блок удалить.

Обновление флагов в RocksDB

Метаинформация блока обновляется в RocksDB не обычной записью, а ассоциативным оператором слияния колонки дескрипторов: новое 12-байтовое значение объединяется со старым побитовым ИЛИ, без какой-либо другой логики. Значит, флаг можно только добавить, а не снять. Обычное сохранение дескриптора снятый флаг не сохранит: при следующем объединении старый бит вернётся. Снятый в памяти флаг всё же попадает на диск только в одном месте — при стартовой сверке во время открытия хранилища, которая пишет пересчитанную метаинформацию напрямую, минуя оператор слияния.

Такое устройство даёт конкурентность без блокировок: несколько потоков, ставящих разные флаги одному и тому же блоку, не затирают чужое обновление, а порядок применения объединений не важен. Цена — асимметрия между постановкой и снятием флага: в базе сохраняется только «флаг поставлен», а не «флаг снят».

Весь набор сеттеров хранилища дескрипторов стоит на этом принципе: методы, каждый из которых ставит один конкретный флаг — защиту от сборки мусора, статус зафиксированного блока, признаки сохранённых состояний и нулевого состояния. Каждый сеттер атомарно добавляет свой флаг и возвращает признак, что флаг действительно изменился. Только тогда дескриптор дозаписывается в RocksDB.

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

Особые случаи нулевого блока

По одному лишь нулевому seqno дескриптор истинен сразу по нескольким предикатам без собственного флага: он всегда сообщает, что относится к нулевому состоянию, что по нему сохранено устойчивое состояние, а для мастерчейна — что он ещё и ключевой блок. Перечисленные предикаты дескриптора (зафиксирован ли блок, есть ли у него состояние, виртуальное состояние, устойчивые состояния и связь со следующим блоком) смотрят только на свой флаг.

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

Защита от сборки мусора

Защита блока от сборки мусора устроена так же — парой флагов «начато» и «завершено», а не одним, отдельно для данных блока и отдельно для состояния. Раз снятый флаг не сохраняется, отменить защиту можно только выставив рядом второй флаг.

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

Кэш дескрипторов

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

Запись сама освобождается из кэша, когда последняя сильная ссылка на дескриптор отпущена, — но только если слот к этому моменту не занял новый дескриптор того же блока.

Есть и принудительный путь: чистка кэша по высоте блока, часть сборки мусора хранилища, снимает запись независимо от того, держит ли дескриптор ещё кто-то. Если это произошло с ещё живым дескриптором, следующее обращение за тем же блоком построит второй, отдельный экземпляр со своими флагами и своими блокировками, не связанный с первым.

Единственность экземпляра на идентификатор блока необходима для корректности: и флаги метаинформации, и блокировки на компоненты блока имеют смысл, только если все потребители работают с одним и тем же объектом. Иначе один поток мог бы выставить флаг, который другой не увидит до перечитывания из RocksDB.

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

Создание и сохранение дескриптора

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

  • дескриптор уже в кэше и жив — возвращается сразу, без обращения к RocksDB;
  • дескриптора в кэше нет, но метаинформация уже есть в таблице дескрипторов — дескриптор поднимается из неё и кладётся в кэш;
  • блок неизвестен вовсе — создаётся новый дескриптор с переданной метаинформацией.

Есть и упрощённый вариант того же поиска, без создания: только первые два пути, а для неизвестного блока он возвращает пустой результат.

На уже известном блоке переданная метаинформация игнорируется целиком: ни время создания, ни привязка к мастер-блоку, ни признак ключевого блока не перезаписываются повторно. Метаинформация блока задаётся один раз, при первом появлении блока в узле, и дальше меняются только флаги.

Сохранение дескриптора в RocksDB учитывает два независимых условия: создаётся ли дескриптор впервые и является ли блок ключевым. Если хотя бы одно из них истинно, запись идёт одним атомарным пакетом. Метаинформация в основной таблице дескрипторов пишется при каждом сохранении, независимо от этих условий. Впервые созданный дескриптор дополнительно попадает в отдельную вспомогательную таблицу, а ключевой блок — в индекс ключевых блоков, который разобран в следующем разделе. Когда ни одно из условий не выполняется, то есть при обычном обновлении флагов у давно известного блока, пакет не собирается вовсе: идёт одиночная запись метаинформации.

Индекс ключевых блоков

Индекс ключевых блоков — таблица «seqno самого ключевого блока → идентификатор блока», отдельная от основной таблицы дескрипторов. Запись в неё добавляется при каждом сохранении дескриптора ключевого блока, а порядок seqno в ключе совпадает с числовым, так что по индексу можно ходить обычным итератором RocksDB.

Через индекс хранилище даёт четыре способа найти ключевой блок:

МетодЧто находит
load_key_block_handle(seqno)дескриптор ключевого блока с точным seqno
find_last_key_block()последний известный ключевой блок
find_prev_key_block(seqno)ближайший ключевой блок строго до seqno
key_blocks_iterator(direction)обход всего индекса вперёд от seqno или назад от последней записи

Все они возвращают дескриптор (или идентификатор блока — для обхода итератором), а не данные блока: за данными нужно отдельно обращаться к хранилищу блоков.

find_prev_key_block ищет ключевой блок строго меньше переданного seqno: сам блок с этим seqno в результат не попадает, а для нулевого seqno метод сразу возвращает пустой результат. Вызывающему, которому нужен ключевой блок «не позже данного», приходится самому передавать seqno + 1. Пустой результат означает и то, что индекс пуст, и то, что все ключевые блоки лежат правее запрошенной границы. Различить эти два случая по ответу метода нельзя.

Отдельно есть find_prev_persistent_key_block — поиск ближайшего устойчивого ключевого блока. Он идёт по индексу назад от заданного seqno и для каждой пары соседних ключевых блоков проверяет, признаётся ли более поздний из них устойчивым по меткам времени создания обоих. Как только пара такую проверку проходит, возвращается более поздний блок пары. Если индекс исчерпан, а условие ни разу не выполнилось, результат пустой. Устойчивость здесь определяется по меткам времени, а не по флагу дескриптора. Флаг устойчивости ставится по факту сохранения устойчивого состояния. Этот метод его не вычисляет.

Связи между блоками

Хранилище связей блоков — подсистема, отдельная от хранилища дескрипторов и от хранилища блоков.

Связь блоков — сохранённая ссылка от блока к соседу по цепочке в одном из четырёх направлений: два предка при слиянии шардов (Prev1, Prev2) и два потомка при разделении (Next1, Next2). Когда шард не сливается и не делится, используются только Prev1 и Next1.

У хранилища связей всего три метода: записать связь, прочитать связь и дождаться связи Next1. Асимметрия между чтением и записью умышленная — пишущий метод принимает дескриптор блока, потому что попутно правит его флаги, а читающий получает обычный идентификатор: дескриптор ему не нужен. Ждать можно только Next1, подписок на прочие направления нет.

Связи хранятся обычной колонкой RocksDB: на каждую связь одна запись, а обратная связь (от соседа к этому блоку) пишется отдельным вызовом.

Как связи появляются

Признак «связь уже записана» живёт не в самой таблице связей, а во флаге дескриптора: свой флаг на каждое направление. Первым делом запись связи проверяет этот флаг. Если он уже стоит, вызов сразу завершается, ничего не читая и не записывая. Иначе связь сначала пишется в таблицу, и только затем дескриптору ставится флаг — если флаг действительно изменился, обновлённая метаинформация сохраняется в основной таблице дескрипторов. Такой «кэш признака» в дескрипторе экономит чтение RocksDB на горячем пути: при повторной обработке уже известного блока обращения к таблице связей не происходит вовсе.

Модуль продвижения по блокам расставляет связи при сохранении блока.

Для блока с одним предком:

  • новому блоку пишется связь назад, Prev1;
  • предку пишется связь вперёд — Next2, если новый блок оказался правым потомком предка при разделении шарда, иначе Next1.

Если у блока сразу два предка (слияние шардов), обоим предкам пишется Next1, а новому блоку — Prev1 и Prev2.

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

Семантика направлений:

  • Next1/Next2 — левый и правый потомок при разделении;
  • Prev1/Prev2 — два предка при слиянии.

Ожидание Next1

В статье о хранилище блоков было показано, что ожидание следующего блока цепочки сначала дожидается, пока у предыдущего блока не появится связь со следующим, и только потом ждёт появления самих данных. Именно эту связь, Next1, и умеет ждать хранилище связей.

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

Под своей частью блокировки ждущий сначала проверяет, не появилась ли связь уже: если появилась, она возвращается сразу. Если нет, он оформляет ожидание и только после этого отпускает свою часть блокировки и засыпает.

Пишущий, наоборот, берёт свою часть блокировки только при записи связи Next1, а для остальных направлений блокировка не используется вовсе. Уведомление ожидающих происходит до того, как эта часть блокировки будет отпущена.

Такая полярность закрывает окно между проверкой «связи ещё нет» и оформлением ожидания: в этот момент никакая параллельная запись связи Next1 невозможна, поэтому пробуждение не может проскочить мимо ждущего. У ожидания нет таймаута: вызывающий ждёт следующий блок сколько угодно долго и может лишь сам отменить ожидание.