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:
- Initial Eviction: Remove cache entry immediately
- Database Mutation: Execute transactional update
- Deferred Pause: Wait for transaction propagation (typically 500ms, tunable per SLA)
- 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:
- Initiate mutation request for record ID 10 via AccountController
- Suspend execcution immediately after initial cache eviction (breakpoint in Aspect)
- Concurrently submit read request for ID 10 from separate thread
- Observe cache miss and database read of uncommitted/old value
- Resume mutation thread to complete database update
- Wait for delayed eviction interval (600ms)
- Verify secondary cache purge occurred
- 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.