Ensuring DB‑Redis Consistency with a Delayed Double‑Delete Strategy in Spring Boot

The article explains how concurrent database updates can cause Redis cache inconsistency, introduces the delayed double‑delete solution, details each step of the algorithm, provides full Spring Boot AOP implementation code, demonstrates verification with test cases, and shares the complete project repository.

Architect
Architect
Architect
Ensuring DB‑Redis Consistency with a Delayed Double‑Delete Strategy in Spring Boot

Business Scenario

In a multithreaded environment two concurrent requests modify the database and then cascade updates to Redis. Interleaving A→C→D→B can leave Redis with stale data while the database has already been updated.

Atomic operations across threads may interleave.

Problem

When request A updates the database and later request C updates the same row, the Redis entry written by A becomes inconsistent with the database. Subsequent reads that hit Redis return stale data.

Solution – Delayed Double‑Delete

Popular strategy for read‑heavy data where writes are infrequent. The steps are:

Delete cache keys that match the affected method.

Execute the database update.

Wait a configurable delay (e.g., 500 ms) to ensure the DB transaction completes.

Delete the same cache keys again.

The first delete prevents a read from seeing the old value before the DB update; the second delete removes any value that might have been written by a concurrent read during the delay, guaranteeing consistency.

Implementation

Dependencies (Maven)

<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>

Custom annotation

@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target(ElementType.METHOD)
public @interface ClearAndReloadCache {
    String name() default "";
}

Aspect

@Aspect
@Component
public class ClearAndReloadCacheAspect {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Pointcut("@annotation(com.pdh.cache.ClearAndReloadCache)")
    public void pointCut() {}

    @Around("pointCut()")
    public Object aroundAdvice(ProceedingJoinPoint pjp) {
        MethodSignature ms = (MethodSignature) pjp.getSignature();
        Method method = ms.getMethod();
        ClearAndReloadCache ann = method.getAnnotation(ClearAndReloadCache.class);
        String name = ann.name();

        // first delete
        Set<String> keys = stringRedisTemplate.keys("*" + name + "*");
        stringRedisTemplate.delete(keys);

        Object result = null;
        try {
            result = pjp.proceed();
        } catch (Throwable t) {
            t.printStackTrace();
        }

        // delayed second delete
        new Thread(() -> {
            try {
                Thread.sleep(1000); // example delay, adjust per business
                Set<String> keys2 = stringRedisTemplate.keys("*" + name + "*");
                stringRedisTemplate.delete(keys2);
                System.out.println("Delayed delete completed");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        return result;
    }
}

Application configuration (application.yml)

server:
  port: 8082

spring:
  redis:
    host: localhost
    port: 6379
  cache:
    redis:
      time-to-live: 60000   # 60 s
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/test
    username: root
    password: 1234
  mybatis-plus:
    mapper-locations: classpath*:com/pdh/mapper/*.xml
    global-config:
      db-config:
        table-prefix:
    configuration:
      log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
      map-underscore-to-camel-case: true

SQL script (user_db.sql)

DROP TABLE IF EXISTS `user_db`;
CREATE TABLE `user_db` (
  `id` int(4) NOT NULL AUTO_INCREMENT,
  `username` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8;

INSERT INTO `user_db` VALUES
(1,'张三'),(2,'李四'),(3,'王二'),(4,'麻子'),(5,'王三'),(6,'李三');

Controller

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/get/{id}")
    @Cache(name = "get method")
    public Result get(@PathVariable("id") Integer id) {
        return userService.get(id);
    }

    @PostMapping("/updateData")
    @ClearAndReloadCache(name = "get method")
    public Result updateData(@RequestBody User user) {
        return userService.update(user);
    }

    @PostMapping("/insert")
    public Result insert(@RequestBody User user) {
        return userService.insert(user);
    }

    @DeleteMapping("/delete/{id}")
    public Result delete(@PathVariable("id") Integer id) {
        return userService.delete(id);
    }
}

Service (core methods)

@Service
public class UserService {

    @Resource
    private UserMapper userMapper;

    public Result get(Integer id) {
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(User::getId, id);
        User user = userMapper.selectOne(wrapper);
        return Result.success(user);
    }

    public Result insert(User user) {
        int cnt = userMapper.insert(user);
        return cnt > 0 ? Result.success(cnt) : Result.fail(888, "DB insert failed");
    }

    public Result delete(Integer id) {
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(User::getId, id);
        int cnt = userMapper.delete(wrapper);
        return cnt > 0 ? Result.success(cnt) : Result.fail(888, "DB delete failed");
    }

    public Result update(User user) {
        int cnt = userMapper.updateById(user);
        return cnt > 0 ? Result.success(cnt) : Result.fail(888, "DB update failed");
    }
}

Test Verification

Insert a new record with id=10.

First GET /user/get/10 loads data from the database and stores it in Redis.

Subsequent reads hit Redis (verified by Redis client output).

While a thread updates the username for id=10, another thread reads the same key before the delayed second delete; the read returns the stale value, demonstrating inconsistency.

After the delayed second delete (example 1 s), the Redis entry is removed; all reads fetch the fresh database state.

Project Repository

https://gitee.com/jike11231/redisDemo.git
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

aopBackend DevelopmentredisCache ConsistencySpringBootDelayed Delete
Architect
Written by

Architect

Professional architect sharing high‑quality architecture insights. Topics include high‑availability, high‑performance, high‑stability architectures, big data, machine learning, Java, system and distributed architecture, AI, and practical large‑scale architecture case studies. Open to ideas‑driven architects who enjoy sharing and learning.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.