Lightning fast NoSQL with Spring Data Redis

6 uses cases for Redis in server-side Java applications

Redis NoSQL
Credit: Daniel Zimmer/Flickr

A multi-tiered architecture built on top of Java EE presents a powerful server-side programming solution. As a Java EE developer for many years, I've been mostly satisfied with a three-tiered approach for enterprise development: a JPA/Hibernate persistence layer at the bottom, a Spring or EJB application layer in the middle, and a web tier on top. For more complex use cases I've integrated a workflow-driven solution with BPM (business process management), a rules engine like Drools, and an integration framework such as Camel.

Recently, however, I was tasked to design a system supporting hundreds of thousands of concurrent users, with sub-second response latency. I immediately saw the limits of my normal Java EE stack. Conventional RDBMS-based web applications, including those built on Hibernate/JPA, have second-order latency and do not scale well. A traditional Java EE persistence architecture would not meet the performance and throughput requirements for the system I was designing. I turned to NoSQL, and eventually found Redis.

Being an in-memory key-value datastore, Redis breaks from the conventional definition of a database, where data is saved on a hard drive. Instead, it can be used in combination with a persistent NoSQL datastore such as MongoDB, HBase, Cassandra, or DynamoDB. Redis excels as a remote cache server, and is an exceptionally fast datastore for volatile data.

In this article I introduce simple and advanced use cases and performance tuning with Redis. I'll provide a brief overview, but I assume you are basically familiar with NoSQL and the variety of solutions available in that field.

Overview of Redis

Like most NoSQL data stores, Redis abandons the relational concepts of tables, rows, and columns. Instead, it is a key-value data store, where each record is stored and retrieved using a unique string key. Redis supports the following built-in data structures as the value of all records:

  • STRING holds a single string value.
  • LIST, SET, and HASH are semantically identical to the same data structures in Java.
  • ZSET is a list of strings ordered by float-point score, resembling PriorityQueue in Java.

Unlike tables in RDBMS, Redis data structures are instantiated on the fly. When you query anything not existing in Redis, it simply returns null. Although Redis doesn't allow nested structures, you can implement a custom Java or JSON serializer/deserializer to map POJOs to strings. In this way, you can save an arbitrary Java bean as a STRING, or place it in a LIST, a SET, and so on.

Performance and scalability

The first thing you will likely notice about Redis is that it is extremely fast. Performance benchmarks vary based on record size and number of connections, but latency is typically in the single-digit milliseconds. For most use cases, Redis can sustain up to 50,000 requests per second (RPS). If you're using higher end hardware, you could get throughput up to 700,000 RPS (though this number could be throttled by the bandwidth of your NIC cards).

Being an in-memory database, Redis has limited storage; the largest instance in AWS EC2 is r3.8xlarge with 244 GB memory. Due to its indexing and performance-optimized data structures, Redis consumes much more memory than the size of the data stored. Sharding Redis can help overcome this limitation. In order to backup in-memory data to a hard drive, you can do point-in-time dumps in scheduled jobs, or run a dump command as needed.

Remote data caching with Spring

Data caching is perhaps the most cost-effective approach for improving application server performance. Enabling data caching is effortless using Spring's cache abstraction annotations: @Cacheable, @CachePut, @CacheEvict, @Caching, and @CacheConfig. In a Spring configuration, you could use Ehcache, Memcached, or Redis as the underlying cache server.

Ehcache is typically configured as a local cache layer, nested and running on the application's JVM. Memcached or Redis would run as an independent cache server. To integrate a Redis cache into a Spring-based application, you will use the Spring Data Redis RedisTemplate and RedisCacheManager.

Accessing cached objects in Redis takes less than a couple of milliseconds in general, which could give you a big boost in application performance when compared to relational database queries.

Local cache vs. remote cache

In a system without network overhead, a local cache is faster than a remote cache. The downside of local caching is that multiple copies of the same object can be out-synced across different nodes in a server cluster. Because of this, a local cache is only suitable for static data, such as systemwide settings where small lags and inconsistencies are tolerable. If you use a local cache for volatile business data, such as user data and transaction data, you will very likely end up running a single instance of the application server.

A remote cache server doesn't have this limitation. Given the same key, it is guaranteed a single copy of the object on the cache server. As long as you keep objects in the cache in-sync with their database value, you don't need to deal with stale data.

Listing 1 shows a Spring data caching example.

Listing 1. Enabling caching in Spring-based applications


@Cacheable(value="User_CACHE_REPOSITORY", key = "#id")
public User get(Long id) {
  return em.find(User.class, id);
}
@Caching(put = {@CachePut(value="USER_CACHE_REPOSITORY", key = "#user.getId()")})  
public User update(User user) {
    em.merge(user);
    return user;  
}
@Caching(evict = {@CacheEvict(value="USER_CACHE_REPOSITORY", key = "#user.getId()")})  
public void delete(User user) {
    em.remove(user);
}
@Caching(evict = {@CacheEvict(value="USER_CACHE_REPOSITORY", key = "#user.getId()")})  
public void evictCache(User user) {
}

Here the read operation is surrounded with Spring's @Cacheable annotation, which is implemented as an AOP advisor under the hood. A time-to-live setting in Spring also specifies how long these objects will remain in the cache. When the get() method is invoked, Spring tries to fetch and return the object from the remote cache first. If the object isn't found, Spring will execute the body of the method and place the database result in the remote cache before returning it.

But what if the same object is updated in the database by another process (such as another server node), or even another thread in the same JVM? With just the @Cacheable annotation employed, you might receive a stale copy from the remote cache server.

To prevent this from happening, you could add a @CachePut annotation to all database update operations. Every time these methods are invoked, the return value replaces the old object in the remote cache. Updating the cache on both database reads and writes keeps the records in-sync between the cache server and the backend database.

Fault tolerance

This sounds perfect, right? Actually, no. With the config in Listing 1 you might not experience any issues under light load, but as you gradually increase the load on the server cluster you will start to see stale data in the remote cache. Be prepared for contention from server nodes, or worse. Even with a successful write in the database, you could end up with a failed PUT in the cache server due to a network glitch. Additionally, NoSQL generally doesn't support full transaction semantics in relational databases, which can lead to partial commits. In order to make your code fault tolerant, consider adding a version number for optimistic locking to your data model.

Upon receiving OptimisticLockingFailureException or CurrentModificationException (depending on your persistence solution), you would call a method annotated with @CacheEvict to purge the stale copy from the cache, then retry the same operation:

Listing 2. Resolving stale objects in the cache


try{
    User user = userDao.get(id);    // user fetched in cache server
    userDao.update(user, oldname, newname);      
}catch(ConcurrentModificationException ex) {   // cached user object may be stale
    userDao.evictCache(user);
    user =  userDao.get(id);     // refresh user object
    userDao.update(user, oldname, newname);    // retry the same operation. Note it may still throw legitimate ConcurrentModificationException.
}

Use cases for Redis as a database

Now let's look at a variety of ways that you can use Redis as a database in server-side Java EE systems. Whether the use case is simple or complex, Redis can help you achieve performance, throughput, and latency that would be formidable to a normal Java EE technology stack.

1. Globally unique incremental counter

This is a relatively simple use case to start with: an incremental counter that displays how many hits a website receives. Spring Data Redis offers two classes that you can use for this utility: RedisAtomicInteger and RedisAtomicLong. Unlike AtomicInteger and AtomicLong in the Java concurrency package, these Spring classes work across multiple JVMs.

Listing 3. Globally unique increment counter


RedisAtomicLong counter = 
	new RedisAtomicLong("UNIQUE_COUNTER_NAME", redisTemplate.getConnectionFactory()); 
Long myCounter = counter.incrementAndGet();    // return the incremented value 

Watch out for integer overflow and remember that operations on these two classes are relatively expensive.

2. Global pessimistic lock

From time to time you will need to deal with contention in a server cluster. Say you're running a scheduled job from a server cluster. Without a global lock, nodes in the cluster will launch redundant job instances. In the case of a chat room partition, you might have a capacity of 50. When that chat room is full, you need to create a new chat room instance to accommodate the next 50.

Detecting a full chat room without a global lock could lead each node in the cluster to create its own chat-room instance, making the whole system unpredictable. Listing 4 shows how to leverage the SETNX (SET if Not eXists) Redis command to implement a global pessimistic lock.

1 2 Page 1
Download the CIO October 2016 Digital Magazine
Notice to our Readers
We're now using social media to take your comments and feedback. Learn more about this here.