Статья
Как ускорить сохранение в базу данных: три подхода к транзакциям
Оптимизация кода в случае применения транзакций в цикле

Часто решением проблем на пути к оптимизации кода (а именно увеличению скорости выполнения задач) может быть работа с транзакциями.


Сегодня я хочу предложить решение вопроса при обращении к базе данных в цикле.

1) Как можно применять транзакцию?
2) Какие варианты использования есть, когда мы выполняем действия по сохранению или обновлению информации в базе данных?
В данном случае для взаимодействия нашего приложения с БД буду использовать spring data jpa а из sql подобных субд мой выбор падет на, наверное, самую популярную сейчас в России postgreSql. Здесь будет рассмотрены три варианта. Два из них очевидны и добавочную стоимость этой информации может быть будет иметь только сравнительный анализ по времени. А на третий можно обратить внимание, именно этот подход, во всяком случае у меня, нашел достаточно широкое применение и закрыл большинство кейсов применения транзакций в цикле. Все, хватит болтать, перейдем от слов к делу, а в нашем случае от слов к коду.

У нас будет слой сервиса, не будем нарушать принципы дизайна приложений:

И естественно наш репозиторий:

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

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

Что здесь происходит?

В каждом цикле мы создаем объект типа User. Когда наш код доходит до метода save() репозитория, который за нас уже реализован и мы его подтянули унаследовав наш интерфейс от интерфеса JpaRepository (Spring под капотом создает прокси, который реализует методы JpaRepository). При этом создается сессия, открывается транзакция, сохраняется наш пользователь в базу данных, транзакция закрывается и эта операция происходит раз за разом при каждой итерации нашего цикла. Я думаю, что код не сложен и дальше разъяснять нет смысла, поэтому подведем итоги.

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

Теперь о минусе. Каждый раз за разом у нас происходит создание сессии, открытие и закрытие новой транзакции. На это тратятся ресурсы и как мы посмотрим дальше не малые.

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

Для транзакции я использую объект transactionTemplate. Не буду на нем тоже останавливаться. Смысл в том что у нас создалась сессия и транзакция открылась один раз и затем закрылась, когда все пользователи созданы и сохранены. Но при этом, если у нас два пользователя оказались с одинаковым email (понятное дело что в моем примере это невозможно, но это только пример) или просто произошел какой-то сбой при обращении к базе данных, то у нас откатится транзакция и не будет сохранен ни один пользователь, что часто бывает для нас неприемлемо. В большинстве случаев нам лучше сохранить пользователя каждого, а тот который не сохранился мы его залогировали или записали отдельно куда-нибудь и уже потом решили что с ним делать. Тогда зачем же я вообще описываю второй случай. А я его описываю, чтобы показать разницу в скорости и она не малая, поэтому не стоит его сразу отметать.

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

Вернемся к нашим циклам и и сравним наши два ранее рассмотренных случая. Тестирование я проводил на своем ноутбуке, каждое значение получал как среднее трех попыток. На другой машине цифры будут отличаться, но динамика будет та же. Производил замеры при создании 10, 20, 30, 40 и 50 тысяч пользователей и соответственно замерял время. Под цифрой 1 – в раздельной транзакции, под цифрой 2 в одной транзакции весь цикл.

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

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

Мы разобрали два кейса, которые являются в некотором роде противоположностями. Где у одного плюс, у другого минус и наоборот. А может все-таки можно как то подумать и объединить не объединяемое и взять по максимуму от обоих вариантов. И мой ответ «ДА». Но прежде чем его показать, давайте приведем наш код к лучшей читаемости, чтобы дядюшке Бобу было приятно (Роберт Мартин «Чистый код») и вынесем логику внутри цикла в отдельный метод:

Теперь же пробуем объединить первый и второй кейс:

Что у нас здесь происходит?

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


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

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

Если мы уверены, что вероятность ошибки отсутствует, то используем вариант 2 (хотя и в этом бы случае использовал третий, так как чуть больше некрасивого кода, но зато большая уверенность)

Если у нас ситуация, что как правило все данные валидные, и мы предполагаем, что ошибок при update не будет, но все таки исключить этого исходя из бизнеса не можем, то конечно используем вариант 3.

Ну, а если мы знаем что ошибки точно будут и бизнес это предполагает, то вариант 1, ошибки отлавливаем производим необходимую обработку и логируем, но в данной ситуации конечно логичнее возможно подумать о некоторой проверке до, а не отлове ошибок, но все будет зависеть от более конкретных условий и ситуаций
Итог:
Транзакции это способ для соблюдения атомарности, но мы должны уметь это средство использовать, чтобы наше приложение оставалось максимально эффективным и быстрым. Мы рассмотрели три возможных случая использования этого инструмента при циклах в нашем коде, каждый имеет свои плюсы и минусы и необходимо тщательно и взвешенно выбирать подход для своего случая.
Надеюсь статья была полезна. Всем всех благ.
Статью написал Владимир Клочков. Java Developer в СберБанке. Выпускник JavaGuru.by/bootcamp