Programming
.NET
NoSQL
C#
April 2017 25

В поисках быстрого локального хранилища

Текст этой статьи также доступен на английском.

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


Контекст задачи


Я работаю в команде, которая занимается разработкой средств разработки для разработчиков реляционных баз данных (SqlServer, MySql, Oracle), среди них как отдельные приложения, так и те, что встраиваются в такой 32 битный “дредноут” как Microsoft Management Studio.

Задача


Восстановить документы открытые в IDE на момент закрытия при следующем запуске.

Usecase


Быстро закрыть IDE перед уходом домой, не думая о том, какие документы сохранены, а какие нет. При следующем запуске среды получить тоже окружение, что было на момент закрытии и продолжить работу.

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

Разбор задачи


Похожая функция есть в браузерах. Там им, с технической точки зрения, живется куда проще: нужно всего-то сохранить пачку URL адресов, а даже у меня редко бывает более сотни вкладок, сам URL это в среднем всего две сотни символов. Таким образом мы хотим получить поведение подобное тому, что есть в браузере, но хранить нам нужно содержимое документа целиком. Получается, что нам нужно куда-то часто и быстро сохранять все документы пользователя. Усложнял задачу и тот факт, что люди иногда работают с SQL не так как с другими языками. Если я, как разработчик на С#, напишу класс более чем на тысячу строк кода, то я поеду в лес в багажнике и по частям это вызовет много вопросов и недовольства, в мире же SQL наряду с крошечными запросами на 10-20 строк существуют монструозные дампы баз, редактировать которые очень трудоемко, а значит пользователи захотят, чтобы их правки были в безопасности.

Требования к хранилищу


Проанализировав задачу сформулировали следующие требования к хранилищу:

  1. Это должно быть встраиваемое легковесное решение.
  2. Скорость записи.
  3. Возможность многопроцессорного доступа. Требование не критичное, так как мы могли бы и сами обеспечить его с помощью объектов синхронизации, но иметь это из коробки было бы чертовски приятно.

Претенденты на роль


Первый лобовой и топорный вариант: хранить все в папке, где-нибудь в AppData.
Очевидный вариант это SQLite. Стандарт в области встраиваемых баз данных. Очень обстоятельный и популярный проект.

Третьим стала база LiteDB. Первый ответ гугла на вопрос: “embedded database for .net”

Первый взгляд


Файловая система — файлы есть файлы. За ними придется следить, им придется придумывать имена. Помимо содержимого файла нужно хранить и небольшой набор свойств(оригинальный путь на диске, строка соединения, версия IDE в которой он был открыт), а значит придется или создавать по два файла на один документ или придумывать формат, дабы отделить свойства от содержимого.

SQLite — классическая реляционная база данных. База представлена одним файлом на диске. На этот файл накатывается схема данных, после чего с ней придется взаимодействовать средствами SQL. Можно будет создать две таблицы: одну для свойств, другую для контента, на случай если будут задачи где понадобится одно без другого.

LiteDB — нереляционная база. Как и в SQLite база представлена одним файлом. Полностью написана на С#. Подкупающая простота использования: всего лишь нужно отдать библиотеке объект, а сериализацию она уже берет на себя.

Performance тест


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

В файлы писал через FileStream со стандартным размером буфера.

В SQLite был один нюанс на котором считаю нужным заострить внимание. Мы не могли складывать всё содержимое документа(выше писал, что они могут быть действительно большими) в одну ячейку базы данных. Дело в том, что в целях оптимизации мы храним текст документа построчно, а это значит, что для того чтобы сложить текст в одну ячейку нам нужно было бы слить весь текст в одну строку, чем удвоить, количество используемой оперативной памяти. Другую сторону той же проблемы получили бы и на чтении данных из базы. Поэтому в SQLite была отдельная табличка, где данные хранились построчно и были связаны по внешнему ключу с таблицей, где лежали лишь свойства документов. Кроме того, получилось немного ускорить базу, вставляя данные пачками по несколько тысяч строк в режиме синхронизации OFF, без журналирования и в рамках одной транзакции(этот трюк я подсмотрел здесь и здесь).

В LiteDB просто отдавался объект, у которого одним из свойств был List<string> и библиотека сама это сохраняла на диск.

Еще во время разработки тестового приложения я понял, что мне больше нравится LiteDB, дело в том, что тестовый код для SQLite занимал более 120 строк, а код решающий ту же задачу для LiteDB менее 20.

Генерация тестовых данных
FileStrings.cs
    internal class FileStrings {

        private static readonly Random random = new Random();

        public List<string> Strings {
            get;
            set;
        } = new List<string>();

        public int SomeInfo {
            get;
            set;
        }

        public FileStrings() {
        }

        public FileStrings(int id, int minLines, decimal lineIncrement) {

            SomeInfo = id;
            int lines = minLines + (int)(id * lineIncrement);
            for (int i = 0; i < lines; i++) {

                Strings.Add(GetString());
            }
        }

        private string GetString() {

            int length = 250;
            StringBuilder builder = new StringBuilder(length);
            for (int i = 0; i < length; i++) {

                builder.Append(random.Next((int)'a', (int)'z'));
            }
            return builder.ToString();
        }
    }

Program.cs
            List<FileStrings> files = Enumerable.Range(1, NUM_FILES + 1)
              .Select(f => new FileStrings(f, MIN_NUM_LINES, (MAX_NUM_LINES - MIN_NUM_LINES) / (decimal)NUM_FILES))
              .ToList();

SQLite
 private static void SaveToDb(List<FileStrings> files) {

      using (var connection = new SQLiteConnection()) {
        connection.ConnectionString = @"Data Source=data\database.db;FailIfMissing=False;";
        connection.Open();
        var command = connection.CreateCommand();
        command.CommandText = @"CREATE TABLE files
(
    id INTEGER PRIMARY KEY,
    file_name TEXT
);
CREATE TABLE strings
(
    id INTEGER PRIMARY KEY,
    string TEXT,
    file_id INTEGER,
    line_number INTEGER
);
CREATE UNIQUE INDEX strings_file_id_line_number_uindex ON strings(file_id,line_number);
PRAGMA synchronous = OFF;
PRAGMA journal_mode = OFF";
        command.ExecuteNonQuery();

        var insertFilecommand = connection.CreateCommand();
        insertFilecommand.CommandText = "INSERT INTO files(file_name) VALUES(?); SELECT  last_insert_rowid();";
        insertFilecommand.Parameters.Add(insertFilecommand.CreateParameter());
        insertFilecommand.Prepare();

        var insertLineCommand = connection.CreateCommand();
        insertLineCommand.CommandText = "INSERT INTO strings(string, file_id, line_number) VALUES(?, ?, ?);";
        insertLineCommand.Parameters.Add(insertLineCommand.CreateParameter());
        insertLineCommand.Parameters.Add(insertLineCommand.CreateParameter());
        insertLineCommand.Parameters.Add(insertLineCommand.CreateParameter());
        insertLineCommand.Prepare();

        foreach (var item in files) {
          using (var tr = connection.BeginTransaction()) {
            SaveToDb(item, insertFilecommand, insertLineCommand);
            tr.Commit();
          }
        }
      }
    }

    private static void SaveToDb(FileStrings item, SQLiteCommand insertFileCommand, SQLiteCommand insertLinesCommand) {

      string fileName = Path.Combine("data", item.SomeInfo + ".sql");

      insertFileCommand.Parameters[0].Value = fileName;


      var fileId = insertFileCommand.ExecuteScalar();

      int lineIndex = 0;
      foreach (var line in item.Strings) {

        insertLinesCommand.Parameters[0].Value = line;
        insertLinesCommand.Parameters[1].Value = fileId;
        insertLinesCommand.Parameters[2].Value = lineIndex++;
        insertLinesCommand.ExecuteNonQuery();
      }
    }

LiteDB
        private static void SaveToNoSql(List<FileStrings> item) {

            using (var db = new LiteDatabase("data\\litedb.db")) {
                var data = db.GetCollection<FileStrings>("files");
                data.EnsureIndex(f => f.SomeInfo);
                data.Insert(item);
            }
        }


В таблице приведены средние результаты нескольких прогонов тестового кода. При измерении статистическое отклонение было незначительным.


Нас не удивила победа LiteDB над SQLite, хоть и удивил порядок этой победы. В полный шок меня повергла победа LiteDB над файлами. Немного исследовав репозиторий библиотеки я, например, нашел очень грамотно реализованную постраничную запись на диск, на этом и успокоился, хоть и уверен это лишь один из многих performance-tricks, которые там используются. Еще хочу обратить внимание на то, как быстро деградирует скорость доступа к файловой системе, когда файлов в папке становится действительно много.

Для разработки это feature была выбрана LiteDB, о чем в дальнейшем мы жалели довольно редко. Спасало, что библиотека написана на родном для всех c# и если что-то было не до конца ясно, то всегда можно было почитать в исходниках.

Недостатки


Помимо выше приведенных преимуществ LiteDB над конкурентами по мере разработки стали всплывать и недостатки, большинство из которых можно списать на молодость библиотеки. Начав использовать библиотеку слегка за рамками “обычного” сценария нашли несколько проблем(#419, #420, #483, #496) Автор библиотеки всегда очень быстро отвечал на вопросы, большинство проблем очень быстро исправлялись. Сейчас осталась только одна(и пусть статус closed вас не смущает). Это проблема конкурентного доступа. По всей видимости где-то в глубине библиотеки спрятался очень противный race-condition. Для себя мы этот баг обошли довольно интересным способом, о чем я планирую написать отдельно.

Еще стоит упомянуть об отсутствии удобного редактора и просмотрщика. Есть LiteDBShell, но это для фанатов консоли.

UPD: недавно нашелся инструмент

Резюме


Мы построили большую и важную функциональность поверх LiteDB, а сейчас ведется разработка еще одной крупной feature где тоже будем использовать эту библиотеку. Для тех, кто сейчас ищет in-process базу для своих нужд предлагаю посмотреть в сторону LiteDB и того как она ляжет на ваши задачи, ведь нет никакой гарантии, что то, что сработало для одного, так же хорошо сработает для чего-то совершенно другого.
+4
10.6k 46
Comments 22
Top of the day