Why does Grails GORM latency increase only under concurrent request load?
0 reputation · 24 Mar 2021, 07:29 UTC
0 reputation · 24 Mar 2021, 07:29 UTC
In a Grails 4.x application, a service method marked with @Transactional performs a simple read‑then‑write operation on a domain class using GORM. The method is invoked via a controller endpoint that is accessed by a load generator simulating multiple concurrent users. When the endpoint is called with a single request, response times remain within expected bounds, but as the concurrency level rises above ten simultaneous requests, the average latency noticeably increases.
The increase appears only under load and does not correlate with visible errors or increased database response times. Possible contributors include the Hibernate session flush mode, connection pool exhaustion, or second‑level cache lock contention, but the exact trigger remains unclear. What configuration or internal GORM behavior could cause latency to rise only under concurrent request load? How can one isolate whether the delay originates from session flushing or connection acquisition? Which Grails or Hibernate settings should be examined to mitigate this effect?
34775 reputation · 24 Mar 2021, 19:01 UTC
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:
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.
dataSource bean that wraps a HikariCP connection pool unless overridden.hibernate.connection.pool_size is 10, matching the default Hikari maximumPoolSize when not set.Session that is flushed on commit, which can block if the session has pending writes and the pool is saturated.borrowed and idle counts.queueSize and activeThreads.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.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.hibernate.hikari.maximumPoolSize=20) and observe whether the latency spike disappears. If it does, pool exhaustion was the root cause.thread contention metric usually points to thread‑pool saturation.| 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. |
@Transactional(readOnly = true) on service methods that only read to avoid unnecessary flushes.fetch = 'join' or join fetch HQL when you know the associations will be needed, but keep an eye on memory.SELECT ... FOR UPDATE only when necessary and reduce the scope of the update.maxThreads if you see a high queue length.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.
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.
Use comments to ask for clarification. Post a solution as an answer.
34,775 reputation · 24 Mar 2021, 07:47 UTC
By default, GORM/Hibernate uses FlushMode.AUTO, which triggers a session flush before every query to ensure the persistence context is up‑to‑date. Under low concurrency the extra flush cost is negligible, but when many threads execute transactional methods simultaneously the repeated flushes increase dirty‑checking work, generate more short‑lived objects, and raise GC pressure. This added CPU work shows up as higher request latency even though individual SQL execution times stay flat. Switching to FlushMode.COMMIT (or FlushMode.MANUAL) moves the flush to transaction commit, reducing per‑query overhead; however, it can cause stale reads if the application relies on seeing uncommitted changes within the same transaction.