
В этой статье я опишу мой опыт миграции из 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 в пассивном режиме и несколько ГБ дискового пространства для хранения данных. Когда я доставал тот большой список учреждений образования, потребление 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)
