Redis 和 MySQL双写一致性的更新策略有哪些?常见面试题深度解答。
目录一. 业务数据查询更新顺序简要分析二. 更新数据库、查询数据库、更新缓存、查询缓存耗时对比2.1 更新数据库最慢2.2 查询数据库较慢2.3 更新缓存次快2.4 查询缓存最快三. 数据一致性更新策略举例说明3.1 先更新数据库再更新缓存3.2 先更新缓存再更新数据库3.3 先删除缓存再更新数据库3.4 先更新数据库再删除缓存3.5 方案对比与选择3.5.1 最好先操作数据库后操作缓存3.5.2 最好删除缓存而不是更新缓存3.5.3 具体场景具体分析四. 低频修改数据场景 的 推荐解决方案五. 高频修改数据场景 的 推荐解决方案5.1. canal 入门5.1.1 canal 简介5.1.2 canal 下载和配置修改5.1.3 canal 运行和确认5.2 MySQL 配置5.2.1 windows 环境配置修改5.2.2 windows 环境配置生效验证5.2.3 创建 canal 所需要的数据库权限5.3 Binlog 监听 消息队列 流程图简要分析5.4 代码实例5.4.1. 业务服务更新数据库5.4.2. Canal 客户端监听 Binlog5.4.3. 缓存同步服务删除缓存 失败入队5.4.4. 消息队列消费者重试删除六. 资金账户类敏感数据 的 推荐解决方案七. 面试题合集RedisMySQL 双写一致性主要是指在使用缓存和数据库的同时存储数据时如果在高并发的场景下二者可能存在数据不一致的情况因此希望尽量保证 Redis 中的数据和 MySQL 中的数据尽可能保持一致。一. 业务数据查询更新顺序简要分析如下图所示最左侧是我们的 Java 程序最右侧是数据库MySQL中间这一层就是缓存 Redis。在实际业务数据查询过程中用户访问网站数据通常会发送查询请求通常分为以下三步。情况一先查询 Redis如果 Redis 有数据直接返回情况二先查询 Redis但是 Redis 无数据MySQL 有数据再去查询MySQL然后返回数据同时将数据回写到 Redis 以便于下次查询情况三先查询 Redis但是 Redis 无数据、再去查询 MySQL但是MySQL也没有数据返回空。查询数据没什么影响关键在于更新数据操作如果要更新数据库那么缓存也要更新。这里就会有一个问题先动缓存还是先动数据库缓存时更新缓存较好还是删除缓存较好由此而来就引申出了MySQLredis 更新策略的四种情况。情况一先更新数据库再更新缓存情况二先更新缓存再更新数据库情况三先删除缓存再更新数据库情况四先更新数据库再删除缓存二.更新数据库、查询数据库、更新缓存、查询缓存耗时对比2.1 更新数据库最慢操作逻辑写入磁盘如 MySQL 的UPDATE需保证 ACID 特性。耗时范围毫秒级到秒级简单更新约 10~100ms复杂事务或高并发下更慢。关键瓶颈事务提交需写事务日志如 Redo Log、刷盘fsync和同步副本主从架构。锁开销行锁、间隙锁等可能阻塞其他操作尤其在并发场景。索引维护更新可能触发 B 树分裂、索引重建等额外开销。2.2查询数据库较慢操作逻辑从磁盘如 MySQL读取数据可能涉及索引扫描、锁等待或复杂查询。耗时范围毫秒级简单主键查询约 1~10ms复杂查询可达 100ms。关键瓶颈磁盘 I/O随机读性能远低于内存机械硬盘约 1ms/次SSD 约 0.1ms/次。锁竞争若查询涉及行锁或表锁可能因事务冲突增加等待时间。网络延迟应用层与数据库分离时需叠加网络 RTT通常 0.1~1ms。2.3更新缓存次快操作逻辑写入内存如 Redis 的SET或DEL。耗时范围微秒级Redis 单次写操作约 0.1~0.5ms。关键差异写操作可能触发内存分配、序列化或淘汰策略如 LRU略慢于读操作。若开启持久化如 AOF写入需追加日志但通常异步执行不影响主线程。2.4查询缓存最快操作逻辑直接从内存如 Redis读取数据无磁盘 I/O 或复杂计算。耗时范围微秒级Redis 单次读操作约 0.1ms 内。关键优势内存操作无物理寻址延迟。单线程模型如 Redis避免锁竞争响应稳定。不难看出操作缓存的耗时在操作数据库耗时前几乎约等于没有所以下面我们重点对比线程之间对于数据库操作的耗时即可了解了这一点我们再往下来探究一致性更新策略的对比。三. 数据一致性更新策略举例说明常见的四种更新策略为先更新数据库再更新缓存、先更新缓存再更新数据库、先删除缓存再更新数据库、先更新数据库再删除缓存。假设AB两个线程同时发起调用。线程A固定为写操作线程B可能是读也可能是写操作。数据库商品表 product 现在商品数量 number 为1003.1 先更新数据库再更新缓存正常逻辑1A update mysql 902A update redis 903B update mysql 804B update redis 80多线程情况下AB会有快有慢可能出现如下异常逻辑1A update mysql 902B update mysql 803B update redis 804A update redis 90A 更新数据库在准备写入缓存时B先更新了数据库并将数据写入缓存然后A又完成了数据库的更新。最终结果导致MySQL值为80Redis 值为90数据不一致。造成这种情况需要的条件(不考虑网络延迟)如果线程B为写操作则需要线程A写入缓存的耗时(0.1~0.5ms) 线程B更新数据(10~100ms)更新缓存的耗时(0.1~0.5ms)如果线程B为读操作则需要线程A写入缓存的耗时要(0.1~0.5ms) 线程B查询缓存的耗时(0.1ms 内)考虑实际业务中读操作往往比写操作多总的来说先更新数据库再更新缓存导致数据不一致这种情况大概率会发生。3.2 先更新缓存再更新数据库正常逻辑1A update redis 902A update mysql 903B update redis 804B update mysql 80异常逻辑1A update redis 902B update redis 803B update mysql 804A update mysql 90A 更新缓存然后更新数据库但是在更新数据库期间B先来更新了缓存和数据库然后A有更新成功了数据库。最终结果导致MySQL值为 90Redis 值为80数据不一致。造成这种情况需要的条件若线程B为写操作则需要线程A更新数据库耗时(10~100ms) 线程B更新数据库耗时(10~100ms)更新缓存耗时(0.1~0.5ms)若线程B为读操作则需要线程A更新数据库耗时(10~100ms) 线程B查询缓存耗时(0.1ms 内)考虑实际业务中读操作往往比写操作多总的来说先更新缓存再更新数据库导致数据不一致这种情况大概率会发生。3.3 先删除缓存再更新数据库多线程举例AB两个线程同时操作可能出现的问题1A delete redis 1002B get number from redis值为空B get 100 from mysql3B set 100 redis4A update mysql 80A线程先删除 redis 的数据然后再去更新数据库进行写操作但是由于A的写操作慢或网络延迟导致还未写成功B线程来读数据发现缓存未命中又去数据库读数据B在读取到就数据之后返回并将旧数据 number 重新写入 缓存 redis。B操作做完一切之后A线程完成了写操作此时 mysql 的新数据80与 redis 的旧数据100不一致数据不一致。我们来分析一下这种情况出现的条件若线程B为写请求需要线程A更新数据库耗时(10~100ms) 需要线程B更新数据库耗时(10~100ms)若线程B为读请求需要线程A更新数据库耗时(10~100ms) 需要线程B查询数据库耗时(1~10ms) 线程B写入缓存耗时(0.1~0.5ms)考虑实际业务中读操作往往比写操作多总的来说先删除缓存再更新数据库导致数据不一致这种情况大概率会发生。3.4 先更新数据库再删除缓存1A update mysql 802B get 100 from redis3A delete redis举例A线程先去更新数据库100变为80缓存先不动然后A再去删除缓存但是B来了读取到了缓存中的100直接返回A 完成了删除缓存的操作。不难看出这种情况在A线程成功删除缓存之前也会造成短时间内的脏数据。我们来分析一下这种情况出现的条件若线程B为写请求需要线程A更新缓存耗时(0.1~0.5ms) 需要线程B更新数据库耗时(10~100ms)若线程B为读请求需要线程A删除缓存耗时(0.1~0.5ms) 需要线程B查询缓存耗时(0.1ms 内)考虑实际业务中读操作往往比写操作多总的来说先更新数据库再删除缓存导致数据不一致这种情况大概率会发生。3.5 方案对比与选择从上面的四种情况并不难看出不管我们选择哪一种缓存数据不一致的情况大概率都会发生。那么我们到底应该选择哪一种策略呢3.5.1 最好先操作数据库后操作缓存其实就单纯数据库支持事务这一条而言我们就应该先操作数据库因为如果数据库更新失败可以进行事务回滚或者程序重试。此时我们还尚未操作缓存不管是更新缓存还是删除缓存都还未进行不会对后续其它读写线程造成影响但如果我们先操作缓存一旦数据库更新失败就会导致后续其他线程进行缓存重建浪费时间和性能做无用功。下面是先操作数据库后操作缓存的几个优点。1降低脏数据风险若先删除缓存再更新数据库在数据库更新完成前若有并发请求查询数据会因为缓存缺失读取数据库的旧值并重新写入缓存导致缓存中保留旧数据。若先更新数据库再删除缓存即使缓存删除失败缓存中的旧数据也只会短暂存在(下次查询触发缓存重建会自动替换为新值)并且数据库已更新为最新数据最终一致性可控。2减少不一致的窗口时间假设更新数据库为10ms删除缓存耗时2ms若先删除缓存再更新数据库数据不一致窗口期为10ms数据库更新期间但若是先更新数据库再删除缓存数据不一致窗口期仅为2ms删除缓存期间3异常处理的容错性数据库操作通常支持事务若更新数据库失败可以回滚此时还未删除缓存不会引入错误。可如果先删除缓存但数据库更新失败此时缓存已丢失后续的请求会穿透到数据库并且数据库未更新成功还会导致缓存重建(额外处理)相对来说耗时间。4避免高并发下的双写覆盖在高并发场景下若A线程先删除缓存B线程在A线程更新数据库前查询旧值并写入缓存可能导致缓存与数据库长期不一致。但如果先更新数据库后删除缓存即使线程B先读取到旧值旧缓存值也会在线程A更新数据库操作完成后被清除顶多造成短时间内数据不一致一旦后续又有新请求就会触发缓存重建将新数据写入缓存。3.5.2 最好删除缓存而不是更新缓存其实我们也可以用懒加载这一层面来理解这个问题更新数据库是要比删除数据库更耗费性能的并且更新的数据不一定会马上被访问既然如此不如不做等待其它读操作在需要的时候再来进行缓存重建即可这样既提高性能还提高了程序的运行效率。下面是删除缓存相对于更新缓存的优点。1避免并发写入导致脏数据更新缓存若线程A更新数据库后未完成更新缓存由于时序或网络延迟线程B先完成了更新数据库和缓存线程A再写入缓存时会导致缓存仍是旧值(期望是B修改后的值)删除缓存无论更新顺序如何删除缓存强制要求下一次查询加载数据库的最新值天然规避了上面线程顺序带来的脏数据影响。2降低计算资源的浪费更新缓存每次更新数据库都需要重新计算并写入缓存可能浪费资源(例如数据被频繁被更新但很少被读取)删除缓存仅在数据集是被查询时重建缓存按需使用资源。3防止部分更新导致的数据不一致更新缓存在遇到复杂缓存的数据结构时例如HashList若只更新部分字段可能因为代码的逻辑错误或网络终端导致缓存数据与数据库不一致。删除缓存直接删除整个键下次查询数据时重建完整数据避免部分更新风险。4简化异常处理更新缓存若缓存更新失败需回滚数据库事务或重试缓存写入增加系统复杂性。删除缓存若缓存删除失败可通过异步补偿机制(如消息队列)即使未删除成功旧数据也仅短暂存在。3.5.3 具体场景具体分析没有哪个方案是最好的实际开发过程中都是需要根据项目的实际情况来进行选择的。对于数据的操作无非就是读和写。读操作暂不关心高频写和低频写使用的方案一般是略有变差的如下表格所示。场景推荐方案一致性级别实现复杂度低频修改数据缓存过期 延迟双删最终一致性低高频修改数据Binlog 监听 消息队列最终一致性高资金账户类敏感数据数据库事务 同步更新最好使用锁强一致性低下面我们就对这三类场景分别作出分析并给出一个简要的代码逻辑。四. 低频修改数据场景 的 推荐解决方案场景推荐方案一致性级别实现复杂度低频修改数据缓存过期 延迟双删最终一致性低如下示例代码// Redis 产品数量固定字符串 Key 前缀可有可不有但标准项目基本都会有 key 前缀方便管理 public static final String PRODUCT_NUMBER PRODUCT:NUMBER:; // 创建一个固定大小为 4 的线程池 private final ExecutorService asyncExecutor Executors.newFixedThreadPool(4); Transactional public void updateProductNumber(Long number,String productName){ // 拼接查询 key String key PRODUCT_NUMBER productName; try{ // (A1) 线程A首次删除缓存 stringRedisTemplate.delete(key); // 这里要注意下方查询缓存重建缓存的逻辑可能是其他线程(线程B)在线程A更新数据库之前就已经完成了 // (B1) 线程B查询数据库 String numberStr stringRedisTemplate.opsForValue().get(key); // (B2)线程B判断缓存值是否为空 if (numberStr null){ // (B3) B线程查询数据库 Long productnumber orderMapper.selectProductNumber(productName); numberStr String.valueOf(productnumber); // (B4) B线程写入缓存并给缓存一个过期时间这里为30分钟如果为热点数据建议时间更短一些比如1分钟3分钟5分钟等根据业务需要调整即可 if (productnumber ! null){ stringRedisTemplate.opsForValue().set(key,numberStr,30,TimeUnit.MINUTES); } // (B5) 因为线程B为读线程缓存重建后返回数据 return ...; } // (A2) 线程A更新数据库 orderMapper.updateProductNumber(number,productName); /* (A3) 线程A 提交事务后开辟新线程异步延迟进行缓存第二次删除关键 * A再次删除缓存就像上面线程B那样因为是查询比线程A快还将缓存重建 * 所以这里进行二次删除将线程B写入到缓存的脏数据删除掉。 * */ asyncExecutor.execute(() - { try { // 这里的线程是异步的所以不会阻塞主线程的执行但是这样会增加开销 // 此外延时时间需根据业务情况测试我随便写的这里随便写为 500ms需根据业务情况调整。 Thread.sleep(500); // (A4) 线程A第二次删除缓存如果后续有其它读请求就会像上面B线程一样将缓存重建 stringRedisTemplate.delete(key); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); // (A5) 线程A完成操作返回数据 return ...; }catch (Exception e){ throw new RuntimeException(程序错误,e); } }给缓存设计过期时间定期清理缓存并回写是保证数据最终一致性的解决方案。所有的写操作都要以数据库为准对缓存操作只是尽最大努力即可。如果数据库写成功缓存更新失败那么只要达到过期时间则后面的读请求自然会从数据库读取最新的值然后回写到缓存达到已执行。总而言之要以数据库(MySQL)写入库的数据为准。此种方案比较适合数据修改频率较低的情况当然了每个项目都有对应的特点结合项目业务特色选择相应的解决方案即可。五. 高频修改数据场景 的 推荐解决方案场景推荐方案一致性级别实现复杂度高频修改数据Binlog 监听 消息队列最终一致性高5.1. canal 入门5.1.1 canal 简介canal 是阿里巴巴旗下的一款开源项目基于数据库增量日志解析提供增量数据订阅消费主要用途是基于 MySQL 数据库增量日志解析目前主要支持MySQL。说白了是一个新的技术第三方中间件需要额外花时间掌握学习。有兴趣的小伙伴可以查阅下面这边文章写的非常好!【Canal】从原理、配置出发从0到1完成Canal搭建-CSDN博客canal 的工作原理类似于将自己伪装成 MySQL(主机) 的一条从机(slave)然后只要主机数据发生变化就会同步MySQL主机的数据。想了解主从复制的可以看博主的另一篇文章浅谈 MySQL 主从复制优点原理_mysql主从优势-CSDN博客5.1.2 canal 下载和配置修改canal 下载如下图所示点击下载接口canal.deployer-1.1.6.tar.gz下载完毕后解压得到如下文件进入 conf——example——instance.properties修改instance.properties 文件。将 address 改为自己的MySQL地址下方两个改为自己的数据库用户名和密码其它不用动。5.1.3 canal 运行和确认OK保存文件就改完了然后 进入 bin 目录双击运行 startup.bat 脚本运行即可。然后会出现黑色窗口我们再单独开一个 cmd 窗口输入netstat -ano | findstr :11111出现如下就表示运行成功了5.2 MySQL 配置5.2.1 windows 环境配置修改配置文件路径通常位于MySQL安装目录下例如C:\Program Files\MySQL\MySQL Server 8.0\my.ini直接使用文本编辑器打开即可必须修改的配置项如下[mysqld] # 启用Binlog指定日志文件名前缀 log-binmysql-bin # 设置Binlog格式为ROWCanal依赖此格式 binlog_formatROW # 设置唯一的服务器ID需确保与其他MySQL实例不冲突 server-id1 # 可选设置Binlog保留天数默认不删除 expire_logs_days75.2.2 windows 环境配置生效验证更改完毕配置文件后切记最好重启MySQL服务。然后进入 navicat 运行如下命令确认是否配置成功。sql 复制 -- 检查Binlog是否启用 SHOW VARIABLES LIKE log_bin; -- 检查Binlog格式是否为ROW SHOW VARIABLES LIKE binlog_format; -- 检查Server ID SHOW VARIABLES LIKE server_id; -- 检查Binlog保留天数 SHOW VARIABLES LIKE expire_logs_days;5.2.3 创建 canal 所需要的数据库权限这里记得换成自己的数据库密码-- 创建用户需替换 YOUR_PASSWORD CREATE USER canal% IDENTIFIED WITH mysql_native_password BY YOUR_PASSWORD; -- 授予复制权限 GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO canal%; -- 授予 Binlog 访问权限MySQL 8.0 必须 GRANT SELECT, RELOAD, SHOW DATABASES, LOCK TABLES, REPLICATION CLIENT ON *.* TO canal%; -- 刷新权限 FLUSH PRIVILEGES;5.3Binlog 监听 消息队列 流程图简要分析流程关键点总结步骤核心目标技术实现1-2更新数据库业务代码直接操作数据库3-4解析 BinlogCanal 客户端监听并提取 Key5-6首次删除缓存独立服务 异常降级到消息队列7-8重试保证最终一致性消息队列异步消费5.4 代码实例在如下代码中234步只需要在项目初期配置好即可后续就不需要怎么做修改了顶多在 canal 客户端添加监听的数据库表此时数据同步代码基本已经与业务代码完全解耦合了。后续我们只需要关注业务层面即可无需分心关注数据一致性的问题。5.4.1.业务服务更新数据库// ProductService.java public class ProductService { Autowired private ProductMapper productMapper; // 步骤1-2更新数据库触发 Binlog 生成 public void updateProductNumber(Long productId, Long productNumber) { // 直接操作数据库 productMapper.updateProductNumber(productId, productNumber); } }5.4.2.Canal 客户端监听 BinlogCanal 这里的配置目前只监听了 test 数据库下的 product 表也可以监听多张表或者整个库或者跨库监听都是可以的。一般情况下都是只监听核心业务表(高频操作表)这样不会有冗余数据并且只监听几张表资源占用较低。场景示例说明精确匹配单表test.product只订阅 test 库的 product 表多表逗号分隔test.user,test.product订阅两个表正则表达式匹配test\\..*订阅test库所有表跨库匹配db1\\..*,db2.order_.*订阅db1所有表和db2的order前缀表通常情况下canal 还需要在 properties 或 yml 文件中进行配置。# Canal 连接 MySQL 的配置 canal.instance.master.address127.0.0.1:3306 canal.instance.dbUsernamecanal canal.instance.dbPasswordYOUR_PASSWORD canal.instance.connectionCharsetUTF-8// CanalClient.java public class CanalClient { public static void main(String[] args) { // 连接 Canal 服务端 CanalConnector connector CanalConnectors.newSingleConnector( new InetSocketAddress(127.0.0.1, 11111), example, , ); connector.connect(); connector.subscribe(test.product); // 订阅 product 表的 Binlog while (true) { Message message connector.getWithoutAck(100); // 拉取 Binlog for (CanalEntry.Entry entry : message.getEntries()) { if (entry.getEntryType() CanalEntry.EntryType.ROWDATA) { // 步骤3-4解析 Binlog提取 Key RowChange rowChange RowChange.parseFrom(entry.getStoreValue()); for (RowData rowData : rowChange.getRowDatasList()) { Long userId rowData.getAfterColumnsList().get(0).getValue(); String key user: userId; // 调用缓存同步服务 CacheSyncService.process(key); } } } connector.ack(message.getId()); // 确认消费 } } }5.4.3.缓存同步服务删除缓存 失败入队// CacheSyncService.java public class CacheSyncService { private static RedisClient redis new RedisClient(redis://localhost:6379); private static MessageQueue mq new KafkaMessageQueue(kafka:9092); // 步骤5-6尝试删除缓存失败则发送到消息队列 public static void process(String key) { try { boolean success redis.delete(key); if (!success) { mq.send(cache_retry_queue, key); // 发送到重试队列 } } catch (Exception e) { mq.send(cache_retry_queue, key); // 异常时也发送 } } }5.4.4.消息队列消费者重试删除// MQConsumer.java public class MQConsumer { public static void main(String[] args) { MessageQueue mq new KafkaMessageQueue(kafka:9092); RedisClient redis new RedisClient(redis://localhost:6379); // 步骤7-8订阅队列并重试删除 mq.subscribe(cache_retry_queue, message - { String key (String) message; try { boolean success redis.delete(key); if (!success) { System.err.println(重试删除失败: key); // 可添加重试次数限制例如最多重试3次 } } catch (Exception e) { mq.send(cache_retry_queue, key); // 再次入队 } }); } }六. 资金账户类敏感数据 的 推荐解决方案场景推荐方案一致性级别实现复杂度资金账户类敏感数据数据库事务 同步更新最好使用锁强一致性低示例代码如下没啥可说的就是使用了分布式锁强制线程串行化执行基本不存在并发导致数据不一致的情况发生。private final AccountMapper accountMapper; private final StringRedisTemplate stringRedisTemplate; private final RedissonClient redissonClient; Transactional(rollbackFor Exception.class) public void transferMoney(Long fromId, Long toId, BigDecimal amount) { RLock lock redissonClient.getLock(account_lock: fromId : toId); try { if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { // MyBatis查询 Account fromAccount accountMapper.selectById(fromId); Account toAccount accountMapper.selectById(toId); // 余额计算 fromAccount.setBalance(fromAccount.getBalance().subtract(amount)); toAccount.setBalance(toAccount.getBalance().add(amount)); // MyBatis更新 accountMapper.updateBalance(fromAccount); accountMapper.updateBalance(toAccount); // 使用StringRedisTemplate存储JSON ObjectMapper objectMapper new ObjectMapper(); stringRedisTemplate.opsForValue().set( account: fromId, objectMapper.writeValueAsString(fromAccount) ); stringRedisTemplate.opsForValue().set( account: toId, objectMapper.writeValueAsString(toAccount) ); } } finally { lock.unlock(); } }七. 面试题合集问题一有这样一种情况微服务查询 Redis 无数据MySQL 有数据为保证数据双写一致性再回写到 Redis 时需要注意什么双检加锁策略了解过吗双检加锁其实和延时双删思路是一样的简单来说。在高并发的情况下如果线程A查询缓存无数据然后会查询数据库发现有数据然后将数据回写到缓存但在查询数据库期间可能已经有其他线程(线程B)先一步完成了查询数据库并回写了缓存此时A再回写缓存已经无意义了。所以在线程A查询到数据准备回写缓存之前可以再进行一次判断查看当前缓存中是否依旧为空如果为空说明缓存还未被重建则再去回写缓存。示例代码如下public static final String USER_PREFIX USER:; public User findUserById(Long id){ // 拼接Redis的key用户对象缓存用户字符串对象 String key USER_PREFIX id; User user null; String userStr null; try { // 1. 从Redis中查询用户信息若结果不为空转化后直接返回 // 若结果为空再去查询数据库 userStr stringRedisTemplate.opsForValue().get(key); if (userStr ! null) { return JSONUtil.toBean(userStr, User.class); } else { /* * 2. 拿到锁之后再次查询缓存确保缓存无数据双重检查俗称双检 * 之做所以这样做是因为在高并发情况下可能会存在多个线程同时进入导致缓存已经重建从而导致数据库被查询多次 * 会对数据库服务器造成压力 **/ userStr stringRedisTemplate.opsForValue().get(key); // 3. 判断是否为空如果不为空说明缓存已被其他线程重建直接返回数据 // 如果为空进行缓存重建从数据库中查询数据然后重建缓存 if (userStr ! null) { return JSONUtil.toBean(userStr, User.class); } else { // 4. 从数据库中查询用户信息 user userMapper.selectById(id); if (user null){ // 如果是一个热点key应该回写到redis里一个空值避免缓存穿透时间根据业务需求自己定我随便写的 stringRedisTemplate.opsForValue().set(key, , 1, TimeUnit.MINUTES); return null; } // 5. 将对象转为json userStr JSONUtil.toJsonStr(user); // 6. 写入Redis stringRedisTemplate.opsForValue().set(key, userStr, 3, TimeUnit.DAYS); } } } catch (Exception e) { logger.error(程序发生错误失败, e); } return user; }下面这四个问题答案全都已经在文章中了就留给小伙伴们自行解答啦问题二只要使用缓存就可能涉及到 Redis 缓存与数据库双存储双写只要有双写就一定有数据一致性问题如何解决数据一致性问题问题三双写一致性先动缓存 Redis 还是先动数据库 MySQL 原因是什么问题四延时双删了解过吗怎么做问题五Redis 和 MySQL双写一定会出现纰漏虽然做不到强一致性但可以做到最终一致性怎么做