Почему я пожалел о миграции на Neo4j

от автора

В этой статье я опишу мой опыт миграции из 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 в маленьких проектах?

Neo4j контейнер с 700 MB RAM сразу после запуска

Neo4j контейнер с 700 MB RAM сразу после запуска
  • Когда вы пытаетесь сделать дамп базы в 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)


ссылка на оригинал статьи https://habr.com/ru/articles/755682/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *