Spring Caching, Scheduling, Async Processing, and Events - Complete Notes
An in-depth and interview-focused guide to Spring Cache, Redis, cache consistency, scheduled work, asynchronous execution, thread pools, application and transactional events, retries, idempotency, and the point where durable messaging becomes necessary.
00. One mental model for four tools
Caching changes where a result comes from. Scheduling changes when work starts. Async processing changes which thread waits. Events change how components learn that something happened.
These features are often discussed together because they move work away from the simplest request-to-database path. They solve different problems and provide different guarantees. A cache is a disposable copy of authoritative data. A scheduler is a clock that asks code to run. An executor is a bounded place where tasks wait for threads. An application event is an in-process notification. None of them is automatically durable, exactly once, distributed, or safe across a process crash.
Think of a restaurant. The cache is a tray of popular prepared items. The scheduler is the wall clock that starts recurring kitchen jobs. The async executor is a limited set of cooks plus a waiting counter. An event is the bell telling nearby staff that an order changed state. A durable broker is a numbered order system whose tickets survive even if the restaurant closes temporarily.
| Need | Useful Spring tool | What it does not promise |
|---|---|---|
| Avoid repeating an expensive read | Spring Cache with Caffeine or Redis | Immediate consistency or permanent storage |
| Start maintenance at a time or interval | @Scheduled and TaskScheduler |
One execution across a cluster or missed-run recovery |
| Return to a caller before independent work finishes | @Async and a configured TaskExecutor |
Durability, unlimited capacity, or transaction propagation |
| Decouple beans inside one application | ApplicationEventPublisher and event listeners |
Cross-service delivery or persistence |
| Survive restarts and deliver across services | Durable broker plus outbox or another reliable publication pattern | Exactly-once business effects without idempotent consumers |
Ask whether work may be lost, duplicated, delayed, reordered, or observed before a database commit. Then choose a mechanism. An annotation is convenient configuration, not a new reliability guarantee.
01. Spring Cache abstraction
Spring Cache applies reusable caching rules around method calls while a cache provider stores the actual entries.
The core interfaces are Cache and CacheManager. A Cache is
a named region containing key-value entries. A CacheManager finds caches by name.
Spring's interceptors connect method annotations to these interfaces. The provider may be a local
concurrent map, Caffeine, Redis, JCache implementation, or another supported store.
The common read pattern is cache-aside. On a hit, return the cached value and skip the method. On a miss, invoke the method, usually reading the source of truth, then put the result into the cache. Spring coordinates this method-level flow. The provider controls storage, expiration, eviction, serialization, and distributed behavior.
| Layer | Responsibility | Example decision |
|---|---|---|
| Application method | Defines the authoritative operation and result | findProduct(id) reads PostgreSQL |
| Spring Cache advice | Computes keys and decides get, put, or evict | Use cache productById and key id |
| Provider | Stores entries and implements provider features | Redis entry TTL is 10 minutes |
| Operations policy | Controls capacity, observability, failure mode, and access | Alert on latency, errors, evictions, and memory pressure |
@Configuration
@EnableCaching
class CacheConfiguration {
}
@Service
class ProductQueryService {
private final ProductRepository products;
ProductQueryService(ProductRepository products) {
this.products = products;
}
@Cacheable(cacheNames = "productById", key = "#productId")
public ProductView findProduct(UUID productId) {
return products.findViewById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
}
}
The first call for an identifier normally enters findProduct. A later call with the
same key may return without executing the method. This means logging, authorization checks,
metrics, or other side effects inside the cached method might not run on a hit. Keep a cacheable
method side-effect free and cache data only after access-control boundaries are understood.
Local and distributed caches
| Property | Local cache | Distributed Redis cache |
|---|---|---|
| Read latency | Very low, no network hop | Includes network and Redis latency |
| Data per application instance | Each instance has its own copy | Instances share a cache |
| Invalidation | Must reach every process | One shared entry can be removed |
| Failure effect | Consumes application heap | Depends on network and Redis service |
| Best fit | Small, hot, instance-tolerant data | Shared cache, larger working set, coordinated keys |
A two-level cache can combine both, but it creates two consistency boundaries. Evicting Redis does not automatically evict each process-local copy. Add an invalidation channel, short local TTL, or accept the larger stale window. Complexity is justified only when measurements show the extra layer is valuable.
The proxy boundary
In the default proxy mode, caching annotations are applied only when a call enters through the
Spring proxy. A method calling another annotated method on this bypasses the proxy,
so the cache advice does not run. The same basic limitation appears with @Async,
@Transactional, and proxy-based retry. Move the annotated operation to a separate
bean, call it through its injected proxy, or use AspectJ only when its complexity is justified.
02. Cache keys, conditions, and annotations
A cache is correct only when equal keys always represent interchangeable results under the same data and authorization rules.
Default and explicit keys
With no arguments, Spring uses SimpleKey.EMPTY. With one argument, the argument
itself is the key. With multiple arguments, Spring creates a SimpleKey containing
them. This is convenient, but an explicit key documents which inputs actually determine the
result.
@Cacheable(
cacheNames = "catalogSearch",
key = "T(String).format('%s:%s:%s', #tenantId, #query, #currency)")
public SearchResult search(
UUID tenantId,
String query,
Currency currency,
RequestTrace trace) {
// trace does not affect the result, so it is not part of the key.
return gateway.search(tenantId, query, currency);
}
A stable key should include tenant, locale, currency, feature version, authorization dimension,
query filters, or any other input that can change the returned value. Do not use mutable objects
with unstable equals or hashCode. For Redis, prefer short, readable,
deterministic keys. Hash very large query objects only after defining a canonical form, because
field order and serialization changes can otherwise create different hashes for the same query.
If two users have different allowed views but share a key, one user's cached response can be served to another. Cache a permission-neutral object and filter it afterward, or include the security dimension in the key. Never assume a controller authorization check makes shared cached output safe.
What each annotation means
| Annotation | Method execution | Cache effect | Typical use |
|---|---|---|---|
@Cacheable |
Skipped on a hit | Stores a miss result | Read-through method |
@CachePut |
Always runs | Stores the returned result | Refresh cache after update |
@CacheEvict |
Normally runs | Removes one key or all entries | Invalidate after mutation |
@Caching |
Depends on nested operations | Groups several cache operations | Evict detail and list caches together |
@CacheConfig |
No operation by itself | Shares class-level names or resolvers | Reduce repeated annotation metadata |
@Cacheable(
cacheNames = "productById",
key = "#id",
condition = "#useCache",
unless = "#result.discontinued()")
public ProductView find(UUID id, boolean useCache) {
return load(id);
}
@CachePut(cacheNames = "productById", key = "#result.id()")
@Transactional
public ProductView rename(UUID id, String name) {
return mapper.toView(products.rename(id, name));
}
@CacheEvict(cacheNames = "productById", key = "#id")
@Transactional
public void delete(UUID id) {
products.deleteById(id);
}
condition is checked before method invocation and may prevent caching. An
unless expression is checked after invocation and can veto storing the result.
#result is therefore useful in unless, @CachePut key
expressions, and after-invocation eviction expressions, but not in a pre-invocation condition.
By default, @CacheEvict evicts only after successful completion. If the method
throws, the entry remains. With beforeInvocation = true, eviction happens first even
if the method later fails. Use before-invocation eviction only when stale cached data is more
dangerous than an unnecessary miss and the database failure cannot make the old cache value
preferable.
Null and negative caching
Caching "not found" can protect a database from repeated requests for absent identifiers, but it
creates a stale absence when the record is later created. Use a short negative TTL and evict on
creation. Many providers can adapt null values, while some configurations disable them. An
explicit result such as Optional or a small lookup outcome makes the policy easier to
understand.
03. Invalidation, TTL, and consistency
Cache consistency is a business policy about how long a copied value may disagree with the source of truth.
A TTL is a safety bound and memory-management tool, not precise synchronization. A 10-minute TTL means a stale entry may remain usable for roughly that duration after the source changes unless explicit invalidation occurs. Expiration can happen before an application reads the entry, and provider eviction can remove it even earlier under memory pressure.
| Strategy | Write path | Strength | Main risk |
|---|---|---|---|
| TTL only | Do nothing to cache | Simple, eventual refresh | Known stale window |
| Cache-aside invalidation | Commit source, then remove key | Next read reloads authoritative data | Crash between commit and eviction |
| Write-through update | Update source and cache | Warm cache immediately | Two writes can fail or reorder |
| Versioned key | Change version used in new keys | No broad delete needed | Old keys consume memory until expiry |
| Event-driven invalidation | Publish committed change, consumers evict | Coordinates many processes or caches | Requires reliable delivery and idempotency |
Cache operations and database transactions
Cache and database are separate resources. Putting or evicting before a database transaction commits can expose a state that later rolls back. Putting only after commit is safer, but a process can crash after commit and before the cache operation. For important cross-process invalidation, store an outbox row in the same database transaction and publish it reliably. For a noncritical cache, combine post-commit eviction with a bounded TTL and accept the small failure window.
A later reader repopulates from the committed source. Updating the cache can race with concurrent readers and writers. It can also cache a representation that differs from the final committed state because of triggers, defaults, or concurrent changes.
Choosing a TTL
- Start from the maximum stale period the business can tolerate, not a round number.
- Use shorter TTLs for volatile, sensitive, or frequently corrected data.
- Use longer TTLs for immutable or versioned reference data.
- Add randomized jitter so many entries do not expire at exactly the same second.
- Measure hit rate, origin load, entry size, and change rate before tuning.
- Do not cache account balances, authorization decisions, or one-time secrets casually.
- Remember that provider capacity eviction can shorten the effective lifetime.
TTL is normally reset when an entry is created or updated. Time to idle, or TTI, resets expiration
on access. Spring Data Redis can emulate TTI with Redis GETEX, but every access path
must reset the TTL consistently and Redis 6.2 or later is required. Mixing cache reads with plain
GET operations breaks the TTI expectation.
Versioning and schema changes
Cached bytes outlive a deployment. A new class shape may fail to deserialize old entries or, worse, deserialize with incorrect defaults. Add a format version to cache names or key prefixes, deploy readers that tolerate a planned transition, or clear only the affected region. Avoid a global Redis flush because Redis may hold unrelated application data.
04. Redis cache integration
Redis gives application instances a shared in-memory cache, but network, serialization, memory, and security become part of the request path.
Spring Boot can auto-configure a RedisCacheManager when Redis is available and
caching is enabled. Cache names, a default TTL, null handling, and prefixes can be configured. For
different policies per region, build the manager with per-cache
RedisCacheConfiguration values.
spring:
cache:
type: redis
cache-names:
- productById
- exchangeRates
redis:
time-to-live: 10m
cache-null-values: false
use-key-prefix: true
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
username: ${REDIS_USERNAME:}
password: ${REDIS_PASSWORD:}
ssl:
enabled: true
connect-timeout: 2s
timeout: 1s
@Configuration
@EnableCaching
class RedisCachingConfiguration {
@Bean
RedisCacheManagerBuilderCustomizer cacheTtls() {
return builder -> builder
.withCacheConfiguration(
"productById",
defaults(Duration.ofMinutes(10)))
.withCacheConfiguration(
"exchangeRates",
defaults(Duration.ofSeconds(45)));
}
private RedisCacheConfiguration defaults(Duration ttl) {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(ttl)
.disableCachingNullValues()
.computePrefixWith(name -> "kb:v2:" + name + "::");
}
}
Keep a cache-name prefix. It prevents identical keys from different caches from colliding. Include
an application, environment, tenant model, and data-format version when Redis is shared. A key
such as kb:prod:v2:productById::42 is easier to inspect than opaque Java-serialized
bytes.
Serialization
The serializer is a compatibility and security decision. JDK native serialization ties data to Java types and has a history of unsafe deserialization when untrusted bytes are accepted. A constrained JSON or another explicit format is easier to inspect, but polymorphic type metadata also needs a strict allowlist. Cache immutable DTOs rather than managed JPA entities. Entities can contain lazy proxies, bidirectional graphs, or fields whose meaning changes with a persistence context.
Define the format contract, control type resolution, limit object size and nesting, and version incompatible changes. Never deserialize bytes writable by an untrusted Redis client. Select the current serializer API supported by the exact Spring Data Redis and Jackson generation in the application rather than copying a deprecated serializer class from an older example.
Expiration and memory pressure
Redis stores an absolute expiration timestamp for a key. EXPIRE and the expiration
options on SET create or update it. Expired keys are removed through access and
background expiration work. Separately, when memory exceeds maxmemory, Redis follows
maxmemory-policy. An all-keys LRU or LFU policy is common for a dedicated cache.
Volatile policies consider only expiring keys and behave like no-eviction if no eligible keys
exist.
- Set an explicit memory limit with headroom for clients, replication, persistence, and OS use.
- Choose an eviction policy based on measured access patterns.
- Keep durable data in a separate Redis deployment from disposable cache data when possible.
-
Monitor
used_memory, evictions, expirations, hits, misses, latency, and errors. - Remember that Redis LRU and LFU policies are approximations designed for efficiency.
Fail-open or fail-closed
A cache is normally an optimization, so a Redis failure may fall back to the database. This fail-open approach can create a database traffic surge exactly when Redis is unavailable. Bound timeouts, limit fallback concurrency, shed nonessential load, and protect the origin with a circuit breaker or bulkhead. If cached data is itself required for correctness, it is no longer a disposable cache and needs stronger storage and recovery design.
05. Cache stampedes and advanced patterns
A stampede occurs when many callers miss the same hot key and perform the expensive load concurrently.
Stampedes commonly follow synchronized expiration, a cold deployment, mass invalidation, or a cache outage. The origin receives traffic multiplied by the number of waiting callers. This can turn one cache miss into a database incident.
| Technique | How it helps | Limit |
|---|---|---|
@Cacheable(sync = true) |
Provider may let one thread compute a missing key | Capability depends on provider and is often process-local |
| Per-key single-flight | Coalesces identical loads in one process | Every application instance may still load once |
| Distributed short lock | Coordinates loaders across instances | Lease expiry, fencing, crash, and unlock correctness matter |
| TTL jitter | Spreads expirations over time | Does not protect one extremely hot key |
| Refresh ahead | Refreshes before hard expiration | Background refresh can waste work or fail silently |
| Stale while revalidate | Serves bounded stale data while one caller refreshes | Requires explicit stale policy and metadata |
| Negative caching | Stops repeated loads for missing records | Can hide newly created data until eviction |
Spring's sync = true is a hint to the provider and restricts combinations with other
cache attributes. Confirm the provider's exact behavior. A local provider can synchronize only
inside one JVM. A Redis-backed cache may still let one loader per application instance reach the
database.
Distributed lock warning
A Redis lock normally uses an atomic conditional set with an expiration and a unique owner token.
Unlock must delete only when the stored token still belongs to that owner, usually with an atomic
script. A lease can expire while slow work is still running, allowing a second loader. Fencing
tokens are needed when the protected resource must reject an older worker. Do not describe a
simple SET NX as a universal distributed-lock solution.
Cache metrics that answer useful questions
- Hit and miss count by bounded cache name, not by unbounded key.
- Load duration, load failure count, and concurrent loader count.
- Entry count, total bytes, large-value distribution, evictions, and expirations.
- Redis command latency, connection-pool wait, timeouts, and reconnects.
- Origin query rate and latency during normal service and cache failure.
- Stale-value age when a stale-while-revalidate design is used.
A high hit rate is not automatically good. A cheap query might cost less than serialization and a network call. A cache with a 99 percent hit rate can still serve dangerously stale authorization data. Measure end-to-end latency, origin capacity, correctness, and operational cost.
06. Scheduling fundamentals
A scheduler decides when to submit a task. The task still needs a thread, bounded work, error handling, and concurrency rules.
Add @EnableScheduling to a configuration class to detect
@Scheduled methods. A scheduled method is normally a no-argument method. Fixed-delay,
fixed-rate, cron, and one-time initial-delay triggers express different timing models.
@Component
class MaintenanceJobs {
@Scheduled(
fixedDelayString = "${jobs.cleanup.delay:PT10M}",
initialDelayString = "${jobs.cleanup.initial-delay:PT1M}")
void cleanupExpiredSessions() {
// Next delay starts after this invocation finishes.
}
@Scheduled(fixedRate = 30, timeUnit = TimeUnit.SECONDS)
void captureQueueDepth() {
// Target start times are 30 seconds apart.
}
@Scheduled(
cron = "${jobs.invoice.cron:0 15 2 * * *}",
zone = "${jobs.invoice.zone:UTC}")
void createDailyInvoices() {
// Spring cron has six fields, including seconds.
}
}
| Trigger | Meaning | Best use | Important edge case |
|---|---|---|---|
| Fixed delay | Wait after the preceding execution completes | Polling or cleanup that must not chase a clock | Long task shifts every later start |
| Fixed rate | Target start times follow a regular period | Sampling and lightweight periodic work | Slow tasks can queue or overlap with pool capacity |
| Cron | Calendar-based schedule | Daily or weekday business jobs | Time zones and daylight-saving transitions |
| Initial delay only | One invocation after startup delay | Deferred startup task | Runs again after each restart |
A fixed rate does not make work complete at a fixed rate. If a single scheduler thread is busy,
another scheduled callback waits. With a pool, separate triggers can overlap. A repeatable
@Scheduled declaration creates independent triggers, so even the same method may
overlap.
Cron and time
Spring cron expressions use six fields: second, minute, hour, day of month, month, and day of
week. Always set an explicit zone for a business schedule. UTC avoids daylight-saving
ambiguity, but some business rules truly mean local time. During a daylight-saving transition, a
local time may not exist or may occur twice. Define the product rule, test the transition, and
make the job idempotent.
Inject Clock into business logic instead of repeatedly calling the system clock.
Tests can then use Clock.fixed. The scheduler should trigger a small coordinator that
passes an explicit time window to a service, such as
[lastSuccessfulInstant, currentInstant), instead of hiding time calculations
throughout the job.
TaskScheduler and configuration
TaskScheduler is Spring's scheduling abstraction. A
ThreadPoolTaskScheduler delegates scheduling to a
ScheduledExecutorService and uses scheduler threads to execute tasks. Configure pool
size, thread names, shutdown behavior, and an error handler explicitly for important jobs. Spring
Boot exposes scheduling settings under spring.task.scheduling.
@Configuration
@EnableScheduling
class SchedulingConfiguration implements SchedulingConfigurer {
@Bean
ThreadPoolTaskScheduler jobScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(4);
scheduler.setThreadNamePrefix("job-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
scheduler.setErrorHandler(error ->
LoggerFactory.getLogger("scheduled-jobs")
.error("Scheduled task failed", error));
return scheduler;
}
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
registrar.setTaskScheduler(jobScheduler());
}
}
07. Production scheduling
Every application replica registers its own schedules, so a three-replica deployment normally runs the same scheduled method three times.
This behavior is correct for per-instance work such as refreshing local state. It is dangerous for global invoicing, emails, cleanup, or settlement. Decide whether the job is per-instance, one active worker, partitioned, or intentionally redundant.
| Approach | Useful when | Failure to design for |
|---|---|---|
| Run on every replica | Work is local and independent | Unexpected shared side effects |
| Leader or external platform scheduler | One trigger is enough | Leader failover and duplicate trigger |
| Database or lease lock | Short global job with shared coordination store | Lease expires before task finishes |
| Quartz with persistent store | Persistent triggers, calendars, clustering, misfire policy | Operational schema and cluster configuration |
| Broker-driven work | Large work can be split into durable items | Duplicates, poison messages, backpressure |
Missed, slow, and overlapping runs
The basic in-memory scheduler does not persist a fire while the application is down. On restart, it resumes from current trigger state rather than replaying every missed business period. If every period matters, store job checkpoints, scan for unfinished periods, or use a persistent scheduler or broker.
Design the job around claimable units instead of one huge transaction. For example, claim 100 pending rows with a lease, process them, record results, and repeat. The lease identifies work that another worker may reclaim after a crash. A unique business constraint prevents duplicate output. Keep database transactions short and avoid holding locks during network calls.
@Component
class ExpirationJob {
private final ExpirationService service;
ExpirationJob(ExpirationService service) {
this.service = service;
}
@Scheduled(fixedDelayString = "${jobs.expiration.delay:PT30S}")
void trigger() {
int processed;
do {
processed = service.processNextBatch(100);
} while (processed == 100);
}
}
Put transactional work in another Spring bean so its @Transactional proxy is used.
Bound the loop by a time budget as well as batch count in latency-sensitive services. Emit one
summary log and metrics instead of one high-cardinality log per row.
Job operability checklist
- Feature flag or property to disable the job safely.
- Explicit time zone, batch size, timeout, and concurrency.
- Last start, last success, last failure, duration, processed count, and backlog metrics.
- Stable job-run identifier propagated to logs and downstream calls.
- Manual recovery command that uses the same idempotent business service.
- Alert on missing success as well as explicit failures.
- Shutdown policy that stops accepting work and bounds drain time.
08. Async execution and thread pools
@Async submits a method invocation to an executor and returns control to the caller.
It does not make the work cheaper or durable.
Enable annotation support with @EnableAsync. In proxy mode, a call must cross the
proxy. The annotated method normally returns void, Future, or
CompletableFuture. The method may accept ordinary arguments. Use an explicit executor
name when workloads need different capacity or isolation.
@Configuration
@EnableAsync
class AsyncConfiguration implements AsyncConfigurer {
@Bean("notificationExecutor")
ThreadPoolTaskExecutor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(12);
executor.setQueueCapacity(200);
executor.setKeepAliveSeconds(30);
executor.setThreadNamePrefix("notify-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.AbortPolicy());
executor.initialize();
return executor;
}
}
@Service
class NotificationService {
@Async("notificationExecutor")
public CompletableFuture<DeliveryResult> send(Notification command) {
DeliveryResult result = gateway.deliver(command);
return CompletableFuture.completedFuture(result);
}
}
Pool anatomy and queueing
| Setting | Meaning | Design question |
|---|---|---|
| Core pool size | Baseline worker count | How much steady concurrency can dependencies support? |
| Queue capacity | Tasks waiting before more threads or rejection | How much latency and memory may accumulate? |
| Maximum pool size | Upper worker count after a bounded queue fills | Can the database or remote service survive this burst? |
| Keep-alive | How long extra idle threads remain | Is burst capacity temporary? |
| Rejection policy | Behavior when both pool and queue are full | Fail, run on caller, drop, or custom persistence? |
With a ThreadPoolExecutor-style bounded queue, tasks generally fill core threads,
then the queue, then expand toward maximum threads, then reject. A huge queue can make
maxPoolSize irrelevant while requests wait for minutes and consume memory. An
unbounded executor can create threads until the process or downstream dependency collapses.
Capacity limits make overload visible.
Reasoning about pool size
CPU-heavy work rarely benefits from far more runnable platform threads than available CPU.
Blocking I/O can use more concurrency because threads spend time waiting, but the correct limit is
also bounded by database connections, downstream rate limits, file descriptors, memory, and
service-level objectives. Little's Law gives a useful starting relationship:
concurrency = throughput × average time in system. Measure real waiting and service
time before tuning.
Virtual threads can reduce the cost of blocking threads, but they do not create downstream capacity. Keep semaphores, connection pools, concurrency limits, deadlines, and backpressure. Avoid mixing CPU-heavy work and slow remote calls in one executor because one workload can starve the other.
Return semantics
A controller returning "accepted" after submitting an in-memory task must be honest: the process
may crash immediately and lose that work. If loss is acceptable, document it. If the caller needs
a result, return or compose the CompletableFuture and handle timeout and failure. If
business completion must survive restarts, persist a job or send a durable message before
acknowledging acceptance.
09. Async failures, transactions, and context
Crossing a thread boundary also crosses exception, transaction, logging-context, and request-context boundaries.
Exception handling
For a Future or CompletableFuture, the failure is captured and observed
when the caller waits or attaches a completion stage. A void async method cannot
return its failure to the caller. Configure an AsyncUncaughtExceptionHandler to log,
meter, and route such failures. Logging alone does not retry or recover lost work.
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (failure, method, arguments) -> {
log.error(
"Async method failed method={} argumentCount={}",
method.toGenericString(),
arguments.length,
failure);
meterRegistry.counter(
"application.async.failures",
"method",
method.getName()).increment();
};
}
Do not log complete arguments automatically. They may contain passwords, tokens, personal data, or large payloads. Use safe identifiers and a bounded method-name tag.
Transaction boundaries
An imperative Spring transaction is normally bound to the caller thread. The async worker does not
inherit it. If the worker method has @Transactional and is invoked through the
appropriate proxy, it starts its own transaction. Passing a managed JPA entity to another thread
is unsafe because the persistence context is not transferred and entity state can be mutable. Pass
an immutable identifier or command and reload needed state inside the worker's transaction.
The worker may start immediately, read before the caller commits, or act even if the caller rolls back. Trigger after commit for best-effort local work, or use a transactional outbox when the work must not disappear.
Security and diagnostic context
Thread-local state such as MDC correlation values, locale, request attributes, and
SecurityContext does not automatically become correct on reused worker threads. Use a
deliberate TaskDecorator or supported context-propagation mechanism that captures
only required values, installs them for the task, and clears them in a finally block.
Blindly propagating a user security context into long-lived background work can grant permissions
after the request should have ended. Prefer explicit service identity and immutable audit data.
Shutdown and cancellation
Graceful shutdown stops new submissions, waits for a bounded interval, and then allows the
platform to terminate. Waiting forever can block deployments. Interrupts are cooperative, so code
should restore interruption status or exit when interrupted. A cancelled
CompletableFuture does not guarantee a remote call or database statement has been
undone.
10. Spring application events
An application event says that something happened inside one Spring application context and lets publishers avoid direct knowledge of every listener.
public record OrderPlaced(
UUID eventId,
UUID orderId,
UUID customerId,
Instant occurredAt) {
}
@Service
class OrderService {
private final ApplicationEventPublisher events;
private final OrderRepository orders;
private final Clock clock;
@Transactional
public UUID place(PlaceOrder command) {
Order order = orders.save(Order.create(command));
events.publishEvent(new OrderPlaced(
UUID.randomUUID(),
order.id(),
order.customerId(),
clock.instant()));
return order.id();
}
}
@Component
class OrderProjectionListener {
@EventListener
void record(OrderPlaced event) {
// Runs synchronously by default.
}
}
Publishing is a handoff to the application event multicaster, not proof that work is durable. By default, listeners run synchronously in the publisher's thread. Their latency becomes publisher latency, their exception may propagate to the publisher, and they can participate in the current thread-bound transaction. Ordering can be set, but heavily ordered listeners often reveal hidden workflow coupling.
Event design rules
-
Name facts in past tense, such as
OrderPlaced, not commands such asSendEmail. - Use immutable payloads with stable identifiers and occurrence time.
- Do not publish mutable JPA entities or request objects.
- Keep payloads small and avoid secrets or unnecessary personal data.
- Give events an identifier when duplicate handling or tracing matters.
- Let listeners own their side effect instead of making the event encode every implementation.
@EventListener(condition = "...") can filter with SpEL. Listener methods can return
another event or collection of events for synchronous publication. That return-based publication
does not apply to asynchronous listeners, which should inject the publisher if they intentionally
emit another event.
Asynchronous listeners
Adding @Async to a listener submits it to an executor. The publisher no longer waits,
but the event is still in memory and may be lost on crash or rejected under load. Configure a
dedicated bounded executor and failure handling. Use this for best-effort, low-consequence local
reactions, not for payments, audit records, or required integration delivery.
11. Transactional and domain events
@TransactionalEventListener binds listener execution to a transaction phase so a
listener can react to the transaction outcome.
| Phase | When listener runs | Typical purpose |
|---|---|---|
BEFORE_COMMIT |
Before commit processing completes | Required work that should still fail the transaction |
AFTER_COMMIT |
After a successful commit | Best-effort cache eviction or local follow-up |
AFTER_ROLLBACK |
After rollback | Rollback-specific cleanup or metrics |
AFTER_COMPLETION |
After commit or rollback | Outcome-independent cleanup |
@Component
class OrderCacheListener {
private final CacheManager caches;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void evict(OrderPlaced event) {
Cache cache = Objects.requireNonNull(
caches.getCache("customerOrders"));
cache.evict(event.customerId());
}
}
The default phase is AFTER_COMMIT. Without an active transaction, a transactional
listener is not invoked by default. fallbackExecution = true allows execution when no
transaction exists, but then the semantic guarantee changes. Use it only when both paths are
explicitly acceptable.
The database has committed, but the process can fail before or during the listener. Retrying an in-memory listener after restart is not automatic. Also, data-access resources may still appear bound during completion, but new writes in an after-commit listener need an explicit new transaction if they are meant to commit.
Domain events
A domain event expresses a meaningful fact discovered by the domain model, such as
SubscriptionExpired. Spring Data aggregate support can expose domain events when an
aggregate is saved. This helps keep the model expressive, but repository publication is still an
in-process mechanism. Events may not be published if code changes state without the expected
repository operation, and they do not become durable automatically.
Keep domain events separate from external integration events. Internal events can use domain language and process-local types. Integration events are versioned contracts for other systems, often containing less data and stronger compatibility rules. An outbox publisher can translate a committed domain outcome into an integration event.
Transactional outbox
The outbox pattern stores the business change and an event row in the same local database transaction. A separate publisher reads committed outbox rows and sends them to a broker, then records publication. A crash can cause a message to be published more than once, so consumers must be idempotent. The pattern closes the dangerous gap where a database commits but direct publication fails.
- Validate the command and update business tables.
- Insert an outbox row with event ID, type, version, payload, and occurred time.
- Commit both changes atomically.
- A publisher claims committed rows in bounded batches.
- Publish to the durable broker with the event ID.
- Mark or delete published rows using a safe retention policy.
- Consumers deduplicate by event ID and enforce business constraints.
12. Retry patterns
Retry is correct only for a transient failure, with a bounded attempt budget, safe repeated effects, and backoff that reduces pressure.
| Failure | Retry? | Reason |
|---|---|---|
| Connection reset before a safe read completes | Often | Temporary transport failure |
| HTTP 429 or 503 with bounded policy | Often | Remote service signals temporary inability |
| PostgreSQL serialization failure | Complete transaction | Concurrent outcome may succeed from a fresh snapshot |
| Validation failure or HTTP 400 | No | The same invalid input remains invalid |
| Authentication or authorization failure | Normally no | Retry does not grant permission |
| Non-idempotent payment with unknown outcome | Only with provider idempotency key and status recovery | First attempt may already have succeeded |
A production retry policy
- Classify a narrow set of transient exceptions.
- Set a maximum attempt count and total elapsed-time budget.
- Use exponential backoff so each failure increases the delay.
- Add randomized jitter so many clients do not retry together.
- Honor a trustworthy server-provided retry delay where applicable.
- Set a timeout on each attempt and a deadline for the whole operation.
- Record attempts, final outcome, cause, and exhausted count with bounded tags.
- Combine with circuit breaking and concurrency limits when a dependency is unhealthy.
Never multiply retry layers accidentally. Three HTTP-client attempts inside three service attempts inside three message redeliveries can create 27 calls. Define which layer owns retry and include downstream attempt time in the caller's deadline.
Spring Framework 7 and Spring Retry
Spring Framework 7 includes core resilience support such as
org.springframework.resilience.annotation.Retryable, enabled with
@EnableResilientMethods. Its maxRetries means retries after the initial
call, so total attempts equal one plus maxRetries. It supports delay, multiplier,
maximum delay, jitter, included and excluded failures, and programmatic retry.
@Configuration
@EnableResilientMethods
class ResilienceConfiguration {
}
@Service
class RateClient {
@Retryable(
includes = {SocketTimeoutException.class},
maxRetries = 3,
delay = 100,
multiplier = 2,
maxDelay = 1_000,
jitter = 50)
public ExchangeRates loadRates() {
return remoteClient.load();
}
}
The separate Spring Retry project uses its own @EnableRetry,
org.springframework.retry.annotation.Retryable, @Backoff, and
@Recover model. It is in maintenance-only status because Spring Framework 7
supersedes it for new Framework 7 applications. Existing Spring Boot applications on earlier
Framework generations may still use Spring Retry. Do not mix the two annotations or assume that
similarly named attributes count attempts in the same way.
Proxy and transaction placement
Proxy-based retry needs an external call through the proxy. For a transaction retry, place retry outside the transaction so each attempt receives a new transaction and persistence context. If retry advice executes inside one transaction already marked rollback-only, later attempts cannot repair it. Verify advice order with an integration test instead of relying on annotation order in source code.
13. Idempotency and duplicate work
An idempotent operation produces the intended business state when the same logical request is processed more than once.
Retries, scheduler overlap, broker redelivery, client timeouts, and lease expiry all create duplicates. "Exactly once" transport language does not remove the need for business idempotency because a database commit and acknowledgement can fail at different moments.
| Technique | Example | Protection |
|---|---|---|
| Natural unique constraint | One invoice per account and billing period | Concurrent duplicate creation |
| Idempotency key table | Client key maps to request hash and stored response | Repeated API command |
| Processed-event record | Unique consumer name plus event ID | Message redelivery |
| State transition guard | Update only where status is PENDING |
Repeated transition |
| Provider idempotency key | Stable payment request identifier | Unknown remote outcome |
Safe API idempotency flow
- Require a high-entropy key scoped to authenticated client and operation.
- Compute a canonical request hash and store it with the key.
- Atomically claim the key using a unique database constraint.
- Reject reuse of the same key with a different request hash.
- Store the terminal status and response needed for an identical replay.
- Handle an in-progress duplicate with wait, conflict, or status lookup semantics.
- Retain records for at least the documented client retry window.
A Redis lock alone is not a complete idempotency record. It can expire, be evicted, or disappear during failover. For important financial or externally visible operations, enforce the invariant in the authoritative database or remote provider.
Idempotent event consumer
Insert the consumer and event ID into a table with a unique constraint in the same transaction as the business mutation. If the insert conflicts, the event was already applied and can be acknowledged. If the transaction rolls back, neither marker nor mutation remains. Keep the marker long enough to cover broker retention and replay procedures.
14. When to move work to a durable message broker
Use a durable broker when accepted work must survive process failure, cross service boundaries, absorb bursts, or be retried independently.
| Requirement | In-memory async or events | Durable broker |
|---|---|---|
| Survive application restart | No | Designed to, subject to broker durability configuration |
| Cross-service delivery | No | Yes |
| Replay history | No | Available in log-oriented systems and retained queues |
| Backpressure | Bounded local queue and rejection | Durable backlog plus consumer flow control |
| Independent retry and dead-letter handling | Custom and process-local | Common broker and consumer capability |
| Very low local handoff latency | Excellent | Network and persistence overhead |
Strong signals for a broker include payment fulfillment, required email or webhook delivery, inventory reservation, audit integration, long-running media processing, bursty fan-out, multi-service workflows, and work that needs an operator-visible backlog. Keep local events for decoupling code inside one process when loss is acceptable or the listener remains part of the same synchronous transaction.
Broker realities
- Messages can be delivered more than once, so consumers must be idempotent.
- Ordering is usually limited to a partition, key, or queue, not every message globally.
- Acknowledgement should follow the durable business effect.
- Retry topics or delayed queues need a bounded policy and dead-letter destination.
- Poison messages need inspection, redaction, repair, and replay procedures.
- Schema versions need backward and forward compatibility plans.
- Outbox or change-data-capture closes the database-to-broker dual-write gap.
15. Putting the tools together
A production design assigns one clear responsibility and one failure policy to each boundary.
Read path with shared cache
- Authorize the request before returning protected data.
- Compute a versioned, tenant-safe key.
- Read Redis with a short timeout.
- On a hit, return the immutable cached DTO.
- On a miss, coalesce hot-key loads and query the authoritative database.
- Store with TTL and jitter, then return.
- On Redis failure, apply a bounded origin fallback rather than unlimited database traffic.
Write path with durable invalidation
- Validate an idempotency key and business rules.
- Update business data and insert an outbox event in one database transaction.
- Commit, then return the committed result.
- An outbox publisher sends the versioned change event to a broker.
- Consumers evict shared and local caches idempotently.
- A short TTL limits stale data if invalidation is delayed.
Scheduled batch path
- An external or leader-aware scheduler triggers a lightweight coordinator.
- The coordinator claims a bounded batch using database leases.
- Workers process units with an idempotent state transition.
- Transient remote failures use bounded retry and jitter.
- Durable follow-up is published through an outbox.
- Metrics report lag, claim age, completion rate, retry, and terminal failure.
At every arrow, ask what happens if the process stops immediately before it, during it, and immediately after it. If the answer loses required work or repeats an unsafe effect, add a durable record, idempotency guard, or changed acknowledgement boundary.
16. Testing strategies
Test business logic without time or threads first, then test Spring interception and real providers at the smallest useful integration boundary.
Caching tests
- Unit-test key policy, TTL policy, and invalidation decisions as ordinary functions.
- Use a Spring integration test to prove the call crosses a proxy and a hit skips the loader.
- Verify different tenant, locale, and permission inputs never share an entry.
- Verify mutation evicts or updates every affected detail, list, and aggregate cache.
- Use a real Redis test container for TTL, serialization, prefix, connection failure, and concurrency.
- Test cold-cache load and mass-expiration behavior, not only warm hits.
@SpringBootTest
class ProductCacheTest {
@Autowired ProductQueryService service;
@MockBean ProductRepository repository;
@Test
void repeatedLookupLoadsOnce() {
UUID id = UUID.randomUUID();
when(repository.findViewById(id))
.thenReturn(Optional.of(new ProductView(id, "Keyboard")));
assertThat(service.findProduct(id))
.isEqualTo(service.findProduct(id));
verify(repository, times(1)).findViewById(id);
}
}
Clear the relevant cache between tests because Spring's test context can be reused. Do not make tests order-dependent. A mock-only unit test of the service cannot prove that cache advice runs.
Scheduling tests
Put real work in a service method that accepts a time window and batch size. Unit-test it with a
fixed Clock. Test cron calculation around month boundaries and daylight-saving
transitions. Keep annotation wiring tests small. Avoid sleep-based assertions; use latches,
Awaitility-style bounded polling, or directly invoke the coordinator when timing itself is not
under test.
Async tests
- Unit-test worker logic synchronously without an executor.
- Integration-test that invocation returns and executes on the named thread or executor.
- Use latches or controllable executors to make order deterministic.
- Fill a tiny test pool and assert the selected rejection behavior.
- Assert future failures, void exception handling, context cleanup, and graceful shutdown.
- Test that the worker starts its own transaction and reloads data by identifier.
Events, outbox, and retry tests
- Use
@RecordApplicationEventswhen the event contract itself matters. - Verify an after-commit listener does not run after rollback.
- Test no-transaction behavior with and without
fallbackExecution. - Commit a business change and assert the outbox row exists in the same outcome.
- Simulate publish-before-mark failure and prove duplicate publication is safe.
- Use a fake clock and scripted transient failures to test exact retry delays and exhaustion.
- Prove permanent failures are attempted once and duplicate event IDs change state once.
17. Security, performance, and operations
Background and cached paths need the same security review, capacity model, observability, and recovery discipline as synchronous request paths.
Security practices
- Use Redis authentication, TLS, network isolation, least-privilege ACLs, and secret rotation.
- Do not expose Redis directly to untrusted networks or treat it as a public API.
- Never place passwords, access tokens, one-time codes, or unnecessary personal data in cache.
- Include tenant and authorization dimensions in keys where output differs.
- Prevent unsafe polymorphic deserialization and restrict writable Redis clients.
- Validate event payloads at trust boundaries and sign webhooks where required.
- Run scheduled and async work with an explicit service identity and auditable actor.
- Redact task arguments, cache values, event payloads, and broker messages from logs.
Capacity and performance
- Give separate executors to workloads with different latency and criticality.
- Keep every queue bounded and make rejection behavior part of the API contract.
- Limit concurrency by the narrowest downstream resource, often database connections.
- Batch carefully: too small wastes overhead, while too large increases locks and recovery work.
- Use cache entries smaller than the network and serialization cost they save.
- Spread TTL and recurring-job start times with jitter when exact alignment is unnecessary.
- Apply deadlines across retries so backoff does not exceed caller usefulness.
Operational signals
| Area | Metrics and diagnostics | Alert example |
|---|---|---|
| Cache | Hit ratio, load latency, Redis latency, error, eviction, memory | Origin load surges while Redis errors increase |
| Scheduler | Last success, duration, backlog, processed count, overlap | No successful run within expected interval |
| Executor | Active threads, pool size, queue depth, oldest task, rejection | Queue age violates the completion objective |
| Events and outbox | Published, listener failure, unpublished rows, oldest row age | Oldest outbox row continues to age |
| Retry | Attempts, recovered, exhausted, delay, final exception class | Retry volume rises before final failures rise |
| Broker | Consumer lag, redelivery, dead letter, publish latency | Lag or dead-letter growth exceeds recovery capacity |
Thread dumps reveal executor starvation, lock waits, and blocked network calls. Give pools recognizable thread-name prefixes. Do not tag metrics with cache key, event ID, customer ID, or full exception message because unbounded dimensions can overload the monitoring system.
18. Common mistakes and corrections
| Mistake | Why it fails | Correction |
|---|---|---|
Calling a cached or async method on this |
Default proxy advice is bypassed | Move the operation to another injected bean |
| Caching a JPA entity | Lazy proxies, mutable state, and schema coupling escape | Cache an immutable, versioned DTO |
| Key omits tenant or locale | Wrong or protected result is reused | Model every result-varying dimension |
| One TTL for every cache | Volatility and stale tolerance differ | Set policy per named cache |
| Clearing all Redis keys | Unrelated workloads lose state and origin load spikes | Use versioned prefixes or targeted region eviction |
Assuming sync = true is cluster single-flight |
Provider semantics may be JVM-local | Verify provider or add appropriate coordination |
| Using default single scheduler thread unknowingly | One slow task delays unrelated jobs | Configure and isolate measured workloads |
| Running a global job on every replica | Side effects duplicate | Use idempotency plus leader, lock, or durable work queue |
| Using an unbounded async executor | Threads, tasks, or downstream calls grow without control | Bound pool and queue, define rejection |
Ignoring void async exceptions |
Caller cannot observe failure | Return a future or configure uncaught handling |
| Passing a managed entity to an async thread | Persistence context and thread safety do not transfer | Pass an immutable ID and reload |
| Publishing an event before commit for required work | Listener can observe rollback or uncommitted data | Use transaction phase or an outbox |
| Calling an after-commit event durable | Process failure can lose it | Persist an outbox in the original transaction |
| Retrying every exception immediately | Permanent errors repeat and outages amplify | Classify, bound, back off, and add jitter |
| Assuming a broker means exactly-once effects | Commit and acknowledgement can be separated by failure | Build idempotent consumers and constraints |
19. Practice exercises
-
Implement a tenant-safe
productByIdcache. Give products and missing products different TTL policies. Prove with tests that tenants cannot share entries. - Add Redis with a versioned prefix and JSON format. Start with a prior cached schema and test one rolling-deployment compatibility strategy.
- Create a hot-key load test. Compare no coordination, local single-flight, TTL jitter, and stale while revalidate. Record origin concurrency and P99 latency.
-
Build a daily job that accepts a time window and a fixed
Clock. Test a daylight-saving gap and overlap in a chosen business time zone. - Configure a two-thread, two-entry async executor. Fill it deliberately and verify the exact rejection response seen by the caller.
- Publish an application event inside a transaction. Prove ordinary listener, after-commit listener, and rollback behavior with integration tests.
- Implement an outbox table and publisher. Crash the publisher after send but before marking the row, then prove the consumer processes the redelivered event once.
- Define a retry matrix for one remote API. Include exception classification, maximum elapsed time, backoff, jitter, idempotency key, and terminal recovery.
- Create an idempotent invoice command backed by a unique account-period constraint. Run two workers concurrently and verify only one invoice exists.
- Draw failure points for database commit, cache eviction, event publication, and broker acknowledgement. State the recovery and duplication behavior at every point.
20. Interview questions and answers
1. What does Spring Cache abstract?
It abstracts method-level cache operations through Cache and
CacheManager. It does not provide the physical store or standardize every provider
feature such as TTL, eviction, serialization, or distributed locking.
2. How does @Cacheable work?
A Spring interceptor computes a cache and key before the method call. On a hit it returns the cached value and skips the method. On a miss it invokes the method and normally stores the result. In default proxy mode, self-invocation bypasses this behavior.
3. What is the difference between @Cacheable and @CachePut?
@Cacheable can skip method execution on a hit. @CachePut always invokes
the method and stores its result. Put is useful when a completed update produces the exact cached
representation.
4. Why is cache invalidation difficult?
The source and cache are separate systems, concurrent readers and writers can interleave, and a process can fail between database commit and cache change. TTL bounds staleness, while durable event-driven invalidation can close larger coordination gaps but introduces delivery and idempotency work.
5. What is a cache stampede?
Many callers miss the same key and perform the expensive load concurrently. Mitigations include
single-flight, sync = true where provider semantics fit, TTL jitter, refresh ahead,
stale while revalidate, and carefully designed distributed coordination.
6. Local cache or Redis?
A local cache is faster and simpler but each application instance has a different copy. Redis shares entries and capacity across instances but adds network, serialization, availability, and security concerns. The consistency and latency requirements decide.
7. What is the difference between TTL and TTI?
TTL normally counts from create or update and does not reset on read. TTI resets on access. Redis
natively uses expiration, while Spring Data Redis can emulate TTI using GETEX if all
access paths follow the same policy.
8. Fixed delay versus fixed rate?
Fixed delay waits the configured duration after the previous run finishes. Fixed rate targets start times separated by a period. Pool capacity and task duration still determine whether actual starts overlap or wait.
9. Does @Scheduled run once in a cluster?
No. Each application context registers its schedule. Use per-instance semantics intentionally or add leader election, an external scheduler, a distributed coordination mechanism, Quartz clustering, or durable queued work. Keep the business action idempotent.
10. What happens to missed scheduled runs during downtime?
The basic in-memory scheduler does not persist and replay every missed business run. Store checkpoints, scan for missing periods, or use a persistent scheduler or broker when missed work must be recovered.
11. What does @Async guarantee?
It submits an intercepted invocation to an executor so the caller need not execute it on the same thread. It does not guarantee persistence, eventual completion, inherited transaction, context propagation, or unlimited capacity.
12. Why must an async executor be bounded?
Incoming work can exceed processing capacity. A bounded pool and queue cap memory and downstream concurrency and make overload observable through rejection. An unbounded resource converts overload into growing latency and eventual failure.
13. How are async exceptions handled?
A future carries failure to a caller that observes it. A void async method has no return channel,
so an AsyncUncaughtExceptionHandler is needed for observation. Required recovery
usually belongs in durable workflow design rather than logs alone.
14. Does a transaction propagate into @Async?
An imperative thread-bound transaction normally does not. The async method may start its own transaction when its annotated call crosses the correct proxy. Pass IDs or immutable commands, not managed entities.
15. Are Spring application events synchronous?
By default, yes. Listeners execute in the publisher thread through the application event multicaster. They add latency and may propagate exceptions. They can be made asynchronous, but that makes the handoff in-memory rather than durable.
16. What does @TransactionalEventListener solve?
It binds a listener to a transaction phase, most commonly after successful commit. It prevents a reaction intended for committed state from running after rollback. It does not persist the event or guarantee execution after a crash.
17. Domain event versus integration event?
A domain event represents a fact meaningful inside the domain model. An integration event is a versioned contract for another process or service. Translation and an outbox keep internal design separate from external compatibility and delivery concerns.
18. What problem does the outbox pattern solve?
It atomically records business state and publication intent in one database transaction, avoiding the gap between database commit and direct broker send. The publisher can send more than once, so consumers still need idempotency.
19. When should an operation be retried?
Retry a classified transient failure only when another attempt has a meaningful chance of succeeding and repeated effects are safe. Bound attempts and elapsed time, add exponential backoff and jitter, and do not retry validation or authorization errors.
20. How do Spring Framework 7 retry counts differ from common Spring Retry examples?
Framework 7's maxRetries counts retry attempts after the initial call. The separate
Spring Retry project's maxAttempts counts total attempts. They are different packages
and configuration models.
21. Why is jitter important?
Without jitter, many clients that fail together use identical backoff and call the dependency together again. Random variation spreads attempts and reduces synchronized load spikes.
22. What is idempotency?
It is the property that processing the same logical operation more than once reaches the intended business outcome without duplicate effects. Implement it with unique constraints, request keys, state guards, processed-event records, and provider-supported keys.
23. When is a durable broker better than @Async?
Use a broker when accepted work must survive restart, cross processes, absorb a durable backlog,
be retried independently, or support replay and dead-letter handling. @Async is best
for bounded, process-local work where loss semantics are acceptable.
24. Can message processing be exactly once?
A transport may offer strong delivery or transactional features, but end-to-end business effects still cross failure boundaries. Design for redelivery with idempotent consumers, database constraints, stable event IDs, and correct acknowledgement placement.
25. How would you diagnose an async backlog?
Inspect active thread count, pool maximum, queue depth and age, rejection count, task duration, thread dumps, downstream latency, connection-pool waits, and incoming rate. Fix the bottleneck or reduce admission before merely increasing threads.
21. Concise cheat sheet
@Cacheableskips work on hit;@CachePutalways runs.- Key every dimension that changes the result, especially tenant and permission.
- Use immutable DTOs, versioned prefixes, targeted invalidation, TTL, and jitter.
- Redis is shared but adds network, serialization, memory, and security concerns.
- Plan cold start, stampede, cache outage, and origin protection.
- Fixed delay measures after completion; fixed rate targets periodic starts.
- Set an explicit cron zone and design for daylight-saving behavior.
- Every application replica schedules independently.
- Use named, bounded pools and queues with explicit rejection and shutdown.
- Transactions and thread-local context do not automatically cross async boundaries.
- Application events are synchronous and in-process by default.
@TransactionalEventListenerselects transaction phase, not durability.- Outbox plus broker handles required cross-process publication.
- Retry only transient failures with bounds, backoff, jitter, deadlines, and idempotency.
- Assume duplicate work can happen and enforce business invariants at the source of truth.
22. Official references
- Spring Framework cache abstraction
- Spring cache annotations
- Spring Boot caching support
- Spring Data Redis cache and expiration
- Spring task execution and scheduling
- Spring Boot task execution and scheduling
- Spring application context events
- Spring transaction-bound events
- Spring Framework resilience and retry
- Spring Retry project and migration status
- Redis keys and expiration
- Redis memory and key eviction
- Redis cache-aside pattern