Skip to content

dbquerywatch/dbquerywatch

Repository files navigation

dbQueryWatch: shift-lefting the detection of slow queries

1. What is it?

You may be thinking: Handling slow queries is straightforward: just utilize a monitoring system to generate alerts whenever a slow query is detected in the database. Yes, this approach is effective, but at what cost?

Slow system response frustrates users [1], requiring the engineering team to swiftly identify and resolve the problematic query (or queries).

Approximately 50% of SQL performance issues stem from improper utilization of indexes. Fortunately, this kind of problem could be detected in advance by analyzing the execution plan of all database operations executed by the application.

Some DBMS like PostgreSQL, for example, can reports the estimated total cost of a query. But how you can make use of this key information to establish an upper-bound for DB operations in a particular integration test?

This is where dbQueryWatch comes into play!

dbQuery in action

2. Getting started

2.1. Prerequisites

Application Framework

Spring Boot

Your application must be based on Spring Boot 2.7 or newer.

Persistence Framework / Library

Hibernate

dbQueryWatch supports any persistence layer that uses Spring-managed datasources including, but not limited to:

  • Hibernate/JPA

  • jOOQ

  • Jdbi

  • MyBatis

Database

oracle 9i %23f80000?style=plastic&labelColor=%23ee3537&logo=oracle&logoColor=white MySQL 5.7%2b %23e48e00 PostgreSQL 9%2b %23316192

To get more tenable results from the query execution plan analysis, your integration tests should be using a database as production-like as possible. dbQueryWatch supports the most popular relational databases:

  • Oracle 10g+

  • MySQL 5.7+

  • PostgreSQL 9+

Database Schema & Indexes

Schema and indexes should be in sync with your production environment.

2.2. Adding the library to your project

The latest version of the library is available from Maven Central repository. Just include it into your test scope/configuration as shown below:

For Maven:

pom.xml
    <dependency>
        <groupId>org.dbquerywatch</groupId>
        <artifactId>dbquerywatch</artifactId>
        <version>2.0.0</version>
        <scope>test</scope>
    </dependency>

For Gradle (Kotlin):

build.gradle.kts
    testImplementation("org.dbquerywatch", "dbquerywatch", "2.0.0")

2.3. Enabling the detection

At its core, dbQueryWatch needs to link each performed database operation with its respective test class. In typical scenarios, this matching works effortlessly without any additional configuration.

2.3.1. MockMvc

Most Spring Boot integration tests are based on MockMvc that operates in a mock servlet environment, allowing test and persistence methods to run on the same thread. In that case, you only need to annotate your integration test classes with @CatchSlowQueries as shown below:

import org.dbquerywatch.api.spring.junit5.DbQueryWatch;

@SpringBootTest
@AutoConfigureMockMvc
@DbQueryWatch(allowSeqScans = false)  // (1)
class SomeIntegrationTest {
    @Autowired MockMvc mvc;

    @Test
    @DbQueryWatch(allowSeqScans = false, maxOverallCost = 10000)  // (2)
    void an_integration_test() {
        // ...
    }
}
  1. Enables the detector for all test methods

  2. Uses custom settings for an individual test method

2.3.2. Real Servlet Environment

Some integration tests uses a real servlet environment that implicitly detaches the execution of test and persistence methods. In such case, dbQueryWatch requires context propagation provided by tracing tools like Spring Sleuth or Micrometer to ensure proper correlation between test classes and database operations. On section How to enable tracing you can find how to easily enable tracing in your project.

dbQueryWatch automatically customize the autowired WebTestClient to include the HTTP tracing headers, compatible with both W3C/OpenTelemetry and Brave tracers.

import org.dbquerywatch.api.spring.junit5.DbQueryWatch;

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@CatchSlowQueries  // (1)
@Execution(CONCURRENT) // (2)
class SomeIntegrationTest {
    @Test
    @DbQueryWatch(allowSeqScans = false, maxOverallCost = 10000)  // (3)
    void an_integration_test(WebTestClient client) {
        // ...
    }
}
  1. Enables the detector for all test methods. @CatchSlowQueries is equivalent to @DbQueryWatch(allowSeqScans = false)

  2. Parallel mode is supported when using WebEnvironment.RANDOM_PORT + JUnit injected WebTestClient

  3. Uses custom settings for an individual test method

2.4. Understanding the issues report

dbQueryWatch monitors all database operations executed within the scope of a test class. Once all test methods have been executed, dbQueryWatch analyzes the Execution Plan of each operation, and if one or multiple slow operation were detected, the test class fail. It then prints a issues report to the console with the following details:

SQL

The SQL statement exactly as it was executed by your persistence layer.

Execution Plan

The execution plan as reported by the database.

Issues

A list of issues detected by the analysis.

Caller Methods

A list of application methods which directly or indirectly executed the database operation.

A sample report taken from the sample application test class:

org.dbquerywatch.application.domain.service.DatabasePerformanceIssuesDetectedException: Found 2 database performance issues!
## 1/2 SeqScan
- DataSource: 'dataSource (PostgreSQL)'
  SQL: "SELECT id, published_at, author_full_name, author_last_name, title FROM articles WHERE published_at >= ? AND published_at <= ?"
  ExecutionPlan: '[{"JIT":{"Options":{"Expressions":true,"Optimization":true,"Deforming":true,"Inlining":true},"Functions":4,"Timing":{"Generation":0.181,"Total":19.134,"Emission":9.686,"Optimization":8.021,"Inlining":1.246}},"Planning Time":0.045,"Triggers":[],"Planning":{"Local Written Blocks":0,"Temp Written Blocks":0,"Shared Hit Blocks":0,"Local Hit Blocks":0,"Local Dirtied Blocks":0,"Temp Read Blocks":0,"Local Read Blocks":0,"Shared Read Blocks":0,"Shared Dirtied Blocks":0,"Shared Written Blocks":0},"Plan":{"Relation Name":"articles","Temp Written Blocks":0,"Local Read Blocks":0,"Plan Rows":1,"Total Cost":11.05,"Local Written Blocks":0,"Shared Hit Blocks":1,"Startup Cost":0,"Actual Total Time":18.972,"Local Hit Blocks":0,"Plan Width":960,"Local Dirtied Blocks":0,"Temp Read Blocks":0,"Shared Read Blocks":0,"Shared Dirtied Blocks":0,"Rows Removed by Filter":1,"Parallel Aware":false,"Actual Startup Time":18.967,"Filter":"((published_at >= '1970-01-01'::date) AND (published_at <= '1980-12-31'::date))","Alias":"articles","Actual Loops":1,"Node Type":"Seq Scan","Async Capable":false,"Actual Rows":3,"Shared Written Blocks":0},"Execution Time":19.182}]'
  TotalCost: 11
  CallerMethods:
      - org.dbquerywatch.testapp.adapters.db.DefaultArticleRepository::query
  SeqScans:
      - objectName: articles
        predicate: "((published_at >= '1970-01-01'::date) AND (published_at <= '1980-12-31'::date))"
## 2/2 Excessive Overall Cost
- Actual: 11
  Maximum: 5
  CriticalStatements:
      - DataSource: 'dataSource (PostgreSQL)'
        SQL: "SELECT id, published_at, author_full_name, author_last_name, title FROM articles WHERE published_at >= ? AND published_at <= ?"
        ExecutionPlan: '[{"JIT":{"Options":{"Expressions":true,"Optimization":true,"Deforming":true,"Inlining":true},"Functions":4,"Timing":{"Generation":0.181,"Total":19.134,"Emission":9.686,"Optimization":8.021,"Inlining":1.246}},"Planning Time":0.045,"Triggers":[],"Planning":{"Local Written Blocks":0,"Temp Written Blocks":0,"Shared Hit Blocks":0,"Local Hit Blocks":0,"Local Dirtied Blocks":0,"Temp Read Blocks":0,"Local Read Blocks":0,"Shared Read Blocks":0,"Shared Dirtied Blocks":0,"Shared Written Blocks":0},"Plan":{"Relation Name":"articles","Temp Written Blocks":0,"Local Read Blocks":0,"Plan Rows":1,"Total Cost":11.05,"Local Written Blocks":0,"Shared Hit Blocks":1,"Startup Cost":0,"Actual Total Time":18.972,"Local Hit Blocks":0,"Plan Width":960,"Local Dirtied Blocks":0,"Temp Read Blocks":0,"Shared Read Blocks":0,"Shared Dirtied Blocks":0,"Rows Removed by Filter":1,"Parallel Aware":false,"Actual Startup Time":18.967,"Filter":"((published_at >= '1970-01-01'::date) AND (published_at <= '1980-12-31'::date))","Alias":"articles","Actual Loops":1,"Node Type":"Seq Scan","Async Capable":false,"Actual Rows":3,"Shared Written Blocks":0},"Execution Time":19.182}]'
        TotalCost: 11
        CallerMethods:
            - org.dbquerywatch.testapp.adapters.db.DefaultArticleRepository::query

As indicated by the issue’s description, there was a Full Table Scan on table articles due to a missing index over column published_at. Simply adding this index will fix the performance issue for this operation.

2.5. Configuration

You can tweak the operation of the analyzer through a couple of spring properties.

2.5.1. Excluding small tables

Nearly all business domains have one or more tables that are destined to stay small. You can exclude those tables by setting the dbquerywatch.small-tables property. Example:

dbquerywatch:
  small-tables: journals

The tables name are case-insensitive and can include the schema qualifier, in case of ambiguity.

2.5.2. Specifying the base packages for your persistent layer methods

dbQueryWatch inspects the stacktrace to identify the application-level methods to be listed under the Caller Methods section of the issues report. The library deduce these base packages from your spring configuration, but you may want to customize them using the dbquerywatch.app-base-packages property.

Example: let’s say your application adopts the Hexagonal Architecture, and all persistence methods reside on com.example.application.adapter.db package. In addition, you want to define the com.example.application as a fallback option. Your custom setting would be:

dbquerywatch:
  app-base-packages: com.example.application.adapters.db,com.example.application

4. Similar projects

Appendix A: How to enable tracing

In case tracing is still not enabled for your application, you can just enable it for testing purposes.

💡
To avoid sending actual spans to a Zipkin server, you can just set the property spring.zipkin.enabled=true in your test configuration (at src/test/resources/application.yml, for example).

A.1. Spring Boot 2.7

A minimal configuration would be:

For a Gradle project:

build.gradle.kts
    testImplementation(platform("org.springframework.cloud:spring-cloud-sleuth-dependencies:3.1.11"))
    testRuntimeOnly("org.springframework.cloud", "spring-cloud-starter-sleuth")
    testRuntimeOnly("org.springframework.cloud", "spring-cloud-sleuth-zipkin")

For a Maven project:

pom.xml
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-sleuth-dependencies</artifactId>
            <version>3.1.11</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-sleuth</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-sleuth-zipkin</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

For more advanced configurations, please refer to Spring Sleuth “How-to” Guides.

A.2. Spring Boot 3+

You must set spring.test.observability.auto-configure=true in your test configuration in order to enable tracing for all your integration tests.

For a Gradle project:

build.gradle.kts
    testRuntimeOnly("org.springframework.boot", "spring-boot-starter-actuator")  // if it's not already included
    testRuntimeOnly("io.micrometer", "micrometer-tracing-bridge-brave")
    testRuntimeOnly("io.zipkin.reporter2", "zipkin-reporter-brave")

For a Maven project:

pom.xml
    <dependencies>
        <dependency>
            <!-- if it's not already included -->
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-tracing-bridge-brave</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.zipkin.reporter2</groupId>
            <artifactId>zipkin-reporter-brave</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

For other possible configurations, see the section Tracer Implementations on Spring Boot Reference Documentation.