Всем привет! Думаю, что в жизни каждого программиста время от времени появляются batch операции (в простонародье — это когда вы хотите сделать какое-то действие пачкой, например, сразу вставить много данных в таблицу). Давайте сегодня как раз об этом и поговорим.
Для вольных разговоров на тему батч операций, давайте возьмём супер синтетический пример. Рассмотрим Web-сервис, состоящий из двух частей. Первая — это Web UI. Тут у нас будет кнопка, по нажатию на которую генерируются 200 тысяч UUID строк, которые шлются на серверное API. Вторая часть нашей системы — это, очевидно, API, которое принимает на вход массив строк (сгенерированных ранее UUID-ов), и сохраняет это дело сразу в PostgreSQL, без какой-либо обработки.
Таблица, в которую мы хотим вставить — самая простая, из тех, что могли быть:
create table events ( data varchar(36) not null );
Мерить будем время между нажатием на кнопку «отправить» и получением ответа от сервера (да-да, это довольно глупо для benchmark, в котором мы хотим замерить время вставки в базу данынх, но зато это похоже на некоторый реалистичный сценарий работы с API).
Как бы поступил программист, которому нужно вставить массив данных в БД, если он ничего бы не знал про batch операции? Правильно — он просто бы в цикле сделал N запросов, где N — количество элементов в массиве.
На Spring это могло бы выглядеть примерно так:
transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) { for (String data : bulkInsert.getData()) { jdbcTemplate.update("INSERT INTO events (data) VALUES (?)", data); } } });
Минусы, наверное, понятны. У нас тут N физических запросов, которые сначала идут по сети, а затем, один за одним, выполняются в базе.
Плюсы — наивная простота. Ну, и оно работает.
К счастью для нас, мы пишем на Java, и тут многие проблемы уже решены. Вот и в JDBC есть API, которое позволяет сохранять данные в базу пачками. Код, по сравнению с первой реализацией, менять почти не нужно:
transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) { List<Object[]> batchArgs = new ArrayList<Object[]>(); for (String data : bulkInsert.getData()) { Object[] values = new Object[] {data}; batchArgs.add(values); } jdbcTemplate.batchUpdate("INSERT INTO events (data) VALUES (?)", batchArgs); } });
Однако, тут не всё так просто. Если вы возьмёте этот кусок кода и посмотрите в логи PostgreSQL, то увидите там N запросов. Это как-то не сочитается со словом «batch» в названии этого API.
Оказывается, что этот код делает только половину задачи. JDBC группирует запросы в пачки и шлет их по сети вместе. Однако непосредственно в базе данных запросы выполняются, как и в первом примере, один за другим. То есть, мы заметно улучшили положение дел (так как снизили нагрузку с сети), но это не идеальная ситуация.
Об этой оссобенности данного API написал Владимир — https://stackoverflow.com/a/48349524/1756750.
Как вы могли прочитать выше у Владимира, для того чтобы JDBC начал сворачивать запросы во едино, нужно добавить reWriteBatchedInserts=true в адрес подключения. То есть, для спринта это будет выглядеть примерно так:
spring.datasource.url=jdbc:postgresql://localhost:5432/bulk-api?reWriteBatchedInserts=true
Код остается ровно таким же, как в примере выше. Плюсы — очевидно, что довольно быстро (на некоторых тестах). Минусы — такие тонкости JDBC API могут уже не работать на базах данных, которые внутри себя не являются PostgreSQL, а лишь предоставляют API этой базы (например, Amazon Aurora PostgreSQL). То есть, для NewSQL баз это надо мерить, а не слепо верить описанию.
PostgreSQL позволяет за один INSERT запрос вставить сразу несколько значений. То есть, вот такой запрос вполне валиден:
INSERT INTO events(data) VALUES ('1'), ('2');
Поэтому можно взять условный StringBuilder, и руками составлять запросы. Главное, надо помнить, что в одном запросе не следует передавать больше 1000 элементов.
Плюсы — возможно, это быстро, но это надо тестировать (об этом далее). Кроме того, не требует каких-то специфичных настроек (типа, reWriteBatchedInserts=true).
Минусы — неудобное API. Кроме того, легко наломать дров. Есть разные corner cases, где такие запросы с множественными значениями могут не работать.
В PG есть замечательная команда COPY, которая позволяет невеятно быстро заливать в базу данные. Нашёлся умелец, который убернул эту команду в удобное Java API. Так и появился проект PgBulkInsert.
Пример своего использования добавлять не буду — на гитхабе этой либы есть рабочие куски кода.
Плюсы — потенцилаьно, очень быстро.
Минусы- супер завязано на PostgreSQL, то есть, может не работать в некоторых облачных базах данных. Кроме того, в Java библиотеке поддерживаются не все типы данынх (например, нет VARCHAR). Ну, и не особо минус, но, это никак не дружит со спрингом. То есть, у в вас в коде часть запросов может быть написано на JOOQ, или jdbcTemplate, а часть — на этой либе.
Напомню, что делаю супер синтетический тест: шлю с браузера 200 тысяч UUID-ов, и измеряю время, когда сервер ответит успехом сохранения.
Получилось примерно так (каждая строка с числом — отдельный эксперимент):
Собственно, никаких сюрпризов на данном тесте нет:
Что использовать в реальных проектах? Первое, что нужно сказать — в реальных проектах нужно всё сначала поменить, перед тем как выбирать инструмент. Может быть, у вас Сеть будет проблемным местом, или диски Базы Данных. Или еще что-то. И в том случае так может оказаться, что никакой разницы от используемого API не будет.
Если давать совет в сферическом вакуме — на мой вкус, JDBC batch API — хороший компромисс между скоростью API и надёжностью и стабильностью API. На COPY я бы полез только, если бы это был главный критический участок проекта, и без этого я бы не смог завершить мою задачу.
Категории: Программирование