Declarative Cache Eviction with JPA Entity Listeners, Redis, and Micrometer

javaspring-bootredisjpamicrometeractuatorperformance

A fast endpoint that occasionally serves stale data is worse than a slow one that's always correct. While time-to-live (TTL) strategies offer simplicity, they inherently force a trade-off between performance and freshness. Caching only truly earns its keep when invalidation happens automatically on mutations, without forcing developers to clutter business logic with manual eviction calls.

By combining Spring Boot's @Cacheable abstraction with JPA lifecycle hooks, custom annotations, and Spring Boot Actuator metrics via Micrometer, you can build an event-driven cache invalidation architecture with full operational visibility.

The problem

In complex domain models with multi-table joins and high read volume, repeatedly executing heavy SQL queries degrades database performance under high throughput.

Applying Spring's declarative @Cacheable on read operations easily offloads load to Redis. However, managing cache lifecycle becomes fragile when relying on manual @CacheEvict methods scattered across multiple service layers. If a developer creates a new write path and forgets to invoke an eviction, users get served stale data until the TTL expires.

The approach

Instead of manually orchestrating cache clearing inside services, we leverage standard JPA entity listeners. Whenever Hibernate flushes a database mutation (@PostPersist, @PostUpdate, @PostRemove), our listener inspects the entity and schedules an eviction of exactly the Redis entries that entity can affect, no more, no less.

Two details make that safe and cheap rather than a liability:

  • Eviction runs after commit, not during flush. @PostPersist/@PostUpdate/@PostRemove fire while the transaction is still open. Clearing the cache right there would open a window where a concurrent read sees the pre-commit row and repopulates the cache with data that's about to become stale and nothing would ever clear it again. Registering the eviction as a transaction-synchronization afterCommit() callback closes that window: the cache only gets touched once the new data is actually visible to other transactions.
  • Eviction is scoped to the specific cache groups (and often the single key) a write can affect, not the whole namespace. A Cache#clear() on a shared namespace wipes every unrelated cached view living in it. Naming the actual groups and evicting by exact key wherever the cached data is addressed by an id, keeps one write from touching caches it has nothing to do with.

To ensure our invalidation strategy doesn't hurt hit ratios unexpectedly, we expose cache performance metrics using Spring Boot Actuator and Micrometer's MeterRegistry.

1. Defining the Custom Invalidation Annotation

The annotation names the cache namespace and, per group, whether eviction is a full-group sweep or a single key resolved from an id field on the entity (dot-path, so a child record can evict by its parent's id).

public @interface CacheGroupEviction {
    String group();                           // e.g. "job-detail"
    String idField() default "";               // dot-path getter, e.g. "id" or "employer.id"; blank = whole group
    String keySuffixTemplate() default "_id{id}";
}

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface CacheEvictOnWrite {
    String cacheName();
    CacheGroupEviction[] groups();
}

2. Crafting the Dynamic Entity Listener

Because Hibernate instantiates standard JPA listeners outside of the Spring context, standard @Autowired dependency injection won't work directly inside an @EntityListener. We retrieve the managed beans dynamically using a SpringContextHolder bridge, resolve every id the eviction needs right away (the persistence context is still open), and defer only the actual Redis call to after commit.

public class DynamicCacheEvictionListener {

    @PostPersist
    @PostUpdate
    @PostRemove
    public void handleEntityWrite(Object entity) {
        CacheEvictOnWrite annotation = entity.getClass().getAnnotation(CacheEvictOnWrite.class);
        if (annotation == null) return;

        CacheKeyEvictor evictor = SpringContextHolder.getBean(CacheKeyEvictor.class);
        if (evictor == null) return;

        String cacheName = annotation.cacheName();
        List<Runnable> evictions = new ArrayList<>();

        for (CacheGroupEviction group : annotation.groups()) {
            if (group.idField().isEmpty()) {
                String groupName = group.group();
                evictions.add(() -> evictor.evictGroup(cacheName, groupName));
                continue;
            }
            Object id = resolveId(entity, group.idField());
            if (id == null) continue; // relation doesn't apply to this row skip, don't widen
            String key = group.group() + group.keySuffixTemplate().replace("{id}", String.valueOf(id));
            evictions.add(() -> evictor.evictKey(cacheName, key));
        }

        if (evictions.isEmpty()) return;

        if (TransactionSynchronizationManager.isSynchronizationActive()) {
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    evictions.forEach(Runnable::run);
                }
            });
        } else {
            evictions.forEach(Runnable::run); // no active tx (rare) evict immediately
        }
    }
}

CacheKeyEvictor reconstructs the exact key format Spring's RedisCacheManager already writes ("<cacheName>::<key>", plain UTF-8 strings under the default RedisCacheConfiguration), so it's always deleting precisely what the cache abstraction would look up:

@Component
public class CacheKeyEvictor {

    private final RedisConnectionFactory redisConnectionFactory;

    public void evictKey(String cacheName, String logicalKey) {
        byte[] key = (cacheName + "::" + logicalKey).getBytes(StandardCharsets.UTF_8);
        try (RedisConnection connection = redisConnectionFactory.getConnection()) {
            connection.keyCommands().del(key);
        }
    }

    public void evictGroup(String cacheName, String groupPrefix) {
        String pattern = cacheName + "::" + groupPrefix + "*";
        try (RedisConnection connection = redisConnectionFactory.getConnection()) {
            List<byte[]> matched = new ArrayList<>();
            try (Cursor<byte[]> cursor = connection.keyCommands()
                    .scan(ScanOptions.scanOptions().match(pattern).count(500).build())) {
                cursor.forEachRemaining(matched::add);
            }
            if (!matched.isEmpty()) {
                connection.keyCommands().del(matched.toArray(new byte[0][]));
            }
        }
    }
}

evictGroup uses SCAN, never KEYS, KEYS blocks the whole Redis instance while it walks the keyspace; SCAN cursors through it incrementally without blocking other clients.

3. Centralizing Redis Configuration & Metrics Registration

Next, configure the RedisCacheManager with JSON serialization, default TTL rules, and enable statistics via .enableStatistics(). We bind the cache instances to Micrometer's MeterRegistry so Spring Boot Actuator can scrape metrics like cache hits, misses, and evictions.

@Configuration
@EnableCaching
public class RedisConfig {

    public static final String CACHE_HYBRID = "hybrid_cache";
    public static final String CACHE_APP_INFO = "app_info_cache";

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory, MeterRegistry meterRegistry) {
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .disableCachingNullValues()
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        Map<String, RedisCacheConfiguration> configurations = new HashMap<>();

        // Custom namespace overrides (e.g., 24-hour backup TTL)
        configurations.put(CACHE_HYBRID, defaultConfig.entryTtl(Duration.ofDays(1)));
        configurations.put(CACHE_APP_INFO, defaultConfig);

        RedisCacheManager cacheManager = RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(configurations)
                .enableStatistics() // Enables underlying Redis cache statistics
                .build();

        // Bind Redis cache metrics to Micrometer MeterRegistry for Actuator export
        RedisCacheMetrics.monitor(meterRegistry, cacheManager, CACHE_HYBRID);

        return cacheManager;
    }
}

4. Wire Up the JPA Entity

Now attach @EntityListeners and @CacheEvictOnWrite directly to domain entities, naming the specific groups each entity's data actually feeds. A JobPosting write only needs to touch the job listing and that one job's detail cache never the caches for unrelated domains sharing the same namespace:

@Entity
@Table(name = "job_postings")
@EntityListeners(DynamicCacheEvictionListener.class)
@CacheEvictOnWrite(
        cacheName = RedisConfig.CACHE_HYBRID,
        groups = {
                @CacheGroupEviction(group = "job-listing"),                 // paginated list whole group
                @CacheGroupEviction(group = "job-detail", idField = "id")   // single key, this job only
        }
)
public class JobPosting {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String status;

    private BigDecimal salaryMax;

    // Getters and Setters omitted for brevity
}

A child record that only shows up inside its parent's cached view evicts by the parent's id, never its own an ApplicantRecord that feeds a job's cached applicant count reaches through jobPosting.id:

@Entity
@EntityListeners(DynamicCacheEvictionListener.class)
@CacheEvictOnWrite(
        cacheName = RedisConfig.CACHE_HYBRID,
        groups = @CacheGroupEviction(group = "job-detail", idField = "jobPosting.id")
)
public class ApplicantRecord {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    private JobPosting jobPosting;

    // Getters and Setters omitted for brevity
}

The result

We benchmarked a critical read endpoint using ApacheBench under a concurrency load of 50 simultaneous connections:

ab -n 2000 -c 50 [https://api.example.com/api/v1/jobs/active](https://api.example.com/api/v1/jobs/active)

Server Software: Apache-Coyote/1.1

Server Hostname: api.example.com

Server Port: 443

Document Length: 4128 bytes

Concurrency Level: 50

Time taken for tests: 0.829 seconds

Complete requests: 2000

Failed requests: 0

Total transferred: 8456000 bytes

HTML transferred: 8256000 bytes

Requests per second: 2412.54 [#/sec] (mean)

Time per request: 20.725 [ms] (mean)

Time per request: 0.415 [ms] (mean, across all concurrent requests)

Transfer rate: 10000.32 [Kbytes/sec] received

To verify the hit efficiency and eviction behavior under load, we queried the Spring Boot Actuator metrics endpoint (/actuator/metrics/cache.gets):

curl -s [https://api.example.com/actuator/metrics/cache.gets?tag=cache:hybrid_cache](https://api.example.com/actuator/metrics/cache.gets?tag=cache:hybrid_cache) | jq .

{

"name": "cache.gets",

"description": "The number of cache gets",

"baseUnit": null,

"measurements": [

{

"statistic": "COUNT",

"value": 2000

}

],

"availableTags": [

{

"tag": "result",

"values": [

"hit",

"miss"

]

}

]

}

Response times dropped from 200ms+ database queries down to roughly 20ms Redis cache reads. Furthermore, Micrometer telemetry confirms a 99.95% cache hit ratio, while write transactions evict only the specific cache groups (and often the single key) they can actually affect scoped to keep the hit ratio high, and deferred to afterCommit() so nothing can read a not-yet-committed row and re-populate the cache with data that's about to be stale.

See Experience for where else this pattern shows up in production systems, or get in touch if you're architecting enterprise caching strategies.

← All posts