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

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


  1. dgoncharov
    20.08.2023 08:00
    +12

    Структура ваших данных как-то совершенно не ясна. Что такое Entity и почему оно наследуется от BaseEntity? Это учреждение образования??? Если их у вас 1300, то это не "большой список". Что такое "вложенная сущность" и зачем она? В целом, сложилось впечатление, что вы попытались "забивать гвозди микроскопом", а теперь удивляетесь, что плохо получается.


    1. ilyalisov Автор
      20.08.2023 08:00

      Да, это учреждение образования. BaseEntity хранит общие поля для всех сущностей приложения - айди, даты создания и внутренний статус сущнсоти (удалена, заблокирована, активна и т.п.)
      Вложенная сущность - поле с сущностью Spring Data JPA.
      Вероятно графовые базы работают хорошо при других видах данных и других настройках спринга, однако в моем варианте при замене репозитория на neo4j, один и тот же запрос в базу выполянется на порядок дольше


      1. dgoncharov
        20.08.2023 08:00

        Я сильно подозреваю, что у вас и на исходной базе данных запрос выполняется "на порядок дольше", чем мог бы. Но точно сказать не могу, потому что структура данных по-прежнему непонятна.


        1. ilyalisov Автор
          20.08.2023 08:00

          Я избавился от вычисляемых полей и время фетчинга упало с 2,5 сек до 45-60мс на постгресе


  1. Dekmabot
    20.08.2023 08:00
    +13

    Расскажите подробнее где и как тормозят запросы?

    17 таблиц, 1300 poi на странице, 50K записей в хранилище... боюсь гадать на кофейной гуще, но кажется нагрузка не в объёме данных и проблема не в типе хранилища.

    Я бы посмотрел:

    • где именно проблема: в бд или в прослойке, так как если браузер показывает pending - это ещё не значит что проблема в бд.

    • на суммарное количество запросов в бд, возможно не решена n+1.

    • на структуру запросов, возможно join`ы оптимальнее разбить на отдельные запросы с lazy-load.

    • банальные индексы не там и не те, лишние сортировки или limit/offset.

    • на математику внутри запросов, для расчёта попадания координат в область есть стандартные методы.

    • возможно грузить стоит отдельно координаты для отображения на карте, а отдельно - детальную информацию для просмотра.


    1. dyadyaSerezha
      20.08.2023 08:00

      Согласен. Не знаю, насколько оптимально spring делает запросы к БД (случай с postgres) , но помониторить запросы со стороны БД было бы очень полезно. Ну и если автор строит карту с 50К объектами, то это в корне неверно.

      И что это за join-таблицы? Результаты запросов? Полная инфа обо всех объектах в системе? А оно прям вот нужно сразу все обо всех? "Что-то в консерватории поправить надо", мне кажется.


      1. sshikov
        20.08.2023 08:00

        Ну и если автор строит карту с 50К объектами, то это в корне неверно.

        Конечно. Хотя в принципе я показывал карту с десятками тысяч объектов одновременно (на хилом ноутбуке) — просто тут очевидно напрашивается кластеризация, потому что ни в каком масштабе их все равно все сразу не видно. Тем более на карте объекты все равно группируются вокруг населенных пунктов, так что даже естественная кластеризация по их названию (до определенного масштаба) вполне решает большую часть проблем. Даже без работы с координатами.


        1. dyadyaSerezha
          20.08.2023 08:00

          Хотя я показывал карту с тысячами объектов еще на компе с 286 процом и 1 MB RAM под Windows 3.0 (если уж меряться).

          И кстати, что автор имеет ввиду под ROM?


          1. sshikov
            20.08.2023 08:00

            Я верю (у меня был AMD A10 и 4 гига), но про другое. У автора должно хватать ресурсов, у него есть 6 гигов — но это не значит, что на экране обычного размера на карте нужно показывать 50 тыс объектов. Не будет видно ничего, будет месиво из маркеров.


            1. dyadyaSerezha
              20.08.2023 08:00

              Не будет видно ничего, будет месиво из маркеров.

              Так я об этом и написал - "в корне неверно".


          1. ilyalisov Автор
            20.08.2023 08:00

            Объем volume, занимаемый докер образом Neo4j. Он превышает гигабайт, когда в то же время у постгреса он в разы меньше при одинаковом объеме данных


            1. dyadyaSerezha
              20.08.2023 08:00

              Ок, но почему ROM? Автор вообще в курсе, что такое ROM?


      1. ilyalisov Автор
        20.08.2023 08:00

        Данный вариант работает с момента, когда объектов было несколько десятков. А так действительно надо пересмотреть логику с кластеризацией


    1. ilyalisov Автор
      20.08.2023 08:00

      Я займусь этим в ближайшее время, благодарю за наводку! Сейчас я использую стандартные репозитории с парочкой индексов, поэтому думаю, что еще спринг может подтягивать внутренности объектов целиком, что задерживает ответ


  1. Tasta_Blud
    20.08.2023 08:00
    +3

    в пику предыдущим комментаторам:

    поздравляю, вы молодец, вы попробовали и у вас не получилось. отрицательный результат тоже результат.


    1. dgoncharov
      20.08.2023 08:00
      +1

      С чем поздравлять-то? С тем, что человек несколько недель впустую потерял? Так можно проект туда-сюда мигрировать еще много раз в надежде "а вдруг получится".


      1. whoisking
        20.08.2023 08:00
        +2

        С позиции личного развития как разработчика путь вида 1. Что-то сделать 2. Что-то переделать. 3 написать статью и узнать из комментов что делал вообще всё неправильно - это очень даже неплохой вариант превращения себя в полноценного разраба, всё зависит от выводов, которые сделает автор. В комментах обозначили что копать всегда надо глубже, знания для построения проекта нужны чуть более фундаментальные, чем есть, обозначили ключевые слова, по которым гуглить. Но, естественно, эта статья скорее больше для автора, либо для тех, кто находится примерно на таком же пути. Поэтому не считаю, что зря.


        1. ilyalisov Автор
          20.08.2023 08:00

          Да, вы правы. Эта статья скорее мой первый опыт публикаций на Хабре. В комментариях можно подчерпнуть несколько идей и советов.


  1. breninsul
    20.08.2023 08:00
    +6

    Ничего не ясно, судя по описанию данных мало + всё как-то на уровне Spring Data.

    Я подозреваю, что просто необходимо несколько нативных SQL запросов с временем выполнения несколько мс. Не может 50к записей 2 секунды тащиться. Скорее всего orm делает какую-то ерунду, вытягивает вложенные объекты вложенных объектов десятками запросов.

    По поводу графовых бд - на них ноды Etherium работают, и много чего. Там тоже не может 20сек 50к записей тянуться, spring наверняка творит дичь


    1. dyadyaSerezha
      20.08.2023 08:00
      +1

      Именно это я имел ввиду в своём комментарии выше. Только не десятками, а сотнями или тысячами запросов.


      1. sshikov
        20.08.2023 08:00

        Да у автора там формула на формуле сидит, и формулой погоняет. Если все эти count(*) вдруг выполнятся при вытаскивании каждой строки — вот вам сотни тысяч запросов. Я не говорю что так и было — но это надо профилировать. В первую очередь.


    1. ilyalisov Автор
      20.08.2023 08:00

      Я согласен с вами, думаю, что постгрес более оптимизирован под работу из коробки чем neo4j в спринге. Конечно, если все написать на нативные запросы, то можно много времени выиграть, я думаю, что так и поступлю


      1. breninsul
        20.08.2023 08:00
        +1

        Тут же дело не в PostgresSQL или neo4j, а на уровне Spring|Hibernate


      1. muturgan
        20.08.2023 08:00
        +1

        Даёшь сырые запросы?


    1. aleks_raiden
      20.08.2023 08:00

      Сорян, ноды ефира работают обычно поверх самых простых (утрирую) key-value хранилищ, две самые распространенные - поверх rocksdb и mdbx


      1. breninsul
        20.08.2023 08:00

        Спасибо, буду изучать.


  1. LeshaRB
    20.08.2023 08:00
    +2

    Старая статья про Entity и Lombok
    https://habr.com/ru/amp/publications/564682/


    1. aleksandy
      20.08.2023 08:00
      +3

      Там проблемы не только с этим.

      аналог для @Formula аннотации из Spring Data JPA

      Начнём с того, что это аннотация hibernate, а не spring-data. И придумана она совсем не для того, для чего её использует автор. У него же на ровном месте N+6 запросов, даже если сущность по идентификатору запрашивать.

      Статья - хороший пример того, что получится, если делать, не понимая что и как.