高并发下Redis与MySQL一致性方案全解析

原来听过一句话,你遇到的坑前人已经踩过了,是的,我们应该学会站在巨人的肩膀上,大多数时候我们还只是在巨人的脚下,抬头看着他说,啊,好伟大啊,好厉害啊
是的,不过要记得,当我们站在巨人的肩膀上时,我们对于他们来说就也是巨人了~

也就是高并发的场景

并发量不高的普通业务,我们可以用普通方案

先更新数据库,再删除缓存,若缓存删除失败,数据库回滚,业务提示修改失败

image.png

正常的查询和修改数据的过程如上图,同步的过程

现有场景

  • 线程A更新数据库
  • 线程B更新数据库
  • 线程A和B处理缓存顺序可能不一样

策略一:先更数据库,再更缓存

为什么是删除缓存而不是更新缓存?高并发下

  • 线程A更新数据库
  • 线程B更新数据库
  • B更新缓存
  • A更新缓存

结果:数据库B,缓存A,脏数据;并且如果value经过复杂计算才得到的话,更新会降低性能

策略二:先删缓存,再更新数据库

中间可能并发读到旧数据,数据库新数据,导致脏数据,流程如下

  • A删除缓存
  • B查询缓存(旧数据)
  • B发现没有缓存,设置到缓存中(旧数据)
  • A更新数据库(新数据)

策略三:先更数据库,再删缓存

黄金组合

  • 问题1:删除cache滞后了,短时间内并发读的还是旧数据;删除成功才能恢复一致性
  • 问题2:删除cache失败了,会长时间不一致,直到cache自动过期

可用策略四

策略四:延迟双删策略

  • 删除缓存
  • 处理数据库
  • 延迟一定时间,再次删除缓存

延迟是为了防止在延迟期间有其他线程将旧数据重新写入缓存,如果在数据库更新完成,缓存删除之后回填到cache中,就会有脏数据,所以要等待,请君入瓮

优点:一定程度提高数据一致性;简单

缺点:延迟时间不好确定;多次删除性能低;二次删除失败同样问题

策略五:高并发下-逻辑删除/异步删除策略

逻辑删除

假删除,设置额外标志,空间换时间,数据放到hash中

在缓存数据中添加字段(如 logicExpireTime),表示业务有效期。

例如,数据实际有效期为30分钟,logicExpireTime字段 value设置为 30分钟, 但整个key物理缓存设置为1小时(包含冗余时间)。

写入缓存时,设置逻辑过期时间(如 logicExpireTime = 当前时间 + 30分钟)和较长物理TTL。

和逻辑过期配套的动作,叫做:异步重建

异步重建

发现过期-异步更新cache-返回旧数据(避免缓存击穿)

更新的时候, 更改 逻辑过期时间 = 当前时间(相当于已过期)

分布式锁:确保一个线程重建

更新缓存:从数据库获取最新数据,更新缓存并重置 logicExpireTime 和物理TTL

物理删除机制:依赖Redis的TTL机制,到期自动删除

  • 逻辑过期时间= 业务过期时间
  • 物理过期时间= 逻辑过期时间 + 高并发冗余时间

demo

 public class RedisTest1 {
     @Autowired
     private RedisTemplate redisTemplate;
     @Autowired
     private ExecutorPoolV2 executorPoolV2;
     // 分布式锁Key前缀
     private static final String LOCK_PREFIX = "lock:";
     private static final String DATA = "data";
     // 逻辑过期时间字段名
     private static final String LOGIC_EXPIRE_FIELD = "logicExpireTime";
     // 默认缓存物理过期时间(兜底)
     private static final int DEFAULT_PHYSICAL_TTL = 3600; // 1小时
 
     public void normalDelete() {
         // 测试逻辑
         String key = "product:1001";
         // 第一次查询:触发缓存重建
         System.out.println("第一次查询: " + getData(key));
         // 模拟缓存逻辑过期
         redisTemplate.opsForHash().put(key, LOGIC_EXPIRE_FIELD, String.valueOf(System.currentTimeMillis() - 1000));
         // 高并发查询(模拟多个线程同时请求过期数据)
         for (int i = 0; i < 5; i++) {
             new Thread(() -> {
                 System.out.println(Thread.currentThread().getName() + " 查询结果: " + getData(key));
             }).start();
         }
 
         ThreadUtil.sleep(2000);// 等待异步刷新完成
     }
 
     public String getData(String key) {
         // 1. 从缓存获取数据
         Object cacheData = redisTemplate.opsForHash().get(key, DATA);
         Object logicExpireStr = redisTemplate.opsForHash().get(key, LOGIC_EXPIRE_FIELD);
         log.info("cacheData :{}, logicExpireStr:{}", cacheData, logicExpireStr);
         // 2. 缓存不存在则初始化
         if (cacheData == null) {
             return rebuildCache(key);
         }
         // 3. 检查逻辑过期时间
         long logicExpireTime = Long.parseLong((String) logicExpireStr);
         if (logicExpireTime <= System.currentTimeMillis()) {
             log.info("已过期");
             // 4. 异步刷新缓存
             executorPoolV2.asyncExecutorV2().execute(() -> {
                 // 获取分布式锁(防并发重建)
                 String lockKey = LOCK_PREFIX + key;
                 Boolean lock = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
                 try {
                     if (lock) {
                         System.out.println("获取锁---> 开始异步刷新缓存: " + key);
                         rebuildCache(key);
                     }
                 } finally {
                     if (lock) {
                         redisTemplate.delete(lockKey);
                     }
                 }
             });
 
         }
         return cacheData.toString();
     }
 
     /*
      * 重建缓存(模拟数据库查询)
      */
     private String rebuildCache(String key) {
         // 模拟数据库查询耗时
         ThreadUtil.sleep(1000);
         // 生成新数据(这里模拟实际业务数据)
         String newData = "最新数据@" + System.currentTimeMillis();
         // 设置逻辑过期时间(30分钟后过期)
         long newLogicExpire = System.currentTimeMillis() + 30 * 60 * 1000;
         log.info("逻辑过期时间:{}", newData);
         // 更新缓存(设置物理TTL兜底)
         redisTemplate.opsForHash().put(key, DATA, newData);
         redisTemplate.opsForHash().put(key, LOGIC_EXPIRE_FIELD, String.valueOf(newLogicExpire));
         redisTemplate.expire(key, DEFAULT_PHYSICAL_TTL, TimeUnit.SECONDS);
         log.info("更新缓存完毕");
         return newData;
     }
 }
逻辑删除优势
  1. 高可用,过期期间仍返回旧数据,避免缓存穿透(只可用于高并发,否则过期很长时间了返回旧数据是不合适的;如果非高并发用普通的先更新数据库再删缓存就好了,或者延迟双删已够用)
  2. 减少延迟:异步重建不阻塞
  3. 资源优化:物理自动过期兜底
注意事项
  1. 短暂不一致,需业务容忍
  2. 重建失败,需重试或告警
  3. 锁竞争:合理使用
  4. 冗余时间设置,平衡内存和重建压力
适用场景
  1. 高并发读:如商品信息、秒杀活动
  2. 允许最终一致性,重建缓存完成前返回的是旧数据,极短时间
  3. 频繁更新数据,异步重建,不阻塞;如果非高并发在获取缓存时返回旧数据是有问题的,因为有可能过期很久了

异步删除

策略3存在的问题:删延迟,删失败存在数据不一致问题

异步删除引入队列,将删cache加入队列,消费删除任务,并且做多级补偿

这种补偿分为三种细化方案

  1. 基于内存队列删除缓存
  2. 基于消息队列删除缓存
  3. 基于binlog+消息队列删除缓存

策略六:先更数据库,再基于内存队列删除缓存

可以用阻塞队列存储要删缓存的指令

毫秒级(通常<10ms)

问题

  1. 若写操作非常频繁,队列任务较多,可能消费较慢,可引入多线程机制,加快消费速度
  2. 内存队列是JVM内部队列,如果JVM崩溃或重启,内存没来记得处理cache会丢失

适用场景

  • 高并发瞬时故障恢复(如网络抖动)‌。
  • 对延迟敏感但允许短暂数据不一致的业务(如秒杀库存缓存删除)‌

策略七:基于消息队列删除缓存

可用RocketMQ(高可用),延迟范围:100ms~2s (推荐

将删除cache的操作序列化成消息写入消息队列中间件即可

注意要同步发送到消息队列成功,消费失败最后进入死信队列,可对死信队列监控运维告警

优势:增加了cache删除的可靠性

适用场景:

  • 跨服务解耦场景(如分布式系统间缓存同步)‌。
  • 允许稍高延迟但需高可靠性的业务(如订单状态更新)‌

是否可以交给组件完成?答案是可以的:binlog+消息队列

策略八:基于binlog+消息队列删除缓存

可以使用阿里的canal中间件,采集在数据写入Mysql时生成的binlog日志,Canal将日志发送到RocketMq队列

在消费端,可以编写一个专门的消费者完成缓存binlog日志订阅,筛选出其中的更新类型log,解析之后进行对应Cache的删除操作,并且通过RocketMq队列ACK机制确认处理这条更新log,保证Cache删除能够得到最终的删除

延迟:1s~30s,延迟来源:主从同步延迟,binlog解析分发

优势:无业务入侵,只执行DB操作就可以了

适用场景:

  • 强数据一致性与缓存更新逻辑解耦的场景(如用户账户余额同步)‌
  • 可接受分钟级最终一致性的低频更新场景(如商品分类信息变更)‌

删除缓存失败后的补偿重试机制

放到消息队列中,异步重试删除

工业级方案(最终一致性方案)

三级补偿机制:延迟队列 + 消息队列补偿 + 定时任务scan key对比补偿java

延迟队列消费者逻辑(含重试控制)

延迟一下应对瞬时故障

第二级补偿,消息队列持久化重试‌

解决进程重启或长时间故障导致的数据丢失

 // RocketMQ 消费者类,利用原生重试机制
 public class CacheRetryMQConsumer implements MessageListenerConcurrently {
     private final RedisClient redisClient; // 依赖注入Redis客户端
 
     @Override
     public ConsumeConcurrentlyStatus consumeMessage(
         List<MessageExt> messages, 
         ConsumeConcurrentlyContext context
     ) {
         for (MessageExt message : messages) {
             String cacheKey = new String(message.getBody(), StandardCharsets.UTF_8);
             try {
                 // 尝试删除缓存
                 boolean success = redisClient.del(cacheKey);
                 if (!success) {
                   // 最终失败后记录到数据库
         jdbcTemplate.update("INSERT INTO cache_fail_logs VALUES (?)", cacheKey);
                     // 删除失败,触发RocketMQ退避重试
                     return ConsumeConcurrentlyStatus.RECONSUME_LATER;
                 }
             } catch (Exception e) {
                 // 异常场景(如网络超时),触发重试
                 return ConsumeConcurrentlyStatus.RECONSUME_LATER;
             }
         }
         // 消费成功
         return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
     }
 }

第三级补偿, 定时任务兜底比对‌

适用Redis scan命令对hotkey:前缀的key进行扫描,封装成消息发送到MQ中;

flink数据比对和缓存操作:Flink从Rocketmq中消费消息,获取Redis key后,从数据库中查询对应的原始数据,与Redis中的缓存数据进行比对。如果发现数据不一致,则根据业务逻辑更新缓存数据,确保缓存与数据库的一致性

此处用的XXL-Job扫描key

 // 示例任务处理器伪代码
 public class CacheKeyScanJobHandler extends IJobHandler {
     @Override
     public ReturnT<String> execute(String param) {
         // 获取分片参数
         int shardIndex = getShardIndex();
         int totalShards = getTotalShards();
         // 构造SCAN模式
         String pattern = "hotkey:"; 
         int batchSize = 500;
 
         // 分布式SCAN逻辑
         RedisConnection conn = getRedisConnection();
         ScanOptions options = ScanOptions.scanOptions()
                                .match(pattern)  //减少不必要的遍历*:
                                .count(batchSize)
                                .build();
 
         Cursor<byte[]> cursor = conn.scan(options);
         while(cursor.hasNext()){
             byte[] keyBytes = cursor.next();
             String key = new String(keyBytes);
 
             // 分片路由算法
             if(key.hashCode() % totalShards == shardIndex){
 
                 sendToRocketMQ( new KeyMsg(key) ); // 发送到MQ
             }
         }
         return SUCCESS;
     }
 }



 //发送消息到MQ
 public static void sendToRocketMQ(KeyMsg  keyMsg) {
     // 获取Rocketmq生产者实例
     DefaultMQProducer producer = new DefaultMQProducer("producer_group");
     producer.setNamesrvAddr("rocketmq_namesrv_addr");
     try {
         producer.start();
         for (String key : keys) {
             Message msg = new Message("CacheCompareTopic", "Tag", keyMsg.getBytes(RemotingHelper.DEFAULT_CHARSET));
             SendResult sendResult = producer.send(msg);
             // 处理发送结果
         }
     } catch (Exception e) {
         // 异常处理
     } finally {
         producer.shutdown();
     }
 }
  • 99%场景**‌:通过阻塞队列和延迟队列在百毫秒内完成删除。
  • 0.99%场景‌:依赖消息队列在秒级至分钟级恢复。
  • 0.01%极端场景‌:由定时任务最终保障一致性java

图片

该方案适用于高并发和高可靠 都需要的要求苛刻的场景,比如,电商库存、金融交易等场景的 数据一致性

参考

https://mp.weixin.qq.com/s/_VyHzICG_qZENjjnHGC0UA

我不知道自己还能做多久,只知道我会认真的去做~

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值