How to Control Redis Cache Purge using Cache-Control Header: The Friend in Need @OneShop
Currently, we are using Redis cache to cache a host of things, like API responses, user session data, etc. Caching allows us to limit API requests to other micro-services and helps us in maintaining the state of user’s selected preferences during the journey, along with crucial session information.
Moreover, currently, we are using the following two types of cache operations in OneShop:
Cache Sensitive Operations: These operations are those for which we do not have multiple sources of truth. These require cache to be present and cannot be fetched on the fly again and again. In the absence of cache, these features will totally break. For instance, user session information, login tokens, etc. We CANNOT support Cache-Control based on headers for these operations.
Cache Insensitive Operations: These operations are business operations, having multiple sources of truth and can be fetched as and when required from the real source. The only purpose of this cache, is to boost performance. Features like shopping cart fetching, cross micro-service requests like sales catalog fetching are cache insensitive operations. Hence, we CAN support Cache-Control headers for these operations.
One day, while debugging an incident on production, we encountered a situation in which we found that the user journey was working fine when data was being pulled from actual source. However, it was failing when data was being pulled from the cache. In order to establish that cache was the cause of the problem, we were required to purge the cache key manually on Redis with the help of our DevOps team. This prompted us to implement a cache-control mechanism, wherein we could bypass or purge the related cache keys depending upon certain use cases. Hence, we had identified the following cases, where we could leverage this functionality to support cache bypassing for HTTP calls:
We would like a certain request to be served from the real source, not cache. For this purpose, we would send an HTTP header ‘Cache-Control: no-cache’
We do not want to wait for the stored cache key to expire, instead we want to fetch from the real source and purge the cache key. For this purpose we would send an HTTP header ‘Cache-Control: max-age=0’.
In our existing implementation, no segregation existed between the cache-sensitive and insensitive operations. All operations were utilising a common bean which performed common operations on both kinds of requests.
Modifying RedisCacheObject to Support Cache Control Headers For All Cache Related Operations
An early problem that we faced, that rendered any simple solutions moot, was that we have two different types of cache operations, as mentioned above. Hence, a single implementation for both the cases would have meant that we would have to find out which operations required cache-control during runtime, which was a difficult process. Hence, this approach was not feasible for us.
Using a REGEX key to distinguish between cache keys that can be overridden
This approach was simple to implement. We’d identify the key substrings that could be bypassed by cache, and create a REGEX using those keys. We could keep that regex in our application properties file. Any keys matching that regex would be bypassed in the presence of Cache-Control headers and others would be left out. The problem was maintainability and educating everyone about the usage. As we add more cache-insensitive operations, the regex would become complicated and difficult to maintain. Hence, although easy to implement, this approach was not very useful in the long run.
Creating Another Implementation for the Interface IDTCacheType<>
Currently, we have a single Interface, IDTCacheType<>, which is injected into all services wherever we need to perform cache-related operations. Our implementation of IDTCacheType<> Interface, RedisCacheObject was already a bean. However, the implementation part could be a valuable lesson in identifying how to deal with such situations.
Creating Multiple Beans — Elegantly: Given our existing implementation, our aim was; utilising abstraction to limit our changes to a minimum and making sure that the existing functionality does not break, given the sensitive nature of our cache operations. Hence, we needed an elegant solution which could leverage abstraction, provided to us by OOPs. We finally ended up with two implementations:
- RedisCacheObject: This is our existing cache implementation, which is using Spring’s Redis Template. It has no support for cache-control headers.
- RedisCacheControlObject: This is our new cache implementation, which extends RedisCacheObject. This implementation honours cache-control headers, as per their values.
However, this still posed the problem of having multiple beans, and we’d have to resort to using qualifiers to resolve a bean name, and this would have required changes at over hundreds of files, the extent of which could not be easily determined!
The final solution involved Spring’s magic with the help of ‘@Primary’ annotation. This annotation creates a primary bean, so that by default, all the beans that we ‘autowire’ for a particular Interface, fall back to this bean reference without requiring any ‘@Qualifier’ annotation.
This solved a big problem that we had, of educating people about the usage of different bean qualifiers for cache in the long run, since it was easy to miss while implementing any new cache object.
So, what we ended up doing, was that we created a ‘Primary Bean’ for our ‘cache-insensitive’ operations while creating a ‘Qualifier Bean’ for our ‘cache-sensitive’ operations. The reason for this was simple, ‘cache-sensitive’ operations are far lesser than ‘cache-insensitive’ ones.
Hence, we provided a bean name to our RedisCacheObject class and made the RedisCacheControlObject a ‘Primary Bean’, so no ‘@Qualifier’ annotation would be required for this. RedisCacheControlObject would now be available as a default bean for all business use case implementations.
Whereas, whenever we need a bean without Cache-Control support, we will have to add the qualifier tag while autowiring.
In a Nutshell:
The above approach provides us a way to create a hook inside our existing Redis implementation to support cache-control headers without interfering with our existing implementation.
Currently, our Redis implementation is being used by 10 micro-services of OneShop. With the help of powers handed to us by Spring and OOPs, we were able to implement cache-control functionality in such a way that there is no impact on OneShop’s interaction with cache.
This has also led us to practically implement the correct use case for using primary and secondary spring beans.