Пакетная обработка (batch processing) в Hibernate

Как уже говорилось, в конце транзакции происходит сброс (flush) накопившихся изменений в базу данных. При этом команды insert, update, delete в норме отправляются в базу данных по сети поодиночке.
Но если одинаковых команд много, то их выгодно отправить в базу вместе — пакетом, чтобы сэкономить сетевые ресурсы. Рассмотрим, как это сделать.

Модель

Допустим, у нас есть объекты City и District в отношении OneToMany:

@Data
@NoArgsConstructor
@Entity
public class City {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;
    private String name;

    @OneToMany(
            cascade = CascadeType.ALL,
            mappedBy = "city",
            orphanRemoval = true)
    private List<District>  districts=new ArrayList<>();

   ...

}

@Data
@NoArgsConstructor
@Entity
public class District {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;
    private String name;
    @ManyToOne
    private City city;
    ...
}

Вначале добавим 10 объектов City в базу данных без пакетной обработки, поодиночке.

Добавление объектов без batch

@Transactional
@Test
public void whenNotConfigured_ThenSendsInsertsSeparately() {
    for (int i = 0; i < 10; i++) {
        City city = new City(String.valueOf("city" + i));
        em.persist(city);
    }
    em.flush();
}

Тут в конце поставлен для наглядности em.flush(), но он необязателен. В конце транзакции flush происходит в любом случае неявно.

Отслеживание пакетов в консоли

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

@Component
public class DatasourceProxyBeanPostProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessBeforeInitialization(final Object bean, final String beanName) throws BeansException {
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(final Object bean, final String beanName) throws BeansException {
        if (bean instanceof DataSource) {
            ProxyFactory factory = new ProxyFactory(bean);
            factory.setProxyTargetClass(true);
            factory.addAdvice(new ProxyDataSourceInterceptor((DataSource) bean));
            return factory.getProxy();
        }

        return bean;
    }

    private static class ProxyDataSourceInterceptor implements MethodInterceptor {

        private final DataSource dataSource;

        public ProxyDataSourceInterceptor(final DataSource dataSource) {
            this.dataSource = ProxyDataSourceBuilder.create(dataSource).name("Batch-Insert-Logger").asJson().countQuery().logQueryToSysOut().build();
        }

        @Override
        public Object invoke(final MethodInvocation invocation) throws Throwable {
            Method proxyMethod = ReflectionUtils.findMethod(dataSource.getClass(), invocation.getMethod().getName());
            if (proxyMethod != null) {
                return proxyMethod.invoke(dataSource, invocation.getArguments());
            }
            return invocation.proceed();
        }
    }
}

Тогда в консоли мы получим лог происходящего:

{"name":"Batch-Insert-Logger", "connection":6, "time":1, "success":true, "type":"Prepared", "batch":false, "querySize":1, "batchSize":0, "query":["insert into city (name, id) values (?, ?)"], "params":[["city0","1"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":false, "querySize":1, "batchSize":0, "query":["insert into city (name, id) values (?, ?)"], "params":[["city1","2"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":false, "querySize":1, "batchSize":0, "query":["insert into city (name, id) values (?, ?)"], "params":[["city2","3"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":false, "querySize":1, "batchSize":0, "query":["insert into city (name, id) values (?, ?)"], "params":[["city3","4"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":false, "querySize":1, "batchSize":0, "query":["insert into city (name, id) values (?, ?)"], "params":[["city4","5"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":false, "querySize":1, "batchSize":0, "query":["insert into city (name, id) values (?, ?)"], "params":[["city5","6"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":false, "querySize":1, "batchSize":0, "query":["insert into city (name, id) values (?, ?)"], "params":[["city6","7"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":false, "querySize":1, "batchSize":0, "query":["insert into city (name, id) values (?, ?)"], "params":[["city7","8"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":false, "querySize":1, "batchSize":0, "query":["insert into city (name, id) values (?, ?)"], "params":[["city8","9"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":false, "querySize":1, "batchSize":0, "query":["insert into city (name, id) values (?, ?)"], "params":[["city9","10"]]}

Из него видно, что команды insert отправляются в базу по одной, пакетная обработка отключена и размер пакета 0.

Чтобы это исправить, то есть включить пакетную обработку, надо добавить в application.properties настройку:

spring.jpa.properties.hibernate.jdbc.batch_size=5

В ней мы указали размер пакета 5.

Добавление объектов в пакете

Теперь, если мы снова выполним тест со включенной настройкой, те же самые команды отправятся по сети в базу двумя пакетами по 5 insert в каждом:

{"name":"Batch-Insert-Logger", "connection":6, "time":1, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["insert into city (name, id) values (?, ?)"], "params":[["city0","1"],["city1","2"],["city2","3"],["city3","4"],["city4","5"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["insert into city (name, id) values (?, ?)"], "params":[["city5","6"],["city6","7"],["city7","8"],["city8","9"],["city9","10"]]}

Это видно в консоли.

Но есть один нюанс, связанный с памятью. Дело в том, что пакетную обработку обычно включают, когда требуется обработать большое количество сущностей. И все они занимают место в Persistence Context (EntityManager). Каждая команда:

em.persist(city);

добавляет очередной объект city в Persistence Context, делает city отслеживаемой сущностью (managed entity).

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

@Test
@Transactional
public void whenFlushingAfterBatch_ThenClearsMemory() {
    for (int i = 0; i < 10; i++) {
        City city = new City(String.valueOf(i));
        em.persist(city);

        if ((i + 1) % BATCH_SIZE == 0) {
            em.flush();
            em.clear();
        }
    }
}

Вывод в консоли при этом не изменится (то есть формирование пакетов по 5 команд insert останется прежним).

Просто теперь помимо сетевых ресурсов мы экономим и оперативную память.

Итак, количество SQL-команд в пакете при отправке в базу по сети задает настройка:

spring.jpa.properties.hibernate.jdbc.batch_size=5

Она экономит число отправок: благодаря ей отправляется по 5 команд, а не по одной (то есть число походов в БД уменьшается в 5 раз).

А оперативную память нашего приложения экономят методы внутри цикла:

em.flush(); 
em.clear();

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

Это основное по пакетной обработке (batch processing). Дальше мы пройдемся по особенностям Hibernate, которые нужно знать, чтобы пакетная обработка  все же состоялась (так как при малейшем изменении логики batch может снова отмениться). Например, если помимо City мы добавляем еще District.

Добавление двух видов объектов в пакете

Итак, изменим немного логику: будем добавлять не только City, то и District (по два District к каждому City):

@Test
@Transactional
public void whenThereAreMultipleEntities_ThenCreatesNewBatch() {
    for (int i = 0; i < 10; i++) {

        City city = new City(String.valueOf(i));

        District first = new District(city);
        District second = new District(city);
        city.addDistrict(first);
        city.addDistrict(second);
        em.persist(city);


        if ((i + 1) % BATCH_SIZE == 0) {
            em.flush();
            em.clear();
        }
    }
}

Теперь City добавляются по 1 в пакете, в District  — по 2:

{"name":"Batch-Insert-Logger", "connection":6, "time":1, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":1, "query":["insert into city (name, id) values (?, ?)"], "params":[["0","1"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":1, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":2, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["1","NULL(VARCHAR)","2"],["1","NULL(VARCHAR)","3"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":1, "query":["insert into city (name, id) values (?, ?)"], "params":[["1","4"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":2, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["4","NULL(VARCHAR)","5"],["4","NULL(VARCHAR)","6"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":1, "query":["insert into city (name, id) values (?, ?)"], "params":[["2","7"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":2, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["7","NULL(VARCHAR)","8"],["7","NULL(VARCHAR)","9"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":1, "query":["insert into city (name, id) values (?, ?)"], "params":[["3","10"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":2, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["10","NULL(VARCHAR)","11"],["10","NULL(VARCHAR)","12"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":1, "query":["insert into city (name, id) values (?, ?)"], "params":[["4","13"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":2, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["13","NULL(VARCHAR)","14"],["13","NULL(VARCHAR)","15"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":1, "query":["insert into city (name, id) values (?, ?)"], "params":[["5","16"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":2, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["16","NULL(VARCHAR)","17"],["16","NULL(VARCHAR)","18"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":1, "query":["insert into city (name, id) values (?, ?)"], "params":[["6","19"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":2, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["19","NULL(VARCHAR)","20"],["19","NULL(VARCHAR)","21"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":1, "query":["insert into city (name, id) values (?, ?)"], "params":[["7","22"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":1, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":2, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["22","NULL(VARCHAR)","23"],["22","NULL(VARCHAR)","24"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":1, "query":["insert into city (name, id) values (?, ?)"], "params":[["8","25"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":1, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":2, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["25","NULL(VARCHAR)","26"],["25","NULL(VARCHAR)","27"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":1, "query":["insert into city (name, id) values (?, ?)"], "params":[["9","28"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":1, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":2, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["28","NULL(VARCHAR)","29"],["28","NULL(VARCHAR)","30"]]}

Чтобы это исправить и отправлять по 5, надо добавить в application.properties настройку:

spring.jpa.properties.hibernate.order_inserts=true

Теперь получим по 5 insert в пакете как для City, так и для District:

{"name":"Batch-Insert-Logger", "connection":6, "time":1, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["insert into city (name, id) values (?, ?)"], "params":[["0","1"],["1","4"],["2","7"],["3","10"],["4","13"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":1, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["1","NULL(VARCHAR)","2"],["1","NULL(VARCHAR)","3"],["4","NULL(VARCHAR)","5"],["4","NULL(VARCHAR)","6"],["7","NULL(VARCHAR)","8"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["7","NULL(VARCHAR)","9"],["10","NULL(VARCHAR)","11"],["10","NULL(VARCHAR)","12"],["13","NULL(VARCHAR)","14"],["13","NULL(VARCHAR)","15"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["insert into city (name, id) values (?, ?)"], "params":[["5","16"],["6","19"],["7","22"],["8","25"],["9","28"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["16","NULL(VARCHAR)","17"],["16","NULL(VARCHAR)","18"],["19","NULL(VARCHAR)","20"],["19","NULL(VARCHAR)","21"],["22","NULL(VARCHAR)","23"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["insert into district (city_id, name, id) values (?, ?, ?)"], "params":[["22","NULL(VARCHAR)","24"],["25","NULL(VARCHAR)","26"],["25","NULL(VARCHAR)","27"],["28","NULL(VARCHAR)","29"],["28","NULL(VARCHAR)","30"]]}

Пакетное редактирование

Аналогичная особенность у пакетного редактирования: если редактировать не просто City, а еще District, то есть делать update City не подряд, а прерывать редактированиями District:

@Test
@Transactional
@Commit
public void whenUpdatingEntities_thenCreatesBatch() {
    TypedQuery<City> query =
            em.createQuery("SELECT c from City c", City.class);
    List<City> cities = query.getResultList();
    for (City city : cities) {
        city.setName("new " + city.getName());
        city.getDistricts().get(0).setName("new d1");
        city.getDistricts().get(1).setName("new d2");
    }
}

то снова пакеты из 5 команд не сформируются, а будут пакеты по 1 и по 2 (лог не привожу). Чтобы это исправить, надо добавить в application.properties настройку:

spring.jpa.properties.hibernate.order_updates=true

Тогда команды update будут отправляться пакетами по 5 штук:

{"name":"Batch-Insert-Logger", "connection":6, "time":3, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["update city set name=? where id=?"], "params":[["new name1","-10"],["new name2","-9"],["new name3","-8"],["new name4","-7"],["new name5","-6"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":0, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["update city set name=? where id=?"], "params":[["new name6","-5"],["new name7","-4"],["new name8","-3"],["new name9","-2"],["new name10","-1"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":1, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["update district set city_id=?, name=? where id=?"], "params":[["-1","new d1","-130"],["-1","new d2","-129"],["-2","new d1","-128"],["-2","new d2","-127"],["-3","new d1","-126"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":1, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["update district set city_id=?, name=? where id=?"], "params":[["-3","new d2","-125"],["-4","new d1","-124"],["-4","new d2","-123"],["-5","new d1","-122"],["-5","new d2","-121"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":1, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["update district set city_id=?, name=? where id=?"], "params":[["-6","new d1","-120"],["-6","new d2","-119"],["-7","new d1","-118"],["-7","new d2","-117"],["-8","new d1","-116"]]}
{"name":"Batch-Insert-Logger", "connection":6, "time":1, "success":true, "type":"Prepared", "batch":true, "querySize":1, "batchSize":5, "query":["update district set city_id=?, name=? where id=?"], "params":[["-8","new d2","-115"],["-9","new d1","-114"],["-9","new d2","-113"],["-10","new d1","-112"],["-10","new d2","-111"]]}

Итоги

Мы рассмотрели, что такое пакетная обработка в Hibernate, как ее включить и отслеживать в консоли. Пример на Spring Boot доступен на GitHub.

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

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