Мы — отдел большой компании, развивающий важную систему на 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, написание выгрузки не вызвало проблем. Написать новый код под фреймворк оказывается гораздо проще, чем переделать старый, к тому же поставленный на продакшн.
usharik
vtch Автор
Без шуток, настоящий old school Java SE