Java – Caching inside transactions with Spring

1 Oct 2021 | 12 min read

Java developers use caching as a popular way to speed up an application. It enables them to receive data multiple times when the actual database request is executed only once.

The transaction is something that does not let an application do something only partially. Either the whole thing is done, or everything is rolled back.

Since the Spring framework provides us with annotations that implement both of the previously mentioned mechanisms, in this article I will be covering that framework.

Cache

Let’s see how the use of Cache looks in code. I assume that at this point you know what Spring Bean is and how Dependency Injection works in Spring.

First, you need to define CacheManager with caches named within:

@Configuration
@EnableCaching
public class CacheConfiguration {

   @Bean
   public CacheManager cacheManager() {
       return new ConcurrentMapCacheManager(GROCERIES_CACHE);
   }

}

Having CacheManager set up, you can now write your methods using `@Cacheable` and `@CacheEvict` annotations:

@Cacheable(value = ITEM_CACHE)
public List<Item> getItems() {
   return itemRepository.findAll();
}

@CacheEvict(value = ITEM_CACHE, allEntries = true)
public Item saveItem(Item item) {
   return itemRepository.save(item);
}

The first one writes data to caches while the second one erases that data. If you don’t do that, two things could happen:

  • Firstly, you would have your cache only growing. This means that at some point you’d run out of disk space.
  • Secondly, you would have the wrong data. If someone gets objects for the first time it writes to the cache. When they ask for it for the second time, they would get the same data without asking the database. But what if someone puts new data into the database in the meantime? Without `@CacheEvict` anyone who asked for cached objects would not get the new data.
Cache after Cacheable method
Cache after Cacheable method
Cache after CacheEvict method
Cache after CacheEvict method

Transaction

What does `@Transactional` annotation even do? Well, you can think about it like a `try and catch` block – try to do some things and if any of those fails, revert all the changes.

But simple `try and catch` has some cons. For example, if `Method1` is transactional and inside it calls `Method2` which also is transactional, there would be two transactions. `@Transactional` annotation handles that and other things.

Cache in transaction

Let’s now use both of the previously mentioned mechanisms. We will create a new item using the transactional method:

@Override
@Transactional
public ItemResponse createItem(ItemRequest itemRequest) {

   //Check if object with that name already exists
   List<Item> items = itemRepositoryFacade.getItems();
   items.stream()
           .filter(filterItem -> filterItem.getName().equals(itemRequest.getName()))
           .findAny()
           .ifPresent(item -> {
               throw new EntityExistsException("Item with that name already exists");
           });

   //Add new object to database
   Item item = itemMapper.toEntity(itemRequest);
   Item savedItem = itemRepositoryFacade.saveItem(item);

   //Check if there are too many items with the same price
   List<Item> updatedItems = itemRepositoryFacade.getItems();
   long samePrice = updatedItems.stream()
           .filter(filterItem -> filterItem.getPrice().equals(itemRequest.getPrice()))
           .count();

   if (samePrice > 5) {
       throw new StockException();
   }

   return itemMapper.toResponse(savedItem);
}

As you can see, we used `@Transactional` annotation here and we also used two cache methods. `getItems()` writes or gets from the cache and `saveItem()` removes data from that cache.

  • Step 1.  Get items to check if one already exists with the same name as we provided in the request (Cacheable method).
  • Step 2. Save the new item to the database.
  • Step 3. Get all items to check if there are too many items with the same price. If the number is higher than 5, throw an exception. 

But…

Keep in mind that it could and probably should be done in a different way, even at the database level, this is just an example.

If every step goes well it means there are no problems with cache. 

But let’s imagine you’ve had more than 5 items with the same price:

  • Step 1:  Get data from the database and put values into the cache.
  • Step 2: Write data to the database and evict data from the cache. 
  • Step 3: Repeat step 1. 

BOOM – exception is thrown. 

What does that mean? This whole method has been marked as transactional, so changes that were made to the database during the second step had been rolled back. There will be no new items in it. Yet, the rollback has been done after an exception had been thrown, so changes in cache stayed as they were before an exception. 

So now if you ask for items using a method annotated with `@Cacheable`, like `getItems()`,  you’ll have a list of items including that new one whose insertion was rolled back.

Thankfully, Spring does come with a partial solution to this. It is called `TransactionAwareCacheManagerProxy`. It wraps a given CacheManager and that helps to work with transactions.

@Bean
public CacheManager cacheManager() {

   SimpleCacheManager cacheManager = new SimpleCacheManager();
   cacheManager.setCaches(List.of(new ConcurrentMapCache(ITEM_CACHE)));
   cacheManager.initializeCaches();

   return new TransactionAwareCacheManagerProxy(cacheManager);
}

Notice that when using `getItems()` you do not put any data into the cache. A similar thing happens when using `saveItem()` – you do not evict cache data. But if data would already be in the cache, `getItems()` would retrieve it from there. Basically, with this new wrapper, you don’t do any changes to cache until the transaction is completed, yet you can still retrieve data from it.

This solution may save you from polluting our cache with wrong data, yet it has a big flaw. What is it, you ask? You can find out in the next article about transactions and caching, so stay tuned!

But it is not the end yet.

But it’s not over yet.

Cache and reference

We covered examples where data is being put into the cache or evicted. What will happen if you retrieve an object from cache and change its field values?

Let’s see our `updateItem` method:

@Override
@Transactional
public ItemResponse updateItem(ItemRequest itemRequest) {

   List<Item> items = itemRepositoryFacade.getItems();
   Optional<Item> itemOptional = items.stream()
           .filter(filterItem -> filterItem.getName().equals(itemRequest.getName()))
           .findFirst();

   if (itemOptional.isEmpty()) {
       throw new ItemNotFoundException();
   }

   Item item = itemOptional.get();
   Double price = itemRequest.getPrice();
   item.setPrice(price);
   item.setDiscount(calculateDiscount(price, items.size()));

   Item savedItem = itemRepositoryFacade.saveItem(item);

   return itemMapper.toResponse(savedItem);
}

As you can see, it does retrieve all items from the database, because you need information about how many items are there for calculating a discount. That calculation can throw an exception, e.g. when the price is equal to 0.

So where is the catch? In `item.setPrice(price)`. Objects in the cache are stored using reference. So when you change some value in that object, it changes in the cache as well. 

What if something goes wrong in saving or mapping to response? You’ll be left with changed data in the cache, but not in the database.

Object in cache is accessed by reference
Object in cache is accessed by reference

To prevent this from happening, you can make a copy of our object and work on that copy instead:

Item cacheItem = itemOptional.get();
Item item = cacheItem.clone();

Note! clone() only makes a shallow copy. If you have custom objects that you would like to change here, you should consider implementing a deep copy mechanism.

By using a copy of an object, you must perform a cache evict, since no changes to objects in the cache have been made. Thankfully, saving our item to the database does exactly that.

Conclusion

In this article, I’ve covered problems that come with using cache in transactions and modifying cache reference data. I’ve tried to come up with solutions to the real-life examples I have personally experienced. However, they are not that universal and could generate other problems.

Dawid Necka Java Developer

Your data is processed by Miquido sp. z o.o. sp.k. with its registered office in Kraków at Zabłocie 43A, 30 - 701 Kraków. The basis for processing your data is your consent and the legitimate interest of Miquido.
You may withdraw your consent at any time by contacting us at marketing@miquido.com. You have the right to object, the right to access your data, the right to request rectification, deletion or restriction of data processing. For detailed information on the processing of your personal data, please see Privacy Policy.

Show more