The most surprising thing about load testing database queries is that the bottleneck is rarely the database itself; it’s usually your application’s connection management or the network between your app and the DB.
Let’s see Gatling’s JDBC protocol in action. Imagine you have a users table and you want to simulate users logging in by fetching their id and email based on a username.
import io.gatling.core.Predef._
import io.gatling.jdbc.Predef._
import scala.concurrent.duration._
class DatabaseSimulation extends Simulation {
val jdbcConfig = jdbc.url("jdbc:postgresql://localhost:5432/mydb")
.username("user")
.password("password")
.pool("HikariCP") // Or another pool like c3p0, BoneCP
.maxConnections(10) // Max connections in the pool
.idleTimeout(30000) // Max idle time before closing connection
.build
val scn = scenario("Database Login")
.exec(
jdbc("Load User Data")
.query("SELECT id, email FROM users WHERE username = 'testuser'")
.asLong // Expecting a Long result for 'id'
.asString // Expecting a String result for 'email'
.check(bodyBytes.is(prepared("SELECT id, email FROM users WHERE username = ?").asLong.saveAs("userId").asString.saveAs("userEmail")))
)
.pause(1 second)
setUp(
scn.inject(rampUsers(100) during (30 seconds))
).protocols(jdbcConfig)
}
This simulation defines a database configuration using jdbc.url, username, and password. Crucially, it specifies a connection pool (HikariCP in this example) with maxConnections set to 10. This means only 10 concurrent connections to the database will be active at any given time from this Gatling simulation. The query function executes a SQL statement. The .asLong and .asString indicate the expected types for the columns being retrieved. The .check block demonstrates how to validate the results, using prepared to show how you’d parameterize a query to prevent SQL injection and save the results into Gatling’s session for later use.
Here’s what’s happening under the hood: Gatling doesn’t establish a new physical database connection for every single simulated user request. Instead, it utilizes a connection pool. When your scenario executes a jdbc step, Gatling requests a connection from the pool. If a connection is available, it’s handed over, used for the query, and then returned to the pool. If all connections in the pool are busy, the request waits until one is returned or the pool’s connection timeout is reached, which can cause Gatling to report an error if it’s configured too low. The setUp block defines the load profile: 100 virtual users will gradually ramp up over 30 seconds, all executing the Database Login scenario.
The primary problem Gatling’s JDBC protocol solves is simulating realistic database load without overwhelming your application or the database with inefficient connection handling. Traditional approaches might create a new connection per request, which is extremely resource-intensive. By using a connection pool, Gatling mimics how real applications manage database access, allowing you to discover bottlenecks related to connection acquisition, query execution time, and network latency under concurrent access. The maxConnections parameter is your main lever here; increasing it can improve throughput if your database and network can handle it, but setting it too high can lead to resource exhaustion on the database server.
A subtle but critical aspect is how prepared statements are handled within the .check block. While the query function itself might execute a raw SQL string for simplicity in the initial example, using prepared within the .check is where you’d typically perform parameterized queries for actual load testing. This ensures that the database can properly cache execution plans for identical queries, leading to more accurate performance measurements and reflecting a more secure application pattern. If you’re only using raw SQL in the query function and not parameterizing, your results might show higher latency than expected because the database has to parse and plan each unique string, even if the underlying intent is the same.
The next concept to explore is how to simulate more complex database interactions, such as transactions involving multiple queries or updating data, and how to handle various error conditions that might arise during database operations.