Configuring Spring Boot Read Replica DataSource Routing
This guide is part of Spring Boot DataSource Configuration. Adding a read replica is the standard response to a primary that is running out of read capacity, and in Spring Boot it is mostly a connection pool problem: you end up with two pools, two sets of limits, and one routing decision made per transaction.
The routing itself is straightforward. The parts that go wrong in production are the sizing — two pools multiply the fleet’s connection count — and read-after-write consistency, which replication lag breaks in ways that are intermittent and hard to reproduce.
The routing mechanism
Spring’s AbstractRoutingDataSource picks a target data source per connection acquisition, based on a key you supply. Combined with TransactionSynchronizationManager.isCurrentTransactionReadOnly(), the key can be derived from the @Transactional(readOnly = true) flag that most codebases already have:
public class ReplicaRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? DataSourceKey.REPLICA
: DataSourceKey.PRIMARY;
}
}
Two details make or break this. The routing key must be resolved inside an active transaction, because isCurrentTransactionReadOnly() returns false outside one — so a repository call with no @Transactional annotation silently goes to the primary. And the routing data source must be wrapped in a LazyConnectionDataSourceProxy, or the connection is acquired before the transaction’s read-only flag is set, and every query goes to the primary regardless of annotation:
@Bean
@Primary
public DataSource dataSource(
@Qualifier("primaryDataSource") DataSource primary,
@Qualifier("replicaDataSource") DataSource replica) {
ReplicaRoutingDataSource routing = new ReplicaRoutingDataSource();
routing.setTargetDataSources(Map.of(
DataSourceKey.PRIMARY, primary,
DataSourceKey.REPLICA, replica));
routing.setDefaultTargetDataSource(primary);
routing.afterPropertiesSet();
// Defers connection acquisition until the first statement, by which
// time the transaction's read-only flag has been set.
return new LazyConnectionDataSourceProxy(routing);
}
Omitting LazyConnectionDataSourceProxy is the single most common reason a routing setup “does not work”: everything is wired correctly, no error appears, and 100% of traffic goes to the primary.
Two pools, configured independently
Both data sources need their own HikariCP configuration, and giving them the same numbers is almost always wrong — they carry different workloads:
app:
datasource:
primary:
jdbc-url: jdbc:postgresql://orders-primary.internal:5432/orders
username: orders_app
maximum-pool-size: 10
minimum-idle: 10
pool-name: primary-pool
connection-timeout: 3000
max-lifetime: 280000
keepalive-time: 120000
leak-detection-threshold: 12000
replica:
jdbc-url: jdbc:postgresql://orders-replica.internal:5432/orders
username: orders_ro
maximum-pool-size: 20
minimum-idle: 20
pool-name: replica-pool
connection-timeout: 3000
max-lifetime: 280000
keepalive-time: 120000
leak-detection-threshold: 12000
read-only: true
@Bean
@ConfigurationProperties("app.datasource.primary")
public HikariDataSource primaryDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean
@ConfigurationProperties("app.datasource.replica")
public HikariDataSource replicaDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
Three points about that configuration.
The replica pool is larger because reads are the majority of the traffic and are what you moved. Sizing both at the old single-pool value doubles your connection count for no reason — the correct move is to split the original budget in proportion to the traffic split, then adjust.
read-only: true on the replica pool sets the JDBC connection to read-only, which makes an accidental write fail immediately and locally rather than reaching the replica and producing a confusing cannot execute INSERT in a read-only transaction from PostgreSQL. It is a cheap guard against a routing bug.
Distinct pool-name values are essential. Every metric, log line and leak warning is tagged with the pool name; without distinct names you cannot tell which pool saturated. The mechanics of that warning are in configuring the HikariCP leak detection threshold.
The fleet arithmetic doubles, and this is the part that surprises teams:
before: 20 pods x 20 connections = 400 to the primary
after: 20 pods x (10 primary + 20 replica) = 200 primary + 400 replica
Read replicas have their own max_connections, usually the same as the primary’s. Adding a replica adds capacity for reads, not for connections, and a fleet that was already close to the primary’s ceiling will now be close to two ceilings.
Replication lag and read-after-write
This is the failure that reaches users. A user submits a form, the write commits on the primary, the response redirects to a page that reads through the replica, and the replica has not yet applied the change. The user sees their own edit missing.
There is no configuration that fixes this in general; the lag is physics. What works is deciding, per read, whether it can tolerate lag:
Route the read to the primary when it must see a recent write. The simplest correct approach: within a session or request that has just written, use the primary for subsequent reads. Mark those transactions without readOnly = true, or set an explicit routing hint.
Use a bounded staleness check. On PostgreSQL you can compare the replica’s replay position against the LSN of your write, and fall back to the primary when the replica has not caught up:
// after committing on the primary
String lsn = jdbc.queryForObject("SELECT pg_current_wal_lsn()::text", String.class);
requestScope.setWriteLsn(lsn);
// before a replica read in the same request
Boolean caughtUp = replicaJdbc.queryForObject(
"SELECT pg_last_wal_replay_lsn() >= ?::pg_lsn", Boolean.class, lsn);
That is more machinery than most applications need, but it is the only approach that is actually correct rather than probabilistic.
Accept staleness explicitly for reads that can tolerate it. Dashboards, search results, and listings are usually fine on a replica. Account balances and confirmation pages usually are not. Making that a deliberate per-endpoint decision — rather than a blanket readOnly = true on every read method — is the design that survives contact with production.
Monitor the lag itself so you know when the assumption breaks:
SELECT client_addr, state,
pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_lag_bytes,
replay_lag
FROM pg_stat_replication;
Common failure patterns and remediation
| Symptom | Underlying cause | Remediation |
|---|---|---|
| All traffic still goes to the primary | No LazyConnectionDataSourceProxy |
Wrap the routing data source in the lazy proxy |
| Some reads go to the primary unexpectedly | Repository method has no @Transactional(readOnly = true) |
Annotate, or make read-only the default at the service layer |
cannot execute INSERT in a read-only transaction |
A write path annotated read-only | Fix the annotation; keep read-only: true on the pool as the guard |
| Users see stale data after their own writes | Replication lag on a read-after-write path | Route those reads to the primary, or check replay position |
| Database ceiling exhausted after adding the replica | Two pools per pod, sized as if each were the only one | Split the original budget rather than duplicating it |
| Cannot tell which pool saturated | Both pools share a name | Set distinct pool-name values |
| Replica pool idle, primary saturated | Read-only transactions not reaching the router | Verify the lookup key with a debug log inside determineCurrentLookupKey |
| Failover leaves the replica pool pointing at a promoted primary | Endpoints hard-coded rather than using the reader endpoint | Use the provider’s reader endpoint and cap connection age |
Verifying it works
Do not trust the wiring; measure the split. Micrometer publishes per-pool metrics tagged with the pool name, so the ratio is directly observable:
sum by (pool) (rate(hikaricp_connections_acquire_seconds_count[5m]))
You should see both pools carrying traffic in roughly the proportion you expect. A replica pool at zero means routing is not happening; a primary pool at zero means writes are not happening, which is its own problem.
A cheap functional check during development is to log the resolved key:
@Override
protected Object determineCurrentLookupKey() {
Object key = TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? DataSourceKey.REPLICA : DataSourceKey.PRIMARY;
if (log.isDebugEnabled()) log.debug("routing to {}", key);
return key;
}
Leave that at DEBUG. It fires on every connection acquisition and will flood production logs if enabled there.
Operational boundary
Routing splits load; it does not add connection capacity. Both endpoints have their own ceiling, and the fleet now consumes from both. If the reason for adding a replica was too many clients rather than read CPU saturation, routing does not solve it — a proxy does, and the arithmetic is in Connection Pool Sizing Formulas.
Failover is the other boundary. When the primary fails over, the writer endpoint moves, and long-lived pooled connections stay bound to the old host until they are retired. Keep max-lifetime short enough that endpoints are re-resolved regularly — the mechanism is described in tuning HikariCP maxLifetime and idleTimeout, and the managed-database specifics in AWS Aurora Connection Scaling.
Frequently Asked Questions
Why does everything go to the primary despite correct annotations?
LazyConnectionDataSourceProxy. Without it the connection is acquired before the transaction’s read-only flag is set, so the router has nothing to route on.Do I need two entity managers?
How should I split the connection budget between the pools?
What stops an accidental write reaching the replica?
read-only: true on the replica pool. The JDBC driver then rejects the write locally with a clear error instead of sending it.How do I handle read-after-write consistency?
Can I route by something other than the read-only flag?
ThreadLocal set by an interceptor, a custom annotation, a tenant identifier. The read-only flag is convenient because most codebases already have it.Does this work with multiple replicas?
Related
- Spring Boot DataSource Configuration — the parent guide on configuring a single data source.
- Connection Pool Sizing Formulas — splitting a budget between two pools.
- Tuning HikariCP maxLifetime and idleTimeout — keeping endpoints re-resolved across a failover.
- AWS Aurora Connection Scaling — reader endpoints and failover behaviour.
- Configuring the HikariCP Leak Detection Threshold — attributing warnings when two pools exist.