The previous lesson ended with a very specific error message: Failed to configure a DataSource. Spring Boot detected the JPA starter, tried to build the persistence unit and found itself without the most basic ingredient, a connection to a database. This lesson solves exactly that, and it goes well beyond pasting four properties: a badly sized DataSource is the number one cause of Spring Boot applications falling over in production, well ahead of any logic error.
We are going to set up two environments for CicloUrbana: in-memory H2 for developing fast, and PostgreSQL 16 in Docker as the real database. We will understand what a connection pool is and tune HikariCP parameter by parameter with sizing criteria you can defend in front of a colleague. We will settle the Hibernate properties that govern the ORM's behaviour, including the important and rarely discussed decision to switch open-in-view off. And we will leave logging ready to show the SQL that actually travels towards Ribalta.
Contents
- What a
DataSourceis and why connections are pooled - In-memory H2 for development
- The H2 console and its security risk
- PostgreSQL 16 with Docker Compose
- The essential
spring.datasourceproperties - HikariCP in depth
- Sizing the pool with judgement
- The JPA and Hibernate properties
open-in-view: why it gets switched off- Reading the generated SQL comfortably
- Multiple data sources
- Credentials outside the repository
- Checking the connection at startup
- Common Mistakes and Tips
- Exercises
- What a
DataSource is and why connections are pooled
DataSource is and why connections are pooledjavax.sql.DataSource is a Java interface with one essential method: getConnection(). It is the standard factory of database connections, and it is what Hibernate asks for when it needs to talk to PostgreSQL.
The interesting question is what sits behind that method. The naive implementation would open a fresh TCP connection every time. And opening a database connection is expensive: TCP handshake, authentication, TLS negotiation, creation of a server process or thread, allocation of session memory. In PostgreSQL, between 20 and 100 milliseconds. If GET /api/v1/stations takes 5 ms for its query and 40 ms to open the connection, 89% of the time goes into plumbing.
A connection pool solves this by keeping a set of already-open connections and lending them out:
sequenceDiagram
participant S as StationService
participant P as HikariCP Pool
participant DB as PostgreSQL
Note over P,DB: At startup: N connections are opened
S->>P: getConnection()
P-->>S: connection #3 (already open, ~0.1 ms)
S->>DB: SELECT * FROM stations
DB-->>S: rows
S->>P: close()
Note over P: NOT closed: it returns to the pool
P-->>P: connection #3 available
The detail that throws people the first time: when your code calls connection.close(), the connection is not closed. The pool hands back a wrapper whose close() means "return it to the pool". That is why try-with-resources around connections is still correct and necessary.
Consequences to keep in mind throughout the module:
- The number of simultaneous connections is bounded by the pool size, not by the number of requests.
- If all of them are lent out, the next request waits. If it waits too long, it fails with a timeout.
- A connection that is lent and never returned is a leak that eventually drains the pool and brings the application down.
Spring Boot includes HikariCP through spring-boot-starter-jdbc, which the JPA starter pulls in. There is nothing to add.
- In-memory H2 for development
H2 is a relational database written in Java that can live inside your own process. For developing CicloUrbana it is ideal: it starts in milliseconds, requires no installation and comes back clean on every run.
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>The runtime scope is deliberate: the driver is needed at run time, never at compile time. Your code must not import a single H2 class. If you ever need compile, it is a sign that something has become coupled to the engine.
The configuration in application.yml:
spring:
datasource:
url: jdbc:h2:mem:ciclourbana;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
username: sa
password:
driver-class-name: org.h2.Driver
h2:
console:
enabled: true
path: /h2-console
jpa:
hibernate:
ddl-auto: update
open-in-view: false
show-sql: true
properties:
hibernate:
format_sql: trueLet's break down the URL, which is where the interesting part lives:
| Fragment | Meaning |
|---|---|
jdbc:h2:mem: |
In-memory database: it disappears when the process ends |
ciclourbana |
Name of the database; different names are different databases |
DB_CLOSE_DELAY=-1 |
Do not destroy the DB when the last connection closes |
MODE=PostgreSQL |
Emulates PostgreSQL's syntax and types |
DB_CLOSE_DELAY=-1 is not optional. Without it, as soon as the pool closes its last active connection H2 wipes the whole database, and the data loaded by DemoStationLoader vanishes mid-run.
MODE=PostgreSQL is a strategic decision of this course: it makes H2 behave like PostgreSQL in types, functions and syntax, so that what works in development is far more likely to work in production. It is not full equivalence —which is why in 06-05 we will use Testcontainers with a real PostgreSQL for the tests—, but it narrows the gap a lot.
If you would rather the data survived between runs, H2 can also write to a file with jdbc:h2:file:./data/ciclourbana;MODE=PostgreSQL; remember to add data/ to .gitignore in that case.
- The H2 console and its security risk
With spring.h2.console.enabled: true, once the application starts you have a web SQL client at http://localhost:8080/h2-console. The connection details to type in are the ones from the YAML:
It is extremely handy for seeing which tables Hibernate created from your entities (04-03) and checking that the data is where you think it is.
And it is, at the same time, the biggest security hole you can leave open. The H2 console lets you run arbitrary SQL with no real authentication. Worse still: H2 allows Java code to be executed from SQL through aliases, which turns an exposed console into remote code execution. There have been serious CVEs for precisely this.
The rules are non-negotiable:
- Never enable the console in an environment reachable from outside.
- Turn it on only in the development profile (profiles are covered in 07-02):
# application-dev.yml
spring:
h2:
console:
enabled: true
settings:
web-allow-others: false # localhost only- In
application.yml(the base file), leave it off:enabled: false. - When we add Spring Security in module 5, the console will need an explicit rule and, even then, will remain restricted to development.
web-allow-others: false is the default value and limits access to localhost. Do not change it.
- PostgreSQL 16 with Docker Compose
H2 is fine for developing, but CicloUrbana will run in production on PostgreSQL 16. Bringing it up with Docker avoids installing anything on the machine and guarantees the whole team uses the same version. Create docker-compose.yml at the root of the project:
services:
postgres:
image: postgres:16-alpine
container_name: ciclourbana-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ciclourbana
POSTGRES_USER: ciclourbana
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ciclourbana_dev}
TZ: Europe/Madrid
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ciclourbana -d ciclourbana"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres-data:Point by point:
| Element | Why it is there |
|---|---|
postgres:16-alpine |
Pinned version; alpine cuts the image to ~80 MB |
restart: unless-stopped |
Comes back up after the machine reboots |
POSTGRES_DB/USER/PASSWORD |
On first startup they create the database and the user |
${POSTGRES_PASSWORD:-...} |
Takes the environment variable; if absent, uses the development value |
ports: 5432:5432 |
Exposes the port to the host so you can connect from the IDE |
volumes: postgres-data |
Named volume: the data survives docker compose down |
healthcheck |
Lets you know when it is genuinely ready, not merely started |
Common commands:
docker compose up -d # bring it up in the background
docker compose ps # see status and health
docker compose logs -f postgres # follow the log
docker compose exec postgres psql -U ciclourbana -d ciclourbana
docker compose down # stop (the data stays)
docker compose down -v # stop AND DELETE the volumeCareful with down -v: it deletes the volume and all the data with it. In 07-04 we will pick this file up again to add the container of the application itself.
The PostgreSQL driver:
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>And the matching configuration:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/ciclourbana
username: ciclourbana
password: ${POSTGRES_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
open-in-view: falseYou can keep both drivers in the pom.xml at once: which one gets used is decided by the URL. In 07-02 we will see how to separate both configurations cleanly into profiles.
- The essential
spring.datasource properties
spring.datasource properties| Property | What it is | Example | Mandatory? |
|---|---|---|---|
url |
Full JDBC string | jdbc:postgresql://localhost:5432/ciclourbana |
Yes (except for an embedded DB) |
username |
Database user | ciclourbana |
Almost always |
password |
Password | ${POSTGRES_PASSWORD} |
Almost always |
driver-class-name |
JDBC driver class | org.postgresql.Driver |
No, it is inferred |
name |
Name of the DataSource |
ciclourbana-ds |
No |
Why driver-class-name is not needed. Spring Boot uses DatabaseDriver, an enum that maps URL prefixes to driver classes: jdbc:postgresql: → org.postgresql.Driver, jdbc:h2: → org.h2.Driver, jdbc:mysql: → com.mysql.cj.jdbc.Driver. With the URL and the driver on the classpath, the inference is automatic. You only have to declare it when you use an alternative driver or a URL with an unrecognised prefix.
And if you configure nothing while having H2 on the classpath, Spring Boot creates an embedded DataSource with a random URL along the lines of jdbc:h2:mem:2a5f.... It is convenient for a quick start, but since the name changes on every run you cannot connect with the console. For CicloUrbana we always declare it explicitly.
- HikariCP in depth
HikariCP has been Spring Boot's default pool since version 2.0, and with good reason: it is the fastest in the Java ecosystem and the one that needs the least configuration to be right. Its philosophy is to have few options and sensible defaults.
All its parameters go under spring.datasource.hikari:
spring:
datasource:
hikari:
pool-name: CicloUrbanaPool
maximum-pool-size: 10
minimum-idle: 10
connection-timeout: 30000 # 30 s
idle-timeout: 600000 # 10 min
max-lifetime: 1800000 # 30 min
leak-detection-threshold: 60000 # 60 s
auto-commit: false| Parameter | What it controls | Default | If you go too low | If you go too high |
|---|---|---|---|---|
maximum-pool-size |
Maximum simultaneous connections | 10 | Requests waiting and timeouts | You saturate the database and performance gets worse |
minimum-idle |
Idle connections kept around | = maximum | Latency from creating connections under a spike | Idle connections eating memory in the DB |
connection-timeout |
Maximum wait for a connection | 30,000 ms | Spurious failures under load | Threads pile up waiting and the app freezes |
idle-timeout |
Time before closing an idle one | 600,000 ms | Connections opened and closed non-stop | Dead connections taking up space |
max-lifetime |
Maximum life of a connection | 1,800,000 ms | Excessive recycling | Connections expired by the firewall or the DB |
leak-detection-threshold |
Warning if a connection is not returned | 0 (off) | Leaks go unnoticed | False positives with legitimately long processes |
Details that really matter:
max-lifetime must be lower than whatever lifetime the database or the firewall imposes. It is the most common cause of intermittent Connection is closed errors in production: a firewall cuts idle connections after 30 minutes and the pool goes on believing they are valid. The official recommendation is to set it several seconds below that limit; 30 minutes is a prudent value almost always.
minimum-idle equal to maximum-pool-size is the recommendation of HikariCP's authors for steady loads: a fixed-size pool avoids paying the cost of opening connections at precisely the worst moment, the traffic spike.
leak-detection-threshold deserves to be enabled in development and pre-production. When a connection has been lent out for longer than the threshold, HikariCP writes the stack trace of whoever asked for it. It is the most direct way to find a leak:
Connection leak detection triggered for org.postgresql.jdbc.PgConnection@3f2a1b,
stack trace follows
java.lang.Exception: Apparent connection leak detected
at com.ciclourbana.rentals.RentalService.start(RentalService.java:64)auto-commit: false leaves transaction management to Spring, which is what we want with @Transactional (04-07). Spring Boot already sets it correctly when using JPA.
- Sizing the pool with judgement
Intuition says "more connections, more performance". It is false, and understanding that separates those who configure from those who copy.
A database runs queries on a limited number of cores and disks. With more active connections than resources, the server spends its time context-switching and competing for locks instead of working. HikariCP's documentation shows a classic case: a server with 10,000 users performing better with a pool of 10 than with one of 100.
PostgreSQL's reference formula:
For a PostgreSQL in a container with 4 vCPUs and SSD storage (where the disk term is around 1-2):
Ten. That is HikariCP's default value and it rarely needs raising. Practical rules for CicloUrbana:
- Start at 10. Only raise it with metrics that justify it (Actuator and Micrometer expose
hikaricp.connections.*; we will see this in 09-03). - Add up all the instances. If you deploy 4 replicas with a pool of 10, the database sees 40 connections. PostgreSQL's
max_connections(100 by default) is a global ceiling that runs out sooner than people think. - If there are waits, the answer is almost never more connections: it is usually a slow query, a missing index or a transaction that is too long.
- Never do slow work inside a transaction. Calling the payment gateway with a borrowed connection holds a scarce resource for seconds. In CicloUrbana, charging for the rental must happen outside the transaction; in 04-07 we will solve it with
@TransactionalEventListener.
- The JPA and Hibernate properties
The ORM's behaviour is configured under spring.jpa.
| Property | What it does | Value for CicloUrbana |
|---|---|---|
spring.jpa.hibernate.ddl-auto |
Automatic schema management | update in dev, validate in prod |
spring.jpa.show-sql |
Prints the SQL to System.out |
false (better to use the log) |
spring.jpa.properties.hibernate.format_sql |
Formats the SQL over several lines | true in development |
spring.jpa.database-platform |
SQL dialect | It is inferred, do not touch |
spring.jpa.open-in-view |
Keeps the context open in the view | false |
spring.jpa.properties.hibernate.jdbc.batch_size |
Groups statements into batches | 20 (useful in bulk loads) |
spring.jpa.defer-datasource-initialization |
Delays data.sql until after the schema is created |
true if you use data.sql |
The five values of ddl-auto, the most dangerous property in the framework:
| Value | What it does | When to use it |
|---|---|---|
none |
Nothing. Hibernate does not touch the schema | Production with Flyway (04-08) |
validate |
Checks that the schema matches the entities and fails if it does not | Production. The best safety net |
update |
Adds missing tables and columns. It neither deletes nor modifies | Early development, never production |
create |
Drops the schema and creates it from scratch at startup | Throwaway manual testing |
create-drop |
Like create, and it also drops on shutdown |
Automated tests (module 6) |
update is not safe in production, and the reason is usually misunderstood. It is not just that it "might delete data" —in fact it does not drop columns—: it is that it cannot modify what already exists. If you change a varchar(80) to varchar(40), or add a NOT NULL column to a table with rows, update either ignores it silently or fails halfway, leaving the schema in a state nobody has reviewed or can reproduce. On top of that there is no record of what was applied and no way to undo it.
The module's plan is explicit: we use update while we design the entities in 04-03 and 04-04, and in 04-08 we replace it with Flyway and validate. By the time you reach that lesson, update disappears from the project for good.
The dialect is not configured. Hibernate 6 detects it by interrogating the driver and tailors SQL generation to the engine's specific version. Pinning it by hand only serves to leave you stuck on an old version without noticing.
open-in-view: why it gets switched off
open-in-view: why it gets switched offSpring Boot enables spring.jpa.open-in-view=true by default, and warns about it with a message in the log:
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be
performed during view rendering. Explicitly configure spring.jpa.open-in-view to
disable this warningWhat it does: a filter (OpenEntityManagerInViewInterceptor) keeps the persistence context open for the whole HTTP request, not just for the service's transaction.
It sounds convenient, and that is the trap. The three problems it causes:
- It hides N+1 until production. If the controller serialises an entity with a lazy collection, with
open-in-viewon the collection is loaded without complaint, firing queries during serialisation. With it off,LazyInitializationExceptionis raised in development, which is where you want to find out. - It holds the connection longer than necessary. The JDBC connection can stay bound to the entire request, JSON serialisation included. With a pool of 10, this drastically reduces the number of concurrent requests.
- It blurs the boundaries. The presentation layer ends up running queries, the exact opposite of the layer separation we built in 03-05 with DTOs.
For CicloUrbana the decision is made as of this lesson:
The trade-off is that the service must return fully mapped DTOs, with everything needed already loaded. That is exactly what we have been doing since 03-05 with StationMapper and StationDetailResponse, so the cost is zero. We will come back to this in 04-04 and 04-07.
- Reading the generated SQL comfortably
Working with an ORM without seeing the SQL is programming blind. There are two ways to see it and one is clearly better.
The quick way (seriously, avoid it): spring.jpa.show-sql: true. It writes to System.out, unformatted, with no level, no timestamp and without going through the logging system. It is good for a glance and little more.
The right way: Hibernate's log.
logging:
level:
org.hibernate.SQL: DEBUG # the statements
org.hibernate.orm.jdbc.bind: TRACE # the parameter values
org.hibernate.stat: DEBUG # per-session statistics
spring:
jpa:
show-sql: false
properties:
hibernate:
format_sql: true
highlight_sql: true
generate_statistics: trueWith org.hibernate.SQL at DEBUG you will see every statement; with org.hibernate.orm.jdbc.bind at TRACE, the values that replace the ?. Watch the name: in Hibernate 5 it was org.hibernate.type.descriptor.sql; in Hibernate 6, the one shipped with Spring Boot 3, it is org.hibernate.orm.jdbc.bind. Copying the old configuration is a frequent reason for "the parameters don't show up".
The result on the console:
Hibernate:
select
s1_0.id,
s1_0.address,
s1_0.capacity,
s1_0.name
from
stations s1_0
where
s1_0.capacity>=?
binding parameter [1] as [INTEGER] - [24]generate_statistics: true adds, at the end of each session, a summary that is invaluable for spotting N+1:
Forty-seven entities with two statements is fine. Forty-seven entities with forty-eight statements is a textbook N+1 (04-04).
Security warning: the TRACE level for parameters prints every value sent to the log, including emails, hashed passwords or personal data of Ribalta's citizens. It is a development setting. In production, never.
- Multiple data sources
Occasionally an application needs to talk to two databases: CicloUrbana could have its operational database and a read-only replica for reports. As soon as you declare two DataSource beans, the autoconfiguration backs off and you have to build them by hand.
package com.ciclourbana.common.config;
@Configuration
public class DataSourceConfig {
@Bean
@Primary
@ConfigurationProperties("ciclourbana.datasource.primary")
public DataSourceProperties primaryProperties() {
return new DataSourceProperties();
}
@Bean
@Primary
public DataSource primaryDataSource() {
return primaryProperties()
.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
// The pair of beans for "reports" is identical, without @Primary and
// pointing at ciclourbana.datasource.reports.
}ciclourbana:
datasource:
primary:
url: jdbc:postgresql://localhost:5432/ciclourbana
username: ciclourbana
password: ${POSTGRES_PASSWORD}
reports:
url: jdbc:postgresql://replica:5432/ciclourbana
username: reports
password: ${REPORTS_PASSWORD}@Primary (which you already know from 02-02) resolves the ambiguity: without it, any injection of DataSource would fail with NoUniqueBeanDefinitionException. On top of that, with two sources you have to declare EntityManagerFactory and TransactionManager manually for each one, and separate the repositories into packages with @EnableJpaRepositories(basePackages = ...).
That is quite a lot of work, and this is why the recommendation is clear: do not do it unless it is unavoidable. Very often what people are after (isolating reports) is better solved with materialised views, caching (09-02) or splitting the service out (07-05).
- Credentials outside the repository
The PostgreSQL password cannot live in application.yml, because application.yml is in Git and Git has an eternal memory: deleting it in a later commit does not remove it from history.
We already saw the configuration precedence in 02-04; here we apply it. The most portable form is an environment variable with a placeholder:
spring:
datasource:
url: ${CICLOURBANA_DB_URL:jdbc:postgresql://localhost:5432/ciclourbana}
username: ${CICLOURBANA_DB_USER:ciclourbana}
password: ${CICLOURBANA_DB_PASSWORD}The first two carry a default value after the :; the third does not, deliberately: if the variable is not defined, the application fails to start with a clear message instead of trying to connect with an empty password.
Remember, too, the automatic name translation: Spring Boot turns spring.datasource.password into SPRING_DATASOURCE_PASSWORD, so defining that environment variable works without writing anything in the YAML.
| Option | Where it fits | Grade |
|---|---|---|
| Environment variables | Any deployment | Good |
A .env file outside Git |
Local development | Acceptable |
| Kubernetes secrets | Production on K8s (08-04) | Very good |
| Vault / AWS Secrets Manager | Production with rotation | The best |
Password in application.yml |
Nowhere | Unacceptable |
And in .gitignore, at a minimum: .env, *.env.local and data/.
- Checking the connection at startup
With everything configured, an explicit verification is worthwhile. A CommandLineRunner like the ones from 01-05, restricted to development:
package com.ciclourbana.common;
// imports: javax.sql.DataSource, java.sql.Connection, java.sql.DatabaseMetaData,
// org.slf4j.*, org.springframework.boot.CommandLineRunner, org.springframework.stereotype.Component
@Component
public class ConnectionVerifier implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(ConnectionVerifier.class);
private final DataSource dataSource;
public ConnectionVerifier(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void run(String... args) throws Exception {
try (Connection connection = dataSource.getConnection()) {
DatabaseMetaData metadata = connection.getMetaData();
log.info("Connection established with {} {}",
metadata.getDatabaseProductName(),
metadata.getDatabaseProductVersion());
log.info("Driver: {} {}",
metadata.getDriverName(), metadata.getDriverVersion());
log.info("URL: {}", metadata.getURL());
}
}
}The try-with-resources matters: without it, the connection never returns to the pool and you have just created a leak in the very first line of code that touches the DataSource.
Expected output with PostgreSQL:
INFO c.c.common.ConnectionVerifier : Connection established with PostgreSQL 16.2
INFO c.c.common.ConnectionVerifier : Driver: PostgreSQL JDBC Driver 42.7.2
INFO c.c.common.ConnectionVerifier : URL: jdbc:postgresql://localhost:5432/ciclourbanaIn module 7 we will see that Actuator offers this same information permanently at /actuator/health, with a db indicator that runs a validation query.
Common Mistakes and Tips
Leaving ddl-auto: update when deploying. The most expensive mistake in the module. It works in development, it appears to work in production, and one day it leaves the schema in a state nobody knows how to rebuild. Lesson 04-08 exists to get rid of it.
Raising maximum-pool-size to fix slowness. It almost always makes things worse: the database saturates and every query slows down. Before touching the pool, look at the SQL, the indexes and the duration of the transactions.
Leaving open-in-view at its default. The log warning is systematically ignored. Set it to false on the project's first line of JPA configuration; doing it later brings dozens of LazyInitializationException to light all at once.
Forgetting DB_CLOSE_DELAY=-1 in H2. It produces the baffling "my data disappears mid-run" with no error whatsoever.
Using org.hibernate.type.descriptor.sql to see the parameters. That is the Hibernate 5 name. In Spring Boot 3 you have to use org.hibernate.orm.jdbc.bind.
Leaving the H2 console reachable. Arbitrary SQL execution without authentication. Development only, localhost only.
Tip: set pool-name. With CicloUrbanaPool the log messages and the metrics are identifiable at a glance, especially if one day there are two pools.
Tip: enable leak-detection-threshold in development. Sixty seconds is a good threshold. Finding a leak from its stack trace takes minutes; finding it in production through pool exhaustion takes an afternoon.
Tip: pin the Docker image version. postgres:16-alpine, never postgres:latest. Having the whole team on the same engine avoids the hardest class of failure to reproduce.
Exercises
Exercise 1: sizing CicloUrbana's pool
The council deploys CicloUrbana across 3 replicas. The PostgreSQL server has 8 vCPUs, SSD storage and max_connections = 100. There is also a nightly reporting process that opens up to 5 connections and an administration dashboard with 5 more.
- Calculate
maximum-pool-sizeper replica using PostgreSQL's formula. - Check that the total does not exhaust
max_connections. - Write the complete
spring.datasource.hikariblock, justifying each value.
Exercise 2: separating development and production without duplicating configuration
Write CicloUrbana's configuration split across application.yml (common), application-dev.yml (H2 + console + SQL logs) and application-prod.yml (PostgreSQL + credentials from environment variables + validate). State which property must never appear in the production file and why.
Exercise 3: diagnosing pool exhaustion
In production this error appears intermittently at peak hours:
List four possible causes ordered from most to least likely, and for each one state how to confirm it and how to fix it. Explain why raising maximum-pool-size is not the first answer.
Solutions
Solution 1.
- Formula:
(cores * 2) + effective_spindles = (8 * 2) + 2 = 18connections for the whole database, not per replica. Split across 3 replicas:18 / 3 = 6per replica. A value of 6 is defensible; 8 is too, leaving some headroom. What is not defensible is 20 per replica. - Total:
3 replicas × 6 = 18, plus 5 for the nightly process and 5 for the dashboard = 28 connections. Againstmax_connections = 100that leaves plenty of headroom, including what PostgreSQL reserves for the superuser and maintenance. With 20 per replica it would be 70, already uncomfortable and far above what 8 vCPUs can serve in parallel. - Configuration:
spring:
datasource:
hikari:
pool-name: CicloUrbanaPool
maximum-pool-size: 6 # (8*2+2)/3 replicas
minimum-idle: 6 # fixed pool: no opening cost at the spike
connection-timeout: 3000 # fail fast (3 s) instead of queuing threads
idle-timeout: 600000 # irrelevant with minimum-idle = maximum
max-lifetime: 1800000 # 30 min, below the firewall's cut-off
leak-detection-threshold: 0 # off in productionThe most debatable value is connection-timeout: 3000. Lowering it from the default 30 s is deliberate: if the pool is exhausted, waiting 30 seconds only piles up threads and makes the problem worse. Failing in 3 seconds returns a quick 503, keeps the application alive and leaves the symptom visible in the metrics.
Solution 2.
# application.yml — common to every environment
spring:
application:
name: ciclourbana
jpa:
open-in-view: false
properties:
hibernate:
jdbc:
batch_size: 20
h2:
console:
enabled: false# application-dev.yml
spring:
datasource:
url: jdbc:h2:mem:ciclourbana;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
username: sa
password:
hikari: { pool-name: CicloUrbanaPool, maximum-pool-size: 5, leak-detection-threshold: 60000 }
h2:
console: { enabled: true, path: /h2-console }
jpa:
hibernate: { ddl-auto: update }
properties: { hibernate: { format_sql: true } }
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE# application-prod.yml
spring:
datasource:
url: ${CICLOURBANA_DB_URL}
username: ${CICLOURBANA_DB_USER}
password: ${CICLOURBANA_DB_PASSWORD}
hikari:
pool-name: CicloUrbanaPool
maximum-pool-size: 6
minimum-idle: 6
connection-timeout: 3000
max-lifetime: 1800000
jpa:
hibernate: { ddl-auto: validate }
logging:
level: { org.hibernate.SQL: WARN }What must never appear in production, in order of severity:
org.hibernate.orm.jdbc.bind: TRACE: it would dump into the log all the personal data of Ribalta's citizens that passes through a query. That is a privacy breach, on top of a noticeable performance cost.ddl-auto: updateorcreate: unreviewed schema modifications, or total data loss.spring.h2.console.enabled: true: arbitrary SQL execution without authentication.- The literal password: always through an environment variable, and with no default so that the failure is loud.
Profiles are activated with --spring.profiles.active=prod or SPRING_PROFILES_ACTIVE=prod, and are studied in depth in 07-02.
Solution 3. Causes ordered by real likelihood:
- Transactions that are too long (the most likely). A
@Transactionalmethod that calls an external service —CicloUrbana's payment gateway— holds the connection for the whole network call. With 6 connections and 2-second calls, the pool drains under very little traffic. Confirm: enableleak-detection-thresholdin pre-production and review the@Transactionalmethods looking for I/O. Fix: move the external call outside the transaction, with@TransactionalEventListener(AFTER_COMMIT)(04-07). - A connection leak. Some code obtains a connection from the
DataSourcewithouttry-with-resources. It is rare with Spring Data, but it shows up in hand-written utilities. Confirm:leak-detection-threshold: 60000and look for the "Apparent connection leak detected" traces. Fix: always close withtry-with-resources. - Slow queries from a missing index. A query that takes 5 seconds occupies its connection for 5 seconds. Confirm:
pg_stat_statementsin PostgreSQL orlog_min_duration_statement = 1000. Fix: indexes and rewriting the query (09-01). - The N+1 problem. An endpoint firing hundreds of queries per request multiplies the holding time. Confirm:
generate_statistics: trueand count the statements per session. Fix:JOIN FETCHor@EntityGraph(04-04 and 04-06).
Why raising maximum-pool-size is not the first answer: exhaustion is a symptom, not the disease. If the cause is a 2-second transaction, doubling the pool doubles the load on the database without fixing anything; the problem reappears at twice the traffic, now with a more saturated server. What is more, more active connections competing for CPU and locks slow down every query, including the ones that were fine. Raising the pool is the last measure, after you have measured and ruled out the four causes above.
Conclusion
CicloUrbana now has plumbing. You know what a DataSource is and why opening connections is expensive, which justifies the existence of a pool and the fact that close() closes nothing. You have in-memory H2 configured for development with DB_CLOSE_DELAY=-1 and MODE=PostgreSQL, its web console available on localhost only and the security warning engraved. You have a docker-compose.yml with PostgreSQL 16, a named volume, a healthcheck and the password from an environment variable, ready to be picked up again in 07-04. You know the four essential spring.datasource properties and why the driver is inferred on its own. You have walked through HikariCP parameter by parameter, you know that max-lifetime must sit below the firewall's cut-off, that minimum-idle equal to the maximum avoids the worst possible moment to open connections and that leak-detection-threshold is the fastest way to find a leak. And, above all, you have a defensible criterion for sizing the pool: (cores × 2) + disks, split across replicas, with the certainty that more connections do not mean more performance.
On the JPA side you have settled the decisions that govern the rest of the module: ddl-auto at update only while we design the entities, with the explicit commitment to replace it with Flyway and validate in 04-08; open-in-view: false from now on, accepting that the service returns complete DTOs in exchange for N+1 and lazy loading showing their face in development; and Hibernate's log configured with org.hibernate.SQL and org.hibernate.orm.jdbc.bind so you can read the real SQL with its parameters. You also know how several data sources are declared with @Primary and why it is worth avoiding, how to keep credentials out of Git and how to verify the connection at startup without leaving a leak behind on the way.
But the database is empty: there is not a single table, because there is not a single entity. Station is still an immutable record in memory. Lesson 04-03, Creating JPA Entities, changes that: we will see why a record cannot be an entity, turn Station, Bike and Rental into entities with @Entity, @Table and indexes, choose the right identifier generation strategy for PostgreSQL, map each data type carefully —including why amounts are BigDecimal and never double—, embed Location with @Embeddable, add automatic auditing and debut @Version, the optimistic locking that finally retires the ShallowEtagHeaderFilter from 03-03.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
