增删改查时如何提高Mysql与Redis的一致性

1. 查询(Read):旁路缓存模式 (Cache Aside)

查询操作是维护一致性的起点。其核心逻辑是:缓存作为数据库的影子,随需而生。

正例:延迟加载

  1. 先从缓存读取数据。
  2. 若缓存命中,直接返回。
  3. 若缓存未命中,从数据库读取,并将其写入缓存。

示例代码

java 复制代码
public DataDO getData(Long id) {
    String key = "data:" + id;
    // 1. 查缓存
    DataDO data = redis.get(key);
    if (data != null) return data;
    
    // 2. 查库并回写(注意设置过期时间防止冷数据堆积)
    data = mapper.selectById(id);
    if (data != null) {
        redis.setex(key, 30, TimeUnit.MINUTES, data);
    }
    return data;
}

2. 增加(Create):先库后球 (Database First)

对于新产生的数据,由于缓存中本就不存在,一致性维护相对简单,但必须遵守时序规则

反例:先写缓存,再写数据库

  • 现象 :先执行 redis.set(token),随后数据库 insert 抛出主键冲突或字段非空异常(如之前遇到的 expires_time 漏填)。
  • 后果:数据库事务回滚,但 Redis 写入不可逆,产生"孤儿缓存",前端持有 Token 却在库中找不到对应记录。

正例:先操作数据库,成功后再写入/删除缓存

java 复制代码
@Transactional
public void create(DataDO data) {
    // 1. 先写数据库(最重、最易失败的操作先走)
    mapper.insert(data);
    // 2. 再写/删除缓存(即使失败,后续读操作也能通过从库加载来补救)
    redis.set(key, data);
}

3. 修改(Update):先改库,再删缓存

这是缓存一致性讨论中最激烈的环节。我们不仅要掌握正确顺序,更要理解其背后的极端边界情况

在分布式一致性设计的讨论中,针对"修改(Update)"操作,最关键的抉择在于:到底是"更新"缓存,还是"删除"缓存?

以下是针对第三章的补充内容,详细说明了方案选型的底层逻辑。


3. 修改(Update):先改库,再删缓存

3.1 方案选型:为什么是"删除"而不是"更新"?

在更新数据库时,针对缓存的操作有两种理论方案:

  1. 更新缓存:每次数据库改完,就把新值写回 Redis。
  2. 删除缓存:数据库改完,直接把旧缓存删了,等下次查询时再从库里加载。

我们之所以选择"删除缓存",主要基于以下两个原因:

A. 规避并发下的数据错位

假设有两个线程 A 和 B 同时更新同一条数据,如果使用"更新缓存"方案:

  1. 线程 A 更新了数据库。
  2. 线程 B 更新了数据库。
  3. 线程 B 网络较快,先更新了缓存。
  4. 线程 A 网络较慢,后更新了缓存。
  • 后果:数据库里是 B 的新值,但缓存里却是 A 的旧值。
  • 结论:如果采用"删除缓存",无论谁先谁后,缓存最终都是空的,下次查询都会拿到数据库的最新的正确值。
B. 降低无效计算与写操作
  • 场景:某个业务字段(如商铺详情)更新非常频繁(每秒 100 次),但查询相对较少(每分钟 1 次)。
  • 更新缓存:1 分钟内会执行 6000 次 Redis 写入,其中 5999 次都是无意义的。
  • 删除缓存:1 分钟内只需执行几次删除操作,极大节省了系统资源。

3.2 方案改进:有没有其他更强的方案?

虽然"先改库,再删缓存"是主流,但在对一致性要求极高的场景下,还有两个进阶方案:

方案一:延迟双删(Cache Aside 升级版)

为了解决我们之前提到的"读写竞态导致旧值回写"的极小概率问题,可以采用:

  1. 先删除缓存。
  2. 更新数据库。
  3. 睡眠 500ms(根据业务读耗时决定)
  4. 再次删除缓存。
  • 原理:确保在数据库更新期间,那些由于并发读操作产生并试图写回缓存的"脏数据",会被第二次删除动作彻底清理。
方案二:监听 Binlog 异步同步(Canal 方案)

这是最优雅的解耦方案:

  1. 业务只管改库:程序只负责修改 MySQL。
  2. 监听 Binlog:通过 Canal 等中间件模拟成 MySQL 的从库,实时获取数据库的变化。
  3. 异步删缓存:Canal 接收到数据变动消息后,再去操作 Redis。
  • 优点:即使业务代码执行完后系统宕机,由于 Binlog 已经产生,后续的删除动作依然会由 Canal 保证执行,且不占用业务主流程的时间。

反例:先删缓存,再更新数据库

这是绝对禁止的操作,因为它会导致缓存中被永久性写入"旧数据"。

  1. 线程 A 删除了缓存。
  2. 线程 B 进来查询,发现缓存为空,从数据库读取了旧值
  3. 线程 B旧值回写进缓存。
  4. 线程 A 完成数据库更新。
  • 结果:数据库已是最新,但缓存永远停留在旧值,除非缓存过期。

正例:先更新数据库,再删除缓存

这是目前公认的推荐方案,虽然它也并非"银弹"。

场景分析:该方案的"理论漏洞"

在极端的并发竞态下,即使先改库再删缓存,也可能发生以下时序:

  1. 缓存刚好失效
  2. 线程 A(读操作) :查询缓存发现未命中,去数据库读到了旧值
  3. 线程 B(写操作) :更新数据库为新值
  4. 线程 B(写操作):执行删除缓存(此时缓存本就是空的,删除无影响)。
  5. 线程 A(读操作) :将刚才读到的旧值写入缓存。
  • 后果:此时数据库是新值,缓存是旧值。
为什么这种"不一致"在可接受范围内?

尽管存在上述漏洞,但它在实际生产中发生的概率极低,原因有二:

  • 概率极低:发生该情况的前提是"缓存刚好失效"且"读写高度并发"。
  • 速度差异:步骤 2(数据库读)和步骤 5(写入缓存)之间的耗时,通常远小于步骤 3(数据库写)和步骤 4(删除缓存)的耗时。在现实中,读操作几乎总是先于写操作完成,从而避免了旧值回写覆盖新值的尴尬。

示例代码

java 复制代码
@Transactional(rollbackFor = Exception.class)
public void updateBusiness(BusinessDO business) {
    // 1. 先操作数据库,保证持久化成功
    businessMapper.updateById(business);

    // 2. 数据库成功后,删除缓存
    // 注意:如果是重要的业务,建议配合重试机制防止删除失败
    businessRedisDAO.delete(business.getId());
}

💡 针对读者的建议

为了进一步弥补这个极小的漏洞,我们通常会配合以下手段:

  1. 给缓存设置合理的过期时间(TTL):即使发生了上述不一致,数据也会在一段时间后自动修正。
  2. 双删延迟(针对特殊场景):在修改数据库后,先删一次缓存,等待几百毫秒后再删一次,以确保那些执行慢的"旧值回写"动作被最终清理。

4. 删除(Delete):事务同步 (Transactional Sync)

删除操作要求物理数据与索引数据同步消失。

反例:缓存与数据库操作不在同一事务

  • 现象 :数据库执行 delete 成功,但因代码异常导致未执行 redis.delete
  • 后果:查询请求会持续命中缓存中的旧数据,呈现"删不掉"的假象。

正例:声明式事务保证最终一致性

java 复制代码
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
    // 1. 物理删除
    mapper.deleteById(id);
    // 2. 逻辑/物理清除缓存
    redis.delete(key);
}

讲解 :在单体系统中,利用 @Transactional 确保数据库失败时不会触发缓存删除;在分布式场景下,若缓存删除失败,需通过 MQ 重试Canal 监听 Binlog 异步清理。


5. 核心原则总结

维度 操作建议 关键理由
查询 先缓存 -> 后数据库 降低数据库压力,按需加载
增加 先写库 -> 后写缓存 防止数据库回滚导致缓存数据"虚晃一枪"
修改 先改库 -> 后删缓存 避免并发读写引发的旧数据回写覆盖新数据
删除 先删库 -> 后删缓存 保证数据的一致下线
相关推荐
打工的小王7 小时前
MySql(二)索引
数据库·mysql
数据知道7 小时前
PostgreSQL 性能优化:如何提高数据库的并发能力?
数据库·postgresql·性能优化
wengqidaifeng7 小时前
数据结构(三)栈和队列(上)栈:计算机世界的“叠叠乐”
c语言·数据结构·数据库·链表
数据知道7 小时前
PostgreSQL性能优化:内存配置优化(shared_buffers与work_mem的黄金比例)
数据库·postgresql·性能优化
静听山水7 小时前
Redis核心数据结构
数据结构·数据库·redis
流㶡8 小时前
MySQL 常用操作指南(Shell 环境)
数据库
luoluoal8 小时前
基于python的医疗问句中的实体识别算法的研究(源码+文档)
python·mysql·django·毕业设计·源码
静听山水8 小时前
Redis核心数据结构-Hash
数据结构·redis·哈希算法
数据知道8 小时前
PostgreSQL 性能优化:连接数过多的原因分析与连接池方案
数据库·postgresql·性能优化