Pull to refresh

HikariCP — самый быстрый пул соединений на java

Reading time7 min
Views100K
Java недавно стукнуло 20 лет. Казалось бы, на сегодняшний день на java написано все. Любая идея, любой проект, любой инструмент на java? — это уже есть. Тем более когда речь идет о таких банальных вещах как пул соединений к базе данных, который используют миллионы разработчиков по всему миру. Но не тут то было! Встречайте — проект HikariCP — самый быстрый на сегодняшний день пул соединений на java.

HikariCP — еще один яркий пример того, что всегда стоить брать под сомнение эффективность некоторых решений, даже если их используют миллионы людей и живут они десятки лет. Хикари — прекрасный пример того, как микро оптимизации, которые по отдельности никогда не смогут дать вам больше 0.00001% прироста — в совокупности позволяют создать очень быстрый и эффективный инструмент.

Этот пост — вольный и частичный перевод статьи Down the Rabbit Hole от автора HikariCP перемешанный с потоком моего сознания.

image



Down the Rabbit Hole



Эта статья — рецепт нашего секретного соуса. Когда Вы начинаете просматривать разного рода бенчмарки, у Вас, как у нормального человека, должна возникнуть к ним здравая доля скептицизма. Когда Вы думаете о производительности и пуле соединений, трудно избежать коварной мысли о том, что пул — самая важная ее часть. На самом деле, это не совсем так. Количество вызовов getConnection() в сравнении с другими операциями типичного JDBC довольно мало. Огромное число улучшений производительности достигается за счет оптимизации враперов вокруг Connection, Statement, и тд.

Для того чтобы сделать HikariCP быстрым (каким он и является), нам пришлось копнуть до уровня байткода и ниже. Мы использовали все известные нам трюки чтобы JIT помог Вам. Мы изучали скомпилированный байткод для каждого метода и даже изменяли методы так, чтобы они попадали под лимит инлайнинга. Мы уменьшали количество уровней наследования, ограничивали доступ к некоторым переменным, чтобы уменьшить область их видимости и удаляли любые приведения типов.
Иногда, видя что метод превышает лимит инлайнинга, мы думали о том как изменить его таким образом, чтобы избавится от нескольких байт-инструкций. Например:

public SQLException checkException(SQLException sqle) {
    String sqlState = sqle.getSQLState();
    if (sqlState == null)
        return sqle;

    if (sqlState.startsWith("08"))
        _forceClose = true;
    else if (SQL_ERRORS.contains(sqlState))
        _forceClose = true;
    return sqle;
}


Достаточно простой метод, который проверяет, есть ли ошибка потери соединения. А теперь байткод:

0: aload_1
1: invokevirtual #148                // Method java/sql/SQLException.getSQLState:()Ljava/lang/String;
4: astore_2
5: aload_2
6: ifnonnull     11
9: aload_1
10: areturn
11: aload_2
12: ldc           #154                // String 08
14: invokevirtual #156                // Method java/lang/String.startsWith:(Ljava/lang/String;)Z
17: ifeq          28
20: aload_0
21: iconst_1
22: putfield      #144                // Field _forceClose:Z
25: goto          45
28: getstatic     #41                 // Field SQL_ERRORS:Ljava/util/Set;
31: aload_2
32: invokeinterface #162,  2          // InterfaceMethod java/util/Set.contains:(Ljava/lang/Object;)Z
37: ifeq          45
40: aload_0
41: iconst_1
42: putfield      #144                // Field _forceClose:Z
45: aload_1
46: return


Наверное ни для кого уже не секрет, что лимит инлайнинга в Hostpot JVM — 35 байткод инструкций. Поэтому мы уделили некоторое внимание этому методу, чтобы сократить его и изменили его следующим образом:

String sqlState = sqle.getSQLState();
if (sqlState != null && (sqlState.startsWith("08") || SQL_ERRORS.contains(sqlState)))
    _forceClose = true;
return sqle;


Получилось довольно близко к лимиту, но все еще 36 инструкций. Поэтому мы сделали так:

String sqlState = sqle.getSQLState();
    _forceClose |= (sqlState != null && (sqlState.startsWith("08") || SQL_ERRORS.contains(sqlState)));
return sale;


Выглядит проще. Неправда ли? На самом деле, этот код хуже предыдущего — 45 инструкций.
Еще одна попытка:

String sqlState = sqle.getSQLState();
if (sqlState != null)
     _forceClose |= sqlState.startsWith("08") | SQL_ERRORS.contains(sqlState);
return sqle;


Обратите внимание на использование унарного ИЛИ (|). Это отличный пример жертвования теоретической производительностью (так как в теории || будет быстрее) ради реальной производительности (так как метод теперь будет заинлайнен). Байткод результата:

0: aload_1
1: invokevirtual #153                // Method java/sql/SQLException.getSQLState:()Ljava/lang/String;
4: astore_2
5: aload_2
6: ifnull        34
9: aload_0
10: dup
11: getfield      #149                // Field forceClose:Z
14: aload_2
15: ldc           #157                // String 08
17: invokevirtual #159                // Method java/lang/String.startsWith:(Ljava/lang/String;)Z
20: getstatic     #37                 // Field SQL_ERRORS:Ljava/util/Set;
23: aload_2
24: invokeinterface #165,  2          // InterfaceMethod java/util/Set.contains:(Ljava/lang/Object;)Z
29: ior
30: ior
31: putfield      #149                // Field forceClose:Z
34: return


Как раз ниже лимита в 35 байткод инструкций. Это маленький метод и на самом деле даже не высоконагруженный, но идею Вы поняли. Небольшие методы не только позволяют JITу встраивать их в код, они так же означают меньше фактических машинных инструкций, что увеличивает количество кода, который поместится в L1 кэше процессора. Теперь умножьте все это на количество таких изменений в нашей библиотеке и Вы поймете почему HickaryCP действительно быстр.

Микро оптимизации



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

ArrayList



Одной из самых не тривиальных оптимизаций было удаление коллекции ArrayList<Statement> в классе ConnectionProxy, которая использовалась для отслеживания открытых объектов Statement. Когда Statement закрывается, он должен быть удален из этой коллекции. Также, в случае если закрывается соединение — нужно пройтись по коллекции и закрыть любой открытый Statement и уже после — очистить коллекцию. Как известно ArrayList осуществляет проверку диапазонов индекса на каждый вызов get(index). Но, так как мы можем гарантировать выбор правильного индекса — эта проверка излишня. Также, реализация метода remove(Object) осуществляет проход от начала до конца списка. В тоже время общепринятый паттерн в JDBC — или сразу закрывать Statements после использования или же в порядке обратном открытию (FILO). Для таких случаев, проход, который начинается с конца списка — будет быстрее. Поэтому мы заменили ArrayList<Statement> на FastStatementList в котором нету проверки диапазонов и удаление элементов из списка начинается с конца.

Медленный синглтон



Для того, чтобы сгенерировать прокси для объектов Connection, Statement, ResultSet HikariCP изначально использовал фабрику синглтонов. В случае, например, ConnectionProxy эта фабрика находилось в статическом поле PROXY_FACTORY. И в коде было несколько десятков мест, которые ссылались на это поле.

public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException
{
    return PROXY_FACTORY.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
}


В байткоде это выглядело так:

public final java.sql.PreparedStatement prepareStatement(java.lang.String, java.lang.String[]) throws java.sql.SQLException;
flags: ACC_PRIVATE, ACC_FINAL
Code:
 stack=5, locals=3, args_size=3
 0: getstatic     #59                 // Field PROXY_FACTORY:Lcom/zaxxer/hikari/proxy/ProxyFactory;
 3: aload_0
 4: aload_0
 5: getfield      #3                  // Field delegate:Ljava/sql/Connection;
 8: aload_1
 9: aload_2
 10: invokeinterface #74,  3           // InterfaceMethod java/sql/Connection.prepareStatement:(Ljava/lang/String;[Ljava/lang/String;)Ljava/sql/PreparedStatement;
 15: invokevirtual #69                 // Method com/zaxxer/hikari/proxy/ProxyFactory.getProxyPreparedStatement:(Lcom/zaxxer/hikari/proxy/ConnectionProxy;Ljava/sql/PreparedStatement;)Ljava/sql/PreparedStatement;
 18: return


Вы можете увидеть, что первым идет вызов getstatic, чтобы получить значение статического поля PROXY_FACTORY. Так же обратите внимание на последний вызов invokevirtual для метода getProxyPreparedStatement() объекта ProxyFactory.
Оптимизация заключалась в том, что мы удалили фабрику синглтонов и заменили ее классом со статическими методами. Код стал выглядеть так:

public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException
{
    return ProxyFactory.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
}


Где getProxyPreparedStatement() — статический метод класса ProxyFactory. А вот так выглядит байткод:

private final java.sql.PreparedStatement prepareStatement(java.lang.String, java.lang.String[]) throws java.sql.SQLException;
flags: ACC_PRIVATE, ACC_FINAL
Code:
 stack=4, locals=3, args_size=3
 0: aload_0
 1: aload_0
 2: getfield      #3                  // Field delegate:Ljava/sql/Connection;
 5: aload_1
 6: aload_2
 7: invokeinterface #72,  3           // InterfaceMethod java/sql/Connection.prepareStatement:(Ljava/lang/String;[Ljava/lang/String;)Ljava/sql/PreparedStatement;
 12: invokestatic  #67                 // Method com/zaxxer/hikari/proxy/ProxyFactory.getProxyPreparedStatement:(Lcom/zaxxer/hikari/proxy/ConnectionProxy;Ljava/sql/PreparedStatement;)Ljava/sql/PreparedStatement;
 15: areturn


Здесь следует обратить внимание сразу на 3 момента. Вызова getstatic больше нету. invokevirtual был заменен на invokestatic, который в свою очередь лучше оптимизируется виртуальной машиной. И последний момент, который трудно заметить — размер стека уменьшился с 5-ти элементов до 4-х. Так как до оптимизации в случае с invokevirtual на стек должна так же прийти ссылка на сам объект ProxyFactory. Это значит и дополнительную pop инструкцию для получения этой ссылки из стека в момент вызова getProxyPreparedStatement(). В общем, если просуммировать, то мы избавились от доступа к статическому полю, убрали лишние операции push и pop на стеке и сделали вызов метода более пригодным для оптимизации JIT.

Конец.

Полный оригинал Down the Rabbit Hole.

UPDATE:
В комментариях часть статьи «Медленный синглтон» вызвала много обсуждений. apangin утверждает, что все эти микро оптимизации бессмысленны и не дают никакого прироста. В коментарии приводится простой бенчмарк одинаковой стоимости invokeVirtual и invokeStatic. А тут бенчмарк пула соединений одноклассников, который якобы в 4 раза быстрее HickaryCP. На что автор HickaryCP дает следующий ответ:

First I would like to comment on @odnoklassniki comment that their pool is 4x faster. I have added their pool to the JMH benchmark and committed the changes for anyone to run. Here is the result vs. HikariCP:

./benchmark.sh clean quick -p pool=one,hikari ".*Connection.*"

Benchmark                       (pool)   Mode  Cnt      Score      Error   Units
ConnectionBench.cycleCnnection     one  thrpt   16   4991.293 ±   62.821  ops/ms
ConnectionBench.cycleCnnection  hikari  thrpt   16  39660.123 ± 1314.967  ops/ms


This is showing HikariCP at 8x faster than one-datasource.

Keep in mind that not only has HikariCP changed since that wiki page was written, but the JMH test harness itself has changed. In order to recreate the results I got at that time, I checked out HikariCP source with that specific commit, and checked out the source just before that commit. I ran both using the benchmark harness available at that time:

Before static proxy factory methods:
Benchmark                             (pool)   Mode   Samples         Mean   Mean error    Units
ConnectionBench.testConnectionCycle   hikari  thrpt        16     9303.741       67.747   ops/ms


After static proxy factory methods:
Benchmark                             (pool)   Mode   Samples         Mean   Mean error    Units
ConnectionBench.testConnectionCycle   hikari  thrpt        16     9436.699       71.268   ops/ms


It shows a minor improvement after the change that is above the mean error.

Typically, every change is checked with the benchmark before being committed, so it is doubtful that we would have committed that change unless the benchmark showed improvement.

EDIT: And wow has HikariCP performance improved since January 2014!
Tags:
Hubs:
+18
Comments74

Articles