Привет, хабр! Сегодняшняя статья навеяна довольно стандартной ситуацией – существует некий «большой» объект, но для работы приложения далеко не всегда требуется загружать его полностью в память. Для решения такой проблемы существует ленивая загрузка полей. Суть её состоит в том, что загрузка поля объекта откладывается до того момента, как оно [поле] понадобится.

Объекты могут храниться в локальном хранилище, или же они могут находиться под управлением отдельного микросервиса. В рамках текущей статьи мы рассмотрим и первый, и второй вариант.

Разберем следующий пример: объекты хранятся в локальном хранилище, для работы с которым используем Spring Data.

Существует база данных со следующими таблицами: Организация, Сотрудник, Коды организации (да, коды организации могли бы быть встраиваемой сущностью, но для наглядности выделены в отдельную таблицу). Они [таблицы] представляются следующими сущностями:

Организация
@Data
@Accessors(chain = true)
@Entity
@NoArgsConstructor
@Table(name = "org")
public class Organization {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne(targetEntity = Employee.class, fetch = FetchType.LAZY)
    @JoinColumn(name = "head_id")
    private Employee head;
  
    @OneToMany(targetEntity = Employee.class, mappedBy = "organization", fetch = FetchType.LAZY)
    private Set<Employee> employees = new HashSet();
  
    @OneToOne(targetEntity = OrganizationCodes.class, fetch = FetchType.LAZY)
    @JoinColumn(name = "code_id")
    private OrganizationCodes codes;
}

Сотрудник
@Data
@Accessors(chain = true)
@Entity
@NoArgsConstructor
@Table(name = "employee")
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "first_name", nullable = false)
    private String firstName;

    @Column(name = "last_name", nullable = false)
    private String lastName;

    @ManyToOne(targetEntity = Organization.class, fetch = FetchType.LAZY)
    @JoinColumn(name = "org_id")
    private Organization organization;
}

Коды организации
@Data
@Accessors(chain = true)
@Entity
@NoArgsConstructor
@Table(name = "employee")
public class OrganizationCodes {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "inn", nullable = false)
    private String inn;
    
    @Column(name = "kpp", nullable = false)
    private String kpp;
    
    @Column(name = "ogrn", nullable = false)
    private String ogrn;
}

Как можно заметить, все поля организации (кроме идентификатора) являются «ленивыми».

Для каждой сущности написаны простые репозитории и сервисные классы.

Репозитории
@Repository
public interface OrganizationRepository extends JpaRepository<Organization,Long> {
}
@Repository
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
    Set<Employee> findByOrganization(Organization organization);
}
@Repository
public interface OrganizationCodesRepository extends JpaRepository<OrganizationCodes, Long> {
}

Сервисные классы
@Service
public class OrganizationServiceImpl implements OrganizationService {

    private final OrganizationRepository repository;

    @Autowired
    public OrganizationServiceImpl(OrganizationRepository repository){
        this.repository = repository;
    }

    @Override
    public Organization getById(long orgId) {
        return repository.getById(orgId);
    }
}

Реализация остальных сервисов не важна в рамках статьи, поэтому приведу только интерфейсы

public interface EmployeeService {
    Employee getById(long employeeId);
    Set<Employee> getByOrgId(long orgId);
}
public interface OrganizationCodesService {
    OrganizationCodes getById(long id);
}

Итак, если мы загрузим организацию и попробуем что то сделать, например, с полем head

    private String getHeadNameFromOrg(long orgId) {
        Employee head = organizationService.getById(orgId).getHead();
        return head.getFirstName();
    }

то увидим ошибку:org.hibernate.LazyInitializationException: could not initialize proxy [ru.brutforcer.org.service.entity.Organization#1] - no Session. Это потому, что данное поле является «ленивым», и просто так с ним не поработать.

Загрузить ленивое поле в Spring Data (да и вообще в Hibernate) можно следующим образом:

1) Указать аннотацию @Transactional на методе, и работать с полем в пределах одной транзакции:

    @Transactional
    public String getHeadNameFromOrg(long orgId) {
        Employee head = organizationService.getById(orgId).getHead();
        return head.getFirstName();
    }

2) Указать необходимость загрузки поля непосредственно в методе репозитория

@Repository
public interface OrganizationRepository extends JpaRepository<Organization,Long> {

    @Query("SELECT org FROM Organization org join fetch org.head where org.id = :orgId")
    Organization getByIdFetchHead(@Param("orgId") long orgId);
}

И, добавив соответствующий метод в сервис, использовать уже новый метод

    public String getHeadNameFromOrg(long orgId) {
        Employee head = organizationService.getByIdFetchHead(orgId).getHead();
        return head.getFirstName();
    }

Первый вариант довольно прост, но может создать длительную транзакцию. Это не всегда хорошо - как минимум, могут появиться проблемы с ее [транзакции] изоляцией. А еще в этом случае происходит несколько запросов в базу данных (хоть и в пределах одной транзакции).

Второй вариант лично мне нравится больше, но имеет другую проблему: что, если у нас будет не 2 ленивых поля, а десять? И нужно будет загружать их в разных комбинациях? Тогда наш репозиторий будет разрастаться огромным количеством методов.

Интерфейс сервисного класса

Вот так будет выглядеть интерфейс сервисного класса со всеми комбинациями загрузки ленивых полей, если их количество 3шт. И это только загрузка сущности по идентификатору.

public interface OrganizationService {

    Organization getById(long orgId);

    Organization getByIdFetchHead(long orgId);
    Organization getByIdFetchCodes(long orgId);
    Organization getByIdFetchEmployees(long orgId);
    
    Organization getByIdFetchHeadFetchCodes(long orgId);
    Organization getByIdFetchCodesFetchEmployees(long orgId);
    Organization getByIdFetchEmployeesFetchHead(long orgId);
    
    Organization getByIdFetchAllProperties(long orgId);
}

Если в кратце, количество сочетаний будет 2^n, где n - количество ленивых полей: для 4х полей 16, для 5 полей 32 и т.д.

Рассмотрим другую ситуацию – данные находятся под управлением другого микросервиса, общение с которым происходит посредством HTTP запросов в формате REST. Тут магия Hibernate уже не поможет.

Приведу некоторые уточнения.

  • Имеется возможность запросить каждое из «ленивых полей» отдельно.

  • При загрузке у «ленивых» объектов-полей обязательно заполнено поле id.

  • Полную реализацию методов загрузки сущностей для данного примера приводить не буду: ограничусь интерфейсами и некоторыми общими моментами.

Появляется вопрос: можно ли в запросе указать, какие поля загружать, а какие нет? Если да, то есть вариант создать кучу методов, как во втором варианте решения первого примера.

В противном случае, получается, сервис предоставляет возможность или вытянуть объект полностью «лениво» , или полностью загруженный.

Что можно в этом случае сделать? Очевидно, написать метод, который будет кидать запрос и подгружать конкретное поле. Получится примерно то же самое, что и в первом варианте, только немного (совсем чуть-чуть) сложнее.

@Service
public class OrganizationServiceImpl implements OrganizationService {

    private final OrganizationRepository repository;
    private final EmployeeService employeeService;

    @Autowired
    public OrganizationServiceImpl(OrganizationRepository repository, 
                                   EmployeeService employeeService) {
        this.repository = repository;
        this.employeeService = employeeService;
    }

    @Override
    public Organization getByIdFetchHead(long orgId) {
        Organization org = repository.getById(orgId);
        Employee head = employeeService.getById(org.getHead().getId());
        org.setHead(head);
        return org;
    }
}

Теперь загрузка происходит в 2 запроса вместо одного, и тут придется подумать – загружать ли объект жадно, но за один запрос, или только необходимое, но за два. Например, может быть такое, что два мелких запроса будут обрабатываться быстрее одного большого.

Особенно сильно в данном вопросе будет решать наличие реализованного кэша при загрузке ленивых полей: если его использование будет уместным, то вместо второго запроса значение можно брать из кэша.

Однако, у нас остается проблема: куча методов с разными комбинациями загрузки полей. Это очень неудобно, и решение может быть следующим.

Добавим в основную сущность методы для загрузки ленивых полей (в целом, они копируют поведение обычных геттеров, однако, например, если в проекте используется Project Reactor, они могут возвращать соответствующие Mono или Flux) и копирующий конструктор (т.к. мы говорим о загрузке данных из другого микросервиса, все аннотации, связанные с JPA я убрал):

@Data
@Accessors(chain = true)
@NoArgsConstructor
public class Organization {

    private Long id;
    private Employee head;
    private Set<Employee> employees;
    private OrganizationCodes codes;

    public Employee loadHead(){
        return this.head;
    }
    public Set<Employee> loadEmployees(){
        return this.employees;
    }
    public OrganizationCodes loadCodes(){
        return this.codes;
    }

    public Organization(Organization org) {
        this.id = org.getId();
        this.head = org.getHead();
        this.codes = org.getCodes();
        this.employees = org.getEmployees();
    }
}

Далее в сервисном классе перед возвратом результата переопределяем поведение методов загрузки:

@Service
public class OrganizationServiceImpl implements OrganizationService {

    private final OrganizationRepository repository;
    private final EmployeeService employeeService;
    private final OrganizationCodesService organizationCodesService;

    @Autowired
    public OrganizationServiceImpl(OrganizationRepository repository,
                                   EmployeeService employeeService,
                                   OrganizationCodesService organizationCodesService) {
        this.repository = repository;
        this.employeeService = employeeService;
        this.organizationCodesService = organizationCodesService;
    }

    @Override
    public Organization getById(long orgId) {
        Organization org = repository.getById(orgId);
        return delegate(org);
    }

    private Organization delegate(Organization org) {
        return new Organization(org){
            @Override
            public Employee loadHead() {
                if (super.getHead() != null && super.getHead().getFirstName() != null) {
                    return super.getHead();
                } else if (super.getHead() != null && super.getHead().getId() != null) {
                    Employee head = employeeService.getById(org.getHead().getId());
                    super.setHead(head);
                    return head;
                } else {
                    return null;
                }
            }

            @Override
            public Set<Employee> loadEmployees() {
                if (super.getEmployees() != null)
                    return super.getEmployees();
                else {
                    Set<Employee> employees = employeeService.getByOrgId(org.getId());
                    super.setEmployees(employees);
                    return employees;
                }
            }

            @Override
            public OrganizationCodes loadCodes() {
                if (super.getCodes() != null && super.getCodes().getInn() != null) {
                    return super.getCodes();
                } else if (super.getCodes() != null && super.getCodes().getId() != null) {
                    OrganizationCodes codes = organizationCodesService.getById(super.getCodes().getId());
                    super.setCodes(codes);
                    return codes;
                } else {
                    return null;
                }
            }
        };
    }
}

(Примечание: проверка загруженности полей head и codes, помимо обычной проверки на null, содержит проверку еще внутренних полей объектов. Это сделано, потому что по условию у данных объектов обязательно должен быть заполнен id сущности. Соответственно, вероятность того, что эти поля при ленивой загрузке равны null, стремится к нулю. Однако, если уже обязательные поля не загружены, то и объект является лениво загруженным. Так же добавлю, что проверки могут быть иными, а в каких то случаях стоит применить синхронизацию при проверке и загрузке полей таким образом).

В результате проделанных манипуляций, в любой точке кода всегда можно вызвать загрузочный метод и получить необходимое поле. Предложенное решение работает не только с данными, получаемыми из удаленных сервисов – его можно адаптировать и к работе с локальной бд.

Проблемой же является необходимость отправки нескольких запросов. Частично эту проблему сглаживает наличие кэша, однако когда данный подход стоит применять, а когда нет – зависит от конкретной ситуации.

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


  1. aleksandy
    09.08.2022 19:27

    увидим ошибку:org.hibernate.LazyInitializationException

    А вся проблема в том, что надо правильно проектировать архитектуру. И не пренебрегать явным разделением инфраструктурных и бизнес-сервисов.

    Реализация остальных сервисов не важна в рамках статьи

    Только при условии, что это исключительно сервисы бизнес-логики. Что в свете предложенных решений

    Указать аннотацию @Transactional

    и

    Указать необходимость загрузки поля непосредственно в методе репозитория

    очевидно, не так. Т.к. в примерах налицо нарушение основополагающего принципа всей слоёной архитектуры: "Нельзя отдавать внешнему слою модель из внутреннего".

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

    Вообще непонятно в чём проблема с изоляцией транзакций, можете привести пример?

    переопределяем поведение методов загрузки

    В результате код

    Organization organization = organizationService.getById(123L);
    organization.getHead();
    organization.getEmployees();
    organization.getCodes();

    Будет открывать как минимум 4 (четыре!!!) разные транзакции, что в конкуррентной среде приведёт к получению несогласованных данных.

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


    1. brutfooorcer Автор
      09.08.2022 20:23

      А вся проблема в том, что надо правильно проектировать архитектуру. И не пренебрегать явным разделением инфраструктурных и бизнес-сервисов.

      Это было указано лишь для того, что бы показать, что будет, если мы попытаемся использовать не инициализированное поле.

      очевидно, не так. Т.к. в примерах налицо нарушение основополагающего принципа всей слоёной архитектуры: "Нельзя отдавать внешнему слою модель из внутреннего".

      «Не так» - а как? Что то я не понял этой части комментария. Что вы предлагаете? Есть другие, более лучшие способы работы с ленивым полем?

      Вообще непонятно в чём проблема с изоляцией транзакций, можете привести пример?

      Чем длиннее транзакция, тем больше вероятность грязного чтения/записи данных. Если между загрузкой самого объекта и загрузкой ленивого поля (которых может быть несколько) будет некая пауза (которая может произойти даже в результате работы сборщика мусора), мы можем получить несогласованные данные при загрузке ленивых полей. Или надо более тонко управлять транзакциями, а не просто воткнуть аннотацию.

      В результате код будет открывать как минимум 4 (четыре!!!) разные транзакции, что в конкуррентной среде приведёт к получению несогласованных данных.

      Потому в статье и написано, что код «можно адаптировать» под работу с локальным хранилищем. Во-первых, такой код явно должен быть аннотирован @Transactional.  Во-вторых – я не даром написал этот подход именно при хранении данных во внешнем источнике. При чем, наиболее реальный смысл он имеет только при использовании кэша – иначе, в большинстве случаев, «жирный» запрос будет выгоднее. Без кэша при работе с локальной бд всегда будет удобнее и выгоднее использовать уже существующие средства JPA (в т.ч. описанные в статье), чем подгружать поля отдельными запросами. Но если имеет место быть кэш, то в некоторых ситуациях можно подумать.

      Ну и про точечные методы – я написал их проблему: количество.

      Подводя итог, смысл статьи – не предложить везде использовать последний подход. Смысл статьи – показать способы работы с ленивыми полями. Даже указал в конце, что применять последний подход или нет – зависит от ситуации)


      1. aleksandy
        09.08.2022 22:14

        «Не так» - а как? Что то я не понял этой части комментария.

        "Не так" тут то, что сервису, реализующему бизнес-логику (получение кода организации по её идентификатору), приходится заниматься инфраструктурными делами.

        Что вы предлагаете?

        Разделять бизнес-логику и инфраструктуру. Методы сервиса не вызываются в вакууме, это всегда какой-либо адаптер для взаимодействия с внешним миром (контроллер, например, или слушатель какой-либо очереди в брокере сообщений). Внутри адаптера должен вызываться инфраструктурный сервис, который и будет заниматься транзакциями и другими настройками окружения с последующий делегацией вызова в бизнесовый сервис, получением от него доменного объекта и конвертацией его в подходящую DTO.

        Чем длиннее транзакция, тем больше вероятность грязного чтения/записи данных.

        Если грязное чтение так критично, то, очевидно, нужно использовать блокировки и/или соответвтсвующий уровень изоляции. А от транзакции никуда в общем случае не деться по-любому, т.к. схема любой бизнес-операции в самом тупом её виде "Прочитать из БД" -> "Обработать" -> "Сохранить". Причём в случае с JPA последний пункт будет выполнен без явного на то указания, достаточно лишь изменить состояние прочитанной из БД сущности.

        реальный смысл он имеет только при использовании кэша

        Кэширование данных - тоже ничего с бизнес-логикой не имеет, этим занимаются инфраструктурные сервисы. Собственно, последняя реализация OrganizationServiceImpl и является этим самым инфраструктурным фасадом с той лишь ошибкой, что она реализует интерфейс бизнесового сервиса, вместо того, чтобы инкапсулировать его в себе.

        про точечные методы – я написал их проблему: количество

        Если хорошо продумать, то требуемое количество окажется не так и велико, т.к. довольно редко в бизнесовых сценариях бывает так, что одна и та же сущность нужна в 100500 местах, да ещё каждый раз с разным набором. Как правило, какие-то данные будут требоваться всегда, и тогда их имеет смысл тянуть одним запросом.

        P.S. Вообще если бы кто-то на заре spring-data не принял в угоду упрощения идиотское решение проаннотировать дефолтную реализацию JpaRepository @Transactional, то и никакой бы проблемы не было. Т.к. методы репозитория падали из-за отсутствия открытой транзакции, заставляя разработчика думать, а не тупо копиравать строчки из step-by-step руководств.


        1. brutfooorcer Автор
          09.08.2022 22:49

          Разделять бизнес-логику и инфраструктуру....

          Я боюсь, в моих примерах в принципе нет бизнес логики. Повторюсь, но в данной статье лишь указаны способы инициализации ленивых полей. Что бы это ни было, для того, что бы инициализировать ленивое поле, придется либо указывать @Transactional, либо создавать соответствующий метод в репозитории (или реализовывать более низкоуровневые варианты, это, конечно, тоже имеет место быть).

          Если грязное чтение так критично, то, очевидно, нужно использовать блокировки и/или соответвтсвующий уровень изоляции....

          Очевидно. Но это не отменяет написанного: длительные транзакции - зло. Мы можем избавиться от грязного чтения путем установки уровня изолированности (того же дефолтного в PostgreSQL READ_COMMITED), но появятся другие проблемы (например, невоспроизводимое чтение). Если мы поставим еще более высокий уровень изолированности - появятся проблемы с производительностью. Поэтому:

          1. Лучше, когда длительных транзакций нет, чем когда они есть.

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

          3. Всё остальное зависит от конкретной ситуации.

          Кэширование данных - тоже ничего с бизнес-логикой не имеет, этим занимаются инфраструктурные сервисы.

          Каким образом отсутствие связи между кэшированием и бизнес-логикой коррелирует с тем, что определенные подходы становятся намного эффективнее, если применяется кэширование?

          Если хорошо продумать, то требуемое количество окажется не так и велико, т.к. довольно редко в бизнесовых сценариях бывает так, что одна и та же сущность нужна в 100500 местах, да ещё каждый раз с разным набором. Как правило, какие-то данные будут требоваться всегда, и тогда их имеет смысл тянуть одним запросом.

          Возможно. Но даже в случае трех различных наборов лично мне кажется удобнее использовать последний подход, а не клепать три разных метода загрузки. Но ответ на вопрос эффективности уже зависит от конкретного кейса.

          P.S. Вообще если бы кто-то на заре spring-data не принял в угоду упрощения идиотское решение проаннотировать дефолтную реализацию JpaRepository @Transactional, то и никакой бы проблемы не было. Т.к. методы репозитория падали из-за отсутствия открытой транзакции, заставляя разработчика думать, а не тупо копировать строчки из step-by-step руководств.

          И тогда все разработчики просто помечали бы все методы @Transactional)


          1. pylyptiy
            10.08.2022 07:45

            Что бы это ни было, для того, что бы инициализировать ленивое поле, придется либо указывать @Transactional, либо создавать соответствующий метод в репозитории

            чем плох по вашему вариант с hibernate.enable_lazy_load_no_trans = true? риском постоянного грязного чтения в конкурентной среде? или есть еще подводные камни этого самокостыля от хиба?


            1. brutfooorcer Автор
              10.08.2022 07:50

              Так ведь в этом случае каждое поле грузится в отдельной транзакции. Это хорошо лишь тогда, когда разработчик вообще не хочет думать об архитектуре и действительно бездумно открывает транзакции, или если нет вообще никаких претензий приложения на производительность (что, в общем то, не отменяет первого).

              Плюсом (точнее, еще одним минусом) тут есть вероятность получить несогласованные данные даже на самых строгих уровнях изоляции, поскольку объекты достаются из разных транзакций.

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


          1. aleksandy
            10.08.2022 08:54

            И тогда все разработчики просто помечали бы все методы @Transactional)

            И это было бы хорошо. Явное - лучше неявного.

            Когда код надо/не надо пестрит @Transactional-ми, то, если разработчик не полный долбоящер, мысль, что что-то тут не так, должна появиться.


  1. gleb_l
    09.08.2022 23:23
    +1

    Исключая блобы, поиск обычно дольше выборки. Насчёт кэша - чтобы поле взялось из кэша, туда его кто-то должен положить. Инвалидировать на уровне не только строк, но и полей - плодить сложность там, где выгоды от неё не будет - ибо количество метаданных сравнится с количеством самих данных. В итоге вся затея с ленивой вычиткой полей больше похоже на натягивание абстрактной совы на такой же глобус. Гораздо эффективнее сделать например пару методов - загрузить ключи, референсы и то, что характеризует экземпляр через tostring например, и второй - загрузить полностью. Прибыль от первого будет в основном, если поля целиком покрываются индексом, по которому работает предикат, либо присутствуют в его included-колонках.


    1. brutfooorcer Автор
      10.08.2022 08:12

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

      Вот кейс: существуют некие "заявки". У них есть 15 полей, среди которых отдельными сущностями сидят: создатель, организация, предоставляющая услугу, исполнитель, статус. Создатель/исполнитель/организация довольно крупные объекты.

      Наша задача - показать пользователю заявки, в размере 100 шт. И при каждом обновлении страницы с заявками мы будем тянуть 100 шт заявок. Возможно, это не самое лучшее архитектурное решение, но это уже есть, система уже работает, и предлагать тянуть страницами поменьше уже поздно.

      Так вот, по факту, нам нужно вытянуть лишь саму заявку и ее статус. Статус представляет собой сущности в ограниченном количестве (допустим, 10 различных статусов). Они очень редко меняются, а если кто то и поменял название, то ничего страшного, что пользователь некоторое время увидит старое (и вместо статусов можно взять, например, некий тип заявки, или любое "классификационное значение" - да вообще много можно чего придумать).

      И в этом случае нам гораздо проще вытащить сначала заявки, потом проверить наличие статусов в кэше, и запросить отсутствующие статусы. При обновлении страницы нам уже не придется дозапрашивать статусы - с большой вероятностью они будут лежать в кэше, и мы будем справляться с обновлением в среднем за 1 запрос, при этом намного меньше нагружая как сеть, так и сервис, предоставляющий данные.