Efficient Routing of Spring Transactions to Master and Slave Nodes
Written on
Chapter 1: Introduction to Transaction Routing
As projects expand, there may arise a necessity to direct read requests to a secondary (slave) node. Thankfully, this can be accomplished in Spring using the @Transactional annotation along with some configuration.
Section 1.1: Defining Node Types
To start, we need to categorize our nodes. In this context, we will have two types: master and slave. However, this categorization can be expanded if separate connection pools are required for rapid and prolonged queries.
public enum DatabaseNodeType {
MASTER,
SLAVE
}
Next, we need to create a context holder to track which transaction is active in a specific thread. Later, we will configure an interceptor that inspects the parameters of the @Transactional annotation and assigns the correct node type.
public final class DatabaseContextHolder {
private static final ThreadLocal<DatabaseNodeType> CONTEXT = new ThreadLocal<>();
private DatabaseContextHolder() {
throw new UnsupportedOperationException(DatabaseContextHolder.class.getSimpleName() + " cannot be instantiated");}
public static void set(DatabaseNodeType databaseNodeType) {
CONTEXT.set(databaseNodeType);}
public static DatabaseNodeType getEnvironment() {
return CONTEXT.get();}
public static void reset() {
CONTEXT.set(DatabaseNodeType.MASTER);}
}
Section 1.2: Utilizing AbstractRoutingDataSource
The AbstractRoutingDataSource plays a crucial role in our approach. We need to override the determineCurrentLookupKey method to return a value from our context holder. This will determine which data source is used when a transaction is initiated.
public class MasterSlaveRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DatabaseContextHolder.getEnvironment();}
}
Following this, we must define our data source beans and set MasterSlaveRoutingDataSource as the primary data source that utilizes the correct node type.
@Configuration
public class JpaRepositoryConfiguration {
public static final String MASTER_DATA_SOURCE = "MASTER_DATA_SOURCE";
public static final String SLAVE_DATA_SOURCE = "SLAVE_DATA_SOURCE";
@Bean
@Primary
public DataSource dataSource() {
MasterSlaveRoutingDataSource masterSlaveRoutingDataSource = new MasterSlaveRoutingDataSource();
Map<DatabaseNodeType, DataSource> targetDataSources = new HashMap<>();
targetDataSources.put(DatabaseNodeType.MASTER, masterDataSource());
targetDataSources.put(DatabaseNodeType.SLAVE, slaveDataSource());
masterSlaveRoutingDataSource.setTargetDataSources(targetDataSources);
masterSlaveRoutingDataSource.setDefaultTargetDataSource(masterDataSource());
return masterSlaveRoutingDataSource;
}
@Bean(MASTER_DATA_SOURCE)
@ConfigurationProperties(prefix = "datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();}
@Bean(SLAVE_DATA_SOURCE)
@ConfigurationProperties(prefix = "datasource.slave")
public DataSource slaveDataSource() {
return DataSourceBuilder.create().build();}
}
Chapter 2: Managing Transaction Context
All that remains is to set the appropriate key in the context holder when a transaction begins. There are several methods to achieve this.
Section 2.1: Using Aspect-Oriented Programming (AOP)
The following aspect intercepts invocations marked with @Transactional that are either in the com.mvpotter package or its sub-packages, or annotated with @Repository. It’s essential to reset the context after the method execution to avoid issues with reused threads.
@Aspect
@Order(0)
@Component
public class TransactionAspect {
@Around("(within(com.mvpotter.*) || @annotation(org.springframework.stereotype.Repository)) && @annotation(transactional)")
public Object proceed(ProceedingJoinPoint proceedingJoinPoint, Transactional transactional) throws Throwable {
try {
if (transactional.readOnly()) {
DatabaseContextHolder.set(DatabaseNodeType.SLAVE);} else {
DatabaseContextHolder.set(DatabaseNodeType.MASTER);}
return proceedingJoinPoint.proceed();
} finally {
DatabaseContextHolder.reset();}
}
}
Section 2.2: Creating a Wrapper for PlatformTransactionManager
We can also wrap the PlatformTransactionManager and update the context holder's value when a transaction is initiated.
public class ReplicaAwareTransactionManager implements PlatformTransactionManager {
private final PlatformTransactionManager wrapped;
public ReplicaAwareTransactionManager(final PlatformTransactionManager wrapped) {
this.wrapped = wrapped;}
@Override
public TransactionStatus getTransaction(TransactionDefinition definition) {
if (definition != null && definition.isReadOnly()) {
DatabaseContextHolder.set(DatabaseNodeType.SLAVE);} else {
DatabaseContextHolder.set(DatabaseNodeType.MASTER);}
try {
return wrapped.getTransaction(definition);} finally {
DatabaseContextHolder.reset();}
}
@Override
public void commit(TransactionStatus status) throws TransactionException {
wrapped.commit(status);}
@Override
public void rollback(TransactionStatus status) throws TransactionException {
wrapped.rollback(status);}
}
Finally, we need to define beans to wrap the transaction manager and designate the wrapper as the primary manager.
@Bean
@Primary
public PlatformTransactionManager transactionManager(@Qualifier("dataSourceTransactionManager") PlatformTransactionManager transactionManager) {
return new ReplicaAwareTransactionManager(transactionManager);
}
@Bean("dataSourceTransactionManager")
public PlatformTransactionManager platformTransactionManager() {
return new DataSourceTransactionManager(dataSource());
}
Chapter 3: Practical Examples
This video provides a real-world example of implementing master-slave routing in Spring Boot using Debezium.
In this video, Vlad Mihalcea discusses high-performance Hibernate strategies that can enhance your application's data handling capabilities.