Redis与数据库双写一致性详解

Redis 与数据库双写一致性(详细实战版)

目标:把 "为什么不一致" → "有哪些方案" → "各自怎么实现" → "选型建议" 一次讲清。

适用场景:MySQL + Redis (但思想对所有缓存都通用)。

结论先行:100% 强一致在高并发下基本不可取,工程上追求"最终一致 + 可控窗口 + 可兜底"


目录

  • [1. 什么是双写一致性问题](#1. 什么是双写一致性问题)
  • [2. 不一致到底是怎么产生的](#2. 不一致到底是怎么产生的)
  • [3. 四种主流解决思路总览](#3. 四种主流解决思路总览)
  • [4. 方案一:Cache Aside(旁路缓存,最主流)](#4. 方案一:Cache Aside(旁路缓存,最主流))
  • [5. 方案二:延迟双删(工程补强版)](#5. 方案二:延迟双删(工程补强版))
  • [6. 方案三:基于 Binlog / MQ 的最终一致](#6. 方案三:基于 Binlog / MQ 的最终一致)
  • [7. 方案四:强一致方案(不推荐但你要知道)](#7. 方案四:强一致方案(不推荐但你要知道))
  • [8. 写多读多场景的组合拳](#8. 写多读多场景的组合拳)
  • [9. 常见错误方案(踩坑清单)](#9. 常见错误方案(踩坑清单))
  • [10. 选型决策表](#10. 选型决策表)
  • [11. 面试 / 设计题标准回答模板](#11. 面试 / 设计题标准回答模板)

1. 什么是双写一致性问题

双写一致性 = 数据同时存在于:

  • 数据库(MySQL,强一致、持久化)
  • 缓存(Redis,高性能、非强一致)

问题核心:

一次更新,需要写 DB + 写 Cache,但这两个操作不在同一个原子事务里。


2. 不一致到底是怎么产生的

假设一次"更新用户信息":

2.1 经典错误顺序:先写缓存,再写数据库 ❌

复制代码
写缓存成功
↓
写数据库失败

结果:

  • 缓存是新数据
  • DB 是旧数据
    👉 缓存脏读

2.2 看似正确但仍有坑:先写 DB,再删缓存 ⚠️

并发场景:

复制代码
线程 A:更新数据
线程 B:读取数据

时间线:

复制代码
A:UPDATE DB (成功)
B:GET cache(miss)
B:SELECT DB(读到旧数据)
B:SET cache(旧数据)
A:DEL cache

结果:

  • 缓存里又被写回了旧数据
    👉 经典并发不一致

3. 四种主流解决思路总览

思路 一致性 复杂度 是否主流
Cache Aside 最终一致 ⭐⭐⭐⭐⭐
延迟双删 最终一致(更稳) ⭐⭐⭐⭐
Binlog/MQ 同步 最终一致 ⭐⭐⭐⭐
强一致(分布式锁/事务) 极高

4. 方案一:Cache Aside(旁路缓存,最主流)

4.1 核心思想

  • :先读缓存,miss 再读 DB,写回缓存
  • :只写 DB,然后 删除缓存

口诀:"写 DB,删缓存;读缓存,miss 查 DB"


5. 方案二:延迟双删(工程补强版)

text 复制代码
1. DEL cache
2. UPDATE DB
3. sleep(200~1000ms)
4. DEL cache

6. 方案三:基于 Binlog / MQ 的最终一致

  • DB 是唯一真相源
  • 缓存由 binlog 驱动更新
  • 常见:Canal / Debezium + MQ

7. 方案四:强一致方案(不推荐)

  • 分布式锁
  • 分布式事务
  • 吞吐量和复杂度都不可接受

Spring Boot 的"代码级落地":Cache Aside + 延迟双删 + 防击穿互斥重建 +(可选)binlog/MQ 兜底思路。
我用 MySQL + MyBatis-Plus + Redis(StringRedisTemplate) + Redisson 举例(你也可以只用 StringRedisTemplate,Redisson主要用来做分布式锁更省事)

1)依赖(Maven)

xml 复制代码
<!-- Redis -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- MyBatis Plus(你项目大概率有) -->
<dependency>
  <groupId>com.baomidou</groupId>
  <artifactId>mybatis-plus-boot-starter</artifactId>
  <version>3.5.7</version>
</dependency>

<!-- Redisson(做分布式锁 / singleflight) -->
<dependency>
  <groupId>org.redisson</groupId>
  <artifactId>redisson-spring-boot-starter</artifactId>
  <version>3.30.0</version>
</dependency>

<!-- JSON(任选一个,你项目可能用 Jackson) -->
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
</dependency>

2)Key 设计 & TTL 约定

用户缓存 key:user:profile:{id}

空值占位 key(防穿透):同一个 key 存 "NULL",TTL 更短(比如 30s)

正常 TTL:比如 10 分钟 + 随机抖动(避免雪崩)

3)Redis 操作封装(含空值占位)

java 复制代码
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;

@Component
@RequiredArgsConstructor
public class CacheClient {
    private final StringRedisTemplate redis;
    private final ObjectMapper objectMapper;

    public static final String NULL_VAL = "__NULL__";

    public <T> T get(String key, Class<T> clazz) {
        String v = redis.opsForValue().get(key);
        if (v == null) return null;
        if (NULL_VAL.equals(v)) return null; // 空值占位
        try {
            return objectMapper.readValue(v, clazz);
        } catch (Exception e) {
            throw new RuntimeException("Cache deserialize error, key=" + key, e);
        }
    }

    public void setJson(String key, Object value, Duration ttl) {
        try {
            String json = objectMapper.writeValueAsString(value);
            redis.opsForValue().set(key, json, ttl);
        } catch (Exception e) {
            throw new RuntimeException("Cache serialize error, key=" + key, e);
        }
    }

    public void setNull(String key, Duration ttl) {
        redis.opsForValue().set(key, NULL_VAL, ttl);
    }

    public void del(String key) {
        redis.delete(key);
    }

    public Duration ttlWithJitterSeconds(long baseSeconds, long jitterSeconds) {
        long add = ThreadLocalRandom.current().nextLong(0, Math.max(1, jitterSeconds + 1));
        return Duration.ofSeconds(baseSeconds + add);
    }
}

4)读:Cache Aside + 防击穿互斥重建(single flight)

思路:

先查缓存

miss 就抢锁(只有一个线程回源 DB + 回填缓存)

没抢到锁的线程稍等再读缓存(避免打 DB)

java 复制代码
import lombok.RequiredArgsConstructor;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;

import java.time.Duration;

@Service
@RequiredArgsConstructor
public class UserQueryService {

    private final CacheClient cache;
    private final RedissonClient redisson;
    private final UserMapper userMapper; // MyBatis-Plus mapper

    private String cacheKey(Long userId) {
        return "user:profile:" + userId;
    }

    public UserDO getUser(Long userId) {
        String key = cacheKey(userId);

        // 1) 先读缓存
        UserDO cached = cache.get(key, UserDO.class);
        if (cached != null) return cached;

        // 2) 防击穿:加互斥锁(同一个 key 一个锁)
        String lockKey = "lock:" + key;
        RLock lock = redisson.getLock(lockKey);

        boolean locked = false;
        try {
            // waitTime=50ms:等一下别人释放锁
            // leaseTime=2s:防止死锁(业务要保证回源不超过这个时间)
            locked = lock.tryLock(50, 2_000, java.util.concurrent.TimeUnit.MILLISECONDS);

            if (!locked) {
                // 3) 没抢到锁:短暂 sleep,然后再读缓存(大多数情况下别人已经回填)
                Thread.sleep(30);
                return cache.get(key, UserDO.class); // 可能仍然 null,业务自行处理
            }

            // 4) 双重检查:拿到锁后再读一次缓存(避免重复回源)
            UserDO again = cache.get(key, UserDO.class);
            if (again != null) return again;

            // 5) 回源 DB
            UserDO db = userMapper.selectById(userId);

            // 6) 回填缓存(空值也要缓存,防穿透)
            if (db == null) {
                cache.setNull(key, Duration.ofSeconds(30));
                return null;
            }

            Duration ttl = cache.ttlWithJitterSeconds(600, 60); // 10min + 0~60s
            cache.setJson(key, db, ttl);
            return db;

        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            if (locked) {
                lock.unlock();
            }
        }
    }
}

5)写:更新 DB + 删除缓存(标准 Cache Aside)

最关键:先写 DB(事务提交后)再删缓存

如果你在事务里就删缓存,事务回滚会更乱。

5.1 推荐做法:事务提交后删缓存(最稳)

用 Spring 的 TransactionSynchronization 注册 afterCommit 回调:

java 复制代码
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Service
@RequiredArgsConstructor
public class UserCommandService {

    private final CacheClient cache;
    private final UserMapper userMapper;

    private String cacheKey(Long userId) {
        return "user:profile:" + userId;
    }

    @Transactional
    public void updateUserName(Long userId, String newName) {
        // 1) 更新 DB
        UserDO u = new UserDO();
        u.setId(userId);
        u.setName(newName);
        userMapper.updateById(u);

        // 2) 事务提交后删缓存(afterCommit)
        String key = cacheKey(userId);
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                cache.del(key);
            }
        });
    }
}

6)写加强:延迟双删(解决"并发读把旧值回写")

在 afterCommit 里做两次删除:一次立刻删,一次延迟删。

延迟删别用 Thread.sleep() 卡线程,扔到线程池或定时器。

java 复制代码
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.concurrent.ScheduledFuture;

@Component
@RequiredArgsConstructor
public class DelayedCacheEvictor {
    private final CacheClient cache;
    private final ThreadPoolTaskScheduler scheduler;

    public void doubleDelete(String key, Duration delay) {
        cache.del(key); // 第一次删(立即)

        scheduler.schedule(
            () -> cache.del(key), // 第二次删(延迟)
            java.util.Date.from(java.time.Instant.now().plus(delay))
        );
    }
}

线程池配置:

java 复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

@Configuration
public class SchedulerConfig {
    @Bean
    public ThreadPoolTaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler s = new ThreadPoolTaskScheduler();
        s.setPoolSize(2);
        s.setThreadNamePrefix("cache-evict-");
        s.initialize();
        return s;
    }
}

写服务改为:

java 复制代码
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.time.Duration;

@Service
@RequiredArgsConstructor
public class UserCommandService2 {

    private final UserMapper userMapper;
    private final DelayedCacheEvictor evictor;

    private String cacheKey(Long userId) {
        return "user:profile:" + userId;
    }

    @Transactional
    public void updateUserName(Long userId, String newName) {
        UserDO u = new UserDO();
        u.setId(userId);
        u.setName(newName);
        userMapper.updateById(u);

        String key = cacheKey(userId);
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                evictor.doubleDelete(key, Duration.ofMillis(500));
            }
        });
    }
}

7)更硬核兜底:binlog/MQ 异步删缓存(可选)

如果你已经有 Canal/Debezium + MQ:

业务写 DB 后不用管缓存(或只做一次删)

下游消费 binlog 事件:按主键删 user:profile:{id}

最终一致更强、业务更干净

关键点:消费者要幂等(删缓存天然幂等)。

8. 写多读多场景的组合拳

  • Cache Aside
  • TTL 兜底
  • 延迟双删
  • 热点 key 互斥重建

9. 常见错误方案

❌ 先写缓存再写 DB

❌ 更新缓存而不是删除

❌ 缓存永不过期


10. 选型决策表

场景 推荐方案
中小系统 Cache Aside
高并发 Cache Aside + 延迟双删
多系统 Binlog + MQ

11. 一句话总结

一致性以 DB 为准,Redis 只解决性能问题。

相关推荐
Data_agent2 小时前
京东商品价格历史信息API使用指南
java·大数据·前端·数据库·python
weixin_445476682 小时前
线上问题排查记录——MySQL 子查询报错 “Subquery returns more than 1 row” 问题总结
数据库·mysql
学习编程的Kitty2 小时前
Redis(2)——事务
数据库·redis·缓存
小波小波轩然大波2 小时前
mysql技术
数据库·mysql
阿方索2 小时前
MySQL
数据库·mysql
蓝影铁哥2 小时前
浅谈国产数据库OceanBase
java·linux·数据库·oceanbase
JosieBook2 小时前
【大模型】用 AI Ping 免费体验 GLM-4.7 与 MiniMax M2.1:从配置到实战的完整教程
数据库·人工智能·redis
shuair3 小时前
redis缓存双写
redis·缓存·mybatis
weixin_425023003 小时前
MybatisPlusJoin 完整样例
java·数据库·sql