Answer the Question First
When a @Transactional service method that performs a simple read‑then‑write behaves normally for single requests but slows down once you hit ~10 concurrent users, the most common internal GORM triggers are:
- JDBC connection‑pool exhaustion – each thread blocks waiting for a free connection.
- Database lock contention – the default READ COMMITTED isolation can cause row‑level locks when several transactions update the same rows.
- Lazy‑loading round‑trips – multiple sessions fetching associations in parallel can create N+1 queries that only show up under load.
- Thread‑pool saturation in the application server – Tomcat’s worker pool can queue requests when all threads are busy.
None of these alone explain the latency spike unless the load pushes the system past a threshold (e.g., pool size or lock wait time). The key is that with a single request the system is comfortably within limits; when concurrency rises, one of the above resources becomes a bottleneck.
Confirmed Facts (Grails 4.x + Hibernate 5.x)
- Grails 4.x uses Hibernate 5.x by default and configures a
dataSource bean that wraps a HikariCP connection pool unless overridden.
- The default
hibernate.connection.pool_size is 10, matching the default Hikari maximumPoolSize when not set.
- Each @Transactional service method opens a Hibernate
Session that is flushed on commit, which can block if the session has pending writes and the pool is saturated.
- Tomcat’s default thread pool is 200 threads; however, if the request handler performs blocking I/O (e.g., database I/O), the effective concurrency can drop below the thread count.
How to Isolate the Source of the Delay
- Enable Grails metrics (Micrometer or Dropwizard) and look at:
- Connection pool
borrowed and idle counts.
- Thread pool
queueSize and activeThreads.
- Log SQL statements with
hibernate.show_sql=true and hibernate.format_sql=true (or use org.hibernate.SQL logger). Run a load test with increasing users and capture the number of statements per request. Look for N+1 patterns that grow with concurrency.
- Check database lock statistics during the test. For PostgreSQL, query
pg_locks and pg_stat_activity; for MySQL, inspect information_schema.innodb_locks and SHOW PROCESSLIST. If you see many waiting rows, lock contention is the culprit.
- Temporarily increase the connection pool size (e.g.,
hibernate.hikari.maximumPoolSize=20) and observe whether the latency spike disappears. If it does, pool exhaustion was the root cause.
- Profile the JVM with VisualVM or async-profiler to detect thread contention or GC pauses that correlate with the latency spikes. A high
thread contention metric usually points to thread‑pool saturation.
Key GORM/Hibernate Settings to Examine
| Setting | Typical Value | What to Check |
| hibernate.hikari.maximumPoolSize | 10 (default) | Increase if you see borrowed=10 and idle=0 under load. |
| hibernate.connection.isolation | READ_COMMITTED | Consider READ_UNCOMMITTED for write‑heavy workloads if consistency allows. |
| grails.transaction.defaultReadOnly | true | Set to false only if writes are frequent; otherwise, keep read‑only to reduce lock contention. |
| hibernate.default_batch_fetch_size | 25 | Adjust for eager fetching of collections to reduce N+1 queries. |
| hibernate.enable_lazy_load_no_trans | false | Enable if you need lazy loading outside transactions, but it can add round‑trips. |
Practical Mitigation Steps
- Increase the connection pool size to match the expected peak concurrency.
- Use
@Transactional(readOnly = true) on service methods that only read to avoid unnecessary flushes.
- Apply
fetch = 'join' or join fetch HQL when you know the associations will be needed, but keep an eye on memory.
- If lock contention is high, consider
SELECT ... FOR UPDATE only when necessary and reduce the scope of the update.
- Review the application server’s thread pool configuration and increase
maxThreads if you see a high queue length.
Missing Diagnostic Detail (Optional)
To fine‑tune the recommendation, it would help to know which JDBC connection pool provider you’re using (HikariCP, Tomcat JDBC, c3p0). If you’re on HikariCP, the maximumPoolSize property is the most relevant; if on Tomcat JDBC, look at maxActive and maxWait.
Conclusion
In Grails 4.x, a latency spike that appears only under concurrent load is almost always tied to a resource that becomes saturated when many threads compete. The most common culprits are the JDBC connection pool, database lock contention, and lazy‑loading overhead. By enabling metrics, logging SQL, inspecting database lock stats, and adjusting pool sizes or fetch strategies, you can pinpoint the exact bottleneck and apply the appropriate fix.