Caching, async processing and scheduling
Cache abstractions with Caffeine and Redis, correct cache keys, @Async executors that do not lose exceptions, and reliable scheduling.
Caching without lying
@Configuration
@EnableCaching
class CacheConfig {
@Bean
CacheManager cacheManager() {
var caffeine = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10));
return new CaffeineCacheManager("bookById", "catalog");
}
}
@Cacheable(cacheNames = "bookById", key = "#id", sync = true)
public BookView find(Long id) { return repo.load(id); }
@CachePut(cacheNames = "bookById", key = "#result.id")
public BookView update(Long id, UpdateBook cmd) { ... }
@CacheEvict(cacheNames = "bookById", key = "#id")
public void delete(Long id) { ... }sync = truemakes concurrent misses for the same key wait for one load instead of stampeding the database.- A cache entry is a copy. With Redis the object is serialised, so entities, lazy proxies and non-serialisable fields break; cache a DTO.
- Cache keys must include every input that can change the result, including the tenant and the locale.
- With multiple instances, use a shared Redis cache or evictions will only happen on the node that handled the write.
💡
Self-invocation bypasses caching just as it bypasses transactions: an internal
this.find(id) call never reaches the proxy, so the cache is skipped silently. Split the cached method into its own bean.Async work with a real executor
@Configuration
@EnableAsync
class AsyncConfig implements AsyncConfigurer {
@Bean("mailExecutor")
ThreadPoolTaskExecutor mailExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(16);
ex.setQueueCapacity(500);
ex.setThreadNamePrefix("mail-");
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
ex.setTaskDecorator(new MdcTaskDecorator()); // propagate correlation id
ex.initialize();
return ex;
}
@Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> log.error("async {} failed", method.getName(), ex);
}
}@Async("mailExecutor")
public CompletableFuture<Void> sendReceipt(Order order) {
mailer.send(order);
return CompletableFuture.completedFuture(null);
}- Returning
voidswallows failures unless you register anAsyncUncaughtExceptionHandler. - Spring Boot's default async executor is a simple in-memory pool; define your own so the queue bound and rejection policy are explicit.
- The default rejection policy aborts and throws - behind a request thread that becomes a 500. Choose deliberately: callers-run slows producers, which is usually the behaviour you want.
Scheduling in a multi-instance world
@Scheduled(cron = "0 */15 * * * *", zone = "UTC")
@SchedulerLock(name = "refreshCatalog") // ShedLock: one node at a time
public void refreshCatalog() { catalog.reload(); }
@Scheduled(fixedDelayString = "${jobs.reconcile.delay:PT5M}")
public void reconcile() { ... }| Attribute | Meaning | Pitfall |
|---|---|---|
fixedRate | Every N ms from the last start | Overlapping runs on a slow task |
fixedDelay | N ms after the last run finishes | Drifts later over time |
cron | Calendar expression | Server timezone if zone is omitted |
@SchedulerLock | Single-instance execution via a shared lock | Requires a lock table or Redis |
Every instance of your service runs its own scheduler. Without a distributed lock, a nightly job runs once per replica. Also give the executor enough threads and cap the pool - a scheduler with one thread and one slow job silently skips everything behind it.
FAQ
How do I invalidate a cache correctly?
Evict on every write path for the same key, prefer short TTLs so a missed eviction self-heals, and treat the cache as an optimisation that must never be the source of truth. If a stale read is unacceptable, do not cache that value.
Should I use @Async or a message queue?
Async runs in the same process: fast to add, but work is lost on a crash and there is no backlog visibility. Use a real queue when the work must survive a restart or be retried and monitored independently.
Related
Transactions, locking and concurrency Observability with Actuator, logging and metrics
Last refreshed 2026-09-18.