This tutorial shows you how to build a simple Spring Boot application with CockroachDB, using the MyBatis-Spring-Boot-Starter module for data access.

Before you begin

  1. Install CockroachDB.
  2. Start up a secure or insecure local cluster.
  3. Choose the instructions that correspond to whether your cluster is secure or insecure:
Warning:

The --insecure flag used in this tutorial is intended for non-production testing only. To run CockroachDB in production, use a secure cluster instead.

Step 1. Install JDK

Download and install a Java Development Kit. MyBatis-Spring supports Java versions 8+. In this tutorial, we use JDK 11 from OpenJDK.

Step 2. Install Gradle

This example application uses Gradle to manage all application dependencies. Spring supports Gradle versions 6+.

To install Gradle on macOS, run the following command:

copy
icon/buttons/copy
$ brew install gradle

To install Gradle on a Debian-based Linux distribution like Ubuntu:

copy
icon/buttons/copy
$ apt-get install gradle

To install Gradle on a Red Hat-based Linux distribution like Fedora:

copy
icon/buttons/copy
$ dnf install gradle

For other ways to install Gradle, see its official documentation.

Step 3. Get the application code

To get the application code, download or clone the mybatis-cockroach-demo repository.

Step 4. Create the maxroach user and bank database

Start the built-in SQL shell:

copy
icon/buttons/copy
$ cockroach sql --certs-dir=certs

In the SQL shell, issue the following statements to create the maxroach user and bank database:

copy
icon/buttons/copy
> CREATE USER IF NOT EXISTS maxroach;
copy
icon/buttons/copy
> CREATE DATABASE bank;

Give the bank user the necessary permissions:

copy
icon/buttons/copy
> GRANT ALL ON DATABASE bank TO maxroach;

Exit the SQL shell:

copy
icon/buttons/copy
> \q

Step 5. Generate a certificate for the maxroach user

Create a certificate and key for the maxroach user by running the following command. The code samples will run as this user.

copy
icon/buttons/copy
$ cockroach cert create-client maxroach --certs-dir=certs --ca-key=my-safe-directory/ca.key --also-generate-pkcs8-key

The --also-generate-pkcs8-key flag generates a key in PKCS#8 format, which is the standard key encoding format in Java. In this case, the generated PKCS8 key will be named client.maxroach.key.pk8.

Step 6. Run the application

To run the application:

  1. Open and edit the src/main/resources/application.yml file so that the url field specifies the full connection string to the running CockroachDB cluster. To connect to a secure cluster, this connection string must set the sslmode connection parameter to require, and specify the full path to the client, node, and user certificates in the connection parameters. For example:

      ...
      datasource:
        url: jdbc:postgresql://localhost:26257/bank?ssl=true&sslmode=require&sslrootcert=certs/ca.crt&sslkey=certs/client.maxroach.key.pk8&sslcert=certs/client.maxroach.crt
      ...
    
  2. Open a terminal, and navigate to the mybatis-cockroach-demo project directory:

    copy
    icon/buttons/copy
    $ cd <path>/mybatis-cockroach-demo
    
  3. Run the Gradle script to download the application dependencies, compile the code, and run the application:

    copy
    icon/buttons/copy
    $ ./gradlew bootRun
    

Start the built-in SQL shell:

copy
icon/buttons/copy
$ cockroach sql --insecure

In the SQL shell, issue the following statements to create the maxroach user and bank database:

copy
icon/buttons/copy
> CREATE USER IF NOT EXISTS maxroach;
copy
icon/buttons/copy
> CREATE DATABASE bank;

Give the bank user the necessary permissions:

copy
icon/buttons/copy
> GRANT ALL ON DATABASE bank TO maxroach;

Exit the SQL shell:

copy
icon/buttons/copy
> \q

Step 6. Run the application

To run the application:

  1. Open and edit the src/main/resources/application.yml file so that the url field specifies the full connection string to the running CockroachDB cluster. For example:

      ...
      datasource:
        url: jdbc:postgresql://localhost:26257/bank?ssl=false
      ...
    
  2. Open a terminal, and navigate to the mybatis-cockroach-demo project directory:

    copy
    icon/buttons/copy
    $ cd <path>/mybatis-cockroach-demo
    
  3. Run the Gradle script to download the application dependencies, compile the code, and run the application:

    copy
    icon/buttons/copy
    $ ./gradlew bootRun
    

The output should look like the following:

> Task :bootRun

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.2.6.RELEASE)

2020-06-01 14:40:04.333  INFO 55970 --- [           main] c.e.c.CockroachDemoApplication           : Starting CockroachDemoApplication on MyComputer with PID 55970 (path/mybatis-cockroach-demo/build/classes/java/main started by user in path/mybatis-cockroach-demo)
2020-06-01 14:40:04.335  INFO 55970 --- [           main] c.e.c.CockroachDemoApplication           : No active profile set, falling back to default profiles: default
2020-06-01 14:40:05.195  INFO 55970 --- [           main] c.e.c.CockroachDemoApplication           : Started CockroachDemoApplication in 1.39 seconds (JVM running for 1.792)
2020-06-01 14:40:05.216  INFO 55970 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2020-06-01 14:40:05.611  INFO 55970 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
deleteAllAccounts:
    => 2 total deleted accounts
insertAccounts:
    => 2 total new accounts in 1 batches
printNumberOfAccounts:
    => Number of accounts at time '14:40:05.660226':
    => 2 total accounts
printBalances:
    => Account balances at time '14:40:05.678942':
    ID 1 => $1000
    ID 2 => $250
transferFunds:
    => $100 transferred between accounts 1 and 2, 2 rows updated
printBalances:
    => Account balances at time '14:40:05.688511':
    ID 1 => $900
    ID 2 => $350
bulkInsertRandomAccountData:
    => finished, 500 total rows inserted in 1 batches
printNumberOfAccounts:
    => Number of accounts at time '14:40:05.960214':
    => 502 total accounts
2020-06-01 14:40:05.968  INFO 55970 --- [extShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2020-06-01 14:40:05.993  INFO 55970 --- [extShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.

BUILD SUCCESSFUL in 12s
3 actionable tasks: 3 executed

The application runs a number of test functions that result in reads and writes to the accounts table in the bank database.

For more details about the application code, see Application details.

Application details

This section walks you through the different components of the application project in detail.

Main process

The main process of the application is defined in src/main/java/com/example/cockroachdemo/CockroachDemoApplication.java:

copy
icon/buttons/copy
package com.example.cockroachdemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CockroachDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(CockroachDemoApplication.class, args);
    }
}

The SpringApplication.run call in the main method bootstraps and launches a Spring application. The @SpringBootApplication annotation on the CockroachDemoApplication class triggers Spring's component scanning and auto-configuration features.

The BasicExample class, defined in src/main/java/com/example/cockroachdemo/BasicExample.java, is one of the components detected in the component scan:

copy
icon/buttons/copy
package com.example.cockroachdemo;

import java.time.LocalTime;

import com.example.cockroachdemo.model.Account;
import com.example.cockroachdemo.model.BatchResults;
import com.example.cockroachdemo.service.AccountService;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

@Component
@Profile("!test")
public class BasicExample implements CommandLineRunner {
    @Autowired
    private AccountService accountService;

    @Override
    public void run(String... args) throws Exception {
        accountService.createAccountsTable();
        deleteAllAccounts();
        insertAccounts();
        printNumberOfAccounts();
        printBalances();
        transferFunds();
        printBalances();
        bulkInsertRandomAccountData();
        printNumberOfAccounts();
    }

    private void deleteAllAccounts() {
        int numDeleted = accountService.deleteAllAccounts();
        System.out.printf("deleteAllAccounts:\n    => %s total deleted accounts\n", numDeleted);
    }

    private void insertAccounts() {
        Account account1 = new Account();
        account1.setId(1);
        account1.setBalance(1000);

        Account account2 = new Account();
        account2.setId(2);
        account2.setBalance(250);
        BatchResults results = accountService.addAccounts(account1, account2);
        System.out.printf("insertAccounts:\n    => %s total new accounts in %s batches\n", results.getTotalRowsAffected(), results.getNumberOfBatches());
    }

    private void printBalances() {
        int balance1 = accountService.getAccount(1).map(Account::getBalance).orElse(-1);
        int balance2 = accountService.getAccount(2).map(Account::getBalance).orElse(-1);

        System.out.printf("printBalances:\n    => Account balances at time '%s':\n    ID %s => $%s\n    ID %s => $%s\n",
            LocalTime.now(), 1, balance1, 2, balance2);
    }

    private void printNumberOfAccounts() {
        System.out.printf("printNumberOfAccounts:\n    => Number of accounts at time '%s':\n    => %s total accounts\n",
            LocalTime.now(), accountService.findCountOfAccounts());
    }

    private void transferFunds() {
        int fromAccount = 1;
        int toAccount = 2;
        int transferAmount = 100;
        int transferredAccounts = accountService.transferFunds(fromAccount, toAccount, transferAmount);
        System.out.printf("transferFunds:\n    => $%s transferred between accounts %s and %s, %s rows updated\n",
            transferAmount, fromAccount, toAccount, transferredAccounts);
    }

    private void bulkInsertRandomAccountData() {
        BatchResults results = accountService.bulkInsertRandomAccountData(500);
        System.out.printf("bulkInsertRandomAccountData:\n    => finished, %s total rows inserted in %s batches\n",
            results.getTotalRowsAffected(), results.getNumberOfBatches());
    }
}

BasicExample implements the Spring CommandLineRunner interface. Implementations of this interface automatically run when detected in a Spring project directory. BasicExample runs a series of test methods that are eventually executed as SQL queries in the data access layer of the application.

Configuration

All MyBatis-Spring applications need a DataSource, a SqlSessionFactory, and at least one mapper interface. The MyBatis-Spring-Boot-Starter module, built on MyBatis and MyBatis-Spring, and used by this application, greatly simplifies how you configure each of these required elements.

Applications that use MyBatis-Spring-Boot-Starter typically need just an annotated mapper interface and an existing DataSource in the Spring environment. The module detects the DataSource, creates a SqlSessionFactory from the DataSource, creates a thread-safe SqlSessionTemplate with the SqlSessionFactory, and then auto-scans the mappers and links them to the SqlSessionTemplate for injection. The SqlSessionTemplate automatically commits, rolls back, and closes sessions, based on the application's Spring-based transaction configuration.

This sample application implements batch write operations, a CockroachDB best practice for executing multiple INSERT and UPSERT statements. MyBatis applications that support batch operations require some additional configuration work, even if the application uses MyBatis-Spring-Boot-Starter:

  • The application must define a specific mapper interface for batch query methods.
  • The application must define a SqlSessionTemplate constructor, specifically for batch operations, that uses the BATCH executor type.
  • The batch mapper must be explicitly registered with the batch-specific SqlSessionTemplate.

The class defined in src/main/java/com/example/cockroachdemo/MyBatisConfiguration.java configures the application to meet these requirements:

copy
icon/buttons/copy
package com.example.cockroachdemo;

import javax.sql.DataSource;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.session.ExecutorType;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

/**
 * This class configures MyBatis and sets up mappers for injection.
 * 
 * When using the Spring Boot Starter, using a class like this is completely optional unless you need to
 * have some mappers use the BATCH executor (as we do in this demo). If you don't have that requirement,
 * then you can remove this class. By Default, the MyBatis Spring Boot Starter will find all mappers
 * annotated with @Mapper and will automatically wire your Datasource to the underlying MyBatis
 * infrastructure.
 */
@Configuration
@MapperScan(basePackages = "com.example.cockroachdemo.mapper", annotationClass = Mapper.class)
@MapperScan(basePackages = "com.example.cockroachdemo.batchmapper", annotationClass = Mapper.class,
            sqlSessionTemplateRef = "batchSqlSessionTemplate")
public class MyBatisConfiguration {

    @Autowired
    private DataSource dataSource;

    @Bean
    public SqlSessionFactory sqlSessionFactory() throws Exception {
        SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
        factory.setDataSource(dataSource);
        return factory.getObject();
    }

    @Bean
    @Primary
    public SqlSessionTemplate sqlSessionTemplate() throws Exception {
        return new SqlSessionTemplate(sqlSessionFactory());
    }

    @Bean(name = "batchSqlSessionTemplate")
    public SqlSessionTemplate batchSqlSessionTemplate() throws Exception {
        return new SqlSessionTemplate(sqlSessionFactory(), ExecutorType.BATCH);
    }
}

This class explicitly defines the batch SqlSessionTemplate (i.e., batchSqlSessionTemplate), and registers batchmapper, the batch mapper interface defined in src/main/java/com/example/cockroachdemo/batchmapper/BatchMapper.java with batchSqlSessionTemplate. To complete the MyBatis configuration, the class also declares a DataSource, and defines the remaining SqlSessionFactory and SqlSessionTemplate beans.

Note that a configuration class is not required for MyBatis-Spring-Boot-Starter applications that do not implement batch operations.

Data source

src/main/resources/application.yml contains the metadata used to create a connection to the CockroachDB cluster:

copy
icon/buttons/copy
spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:26257/bank?ssl=true&sslmode=require&sslrootcert=/Users/ericharmeling/certs/ca.crt&sslkey=/Users/ericharmeling/certs/client.maxroach.key.pk8&sslcert=/Users/ericharmeling/certs/client.maxroach.crt
    username: maxroach

Spring Boot uses the application's datasource property to auto-configure the database connection. This database connection configuration can be injected into the application's SqlSessionFactoryBean, as is explicitly done in the MyBatisConfiguration configuration class definition.

Mappers

All MyBatis applications require at least one mapper interface. These mappers take the place of manually-defined data access objects (DAOs). They provide other layers of the application an interface to the database.

MyBatis-Spring-Boot-Starter usually scans the project for interfaces annotated with @Mapper, links the interfaces to a SqlSessionTemplate, and registers them with Spring so they can be injected into the application's Spring beans. As mentioned in the Configuration section, because the application supports batch writes, the two mapper interfaces in the application are registered and linked manually in the MyBatisConfiguration configuration class definition.

Account mapper

src/main/java/com/example/cockroachdemo/mapper/AccountMapper.java defines the mapper interface to the accounts table using the MyBatis Java API:

copy
icon/buttons/copy
package com.example.cockroachdemo.mapper;

import java.util.List;
import java.util.Optional;

import com.example.cockroachdemo.model.Account;

import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;

@Mapper
public interface AccountMapper {
    @Delete("delete from accounts")
    int deleteAllAccounts();

    @Update("update accounts set balance=#{balance} where id=${id}")
    void updateAccount(Account account);

    @Select("select id, balance from accounts where id=#{id}")
    Optional<Account> findAccountById(int id);

    @Select("select id, balance from accounts order by id")
    List<Account> findAllAccounts();

    @Update({
        "upsert into accounts (id, balance) values",
        "(#{fromId}, ((select balance from accounts where id = #{fromId}) - #{amount})),",
        "(#{toId}, ((select balance from accounts where id = #{toId}) + #{amount}))",
    })
    int transfer(@Param("fromId") int fromId, @Param("toId") int toId, @Param("amount") int amount);

    @Update("CREATE TABLE IF NOT EXISTS accounts (id INT PRIMARY KEY, balance INT, CONSTRAINT balance_gt_0 CHECK (balance >= 0))")
    void createAccountsTable();

    @Select("select count(*) from accounts")
    Long findCountOfAccounts();
}

The @Mapper annotation declares the interface a mapper for MyBatis to scan. The SQL statement annotations on each of the interface methods map them to SQL queries. For example, the first method, deleteAllAccounts() is marked as a DELETE statement with the @Delete annotation. This method executes the SQL statement specified in the string passed to the annotation, "delete from accounts", which deletes all rows in the accounts table.

Batch account mapper

src/main/java/com/example/cockroachdemo/batchmapper/BatchAccountMapper.java defines a mapper interface for batch writes:

copy
icon/buttons/copy
package com.example.cockroachdemo.batchmapper;

import java.util.List;

import com.example.cockroachdemo.model.Account;

import org.apache.ibatis.annotations.Flush;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.executor.BatchResult;

@Mapper
public interface BatchAccountMapper {
    @Insert("upsert into accounts(id, balance) values(#{id}, #{balance})")
    void insertAccount(Account account);

    @Flush
    List<BatchResult> flush();
}

This interface has a single INSERT statement query method, along with a method for flushing (i.e., executing) a batch of statements.

Services

src/main/java/com/example/cockroachdemo/service/AccountService.java defines the service interface, with a number of methods for reading and writing to the database:

copy
icon/buttons/copy
package com.example.cockroachdemo.service;

import java.util.Optional;

import com.example.cockroachdemo.model.Account;
import com.example.cockroachdemo.model.BatchResults;

public interface AccountService {
    void createAccountsTable();
    Optional<Account> getAccount(int id);
    BatchResults bulkInsertRandomAccountData(int numberToInsert);
    BatchResults addAccounts(Account...accounts);
    int transferFunds(int fromAccount, int toAccount, int amount);
    long findCountOfAccounts();
    int deleteAllAccounts();
}

MyBatisAccountService.java implements the AccountService interface, using the mappers defined in AccountMapper.java and BatchAccountMapper.java, and the models defined in Account.java and BatchResults.java:

copy
icon/buttons/copy
package com.example.cockroachdemo.service;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.Random;

import com.example.cockroachdemo.batchmapper.BatchAccountMapper;
import com.example.cockroachdemo.mapper.AccountMapper;
import com.example.cockroachdemo.model.Account;
import com.example.cockroachdemo.model.BatchResults;

import org.apache.ibatis.executor.BatchResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class MyBatisAccountService implements AccountService {
    @Autowired
    private AccountMapper mapper;
    @Autowired
    private BatchAccountMapper batchMapper;
    private Random random = new Random();

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void createAccountsTable() {
        mapper.createAccountsTable();
    }

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public BatchResults addAccounts(Account...accounts) {
        for (Account account : accounts) {
            batchMapper.insertAccount(account);
        }
        List<BatchResult> results = batchMapper.flush();

        return new BatchResults(1, calculateRowsAffectedBySingleBatch(results));
    }

    private int calculateRowsAffectedBySingleBatch(List<BatchResult> results) {
        return results.stream()
            .map(BatchResult::getUpdateCounts)
            .flatMapToInt(Arrays::stream)
            .sum();
    }

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public BatchResults bulkInsertRandomAccountData(int numberToInsert) {
        int BATCH_SIZE = 128;
        List<List<BatchResult>> results = new ArrayList<>();

        for (int i = 0; i < numberToInsert; i++) {
            Account account = new Account();
            account.setId(random.nextInt(1000000000));
            account.setBalance(random.nextInt(1000000000));
            batchMapper.insertAccount(account);
            if ((i + 1) % BATCH_SIZE == 0) {
                results.add(batchMapper.flush());
            }
        }
        if(numberToInsert % BATCH_SIZE != 0) {
            results.add(batchMapper.flush());
        }
        return new BatchResults(results.size(), calculateRowsAffectedByMultipleBatches(results));
    }

    private int calculateRowsAffectedByMultipleBatches(List<List<BatchResult>> results) {
        return results.stream()
            .mapToInt(this::calculateRowsAffectedBySingleBatch)
            .sum();
    }

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Optional<Account> getAccount(int id) {
        return mapper.findAccountById(id);
    }

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public int transferFunds(int fromId, int toId, int amount) {
        return mapper.transfer(fromId, toId, amount);
    }

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public long findCountOfAccounts() {
        return mapper.findCountOfAccounts();
    }

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public int deleteAllAccounts() {
        return mapper.deleteAllAccounts();
    }
}

Note that the public methods (i.e., the methods to be called by other classes in the project) are annotated as @Transactional methods. This ensures that all of the SQL statements executed in the data access layer are run within the context of a database transaction

@Transactional takes a number of parameters, including a propagation parameter that determines the transaction propagation behavior around an object (i.e., at what point in the stack a transaction starts and ends). propagation=REQUIRES_NEW for the methods in the service layer, meaning that a new transaction must be created each time a request is made to the service layer. With this propagation behavior, the application follows the entity-control-boundary (ECB) pattern, as the service boundaries determine where a transaction starts and ends rather than the lower-level query methods of the mapper interfaces.

For more details on aspect-oriented transaction management in this application, see below.

Models

Instances of the Account class, defined in src/main/java/com/example/cockroachdemo/model/Account.java, represent rows in the accounts table:

copy
icon/buttons/copy
package com.example.cockroachdemo.model;

public class Account {
    private int id;
    private int balance;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public int getBalance() {
        return balance;
    }

    public void setBalance(int balance) {
        this.balance = balance;
    }
}

Instances of the BatchResults class, defined in src/main/java/com/example/cockroachdemo/model/BatchResults.java, hold metadata about a batch write operation and its results:

copy
icon/buttons/copy
package com.example.cockroachdemo.model;

public class BatchResults {
    private int numberOfBatches;
    private int totalRowsAffected;

    public BatchResults(int numberOfBatches, int totalRowsAffected) {
        this.numberOfBatches = numberOfBatches;
        this.totalRowsAffected = totalRowsAffected;
    }

    public int getNumberOfBatches() {
        return numberOfBatches;
    }

    public int getTotalRowsAffected() {
        return totalRowsAffected;
    }
}

Transaction management

MyBatis-Spring supports Spring's declarative, aspect-oriented transaction management syntax, including the @Transactional annotation and AspectJ's AOP annotations.

Transactions may require retries if they experience deadlock or transaction contention that cannot be resolved without allowing serialization anomalies. To handle transactions that are aborted due to transient serialization errors, we highly recommend writing client-side transaction retry logic into applications written on CockroachDB. In this application, transaction retry logic is written into the methods of the RetryableTransactionAspect class, defined in src/main/java/com/example/cockroachdemo/RetryableTransactionAspect.java:

copy
icon/buttons/copy
package com.example.cockroachdemo;

import java.lang.reflect.UndeclaredThrowableException;
import java.util.concurrent.atomic.AtomicLong;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.dao.ConcurrencyFailureException;
import org.springframework.dao.TransientDataAccessException;
import org.springframework.stereotype.Component;
import org.springframework.transaction.TransactionSystemException;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.Assert;

/**
 * Aspect with an around advice that intercepts and retries transient concurrency exceptions.
 * Methods matching the pointcut expression (annotated with @Transactional) are retried.
 * <p>
 * This advice needs to runs in a non-transactional context, which is before the underlying
 * transaction advisor (@Order ensures that).
 */
@Component
@Aspect
// Before TX advisor
@Order(Ordered.LOWEST_PRECEDENCE - 1)
public class RetryableTransactionAspect {
    protected final Logger logger = LoggerFactory.getLogger(getClass());

    @Pointcut("@annotation(transactional)")
    public void anyTransactionBoundaryOperation(Transactional transactional) {
    }

    @Around(value = "anyTransactionBoundaryOperation(transactional)",
            argNames = "pjp,transactional")
    public Object retryableOperation(ProceedingJoinPoint pjp, Transactional transactional)
            throws Throwable {
        final int totalRetries = 30;
        int numAttempts = 0;
        AtomicLong backoffMillis = new AtomicLong(150);

        Assert.isTrue(!TransactionSynchronizationManager.isActualTransactionActive(), "TX active");

        do {
            try {
                numAttempts++;
                return pjp.proceed();
            } catch (TransientDataAccessException | TransactionSystemException ex) {
                handleTransientException(ex, numAttempts, totalRetries, pjp, backoffMillis);
            } catch (UndeclaredThrowableException ex) {
                Throwable t = ex.getUndeclaredThrowable();
                if (t instanceof TransientDataAccessException) {
                    handleTransientException(t, numAttempts, totalRetries, pjp, backoffMillis);
                } else {
                    throw ex;
                }
            }
        } while (numAttempts < totalRetries);

        throw new ConcurrencyFailureException("Too many transient errors (" + numAttempts + ") for method ["
                + pjp.getSignature().toLongString() + "]. Giving up!");
    }

    private void handleTransientException(Throwable ex, int numAttempts, int totalAttempts,
                                          ProceedingJoinPoint pjp, AtomicLong backoffMillis) {
        if (logger.isWarnEnabled()) {
            logger.warn("Transient data access exception (" + numAttempts + " of max " + totalAttempts + ") "
                    + "detected (retry in " + backoffMillis + " ms) "
                    + "in method '" + pjp.getSignature().getDeclaringTypeName() + "." + pjp.getSignature().getName()
                    + "': " + ex.getMessage());
        }
        if (backoffMillis.get() >= 0) {
            try {
                Thread.sleep(backoffMillis.get());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            backoffMillis.set(Math.min((long) (backoffMillis.get() * 1.5), 1500));
        }
    }
}

The @Aspect annotation declares RetryableTransactionAspect an aspect, with pointcut and advice methods.

Transactional pointcut

The @Pointcut annotation declares the anyTransactionBoundaryOperation method the pointcut for determining when to execute the aspect's advice. The @annotation designator passed to the @Pointcut annotation limits the matches (i.e., join points) to method calls with a specific annotation, in this case, @Transactional.

Transaction retry advice

retryableOperation handles the application retry logic, with exponential backoff, as the advice to execute at an anyTransactionBoundaryOperation(transactional) join point. Spring supports several different annotations to declare advice. The @Around annotation allows an advice method to work before and after the join point. It also gives the advice method control over executing any other matching advisors.

retryableOperation first verifies that there is no active transaction. It then increments the retry count and attempts to proceed to the next advice method with the ProceedingJoinPoint.proceed() method. If the underlying data access layer method (i.e., the mapper interface method annotated with @Transactional) succeeds, the results are returned and the application flow continues. If the method fails, then the transaction is retried. The time between each retry grows with each retry until the maximum number of retries is reached.

Advice ordering

Spring automatically adds transaction management advice to all methods annotated with @Transactional. Because the pointcut for RetryableTransactionAspect also matches methods annotated with @Transactional, there will always be two advisors that match the same pointcut. When multiple advisors match at the same pointcut, an @Order annotation on an advisor's aspect can specify the order in which the advice should be evaluated.

To control when and how often a transaction is retried, the transaction retry advice must be executed outside the context of a transaction (i.e., it must be evaluated before the primary transaction management advisor). By default, the primary transaction management advisor is given the lowest level of precedence. The @Order annotation on RetryableTransactionAspect is passed Ordered.LOWEST_PRECEDENCE-1, which places this aspect's advice at a level of precedence above the primary transaction advisor, which results in the retry logic being evaluated before the transaction management advisor.

For more details about advice ordering in Spring, see Advice Ordering on the Spring documentation site.

See also

Spring documentation:

CockroachDB documentation:



YesYes NoNo