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