Опять транзакции…

Всем привет. На своем последнем месте работы я выполнял обязанности Java разработчика в одной из команд сервиса, чье приложение установлено в смартфоне большинства жителей РФ. Использовался стандартный стек технологий: Java, Spring (web, jdbc, core), PostgreSQL, Kafka. Обычное синхронное API по работе с данными, без всякой реактивщины, с нагрузкой более миллиона пользователей в день. Я столкнулся с тем что сервисы по работе с БД были обильно «усыпаны» Spring аннотациями @Transactional. Даже одиночные запросы на чтение данных использовали аннотацию с параметром readOnly=true. Я пытался писать комментарии к мердж-реквестам с вопросом: «зачем вы это делаете?». Но получал ответы из разряда: «для перфоманса», «у нас так принято, чтобы случайно не упустить случай когда транзакция будет действительна нужна», «раньше у нас была какая-то проблема с коннектами (какая именно никто так и не вспомнил сколько я не пытал), мы везде добавили аннотации и все заработало». Если интересно чем в итоге закончилась эта дискуссия, то подробности далее.

Немного теории

Начнем с определения транзакции. Для этого вполне подойдет википедия:

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

Как видно из определения, транзакция нужна когда у нас есть несколько операций, которые меняют данные в БД. В противном случае смысла в ней никакого нет и база сама откроет и закроет транзакцию внутри себя, без дополнительных указаний извне. Речь в данной статье будет идти о клиентском управлении транзакциями, через аннотацию Spring @Transactional. То что БД внутри себя создает транзакции даже на чтение и какие оптимизации для этого использует хорошо написано в статье про Postgres и это отдельная тема.

Переходим к практике

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

create table if not exists books (
  id bigserial not null,
  name varchar not null,
  isbn varchar not null,
  primary key (id),
  UNIQUE (isbn)
);

Создадим простой репозиторий, который будет извлекать книгу по ее ISBN. И сделаем три метода: простое извлечение, с транзакцией и с транзакцией только для чтения.

@Component
class JdbcBookRepository(private val jdbcTemplate: JdbcTemplate) {

    private val bookRowMapper: RowMapper = BeanPropertyRowMapper(Book::class.java)

    fun getByIsbn(isbn: String?): Book? {
        return jdbcTemplate.queryForObject("select * from books where isbn = ?", bookRowMapper, isbn)
    }

    @Transactional
    fun getByIsbnTransactional(isbn: String?): Book? {
        return jdbcTemplate.queryForObject("select * from books where isbn = ?", bookRowMapper, isbn)
    }

    @Transactional(readOnly = true)
    fun getByIsbnTransactionalR(isbn: String?): Book? {
        return jdbcTemplate.queryForObject("select * from books where isbn = ?", bookRowMapper, isbn)
    }
}

Добавим тест, который измеряет скорость выполнения запросов (каждый запрос будет выполнен 1000 раз с предварительным прогревом):

@SpringBootTest
@Testcontainers
class JdbcIntegrationTests {

    @Autowired
    lateinit var bookRepository: JdbcBookRepository

    @Test
    fun test() {
        val transactional: LongSummaryStatistics = performTest({ bookRepository.getByIsbnTransactional("1") }, 1000)
        val transactionalR: LongSummaryStatistics = performTest({ bookRepository.getByIsbnTransactionalR("2") }, 1000)
        val transactionalNo: LongSummaryStatistics = performTest({ bookRepository.getByIsbn("3") }, 1000)

        println("transaction no      : $transactionalNo")
        println("transaction         : $transactional")
        println("transaction readonly: $transactionalR")
    }

    companion object {
        @Container
        @ServiceConnection
        val postgreSQLContainer: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:13.3")
    }
}

Замеры производим следующим образом:

public static LongSummaryStatistics performTest(Supplier consumer, int iterationCount) {
        //warm up
        LongStream.range(0, iterationCount).forEach(i -> consumer.get());
        //test
        return LongStream.range(0, iterationCount)
                .map(i -> {
                    long l = System.currentTimeMillis();
                    consumer.get();
                    return System.currentTimeMillis() - l;
                })
                .summaryStatistics();
    }

Результаты (миллисекунды):

9666c8ec4c5ca1225694797f7465c449.png

Как так получилось, что скорость выполнения одиночного select запроса с транзакцией в два раза ниже чем без нее? Ответ на этот вопрос очень прост. Когда транзакции нет, то через JDBC отправляется запрос с параметром соединений autoCommit=true и все взаимодействие с бд происходит за один сетевой вызов (БД сама откроет и закроет транзакцию внутри себя, собственно об этом и говорит имя параметра — autoCommit). Если у нас стоит аннотация Transactional, то у соединения будет параметр autoCommit=false и будет два сетевых запроса: первый это непосредственно select на который бд откроет транзакцию, а второй это вызов connection.commit (); чтобы сказать базе — что нам от нее больше ничего не надо и что она на своей стороне может закрывать транзакцию. В итоге получаем две сетевые операции вместо одной. А как известно, лишняя сетевая операция по своим издержкам на порядок превосходит другие операции которые происходят при выполнении запроса (подготовка и валидация параметров, маппинг результата и др.). К тому же, в отличие от других операций, обращение по сети, находится вне нашего контроля и если «моргнет» сеть, то издержки будут еще выше, вплоть до того что запрос может отвалиться по таймауту на коммите, когда уже все данные от бд получены.

Вот такие вот оптимизации получаем. Возможно некоторые возразят: «что в случае readOnly=true включается ряд оптимизаций на стороне БД». И отчасти будут правы. Но только отчасти, т.к. передавать хинт для бд путем явного создания транзакции, это как стрелять из пушки по воробьям и получаем лишнюю сетевую операцию на пустом месте. Хинт для бд можно передать непосредственно в самом тексте SQL запроса (зависит от БД) или через JDBC параметр соединения — connection.setReadOnly(true). Поэтому между обычной транзакцией и транзакцией для чтения разницы по скорости нет, т.к. основное время занимает ожидание ответа от БД. К тому же БД достаточно «умны» чтобы понять что к ним пришел запрос на чтение с autoCommit=true и сами включат все необходимые оптимизации.

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

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

А что в случае JPA?

На этом проекте в ввиду относительно высокой нагрузки и требований к доступности сервиса, использовалась достаточно низкоуровневая работа с базой через spring-jdbc. А что же в случае JPA? Наверняка многие знают, что для JPA параметр readOnly включает ряд оптимизаций на стороне фреймворка (например отключает dirty checking). К тому же проект spring-petclinic также использует транзакции для чтения на одиночные запросы. И многие ставят аннотацию Transactional над методом где выполняется несколько SQL запросов на чтение. Давайте заодно измерим быстродействие spring-boot-starter-data-jpa репозиториев:

@SpringBootTest
@Testcontainers
class JpaIntegrationTests {
    @Autowired
    lateinit var bookRepository: JpaBookRepository

    @Autowired
    lateinit var bookTransactionalRepo: JpaBookTransactionalRepository

    @Autowired
    lateinit var bookTransactionalReadRepo: JpaBookTransactionalReadRepository

    @Test
    fun test() {              
        val transactionalR = TestUtil.performTest({ bookTransactionalReadRepo.findByIsbn("2") }, 1000)
        val transactional = TestUtil.performTest({ bookTransactionalRepo.findByIsbn("1") }, 1000)
        val transactionalNo = TestUtil.performTest({ bookRepository.findByIsbn("3") }, 1000)

        println("transaction no      : $transactionalNo")
        println("transaction         : $transactional")
        println("transaction readonly: $transactionalR")
    }

    companion object {
        @Container
        @ServiceConnection
        val postgreSQLContainer: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:13.3")
    }
}

Результаты (миллисекунды):

bf4de6c5d840535a76e95ca09cdcf488.png

Получили аналогичный результат — запрос без транзакции выигрывает примерно в 1.5 раза. Как видно транзакция также как и в первом тесте добавляет 0.1 мс, это есть стоимость одного сетевого обращения к тест контейнеру. И еще 0.1 мс оверхеда добавляет сам Hibernate по сравнению со spring-jdbc. В случае отсутствия транзакции процесс dirty checking даже не запуститься, т.к. транзакции на клиенте у нас нет и сущность будет в сразу в статусе detach, в отличии от кейса с транзакцией.

Еще хотел бы вернуться к «И многие ставят аннотацию Transactional над методом где выполняется несколько SQL запросов на чтение». По моему мнению это также бессмысленно. Во-первых, это противоречит сути определения транзакции и плюс как показано выше добавляет лишний сетевой вызов, а во-вторых, мы так дольше удерживаем соединение. И пока происходит маппинг и подготовка данных после первого SQL запроса, мы держим соединение чтобы дальше передать его во второй SQL запрос. Хотя если бы отпустили коннект, то возможно другой поток мог его начать использовать и успеть вернуть в пул, когда в исходном методе дойдет очередь до второго запроса. Тем более как видно из теста Hibernate добавляет в два раза больше оверхеда на обработку данных простого запроса по сравнению с spring-jdbc.

Случай когда транзакцию используют в JPA чтобы избежать LazyInitializationException я в своей статье не рассматриваю т.к. на эту тему и так сломано немало копий. И данный подход имеет мало отношения к высокой нагрузке и best practice.

Что в итоге

Проект с тестами опубликован на github.

По итогу люди не очень хотели прислушиваться к тем результатам тестов и всему что я изложил выше, вплоть до того что один из архитекторов мне заявил: «что не надо нам рассказывать как работают транзакции в Java, мы и так все знаем». После этого желание продолжать работать в этой компании у меня отпало и я сменил работу. Надеюсь это кому нибудь будет полезно и поможет избежать ошибок и снижения быстродействия на пустом месте, когда это особенно важно. Вывод который я хочу донести — не надо ставить аннотации с транзакцией где попало, этим вы делаете только хуже и мешаете БД работать.

Или все же я был не прав?)

© Habrahabr.ru