Всем привет. На своем последнем месте работы я выполнял обязанности 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<Book> = 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();
    }

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

Как так получилось, что скорость выполнения одиночного 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")
    }
}

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

Получили аналогичный результат — запрос без транзакции выигрывает примерно в 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, мы и так все знаем». После этого желание продолжать работать в этой компании у меня отпало и я сменил работу. Надеюсь это кому нибудь будет полезно и поможет избежать ошибок и снижения быстродействия на пустом месте, когда это особенно важно. Вывод который я хочу донести — не надо ставить аннотации с транзакцией где попало, этим вы делаете только хуже и мешаете БД работать.

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

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


  1. 3draven
    31.03.2024 20:57
    +1

    Так тебе ведь сказали, что им скорость не важна, а так ошибок меньше выходит? Если я верно понял. Если ресурсов завались и не жалко, то вероятно и шут с ними, пусть себе ставят. При таком подходе наверное выходит один запрос в апи это одна транзакция в базе :)


    1. grisha9 Автор
      31.03.2024 20:57
      +2

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


      1. 3draven
        31.03.2024 20:57

        Как это нет ни слова? Тебя с оптимизациями, по твоим же словам, послали. Значит скорость не важна, ты, судя по всему, этого просто так и не понял, а объяснять не стали. В итоге ты уволился.

        Но спор без смысла. Итог, продолжат ставить так как ошибок меньше и это важнее.


        1. grisha9 Автор
          31.03.2024 20:57

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


          1. 3draven
            31.03.2024 20:57

            Это распространенное непонимание. Есть то, что надумади те кто тебя посадил оптимизациями заниматься, а есть факт, что ты в итоге никому ничего доказать не смог. Вывод прост, одно дело, что люди говорят, а другое факт того, что ты уже не там. Отличать реальное функционирование процессов от словес надо уметь, экономит время и нервы.


            1. 3draven
              31.03.2024 20:57

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


    1. achekalin
      31.03.2024 20:57

      А мы-то, в роли пользователей, этого самого "сервиса, чье приложение установлено в смартфоне большинства жителей РФ", недоумеваем, почему запуск вот этой небольшой программки занимает сто-о-олько времени на не самом хилом смартфоне.

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


      1. Scott_Leopold
        31.03.2024 20:57

        Это потому что сначала функционала мало. Приложение выполняется быстро.

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


        1. achekalin
          31.03.2024 20:57

          Хорошая версия, но, кажется, это еще и от рук и желания компании выпустить нормальный продукт.

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

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

          Не говоря что банки рода ВТБ просто не понимают, как должно вести себя нормальное мобильное приложение на одном из топовых процов в мире с кучей ОЗУ - их убожество всегда работает "в среднем отвратительно". Талант!


  1. kemm
    31.03.2024 20:57
    +6

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

    Транзакции для нескольких запросов на чтение необходимы для обеспечения консистентности данных, см. Transaction Isolation Levels#Serializable. Что тут противоречит сути определения транзакции я как-то затрудняюсь придумать...


    1. grisha9 Автор
      31.03.2024 20:57
      +1

      Да тут я наверное погорячился. Но я вот что то сходу не могу придумать реального примера когда мне потребуется Isolation Levels#Serializable для читающих транзакций, чтобы по сути все работало в однопоток, и остальные потребители ждали завершения чтения. Как правило все необходимые данные можно получить в один запрос или в такой изоляции нет смысла исходя из задач предметной области. У нас такой потребности точно не было.


      1. kemm
        31.03.2024 20:57
        +6

        Можно и без serializable, достаточно repeatable read, в общем-то. Зависит от данных, их нормализации, логики и всего прочего. Мне лень придумывать реальный пример, но не зря ж это во все серьёзные дазы банных завезли? 8)) Навскидку, скажем, получение кол-ва ответов в теме и самих ответов (в каком-нибудь диапазоне) для какого-нибудь форума, я уж не знаю...

        чтобы по сути все работало в однопоток, и остальные потребители ждали завершения чтения

        Откуда тут однопоток возьмётся? ДБ всё-таки не настолько дубово устроены.

        У нас такой потребности точно не было.

        Для множества случаев вообще и read uncommitted сойдёт, "не деньги считаем"(с)


        1. grisha9 Автор
          31.03.2024 20:57
          +1

          Согласен был не прав, в столь категоричной формулировке, не смотря на то что это был не наш случай.


    1. martin_wanderer
      31.03.2024 20:57
      +2

      А вот как раз без уточнения уровня изоляции этот момент просто нельзя рассматривать. И что-то мне подсказывает, что люди, которые просто бездумно на все вызовы ставят Transactional, приблизительно всегда работают с read committed, где транзакция не приносит никакой пользы в случае только чтения


      1. grisha9 Автор
        31.03.2024 20:57

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

        Но справделивости ради надо заметить, что постгрес использует технологию снимков данных для работы, поэтому он по дефолту предоставляет уровень repeatable read https://habr.com/ru/companies/postgrespro/articles/442804/


      1. ptr128
        31.03.2024 20:57

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


        1. mvv-rus
          31.03.2024 20:57

          последующие запросы могут считать запись зафиксированную до завершения предыдущих запросов.

          Уточните: что вы имели в виду? Если запись с изменением, зафиксированным после момента начала транзакции с уровнем изоляции снимка, но до первого обращения к записи в этой транзакции, то утверждение неверно. По крайней мере, в общем случае, в схеме хранения с поддержкой снимков: там у СУБД достаточно информации, чтобы найти старую версию записи и вернуть ее, даже если к этой записи транзакция ранее не обращалась.

          И ЕМНИП оно реально неверно (было, в те давние времена) для конкретного Interbase 4. Тамошняя приблуда для интерактивной работы с БД - wisql.exe - при посылке первого же запроса открывала транзакцию (с изоляцией формально, если по тогдашним стандартам - REPEATBLE READ, но по жизни это был снимок, в Interbase была многоверсионная схема хранения), и сидела в ней, пока транзакцию явно не завершишь. В результате при отладке прогаммы на Delphi можно было внезапно не увидеть в wisql сделанное программой и зафиксированное изменение, потому что час назад в wisql был сделан и забыт запрос к совершенно другой таблице, а транзакция так и осталась висеть.


          1. ptr128
            31.03.2024 20:57

            Уточните: что вы имели в виду?

            Я опровергаю утверждение

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

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


            1. mvv-rus
              31.03.2024 20:57

              Вы, похоже, не так поняли утверждение (не свосем однозначное, да) , однако сразу кинулись его опровергать. А я вот так понял, что утверждавший имел в виду вызов метода с несколькими запросами на чтение к БД внутри него - а в этом сценарии уровень изоляции имеет значение.

              Ну, для вас такая безапелляционность - это норма. А вот я пока что, прежде чем спорить дальше, попробую подождать уточнения от @martin_wanderer


              1. ptr128
                31.03.2024 20:57

                Вы, похоже, не так поняли утверждение

                А как его еще понимать?

                бездумно на все вызовы ставят Transactional

                транзакция не приносит никакой пользы в случае только чтения

                Если не поставить Transactional на вызов, содержащий несколько запросов только чтения, то вне зависимости от уровня изоляции можно прочитать неконсистентные данные.

                Вы собрались это оспорить?


                1. mvv-rus
                  31.03.2024 20:57

                  А как его еще понимать?

                  Я же написал. Читайте.

                  Если не поставить Transactional на вызов, содержащий несколько запросов...

                  А если поставить (да ещё и бездумно, как говорил автор обсуждаемого комментария)? Прямо "над методом где выполняется несколько SQL запросов на чтение " (цитата из оригинального, до правки, варианта статьи)? Такой вариант прочтения вам в голову не приходил?

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


                  1. ptr128
                    31.03.2024 20:57

                    Я уже не знаю, по буквам что-ли разбирать?

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

                    Установка Transactional на вызов, содержащий только один запрос, причем не важно на чтение или модификацию, смысла не имеет. Так как любой запрос выполняется в транзакции и "только чтение" не играет никакой роли. С этим согласны?

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

                    Значит выделенная мной фраза некорректна.


                    1. mvv-rus
                      31.03.2024 20:57

                      Я уже не знаю, по буквам что-ли разбирать?

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

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

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

                      С этим я и не спорил, я с самого начала говорил про одну тразакцию на несколько запросов, и даже пример про это привел. И что с того?


                      1. ptr128
                        31.03.2024 20:57

                        "вызов, содержащий только один запрос"

                        К вызову, содержащему только один запрос, эта фраза относиться не может:

                        транзакция не приносит никакой пользы в случае только чтения

                        Так как вызов из одного запроса всегда в транзакции, в не зависимости от того для чтения он или модификации и в не зависимости от того в блоке транзакции он или нет.

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

                        Но тогда утверждение "транзакция не приносит никакой пользы в случае только чтения" некорректно. ЧТД.


                      1. mvv-rus
                        31.03.2024 20:57

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

                        Или вы что-то другое хотели написать?

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


  1. alamer
    31.03.2024 20:57

    Мне кажется автор излишне драматизирует. При одном вызове я лично в своих проектах не нашел оборачивания в транзакцию. Да и в целом если в методе нет i\o или других вычислений, которые бы держали коннект с базой, ничего критического не вижу. Так что it depends как говорится.


    1. grisha9 Автор
      31.03.2024 20:57
      +3

      Мне кажется автор излишне драматизирует. При одном вызове я лично в своих проектах не нашел оборачивания в транзакцию

      Может потомучто вы не используете там ручное управления транзакциями?)

      ничего критического не вижу.

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


  1. ultrinfaern
    31.03.2024 20:57
    +3

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


    1. grisha9 Автор
      31.03.2024 20:57
      +3

      В два раза это микрооптимизация?

       Кто будет помнить, что на этом методе специально выключили транзакции? 

      Определение транзакции достаточно простое, чтобы понять случаи где она не нужна.

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

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

      Невыдуманные примеры из этого проекта:

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

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


      1. lastrix
        31.03.2024 20:57

        Это не проблема транзакций, а кривизна рук разработчиков.
        Есть золотое правило - не отправлять в топики сообщения, когда у тебя активна транзакция. Все.
        Если во время отправки упало - повтори все по новой, читай про идемпотентность


        1. grisha9 Автор
          31.03.2024 20:57
          +2

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


  1. VadimChes
    31.03.2024 20:57
    +1

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


  1. ptr128
    31.03.2024 20:57
    +1

    транзакция нужна когда у нас есть несколько операций, которые меняют данные в БД. 

    Это верно только если в транзакции лишь один запрос. Даже при READ COMMITED второй запрос может считать запись, зафиксированную до завершения первого запроса. А это потенциальные проблемы с консистентностью данных.


    1. grisha9 Автор
      31.03.2024 20:57

      Да, про это уже писали - https://habr.com/ru/articles/803395/comments/#comment_26672365

      Был не корректен в своих фомрулировках, поправил статью.


      1. ptr128
        31.03.2024 20:57

        Там было про уровни изоляции REPEATABLE READ и SERIALIZED. А я указал, что проблема возникает при любом уровне изоляции, включая READ COMMITED. READ UNCOMMITED в PostgreSQL, по сути, нет.


  1. boraldomaster
    31.03.2024 20:57

    Возможно я что-то упускаю, но есть подозрение, что тест, который Вы опубликовали, не вполне представителен.
    Как я понимаю, Вы выполняете одни и те же запросы на одной базе данных. И по "странному" стечению обстоятельств первый тест медленнее второго, а второй медленнее третьего. Это может говорить о том, что база данных "прогрелась" и одни и те же запросы достаются из кэша.
    Кроме того, объёмы данных совсем минимальные, чтобы вообще судить о производительности.
    Кроме того, не вижу у вас индекса.
    Кроме того, не вижу параллельного выполнения запросов.
    Рекомендую для большей наглядности тестов, добавить в базу 10 миллионов записей, выполнять тесты в 100 потоков и каждый тест выполнять на новом инстансе базы данных.
    Тогда возможно получите результаты более близкие к тем, о которых Вам говорили.


    1. grisha9 Автор
      31.03.2024 20:57

      Как я понимаю, Вы выполняете одни и те же запросы на одной базе данных. И по "странному" стечению обстоятельств первый тест медленнее второго, а второй медленнее третьего. Это может говорить о том, что база данных "прогрелась" и одни и те же запросы достаются из кэша.

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

      Результаты 1-го запроса отличаются от 2-го и 3-го примерно в два раза. 2 и 3 отличаются в 1.1 раза т.е. можно сказать что они примерно равны. Особенно если запускать тест несколько раз. Т.е. вывод такой что одиночный запрос без транзакции в два раза быстрее чем с ней. Мой тест прогревает базу, если посмотреть внимательно. Я именно этого и добивался чтобы данные закешировались и показать влияние сетевой задержки.

      Кроме того, объёмы данных совсем минимальные, чтобы вообще судить о производительности.

      О какой производительности? у меня не было цели тестировать производительность БД. И статья не о производительности БД, а про то как мы получаем лишнюю сетевую операцию на пустом месте. Я как раз и ставил цель протестировать около идеальные условия когда данные закэшированны и нет конкуренции и БД отвечает мгновенно. В случае нагрузок БД может отвечать еще дольше и тогда разница будет еще нагляднее, но совершенно очевидно что быстрее сетевые запросы к бд выполняются точно не будут.

      Кроме того, не вижу у вас индекса.

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

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

      Спасибо, но я воздержусь от вашего предложения тестировать перфоманс БД, вы судя по всему совсем не поняли посыл статьи. И совсем непонятно о каких результатах мне говорили?


      1. boraldomaster
        31.03.2024 20:57

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


        1. grisha9 Автор
          31.03.2024 20:57

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

          По поводу симметричности, да к этому можно придраться. Но как не переставляй порядок операций в моем тесте, результат будет одинаковый. Я об этом и написал в статье "что даже такой простой тест позволяет понять это". Добавил по вашей просьбе в этот же тест отдельные методы на каждый случай. На каждый запуск контейнер у меня "накатывается" с нуля (spring.sql.init.mode=always). Вообщем то я для этого и выложил все это в открытый доступ, чтобы каждый мог "поиграться" так как ему захочется. И лучше это делать как я опять же писал в статье на remote DB (например из тестового окружения), чтобы более наглядно увидеть сетевые задержки. В локальном тест контейнере они ничтожно малы. И тест с JPA показывает это - оверхед фреймворка = обращению к бд.

          В статье также указано (правда без цифр) что я проводил данный тест на реальных запросах и на около-прод бд. Результат один и тот же всегда - разница в два раза на одиночных запросах(если они конечно не возвращают мегабайты данных). Цифры на память примерно указал в комментарии https://habr.com/ru/articles/803395/comments/#comment_26676749. Где у человека схожие с вашими вопросы. И как мне кажется вы не совсем понимаете стоимость лишнего сетевого вызова и к чему это ведет - лишнее время удержание потока и коннекта к бд и каким это ведет последствиям. И основной посыл моей статьи в том что не надо ставить транзакции там где они не нужны, будет только хуже. Достаточно простая истина, но приходиться повторять ее чуть ли не в каждом комментарии.


  1. popfalushi
    31.03.2024 20:57

    Подход с пометкой всех методов @Transactional , помимо консистентности в ряде случаев, позволяет подгружать lazy поля. И да, не все поля можно подгрузить через join fetch за один проход (см. MultipleBagFetchException). Подозреваю, это главное, почему везде лепят транзакционность. У вас в примере этот аспект не затронут, а зря.

    А потом - ну это неправильно говорить о двухкратном увеличении скорости работы методов на БД объемом в 6 строк. Если код работает 1мс и БД работает 1мс, то ускорение в 0.5мс - относительно существенное, но в абсолютных значениях оно ничтожно. Если БД отрабатывает 100мс и жава 5мс, и вы сэкономите разными приемами 10 мс, то это похвально, но при отсутствии highload'а это будет незаметно.


    1. grisha9 Автор
      31.03.2024 20:57

      Подход с пометкой всех методов @Transactional , помимо консистентности в ряде случаев, позволяет подгружать lazy поля. И да, не все поля можно подгрузить через join fetch за один проход (см. MultipleBagFetchException). Подозреваю, это главное, почему везде лепят транзакционность. У вас в примере этот аспект не затронут, а зря.

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

      А потом - ну это неправильно говорить о двухкратном увеличении скорости работы методов на БД объемом в 6 строк. Если код работает 1мс и БД работает 1мс, то ускорение в 0.5мс - относительно существенное, но в абсолютных значениях оно ничтожно. Если БД отрабатывает 100мс и жава 5мс, и вы сэкономите разными приемами 10 мс, то это похвально, но при отсутствии highload'а это будет незаметно.

      Судя по всему вы ничего не поняли и статью на которую я давал ссылку тоже проигнорировали. Какая разница сколько в БД строк? Я старался показать именно сетевую задержку и ее влияние. И да, в реальном мире данные от БД до приложения не доходят мгновенно, бд может и отработает как вы пишите за 1мс, только данные будут идти до приложения 100мс (а все это время мы держим коннект в базе). И более того сеть не стабильна. И по отношению к нашему основному приложению БД выступает в роли внешней системы, которая как я писал может отвалиться на коммите который не нужен, и мы получим ошибку когда данные по факту уже получили.

      Величина 0.1мс это величина сетевой задержки в локальном тест контейнере. Код и бд в этом случае отрабатывают по сути мгновенно и это время можно принять за 0. Если выполнить этот тест на remote DB то результат уже будет не 0.1мс, а 10мс. А если БД находиться в “далеком” датацентре то будет уже 100мс. Итого запрос без транзакции выполняется за 100мс, а с ней за 200мс. Именно такие результаты примерно я получал когда тестировал это на рабочих стендах. Эта и есть величина паразитной сетевой задержки, которая зависит от того насколько далеко БД находиться от приложения. Вы сами можете это все проверить на своих рабочих тестовых стендах.

      Посыл статьи в том что не надо ставить транзакции где попало. Будет только хуже. И величина этого "хуже" зависит от передачи данных по сети.