Tuning Apache Commons DBCP2 for Production
This guide is part of HikariCP vs c3p0 vs DBCP2 Benchmark. Many services cannot migrate off Apache Commons DBCP2 on their own schedule: it arrives as Tomcat’s built-in JNDI pool, through an application server, or inside a vendor library. The pool itself is serviceable. Its defaults are not. Out of the box, maxTotal is 8, a borrow waits forever, no background evictor runs, and connections live until the database or a firewall kills them. The typical outage looks like request threads frozen with no error at all, followed hours later by a burst of stale-socket failures:
"http-nio-8080-exec-187" #212 daemon prio=5 WAITING (parking)
at jdk.internal.misc.Unsafe.park(Native Method)
at org.apache.commons.pool2.impl.LinkedBlockingDeque.takeFirst(LinkedBlockingDeque.java:1324)
at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:311)
at org.apache.commons.dbcp2.PoolingDataSource.getConnection(PoolingDataSource.java:134)
The objective is a DBCP2 configuration that fails fast when exhausted, evicts dead and idle connections on its own, and exposes enough telemetry to tell exhaustion from a leak.
Rapid incident diagnosis
Start from a thread dump, because DBCP2’s worst failure mode produces no log line. Count the threads parked in borrowObject:
jcmd $(pgrep -f catalina) Thread.print > /tmp/threads.txt
grep -c "GenericObjectPool.borrowObject" /tmp/threads.txt
grep -B2 -A12 "GenericObjectPool.borrowObject" /tmp/threads.txt | grep "at com.example" | sort | uniq -c | sort -rn | head
A large count means requests are waiting for a connection. The second command shows which application code paths are waiting — useful, but the paths holding connections matter more.
Next, read the pool state over JMX. When jmxName is set, the data source registers an MBean exposing NumActive, NumIdle and MaxTotal, and the underlying Commons Pool object exposes NumWaiters, MeanBorrowWaitTimeMillis and MaxBorrowWaitTimeMillis:
# jmxterm, non-interactive
echo "get -b org.apache.commons.dbcp2:name=ordersDS NumActive NumIdle MaxTotal" \
| java -jar jmxterm.jar -l localhost:9010 -n
Then decide which of three causes applies:
- Exhaustion.
NumActiveequalsMaxTotal, waiters are non-zero, and the database shows those sessions actively running queries. The pool is too small or the queries too slow. - Leak.
NumActiveequalsMaxTotal, but the database shows the corresponding sessionsidleoridle in transactionfor minutes. Code borrowed connections and never closed them. - Dead connections.
NumActiveis low, borrows succeed, but queries fail withCommunications link failureorAn I/O error occurred while sending to the backend. Idle connections were cut by a firewall or the server’s idle timeout and nothing validated them.
On PostgreSQL, the leak test is one query against the application’s user:
SELECT state, count(*), max(now() - state_change) AS longest
FROM pg_stat_activity
WHERE usename = 'orders_app'
GROUP BY state;
Mathematical sizing or parameter formula
Two formulas matter for DBCP2: the pool ceiling, and how often the evictor actually visits every idle connection.
The ceiling follows Little’s Law. Required connections equal arrival rate times hold time, divided by a target utilisation:
maxTotal = ceil( requests_per_second × db_hold_seconds ÷ 0.75 )
A Tomcat service handling 300 requests per second, each holding a connection for 20 ms, needs 300 × 0.02 ÷ 0.75 = 8 connections. The default of 8 happens to fit; at 900 requests per second it needs 24, and the default pool becomes a hard throughput limit.
The evictor is less obvious. It wakes every timeBetweenEvictionRunsMillis and examines only numTestsPerEvictionRun idle connections per run (default 3). The time to visit every idle connection once is:
full_sweep_seconds = ceil( idle_connections ÷ numTestsPerEvictionRun ) × run_interval_seconds
With 24 idle connections, 3 tests per run and a 30-second interval, a full sweep takes 8 × 30 = 240 seconds. If a firewall between the service and the database drops idle flows after 350 seconds, some connections will be idle for up to minEvictableIdleTimeMillis plus a sweep before they are examined, and with a 30-minute idle timeout they will certainly be cut first. Two fixes: make minEvictableIdleTimeMillis plus one sweep shorter than the firewall timeout, or set numTestsPerEvictionRun to a negative value, which DBCP2 interprets as testing a fraction of the idle pool per run — -1 tests all of them.
Exact remediation & configuration
The configuration below is for a directly constructed BasicDataSource; each property has the same name in Tomcat’s <Resource> element and in Spring’s spring.datasource.dbcp2.* namespace. Recent DBCP2 releases also accept Duration setters such as setMaxWait, which replace the ...Millis variants.
BasicDataSource ds = new BasicDataSource();
ds.setUrl("jdbc:postgresql://orders-db.internal:5432/orders");
ds.setUsername("orders_app");
ds.setPassword(secret);
// Sizing
ds.setInitialSize(4);
ds.setMaxTotal(24); // from Little's Law, not the default 8
ds.setMaxIdle(24); // == maxTotal: avoid close/reopen churn under bursts
ds.setMinIdle(4);
// Fail fast instead of parking forever
ds.setMaxWaitMillis(3000);
// Validation: cheap, bounded, and not on every borrow
ds.setValidationQuery("SELECT 1"); // omit on JDBC4 drivers to use isValid()
ds.setValidationQueryTimeout(2); // seconds
ds.setTestOnBorrow(false);
ds.setTestWhileIdle(true);
ds.setFastFailValidation(true); // don't validate a connection that already threw a fatal SQLState
// Evictor: run it, and make it cover the whole idle pool each run
ds.setTimeBetweenEvictionRunsMillis(30_000);
ds.setNumTestsPerEvictionRun(-1);
ds.setMinEvictableIdleTimeMillis(120_000); // below the 350 s firewall cut
ds.setSoftMinEvictableIdleTimeMillis(300_000); // may evict below minIdle only after this
// Lifetime: retire connections before the network or server does
ds.setMaxConnLifetimeMillis(1_800_000);
ds.setLogExpiredConnections(false);
// Leak diagnostics
ds.setRemoveAbandonedOnMaintenance(true);
ds.setRemoveAbandonedTimeout(120); // seconds a connection may be held
ds.setLogAbandoned(true); // records the borrowing stack trace
ds.setJmxName("org.apache.commons.dbcp2:name=ordersDS");
The same settings as a Tomcat JNDI resource in context.xml:
<Resource name="jdbc/orders" auth="Container" type="javax.sql.DataSource"
factory="org.apache.tomcat.dbcp.dbcp2.BasicDataSourceFactory"
driverClassName="org.postgresql.Driver"
url="jdbc:postgresql://orders-db.internal:5432/orders"
username="orders_app" password="${db.password}"
initialSize="4" maxTotal="24" maxIdle="24" minIdle="4"
maxWaitMillis="3000"
validationQuery="SELECT 1" validationQueryTimeout="2"
testOnBorrow="false" testWhileIdle="true"
timeBetweenEvictionRunsMillis="30000" numTestsPerEvictionRun="-1"
minEvictableIdleTimeMillis="120000" maxConnLifetimeMillis="1800000"
removeAbandonedOnMaintenance="true" removeAbandonedTimeout="120" logAbandoned="true"
jmxName="org.apache.commons.dbcp2:name=ordersDS"/>
Treat removeAbandonedOnMaintenance as a diagnostic, not a cure. It closes connections held longer than the timeout and logs the stack trace that borrowed them, which finds leaks quickly, but it will also close a connection legitimately held by a long batch job. Set the timeout above your longest legitimate transaction, and avoid removeAbandonedOnBorrow, which runs the abandoned-connection scan inline on the request thread whenever the pool is nearly full.
Roll the change out as you would any pool change: one instance first, compare MeanBorrowWaitTimeMillis and error rates against its neighbours for an hour, then the rest.
Validation & verification
Verify each failure mode is gone, not just that the service starts.
Bounded wait. In staging, cap maxTotal at 2 and fire 50 concurrent requests that each hold a connection for a second. Every request beyond the first few must fail within about three seconds with the pool’s exception, not hang:
hey -n 50 -c 50 http://staging-orders:8080/slow-query | grep -E "Status code|Slowest"
grep -c "Timeout waiting for idle object" /var/log/tomcat/catalina.out
Evictor and lifetime. Watch the backend connection ages on the database. With a 30-minute maxConnLifetimeMillis, no connection from the service should be older than about 31 minutes, and idle ones should disappear after roughly two minutes when traffic stops:
SELECT pid, state, now() - backend_start AS age, now() - state_change AS in_state
FROM pg_stat_activity
WHERE usename = 'orders_app'
ORDER BY age DESC
LIMIT 10;
Leak detection. Deploy a test endpoint that borrows a connection and never closes it, call it once, and confirm that within removeAbandonedTimeout plus one evictor run the log contains the abandoned-connection report with the endpoint’s stack trace.
Telemetry. Scrape NumActive, NumIdle and NumWaiters into your metrics system and alert when NumWaiters stays above zero for more than a minute — the same saturation rule described in Alerting on Connection Pool Saturation.
DBCP2 inside Tomcat and Spring Boot
Most DBCP2 deployments are not constructed in code, and the same setting can be spelled three different ways depending on who builds the pool.
Tomcat’s bundled copy is repackaged. Tomcat ships DBCP2 under org.apache.tomcat.dbcp.dbcp2, and its JNDI factory reads attributes from the <Resource> element. It is easy to put a newer Commons DBCP2 JAR in WEB-INF/lib, configure it in code, and still have the container’s JNDI resource — with defaults — serving the application. Confirm which pool is live by its JMX name and class:
echo "beans -d Catalina" | java -jar jmxterm.jar -l localhost:9010 -n | grep -i datasource
Spring Boot binds spring.datasource.dbcp2.*. When HikariCP is excluded and DBCP2 is on the classpath, Spring Boot creates a BasicDataSource and binds properties by relaxed name:
spring:
datasource:
type: org.apache.commons.dbcp2.BasicDataSource
url: jdbc:postgresql://orders-db.internal:5432/orders
dbcp2:
initial-size: 4
max-total: 24
max-idle: 24
min-idle: 4
max-wait-millis: 3000
test-on-borrow: false
test-while-idle: true
validation-query-timeout: 2
time-between-eviction-runs-millis: 30000
num-tests-per-eviction-run: -1
min-evictable-idle-time-millis: 120000
max-conn-lifetime-millis: 1800000
remove-abandoned-on-maintenance: true
remove-abandoned-timeout: 120
log-abandoned: true
jmx-name: org.apache.commons.dbcp2:name=ordersDS
Parameter names changed between DBCP 1 and DBCP2. Configuration copied from older documentation silently does nothing, because unknown attributes are ignored:
| DBCP 1.x name | DBCP2 name | Notes |
|---|---|---|
maxActive |
maxTotal |
The most common silent failure: the pool stays at 8 |
maxWait |
maxWaitMillis (or maxWait as a Duration in recent releases) |
Default still waits forever |
removeAbandoned |
removeAbandonedOnBorrow / removeAbandonedOnMaintenance |
Split into two triggers |
| — | maxConnLifetimeMillis |
No DBCP 1 equivalent |
| — | fastFailValidation |
No DBCP 1 equivalent |
After any configuration change, read the effective values back over JMX — MaxTotal, MaxWaitMillis, TimeBetweenEvictionRunsMillis — rather than trusting the file. A MaxTotal of 8 after you configured 24 means a misspelled or legacy attribute.
Metrics without JMX scraping. Micrometer’s DataSourcePoolMetrics exposes active, idle, minimum and maximum connections for DBCP2 pools under jdbc.connections.* when Spring Boot Actuator is present, which is enough for the saturation alert even without the Commons Pool wait statistics.
Frequently Asked Questions
Should maxIdle be lower than maxTotal?
maxIdle is lower, every connection returned while the idle count is at maxIdle is closed immediately, and the next burst reopens it. That close-and-reopen churn is expensive, especially with TLS. Let the evictor shrink the pool after quiet periods instead.Is testOnBorrow=true safer?
maxConnLifetimeMillis retiring old ones, the residual risk is small. Keep testOnBorrow only when a network device cuts idle flows faster than any sweep interval you can afford.Does lifo matter?
lifo=true), which keeps a warm core of connections and lets the rest age out through the evictor. Switch to FIFO only when you need traffic spread evenly across connections, for example behind a load balancer distributing sessions across replicas.When is it worth migrating to HikariCP instead?
MeanBorrowWaitTimeMillis high even though connections are available. The measured gap and the translation table are in Migrating from c3p0 to HikariCP, and the same mapping applies from DBCP2.Does DBCP2 support a connection initialisation statement?
connectionInitSqls runs a list of statements once when each physical connection is created, useful for session settings such as SET application_name. It runs once per connection, not per borrow, so it is not a place for per-request state.Is poolPreparedStatements worth enabling?
Related
- HikariCP vs c3p0 vs DBCP2 Benchmark — the parent comparison of the three JDBC pools.
- Migrating from c3p0 to HikariCP — the parameter translation for moving off a legacy pool.
- Configuring HikariCP Leak Detection Threshold — the HikariCP counterpart to abandoned-connection logging.
- Tuning Connection Acquisition Timeout Under Burst Load — choosing the bounded wait value.
- Tuning Tomcat JDBC Pool for Production — the other pool that arrives with Tomcat, and its own defaults.