dimhold.by
← Статьи

В README я написал, что H2 не воспроизводит мои числа с Postgres. По TCP он лёг рядом с локальным Postgres

В августе я выложил на GitHub небольшой замер, preview-cost. Он спрашивает, во что обходится живое превью, если построить его на откате транзакции. Превью записывает значения с ползунка, читает модель обратно, считает и откатывается. Вторая стратегия это копия в памяти, которая базу не трогает вообще. На Postgres 16 в Docker откат проиграл во всех 4500 парах, на 40 планах на 6,16 мс.

Раздел README про запуск даёт 2 обещания. По умолчанию стоит H2 в режиме Postgres, поэтому обещан прогон за 10 секунд “with nothing installed”. А числа в таблице сняты с настоящего Postgres, “because H2 in memory does not reproduce what a write and discard costs a real database”.

Вторую фразу я написал, ни разу толком не посмотрев на прогон с H2. Так про H2 говорят все и я сам говорю так же, когда кто-то хочет тестировать на нём. Берите настоящую базу, встроенная врёт. Выходит, режим по умолчанию в моём репозитории это режим, который, по словам того же README, чисел не воспроизводит. На этот раз я его запустил.

10 секунд

Я склонировал репозиторий на коммите 26dd519 в пустой каталог с пустым кешем Gradle, на виртуалке с 4 vCPU и Gradle 9.8.0. Обёртки Gradle в репозитории нет, так что “nothing installed” уже значит JDK 25 плюс Gradle. Справедливости ради, README говорит это в предложении прямо перед обещанием.

Когда на машине только JDK 21, сборка останавливается через 16 с. Тулчейн просит версию 25, а скачать её Gradle неоткуда. С Temurin 25.0.4.1 первый gradle run на пустом кеше занял 24 с. Следующие 3 запуска заняли по 6-7 с. Значит, обещание 10 секунд верно со второго запуска. Тот, кто клонирует репозиторий впервые, ждёт 24 с.

Что говорит H2

Прогон по умолчанию это 5 свежих JVM по 300 парных превью в каждой. Я запустил неизменённый Bench на H2 2.3.232 в памяти и на PostgreSQL 16.14, поставленного локально на ту же виртуалку, с pgjdbc 42.7.4. Каждый размер прошёл дважды. В таблице медиана разности “откат минус копия”.

планыH2 в памятиPostgreSQL 16.14, локальноREADME, Postgres в Docker
100,50 / 0,54 мс0,78 / 0,76 мс6,54 мс
400,50 / 0,46 мс0,91 / 0,83 мс6,16 мс
1200,49 / 0,47 мс1,22 / 1,18 мс7,02 мс

На каждой базе и каждом размере откат был медленнее в 99,4-100% пар. H2 даёт тот же вердикт, что и README, только число в 12-15 раз меньше. Локальный Postgres отличается от H2 в 1,4-2,6 раза, а Postgres из README от локального в 6-9 раз.

Я ждал, что на H2 откат станет почти бесплатным, ведь ничего не покидает процесс. Не стал. Я разложил превью с откатом на шаги в одной JVM. На H2 в памяти одно только обновление 4 строк заняло 238-291 мкс. На 40 планах это примерно в 4 раза больше всего расчёта, который занял 59 мкс.

Где сидит разница

H2 умеет работать и сервером. Это тот же движок с тем же хранением в памяти. Но драйвер говорит с ним через TCP-сокет на localhost, так же как pgjdbc говорит с Postgres. Я добавил его в сетку к остальным, вместе с H2 с файлом на диске. Вариант с файлом совпал с H2 в памяти, 0,49-0,59 мс, поэтому на графике его нет.

10 планов 0,50 / 0,54 H2 в памяти 0,84 / 0,83 H2 по TCP 0,78 / 0,76 PostgreSQL, локально 6,54 README, Docker 40 планов 0,50 / 0,46 H2 в памяти 0,82 / 0,82 H2 по TCP 0,91 / 0,83 PostgreSQL, локально 6,16 README, Docker 120 планов 0,49 / 0,47 H2 в памяти 1,22 / 1,25 H2 по TCP 1,22 / 1,18 PostgreSQL, локально 7,02 README, Docker 0 1 2 3 4 5 6 7 откат минус копия, мс (по 2 прохода)
Столбец это среднее 2 проходов, в подписи оба. H2 по TCP совпадает с локальным Postgres на каждом размере. Столбец README это одно число из августа.

H2 по TCP дал 0,84 / 0,83, 0,82 / 0,82 и 1,22 / 1,25 мс. Это в пределах 0,1 мс от локального Postgres на каждом размере, включая подъём на 120 планах. Значит, на этом харнессе большая часть разрыва между H2 и Postgres приходится на сокет и сервер за ним. В моей фразе не было ни того, ни другого. Я проверил ещё раз на той же виртуалке, когда её грузила другая задача. Тогда H2 по TCP оказался на 0,04-0,23 мс выше Postgres на 40 планах, так что 0,1 мс это число с тихой машины.

Запись и сброс различаются меньше, чем я думал. На H2 в памяти обновление заняло 238-291 мкс, на Postgres 312-389 мкс. Шаг отката занял 78-90 мкс против 188-205 мкс, причём Postgres платит за него ещё и обращением к серверу.

Большая часть подъёма с ростом планов приходится на чтение обратно. Каждое превью читает все поля модели, 42 строки на 10 планах и 482 на 120. На Postgres это чтение заняло 387 мкс и 1006 мкс. H2 в памяти отдаёт те же строки за 128 и 174 мкс, думаю, потому что через сокет не идёт ни байта. Мой README назвал накладные расходы “a constant” и сделал это главным выводом. На локальном Postgres они выросли примерно в полтора раза, с 0,76-0,78 мс на 10 планах до 1,18-1,22 мс на 120.

Обращения к серверу

Превью с откатом на Postgres это 3 обращения к серверу: пачка из 4 обновлений, select и откат. Я посчитал их маленьким прокси между драйвером и базой. 100 превью добавили 300 записей клиента в сокет к тем 12, которые без единого превью ушли на подготовку схемы и начальных строк.

Тот же прокси умеет придерживать каждую запись клиента на заданное время, прежде чем передать её дальше. Это грубая модель более длинного пути до базы. Я прогнал через него неизменённый Bench на 40 планах с 5 разными задержками.

0 2 4 6 8 0 250 500 1000 2000 задержка на каждое обращение, мкс штраф, мс README, 40 планов: 6,16 +3 × задержка (без прокси: 0,83-0,91) 2,12 2,85 4,40 7,52 1,12
После первой задержки измеренный штраф идёт примерно на 0,25 мс выше линии 3 обращений. Уровень README достигается между 1000 и 2000 мкс на обращение.

Без задержки сам прокси добавил в том прогоне около 0,2-0,3 мс, 1,12 мс против 0,83-0,91 напрямую. Первые 250 мкс задержки добавили 1 мс, больше, чем 3 раза по 250. Дальше линия идёт вверх примерно на 3 мкс на каждую микросекунду задержки, это и есть 3 обращения. Лишние 0,25 мс, скорее всего, набегают оттого, что прокси каждый раз спит дольше заданного, но отдельно я это не мерил. При 2 мс на обращение штраф составил 7,52 мс, чуть больше чисел README.

Между измеренными точками на 1000 и 2000 мкс уровень 6,16 мс приходится примерно на 1,6 мс на обращение. Это только арифметика. Августовского стенда у меня здесь нет. Этот харнесс выходит на числа README при такой длительности обращения, но утверждать, что у Docker на той машине было именно такое, я не могу.

Что я бы поменял в README

10 секунд можно оставить, с пометкой, что первый запуск занимает около 25. Во фразе про H2 неверна причина. H2 в памяти даёт тот же вердикт, что и Postgres, а число отличается от локального Postgres меньше чем в 3 раза. У него нет сокета, а H2 в режиме сервера его возвращает. Таблица результатов должна говорить, какой Postgres мерили и насколько он был далеко. По моим числам это расстояние составляет большую часть тех 6 мс.

Вывод исходного README не меняется. 1 мс или 6 мс за дебаунсом в 400 мс пользователь не заметит. Значение имеет цена в строках, записанных и заблокированных на каждое превью.

Чего я не проверял

Docker я не запускал вообще, ни на какой машине. Нет и конкурентного доступа, который README уже называет следующим шагом и где блокировки начинают обходиться дорого. H2 был только 2.3.232, драйвер только pgjdbc 42.7.4. Сетка прошла дважды, серия с прокси один раз. Для разбора по шагам я брал одну JVM на ячейку, так что его микросекунды приблизительные. На этой виртуалке расчёт ещё и до 2,6 раза быстрее, чем на августовской машине. Копия в памяти занимает 0,17 мс на 120 планах против 0,45 в README. Расчёт в разности сокращается, потому что его выполняют обе половины пары. Но драйвер и база работают на том же процессоре, так что на более медленной машине мои 0,9 мс, вероятно, тоже бы выросли.