Привет, Хабр!

Большинство JVM-бэкендов работают с БД через JDBC. Даже если в коде только Spring Data, JPA, Slick, Quill, jOOQ или MyBatis, внизу почти всегда один и тот же стек: DataSourceConnectionStatement/PreparedStatementResultSet.

Проблема в том, что JDBC обычно изучают «снизу вверх по методам API»: вот executeQuery, вот commit. Для повседневной разработки этого хватает. Для проектирования, рефакторинга и оптимизации под нагрузкой нужен другой взгляд: общая архитектура, главные части и связи между ними.

Оглавление

  1. Карта архитектуры JDBC

  2. Главные части и связи между ними

  3. Driver и автоматическая загрузка реализации


1. Карта архитектуры JDBC

1.1. Слои стека

JDBC — стандартный Java API для взаимодействия с реляционными БД. Он является частью многослойной архитектуры, в которой API отделён от драйвера, пула соединений и сетевого протокола СУБД:

┌─────────────────────────────────────────────────────────────┐
│  Application / ORM / SQL-framework                          │
│  (Spring Data, JPA, jOOQ, Slick, Quill, MyBatis, JDBC API)  │
├─────────────────────────────────────────────────────────────┤
│  Connection pool (часто реализует DataSource)               │
│  HikariCP, Tomcat JDBC, ...                                 │
├─────────────────────────────────────────────────────────────┤
│  JDBC API  (Java SE / JDBC specification)                   │
│  java.sql.* / javax.sql.*                                   │
├─────────────────────────────────────────────────────────────┤
│  JDBC Driver (vendor implementation)                        │
│  postgresql-*.jar, mysql-connector-j, ojdbc, ...            │
├─────────────────────────────────────────────────────────────┤
│  DB wire protocol + session                                 │
│  Postgres Frontend/Backend, MySQL protocol, ...             │
├─────────────────────────────────────────────────────────────┤
│  СУБД                                                       │
└─────────────────────────────────────────────────────────────┘
Кто такие Frontend и Backend в протоколе PostgreSQL

Frontend/Backend Protocol в PostgreSQL — официальный термин для клиент-серверного протокола БД. Frontend здесь означает клиент, например JDBC-драйвер, а Backend — серверный процесс PostgreSQL. К веб-фронтенду это отношения не имеет.

Ответственность слоёв JDBC-стека

Слой

Что делает

Чего не делает

JDBC API

Описывает интерфейсы и контракты

Не знает Postgres/MySQL, не шлёт пакеты

Driver

Реализует API, говорит на протоколе СУБД

Не управляет пулом приложения

Pool

Управляет числом и lifecycle physical Connection

Не ускоряет SQL и не заменяет транзакционную модель

App/ORM

Доменные операции, mapping, кэши

Часто скрывает JDBC, но не убирает его из runtime

Ключевое разделение: спецификация (API) ≠ реализация (driver) ≠ эксплуатационная обёртка (pool). Тюнинг «JDBC» почти всегда означает тюнинг связки driver + pool + SQL + transaction boundaries.

1.2. Объектная модель

На уровне API основные объекты JDBC связаны порядком создания и общим жизненным циклом:

Driver / DataSource
        │
        ▼
   Connection  ──────────────────────────────┐
        │                                    │
        ├── Statement                        │  transaction state
        ├── PreparedStatement                │  autoCommit, isolation,
        ├── CallableStatement                │  savepoints
        │         │                          │
        │         ▼                          │
        │      ResultSet  ◄── fetch/cursor ──┘
        │
        └── DatabaseMetaData (опционально)
Как работает autoCommit

autoCommit — это режим Connection, который определяет, кто завершает транзакцию. По умолчанию он включён: каждый успешно завершённый statement JDBC автоматически фиксирует отдельным commit. Для запроса statement считается завершённым после обработки связанного ResultSet. При autoCommit=false несколько statement’ов можно объединить в одну транзакцию, но приложение обязано явно вызвать commit() или rollback(). Отключённый autoCommit без корректного завершения транзакции удерживает connection, а на стороне СУБД может удерживать locks и состояние idle in transaction.

Что делает commit на уровне БД

Вызов Connection.commit() завершает текущую транзакцию и фиксирует выполненные в ней изменения. После этого изменения становятся доступны другим транзакциям с учётом их уровня изоляции, а связанные с завершённой транзакцией locks освобождаются. COMMIT не закрывает JDBC Connection: соединение остаётся открытым или возвращается в pool после close().

Как Savepoint позволяет откатить часть транзакции

Savepoint — именованная точка внутри текущей транзакции. Вызов rollback(savepoint) отменяет операции, выполненные после этой точки, но сохраняет более ранние изменения и не завершает транзакцию.

Основные правила жизненного цикла:

  • Connection — корень сессии с БД со стороны клиента;

  • Statement/PreparedStatement создаются из Connection и живут в его контексте;

  • ResultSet создаётся statement’ом и обычно привязан к нему;

  • транзакционное состояние (autoCommit, isolation, open transaction) живёт на Connection, не на Statement;

  • при закрытии Connection драйвер также закрывает созданные через него Statement и ResultSet. Использовать эти объекты после закрытия соединения нельзя.

1.3. Два пакета JDBC API в Java SE

JDBC API входит в Java SE и разнесён по двум пакетам:

  • java.sql — базовый клиентский контракт: Driver, Connection, Statement, ResultSet, SQLException;

  • javax.sql — расширения для работы с источниками данных, пулами и распределёнными транзакциями: DataSource, ConnectionPoolDataSource, XADataSource, RowSet и т.п.

Префикс javax здесь не означает Jakarta EE: оба пакета относятся к JDBC в Java SE. Jakarta EE использует JDBC и интегрирует его с JPA, JTA и контейнерной инфраструктурой, но не определяет основной JDBC API.

В современном приложении вы почти всегда работаете через DataSource, даже если пишете «голый JDBC». DriverManager остаётся низкоуровневым механизмом discovery/connect.


2. Главные части и связи между ними

Сначала определим основные компоненты JDBC и прямые связи между ними. В следующих разделах отдельно разберём назначение и жизненный цикл каждого компонента.

Основные компоненты JDBC и их связи

Часть

Роль в архитектуре

С чем связана напрямую

Driver + SPI

Обнаружение и загрузка vendor-реализации в JVM

URL, DriverManager, classloader

DataSource / Pool / Connection

Получение и удержание сессии

Driver, transactions, statements

Statement hierarchy

Выполнение SQL / bind / batch

Connection, ResultSet, plan/prepare

ResultSet

Чтение результата, fetch, cursor

Statement, Connection, heap/network

Transaction state

Границы транзакции и правила видимости данных

Connection, locks на СУБД, pool checkout

Types / timeouts / errors

Сквозные механизмы

Все уровни: API → driver → DB

Как JDBC загружает драйвер через SPI

SPI (Service Provider Interface) — стандартный механизм Java для поиска и загрузки реализаций интерфейса во время запуска. В JDBC через SPI обнаруживаются реализации java.sql.Driver из JAR-файлов в classpath. Благодаря этому в JDBC 4+ обычно не требуется явно вызывать Class.forName(...).

Когда начинается и заканчивается checkout connection

Checkout connection — выдача соединения из пула приложению при вызове DataSource.getConnection(). Checkout заканчивается после Connection.close(), когда соединение возвращается в пул. Долгий checkout означает, что приложение долго удерживает соединение и уменьшает число доступных слотов пула.

2.1. Последовательность работы: от загрузки драйвера до возврата соединения в пул

1) Driver регистрируется (SPI / explicit)
2) DataSource/pool создаёт или переиспользует Connection
3) App checkout Connection
4) На Connection выставляется transaction mode
5) Создаётся Statement/PreparedStatement
6) Execute → (опционально) ResultSet
7) Чтение ResultSet / обработка update count
8) commit/rollback (если не autoCommit)
9) close ResultSet/Statement
10) close Connection (= return to pool в типичном setup)

Ошибка на любом шаге — это не локальный fail метода. Она затрагивает:

  • состояние connection (нужен rollback / eviction);

  • занятость pool slot;

  • возможные locks/cursors на стороне СУБД.

Когда пул удаляет соединение через eviction

В контексте connection pool eviction — это принудительное удаление physical connection из пула. Пул закрывает соединение и больше не выдаёт его приложению, например после fatal error драйвера, разрыва сокета, failed validation или истечения maxLifetime. rollback завершает неуспешную транзакцию на исправном connection, а eviction полностью удаляет connection, который больше нельзя безопасно переиспользовать.

2.2. Ресурсы с жёсткими ограничениями

В высоконагруженных системах важны не только объекты JDBC API, но и ограниченные системные ресурсы, которые за ними стоят:

Ограниченные ресурсы JDBC-приложения

Ресурс

Где учитывается

Чем исчерпывается

Physical TCP/TLS session

Driver + DB max_connections

Создание connection без pool / слишком большой pool

Pool slot

Hikari/etc maximumPoolSize

Долгий checkout, открытый ResultSet, широкая TX

App thread

Thread pool / virtual thread

Blocking wait на execute / fetch

Server cursor / portal

СУБД session

Незакрытый ResultSet, server-side cursor

Client heap

JVM

Загрузка большого ResultSet в коллекцию

Plan/statement cache

Driver session + СУБД

Нестабильный SQL, отсутствие bind

Почему широкая транзакция долго удерживает ресурсы

Широкая TX — транзакция с большой областью действия: она долго выполняется или включает SQL, вычисления и внешние вызовы. Всё это время connection занят, а БД может удерживать locks.

Как server cursor и portal удерживают соединение

Server cursor хранит на стороне БД состояние запроса и позволяет получать результат порциями. Portal — близкий механизм в PostgreSQL wire protocol. Пока cursor/portal открыт, он занимает ресурсы сессии и обычно удерживает JDBC connection.

JDBC API не предоставляет единой модели учёта этих ресурсов. Их использование зависит от жизненного цикла связанных объектов. Например, открытый ResultSet удерживает соединение из пула. При порционном чтении СУБД также хранит состояние незавершённого запроса до закрытия результата.

2.3. За что отвечают JVM, приложение, драйвер, пул и СУБД

Ответственность распределена так:

  • JVM/SPI — какие driver implementation видны;

  • Pool — сколько physical connection существует и кто их арендует;

  • Application — границы транзакций, SQL, время удержания connection;

  • Driver — формирование сообщений протокола, режим prepare, стратегия fetch, кодирование типов;

  • СУБД — locks, isolation semantics, plan cache, session limits.

Что происходит при protocol framing

Protocol framing — формирование и разбор сетевых сообщений между драйвером и БД: кодирование SQL и параметров, добавление служебных полей, обработка строк результата и ошибок.

Как plan cache ускоряет повторные запросы

Plan cache — механизм СУБД для повторного использования ранее подготовленного плана выполнения SQL. Он уменьшает затраты на повторный разбор и оптимизацию запроса; конкретное поведение зависит от СУБД и типа statement.


3. Driver и SPI: как появляется реализация

3.1. Место в архитектуре

Driver — точка входа vendor-кода в JDBC-модель. Без driver’а API — набор интерфейсов без runtime.

Связи:

  • DriverManager использует список Driver для getConnection(url);

  • DataSource конкретной реализации (или pool) внутри тоже опирается на driver/connect path;

  • URL jdbc:<subprotocol>:... — ключ маршрутизации к нужному driver.

3.2. Discovery

До JDBC 4 драйвер загружали явно:

Class.forName("org.postgresql.Driver");
Connection c = DriverManager.getConnection(url, user, password);

Начиная с JDBC 4 драйвер загружается автоматически. Java использует ServiceLoader, чтобы найти реализацию java.sql.Driver, указанную в файле META-INF/services/java.sql.Driver внутри JAR драйвера.

Процесс открытия соединения в упрощённом виде:

  1. обход зарегистрированных Driver;

  2. acceptsURL(url);

  3. connect(url, info);

  4. TCP/TLS + auth + session setup → Connection.

3.3. Что важно на практике

  • Версия драйвера — часть контракта поведения (fetch defaults, binary mode, batch rewrite, prepare cache).

  • Один и тот же JDBC-код на разных driver’ах даёт разный runtime profile.

  • В приложении driver часто «невидим», потому что спрятан за pool/ORM, но именно он определяет wire-поведение.


Далее мы рассмотрим DataSource, пул и Connection, а затем Statement, prepare и batch.


Если Вам понравилась статья, то буду рад видеть в моем tg канале!


Безопасных вам релизов!