Idea behind this post is to demonstrate how easily tenant specific cache-store in a multi-tenant application can be built using Spring annotations,Custom RedisCacheManager.
Why Caching ?
There are multiple benefits of caching.One key benefit is to reduce database access thus making access to data much faster and less expensive.
For more details on Redis cache use case refer this.
Multi-Tenant Application Design
As per Wikipedia,Multitenancy refers to a software architecture in which a single instance of software runs on a server and serves multiple tenants.
Here we will follow shared database(all tenants share same database->schema->table
) approach. We will use just one entity as follow.
@Entity
@Table(name = "customers")
@Getter
@Setter
@NoArgsConstructor
@DynamicInsert
@DynamicUpdate
@FilterDef(name = "tenantFilter", parameters = {@ParamDef(name = "tenantKey", type = "string")})
@Filter(name = "tenantFilter", condition = "tenant = :tenantKey")
public class Customer implements Serializable {
@Id
String id;
String name;
//Identifier to distinguish each tenant
String tenant;
}
While invoking any APIs mandatory http header parameter
x-tenant-id will be supplied.
- For demo purpose we are using simple tenantKey.
- Recommended to use an alphanumeric id which is unique and difficult to guess(e.g. 64b2a7b330614cd8804bcd93b72a069e).
Enable Caching
spring boot provides simple annotation based configuration to enable caching(more details).For Starting Redis in Local System
docker run --name local-redis -p 6379:6379 -d redis
Now cache the result of below repository method.Just by adding @Cacheable
import java.util.List;
@Repository
public interface CustomerRepository extends JpaRepository<Customer, String> {
@Override
@Cacheable(value = "customers")
List<Customer> findAll();
}
Implementation(No custom manager)
- Loading data from database into cache store.
[
"customers"
]
- 1-1 Mapping between database table to cache store.
- Fetch data from cache, then filter it based on the supplied tenantId and return the response.
- Additional custom processing logic.
- Assume a scenario where few tenants didn't access the application for some hours, but due to above implementation data will be available in cache store.
Now Lets See The Implementation(With custom manager)
- Idea is to prefix all the cache stores with the tenantId.
[
"tenant1_customers",
"tenant2_customers",
"tenant3_customers",
"tenant4_customers",
"tenant5_customers"
]
How Can we Implement This ???
CustomCacheManger Extending RedisCacheManager
@Slf4j
public class CustomCacheManager extends RedisCacheManager {
public CustomCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
super(cacheWriter, defaultCacheConfiguration);
RedisCacheManager.builder()
.cacheWriter(cacheWriter)
.cacheDefaults(defaultCacheConfiguration)
.build();
}
/**
* @param name
* @return Prefix the cache store name with the TENANT KEY
* For SUPER ADMIN no prefix applied
*/
@Override
public Cache getCache(String name) {
log.info("Inside getCache:" + name);
String tenantId = TenantContext.getTenant().get();
if (tenantId.equals(Constants.SUPER_ADMIN_TENANT)) {
return super.getCache(name);
} else if (name.startsWith(tenantId)) {
return super.getCache(name);
}
return super.getCache(tenantId + "_" + name);
}
}
Configuration class to create CacheManger Bean
@Configuration
@EnableCaching
public class RedisConfigurationCustom {
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.json()))
//Configure in Property file as per use case, hardcoded just for demo
.entryTtl(Duration.ofSeconds(600));
}
@Bean
public RedisCacheWriter redisCacheWriter() {
return RedisCacheWriter.lockingRedisCacheWriter(redisConnectionFactory());
}
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory();
}
@Bean
public RedisCacheManager redisCacheManager() {
return new CustomCacheManager(redisCacheWriter(), redisCacheConfiguration());
}
Congratulations now the basic implementation is complete
Let's see what is going on
- Using
HandlerInterceptor
, tenant value is fetched from Request Headers and set in ThreadLocal.- We have created a
CustomCacheManager
by extendingRedisCacheManager
,then overridegetCache(String name)
implementation to manipulate the name.- As you can see we are storing the supplied TenantId in ThreadLocal ,hence we can use it inside getCache(),the one we are overriding to manipulate the cache name.
- Anyplace where cache name(e.g. customers) is specified within
@cachable
getCache() method will be invoked and return name with tenantId prefixed to it for further use within the application.- For some use cases we might need to call the getCache method explicitly using the
CustomCacheManager
, hence to avoid multiple prefixing additional checks are used.
- One such use case is having a scheduled process as super admin to clean cache stores for all the tenant.
This doesn't have all the answers you might want, but I hope this is a helpful starting point.
Sidhanta-Samantaray / multitenant-redis-caching
Multi-Tenant Cache Store using Custom RedisCacheManager
Getting Started
Start Redis in Local System
docker run --name local-redis -p 6379:6379 -d redis
To start the Application
- Provide PostgreSQL details in application.properties file
- Run below command
- clean spring-boot:run
- Swagger URL
Goal of Application
Demonstrate tenant specific cache-store in a multi-tenant application, using Spring annotations and custom RedisCacheManager.
Idea is to prefix all applicable cache stores with the tenantId.
[
"tenant1_customers",
"tenant2_customers",
"tenant3_customers",
"tenant4_customers",
"tenant5_customers"
]
Use sample data for populating the database table
Intercepting Requests
@Configuration
@EnableWebMvc
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
//All the APIs which requires tenant level isolation
registry.addInterceptor(new TenantInterceptor())
.addPathPatterns("/tenant/**");
registry.addInterceptor(new AdminTenantInterceptor())
.addPathPatterns("/admin/**");//For Super Admin
}
}
Top comments (0)