Перейти к основному содержимому

Руководство по SirixDB

· 13 мин. чтения

1. Обзор

В этом руководстве мы дадим обзор того, что такое SirixDB , и наиболее важные цели его разработки.

Далее мы рассмотрим низкоуровневый транзакционный API на основе курсора.

2. Возможности SirixDB

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

В настоящее время SirixDB предлагает две встроенные собственные модели данных, а именно двоичное хранилище XML и хранилище JSON.

2.1. Цели дизайна

Вот некоторые из наиболее важных основных принципов и целей проектирования:

  • Параллелизм — SirixDB содержит очень мало блокировок и стремится максимально подходить для многопоточных систем.

  • Асинхронный REST API — операции могут выполняться независимо; каждая транзакция привязана к определенной ревизии, и только одна транзакция чтения-записи на ресурсе разрешена одновременно с N транзакциями только для чтения.

  • История версий /редакций — SirixDB хранит историю версий каждого ресурса в базе данных, сводя к минимуму накладные расходы на хранение. Производительность чтения и записи настраивается. Это зависит от типа версии, которую мы можем указать для создания ресурса.

  • Целостность данных — SirixDB, как и ZFS, хранит полные контрольные суммы страниц на родительских страницах. Это означает, что почти все повреждения данных могут быть обнаружены при чтении в будущем, поскольку разработчики SirixDB стремятся в будущем разделять и реплицировать базы данных.

  • Семантика копирования при записи — аналогично файловым системам Btrfs и ZFS, SirixDB использует семантику CoW, что означает, что SirixDB никогда не перезаписывает данные. Вместо этого фрагменты страниц базы данных копируются и записываются в новое место.

  • Версии для каждой версии и для каждой записи — SirixDB создает версии не только для каждой страницы, но и для каждой записи. Таким образом, всякий раз, когда мы меняем потенциально небольшую часть

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

  • Гарантированная атомарность (без WAL) — система никогда не перейдет в несогласованное состояние (если только не произойдет аппаратный сбой), а это означает, что неожиданное отключение питания никогда не повредит систему. Это достигается без накладных расходов на журнал упреждающей записи ( WAL ) .

  • Лог-структура и совместимость с SSD — SirixDB последовательно записывает и синхронизирует все на флэш-накопитель во время коммитов. Он никогда не перезаписывает зафиксированные данные

Сначала мы хотим представить низкоуровневый API на примере данных JSON, прежде чем переключиться на более высокие уровни в будущих статьях. Например, XQuery-API для запросов к базам данных XML и JSON или асинхронный временный RESTful API. По сути, мы можем использовать один и тот же низкоуровневый API с небольшими отличиями для хранения, просмотра и сравнения ресурсов XML.

Чтобы использовать SirixDB, нам как минимум нужно использовать Java 11 .

3. Зависимость Maven для встраивания SirixDB

Чтобы следовать примерам, мы сначала должны включить зависимость sirix-core , например, через Maven:

<dependency>
<groupId>io.sirix</groupId>
<artifactId>sirix-core</artifactId>
<version>0.9.3</version>
</dependency>

Или через Gradle:

dependencies {
compile 'io.sirix:sirix-core:0.9.3'
}

4. Древовидное кодирование в SirixDB

Узел в SirixDB ссылается на другие узлы с помощью кодировки firstChild/leftSibling/rightSibling/parentNodeKey/nodeKey :

./41151963048eebed452ac54070b04a5c.png

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

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

В следующих разделах мы представим основной низкоуровневый JSON API SirixDB.

5. Создайте базу данных с одним ресурсом

Во-первых, мы хотим показать, как создать базу данных с одним ресурсом. Ресурс будет импортирован из файла JSON и сохранен во внутреннем двоичном формате SirixDB:

var pathToJsonFile = Paths.get("jsonFile");
var databaseFile = Paths.get("database");

Databases.createJsonDatabase(new DatabaseConfiguration(databaseFile));

try (var database = Databases.openJsonDatabase(databaseFile)) {
database.createResource(ResourceConfiguration.newBuilder("resource").build());

try (var manager = database.openResourceManager("resource");
var wtx = manager.beginNodeTrx()) {
wtx.insertSubtreeAsFirstChild(JsonShredder.createFileReader(pathToJsonFile));
wtx.commit();
}
}

Сначала мы создаем базу данных. Затем мы открываем базу данных и создаем первый ресурс. Существуют различные варианты создания ресурса ( см. официальную документацию ).

Затем мы открываем одну транзакцию чтения-записи для ресурса , чтобы импортировать файл JSON. Транзакция предоставляет курсор для навигации по методам moveToX . Кроме того, транзакция предоставляет методы для вставки, удаления или изменения узлов. Обратите внимание, что API XML даже предоставляет методы для перемещения узлов в ресурсе и копирования узлов из других ресурсов XML.

Чтобы правильно закрыть открытую транзакцию чтения-записи, диспетчер ресурсов и базу данных, мы используем оператор Java try-with-resources .

Мы привели пример создания базы данных и ресурса на основе данных JSON, но создание базы данных и ресурса XML практически идентично.

В следующем разделе мы откроем ресурс в базе данных и покажем навигационные оси и методы.

6. Откройте ресурс в базе данных и перейдите

6.1. Навигация по предварительному заказу в ресурсе JSON

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

try (var database = Databases.openJsonDatabase(databaseFile);
var manager = database.openResourceManager("resource");
var rtx = manager.beginNodeReadOnlyTrx()) {

new DescendantAxis(rtx, IncludeSelf.YES).forEach((unused) -> {
switch (rtx.getKind()) {
case OBJECT:
case ARRAY:
LOG.info(rtx.getDescendantCount());
LOG.info(rtx.getChildCount());
LOG.info(rtx.getHash());
break;
case OBJECT_KEY:
LOG.info(rtx.getName());
break;
case STRING_VALUE:
case BOOLEAN_VALUE:
case NUMBER_VALUE:
case NULL_VALUE:
LOG.info(rtx.getValue());
break;
default:
}
});
}

Мы используем ось потомков для перебора всех узлов в предварительном порядке (сначала в глубину). Хэши узлов строятся снизу вверх для всех узлов по умолчанию в зависимости от конфигурации ресурса.

Узлы массива и узлы объекта не имеют ни имени, ни значения. Мы можем использовать одну и ту же ось для перебора XML-ресурсов, различаются только типы узлов.

SirixDB предлагает множество осей, таких как, например, все оси XPath для навигации по ресурсам XML и JSON. Кроме того, он предоставляет LevelOrderAxis , PostOrderAxis, NestedAxis для оси цепочки и несколько вариантов ConcurrentAxis для одновременной и параллельной выборки узлов.

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

6.2. Ось потомков посетителей

Поскольку очень часто поведение определяется на основе различных типов узлов, SirixDB использует шаблон посетителя .

Мы можем указать посетителя в качестве аргумента построителя для специальной оси с именем VisitorDescendantAxis . Для каждого типа узла существует эквивалентный метод посещения. Например, для ключевых узлов объекта это посещение метода VisitResult (узел ImmutableObjectKeyNode).

Каждый метод возвращает значение типа VisitResult . Единственной реализацией интерфейса VisitResult является следующее перечисление:

public enum VisitResultType implements VisitResult {
SKIPSIBLINGS,
SKIPSUBTREE,
CONTINUE,
TERMINATE
}

VisitorDescendantAxis выполняет итерацию по древовидной структуре в предварительном порядке. Он использует VisitResultType для управления обходом:

  • SKIPSIBLINGS означает, что обход должен продолжаться без посещения правых братьев и сестер текущего узла, на который указывает курсор.
  • SKIPSUBTREE означает продолжить без посещения потомков этого узла.
  • Мы используем CONTINUE , если обход должен продолжаться в предварительном порядке.
  • Мы также можем использовать TERMINATE для немедленного завершения обхода.

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

var axis = VisitorDescendantAxis.newBuilder(rtx)
.includeSelf()
.visitor(new MyVisitor())
.build();

while (axis.hasNext()) axis.next();

Методы в MyVisitor вызываются для каждого узла обхода. Параметр rtx является транзакцией только для чтения. Обход начинается с узла, на который в данный момент указывает курсор.

6.3. Ось путешествия во времени

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

  • первая ось
  • LastAxis
  • Предыдущая ось
  • Следующая ось
  • AllTimeAxis
  • FutureAxis
  • PastAxis

Конструкторы принимают в качестве параметров диспетчер ресурсов, а также транзакционный курсор. Курсор перемещается к одному и тому же узлу в каждой ревизии.

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

Мы покажем простой пример для PastAxis :

var axis = new PastAxis(resourceManager, rtx);
if (axis.hasNext()) {
var trx = axis.next();
// Do something with the transactional cursor.
}

6.4. Фильтрация

SirixDB предоставляет несколько фильтров, которые мы можем использовать вместе с FilterAxis . Следующий код, например, просматривает все дочерние элементы узла объекта и фильтрует ключевые узлы объекта с ключом «a», как в {«a»:1, «b»: «foo»} .

new FilterAxis<JsonNodeReadOnlyTrx>(new ChildAxis(rtx), new JsonNameFilter(rtx, "a"))

FilterAxis может принимать более одного фильтра в качестве аргумента. Фильтр представляет собой JsonNameFilter для фильтрации имен в ключах объекта или один из фильтров типа узла: ObjectFilter , ObjectRecordFilter , ArrayFilter , StringValueFilter , NumberValueFilter , BooleanValueFilter и NullValueFilter .

Ось можно использовать следующим образом для ресурсов JSON для фильтрации по именам ключей объектов с именем «foobar»:

var axis = new VisitorDescendantAxis.Builder(rtx).includeSelf().visitor(myVisitor).build();
var filter = new JsonNameFilter(rtx, "foobar");
for (var filterAxis = new FilterAxis<JsonNodeReadOnlyTrx>(axis, filter); filterAxis.hasNext();) {
filterAxis.next();
}

В качестве альтернативы мы могли бы просто выполнять поток по оси (вообще не используя FilterAxis ), а затем фильтровать по предикату.

rtx имеет тип NodeReadOnlyTrx в следующем примере:

var axis = new PostOrderAxis(rtx);
var axisStream = StreamSupport.stream(axis.spliterator(), false);

axisStream.filter((unusedNodeKey) -> new JsonNameFilter(rtx, "a"))
.forEach((unused) -> /* Do something with the transactional cursor */);

7. Изменить ресурс в базе данных

Очевидно, мы хотим иметь возможность модифицировать ресурс. SirixDB сохраняет новый компактный снимок во время каждой фиксации.

После открытия ресурса мы должны запустить единственную транзакцию чтения-записи, как мы видели раньше.

7.1. Простые операции обновления

Как только мы перешли к узлу, который хотим изменить, мы можем обновить, например, имя или значение, в зависимости от типа узла:

if (wtx.isObjectKey()) wtx.setObjectKeyName("foo");
if (wtx.isStringValue()) wtx.setStringValue("foo");

Мы можем вставлять новые записи объектов с помощью insertObjectRecordAsFirstChild и insertObjectRecordAsRightSibling . Подобные методы существуют для всех типов узлов. Записи объектов состоят из двух узлов: узла ключа объекта и узла значения объекта.

SirixDB проверяет согласованность и выдает непроверенное исключение SirixUsageException , если вызов метода не разрешен для определенного типа узла.

Записи объекта, то есть пары ключ/значение, например, могут быть вставлены как первый дочерний элемент, только если курсор находится на узле объекта. Мы вставляем как узел ключа объекта, так и один из других типов узлов в качестве значения с помощью методов insertObjectRecordAsX .

Мы также можем связать методы обновления — в этом примере wtx расположен на объектном узле:

wtx.insertObjectRecordAsFirstChild("foo", new StringValue("bar"))
.moveToParent().trx()
.insertObjectRecordAsRightSibling("baz", new NullValue());

Сначала мы вставляем узел ключа объекта с именем «foo» в качестве первого потомка узла объекта. Затем создается StringValueNode как первый дочерний элемент только что созданного узла записи объекта.

Курсор перемещается на узел значения после вызова метода. Таким образом, мы сначала должны переместить курсор на ключевой узел объекта, снова родительский. Затем мы можем вставить следующий узел ключа объекта и его дочерний узел NullValueNode в качестве правого брата.

7.2. Массовые вставки

Существуют и более сложные методы массовой вставки, как мы уже видели при импорте данных JSON. SirixDB предоставляет метод для вставки данных JSON в качестве первого дочернего элемента ( insertSubtreeAsFirstChild ) и правого родственного элемента ( insertSubtreeAsRightSibling ).

Чтобы вставить новое поддерево на основе строки, мы можем использовать:

var json = "{\"foo\": \"bar\",\"baz\": [0, \"bla\", true, null]}";
wtx.insertSubtreeAsFirstChild(JsonShredder.createStringReader(json));

JSON API в настоящее время не предлагает возможности копирования поддеревьев. Однако XML API делает это. Мы можем скопировать поддерево из другого XML-ресурса в SirixDB:

wtx.copySubtreeAsRightSibling(rtx);

Здесь узел, на который в данный момент указывает транзакция только для чтения ( rtx ), копируется со своим поддеревом как новый правый брат узла, на который указывает транзакция чтения-записи ( wtx ).

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

Мы можем либо совершить() , либо откатить() транзакцию. Обратите внимание, что мы можем повторно использовать транзакцию после вызова одного из двух методов.

SirixDB также применяет некоторые внутренние оптимизации при вызове массовых вставок.

В следующем разделе мы рассмотрим другие возможности запуска транзакции чтения-записи.

7.3. Начать транзакцию чтения-записи

Как мы видели, мы можем начать транзакцию чтения-записи и создать новый снимок, вызвав метод фиксации . Однако мы также можем запустить транзакционный курсор с автоматической фиксацией:

resourceManager.beginNodeTrx(TimeUnit.SECONDS, 30);
resourceManager.beginNodeTrx(1000);
resourceManager.beginNodeTrx(1000, TimeUnit.SECONDS, 30);

Либо мы автоматически фиксируем каждые 30 секунд, после каждой 1000-й модификации, либо каждые 30 секунд и каждую 1000-ю модификацию.

Мы также можем начать транзакцию чтения-записи, а затем вернуться к прежней ревизии, которую мы можем зафиксировать как новую ревизию:

resourceManager.beginNodeTrx().revertTo(2).commit();

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

var rtxOpenedByRevisionNumber = resourceManager.beginNodeReadOnlyTrx(2);

var dateTime = LocalDateTime.of(2019, Month.JUNE, 15, 13, 39);
var instant = dateTime.atZone(ZoneId.of("Europe/Berlin")).toInstant();
var rtxOpenedByTimestamp = resourceManager.beginNodeReadOnlyTrx(instant);

8. Сравните версии

Чтобы вычислить различия между любыми двумя версиями ресурса, когда-то сохраненного в SirixDB, мы можем вызвать алгоритм сравнения:

DiffFactory.invokeJsonDiff(
new DiffFactory.Builder(
resourceManager,
2,
1,
DiffOptimized.HASHED,
ImmutableSet.of(observer)));

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

Если узел изменяется из-за операций обновления в SirixDB, все узлы-предки также адаптируют свои хеш-значения. Если хэши и ключи узлов в двух ревизиях идентичны, SirixDB пропускает поддерево во время обхода двух ревизий, поскольку при указании DiffOptimized.HASHED в поддереве нет изменений .

Неизменяемый набор наблюдателей — последний аргумент. Наблюдатель должен реализовать следующий интерфейс:

public interface DiffObserver {
void diffListener(DiffType diffType, long newNodeKey, long oldNodeKey, DiffDepth depth);
void diffDone();
}

Метод diffListener в качестве первого параметра указывает тип различий между двумя узлами в каждой ревизии. Следующие два аргумента являются стабильными уникальными идентификаторами узлов сравниваемых узлов в двух ревизиях. Последний аргумент depth указывает глубину двух узлов, которые SirixDB только что сравнила.

9. Сериализация в JSON

В какой-то момент мы хотим сериализовать ресурс JSON в двоичной кодировке SirixDB обратно в JSON:

var writer = new StringWriter();
var serializer = new JsonSerializer.Builder(resourceManager, writer).build();
serializer.call();

Чтобы сериализовать ревизию 1 и 2:

var serializer = new
JsonSerializer.Builder(resourceManager, writer, 1, 2).build();
serializer.call();

И все сохраненные ревизии:

var serializer = new
JsonSerializer.Builder(resourceManager, writer, -1).build();
serializer.call();

10. Заключение

Мы увидели, как использовать низкоуровневый API курсора транзакций для управления базами данных и ресурсами JSON в SirixDB. API более высокого уровня скрывают некоторые сложности.

Полный исходный код доступен на GitHub .