JavaEE企业级开发 延迟双删+版本号机制(乐观锁) 事务保证redis和mysql的数据一致性 示例

提醒

要求了解或者熟练掌握以下知识点

  1. spring 事务
  2. mysql 脏读
  3. 如何保证缓存和数据库数据一致性
  4. 延迟双删
  5. 分布式锁
  6. 并发编程 原子操作类

前言

在起草这篇博客之前

我做了点功课

这边我写的是一个示例代码

数据层都写成了 mock 的形式(来源于 JUnit5)

// Dduo
import java.util.concurrent.ConcurrentHashMap;  
import java.util.concurrent.Executors;  
import java.util.concurrent.ScheduledExecutorService;  
import java.util.concurrent.TimeUnit;  // 数据服务类 
public class DataService { // 模拟缓存(实际使用Redis等实现) private static final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>(); // 延迟双删线程池 private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);  // 模拟数据库,使用一个 Map 来存储数据记录 private static final ConcurrentHashMap<Integer, DataRecord> mockDatabase = new ConcurrentHashMap<>(); // 数据记录类,包含数据的基本信息和版本号 private static class DataRecord { private int id; private String content; private int version; public DataRecord(int id, String content, int version) { this.id  = id; this.content  = content; this.version  = version; } public int getId() { return id; } public String getContent() { return content; } public void setContent(String content) { this.content  = content; } public int getVersion() { return version; } public void setVersion(int version) { this.version  = version; } } // 模拟从数据库获取数据 private static DataRecord mockDatabaseGet(int id) { return mockDatabase.get(id);  } // 模拟数据库更新操作,更新数据并更新版本号 private static boolean mockDatabaseUpdate(int id, String content, int expectedVersion) { DataRecord record = mockDatabase.get(id);  if (record == null) { return false; } // 检查版本号是否匹配 if (record.getVersion()  != expectedVersion) { return false; } // 更新数据内容 record.setContent(content);  // 更新版本号 record.setVersion(expectedVersion  + 1); mockDatabase.put(id,  record); return true; } // 初始化数据库数据 public void initData(int id, String content) { mockDatabase.put(id,  new DataRecord(id, content, 1)); } // 获取数据(带缓存逻辑) public String getData(int id) { String cacheKey = "data_" + id; // 1. 先查缓存 String cached = cache.get(cacheKey);  if (cached != null) { return cached; } // 2. 缓存未命中,查询数据库 DataRecord record = mockDatabaseGet(id); if (record == null) { return null; } // 3. 写入缓存(包含版本号信息) String value = record.getContent()  + "|v" + record.getVersion();  cache.put(cacheKey,  value); return value; } // 更新数据(带延迟双删和版本控制) public boolean updateData(int id, String newContent) { String cacheKey = "data_" + id; // 获取当前数据的版本号 DataRecord record = mockDatabaseGet(id); if (record == null) { return false; } int expectedVersion = record.getVersion();  try { // 1. 第一次删除缓存 cache.remove(cacheKey);  // 2. 更新数据库(带版本校验) boolean updateSuccess = mockDatabaseUpdate(id, newContent, expectedVersion); if (!updateSuccess) { return false; } // 3. 提交后安排延迟删除 scheduler.schedule(()  -> { try { // 二次删除前的二次校验(可选) DataRecord current = mockDatabaseGet(id); if (current != null && current.getVersion()  > expectedVersion) { cache.remove(cacheKey);  // 只删除旧版本缓存 } } catch (Exception e) { // 处理异常,可添加重试逻辑 e.printStackTrace();  } }, 1, TimeUnit.SECONDS); // 延迟时间根据主从同步时间调整 return true; } catch (Exception e) { // 处理异常,可添加补偿逻辑 e.printStackTrace();  return false; } } public static void main(String[] args) { DataService service = new DataService(); // 初始化数据 service.initData(1,  "Initial Content"); // 获取数据 System.out.println("Initial  Data: " + service.getData(1));  // 更新数据 boolean result = service.updateData(1,  "Updated Content"); System.out.println("Update  Result: " + result); // 再次获取数据 System.out.println("Updated  Data: " + service.getData(1));  } 
} 

要点

  1. mockDatabaseUpdate 方法中,当更新数据时,会先检查传入的期望版本号与数据库中记录的版本号是否一致。如果一致,会更新数据内容并将版本号加 1。
  2. getData 方法会先从缓存中查找数据,如果缓存中没有,则从数据库中获取数据,并将数据内容和版本号拼接后存入缓存。
  3. updateData 方法会先获取当前数据的版本号,然后执行延迟双删操作。在更新数据库时,会携带版本号进行校验,确保数据的一致性。

运行示例

main 方法中,我们演示了如何初始化数据、获取数据、更新数据和再次获取数据。运行程序后,你可以看到数据的初始状态、更新结果和更新后的数据。

通过这种方式,版本号和延迟双删机制可以协同工作,保证数据的一致性和缓存的正确性。

  • 延迟双删处理缓存层面的最终一致性
  • 第二次删除前的版本检查避免过度删除

典型时序:

  1. 请求A删除缓存
  2. 请求A更新数据库(版本2)
  3. 请求B读取缓存未命中,查询数据库(版本1)并填充缓存
  4. 延迟任务执行二次删除,发现数据库版本已更新,删除旧版本缓存
  5. 后续请求获取最新数据(版本2)并更新缓存

注意实际需要:

  • 替换mock数据库操作为真实DAO操作
  • 调整延迟时间(通常500ms-1s)
  • 添加缓存空值处理
  • 添加重试机制和监控

为什么要进行延迟双删

缓存和数据库数据的一致性一直是我们在后端开发中探讨的问题

先删除缓存再更新数据库情况

现在有两个线程

线程 1 是 写线程

线程 2 是 读线程

如果线程 1 是先删除缓存再更新数据库

在这个时间间隙 就是线程 1 写线程删除缓存和更行数据库的这个间隙

线程 2 读线程进来了

因为缓存已经被删除了 读线程尝试去数据库读取数据

脏数据就这样被写入了缓存

下次读的时候 因为缓存存在 所以一直读取的是旧数据

发生的几率比较大的原因往往是因为

更新数据库的数据是比较慢的

先更新数据库再删除缓存的情况

线程 1 是读线程 线程 1 首先去数据库读取到了旧数据

在写回缓存的这个间隙

线程 2 是写线程 更新了数据库为新数据

之后线程 1 才写入缓存

这样缓存里依旧是旧数据

但这种情况发生情况很小

应为缓存的写入很快

所以很难出现 读线程在写线程更改了数据库数据后再把数据写入缓存

而且另一种情况

线程 1 读线程 执行完毕后

线程 2 写线程 也最终会进行一次删除缓存的操作

思考

● 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁。因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;

● 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间。这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。

延时双删实现

伪代码

# 延迟双删代码的实现# 删除缓存
redis.delKey(X)# 更新数据库
db.update(X)# 睡眠
Thread.sleep(N)# 再删除缓存
redis.delKey(X)

思考

在延迟双删策略中

我们需要在更新数据库之前

就先把缓存删掉

这样是为了防止在这个间隙有其他请求读取到了缓存

拿到的是失效的缓存数据

清除缓存后 在这个期间 其他请求是不会命中缓存的 会直接去数据库中读取最新数据

这样保证了数据的一致性和缓存的即时更新

在我看来延迟双删是在对比了先删除缓存再更新数据库还是先更新数据库的基础上 选择出了先更新数据库再删除缓存的基础上 的改进

更新数据库数据是一个很慢的过程

这样做可以高效的提高数据的一致性

再高并发读取的情况下 减轻数据库的读取压力 提高读取性能和响应速度

进一步优化

一、使用读写锁优化数据库并发控制

原理:通过区分读锁(共享锁)和写锁(排他锁),确保写操作期间独占资源,避免脏读和不可重复读问题。

示例场景:电商库存扣减

  1. 写锁应用:当用户下单扣减库存时,事务对库存记录加写锁(SELECT ... FOR UPDATE),阻止其他事务同时修改或读取未提交的库存数据。
  2. 读锁应用:商品详情页展示库存时,事务加读锁(SELECT ... LOCK IN SHARE MODE),允许其他读操作共享数据,但阻塞写操作。
  3. 效果:写锁独占期间,其他读请求需等待写锁释放,确保扣减操作的原子性,避免超卖。

二、高效缓存淘汰算法降低缓存失效影响

原理:通过动态调整缓存过期策略,减少因缓存集中失效导致的数据库瞬时压力。

示例场景:新闻热点数据缓存

  1. LRU算法优化:传统LRU可能误淘汰热点数据,可升级为 LRU-K(记录最近K次访问时间),优先保留高频访问数据。
  2. 时间窗口分散:为缓存键的过期时间添加随机值(如基础30分钟 + 随机0-10分钟),避免大量缓存同时失效引发雪崩。
  3. 主动更新机制:结合读写锁,在缓存失效前异步刷新数据(如后台线程检测过期前5分钟的热点Key,提前加载新数据)。

三、综合应用案例:社交平台评论系统

  1. 写锁控制评论发布
    • 用户发布评论时,事务对评论区数据加写锁,阻塞其他用户同时修改同一帖子,确保评论顺序和完整性。
    • 读锁允许其他用户持续加载已有评论,仅写操作短暂阻塞。
  1. LFU算法管理缓存
    • 使用 LFU(Least Frequently Used) 算法缓存热门帖子,自动淘汰低频访问的旧数据。
    • 结合 布隆过滤器 拦截无效查询(如已删除的帖子ID),减少缓存穿透。

四、注意事项

  1. 锁粒度选择:优先使用行级锁(如InnoDB的间隙锁)而非表锁,减少阻塞范围。
  2. 缓存一致性:采用 延迟双删策略(更新数据库后先删缓存,短暂延迟后再次删除),避免并发更新导致脏数据。
  3. 性能监控:通过工具(如Prometheus)监控锁等待时间和缓存命中率,动态调整锁策略和淘汰算法参数。

通过上述方法,可在高并发场景下平衡数据一致性与系统性能,减少因锁竞争或缓存失效导致的业务风险。

具体代码

我们现在要更新数据库

具体业务是插入数据

添加

 /*** 添加句子** @param addSentenceDTO 注意提交是一个事务 如果失败则回滚 我们这边使用的是spring的事务框架*/@Override@Transactional(rollbackFor = Exception.class, timeout = 10) // todo 如果插入标签过多 可能会导致事务回滚public void addSentenceWithTags(AddSentenceDTO addSentenceDTO) throws Exception {// 主记录插入AddSentenceReq addSentenceReq = addSentenceDTO.getAddSentenceReq();tSentencesMapper.addSentence(addSentenceReq);Long sentenceId = addSentenceReq.getSentenceId();// 关联标签插入List<AddTagsReq> tagsList = addSentenceDTO.getTagsList();AddSentenceTagReq addSentenceTagReq = new AddSentenceTagReq();addSentenceTagReq.setSentenceId(sentenceId);addSentenceTagReq.setTagsList(tagsList);int size = tagsList.size();if (size == 0) return;else {int i = tSentencesMapper.batchInsertTags(addSentenceTagReq); // 数据库插入标签并返回改变的标签数量if (i != size) {throw new Exception("传入了无效标签");}}// 此时已经更新了数据库 并且提交了事务(事务未回滚) 延迟双删 更新版本号TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {@Overridepublic void afterCommit() {DATA_VERSION.incrementAndGet(); // 版本号自增String cacheKey = "balloonSentences:all" + DATA_VERSION;delayDoubleDelete(cacheKey, 5, TimeUnit.SECONDS); // 执行延时双删List<GetAllContentResp> dbData = tSentencesMapper.getAll(); // 更新elasticsearchelasticsearchService.saveProduct(dbData);  // 写到elasticsearch里面去}});}

我们把代码逻辑进行了事务管理

当完成提交后

我们自增版本号

这边是使用的一个原子类

  // 原子类 版本号 这边表示的是当前数据版本的版本号private static final AtomicInteger DATA_VERSION = new AtomicInteger(1);

版本号机制重新构造缓存的 key

进行延迟双删

这边为什么又要有版本号机制又要进行双删

因为防止多个线程同时更新 所以要以最近的一次更新来刷新缓存

如果加锁的话 效率就会降低太多了

    /*** 更新缓存中全部句子的数据策略:延迟双删* 策略 先删除缓存 然后更新数据库 然后休眠 再删除缓存* 要求用分布式锁方式多线程进入操作数据库环境** @param cacheKey* @param delay* @param unit*/private void delayDoubleDelete(String cacheKey, int delay, TimeUnit unit) {RLock lock = redissonClient.getLock("lock:" + cacheKey);try {lock.lock();// 第一次删除(立即执行)redisService.deleteObject(cacheKey);// 延迟队列二次删除ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();executor.schedule(() -> {redisService.deleteObject(cacheKey);// 强制刷新缓存refreshCacheWithVersion(DATA_VERSION);}, delay, unit);} finally {lock.unlock();}}

之后再强制刷新缓存一遍

验证了我们刚才的想法

我们使用的要是最新的数据

缓存里面的也要是最新数据

/*** 强制刷新缓存** @param currentVersion*/
private void refreshCacheWithVersion(AtomicInteger currentVersion) {String cacheKey = "balloonSentences:all" + currentVersion;RLock lock = redissonClient.getLock("refresh:" + cacheKey);try {lock.lock();// 版本校验(防止旧版本覆盖)List<GetAllContentResp> newData = tSentencesMapper.getAll();// 删除缓存redisService.deleteObject(cacheKey);// 随机化TTL防雪崩 随机化过期时间redisService.setList(cacheKey, newData, RandomUtil.randomInt(30, 60), TimeUnit.MINUTES);} finally {lock.unlock();}
}

如何确定延时的时间

1.数据库性能

如果数据库更新快

可以选择较短的更新时间

2.缓存过期的时间

如果缓存过期的时间较长

可以选择缩短更新时间

以免过早的删除缓存导致数据不一致

思考

假设在延时双删策略中,第一次删除缓存后,会有一段时间的延时,然后再进行第二次删除缓存。如果此时缓存的过期时间设置得很短,比如只有几秒钟,那么在第二次删除缓存之前,缓存可能已经过期,而应用程序在读取缓存时会发现缓存已失效,从而不得不去数据库中查询最新数据。
为了避免这种情况,延时双删的延时时长应该要大于缓存的过期时间,确保在第二次删除缓存之前,缓存还是有效的,这样可以保证应用程序读取到的数据是一致的。
同时还需要考虑数据更新的频率和缓存的使用情况。如果数据更新较为频繁,那么延时双删的延时时长应该要适当缩短,以便及时更新缓存;如果缓存的使用率很低,可以适当延长延时时长,以减少对缓存服务的压力。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/diannao/75577.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

A2 最佳学习方法

记录自己想法的最好理由是发现自己的想法&#xff0c;并将其组织成可传播的形式 (The best reason for recording what one thinks is to discover what one thinks and to organize it in transmittable form.) Prof Ackoff 经验之谈&#xff1a; 做培训或者写文章&#xff…

嵌入式硬件工程师从小白到入门-PCB绘制(二)

PCB绘制从小白到入门&#xff1a;知识点速通与面试指南 一、PCB设计核心流程 需求分析 明确电路功能&#xff08;如电源、信号处理、通信&#xff09;。确定关键参数&#xff08;电压、电流、频率、接口类型&#xff09;。 原理图设计 元器件选型&#xff1a;匹配封装、电压、…

vue创建子组件步骤及注意事项

在 Vue 中创建子组件需要遵循组件化开发的核心原则&#xff0c;并注意数据流、通信机制、复用性等关键点。以下是详细步骤和注意事项&#xff0c;结合代码示例说明&#xff1a; 一、创建子组件的步骤 1. 定义子组件 创建一个 .vue 文件&#xff08;单文件组件&#xff09;&am…

Cocos Creator版本发布时间线

官网找不到&#xff0c;DeepSeek给的答案&#xff0c;这里做个记录。 Cocos Creator 1.x 系列 发布时间&#xff1a;2016 年 - 2018 年 1.0&#xff08;2016 年 3 月&#xff09;&#xff1a; 首个正式版本&#xff0c;基于 Cocos2d-x 的 2D 游戏开发工具链&#xff0c;集成可…

【Spring AI】基于专属知识库的RAG智能问答小程序开发——功能优化:用户鉴权主体功能开发

系列文章目录 【Spring AI】基于专属知识库的RAG智能问答小程序开发——完整项目&#xff08;含完整前端后端代码&#xff09;【Spring AI】基于专属知识库的RAG智能问答小程序开发——代码逐行精讲&#xff1a;核心ChatClient对象相关构造函数【Spring AI】基于专属知识库的R…

【AI神经网络】深度神经网络(DNN)技术解析:从原理到实践

引言 深度神经网络&#xff08;Deep Neural Network, DNN&#xff09;作为人工智能领域的核心技术&#xff0c;近年来在计算机视觉、自然语言处理、医疗诊断等领域取得了突破性进展。与传统机器学习模型相比&#xff0c;DNN通过多层非线性变换自动提取数据特征&#xff0c;解决…

目标跟踪——deepsort算法详细阐述

deepsort 算法详解 Unmatched Tracks(未匹配的轨迹) 本质角色: 是已存在的轨迹在当前帧中“失联”的状态,即预测位置与检测结果不匹配。 生命周期阶段: 已初始化: 轨迹已存在多帧,可能携带历史信息(如外观特征、运动模型)。 未被观测到: 当前帧中未找到对应的检测框…

Vue-admin-template安装教程

#今天配置后台管理模板发现官方文档的镜像网站好像早失效了&#xff0c;自己稍稍总结了一下方法# 该项目环境需要node17及以下&#xff0c;如果npm install这一步报错可能是这个原因 git clone https://github.com/PanJiaChen/vue-admin-template.git cd vue-admin-template n…

Rust从入门到精通之进阶篇:14.并发编程

并发编程 并发编程允许程序同时执行多个独立的任务&#xff0c;充分利用现代多核处理器的性能。Rust 提供了强大的并发原语&#xff0c;同时通过类型系统和所有权规则在编译时防止数据竞争和其他常见的并发错误。在本章中&#xff0c;我们将探索 Rust 的并发编程模型。 线程基…

算法训练营第二十三天 | 贪心算法(一)

文章目录 一、贪心算法理论基础二、Leetcode 455.分发饼干二、Leetcode 376. 摆动序列三、Leetcode 53. 最大子序和 一、贪心算法理论基础 贪心算法是一种在每一步选择中都采取当前状态下的最优决策&#xff0c;从而希望最终达到全局最优解的算法设计技术。 基本思想 贪心算…

css基础-display 常用布局

CSS display 属性详解 属性设置元素是否被视为块级或行级盒子以及用于子元素的布局&#xff0c;例如流式布局、网格布局或弹性布局。 一、基础显示模式 1. block 作用&#xff1a; 元素独占一行可设置宽高和内外边距默认宽度撑满父容器 应用场景&#xff1a; 布局容器&a…

速卖通API数据清洗实战:从原始JSON到结构化商品数据库

下面将详细介绍如何把速卖通 API 返回的原始 JSON 数据清洗并转换为结构化商品数据库。 1. 数据获取 首先要借助速卖通 API 获取商品数据&#xff0c;以 Python 为例&#xff0c;可使用requests库发送请求并得到 JSON 数据。 import requests# 替换为你的 API Key 和 Secret …

【零基础入门unity游戏开发——2D篇】2D物理系统 —— 2D刚体组件(Rigidbody2D)

考虑到每个人基础可能不一样,且并不是所有人都有同时做2D、3D开发的需求,所以我把 【零基础入门unity游戏开发】 分为成了C#篇、unity通用篇、unity3D篇、unity2D篇。 【C#篇】:主要讲解C#的基础语法,包括变量、数据类型、运算符、流程控制、面向对象等,适合没有编程基础的…

Collectors.toMap / list 转 map

前言 略 Collectors.toMap List<User> userList ...; Map<Long, User> userMap userList.stream().collect(Collectors.toMap(User::getUserId, Function.identity()));假如id存在重复值&#xff0c;则会报错Duplicate key xxx, 解决方案 两个重复id中&#…

热门面试题第13天|Leetcode 110.平衡二叉树 257. 二叉树的所有路径 404.左叶子之和 222.完全二叉树的节点个数

222.完全二叉树的节点个数&#xff08;优先掌握递归&#xff09; 需要了解&#xff0c;普通二叉树 怎么求&#xff0c;完全二叉树又怎么求 题目链接/文章讲解/视频讲解&#xff1a;https://programmercarl.com/0222.%E5%AE%8C%E5%85%A8%E4%BA%8C%E5%8F%89%E6%A0%91%E7%9A%84%E8…

关于Object.assign

Object.assign 基本用法 Object.assign() 方法用于将所有可枚举属性的值从一个或者多个源对象source复制到目标对象。它将返回目标对象target const target { a: 1, b: 2 } const source { b: 4, c: 5 }const returnedTarget Object.assign(target, source)target // { a…

GitHub高级筛选小白使用手册

GitHub高级筛选小白使用手册 GitHub 提供了强大的搜索功能&#xff0c;允许用户通过高级筛选器来精确查找仓库、Issues、Pull Requests、代码等。下面是一些常用的高级筛选用法&#xff0c;帮助你更高效地使用 GitHub 搜索功能。 目录 搜索仓库搜索Issues搜索Pull Requests搜…

手动集成sqlite的方法

注意到sqlite有backup方法&#xff08;https://www.sqlite.org/backup.html&#xff09;。 也注意到android中sysroot下&#xff0c;没有sqlite3的库&#xff0c;也没有相关头文件。 如果要使用 sqlite 的backup&#xff0c;那么就需要手动集成sqlite代码到项目中。可以如下操…

蓝桥杯真题 2109.统计子矩阵

原题地址:1.统计子矩阵 - 蓝桥云课 问题描述 给定一个 NMNM 的矩阵 AA, 请你统计有多少个子矩阵 (最小 1111, 最大 NM)NM) 满足子矩阵中所有数的和不超过给定的整数 KK ? 输入格式 第一行包含三个整数 N,MN,M 和 KK. 之后 NN 行每行包含 MM 个整数, 代表矩阵 AA. 输出格…

蓝桥杯—最少操作数

一.题目 分析:每次可以进行三次操作&#xff0c;求在n步操作后可以达到目标数的最小n&#xff0c;和最短路径问题相似&#xff0c;分层遍历加记忆化搜索防止时间复杂度过高&#xff0c;还需要减枝操作 import java.util.HashSet; import java.util.LinkedList; import java.ut…