В этой статье я опишу мой опыт миграции из Postgres на Neo4j в своем проекте.
Содержание:
Предыстория
Как я мигрировал
Как я понял, что что-то идет не так
Выводы
Предыстория
В этой статье описывается мой PET-проект об отзывах об учреждениях образования и сотрудниках. Изначально, он был написан на Postgres, однако некоторые запросы занимали слишком много времени, чтобы достать объекты, и join-таблицы становились все больше и больше, поэтому я начал думать о другом типе базы данных.
Это все началось, когда я услышал о графовых базах данных и подумал "А что если попробовать это в своем проекте, у меня есть джоины и вложенные объекты, поэтому может быть достаточно хорошо, чтобы использовать графы". После этих мыслей я нашел часовое видео на YouTube, где автор показывал как использовать Neo4j и Cypher запросы. Я открыл новый тикет в репозитории и начал замещать Postgres. Я был восхищен и в предвкушении использования графовых баз данных.
ЗАМЕЧАНИЕ: Я не кастомизировал JPA или Neo4j-OGM, поэтому я сравниваю решения из коробки. Я согласен, что я мог получить другие результаты, если бы кастомизировал какие-нибудь настройки.
Как я мигрировал
Я использую Spring Boot в своем проекте, поэтому мне необходимо добавить несколько новых зависимостей и заменить старые Spring Data JPA аннотации на Neo4j-OGM. Это было сложно, потому что я не мог использовать Postgres и Neo4j в одном монолитном приложении одновременно, поэтому мне пришлось мигрировать все сущности. У меня их 17. Изначально, это было достаточно интересно, стоит лишь заменить аннотации на другие, но через несколько часов я остановился.
Вот пример Entity
класса с Spring Data JPA и Neo4j-OGM:
@jakarta.persistence.Entity
@Table(name = "entities", indexes = {
@Index(name = "idx_entity_name", columnList = "name"),
@Index(name = "idx_entity_type", columnList = "type")
})
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class Entity extends BaseEntity {
@Column(name = "type")
@Enumerated(EnumType.STRING)
private Type type;
@Column(name = "name", length = 1024)
private String name;
@Column(name = "abbreviation")
private String abbreviation;
@Column(name = "country")
@Enumerated(value = EnumType.STRING)
private Country country;
@Column(name = "region")
@Enumerated(value = EnumType.STRING)
private Region region;
@Column(name = "district")
@Enumerated(value = EnumType.STRING)
private District district;
@Column(name = "address")
private String address;
@Column(name = "site_URL")
private String siteURL;
@ManyToOne(fetch = FetchType.LAZY)
private User author;
@Column(name = "image_URL")
private String imageURL;
@ManyToOne(fetch = FetchType.LAZY)
private Entity parentEntity;
@Formula("""
(SELECT COUNT(*)
FROM reviews r
WHERE r.entity_id = id AND r.status='ACTIVE')
""")
private Integer reviewsAmount;
@Formula("""
(SELECT COUNT(DISTINCT r.author_id)
FROM reviews r
WHERE r.entity_id = id AND r.status='ACTIVE')
""")
private Integer peopleInvolved;
@Formula("""
(SELECT COALESCE((SELECT SUM(r.mark)
FROM reviews r
WHERE r.entity_id = id AND r.status = 'ACTIVE'), 0))
""")
private Integer rating;
@Formula("""
(SELECT COUNT(*)
FROM entity_reports r
WHERE r.entity_id = id and r.status = 'ACTIVE')
""")
private Integer reportCounter;
private String coordinates;
@Formula("""
(SELECT COUNT(*) FROM employees_entities e WHERE e.entities_id = id)
""")
private Integer employeesAmount;
@Formula("""
(SELECT COUNT(*) FROM views v WHERE v.entity_id = id)
""")
private Integer viewsAmount;
public enum Type {
...
}
public enum SortType {
...
}
}
@Node("Entity")
@Data
@EqualsAndHashCode(callSuper = true)
@AllArgsConstructor
public class Entity extends Neo4jBaseEntity {
private Type type;
private String name;
private String abbreviation;
private Country country;
private Region region;
private District district;
private String address;
private String siteURL;
@Relationship(type = "IS_AUTHOR",
direction = Relationship.Direction.INCOMING)
private User author;
private String imageURL;
@Relationship(type = "IS_CHILD",
direction = Relationship.Direction.OUTGOING)
private Entity parentEntity;
private Integer reviewsAmount;
private Integer peopleInvolved;
private Integer rating;
private String coordinates;
private Integer employeesAmount;
private Integer viewsAmount;
public enum Type {
...
}
public enum SortType {
...
}
}
Все мои сущности наследуются от BaseEntity
класса. Он содержит id, даты создания и изменения и enum статус. Это не так сложно использовать Neo4j с такой архитектурой - можно добавить @Node
аннотацию на родительский и дочерний классы, тогда наследник будет иметь оба лейбла.
Первое, что оказалось тяжелым - найти аналог для @Formula
аннотации из Spring Data JPA, у меня есть несколько вычисляемых полей. Очень легко иметь такие поля с JPA, они вычисляются с помощью SQL запросы, поэтому у меня нет необходимости думать об их инициализации. В Neo4j я не нашел такой функциональности, поэтому я создал тикет в Neo4j-OGM репозитории. В начале я решил замокать данные поля.
Второе, что оказалось тяжелым - мигрировать 50 тысяч строк из Postgres в Neo4j. Я создал открытый, XML-конфигурируемый инструмент миграции. Это заняло несколько недель для дизайна и реализации данного инструмента для моей архитектуры базы данных. После этого, я прогнал скрипты и получил полностью мигрированную базу данных со всеми связями и узлами. Я не задумывался о сильной оптимизации, поэтому это заняло несколько минут для чтения и записи данных.
Я запустил приложение, и ... получил исключение, LocalDateTime не может быть распарсен без указания конвертера. Я написал его и запустил приложение снова.
Как я понял, что что-то идет не так
На главной странице у меня есть карта, которая показывает учреждения образования. Я достаю их все (около 1300) из базы и React перерисовывает карту после загрузки всех сущностей. С Postgres это заняло несколько секунд, что было не так и плохо. Но потом я не увидел своей карты. Я открыл вкладку Network и перезагрузил страницу снова, запрос отправился на бэкенд и я увидел "Pending..."...
Это заняло 23 секунды (!) для того, чтобы достать все сущности с помощью стандартногоNeo4jRepository
метода. Это было непозволительно долго.
Окей, я могу использовать кэш и презагружать большие сущности, поэтому такие вещи могут быть исключены.Второй удар я получил, когда попробовал обновить сущность с вложенной сущностью. С Spring Data JPA я могу положиться на то, что Postgres перепишет сущность, однако не тронет ни одно поле вложенной сущности. Это все из-за того, что в таблице сущностей я храню внешний ключ на вложенную сущность, поэтому для Postgres не важно была ли обновлена внутренняя сущность. Я использую такую функциональность для оптимизации запросов из фронтенда. Я устанавливаю пустой внутренний объект только с
id
полем, и JPA успешно не трогает внутренний объект. Neo4j удаляет все поля внутреннего объекта в базе данных, которые не были представлены во время сохранения родительского объекта.
Мне нужно проставлять все внутренние объекты в каждом методе, который обновляет внешний объект в базе данных.В коде из начала статьи вы можете увидеть, что
Entity
класс имеетUser
поле, называемоеauthor
.Давайте посмотрим на таблицу того, что Postgres и Neo4j будут делать, когда я сохраняю
Entity
объект в случае, если поля автора null или объект автора имеет толькоid
поле не равное null.
Postgres | Neo4j | |
null | удалит внешний ключ | удалит связь |
только id != null | не тронет пользователя | очистит все поля пользователя |
Я правда не хочу проверять все методы приложения и проставлять все внутренние объекты в каждом запросе.
Мой Postgres Docker контейнер использует около 70 МБ RAM для хранения 50 тысяч строк. Контейнер Neo4j использует около 700 МБ RAM в пассивном режиме и несколько ГБ ROM для хранения данных. Когда я доставал тот большой список учреждений образования, потребление RAM Neo4j выросло до 6 ГБ. Разве этого не достаточно, чтобы не использовать Neo4j в маленьких проектах?
Когда вы пытаетесь сделать дамп базы в Postgres, вы легко можете сделать это с помощью простой команды, пока он работает. Но здесь вы должны остановить базу данных и только тогда запустить дамп. Это удобно? Я так не считаю.
У меня есть
EntityManager
в JPA, поэтому я могу писать кастомные предикаты для сложной пагинации, сортировки и фильтрации. В Neo4j есть что-то похожее с Cypher запросами, но я не могу сортировать результат по вычисляемым полям (что легко делается в JPA), пока с EntityManager я могу.Вот как я могу сортировать сущности во время фильтрации по любому полю, даже вычисляемому.
Order order = switch (criteria.getSortType()) {
case NAME -> criteriaBuilder.asc(entityRoot.get("name"));
case RATING -> criteriaBuilder.desc(entityRoot.get("rating"));
case AVERAGE_RATING -> {
Expression<Number> expression = criteriaBuilder.quot(
entityRoot.get("rating").as(Double.class),
entityRoot.get("reviewsAmount")
);
yield criteriaBuilder.desc(
criteriaBuilder.selectCase()
.when(
criteriaBuilder.equal(
entityRoot.get("reviewsAmount"),
0
),
0.0)
.otherwise(expression)
);
}
case EMPLOYEES_AMOUNT ->
criteriaBuilder.desc(entityRoot.get("employeesAmount"));
case REVIEWS_AMOUNT ->
criteriaBuilder.desc(entityRoot.get("reviewsAmount"));
case VIEWS_AMOUNT ->
criteriaBuilder.desc(entityRoot.get("viewsAmount"));
default -> null;
};
Выводы
Я видел много публикаций о сравнении производительности Neo4j vs Postgres.
Здесь, на официальном сайте Neo4j показано, что Neo4j почти в бесконечно раз быстрее, чем MySQL.
Эта публикация показывает много интересной информации о том, как Neo4j проигрывает в производительности против Postgres.
Если честно, когда я увидел последнюю статью, то я подумал, что это ошибка и Neo4j реально быстрее и произодительнее. Но я ошибся.
В моем личном эксперименте я увидел, что в простом Spring Boot приложение с низкой глубиной вложенности объектов Neo4j хуже, чем Postgres. Мы можем улучшить производительность с помощью кастомных запросов, но я ясно вижу, что мой проект на Postgres намного быстрее и лучше, чем на графовой базе данных.
Я не увидел никаких преимуществ пока использовал Neo4j, я получил опыт, но сейчас пришло время мигрировать проект назад на Postgres.
Я буду рад обсудить все, что вы думаете об этой статье в комментариях или в Telegram (realhumanmaybe)