image


Мы — отдел большой компании, развивающий важную систему на Java SE / MS SQL / db4o. За несколько лет проект перешел от опытного образца к промышленной эксплуатации и db4o превратилась в тормоз расчета, захотелось перейти с db4o на современную noSQL-технологию. Пробы и ошибки завели далеко от первоначального замысла — от db4o отказаться удалось, но ценой компромисса. Под катом размышления и подробности реализации.


Технология db4o умерла?


На Хабре удается найти не так много публикаций про db4o. На Stackoverflow какая-то остаточная активность наподобие свежего комментария к старому вопросу или свежего вопроса без ответа. Вики вообще полагает, что текущая стабильная версия датирована 2011 годом.


Это формирует общее впечатление: технология неактуальна. Нашлось даже официальное подтверждение: Actian decided not to actively pursue and promote the commercial db4o product offering for new customers any longer.


Как db4o попала в расчет


В статье Введение в объектно-ориентированные базы данных говорится про главную фишку db4o — полное отсутствие схемы данных. Можно создать любой объект


User user1 = new User("Vasya", "123456", 25);   

и затем просто его записать в файл базы данных


db.Store(user1)

Записанный объект можно потом извлечь методом Query.execute() в том виде, в котором он был сохранен.


На старте проекта это позволило быстро обеспечить отображение аудиторского следа со всеми поданными данными, не заморачиваясь на структуру реляционных таблиц. Это помогло проекту выжить. Тогда в песочнице ресурсов было мало и сразу после окончания сегодняшнего расчета в MS SQL начинали загружаться данные для завтрашнего. Все непрерывно менялось — пойди разберись, что именно было автоматически подано ночью. А к файлу db4o можно обратиться
в отладке, извлечь снимок нужного дня и ответить на вопрос "мы все данные подавали, а вы ничего не заказали".


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


В боевой эксплуатации файл истории занимает ~ 35 Гб/день, выгрузка идет около часа. Сам по себе файл хорошо сжимается (1:10), но сжатие библиотека com.db4o.ObjectContainer почему-то не выполняет. На севере CentOS библиотека com.db4o.query.Query пишет/читает файл исключительно в один поток. Скорость работы — узкое место.


Принципиальная схема устройства


Информационная модель системы — иерархия объектов A, B, C и D. Иерархия не является деревом, для работы необходимы ссылки C1 -> B1.


ROOT 
||
| ==>A1
|    ||
|    | ==> B1 <------
|    |     ||        |
|    |     | ======> C1
|    |     |         |
|    |     |          ===> C1.$D
|    |      =======> C2
|    |                |
|     ==> B2           ==> C2.$D
|          |
 ===>A2     =======> C3
      |              |
       ==> B3         ===> C3.$D
            |          
             ======> C4
                     |
                      ===> C4.$D

Взаимодействие пользователя с сервером происходит посредством пользовательского интерфейса (GUI), работу которого обеспечивает com.sun.net.httpserver.HttpsServer, клиент и сервер обмениваются XML-документами. При первом отображении сервер присваивает пользовательскому уровню идентификатор, который в дальнейшем не меняется. Если пользователю нужна история некоторого уровня, GUI отправляет серверу завернутый в XML идентификатор. Сервер определяет значения ключей для поиска в базе, сканирует файл db4o за нужный день и извлекает в память запрошенный объект плюс все объекты, на которые тот ссылается. Строит XML-презентацию извлеченного уровня и возвращает ее клиенту.


Сканируя файл, db40 по умолчанию читает все дочерние объекты до определенной глубины, извлекая вместе с искомым объектом немаленькую иерархию. Время чтения можно уменьшить, если установить минимальную глубину активции ненужного класса Foo командой conf.common().objectClass(Foo.class).maximumActivationDepth(1).


Использование анонимных классов приводит к созданию неявных ссылок на объемлющий класс this$0. Такие ссылки db4o обрабатывает и восстанавливает корректно (но медленно).


0. Идея


Итак, у админов странное выражение на лицах, когда разговор заходит о поддержке или администрировании db4o. Извлечение данных происходит медленно, технология не очень жива. Задача: применить вместо db4o актуальную NoSQL-технологию. На глаза попалась пара Spring Data + MongoDB.


1. Лобовой подход


Первая мысль была использовать org.springframework.data.mongodb.core.MongoOperations и метод save(), потому что это похоже на com.db4o.ObjectContainer.db.Store(user1). Документация MongoDB говорит о том, что документы хранятся в коллекциях, логично представить нужные объекты системы как документы соответствующих коллекций. Еще есть @DBRef-аннотации, позволяющие реализовать связи между документами вообще в духе 3NF. Поехали.


1.1. Выгрузка. Ссылочный тип ключа


Cистема состоит из POJO-классов, спроектированных давно и без учета всех этих новых технологий. Используются поля типа Map<POJO,POJO>, есть разветвленная логика работы с ними. Сохраняю такое поле, получаю ошибку


org.springframework.data.mapping.MappingException:
Cannot use a complex object as a key value.

По этому поводу удалось найти только переписку 2011 года, в которой предлагается разработать нестандартный MappingMongoConverter. Отметил пока что проблемные поля @ Transient, еду дальше. Получилось сохранить, изучаю результат.


Сохранение происходит в коллекцию, имя которой совпадает с именем сохраняемого класса. Аннотации @DBRef я пока не использовал, так что коллекция одна, документы JSON достаточно большие и разветвленные. Замечаю, что при сохранении объекта MongoOperations проходит по всем (в том числе унаследованным) непустым ссылкам и записывает их как вложенный документ.


1.2. Выгрузка. Именованное поле или массив?


Модель системы такова, что что класс C может содержать ссылку на один и тот же класс D несколько раз. В отдельном поле defaultMode и среди прочих ссылок в ArrayList, примерно вот так

public class C {

  private D defaultMode;
  private List<D> listOfD = new ArrayList<D>();

  public class D {
    ..
  } 

  public C(){
   this.defaultMode = new D();
   listOfD.add(defaultMode);
  }
}

После выгрузки в JSON-документе будет две его копии: вложенный документ под именем defaultMode и безымянный элемент массива документов. В первом случае к документу можно обратиться по имени, во втором — по имени массива с указанием индекса. Выполнять поиск в коллекциях MongoDB можно в обоих случаях. Работая только со Spring Data и MongoDB, я пришел к выводу, что применять ArrayList можно, если осторожно; ограничений по использованию массивов особо не заметил. Особенности проявились потом, на уровне MongoDB Connector for BI.


1.3. Загрузка. Аргументы конструктора


Пробую прочитать сохраненный документ методом MongoOperations.findOne(). Загрузка объекта A из базы выдает исключение


"No property name found on entity class A to bind constructor parameter to!"

Оказалось, что класс имеет поле corpName, а конструктор — параметр String name и в теле конструктора выполняется присваивание this.corpName = name. MongoOperations требует, чтобы названия полей в классах совпадали с названиями аргументов конструктора. Если конструкторов несколько, нужно выбрать один аннотацией @PersistenceConstructor. Привожу имена полей и параметров в соответствие.


1.4. Загрузка. С$D и this$0


Внутренний вложенный класс D инкапсулирует логику поведения класса C по умолчанию и отдельно от класса C смысла не имеет. Экземпляр D создается для каждого экземпляра С и наоборот — для каждого экземпляра D существует породивший его экземпляр С. У класса D есть еще потомки, которые реализуют альтернативные модели поведения и могут храниться в списке listOfD. Конструктор классов-потомков D требует наличия уже существующего объекта С.


Кроме вложенных внутренних, в системе используются анонимные внутренние классы. Как известно, и те, и другие содержат в себе неявную ссылку на экземпляр объемлющего класса. То есть в составе каждого экземпляра объекта C.D компилятор создает ссылку this$0, которая указывает на родительский объект C.


Cнова пробую прочитать сохраненный документ из коллекции и получаю исключение


"No property this$0 found on entity class С$D to bind constructor parameter to!"

Вспоминаю о том, что методы класса D вовсю используют ссылки C.this.fieldOfClassC, а потомки класса D требуют указать конструктору уже созданный экземпляр C в качестве аргумента. То есть мне нужно обеспечить определенный порядок создания объектов в MongoOperations, чтобы в конструкторе D можно было указать родительский объект C. Cнова нестандартный MappingMongoConverter?


Может быть, не использовать анонимные классы и сделать внутрение классы обычными? Доработка, точнее переработка архитектуры уже внедренной системы — ничего себе задачка...


2. Подход со стороны 3NF/@DBRef


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


2.1. Выгрузка. @DBRef — это красиво


Класс С содержит несколько ссылок на D. Если ссылку defaultMode и ArrayList отметить как @DBRef, то размер документа уменьшится, вместо громадных вложенных документов будут аккуратные ссылки. В json-документе коллекции C появляется поле

"defaultMode" : DBRef("D", ObjectId("5c496eed2c9c212614bb8176"))

В базе MongoDB автоматически создается коллекция D и в ней документ с полем


"_id" : ObjectId("5c496eed2c9c212614bb8176")

Все просто и красиво.


2.2. Загрузка. Констуктор класса D


Работая со ссылками, объект C знает, что дефолтный объект D создается ровно один раз. Если нужно обойти все объекты D, кроме дефолтного, достаточно сравнить ссылки:


private D defaultMode;
private ArrayList<D> listOfD;
for (D currentD: listOfD){
    if (currentD == defaultMode) continue;
    doSomething(currentD);
}

Вызываю findOne(), изучаю свой класс C. Оказывается, MongoOperations читает json-документ и вызывает конструктор D для каждой встреченной аннотации @DBRef, каждый раз создавая новый объект. Получаю странную конструкцию — две разных ссылки на D в поле defaultMode и в массиве listOfD, там, где ссылка должна быть одна и та же.


Изучаю мнение сообщества: "Dbref in my opinion should be avoided when work with mongodb". Еще одно соображение в том же духе из официальной документации: the denormalized data model where related data is stored within a single document will be optimal to resolve DBRefs, your application must perform additional queries to return the referenced documents.


Упомянутая страница документации говорит в самом начале: "For many use cases in MongoDB, the denormalized data model where related data is stored within a single document will be optimal". Для меня написано?


Фокус с конструктором подсказывает, что мыслить как в реляционной СУБД не надо. Выбор такой:


  • если указать @DBRef:
    • будет вызван конструктор для каждой аннотации и создано несколько одинаковых объектов;
    • MongoOperations найдет и прочитает все документы из всех связанных колекций. Будет запрос к индексу по ObjectId и затем чтение из многих коллекций (большой) базы;
  • если не указывать, то будет сохранен "ненормализованный" json с повторениями одних и тех же данных.

Отмечаю для себя: можно не полагаться на @DBRef, а использовать поле типа ObjectId, заполняя его вручную. В этом случае вместо


"defaultMode" : DBRef("D", ObjectId("5c496eed2c9c212614bb8176"))

json-документ будет содержать


"defaultMode" : ObjectId("5c496eed2c9c212614bb8176")

Автоматической загрузки не будет — MongoOperations не знает, в какой коллекции искать документ. Загружать документ нужно будет отдельным (ленивым) запросом с указанием коллекции и ObjectId. Одиночный запрос должен отдавать результат быстро, к тому же для каждой коллекции по ObjectId создается автоматический индекс.


2.3. Ну и что теперь?


Промежуточные итоги. Быстро и просто реализовать функционал db4o на MongoDB не получилось:


  • неясно, как использовать пользовательский POJO в качестве ключа списка Key — Value;
  • неясно, как задать порядок создания объектов в MappingMongoConverter;
  • неясно, следует ли выгружать "ненормализованный" документ без DBRef и нужно ли придумывать свой механизм ленивой инициализации.

Можно добавлять ленивую загрузку. Можно пытаться сделать MappingMongoConverter. Можно изменять существующие конструкторы/поля/списки. Но там многолетние наслоения бизнес-логики — неслабая переделка и риск никогда не пройти тестирование.


Компромиссный выход: сделать новый механизм сохранения данных под решаемую задачу, сохранив механизм взаимодействия с GUI.


3. Попытка третья, опыт первых двух


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


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


3.1. Данные пользовательских презентаций


Для построения презентаций пользовательских уровней в системе есть специальный класс Viewer. Метод Viewer.getXML() получает на вход уровень, извлекает из него необходимые числовые и строковые значения и формирует XML.


Если пользователь попросил показать уровень сегодняшнего расчета, то уровень будет найден в оперативной памяти. Чтобы показать расчет из прошлого, метод com.db4o.query.Query.execute() найдет уровень в файле. Уровень из файла почти не отличается от только что созданного и Viewer построит презентацию, не замечая подмены.


Для решения моей задачи нужен посредник между уровнем расчета и его презентацией — каркас презентации (Frame), который будет хранить данные и строить по имеющимся данным XML. Цепочка действий для построения презентации станет длинее, каждый раз будет генерироваться фрейм и уже фрейм будет генерировать XML:


 было: <уровень расчета> -> Viewer.getXML()
стало: <уровень расчета> -> Viewer.getFrame() -> Frame.getXML()

При сохранении истории нужно будет построить фреймы всех уровней и записать в базу.


3.2. Выгрузка


Задача оказалась относительно несложной и проблем с ней не возникло. Повторяя структуру XML-презентации, фрейм получил рекурсивное устройство в виде иерархии элементов с полями String, Integer и Double. Фрейм запрашивает getXML() у всех своих элементов, собирает в единый документ и возвращает. MongoOperations прекрасно справился с рекурсивной природой фрейма и новых вопросов по ходу работы не задал.


Наконец-то все взлетело! Движок WiredTiger по умолчанию сжимает коллекции документов MongoDB, на файловой системе выгрузка заняла ~ 3,5 Гб в день. Десятикратное уменьшение по сравнению с db4o, неплохо.


Сперва выгрузка была устроена просто — рекурсивный обход дерева уровней, MongoOperations.save() для каждого. Такая выгрузка заняла 5,5 часов, и это при том, что построение презентаций подразумевает только чтение объектов. Добавляю многопоточности: рекурсивно обойти дерево уровней, разбить все имеющиеся уровни на пакеты некоторого размера, создать реализации Callable.call() по числу пакетов, в каждую передать свой пакет и выполнить это все через ExecutorService.invokeAll().


MongoOperations снова не задал вопросов и прекрасно справился с многопоточным режимом. Опытным путем подобрал размер пакета, дающий наилучную скорость выгрузки. Получилось 15 минут для пакета в 1000 уровней.


3.3. Mongo BI Connector, или как людям с этим работать


Язык запросов MongoDB велик и могуч, я неизбежно получил опыт работы с ним, добравшись до этого места. Консоль поддерживает JavaScript, можно писать красивые и мощные конструкции. Это с одной строны. С другой, я могу сломать мозг доброй половине коллег-аналитиков запросом


db.users.find( { numbers: { $in: [ 390, 754, 454 ] } } );

вместо привычного


SELECT * FROM users WHERE numbers IN (390, 754, 454)

На помощь приходит MongoDB Connector for BI, посредством которого можно представить документы коллекций в табличном виде. База MongoDB называется документоориентированной, сама по себе представлять иерархию полей/документов в табличном виде не умеет. Для работы коннектора требуется описать структуру будущей таблицы в отдельном файле .drdl, формат которого сильно напоминает yaml. В файле надо указать соответствие между полем реляционной таблицы на выходе и путем к полю документа JSON на входе.


3.4. Особенности использование массивов


Выше говорилось о том, что для самой MongoDB особой разницы между массивом и полем нет. С точки зрения коннектора массив сильно отличается от именованного поля; пришлось даже сделать рефакторинг уже готового класса Frame. Массив документов стоит использовать только тогда, когда необходимо часть информации вынести в связанную таблицу.


Если JSON-документ представляет собой иерархию именованных полей, то к любому полю можно обратиться указанием пути от корня документа через точку, например x.y. Если в файле DRDL задается соответствие x.y => fieldXY, то в таблице на выходе окажется столько строк, сколько документов в коллекции на входе. Если в каком-то документе поля x.y нет, в соответствующей строке таблицы будет стоять NULL.


Предположим, что у нас есть база MongoDB под названием Frames, в базе есть коллекция A, и в эту коллекцию MongoOperations записал два экземпляра класса А. Получились вот такие документы: первый


{   
"_id": ObjectId("5cdd51e2394faf88a01bd456"),
  "x": { "y": "xy string value 1"},
"days": [{ "k": "0",
           "v": 0.0 }, 
         { "k": "1",
           "v": 0.1 }],
"_class": "A"
}

и второй (ObjectId отличается последней цифрой):


{   
"_id": ObjectId("5cdd51e2394faf88a01bd457"),
  "x": { "y": "xy string value 2"},
"days": [{ "k": "0",
           "v": 0.3 }, 
         { "k": "1",
           "v": 0.4 }],
"_class": "A"
}

Обращаться к элементам массива по индексу BI connector не умеет, и просто так извлечь, например, поле days[1].v из массива в таблицу нельзя. Вместо этого коннектор может представить каждый элемент массива days как строку в отдельной таблице посредством оператора $unwind. Эта отдельная таблица будет связана с исходной отношением один-ко-многим через идентификатор строки. В нашем примере определены таблицы tableA для документов коллекции и tableA_days для документов массива days. Файл .drdl выглядит так:


schema:
- db: Frames
  tables:
  - table: tableA
    collection: A
    pipeline: []
    columns:
    - Name: _id
      MongoType: bson.ObjectId
      SqlName: _id
      SqlType: objectid
    - Name: x.y
      MongoType: string
      SqlName: fieldXY
      SqlType: varchar
  - table: tableA_days
    collection: A
    pipeline:
    - $unwind:
        path: $days
    columns:
    - Name: _id         # внешний ключ
      MongoType: bson.ObjectId
      SqlName: tableA_id
      SqlType: objectid
    - Name: days.k
      MongoType: string
      SqlName: tableA_dayNo
      SqlType: varchar
    - Name: days.v
      MongoType: string
      SqlName: tableA_dayVal
      SqlType: varchar

Содержимое таблиц будет таким: таблица tableA


_id fieldXY
5cdd51e2394faf88a01bd456 xy string value 1
5cdd51e2394faf88a01bd457 xy string value 2

и таблица tableA_days


tableA_id tableA_dayNo tableA_dayVal
5cdd51e2394faf88a01bd456 0 0.0
5cdd51e2394faf88a01bd456 1 0.1
5cdd51e2394faf88a01bd457 0 0.3
5cdd51e2394faf88a01bd457 1 0.4

Итого


Реализовать задачу в исходной постановке не удалось, нельзя просто так взять и заменить db4o на MongoDB. Автоматически восстанавливать любой объект, как db4o, MongoOperations не умеет. Наверное, это можно сделать, но трудозатраты будут несравнимы с вызовом методов store/query библиотеки db4o.


Аудиторский след. Db4o — очень полезный инструмент на старте проекта. Можно просто записать объект, затем восстановить его и при этом никаких забот и таблиц. Все это с важной оговоркой: если потребуется изменить иерархию классов (добавить класс E между A и B), то вся ранее сохраненная информация становится нечитаемой. Но для старта проекта это не очень важно, пока нет большого накопленного массива старых файлов.


Когда появился достаточный опыт работы с MongoOperations, написание выгрузки не вызвало проблем. Написать новый код под фреймворк оказывается гораздо проще, чем переделать старый, к тому же поставленный на продакшн.

Комментарии (2)


  1. usharik
    26.07.2019 15:40

    развивающий важную систему на Java SE / MS SQL / db4o
    Понятно, что шучу и придираюсь, но неужели у вас прям Java SE без капли Java EE?)


    1. vtch Автор
      26.07.2019 16:44

      Без шуток, настоящий old school Java SE