Мы перешли на Lightning DB (LMDB) с LevelDB более года назад для нашего кэширующего сервера, и мы были полностью впечатлены производительностью, масштабируемостью, эффективностью, целостностью и параллелизмом LMDB. Проще говоря, LMDB - вероятно, наиболее эффективное из доступных хранилищ ключ-значение, а также поддерживает многопроцессный параллелизм и имеет надежную и устойчивую к сбоям конструкцию. Первоначально нас интересовала LMDB, потому что она обеспечивала поддержку нескольких процессов, что является наиболее надежным механизмом для параллельного выполнения в Node.js (рабочие потоки доступны, но имеют много ограничений), чего не предлагает LevelDB, но мы были впечатлены преимущества производительности и дизайн, который он обеспечил.

Однако у LMDB есть ряд острых моментов, с которыми нужно иметь дело. Решая эти проблемы, мы собрали простую библиотеку lmdb-store, чтобы упростить использование LMDB в Node.js с современным идиоматическим JavaScript и реально повысить его производительность. Вдобавок мы (через lmdb-store) используем пакет node-lmdb в качестве основного JS-моста к LMDB, что является фантастическим пакетом.

Lmdb-store также основан на обещаниях и итераторах, обеспечивая чистый интерфейс, который элегантно работает с новыми операторами _1 _ / _ 2_ и запросами, которые работают с _3 _-_ 4_ циклами.

Управление размером базы данных

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

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

Выполнение фиксации производительности и пакетной обработки

LMDB обладает замечательной производительностью; сами по себе могут выполняться миллионы операций записи в секунду, если они являются пакетными. Однако существует большое узкое место в производительности, которое можно легко устранить. Если вы выполняете одну или несколько операций записи в транзакции, а затем фиксируете транзакцию в режиме синхронизации, записи могут быть легко выполнены за несколько микросекунд, но с ожиданием завершения операции синхронного ввода-вывода (ожидание диска flush требует подтверждения) может составлять несколько миллисекунд. Если вы последовательно выполняете отдельные записи в их собственных транзакциях, ожидание завершения сброса фиксации может привести к тому, что производительность легко станет в 100–1000 раз медленнее, чем сами операции записи!

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

К счастью, есть гораздо лучшее решение: пакетные транзакции на основе таймера / очереди. Помещая все действия записи в очередь и выполняя их как пакет в одной транзакции по прошествии определенного времени, мы можем выполнять действия записи асинхронно и по-прежнему использовать коммиты с синхронизацией / сбросом диска для целостности данных. И это дает удивительно масштабируемую производительность. Чем быстрее вы выполняете операции записи, тем больше операций записи объединяется в пакет для каждой фиксации транзакции и тем быстрее выполняется LMDB. Медленная часть транзакций, синхронизация диска просто становится фоновой операцией на основе таймера (в отдельном потоке, амортизируется до постоянного времени), а (невероятно небольшая) стоимость операций записи остается линейной с запросами на запись, что позволяет нам поддерживать потрясающие характеристики производительности LMDB по мере увеличения нагрузки на наше приложение.

использование

Давайте посмотрим, как начать работу с lmdb-store. Сначала мы require или import функцию open.

const { open } = require('lmdb-store');
// or
import { open } from 'lmdb-store';

Затем мы используем open для создания магазина, указывая, что мы будем использовать управление версиями:

let inventoryStore = open('inventory', {
  useVersions: true
});

А затем мы начинаем взаимодействовать с ним как с хранилищем ключей и значений:

let shoeCount = inventoryStore.get('shoes'); // synchronous
// asynchronous:
let promiseForShoeUpdate = inventoryStore.put('shoes', 3); 
promiseForShoeUpdate.then(() => {
  // if you need to be notified of when the write completes
});

А поскольку lmdb-store использует очередь событий таймера NodeJS для пакетной обработки, легко объединить несколько операций записи в одной транзакции. Каждый раз, когда последовательные записи происходят в одном и том же череде событий, они автоматически объединяются в одну транзакцию (если они являются частью одной и той же базы данных).

Обратите внимание, что lmdb-store использует двоичные значения (и, возможно, ключи), поэтому вы можете использовать предпочитаемый формат сериализации. В этом примере мы записали создание буфера, но вы можете настроить функции для сериализации и сохранения всех ваших значений как JSON, или для повышения производительности и эффективности мы бы рекомендовали использовать dpack.

Атомарные транзакции

В традиционных базах данных транзакции обычно описываются набором синхронных операций чтения и записи внутри этой транзакции, за которыми следует синхронная фиксация, которая выполняется атомарно. Это гарантирует, что операции записи, которые являются условными или зависят от данных, которые читаются во время транзакции, могут безопасно предполагать, что данные, которые были прочитаны, не будут изменены до фиксации. Любые записи атомарно зависят от состояния любых данных, которые были прочитаны. При асинхронной записи мы все еще можем выполнять атомарные операции, но нам нужно использовать несколько иной подход, чем последовательные шаги в транзакции. К счастью, lmdb-store поддерживает атомарные условные операции записи.

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

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

import { open, getLastVersion } from 'lmdb-store';
function sellShoe() {
  let shoeCount = inventoryStore.get('shoes');
  if (shoeCount == 0)
    throw new OutOfStockError();
  let version = getLastVersion();
  // update shoe count, conditionally on it still being same version
  inventoryStore.put('shoes', shoeCount - 1, version + 1, version);
}

Но что произойдет, если запись не удастся? Давайте запишем это с условным put, добавив ожидаемое предыдущее значение в качестве третьего аргумента, чтобы мы могли гарантировать, что запись происходит атомарно (также записывая это с помощью новой функции async и оператора await, чтобы упростить выполнение):

async function sellShoe() {
  do {
    let shoeCount = inventoryStore.get('shoes');
    if (shoeCount == 0)
      throw new OutOfStockError();
    let version = getLastVersion();
    // update shoe count, conditionally on old shoe count
    let result = await inventoryStore.put('shoes', showCount - 1, version + 1, version);
    // if result is 0, it completed successfully and we are done
    // otherwise the condition failed; it must have been changed
    // by another process, so let's try again
  } while (result !== 0)
}

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

Итерируемые запросы

Запросы также могут быть проблематичными с прямыми API-интерфейсами LMDB. LMDB предоставляет низкоуровневый API курсора для перебора записей в хранилищах на основе диапазона ключей. Однако, когда вы хотите выполнить запрос к базе данных для диапазона, курсоры требуют больших усилий для правильной обработки, требуя инициализации состояния курсора, перемещения курсора для каждой итерации и последующей надлежащей очистки запроса. С другой стороны, идиоматический JavaScript переместился в сторону моделирования коллекций данных с итерациями. Итерируемые объекты идеально подходят для обработки этих типов запросов, поскольку они имеют конструкцию с прогрессивной загрузкой по запросу.

Чтобы использовать повторяющиеся запросы, мы используем метод lmdb-store getRange. Здесь мы создаем запрос для всего в нашем магазине и перебираем его:

let allOfInventory = inventoryStore.getRange({});
for (let { key, value } of allOfInventory) {
  // do something with each key and/or value
}

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

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

Эффективность против производительности

Производительность базы данных часто оценивается с помощью эталонного теста с точки зрения операций с базой данных, которые могут выполняться за настенное время. Это хороший показатель, если ваша база данных должна выполнить только одну серию операций для одного пользователя. Однако в реальном мире обычно сервер обрабатывает запросы от сотен разных пользователей, и многие потоки конкурируют за ресурсы. Поэтому для использования в реальных приложениях гораздо более практичным показателем является то, сколько процессорного времени требуется для ряда операций с базой данных, а не время настенных часов. И именно здесь LMDB действительно опережает другие базы данных / хранилища ключей и значений и показывает, что LMDB демонстрирует превосходную скорость и, что более важно, эффективность, что означает лучшую реальную производительность на серверах. с многочисленными потоками и процессами, работающими вместе.

С lmdb-store легко начать работу и в полной мере использовать возможности LMDB, используя современный идиоматический JavaScript с итераторами и обещаниями для чистых и элегантных повторяемых запросов и асинхронной записи в стиле await, которые можно легко масштабировать до огромного размера базы данных и скорость с минимальными усилиями.