原来听过一句话,你遇到的坑前人已经踩过了,是的,我们应该学会站在巨人的肩膀上,大多数时候我们还只是在巨人的脚下,抬头看着他说,啊,好伟大啊,好厉害啊
是的,不过要记得,当我们站在巨人的肩膀上时,我们对于他们来说就也是巨人了~
也就是高并发的场景
并发量不高的普通业务,我们可以用普通方案
先更新数据库,再删除缓存,若缓存删除失败,数据库回滚,业务提示修改失败

正常的查询和修改数据的过程如上图,同步的过程
现有场景
- 线程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;
}
}
逻辑删除优势
- 高可用,过期期间仍返回旧数据,避免缓存穿透(只可用于高并发,否则过期很长时间了返回旧数据是不合适的;如果非高并发用普通的先更新数据库再删缓存就好了,或者延迟双删已够用)
- 减少延迟:异步重建不阻塞
- 资源优化:物理自动过期兜底
注意事项
- 短暂不一致,需业务容忍
- 重建失败,需重试或告警
- 锁竞争:合理使用
- 冗余时间设置,平衡内存和重建压力
适用场景
- 高并发读:如商品信息、秒杀活动
- 允许最终一致性,重建缓存完成前返回的是旧数据,极短时间
- 频繁更新数据,异步重建,不阻塞;如果非高并发在获取缓存时返回旧数据是有问题的,因为有可能过期很久了
异步删除
策略3存在的问题:删延迟,删失败存在数据不一致问题
异步删除引入队列,将删cache加入队列,消费删除任务,并且做多级补偿
这种补偿分为三种细化方案
- 基于内存队列删除缓存
- 基于消息队列删除缓存
- 基于binlog+消息队列删除缓存
策略六:先更数据库,再基于内存队列删除缓存
可以用阻塞队列存储要删缓存的指令
毫秒级(通常<10ms)
问题
- 若写操作非常频繁,队列任务较多,可能消费较慢,可引入多线程机制,加快消费速度
- 内存队列是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
我不知道自己还能做多久,只知道我会认真的去做~
549

被折叠的 条评论
为什么被折叠?



