Dominic Gunn

A place for an engineers ramblings.

github twitter linkedin instagram email
Splitting read and write operations in Spring Boot
May 27, 2018
5 minutes read

Read Replica Architecture Diagram

At Turnitin we deal with quite a lot of traffic, the vast majority of database operations are reads. Splitting our databases and creating read replicas (slaves) is necessary in order to make sure we are not hindering write operations to the master database, but what good is managing these extra replicas if our applications cannot read from them?

Spring Boot makes it relatively easy for us to split these operations, you can find an example project using this code here, but we’ll talk through the most important parts of it in this post.

The DataSource

Spring Boot will normally configure a DataSource for you after you have provided some configuration details, that isn’t going to be enough in this case as we’re wanting to talk to multiple sources of data.

This post is going to explore the use of AbstractRoutingDataSource in order to route our requests to the right place.

Routing Connections

The following block of code will enable us to route connections to different sources, let’s take a look at it and then talk through what’s happening.

public class RoutingDataSource extends AbstractRoutingDataSource {
	private static final ThreadLocal<Route> ctx = new ThreadLocal<>();

	public enum Route {
		PRIMARY, REPLICA
	}

	public static void clearReplicaRoute() {
		ctx.remove();
	}

	public static void setReplicaRoute() {
		ctx.set(Route.REPLICA);
	}

	@Override
	protected Object determineCurrentLookupKey() {
		return ctx.get();
	}
}

This DataSource will work in combination with Springs TransactionsManager, whenever the TransactionsManager requests a connection to the database it will bind the connection to a thread and then call determineCurrentLookupKey().

This is super handy for us because that means we can utilize ThreadLocal in order to route a connection to our primary or replica databases.

The DataSource

Now we’ve built out our own RoutingDataSource implementation, we need to create a DataSource that makes use of it. You can view the full configuration here, but the most important block of code will be discussed here

@Bean
@Primary
public DataSource dataSource() {
  final RoutingDataSource routingDataSource = new RoutingDataSource();

  final DataSource primaryDataSource = buildDataSource(...);
  final DataSource replicaDataSource = buildDataSource(...);

  final Map<Object, Object> targetDataSources = new HashMap<>();
  targetDataSources.put(RoutingDataSource.Route.PRIMARY, primaryDataSource);
  targetDataSources.put(RoutingDataSource.Route.REPLICA, replicaDataSource);

  routingDataSource.setTargetDataSources(targetDataSources);
  routingDataSource.setDefaultTargetDataSource(primaryDataSource);

  return routingDataSource;
}

We’re going to gloss over the calls to buildDataSource(...) these are just creating generic DataSource’s that we will then use to route our connections, you can view the specifics of that here, but there’s no magic.

The RoutingDataSource does the most of the heavy lifting, we’ve created a map of target DataSource’s keyed by the enum values we specified in our RoutingDataSource, a default DataSource is set for times when our routing context is null or empty. Now whenever the TransactionsManager makes a call to determineCurrentLookupKey() it will have a method of looking up the DataSource we’ve requested.

Routing with AOP

We’re almost there, we have separate DataSources built and a method with which to tell the TransactionManager where to route our connection, but how are we going to have our code switch the routes for us?

A common method of ensuring objects returned from our DataSources are immutable is by using the @Transactional annotation in the following fashion.

@Transactional(readOnly = true)
public Book get(final Integer id) {
	return bookRepository.findById(id).orElse(null);
}

So if we’re happy to say that we expect objects making use of the @Transactional annotation with the readOnly flag to be immutable then I’d also be happy routing this traffic to our read replica, so let’s do that with an interceptor.

The Interceptor

The full interceptor can be viewed here, i’ve snipped a little bit for the sake of ensuring readability in this post.

@Aspect
@Component
@Order(0)
public class ReadOnlyRouteInterceptor {

	@Around("@annotation(transactional)")
	public Object proceed(ProceedingJoinPoint pjp, Transactional tx) ... {
		try {
			if (tx.readOnly()) {
				RoutingDataSource.setReplicaRoute();
			}
			return pjp.proceed();
		} finally {
			RoutingDataSource.clearReplicaRoute();
		}
	}
}

Nice and simple! We’re doing a quick check on whether the readOnly flag has been set on the transactional annotation of the method we’ve intercepted and if it has been, we’re enforcing the replica route and proceeding with the execution, once it’s finished we clear the route.

One thing that might stick out is the use of @Order(0), we need to ensure that our Interceptor runs before a connection to a DataSource is built. We can control that by setting an order with a high priority so that we can guarantee our interceptor runs before that happens.

Is that it?

That’s it! Let’s take a look at a sample application and make both a read and a write in order to verify everything is working as expected.

public static void main(String[] args) {
	ApplicationContext ctx = SpringApplication.run(Launcher.class, args);

	ctx.getBean(BookService.class).save("Test Author");
	ctx.getBean(BookService.class).get(1);
}

Now if we take a look at postgres logs when we execute these queries we can see that there are two separate transactions and that one is ran by our fable_write user and the other by our fable_read user.

Perfect, Spring is properly selecting a DataSource based on the readOnly value of the @Transaction annotation. Mission accomplished!

fable_readLOG: SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE
fable_writeLOG: BEGIN
fable_writeLOG: select nextval ('hibernate_sequence')
fable_writeLOG: insert into books (author, id) values ($1, $2)
fable_writeDETAIL:  parameters: $1 = 'Test Author2', $2 = '10'
fable_writeLOG:  execute S_1: COMMIT

fable_readLOG: SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY
fable_readLOG: BEGIN
fable_readLOG: select ....
fable_readDETAIL:  parameters: $1 = '2'
fable_readLOG:  execute S_1: COMMIT

You can view a complete example project with accompanying Docker Compose to build a database for you here.


Back to posts