DataSource and HikariCP: Pool Internals and Tuning
A database connection is a phone line: dialling it goes through the exchange (the TCP handshake), you state your name and password (authentication), then you agree on language and time zone (session initialisation) — 5–20 ms in total. But the sentence you actually want to say (one indexed SQL statement) usually takes under 1 ms. In other words, if every request dials from scratch, nine tenths of your bill is for "getting through", not for "talking". A connection pool does exactly one thing: lay out a batch of already-dialled lines, let anyone borrow one, and hang up when done — and note that "hanging up" does not cut the line, it only marks it idle.
Six terms, one line each (used throughout):
- Connection: an established channel between your app and the database; underneath it sits a TCP socket, which is expensive to build — hence precious
- DataSource: a JDBC-spec interface with a single contract,
getConnection()— "give me a usable connection". Whether it is brand new or borrowed is none of the caller's business - Connection pool: the rental station that implements
DataSource; it pre-creates, reuses, health-checks and recycles connections - HikariCP: the pool Spring Boot ships by default (the name means "light" in Japanese); fast because of lock-free borrowing, proxy wrapping and background housekeeping
- Borrow / return: taking a connection out of the pool is borrowing; calling
close()afterwards is returning it. Inside a poolclose()does not mean disconnect - Leak: borrowed but never returned — one thread keeps gripping a connection while everyone else queues, and the pool slowly drains dry
a connection pool is the bicycle-share dock at your apartment gate. Riding costs almost nothing; what costs is "manufacturing a bike and trucking it here" (TCP + authentication). So somebody decided to park a batch of bikes up front — scan, ride, dock it again. The correspondences are worth memorising: the number of docking slots = maximumPoolSize; every bike ridden so you can only stand there = requests queueing in the pool (pending); you scan, wait thirty seconds, give up and walk = connectionTimeout firing; riding a bike home and never docking it = a connection leak, and tomorrow the whole estate has no bikes; the overnight crew hauling away rusted frames = HikariCP's HouseKeeper evicting connections past maxLifetime / idleTimeout. And the counter-intuitive part: more slots does not mean faster riding — the road (your database) is only so wide, and too many bikes just creates a jam.

The left side is the real bill for "dial a fresh connection every time"; the right side is what pooling gives you. Everything else in this article answers two questions: why the right side is mandatory, and where exactly to set its knob.
After this article you should be able to answer three questions:
- Why does Spring Boot reach the database without a single line of pool configuration? Who is that "default pool"?
- Is
maximumPoolSizebetter when bigger? If not, why does the official rule of thumb come out as something as small as "cores × 2 + spindles"? - Production suddenly floods with
Connection is not available, request timed out after 30004ms— which metric do you look at first, and which config line?
Turn the knob first and get a feel for it — this is the article's only sandbox; change one parameter and the three numbers on the right move immediately:
pending: 0P99 acquire time: 2msDB Threads_connected: 10/200hikaricp.connections.usage ≈ 6.8ms per use
the one constraint worth burning in from that sandbox is pool size × app instance count < database max_connections. The database's connection ceiling is the global bottleneck; pools merely divide it into slices.
"Why not open a new database connection on every request?" Start with the bill. Establishing a genuinely usable connection means doing all of this, in order:
| Stage | Rough cost | What happens |
|---|---|---|
| TCP three-way handshake | 1–3 ms (same rack) | A reliable channel between client and database |
| TLS handshake (if enabled) | 2–10 ms | Certificate check, key negotiation |
| Authentication | 2–5 ms | The database verifies user / password and privileges |
| Session initialization | 1–3 ms | Charset, time zone, connection properties |
| Total | about 5–20 ms | A new connection repeats all of it |
And an ordinary indexed query? Often under 1 ms. So if every request opened a connection, "establishing the connection" would cost more than ten times "doing the actual work" — the core contradiction.
Connection setup is expensive, yet the connection itself is a reusable long-lived channel. The natural optimisation follows: pre-create a batch of connections and recycle them — borrow when needed, return when done. That is the idea of pooling — database connection pools, thread pools, object pools, all the same trick: spread the cost of creating an expensive resource across N reuses.

The previous article left a thread: JdbcTemplate never creates connections itself; it asks a DataSource. DataSource is the standard interface defined by the JDBC spec, and getConnection() is the whole contract — whether the returned connection is brand new or borrowed from a pool is none of the caller's business. That is the beauty of programming to an interface: switch the implementation and not a line of business code changes.
Four implementations dominate:
| Implementation | Boot default | Performance | Monitoring | Notes / ecosystem |
|---|---|---|---|---|
| HikariCP | ✅ Yes | Extremely high (lock-free) | Built-in Micrometer metrics + JMX | Lightweight, fast startup, few dependencies — the Boot favourite |
| Druid | No | High | Very strong (built-in console + SQL firewall) | From Alibaba; excels at monitoring and defence, many options |
| DBCP2 | No | Medium | Basic JMX | Apache veteran; stable but conservative |
| Tomcat JDBC Pool | No | High | Basic JMX | Same origin as embedded Tomcat; async connection retrieval |
since Spring Boot 2.x, HikariCP is the default pool — if it is on the classpath, DataSourceAutoConfiguration wires it automatically. So "which pool does Spring Boot use" is simply HikariCP, unless you explicitly set spring.datasource.type.
HikariCP is fast not through black magic but by driving every source of overhead in a high-concurrency pool down to near zero. The key designs:
HikariPool ├── ConcurrentBag<PoolEntry> # connection store: ThreadLocal + lock-free stack, nearest-first ├── FastList<Statement> # for closing statements: an ArrayList without bounds checks ├── ProxyConnection # the connection handed out: proxies close / state validation └── HouseKeeper # background thread: keepalive, evict over-aged and idle connections- ConcurrentBag: the heart of the store. It combines "each borrower's own
ThreadLocallist + a lock-free shared stack", so the same thread borrows and returns with almost no contention — a key reason it beats other pools - FastList: replaces
ArrayListfor holding a connection'sStatements, dropping the bounds check andindexOfscan on everyremove; closing statements is faster - ProxyConnection: the
Connectionyou receive is actually a proxy. Callingclose()does not really close the physical connection — the proxy returns it to the pool and resets its state, while also catching misuse such as using a connection after it is returned - HouseKeeper: a background thread that periodically does two things — pings idle connections (
keepaliveTime) to keep them alive, and evicts connections pastmaxLifetimeoridleTimeout, avoiding "dead" connections the database has already closed on its side
Key point: HikariCP's speed comes down to one line — use "lock-free + proxy + background governance" to remove every contention and system call from the borrow/return path, and move connection health checks off to a background thread.
All of those class names look familiar, yet each guards a different segment, and first-timers always mix them up. Play a round: pick a component, then pick what it actually saved you — a wrong pair explains itself on the spot.
And "the life of a connection" does not start at getConnection() nor end at close() — when it is born, examined and forcibly retired is all down to a background scheduler. Click through this ring; box ③ is the one you control:
With the structure clear, one full "borrow and return" follows naturally:

That one is the pool's point of view. The animation below switches to a single request thread's point of view: the same getConnection() first checks its own thread's fast lane, then the shared stack, creates one only if nothing is free, and queues when the cap is hit — seven steps before you can see where a timeout actually comes from.

- The business code calls
getConnection(): triggered insideJdbcTemplatebefore it runs SQL - Look for an idle connection:
ConcurrentBagfirst tries the current thread's local cache, then the shared area - Hit: borrow directly: the
PoolEntryis wrapped in aProxyConnectionand returned — you hold a proxy - Miss and below the limit: create one: a full TCP + authentication round (the bill from Section 1)
- At the limit: queue and wait: no longer than
connectionTimeout, thenSQLTransientConnectionException - Business calls
close(): return it: the proxy interceptsclose()and returns the connection to the pool (not closing it), resetting session state
this borrow-and-return path is exactly a library lending desk. There are only so many copies on the shelf (maximumPoolSize); the librarian never writes a new book in front of you, they hand over a copy that is on the shelf (an idle hit); when every copy is out you leave your name and wait to be called (queueing, pending); keeping a book forever with nobody chasing you = a leak; and returning a book destroys nothing — it simply puts the copy back into circulation. Returning is not burning; close() is not disconnecting. The ConnectionProxy is your library card: you feel like you are holding a card, while behind every swipe sits one real volume registered to your name.
The line to remember above all: in a connection pool, Connection.close() never means "disconnect" — it means "return". The physical connection is reused by the next request until evicted by maxLifetime / idleTimeout.
// This is why the following is correct: close in try-with-resources really means "return"try (Connection conn = dataSource.getConnection()) { // ... run SQL} // here the connection is returned to the pool; the physical connection stays alive- Business code's "close" is written exactly as before — this is the genius of the pool design: it is transparent to the upper layer
- Only once truly returned can another thread borrow it; borrow without returning (Section 10) drains the pool
- On return, HikariCP resets session state such as
autoCommitand read-only flags, so the next borrower is not polluted
Tuning a pool is really tuning the parameters below. Understand what each governs, then talk about values.
| Parameter | Meaning | Default | Recommendation |
|---|---|---|---|
maximumPoolSize | Maximum connections | 10 | Estimate from the formula; bigger is not better |
minimumIdle | Minimum idle connections | = max | Equal to max avoids resize jitter |
connectionTimeout | Max wait for a connection | 30000 ms | 3000–10000 ms; never 0 (infinite wait) |
idleTimeout | Idle lifetime before eviction | 600000 ms | Only effective when minimumIdle < maximumPoolSize |
maxLifetime | Maximum connection lifetime | 1800000 ms | Must be less than the database wait_timeout |
validationTimeout | Connection validation timeout | 5000 ms | Should be less than connectionTimeout |
keepaliveTime | Heartbeat interval | 0 (off) | Only meaningful below maxLifetime |
Those seven knobs belong to two different worlds: the first four stand on the borrow path, and when they are wrong the application waits for a connection; the last three belong to housekeeping, and when they are wrong you receive a dead connection. Without that split, tuning is guesswork:

So what should maximumPoolSize be? The HikariCP docs cite a counter-intuitive rule of thumb:
maximumPoolSize ≈ number of CPU cores × 2 + effective spindle countIts meaning matters more than the number:
- The bottleneck is usually disk IO and lock waits, not database CPU. More connections only make more threads queue for the disk; a bigger pool just lets more threads queue together
- Connections are "concurrency", not "throughput". On an 8-core box, 16–20 is usually plenty; pushing it to hundreds just burns the database on context switching
- The formula is a starting point, not an answer: the real work is load testing — begin at the formula value and adjust from the metrics
The formula is only a starting point; the feel comes from dragging the number. This console uses the article's own headline knob — slide it from 1 to 200 and the verdict changes all the way along. Watch the far right especially: "no queueing" and "faster" are not the same claim:
- On a 4-8 core box, cores × 2 + spindles lands in exactly this range
- pending stays at 0 while Threads_connected is nowhere near the limit
- That default is not careless — it is the 'neither queueing nor stampeding' band
- Before changing it, ask: is pending climbing, or is the database under pressure?
the default maximumPoolSize of 10 is not "too small" — it is "right for most cases". Measure, then tune. Many "not enough connections" alerts trace back to a single non-indexed slow query; enlarging the pool only hides the problem.
A quick check (the first question is light, the second asks you to read a live incident):
Both extremes are dangerous, but they look completely different — the contrast points straight at the fix:
| Too small | Too large | |
|---|---|---|
| Symptoms | Requests queue, sporadic connectionTimeout, periodic stalls | Database CPU / memory alarms, connections saturated |
| Key metrics | hikaricp.connections.pending ↑, acquire time ↑ | Database Threads_connected near its limit, context switches ↑ |
| Chain reaction | Web thread pool fills up → site-wide outage | Database buckles → every instance slows together |
| Direction | Enlarge moderately + hunt slow SQL | Shrink + optimise SQL / add caching |
"too small" is the app waiting for connections; "too large" is the database being crushed by them. The former is visible in the pool metrics; the latter requires looking at Threads_connected versus max_connections on the database side — and especially note that pool size × app instance count must not exceed the database max_connections, or scaling up becomes an incident.
Once you have diagnosed "queueing", the next knob decides how long you are willing to keep queueing. connectionTimeout is the one parameter that stays silent when misconfigured and then fails everywhere at once: too short and ordinary peak jitter gets killed; 0 and threads stand in that queue until the Tomcat pool is empty. Slide it and find the edges:
- 30 s is a default, not a recommendation — it comes from a 'do not fail easily' posture
- A synchronous API waiting 30 s stretches every timeout upstream of it
- Batch jobs and message consumers, where response time is not the point, can keep it
- For online endpoints, lower it explicitly to 3-10 s and align it with the gateway
You cannot tune what you cannot see. HikariCP exposes a set of metrics via Micrometer, queryable through Actuator:
# Currently active connectionsGET /actuator/metrics/hikaricp.connections.active# Threads currently waiting for a connection (sustained > 0 means the pool is small)GET /actuator/metrics/hikaricp.connections.pending# Connection acquisition time (the clearest sign of congestion)GET /actuator/metrics/hikaricp.connections.acquire# Current idle / total connectionsGET /actuator/metrics/hikaricp.connections.idleGET /actuator/metrics/hikaricp.connections.maxhikaricp.connections.pendingis the most sensitive early-warning signal: sustained above 0 means requests are queueing, and the pool should grow (or the SQL should improve)- The percentiles (P99) of
hikaricp.connections.acquireshow jitter in acquisition directly - Correlate pool metrics with slow query logs: more slow SQL → longer hold times → rising pending. Once that causal chain lines up, the root cause is clear
Tip: these metrics require spring-boot-starter-actuator with the metrics endpoint exposed. In production, feed hikaricp.connections.* into Prometheus + Grafana and add an alert on "pending > 0 for 1 minute" — far earlier than a user complaint.
A copy-ready config with every key parameter:
spring: datasource: url: jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai username: root password: ${DB_PASSWORD} hikari: pool-name: HikariPool-1 maximum-pool-size: 20 minimum-idle: 5 # let the pool shrink at off-peak and grow at peak connection-timeout: 3000 # fail fast in 3s, don't leave threads hanging idle-timeout: 600000 # recycle surplus connections after 10 idle minutes max-lifetime: 1200000 # 20 minutes, must be less than MySQL wait_timeout validation-timeout: 3000 keepalive-time: 300000 # a heartbeat every 5 minutesAt startup the log states the pool's status clearly:
HikariPool-1 - Starting...HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@5f1d2b3cHikariPool-1 - Start completed.Starting...: the pool initialises and reads its configurationAdded connection ...: a warm-up connection was established (minimumIdledecides how many)Start completed.: the pool is ready andgetConnection()can borrow immediately- If it stalls here or reports a
Timeout, it is almost always theurl/ credentials / network — for "cannot reach the database", look for these lines in the startup log first
That listing is someone else's answer. Which lines your own project needs is worth generating rather than copying: tick "datasource" alone for the minimum that connects, then add "logging" and "actuator" and watch how the hikaricp.connections.* metrics from Section 7 become queryable:
server:
port: 8080
spring:
application:
name: demo-service
datasource:
url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${DB_USER:root} # ${} 占位符:环境变量优先,冒号后是默认值
password: ${DB_PASS:}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1740000 # 必须小于 MySQL 的 wait_timeout
pool-name: beeHikari
logging:
level:
root: INFO
com.example.demoservice: DEBUG
org.springframework.jdbc.core.JdbcTemplate: DEBUG # 打 SQL 与参数
file:
name: logs/app.log
logback:
rollingpolicy: { max-file-size: 50MB, max-history: 14 }
if the generated actuator block contains exposure.include: '*', edit it back to a whitelist. /actuator/env prints spring.datasource.password in plain text — that is exactly the incident the act lab in Section 12 demonstrates.
When you need read/write splitting or multiple databases, the idea is not "just configure several pools" but a routing data source switching between pools: extend AbstractRoutingDataSource and override determineCurrentLookupKey, keeping the current target in a ThreadLocal; or use dynamic-datasource-spring-boot-starter and annotate a method with @DS("slave"). The core insight is unchanged: each data source sits in front of its own HikariCP pool, and each pool size must be estimated separately.
connection leaks — borrow without returning and the pool drains fast. The symptom is a burst of connectionTimeout after the app has run a while, recovered by a restart and recurring later. Two tools locate it: set spring.datasource.hikari.leak-detection-threshold=20000 (milliseconds) so a connection held longer than 20 seconds logs a stack-trace warning naming the borrower; or take a heap dump and follow the reference chain to ProxyConnection. The root cause is usually a missing try-with-resources, or a manual getConnection() where an early return on some exception branch skips close().
maxLifetime must be less than the database's wait_timeout. MySQL's default wait_timeout is 28800 seconds (8 hours) and it unilaterally closes connections that idle too long. If the pool's maxLifetime is larger, you get "HikariCP thinks the connection is alive while the database has already cut it", and the business hits Communications link failure on a dead connection. HikariCP's default maxLifetime of 30 minutes is usually safe; but once a DBA lowers wait_timeout, maxLifetime must be lowered too — leave margin so the pool evicts connections before the database does.
The second trap (maxLifetime above wait_timeout) shows up as "I received a dead connection". The first one — a leak — is a slow draining: nothing fails on the spot, which is precisely why nobody recognises it in time. Run these six frames together and the "a restart fixes it, then it recurs" fingerprint becomes something you have seen:

The second question reads a live scene (get this one wrong and production will hurt):
The demo below shows how auto-configuration wires the DataSource. Toggle "the user has defined a DataSource" and watch @ConditionalOnMissingBean let auto-configuration step aside for the user's definition — exactly the mechanism that makes the Section 8 config work.
Everything above has been description; this section is hands-on. A connection pool is something you can push buttons on — the five demos below run the real kernel (WASM); click a parameter and it recomputes, so you can watch "idle hit", "create", "queue", "timeout" and "leak" as five genuinely different pictures. Suggested order: run the first to build intuition, then work down the list, and finish with the last two to understand production alerts.
Start with the happy case: the pool holds an idle connection, getConnection() returns at once, and close() hands it back. Notice that the identity of the physical connection in the output never changes — that is reuse.
How to read each parameter:
| Parameter | What you see | Real-world counterpart |
|---|---|---|
| Idle hit | Returns in microseconds; the same physical connection object | Normal state; the vast majority of requests take this road |
| Create when empty | An extra TCP + authentication delay, and Added connection in the log | Cold start, too-small minimumIdle, or a traffic burst |
| Queue at the limit | pending starts climbing while callers block in getConnection() | Pool too small, or slow SQL holding connections |
| Wait timeout | SQLTransientConnectionException: Connection is not available, request timed out after 30004ms | Thirty seconds of queueing with no turn — mass API failure |
| Leak detection | With leakDetectionThreshold enabled, a stack-trace warning names who borrowed without returning | The classic "fine after restart, broken again later" symptom |
these five are not parallel options but five stages of one chain. Click "queue" and "timeout" back to back and you have understood how ninety percent of pool alerts happen: slow SQL or a leak stops connections from circulating → queueing → the queue waits itself into a timeout.
JdbcTemplate never creates connections; it only asks a DataSource. Switch this demo to "what if you forget to release" and you will see the finally that hand-written JDBC most often omits: the moment an exception branch skips close(), that connection never comes back. This is exactly why try-with-resources exists.
Mapping for reference: one row = query, affected rows = update, batch = batch, ResultSet → object = map, leak = leak. The first four belong to article 27; in this article keep your eye on the last one: identical code, with and without finally / try-with-resources, gives the pool two completely different fates.
You cannot tune what you cannot see. spring-boot-starter-actuator turns those hikaricp.connections.* metrics from section 7 into clickable endpoints, and also demonstrates the risk of exposing everything — /actuator/env will happily print your database password.
The minimal beginner-safe configuration (three stanzas, copy and go):
management: endpoints: web: exposure: include: health,info,metrics # only these three; never add env/shutdown in prod endpoint: health: show-details: when_authorized # reports DOWN plus the reason when the DB is unreachableWith that in place you can verify the sandbox numbers directly: curl localhost:8080/actuator/metrics/hikaricp.connections.pending.
An often-missed fact: all statements inside one transaction reuse the same connection, because TransactionSynchronizationManager binds it to a ThreadLocal. So the code below borrows once and returns once:
@Transactionalpublic void transfer(Long from, Long to, BigDecimal amount) { jdbc.update("UPDATE t_account SET balance = balance - ? WHERE id = ?", amount, from); jdbc.update("UPDATE t_account SET balance = balance + ? WHERE id = ?", amount, to); auditLog.record(from, to, amount); // yet another SQL, still the same connection}Change the propagation to REQUIRES_NEW or NOT_SUPPORTED and the number of borrows changes — which means the transaction boundary decides not only whether data rolls back, but also how many connections you hold and for how long.
long transactions are a pool's number-one killer. An open transaction means a gripped connection, even while you call a third-party HTTP API or read a large file. If you spot a remote call inside a @Transactional method, move it outside immediately — otherwise the root cause of your peak-hour pending spike is that, not "the pool is too small".
Enough buttons — type the same things yourself. This console talks to the same in-browser Java kernel and every reply is computed there: start with beans to see which DataSource the container holds, then walk the five stages of a borrow with lab pool:
run lab pool queue and lab pool timeout back to back — the first is "already queueing but nothing has failed yet", the second is what the queue looks like when it expires. Read their output against the pending metric in Section 5 and the first row of the Section 13 table, and the numbers and the stack trace finally connect.
What beginners fear is not the concepts but the wall of red text. This table quotes pool-related errors in a form you can search verbatim (with the key class names and fragments); just follow the third column.
| Error text (fragment) | Actual cause | 30-second self-rescue | Dig deeper in |
|---|---|---|---|
Connection is not available, request timed out after 30004ms (SQLTransientConnectionException) | Thirty seconds of queueing with no connection: either the pool really is small, or slow SQL / leaks are holding them | Check whether hikaricp.connections.pending is above 0, then set leak-detection-threshold=20000 to reproduce; do not enlarge the pool first | Sections 10 and 12 |
com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure | The pool thinks the connection is alive but the database closed it long ago (maxLifetime ≥ wait_timeout, or a firewall cut) | Set max-lifetime clearly below MySQL's wait_timeout and enable keepalive-time | Section 10 |
ERROR 1040 (08004): Too many connections | pool size × instance count exceeds the database max_connections: scaling up became the incident | Sum the pools across all instances, shrink them or add read replicas; ops can keep extra_max_connections in reserve | Section 6 |
Failed to obtain JDBC Connection; nested exception is java.sql.SQLNonTransientConnectionException: Cannot load connection class | Wrong url / driver / credentials — the pool cannot reach the database at all | Look in the startup log whether HikariPool-1 - Starting... reaches Start completed.; if not, it is the connection string | Article 27 |
java.sql.SQLException: Connection is closed / was already closed | The reference was used after being returned (typically an async thread holding the caller's closed connection) | Never pass connections between threads; re-acquire inside the child thread, or let the transaction manage it | Article 31 |
Apparent connection leak detected (from leakDetectionThreshold) | A connection stayed borrowed past the threshold without being returned; the stack trace already names the culprit | Follow the logged stack to the code missing try-with-resources — the fastest lead there is | Section 10 |
The last packet successfully received from the server was N milliseconds ago | An idle connection was cut by the server timeout; same root as Communications link failure | Lower maxLifetime / raise keepaliveTime; autoReconnect on the URL is not a fallback strategy | Section 10 |
what these errors share is that they talk about connections while the disease usually lives in the SQL. So the order never changes: metrics (pending / acquire P99) → slow query log → leak stack → only then pool parameters.
Most beginners point at the wrong frame for that first row of the table, because the method at the top of the stack looks most guilty. Here is a real stack — do not read the answer, just click the frame you think is responsible:
A query endpoint that shipped two weeks ago starts returning 500s in clusters at peak. A restart fixes it instantly; forty minutes later it recurs, with the same sentence and four numbers in brackets.
Goal: run six concurrent tasks against a two-connection pool and see a real timeout exception once. Fully runnable code:
package com.example.pool;import com.zaxxer.hikari.HikariConfig;import com.zaxxer.hikari.HikariDataSource;import org.springframework.jdbc.core.JdbcTemplate;import java.util.concurrent.CountDownLatch;public class PoolDemo { public static void main(String[] args) throws Exception { HikariConfig cfg = new HikariConfig(); cfg.setJdbcUrl("jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai"); cfg.setUsername("root"); cfg.setPassword("secret"); cfg.setMaximumPoolSize(2); // deliberately only 2 cfg.setMinimumIdle(2); // fixed at 2, no resizing, easy to observe cfg.setConnectionTimeout(3000); // give up after 3s — fail fast cfg.setPoolName("tiny-pool"); try (HikariDataSource ds = new HikariDataSource(cfg)) { JdbcTemplate jdbc = new JdbcTemplate(ds); CountDownLatch done = new CountDownLatch(6); for (int i = 0; i < 6; i++) { // 6 threads fighting over 2 connections final int no = i; new Thread(() -> { try (var conn = ds.getConnection()) { // try-with-resources = return long start = System.currentTimeMillis(); new JdbcTemplate(conn).queryForObject("SELECT SLEEP(2)", Integer.class); System.out.printf("[task-%d] borrowed and finished in %dms%n", no, System.currentTimeMillis() - start); } catch (Exception e) { System.out.printf("[task-%d] failed: %s: %s%n", no, e.getClass().getSimpleName(), e.getMessage()); } finally { done.countDown(); } }, "worker-" + i).start(); } done.await(); System.out.println("active=" + ds.getHikariPoolMXBean().getActiveConnections() + " idle=" + ds.getHikariPoolMXBean().getIdleConnections() + " waiting=" + ds.getHikariPoolMXBean().getThreadsWaiting()); } }}Expected console output (timestamps differ slightly, but the shape must match):
HikariCP version: 5.xtiny-pool - Starting...tiny-pool - Added connection com.mysql.cj.jdbc.ConnectionImpl@1a2b3c4dtiny-pool - Start completed.[task-0] borrowed and finished in 2005ms[task-1] borrowed and finished in 2006ms[task-2] borrowed and finished in 4012ms[task-3] borrowed and finished in 4015ms[task-4] failed: SQLTransientConnectionException: tiny-pool - Connection is not available, request timed out after 3004ms[task-5] failed: SQLTransientConnectionException: tiny-pool - Connection is not available, request timed out after 3003msactive=0 idle=2 waiting=0Confirm four things against that output:
- The first two finish almost together = each borrowed one connection (capacity 2)
- Tasks 3 and 4 are late by exactly two seconds = they were queueing until earlier connections came back
- Tasks 5 and 6 throw the very message from row one of the lookup table, and it carries the pool name
tiny-pool— always name your pools in production, otherwise several pools failing together are impossible to tell apart - The trailing
idle=2proves nothing was destroyed; the connections merely went home
Change one item at a time, rerun, and write down what you see:
maximumPoolSizefrom 2 to 6 → you will observe: all six tasks finish around 2 seconds andwaitingstays 0. That is the feel of the "just right" cell in the sandbox.maximumPoolSizeto 200 withSELECT SLEEP(2)unchanged → you will observe: MySQL'sSHOW STATUS LIKE 'Threads_connected'climbs steadily, database CPU rises, and your business is no faster — connections are concurrency, not throughput.- Replace
try (var conn = ...)with a manualvar conn = ds.getConnection();that never closes → you will observe: after a few roundstiny-pool - Connection is not availableappears, plusApparent connection leak detectedonceleakDetectionThresholdis 1000. That is a leak in full: fewer connections each round, fixed by restart, recurring later. - Add
cfg.setMaxLifetime(60_000)and temporarily set MySQLwait_timeoutto 30 seconds → you will observe: after some idleness, occasionalCommunications link failure. Conclusion: let the pool retire connections before the database does.
Requirement: write a /orders/recent endpoint (JdbcTemplate, 20 orders) with an observation panel you can open in a browser. Acceptance checklist:
- [ ]
application.ymlspells out six settings —pool-name,maximum-pool-size,connection-timeout,max-lifetime,keepalive-time,leak-detection-threshold— each with a comment saying what it governs (numbers alone do not count) - [ ] You can point out the three startup lines
Starting... / Added connection / Start completed.and state how many connections were pre-warmed - [ ] With
spring-boot-starter-actuatoradded,GET /actuator/metrics/hikaricp.connections.pendingreturns 0 while idle - [ ] Hammer the endpoint with 100 concurrent clients (JMeter / wrk /
ab), settingmaximum-pool-sizeto 2, 10 and 50 in turn, recording each time: P99 latency, peakpending, databaseThreads_connected - [ ] Write a five-line conclusion from those three runs: where is the knee? Why do more connections not help? Is the bottleneck in the app or the database?
- [ ] Manufacture one deliberate leak (borrow without returning), locate the exact line through the
leak-detection-thresholdstack warning, and fix it - [ ] Bonus: chart the P99 of
hikaricp.connections.acquirein Grafana and add an alert rule for "pending > 0 for 60 seconds"
Finish this tier and you have moved from "knowing a pool exists" to "operating one with data".
what does Connection.close() actually do inside a pool? If your answer was "disconnect the TCP", reread section 4 — it returns the connection and resets session state.
what is the maximumPoolSize rule of thumb, and can you use it as the answer? cores × 2 + spindles is only a starting point; the verdict comes from load tests and the pending metric.
two instances at 100 connections each, database max_connections is 151 — what happens? At peak you get Too many connections, and both instances suffer together. Burn in the hard constraint: pool size × instances < max_connections.
pending stays above 0 — what is your first move? Hunt slow SQL and leaks first, not enlarge the pool; enlarging just hands the pressure to the database.
between maxLifetime and MySQL's wait_timeout, which must be larger? wait_timeout — so the pool retires connections first and business never receives a dead one.
Mantra: **dialling is costly, talking is cheap; `close` returns, it does not disconnect. Small pools queue, big pools crush the database — catch slow SQL and leaks before you ever touch the pool size.**
remember a bill, a mechanism, a formula and a habit. The bill: "opening a connection costs about 5–20 ms while a query is often under 1 ms", so pooling is mandatory. The mechanism: "close() in a pool means return, not disconnect; HikariCP stays fast through a lock-free ConcurrentBag, a proxy and background governance". The formula: "maximumPoolSize ≈ cores × 2 + spindles; connections are concurrency, not throughput, and pool size × instances must not exceed the database's connection limit". The habit: "on an alert, always check leaks and slow SQL first — never blindly enlarge the pool". Treat the connection pool as an observable, tunable craft rather than a default value, and you remove half of all connection-related incidents.