Skip to main content

Command Palette

Search for a command to run...

Implementing Automatic Retry for Database Deadlocks in Spring Boot using Spring Retry

Updated
4 min read
Implementing Automatic Retry for Database Deadlocks in Spring Boot using Spring Retry

Overview

  • Lack of experience in handling deadlocks in databases can lead to panic during the most critical moments when a production-level system faces a high influx of customers. No matter how clearly you write business logic or how thoroughly you conduct unit tests, you can still encounter numerous unexpected deadlocks at the production level, depending on the traffic. This article aims to explain how to minimize deadlocks in a Spring Boot and Spring Data JPA environment, focusing on the READ-COMMITTED transaction isolation level and the usage of @Retryable.

Transactions and Isolation Levels

  • The InnoDB storage engine of MySQL/MariaDB supports transactional functionality. Transactions are a method to ensure data consistency among queries executed within a single client connection. Clients can choose different levels of isolation for each transaction. The stricter the isolation level, the less it is affected by other transactions but this results in longer shared lock times and reduced concurrency. Conversely, a relaxed isolation level leads to more influence from other transactions, decreasing consistency but shortening shared lock times and improving concurrency. The two most commonly used isolation levels provided by InnoDB are REPEATABLE-READ and READ-COMMITTED.

Deadlocks and Isolation Levels

  • A deadlock occurs when a transaction A encounters a shared lock on a row and transaction B attempts to write to that row. Deadlocks are neither bugs nor errors; they are phenomena observed in environments requiring concurrency. MySQL authority Bill Karwin recommends using the READ-COMMITTED transaction isolation level to prevent and mitigate deadlocks, and to write source code that retries the logic in case of a deadlock rather than throwing an exception. [Related Link]

Strategies to Minimize Deadlocks

  • Minimize the transaction scope as much as possible (to reduce shared lock time). In particular, @Transactional annotations at the @Controller level, which are very likely to cause deadlocks, should be removed.

  • Execute all queries at the READ-COMMITTED transaction isolation level (to minimize shared lock time).

  • In case of a deadlock, use @Retryable to retry execution after a random duration within a specified time range (to cope with temporarily inevitable deadlocks).

build.gradle.kts

  • In the root of the project, add the following to your build.gradle.kts file.
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-aop")
    implementation("org.springframework.retry:spring-retry:2.0.5")
}

@EnableRetry

  • To activate @Retryable, it's necessary to write a @Configuration bean and specify @EnableRetry at the class level.

  • The following example shows a custom bean created without specifying @EnableRetry. This is done to consider the operational order between @Transactional and @Retryable when both are annotated on the same method. To ensure that @Retryable always operates first, the priority is set to the highest as shown below.

import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered
import org.springframework.retry.annotation.RetryConfiguration

@Configuration
class RetryConfig : RetryConfiguration() {

    override fun getOrder(): Int {

        return Ordered.HIGHEST_PRECEDENCE
    }
}

@Retryable

  • @Retryable can be specified at all class and method levels of Spring beans. One of the significant uses of @Retryable is for automatic retry handling in case of deadlocks. In anticipation of deadlocks, it can be written in @Repository like below. (It's not necessary to apply only to @Repository; it can be written as needed in a higher class calling @Repository like @Service.)
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.retry.annotation.Backoff
import org.springframework.retry.annotation.Retryable
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Isolation
import org.springframework.transaction.annotation.Transactional

@Repository
// Minimize shared locks by relaxing transaction isolation level to READ-COMMITTED
@Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED)
// Activate automatic retry for up to 3 times with a random interval of 1-3 seconds in case of an exception
@Retryable(
    maxAttempts = 3,
    backoff = Backoff(random = true, delay = 1000, maxDelay = 3000),
    // Exclude retries for exceptions due to data integrity violations
    exclude = [DataIntegrityViolationException::class]
)
interface FooRepository : JpaRepository<Foo, Long> {

    // For write operations, set the default readOnly = false
    @Transactional(isolation = Isolation.READ_COMMITTED)
    fun deleteById(id: Long) { ... }
}

@Repository and @Transactional

  • All @Repository in Spring Data JPA are by default specified with @Transactional(readOnly = true) at the class level, and every delete, save, and flush method is specified with @Transactional(readOnly = false). This means that a transaction at the minimum query level is executed even if no @Transactional is specified at the higher calling level.

  • In the code introduced earlier, understanding this feature, @Transactional(readOnly = true, isolation = Isolation.READ_COMMITTED), @Transactional(isolation = Isolation.READ_COMMITTED) are specified at the class level to ensure that the READ_COMMITTED transaction isolation level operates in any situation.

  • There might be concerns that declaring @Transactional on each method would ignore the transaction declared at a higher level and create individual transactions. However, if you look inside the annotation, it has a default Propagation.REQUIRED propagation level, which naturally continues the transaction of the method calling side, operating as a single transaction.

Reference Articles

U
Unknown2y ago

Thanks to you, I was able to think deeply about lock and isolation. Thank you!

You have set the @Transactional annotation on the Repository interface. I usually prefer to declare it in the Class method of the Service layer... Is it a better practice to declare it in the Repository?

1
T

Of course, it is most commonly used in concrete classes at the Service level :) In this example, I wanted to introduce you to the lowest level where you can use @Transactional in your project, so I showed you an example of specifying it in the @Repository interface. I make it a habit to specify @Repository and @Service, because that way I can cope with both transactions from real-time APIs with low execution time, and logic that has hours of execution time and intentionally doesn't use service-level transactions.

1
U
Unknown2y ago

Taehyeong Lee Thank you for the response. I also plan to develop a habit of doing so. By establishing a solid foundation, it seems possible to preemptively prevent deadlocks that may occur in unexpected areas.

1
U
Unknown2y ago

Thanks to you, I was able to contemplate about lock and isolation.

In source code, you implemented the @Transactional annotation in the Repository Interface, but according to Spring documentation, they recommend setting it at the class level.

how do you think about it?

https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/annotations.html#:~:text=The%20Spring%20team,a%20rollback%20scenario.

---- I discovered this after making the comment ---- It seems that the Repository with Data JPA interface is an exception where it's permissible to use Transactional.

1
T

It also works well to specify @Transactional in a custom method of the Spring Data JPA interface, as in this example. This brings a lot of convenience to the developer, as they don't have to re-write each custom query with @Transactional in an outer contained class. In typical real-time business logic, most of the time the transaction propagation setting will use the default value of Propagation.REQUIRED, so you can also naturally carry over transactions created at higher Service levels.

More from this blog

T

Taehyeong Lee | Software Engineer

58 posts

I am Software Engineer with 15 years of experience, working at Gentle Monster. I specialize in developing high-load, large-scale processing APIs using Kotlin and Spring Boot. I live in Seoul, Korea.