Skip to content

分布式缓存一致性:Cache Aside / Read Through / Write Through,缓存与数据库双写一致性难题

问题

缓存和数据库双写场景下,如何保证数据一致性?Cache Aside 模式为什么推荐先更新数据库再删除缓存?Read Through 和 Write Through 有什么区别?延迟双删到底靠不靠谱?Binlog 同步方案落地会遇到什么坑?

先弄清楚:为什么会有不一致

缓存与数据库是两个独立的存储系统,单次写操作无法原子化地同时更新两个系统,必然存在一个先写、一个后写。不一致窗口就是两个操作之间的时间差。当并发请求到来时,时序交叉就会产生不一致。

不一致的根因:时序交叉

场景 A:先删缓存,再更新 DB

线程 A: 删缓存(key) → 更新 DB(user.name=新值)
线程 B:                    读缓存(miss) → 查 DB(旧值) → 写缓存(旧值)

结果:缓存是旧值,DB 是新值,不一致产生。

场景 B:先更新 DB,再更新缓存(不是删缓存)

线程 A: 更新 DB(新值) → 更新缓存(新值)
线程 B:                 更新 DB(新值') → 更新缓存(新值')

结果:缓存里可能是 A 的新值(如果 B 后写缓存),但 DB 里是 B 的新值'。如果 A 和 B 的写操作有顺序依赖,数据就乱了。

场景 C:先更新 DB,再删缓存(最推荐,但仍有窗口)

线程 A: 更新 DB(新值) → 删缓存 → [后续读拿到新值]
线程 B:                   读缓存(旧值,还没删) → 返回旧值

窗口:DB 已更新、缓存还未被删除的毫秒级间隔。窗口内读请求返回旧数据。

结论:四种模式的差别不是"有没有不一致窗口",而是窗口有多长、用什么手段兜底

四种主流模式(含原理和时序图)

1. Cache Aside(旁路缓存)

读流程读缓存 → 命中直接返回 → miss → 查 DB → 回写缓存 → 返回

写流程更新 DB → 删除缓存

为什么必须删缓存,而不是更新缓存?

面试高频题。关键原因:删缓存是幂等的,更新缓存不是

假设两个并发写:

T1: 写 DB(x=1) → 写缓存(x=1)
T2:              写 DB(x=2) → 写缓存(x=2)

如果写 DB 顺序是 T1→T2,但写缓存因网络延迟变成 T2→T1,最终缓存里是 x=1,DB 里是 x=2,不一致

删缓存就没有这个问题:不管谁先删,下次读 miss 一定会从 DB 拿最新值。

删除缓存失败怎么办?

这是我踩过的坑——有一次线上 Redis 主从切换,删除操作丢了,导致某条用户数据在缓存里过期了 3 天才恢复。三种兜底方案:

方案一:MQ 异步重试

更新 DB → 删缓存 → 失败 → 发到延迟队列 → 5s 后重试 → 最多重试 3 次

实现见下方代码。注意死信队列要设置最大重试次数,否则死循环。

方案二:延迟双删

删缓存 → 更新 DB → 延迟 500ms → 再删一次缓存

为什么是 500ms?我实测的经验值:MySQL 主从延迟一般 < 200ms,加上业务线程切换时间,500ms 足够了。但延迟双删只解决并发读回写旧数据的问题,不解决删除失败的问题。

方案三:Binlog 异步同步(Canal)

业务代码只写 DB → MySQL binlog → Canal 解析 → 刷新缓存

业务代码完全解耦,但引入 Canal 的运维成本。我见过一个团队因为 Canal 的位点(Offset)管理不当,丢了一条 binlog 导致缓存半个月没更新。

Cache Aside 的适用边界

  • 优点:简单,应用层完全控制,缓存只是加速层
  • 缺点:每次读 miss 需要回写一次缓存,存在缓存击穿风险(热点 key 大量并发 miss 时,DB 被压垮)

2. Read Through

应用层只跟缓存交互,不直接操作 DB。缓存层(如 Redis 配合 Caffeine 的 LoadingCache)在 miss 时自动从 DB 加载数据。

时序图

应用层                    缓存层                           DB
  |                         |                              |
  |--- get(key) ---------->|                              |
  |                         |-- miss, 检查是否有加载器 ---->|
  |                         |<--- 加载数据 (User) ---------|
  |                         |--- 写入缓存, 设置 TTL ------|
  |<--- 返回数据 ----------|                              |

优点:业务代码干净,不需要模板代码。缺点:缓存层必须保持高可用,挂掉整个链路不可用。且缓存层和 DB 的加载逻辑需要自己写一个 Loader 实现,常见于 Caffeine、Redis 的 cache-aside 模式封装。

真实踩坑:我见过一个项目用 Redis 的 Read Through 方案,但 Loader 里没做 DB 熔断。缓存 miss 时 DB 已经挂了,Loader 抛异常,导致熔断和超时连锁反应,整个服务雪崩。Loader 必须加 DB 熔断和降级,超时 500ms 就返回空

3. Write Through

写操作先写缓存,缓存同步写 DB。强一致性——写缓存成功 = 写 DB 成功,要么都成功要么都失败。

时序图

应用层                    缓存层                           DB
  |                         |                              |
  |--- put(key, val) ---->|                              |
  |                         |--- 写入缓存 ----------------|
  |                         |--- 同步写入 DB ------------->|
  |                         |<--- DB 写入成功 ------------|
  |<--- 返回成功 ----------|                              |

代价

  • 写延迟 = 缓存写入 + DB 写入,慢 2-3 倍
  • 缓存层需要支持事务性写入(写缓存 + 写 DB 保证原子性),大多数缓存中间件不提供
  • 自己实现事务性写入:用本地事务,先写 DB 再写缓存,但缓存不可回滚

适用场景:金融、账户系统,对一致性要求极高,但写 QPS 很低(< 1000/s)。我见过一个支付系统用 Write Through,Redis 挂了后业务直接停摆——因为缓存层不可用,所有写操作都失败。

4. Write Behind(异步写)

先写缓存,异步批量写 DB。性能最高,但数据丢失风险最大

时序图

应用层                    缓存层                           DB
  |                         |                              |
  |--- put(key, val) ---->|                              |
  |                         |--- 写入缓存, 返回成功 -----|
  |<--- 返回成功 ----------|                              |
  |                         |                              |
  |    (异步线程: 每 1s 或 1000 条批量写入)               |
  |                         |--- 批量写入 DB ------------->|
  |                         |<--- 写入成功 ---------------|

真实案例:我在之前公司用 Write Behind 做用户行为埋点,每天 10 亿 + 条事件。Redis 缓存写满 16GB 后,异步线程批量写入 DB 重启了 3 次才把数据刷完。要点:异步线程必须做持久化 checkpoint,重启时先恢复未刷盘的数据。

适用场景:日志、埋点、会话数据、实时计数等对丢失不敏感的业务。

代码示例:Cache Aside 实现(含生产级兜底)

java
@Service
public class UserCacheService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private JdbcTemplate jdbcTemplate;
    @Autowired
    private RabbitTemplate rabbitTemplate;

    private static final long CACHE_TTL_SECONDS = 300;  // 5 分钟兜底
    private static final int MAX_RETRY = 3;

    /**
     * 读:Cache Aside 模式 + 缓存击穿保护
     */
    public User getUser(Long userId) {
        String cacheKey = "user:" + userId;
        // 1. 查缓存
        String cacheJson = redisTemplate.opsForValue().get(cacheKey);
        if (cacheJson != null) {
            return JSON.parseObject(cacheJson, User.class);
        }
        // 2. 缓存 miss,加分布式锁防止缓存击穿(热点 key 场景必须)
        String lockKey = "lock:user:" + userId;
        String lockValue = UUID.randomUUID().toString();
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, 3, TimeUnit.SECONDS);
        if (Boolean.TRUE.equals(locked)) {
            try {
                // 3. double check:锁等到了之后可能别人已经写了缓存
                cacheJson = redisTemplate.opsForValue().get(cacheKey);
                if (cacheJson != null) {
                    return JSON.parseObject(cacheJson, User.class);
                }
                // 4. 查 DB(设置超时,防止 DB 慢查询拖垮整个线程池)
                User user = jdbcTemplate.queryForObject(
                    "SELECT * FROM user WHERE id = ?",
                    new BeanPropertyRowMapper<>(User.class), userId);
                if (user != null) {
                    // 5. 回写缓存,设置过期时间,加随机偏移防雪崩
                    long ttl = CACHE_TTL_SECONDS + ThreadLocalRandom.current().nextInt(60);
                    redisTemplate.opsForValue().set(cacheKey,
                        JSON.toJSONString(user), ttl, TimeUnit.SECONDS);
                }
                return user;
            } finally {
                // 6. 释放锁(只释放自己的锁)
                String script = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
                redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
                    Collections.singletonList(lockKey), lockValue);
            }
        } else {
            // 7. 没抢到锁,自旋 100ms 重试,最多 3 次
            for (int i = 0; i < 3; i++) {
                Thread.sleep(100);
                cacheJson = redisTemplate.opsForValue().get(cacheKey);
                if (cacheJson != null) {
                    return JSON.parseObject(cacheJson, User.class);
                }
            }
            // 兜底:直接查 DB(虽然 DoS 了 DB,但总比返回 null 好)
            return jdbcTemplate.queryForObject(
                "SELECT * FROM user WHERE id = ?",
                new BeanPropertyRowMapper<>(User.class), userId);
        }
    }

    /**
     * 写:先更新 DB,再删除缓存 + 异步重试兜底
     */
    public void updateUser(Long userId, User newData) {
        // 1. 先更新数据库
        jdbcTemplate.update(
            "UPDATE user SET name = ?, email = ? WHERE id = ?",
            newData.getName(), newData.getEmail(), userId);

        // 2. 删除缓存
        String cacheKey = "user:" + userId;
        try {
            redisTemplate.delete(cacheKey);
        } catch (Exception e) {
            // 3. 删除失败,发到 MQ 死信队列,延迟 5s 重试,最多 3 次
            rabbitTemplate.convertAndSend("cache.dlx", cacheKey, message -> {
                message.getMessageProperties().setDelay(5000);
                message.getMessageProperties().setHeader("x-retry-count", 0);
                return message;
            });
        }
    }

    /**
     * 延迟双删实现
     */
    public void updateUserWithDelayDelete(Long userId, User newData) {
        String cacheKey = "user:" + userId;
        // 1. 第一次删除
        redisTemplate.delete(cacheKey);
        // 2. 更新 DB
        jdbcTemplate.update(
            "UPDATE user SET name = ?, email = ? WHERE id = ?",
            newData.getName(), newData.getEmail(), userId);
        // 3. 延迟 500ms 再删一次(用 ScheduledExecutorService 或 MQ 延迟队列)
        scheduledExecutor.schedule(() -> {
            redisTemplate.delete(cacheKey);
        }, 500, TimeUnit.MILLISECONDS);
    }
}

生产级兜底链

更新 DB → 删缓存 → 失败 → MQ 延迟队列 → 5s 重试

重试 3 次仍失败 → 报警 → 人工介入

缓存过期 → 5 分钟后自然恢复(兜底)

完整对比表

方案一致性不一致窗口性能实现复杂度适用场景耦合度
Cache Aside(先更新 DB 再删缓存)最终一致毫秒级(缓存删除前)大部分业务场景低,缓存只做加速
Cache Aside(延迟双删)最终一致更短,但仍有对一致性要求稍高的场景
Read Through最终一致毫秒级应用层想屏蔽缓存逻辑中,缓存层有加载器
Write Through强一致低(写延迟 2-3 倍)金融、账户等强一致场景高,缓存不可用业务不可用
Write Behind(异步写)弱一致秒级/分钟级最高日志、埋点、延迟不敏感中,丢数据风险需接受
Binlog 同步(Canal)最终一致秒级(binlog 延迟)业务代码完全解耦低,但需运维 Canal 组件

面试高频题

Q1:为什么不直接用缓存更新,而是先删缓存?

上面已经分析过。根本原因:更新缓存是非幂等操作,并发写时 DB 和缓存可能不一致。删除缓存是幂等的,下次读 miss 从 DB 拿最新值,天然保证一致性。

Q2:延迟双删的延迟时间设多少合适?

500ms 是经验值,不是标准答案。需要根据业务场景调整:

  • 主从延迟大 → 设 1-2s
  • 高并发场景 → 设 100-200ms
  • 更稳妥方案:不用固定延迟,在异步线程里轮询 DB 版本号,版本号变化了再删缓存

Q3:删除缓存一直失败怎么办?

不要无限重试。我的方案:

  1. 重试 3 次
  2. 都失败 → 发报警(钉钉/飞书机器人)
  3. 缓存过期兜底(5 分钟自动恢复)
  4. 人工介入查 Redis 是否异常

Q4:Binlog 同步方案会丢数据吗?

。Canal 的位点管理在一些极端场景下(如 Canal 重启时 MySQL 主从切换)可能丢失 binlog。缓解措施

  • 位点持久化到 ZK 或本地文件
  • 定期人工对比 DB 和缓存的数据量差异
  • 设置缓存过期时间兜底(即使同步丢了,过期后自动恢复)

核心原则

永远不要追求缓存与数据库的强一致性,那不现实。 加一个合理的缓存过期时间作为兜底(比如 5 分钟),即使出现不一致,过期后自动恢复。业务代码里只需要做到"尽可能减少不一致窗口",而不是"消除不一致"。

面试时能说清楚两件事就够了:第一,不一致窗口怎么产生的(时序图);第二,你的兜底手段是什么(MQ 重试 + 过期兜底 + 报警)。

参考:DDIA 第 5 章(Replication)和第 12 章(Consistency)对缓存一致性问题有深入剖析;《Redis 设计与实现》第 3 章关于缓存淘汰策略。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。