Cache Consistency Patterns: Implementing Delayed Double Deletion with Spring Boot AOP and Redis

Understanding the Consistency Challenge

In high-concurrency environments, siumltaneous database mutations create temporal windows where Redis caches diverge from persistent storage. Consider two concurrent update threads:

  • Thread Alpha: Updates database record → Updates cache entry
  • Thread Beta: Updates database record → Updates cache entry

Execution sequence interleaving (Alpha-DB → Beta-DB → Beta-Cache → Alpha-Cache) results in stale data persisting in Redis, causing subsequent reads to return obsolete values indefinitely.

The Delayed Double Deletion Pattern

This strategy maintains eventual consistency through four distinct phases:

  1. Initial Eviction: Remove cache entry immediately
  2. Database Mutation: Execute transactional update
  3. Deferred Pause: Wait for transaction propagation (typically 500ms, tunable per SLA)
  4. Secondary Eviction: Remove cache entry again

The intermediate delay accommodates database transaction commit latency, ensuring the secondary deletion occurs after persistence layer reconciliation. The dual eviction prevents cache population from pre-update queries during the mutation window.

Note: Frequently mutated datasets warrant direct database access bypassing cache layers, as this pattern inherently invalidates cached entries, shifting read pressure to the database.

Project Configuration

Add required starters to your build descriptor:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

Configure connection pools and serialization:

server:
  port: 8080

spring:
  redis:
    host: ${REDIS_HOST:localhost}
    port: 6379
    timeout: 2000ms
    lettuce:
      pool:
        max-active: 8
        min-idle: 2
  datasource:
    url: jdbc:mysql://localhost:3306/cache_demo?useSSL=false&serverTimezone=UTC
    username: ${DB_USER:root}
    password: ${DB_PASS:secret}
    driver-class-name: com.mysql.cj.jdbc.Driver
  
  cache:
    type: redis
    redis:
      time-to-live: 300000  # 5 minutes

Database Schema

CREATE TABLE IF NOT EXISTS account (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(128) NOT NULL UNIQUE,
    display_name VARCHAR(64) NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

INSERT INTO account (email, display_name) VALUES 
('alice@example.com', 'Alice Smith'),
('bob@example.com', 'Bob Jones');

Core Implementation

Custom Annotation

Define metadata for cache eviction semantics:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DelayedCacheEvict {
    String cachePattern() default "";
    long delayMillis() default 500;
}

Aspect Implementation

@Aspect
@Component
@Slf4j
public class CacheConsistencyAspect {

    private final StringRedisTemplate redisTemplate;
    private final ScheduledExecutorService scheduler;

    public CacheConsistencyAspect(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.scheduler = Executors.newScheduledThreadPool(4);
    }

    @Around("@annotation(delayedCacheEvict)")
    public Object interceptMutation(ProceedingJoinPoint joinPoint, 
                                    DelayedCacheEvict delayedCacheEvict) throws Throwable {
        
        String pattern = delayedCacheEvict.cachePattern();
        long delay = delayedCacheEvict.delayMillis();
        
        // Phase 1: Immediate eviction
        evictMatchingKeys(pattern);
        log.debug("Pre-update cache purge executed for pattern: {}", pattern);

        Object result;
        try {
            // Phase 2: Execute business logic
            result = joinPoint.proceed();
        } catch (Exception ex) {
            log.error("Database mutation failed, aborting secondary eviction", ex);
            throw ex;
        }

        // Phase 3 & 4: Scheduled secondary eviction
        scheduler.schedule(() -> {
            try {
                evictMatchingKeys(pattern);
                log.debug("Post-update delayed eviction completed after {}ms", delay);
            } catch (Exception e) {
                log.error("Secondary cache eviction failed", e);
            }
        }, delay, TimeUnit.MILLISECONDS);

        return result;
    }

    private void evictMatchingKeys(String pattern) {
        if (!StringUtils.hasText(pattern)) return;
        
        ScanOptions options = ScanOptions.scanOptions()
            .match(pattern)
            .count(100)
            .build();
            
        try (Cursor<byte[]> cursor = redisTemplate.executeWithStickyConnection(
            connection -> connection.scan(options))) {
            
            while (cursor.hasNext()) {
                byte[] key = cursor.next();
                redisTemplate.delete(new String(key, StandardCharsets.UTF_8));
            }
        }
    }
}

Domain Service

@Service
@Transactional
public class AccountService {

    private final AccountRepository repository;
    private final RedisTemplate<String, Object> cacheTemplate;

    public AccountService(AccountRepository repository, 
                         RedisTemplate<String, Object> cacheTemplate) {
        this.repository = repository;
        this.cacheTemplate = cacheTemplate;
    }

    @Cacheable(value = "accountCache", key = "#identifier", unless = "#result == null")
    public AccountProfile fetchById(Long identifier) {
        return repository.findById(identifier)
            .map(this::convertToProfile)
            .orElse(null);
    }

    @DelayedCacheEvict(cachePattern = "accountCache::*", delayMillis = 600)
    public int modifyAccount(Long identifier, String newDisplayName) {
        int affected = repository.updateDisplayName(identifier, newDisplayName);
        if (affected == 0) {
            throw new EntityNotFoundException("Account not found: " + identifier);
        }
        return affected;
    }

    private AccountProfile convertToProfile(AccountEntity entity) {
        return new AccountProfile(
            entity.getId(),
            entity.getEmail(),
            entity.getDisplayName()
        );
    }
}

REST Controller

@RestController
@RequestMapping("/api/v1/accounts")
public class AccountController {

    private final AccountService service;

    public AccountController(AccountService service) {
        this.service = service;
    }

    @GetMapping("/{id}")
    public ResponseEntity<AccountProfile> retrieve(@PathVariable Long id) {
        AccountProfile profile = service.fetchById(id);
        return ResponseEntity.ok(profile);
    }

    @PatchMapping("/{id}")
    public ResponseEntity<Void> update(@PathVariable Long id, 
                                      @RequestBody UpdateRequest request) {
        service.modifyAccount(id, request.getDisplayName());
        return ResponseEntity.noContent().build();
    }
}

Concurrency Verification

Validate the pattern effectiveness through controlled interleaving:

  1. Initiate mutation request for record ID 10 via AccountController
  2. Suspend execcution immediately after initial cache eviction (breakpoint in Aspect)
  3. Concurrently submit read request for ID 10 from separate thread
  4. Observe cache miss and database read of uncommitted/old value
  5. Resume mutation thread to complete database update
  6. Wait for delayed eviction interval (600ms)
  7. Verify secondary cache purge occurred
  8. Subsequent reads fetch updated value directly from database

This demonstrates that without secondary eviction, step 4 would populate cache with stale data persisting until TTL expiration.

Tags: Spring Boot Redis aop Cache Consistency Distributed Systems

Posted on Tue, 26 May 2026 19:52:39 +0000 by zulubanshee