Strategies for Invalidating SQL-Result Caches in Java Applications
Cache invalidation for database query results is critical to ensure data consistency while retaining performance benefits. Below is a practical, production-ready approach using modern Java constructs and common caching libraries.
1. High-Level Workflow
The process involves three coordinated components:
| Phase | Description |
|---|---|
| Orchestration | Schedule cache refresh at configurable intervals using ScheduledThreadPoolExecutor |
| Trigger | Execute a thread-safe cache-clearing oepration targeting specific SQL result caches |
| Implementation | Use cache abstraction (e.g., Spring Cache, Caffeine) with key-based eviction or full reset |
2. Implementation Details
Step 1: Configure a Robust Scheduler
Prefer ScheduledThreadPoolExecutor over single-threaded varients for resilience and scalability:
private final ScheduledExecutorService cacheRefresher =
Executors.newScheduledThreadPool(2, new ThreadFactory() {
private final AtomicInteger counter = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "cache-refresh-pool-" + counter.getAndIncrement());
}
});
// Schedule initial execution after 5 seconds, then every 30 minutes
cacheRefresher.scheduleAtFixedRate(
this::evictStaleQueryResults,
5L, 30L, TimeUnit.MINUTES
);
Step 2: Trigger Cache Eviction Safely
Encapsulate eviction logic in a dedicated method that avoids blocking the scheduler thread:
private void evictStaleQueryResults() {
try {
// Log before eviction for observability
log.info("Initiating scheduled eviction of SQL query caches");
// Delegate to cache manager — avoid hardcoding eviction logic here
cacheManager.invalidateAllQueryCaches();
log.info("SQL query caches successfully refreshed");
} catch (Exception e) {
log.error("Failed to refresh SQL caches", e);
// Optional: notify monitoring system (e.g., via Micrometer counter)
}
}
Step 3: Implement Cache Management Layer
Use Caffeine with explicit cache names for SQL result sets. Example with manual cache control:
@Component
public class SqlResultCacheManager {
private final LoadingCache<String, List<Map<String, Object>>> userSummaryCache;
private final LoadingCache<String, Map<String, Object>> configCache;
public SqlResultCacheManager() {
this.userSummaryCache = Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(1, TimeUnit.HOURS)
.build(query -> executeQueryAndCache(query));
this.configCache = Caffeine.newBuilder()
.maximumSize(100)
.refreshAfterWrite(10, TimeUnit.MINUTES)
.build(key -> fetchConfigByKey(key));
}
public void invalidateAllQueryCaches() {
userSummaryCache.invalidateAll();
configCache.invalidateAll();
}
private List<Map<String, Object>> executeQueryAndCache(String sql) {
// Actual JDBC or JPA-based query execution
return jdbcTemplate.queryForList(sql);
}
private Map<String, Object> fetchConfigByKey(String key) {
return jdbcTemplate.queryForObject(
"SELECT * FROM app_config WHERE key = ?",
new BeanPropertyRowMapper<>(HashMap.class), key);
}
}
3. Alternative: Declarative Caching with Spring
If using Spring Boot, leverage @Cacheable, @CacheEvict, and @EnableCaching:
@Service
public class ReportService {
@Cacheable(value = "salesReports", key = "#year + '-' + #region")
public List<SalesRecord> fetchAnnualSales(int year, String region) {
return salesRepository.findByYearAndRegion(year, region);
}
}
// Externalized cache invalidation
@Component
public class CacheInvalidationScheduler {
@Autowired
private CacheManager cacheManager;
@Scheduled(fixedDelay = 1800000) // 30 min
public void clearReportCache() {
cacheManager.getCache("salesReports").clear();
}
}
4. Key Considerations
- Granularity: Prefer targeted eviction (e.g., by cache name or key pattern) over global clears.
- Concurrency: Ensure cache operations are thread-safe—Caffeine and Spring Cache handle this internally.
- Observability: Add metrics (e.g., eviction count, latency) using Micrometer or Dropwizard Metrics.
- Fallback: Design for graceful degradation if cache refresh fails (e.g., serve stale-on-error).