文章
快来分享你的内容吧~
- 2025-10-22·Java后端
- 2025-10-21·Java后端
成长最快的方式就是硬着头皮上
## 成长最快的方式就是硬着头皮上 真正实习后发现学校教东西的逻辑确实和社会所需要的节奏脱轨。 学校教的东西并不是没有用,但是真正实习之后我才发现是逻辑性的问题。学校里的作业会给你充足的准备时间,划定清晰的题目、固定的参考标准、明确的截止日期,我们可以搜集资料、打磨细节、查漏补缺,等一切准备万全、答案趋近完美后,再提交最终成果。所以很多完美主义者能够在学校做的足够优秀,但步入社会、投身工作之后,却屡屡碰壁,甚至产生强烈的自我内耗与无力感。 绝大多数人都会陷入一个误区:片面认为校园知识无法适配职场,就判定课本所学毫无价值。可事实从来不是知识本身无用,而是我们长期被校园单一的做事逻辑束缚,没有读懂社会真正的运行规则,这也是学生与职场人最核心的差距。 在校园的评价体系里,底层逻辑是闭环式完美主义。所有学习任务都是被动、既定且低风险的。老师给我们标准答案、给我们试错空间、给我们完整的准备周期,任务的变量全部可控。这套逻辑教育我们:万事必先谋定后动,补齐所有短板、筹备好一切资源,才有资格正式开始行动。在这样的环境下,我们习惯规避一切不完美,排斥所有未知风险,把“准备充分”当成行动的前置必要条件。 但职场尤其是初创公司的底层逻辑,与之截然相反,它是开放式迭代主义。社会上绝大多数工作,从来没有完整的题目、没有统一的评分标准,更不会给你充足的时间从零开始万全筹备。需求随时会变更、市场环境每日波动、客户的喜好无法预判,资源永远有限、问题永远层出不穷。没有人能提前集齐所有条件,也没有人能做出百分之百完美的方案。 这也就解释了,为什么很多在校成绩优异的学生,进入职场后会手足无措。他们依旧沿用校园的惯性思维,面对工作任务,第一反应不是着手执行,而是等待:等待思路万全、等待资源到位、等待自己掌握所有相关技能。可在瞬息万变的职场中,“等准备好”本身就是一种最大的内耗与错误。 我们要认清一个残酷的真相:这个世界上不存在绝对完美的准备。无论是工作项目、创业尝试,还是学习新技能、规划人生方向,永远会有未考虑到的细节永远有自身欠缺的能力、永远有无法预判的突发问题。一味等待万事俱备,最后的结果只会是原地踏步,眼睁睁错失所有机会。 真正高效的做事逻辑,从来不是准备好了再开始,而是先开始,再在实践中逐步完善。 先迈出第一步,产出基础版本的成果,用最低的成本落地试错;再根据市场反馈、实际问题,一点点修正漏洞、优化细节、升级方案。比起零成本空想完美,职场更认可“完成大于完美”。一个60分、可以持续优化的初始方案,远比一个停留在脑海中、永远无法落地的100分构想更有价值。 而学校真正的缺憾,从来不是授课内容老旧、知识脱离实际,而是从来没有教会我们这套迭代思维。学校只教会我们如何在固定框架内做到极致,却忽略了教导我们如何在未知的环境里,接受不完美、直面不确定性,在行动中解决问题。 同时我也明白,我们不能因此否定校园知识的意义。专业知识是我们行动的地基,是我们迭代优化成果的底气;而全新的做事逻辑,是打开职场大门的钥匙。知识决定我们的上限,思维决定我们能不能顺利落地、持续成长。 跳出校园固有思维,摒弃等待式的完美主义,接受初始状态的笨拙与不完美。先行动,后优化;先落地再精进。当我们摆脱“万事皆要准备万全”的思维枷锁,才能真正适配社会的节奏,把书本中学到的知识,转化为能创造价值的核心能力。
20260410
# 04月10日面试 ### 1.手写一个线程安全的计数器(两种方法) **1)使用synchronized(最基础、必掌握**) ````java public class Counter { private int count = 0; // 加一 public synchronized void increment() { count++; } // 获取值 public synchronized int getCount() { return count; } } ```` **面试回答要点**: - 使用 `synchronized` 保证原子性、可见性、有序性 - `count++` 是三步操作(读→改→写),非原子,必须加锁 - 简单可靠,但高并发下性能一般 **2)使用 AtomicInteger(推荐,性能更好)** ````java import java.util.concurrent.atomic.AtomicInteger; public class Counter { private final AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } } ```` **面试回答要点**: - 基于 **CAS 无锁机制**,性能比 synchronized 高 - 利用 CPU 原语保证原子操作,避免线程阻塞 - 适合高并发计数场景 ### 2.手写线程安全的单例模式 **1)双重校验锁(DCL)+ volatile(最常考)** ```java public class Singleton { // 禁止指令重排,保证多线程可见性 private static volatile Singleton instance; // 私有构造,禁止外部 new private Singleton() {} public static Singleton getInstance() { // 第一次判断,避免每次加锁 if (instance == null) { synchronized (Singleton.class) { // 第二次判断,防止多线程同时进入外层 if if (instance == null) { instance = new Singleton(); } } } return instance; } } ``` **面试必说要点:** - 构造器私有,防止外部实例化 - `volatile` 禁止指令重排,避免 DCL 失效 - 两次判空 + 锁类对象,保证线程安全 - 懒加载,节约内存 **2)静态内部类** ```java public class Singleton { private Singleton() {} // 静态内部类,只有被调用时才加载 private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } } ``` **优点:** - 由 **JVM 类加载机制** 保证线程安全 - 懒加载、性能高、代码简洁 - 无锁,面试加分项 **3)饿汉式(最简单,但不是懒加载)** ```java public class Singleton { private static final Singleton instance = new Singleton(); private Singleton() {} public static Singleton getInstance() { return instance; } } ``` **特点:** - 天生线程安全,但类加载就初始化,可能浪费内存。 ### 3.MySQL的ACID特性,InnoDB 如何保证 **一、ACID 是什么** - A(Atomicity)原子性:事务要么全部成功,要么全部失败回滚,不允许部分执行。 - C(Consistency)一致性:事务执行前后,数据库完整性约束不变。 - I(Isolation)隔离性:多个事务并发执行时,互相不可见、不干扰。 - D(Durability)持久性:事务一旦提交,结果永久保存,宕机不丢失。 **二、InnoDB 如何保证 ACID** 1. 原子性(A) 保证:undo log(回滚日志) 执行更新前,先把旧数据记入 undo log 事务失败时,通过 undo log 回滚到之前状态 配合 redo log 保证崩溃后仍能正确回滚 2. 一致性(C) 保证:原子性 + 隔离性 + 持久性 + 约束检查 事务满足 AID 自然保证一致性 外加主键、外键、唯一约束、触发器等 3. 隔离性(I) 保证:锁 + MVCC(多版本并发控制) 读写锁、间隙锁、临键锁解决脏写、幻读等问题 MVCC 实现读写不阻塞,通过 undo log 生成历史版本 配合四大隔离级别: Read Uncommitted Read Committed(RC) Repeatable Read(RR,InnoDB 默认) Serializable 4. 持久性(D) 保证:redo log(重做日志) + 刷盘机制 事务提交前,先写 redo log(WAL 预写日志) 即使 buffer pool 数据未刷盘,宕机重启后也能通过 redo log 恢复 配合 binlog 保证主从一致 ### 4.JVM内存模型,OOM的区域以及原因 **一、JVM 内存区域划分(线程私有 + 线程共享)** 1. 线程私有区域(每个线程独立) **程序计数器** 记录当前线程执行字节码行号。 **虚拟机栈** 存放局部变量、方法出口、栈帧。 **本地方法栈** 作用同虚拟机栈,为 native 方法服务。 2. 线程共享区域(所有线程共用) **堆(Heap)** 存放对象实例,GC 主要区域,分为新生代 + 老年代。 **方法区(元空间 MetaSpace)** 存储类信息、常量、静态变量、即时编译代码。 JDK8 后叫元空间,使用本地内存。 **二、OOM 常见区域及原因(高频)** 1. **堆 OOM(Java heap space)** 原因: 对象创建过多,且无法被 GC 回收(内存泄漏) 集合对象全局引用,一直持有不释放 堆内存设置过小(-Xmx 太小) 死循环不断创建对象 典型场景: 从数据库一次查询百万条数据加载进内存 静态 Map 无限缓存对象不清理 2. **虚拟机栈 OOM / StackOverflowError** StackOverflowError: 方法递归调用无出口,栈深度超限 OOM: 线程不断创建,栈内存耗尽 3. **方法区 / 元空间 OOM(Metaspace)** 原因: 大量动态生成类(CGLib、MyBatis、动态代理) 大量 JSP、反射类加载 常量池数据过多 元空间配置太小 4. **直接内存 OOM** 原因: NIO 框架频繁申请堆外内存 堆外内存未正确释放 总物理内存不足 ### 5.ConcurrentHashMap **一、核心特点** - 线程安全的高效哈希表,替代 HashTable、同步 HashMap - 读操作无锁,写操作分段锁 / CAS + synchronized,并发性能远高于 HashTable - 键值不能为 null **二、JDK 1.7 实现** - 分段锁(Segment),继承自 ReentrantLock - 默认 16 个 Segment,并发度 16 - 结构:Segment → 数组 + 链表 - 锁粒度:锁住一个 Segment,多段可同时操作 **三、JDK 1.8 实现(重点必问)** - 取消 Segment,采用 CAS + synchronized 控制并发 - 结构:数组 + 链表 + 红黑树 - 锁粒度:只锁住链表 / 树的头节点,并发更高 1. **put 流程** 根据 key 计算 hash 节点为空:CAS 插入 节点不为空:synchronized 锁住头节点 写入 链表长度 ≥ 8 且数组长度 ≥ 64 → 转为红黑树 扩容:达到负载因子 0.75 触发扩容 2. **get 流程** 无锁,直接根据 hash 查找 利用 volatile 修饰数组 保证可见性 **四、关键机制** 1. hash 计算 高纬位参与运算,减少冲突 2. 扩容机制 扩容为原来 2 倍 多线程协助迁移,提高效率 3. 线程安全保证 读:volatile 保证可见性 写:CAS 无锁 + 轻量级锁 synchronized **五、对比总结** | 对比 | HashMap | HashTable | ConcurrentHashMap | | --------- | -------------------- | ------------ | -------------------- | | 线程安全 | 否 | 是(全表锁) | 是(细粒度锁) | | 性能 | 高 | 低 | 高并发优秀 | | 键值 null | 允许 | 不允许 | 不允许 | | JDK8 结构 | 数组 + 链表 + 红黑树 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
毕设-情绪雷达
# 情绪雷达 ## 注: 项目基于芋道的 mini 版,进行二次开发,部署文档就不过多赘述了,可以看人家的官方文档。 ## 概述: **情绪雷达**,项目的核心开发路线是:针对用户发来的聊天界面截图,利用 ai 分析,并给出几个可供复制的选项。 **思路来源**,当你看着别人发送的消息而手足无措的时候,不知道如何回复的时候,就可以参考并尝试一下本项目,虽然 ai 味道挺浓,但是还挺有帮助的。 ## 核心入口: 苹果快捷指令:通过触发苹果的快捷指令,获取屏幕截图,与聊天意向通过 api 接口,访问,并返回一个网址,其中就包括了,ai 给你的答复。 当然牵扯到了工作流的多个不可压缩的步骤,每次访问消耗,都在 20s 以内。 如下图所示: 1.选择聊天图片(抖音找的……) <!-- 这是一张图片,ocr 内容为: -->  2.触发快捷指令-选择聊天背景 <!-- 这是一张图片,ocr 内容为: -->  3.选择情感参数- <!-- 这是一张图片,ocr 内容为: -->  4.利用返回的 ai 内容 <!-- 这是一张图片,ocr 内容为: -->  ([http://user.dataword.cloud/emotion-radar?chatNumber=26040822070221B&emApiKey=dashabi](http://user.dataword.cloud/emotion-radar?chatNumber=26040822070221B&emApiKey=dashabi)) 快捷指令: [https://www.icloud.com/shortcuts/1ded217589e3492cbc7841d22d5dc759](https://www.icloud.com/shortcuts/1ded217589e3492cbc7841d22d5dc759)
LangChain4J理论概述
# LangChain4J 理论概述 ## 1、LangChain4J 是什么? LangChain4j 的目标是简化将大型语言模型集成到 Java 应用中。 具体做法如下: 1. **统一API:** LLM提供商(如OpenAI或Google Vertex AI)和嵌入(向量)存储器(如Pinecone或Milvus) 使用专有API。LangChain4j 提供了一个统一的 API,避免了为每个 API 学习和实现特定 API 的需求。 要尝试不同的大型语言模型或嵌入存储,你可以轻松切换,无需重写代码。 LangChain4j 目前支持 [20+ 个热门大型语言模型提供商](https://docs.langchain4j.dev/integrations/language-models/)和 [30+ 个嵌入存储](https://docs.langchain4j.dev/integrations/embedding-stores/)。 2. **全面工具箱:** 自2023年初以来,社区一直在构建大量基于LLM的应用程序, 识别常见的抽象、模式和技巧。LangChain4j 将这些内容优化成一个现成可用的软件包。 我们的工具箱包括从底层提示模板、聊天内存管理和函数调用等多种工具 到高级模式如代理和RAG。 对于每个抽象,我们都提供了一个接口,并基于通用技术提供了多种现成可用的实现。 无论你是在构建聊天机器人,还是开发一个从数据摄取到检索的完整流程的RAG, LangChain4j 提供多种选择。 3. **举个例子:** 这些[示例](https://github.com/langchain4j/langchain4j-examples)展示了如何开始创建各种基于LLM的应用, 提供灵感,帮助你快速开始建设。 LangChain4j 于 2023 年初在 ChatGPT 热潮中开始开发。 我们注意到缺少与众多Python和JavaScript大型语言模型库及框架相对应的Java对应工具, 我们必须解决这个问题! 虽然“LangChain”出现在我们的名字里,但这个项目是LangChain、Haystack的理念和概念的融合。 LlamaIndex以及更广泛的社区,加入了我们自己的创新元素。 我们积极监控社区发展,力求快速引入新技术和集成, 确保你保持最新动态。 图书馆正在积极开发中。虽然部分功能仍在开发中, 核心功能已到位,让你现在就能开始构建基于LLM的应用! 为了更便捷的集成,LangChain4j 还支持与 [Quarkus](https://docs.langchain4j.dev/tutorials/quarkus-integration)、[Spring Boot](https://docs.langchain4j.dev/tutorials/spring-boot-integration)、[Helidon](https://docs.langchain4j.dev/tutorials/helidon-integration) 和 [Micronaut](https://docs.langchain4j.dev/tutorials/micronaut-integration) 的集成 ## 2、LangChain4J 能干嘛? - 与[20+ LLM提供商](https://docs.langchain4j.dev/integrations/language-models)的集成 - 与[30+嵌入(向量)存储](https://docs.langchain4j.dev/integrations/embedding-stores)的集成 - 与[20+嵌入模型](https://docs.langchain4j.dev/category/embedding-models)的集成 - 与[5+聊天存储](https://docs.langchain4j.dev/category/chat-memory-stores)存储的集成 - 与[5+图像生成模型](https://docs.langchain4j.dev/category/image-models)的集成 - 与[5+评分(重新排序)模型](https://docs.langchain4j.dev/category/scoring-reranking-models)的整合 - 与统一管理模型(OpenAI)的集成 - 支持文本和图像作为输入(多模态) - [AI 服务](https://docs.langchain4j.dev/tutorials/ai-services)(高级 LLM API) - [代理与智能人工智能](https://docs.langchain4j.dev/tutorials/agents) - [技能](https://docs.langchain4j.dev/tutorials/skills) - 提示模板 - 持久性和内存内[聊天内存](https://docs.langchain4j.dev/tutorials/chat-memory)算法的实现:消息窗口和令牌窗口 - [LLM响应的流式传输](https://docs.langchain4j.dev/tutorials/response-streaming) - 常见 Java 类型和自定义 POJO 的输出解析器 - [工具(函数调用)](https://docs.langchain4j.dev/tutorials/tools) - 动态工具(动态生成的LLM代码执行) - [RAG(检索-增强生成):](https://docs.langchain4j.dev/tutorials/rag) - 摄入方式: - 从多个来源(文件系统、URL、GitHub、Azure Blob 存储、Amazon S3等)导入各种类型的文档(TXT、PDF、DOC、PPT、XLS等) - 使用多种拆分算法将文档拆分成更小的部分 - 文档和分段的后处理 - 使用嵌入模型进行段嵌入 - 嵌入(向量)存储中的嵌入 - 检索(简单和高级): - 查询转换(扩展、压缩) - 查询路由 - 从矢量商店和/或任何自定义来源获取 - 重新排名 - 互惠等级融合 - RAG流程中每个步骤的定制化 - 文本分类 - 代币化和代币计数估计工具 - [Kotlin 扩展](https://docs.langchain4j.dev/tutorials/kotlin):利用 Kotlin 协程功能进行异步非阻塞聊天交互处理。 ### LLM 大模型能干什么  ### LLM 大模型应用技术架构  ## 3、LangChain4J 怎么用? ### 开发分类  ### 定位 
前端笔记
# 前端四要素 1. 逻辑: - 判断 if - 循环 for 2. 事件 - 浏览器事件:window document - Dom事件: 增 删 遍历 修改节点元素内容 - jQuery - **jQuery** 是一个**轻量级、开源、免费的 JavaScript 工具库** - `他是JS框架` - 专门用来**简化原生 JavaScript 的开发**,解决网页交互、DOM 操作、浏览器兼容等问题 - **jQuery 本质就是对原生 JavaScript(下称原生 JS)的封装,是一个基于原生 JS 编写的轻量级前端工具库**,它的所有功能底层最终都会转化为原生 JS 代码在浏览器中执行,核心目的就是把原生 JS 中**常用、繁琐、兼容性差**的代码封装成简洁易用的 API,让开发者用更少的代码实现需求(呼应其核心宗旨 Write Less, Do More)。 3. 视图 - html - css: 是难点 但是可以使用BootStrap(他是CSS框架,通过拖拽的方式创建好看的页面) - js 4. 通信 - Ajax - axios 是Ajax的封装,最常用 # Vue学习线路大体介绍 视图:html CSS JS (给用户看的,刷新后台给数据) 网络通信:axios ![转存失败,请重新上传图片]() 页面跳转: vue-router 状态管理: VUE Vue-UI: ICE 补充模块化开发是后端提出来的他将后端的MVC(后端的V现在都是交给前端用Vue或者React实现)转变为了MVVM View 视图 DATA vm: 数据双向绑定 ## 前端三大框架 ![转存失败,请重新上传图片]() ## UI框架 ![转存失败,请重新上传图片]() ## JS构建工具 ![转存失败,请重新上传图片]() ## vue特点 ![转存失败,请重新上传图片]() ## 补充 真正工作中前端是不会去写CSS的 ![转存失败,请重新上传图片]() 前端打包工具:webpack ES6就是一种js的语法规范,类似与java8和java的关系 TS是JS的升级,他的效果更好。但是在编译后还是JS ## 后端需要学习 分布式,微服务,大数据 # 第一个Vue程序 ## VUE的实现逻辑 视图<------逻辑(双向绑定) ---------->数据 ![转存失败,请重新上传图片]() ## 双向绑定特点 ![转存失败,请重新上传图片]() ## vue七大对象 1. el:element 元素 2. data: 数据 3. methods ## Vue实质 ![转存失败,请重新上传图片]() viewModel 就是双向绑定的 ![转存失败,请重新上传图片]() # Vue基本语法 就三种逻辑 1. 本来就要显示的 2. if 条件 3. for: 循环 因为vue都是从model中拿取数据的,而数据都是存放在data中的 **for使用** ![转存失败,请重新上传图片]() **if使用** ![转存失败,请重新上传图片]() ## DOM 和 BOM **DOM**:管**页面内容**的,让 JS 能 “操作网页标签”;管理页面的标签,内容和样式 **BOM**:管**浏览器本身**的,让 JS 能 “控制浏览器”; 二者关系:**DOM 在 BOM 里面**,通过 BOM 的顶层对象`window`才能访问 DOM。 ## Vue绑定 `v-bind`(缩写 `:`):绑定**DOM 元素的属性 / 特性**,关联 “数据→视图”,核心是 “传数据”; `v-on`(缩写 `@`):绑定**DOM 元素的事件**,关联 “视图→逻辑”,核心是 “响应用户操作”; 二者是 Vue 模板中最基础的两个指令,分别处理 “数据渲染” 和 “交互逻辑”,是 Vue 响应式开发的核心基础。 `v-on` 的核心作用是给 HTML 元素绑定**事件监听器**(比如点击、输入、鼠标移动等交互行为),当事件触发时,执行 Vue 实例中对应的**方法 / 逻辑**,是 “视图→逻辑” 的交互关联。 `v-bind` 的核心作用是把 Vue 实例中的**数据**动态绑定到 HTML 元素的**属性**上(比如 `src`、`href`、`class`、`style`、自定义属性等),数据变则属性值变,是 “数据→视图” 的单向关联。 v-on 绑定事件 **v-on:** 绑定什么(绑定一个点击事件)事件: 使用 v-on: click="方法名字" ![转存失败,请重新上传图片]() ## 双向绑定 ![转存失败,请重新上传图片]() **实现方法** :使用v-model ![转存失败,请重新上传图片]() ![转存失败,请重新上传图片]() # Vue组件 vue组件就是自定义标签。同时把组件也看成是一个模版 ![转存失败,请重新上传图片]() ![转存失败,请重新上传图片]() ![转存失败,请重新上传图片]() Vue.component 定义组件,组件里面有两个属性 1. props:能接收参数 2. template: 能定义模版 组件能接收东西,能定义模版就ok了 # Axios通信技术 axios是让前后端的数据可以互通,以下讲解的是通过axios将将josn数据展示在前端 ## 实现方案 1. 先创建一个data.json文件 2. 使用钩子函数mounted(): mounted(){ axios.get('../data.json').then(response => (this.info=response.data));} mounted() 使用方法 上面的写法是链式编程,ES6新特性 - axios.get("json文件的相对位置") 通过get方法获取到data.json数据 - .then表示获取到数据后我们要做什么(then(response => (this.info=response.data))) 我们要做的是将响应数据拿出来,放到info属性中,之后我们就可以通过info展示data.json文件中的数据了 3. 使用vue对象的data()方法(注意不是data属性)return表示要返回的东西是什么,下图表示的意思就是我要将info中name和url返回出去。返回出去后前端的视图模块就可以展示信息了 4. 现在json数据就可以渲染在前端了 5. 特别注意:如果使用了超链接跳转href前面要加上v-bind进行绑定。 info中的内容(请求的返回参数)必须要和json字符串一样 ![转存失败,请重新上传图片]() # 计算属性 计算属性: 计算出来的结果,保存在属性中。并且在内存中运行(利用了虚拟Dom) 可以把他看作是从**缓存**中拿取值 ## 使用方式 1. 调用vue对象的computed方法,然后写入一个计算时间的方法进行测试 2. 视图在展示的时候,直接写属性名字,而不用写方法的名字。 3. 特点: 每次调用的时候都是从缓存中拿数据,加快了访问速度。但是当调用新的内容的时候,缓存会被打破 他会重新加载(缓存失效,重新计算) 4. 对于结果不经常变换的计算,可以使用计算属性 ![转存失败,请重新上传图片]() ![转存失败,请重新上传图片]() ![转存失败,请重新上传图片]() ![转存失败,请重新上传图片]() # 插槽Slot ## 形象类别 可以在凹槽中配置各种东西 ![转存失败,请重新上传图片]() ## 使用方法 1. 创建vue对象 2. 创建多个组件,其中在创建插槽所使用的组件时,要在定义模版内容的时候格外注意 通过使用 \ 进行换行 3. 在组件标签中定义绑定关系和调用关系 下图所示为数据的传递方向 ![转存失败,请重新上传图片]() 实际上只要理解了数据是如何传递的, 绑定关系是如何建立的 , 是怎么调用数据的,理解插槽就会更加的简单,他无非就是多了一个模版写法而已。下面来介绍一下这三种方式是如何实现的。 1. vue对象里面通过data属性创建了数据源,用于后期视图进行展示 2. 定义的组件件对象,通过props属性和vue对象中data中的数据建立联系。当这层联系建立后,数据源中的数据就可以 以props作为媒介,将数据传输给组件对象的template属性 3. 组件对象的template有了数据后,就可以通过组件标签进行数据展示了。 4. 数据流向:**根实例 data(vm)** → **父组件模板绑定(<todo> 内部的绑定,**父传子的 props 绑定关系**)**→ **子组件 props(todo-title/todo-items)** → **子组件模板展示** ![转存失败,请重新上传图片]() 注意:最顶层的 “父” 是 Vue 根实例(`var vm = new Vue({ el: "#app" ... })`),它对应的模板就是 `<div id="app">` 里的所有内容 `slot="xxx"` 只是给这两个子组件 “标记归属的插槽位置”,**不改变 props 绑定的父传子本质**。 # 自定义事件内容分发 `v-bind`(缩写 `:`):绑定**DOM 元素的属性 / 特性**,关联 “数据→视图”,核心是 “传数据”; `v-on`(缩写 `@`):绑定**DOM 元素的事件**,关联 “视图→逻辑”,核心是 “响应用户操作”; 二者是 Vue 模板中最基础的两个指令,分别处理 “数据渲染” 和 “交互逻辑”,是 Vue 响应式开发的核心基础。 ## 达到效果 让子组件的消息模版可以删除vue对象中的数据。原来是只有父组件的消息模版可以删除vue对象中的数据 ## 效果图 效果图 ![转存失败,请重新上传图片]() 前端通过**el属性**和Vue对象建立联系 前端通过插槽(slot)和 组件建立联系 但是我们想要在组件中删除Vue对象的数据是做不到的。要想实现需要借助**自定义事件**这个中间桥梁 ![转存失败,请重新上传图片]() ## 自定义事件的实现方法 1. 在vue对象中创建删除方法 2. 在组件中定义自定义事件方法 (使用 this.$emit(事件名)) 3. 在前端通过@事件名(因为他也是一个方法所以使用@) 和 组件进行绑定, 通过 @事件名="vue对象的方法名称" 和vue对象建立联系 ![转存失败,请重新上传图片]() ![转存失败,请重新上传图片]() # 第一个Vue-Cli项目 1. ## 搭建步骤 1. 下载node版本并安装 2. 在控制台输入npm install cnpm -g(安装 cnpm 的核心目的是解决国内访问 npm 官方仓库速度慢、甚至下载失败的问题) 安装Node.js淘宝镜像加速器(cnpm) 3. 配置node镜像,加速node访问:npm install --registry=https://registry.npm.taobao.org 4. 安装vue-cli(全局安装):cnpm install vue-cli -g 5. 使用vue list:查看可以基于哪些模版创建vue应用程序,我们选择webpack (但是现在的项目使用vite来代替webpack) 这里介绍一下webpack: - wepback是用于构建和打包的,类似java中的maven - 为什么使用webpack :因为vue是基于ES6的,但是大多数网站用的是ES5。通过webpack进行打包降级从而让大多数完整使用vue程序 6.创建一个基于webpack模版的vue应用程序: vue init webpack myvue (文件夹的名字) 7. 然后一路选no(我这是为了学习,实际开发根据需求选择) 8. 切换目录,切换到创建的vue目录 9. 进入目录:cd myvue 10. npm installl 安装依赖(他是根据package.json来安装的) 11. 如果在安装依赖的时候,报了错误,他会给修复报错的指令比如说:npm audit fix 12. npm run dev 启动项目 # WebPack学习 ## webpack的定义 webPack是一个模块加载器兼打包工具。他可以把各种资源(JS JSX ES6 图片) 作为模块来处理和使用 ## 安装webPack 1. 安装打包工具: npm install webpack -g 2. 安装客户端: npm install webpack-cli -g 3. 测试是否安装成功: webpack -v , webpack-cli -v ## 练习 1. 创建一个项目 2. 创建一个叫modules的目录,用于放置JS模块等资源文件 3. 在modules下创建模块文件,如hello.js 用于编写JS模块相关的代码 ``` //暴露一个方法:sayHello: exports.sayHello=function(){ document.write("<h1>Vue基础笔记</h1>"); } ``` 4. 在modules下创建一个名字叫main.js的入口文件,用于打包时设置entry属性 ``` // require 导入一个模块,就可以调用这个模块中的方法了 var hello = require("/hello"); hello.sayHello(); ``` 5. 在项目目录下创建webpack.config.js 配置文件,使用webpack命令打包(如果显示未找到webpack则以管理员身份运行webpack) ``` module.exports={ //进入路径 entry: "./modules/main.js", // 输出路径 output: { filename: "./js/budle.js" } } ``` 6. 在项目目录下创建HTML页面,如index.html, 导入webPack打包后的JS文件 ``` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Vue学习</title> </head> <body> <!--通过html文件就可以直接运行打包后的文件了--> <script src="dist/js/bundle.js"></script> </body> </html> ``` 7. 运行HTML查看效果 8. 最终效果的得到的感悟: 可以将打包后的js文件通过html启动,然后直接运行 9. 补充: 参数 --watch用于监听变化 webpack --watch # vue-router路由 ## 功能 vue-router实现多个页面跳转 ## 实现方法 1. 定义组件(每个组件文件分为三个部分:template,style(书写vue逻辑比如导入逻辑和导出逻辑),style(如果加上scope属性则表示样式效果只在该文件中生效)) ![转存失败,请重新上传图片]() 2. 创建路由文件,路由文件存放在router目录下。路由文件的结构为: - 引入插件(vue,vue-router) - 安装/注册插件 - 配置和导出插件:有路由数组参数里面可以写路由路径,路由名字(可以不写),跳转的组件 ![转存失败,请重新上传图片]() 3. 在main.js文件中(在 Vue 2 项目里,`main.js` 是整个应用的**入口文件**,是程序执行的起点) - 导入路由 - 配置路由 ![转存失败,请重新上传图片]() 4. 在App.vue文件中配置路由路径(router-link to=跳转的路径)和路由视图(router-view展示内容) ![转存失败,请重新上传图片]() # Vue+ElementUI ## 使用方法 只要根据vue-router的知识,理清楚页面的实现逻辑,使用elementUI就很简单 1. 安装依赖 ``` npm i element-ui -S ``` 2. 创建组件,组件可以放在两类文件夹中 , 直接复制你看好的组件(elementUI组件) - view 用于存放样式类的组件 - components 用于存放功能类的组件 ![转存失败,请重新上传图片]() 3. 创建路由文件(导入插件,注册插件,配置和导出插件,将引入的ElementUI组件配置到路由中) ![转存失败,请重新上传图片]() 4. 在index.js中填写对应的信息,可以直接复制,elemntUI已经提供 ![转存失败,请重新上传图片]() 5. 在App.vue中直接使用 ![转存失败,请重新上传图片]() # 嵌套路由 就是在路由文件中,在某个路由的下面再嵌套一层,使用的属性是:children: [{子路由},{子路由}] 视图显示效果就是,在父视图里面展示子视图 ![转存失败,请重新上传图片]() ## 页面显示效果 我是在main路径下设置了用户列表和用户信息页面 1. 这个是main页面 ![转存失败,请重新上传图片]() 2. 这个是个人信息界面(他就会在main页面的一个区域中展示对应的页面信息,而不是在一个新的页面展示) ![转存失败,请重新上传图片]() 3. 这个是用户列表界面 ![转存失败,请重新上传图片]() # 参数传递及重定向 ## 参数传递实现原理 你在组件文件中,传入一个参数,然后这个参数就会通过name 和 propes 传递到路由文件中,路由文件接收后再传递给对应的组件模版,用于展示信息。 ## 参数传递实现步骤 1. 通过 **:to** 和 name 绑定对应的路由路径,通过params 传递相应的参数 ![转存失败,请重新上传图片]() 2. 通过name 来锁定对应组件,在path中写入 /:id 或者 /:name 来实现路径带参 , props设置为true设置为允许参数传递。路由文件中的 path 通过设置 /:id 或者 /:name 就可以接收到对应的参数然后在页面的url路径中展示出来 ![转存失败,请重新上传图片]() 3. srcipt中写入props用来接收传来的参数,然后再template中写入{{id}} 或者 {{name}} 用来展示信息 **特别注意**:在template中一定要在标签里面<div> 写对应的参数信息 ![转存失败,请重新上传图片]() ## 实现效果 ![转存失败,请重新上传图片]() ## 重定向实现原理 转发:上面方法中url并没有发生变化。 重定向:当url发生变化才是重定向 ## 重定向实现方法 1. 在router文件中,给对应的组件设置 redirect(重定向参数) ![转存失败,请重新上传图片]() 2. 填充对应组件 ![转存失败,请重新上传图片]() 3. 查看效果,点击对应导航栏实现跳转,然后发现url发生变化 ![转存失败,请重新上传图片]() ![转存失败,请重新上传图片]() **路由更加灵活的方式:** 1. 在Login页面携带参数,传递给路由文件 ![转存失败,请重新上传图片]() 2. 组件传递过来的参数,通过 /:name进行接收 ![转存失败,请重新上传图片]() 3. Main组件接收后,用于视图的展示 ![转存失败,请重新上传图片]() 4.最终效果 ![转存失败,请重新上传图片]() ![转存失败,请重新上传图片]() # 路由和404 ## 路由的实现逻辑 ### 路由模式 通过设置routes为history 可以实现在url中省略 # 号 ![转存失败,请重新上传图片]() ![转存失败,请重新上传图片]() 可以看到路径中不带#号了 ![转存失败,请重新上传图片]() ## 路由钩子搭配Axios使用 ### 路由钩子 from:从哪里来 to: 到哪里去 next: 如下图所示 ![转存失败,请重新上传图片]() beforeRouteEnter是在进入对应页面前生效 beforeRouteLeave是在离开对应页面后生效 ![转存失败,请重新上传图片]() ![转存失败,请重新上传图片]() ![转存失败,请重新上传图片]() ## 使用Axios来实现在提取json数据 现在以展示json数据为例,等后期把json数据替换为对应的后端接口就可以。 然后就可以实现在页面展示后端接口传来的数据。 1. 安装axios 和 vue-axios ``` $ npm install axios npm install --save axios vue-axios ``` 2. 在路由文件中配置axios ``` import Vue from 'vue' import axios from 'axios' import VueAxios from 'vue-axios' Vue.use(VueAxios, axios) ``` 3. 进行实际的应用,注意:beforeRouteEnter中可以使用next((vm)=>{})语法 ![转存失败,请重新上传图片]() 先在methods中定义方法,然后再在beforeRouteEnter中通过next进行调用 methods的url后期可以替换为后端接口路径 ![转存失败,请重新上传图片]() ![转存失败,请重新上传图片]() ## 404页面 当url路径没有匹配的网页时,就给他设置404页面 #### 实现方法 1. 创建页面组件 ![转存失败,请重新上传图片]() 2. 配置访问路由:有访问路径的直接用路径就可以找到对应的页面,没有访问路径就配置 ***** 他就可以访问到404页面 ``` { path: "*", component: NotFound } ``` 3. 效果显示 ![转存失败,请重新上传图片]()
多级缓存设计:Caffeine+Redis
# 为什么需要多级缓存 ## 缓存的意义 缓存是数据库数据的临时仓库,更是系统性能的"加速器"。 如果没有缓存,每次取数据,都需要从数据库中拿,会产生以下成本: 1. 网络成本:通过 TCP/IP 协议发送请求、接收响应,Java对象数据序列化 2. 硬件资源成本:CPU 解析执行 SQL,进行磁盘 IO,占用大量内存带宽进行数据读写 3. 并发成本:为保证数据一致性,对读写加锁,有锁就有等待成本;数据库连接池容量也是有限的,大量请求会因等待连接而超时失败 使用缓存之后就会先从缓存(Caffeine 或 Redis)中查询,会相应带来以下好处: 1. 网络:由于缓存数据存储在内存中,且本地缓存无需网络传输,查询速度会非常快 2. 硬件资源:请求被缓存拦截后,数据库读写压力大幅下降,CPU、磁盘 IO 和网络带宽消耗显著减少 3. 并发:缓存能分流大部分查询请求,减少数据库的锁竞争和连接池压力 ## 本地缓存 Caffeine Caffeine 是 Java 中i性能最优的本地缓存,其实可以将其当作一个HashMap,只不过是在 HashMap 上,添加了一些功能: 1. 并发安全:无锁读(通过 volatile 保证可见);细粒度桶锁写,仅对目标数据所在桶加锁,冲突概率极低 2. 缓存淘汰算法:<font style="color:rgb(0, 0, 0) !important;">W-TinyLFU 算法,比 LRU(最近最少)算法命中率高</font> ```plain 1. 新来的先放临时区(Window Cache),这里专门放新面孔,如果临时区满了,就把最久没人碰过的东西挪到观察区 2. 观察区(Probation Cache),如果观察区没满,就直接进去,如果满了,就和观察区最久没碰过的 "老东西" 比 "人气" - 新来的人气高,就把老东西扔了 - 老东西人气高,如果新来的人气太低(少于5次访问),就直接扔了;不然就随机扔一个 3. 保护区(Protected Cache),如果观察区里面的东西被频繁访问(达到一定次数),就升级到保护区,这里的东西不容易被扔,如果保护区满了,就把最久没人碰过的东西挪回观察区,再按照上面的规则判断留不留 总结:LRU 算法只看最近的访问时间,如果某个数据被长期访问,但某天被临时数据挤到后面去,会被误删;但是本算法会统计访问频率,通过时间和频率双重判断,新数据先观察,高频数据先保护,低频数据才被淘汰,本算法思想适用于资源有限但需高效利用的场景,例如抖音短视频推荐、淘宝商品推荐,特此记录 ``` 3. <font style="color:rgb(0, 0, 0) !important;">灵活配置:可自主配置过期策略;控制缓存的容量(数量 或者 占用内存的权重);自动加载(缓存未命中时,自动执行预设逻辑,查询Redis 或者数据库,并回填缓存,但是这个我感觉不好用,我一般自己写逻辑)</font> ## 分布式缓存 Redis 本地缓存(Caffeine)存在一些缺点: 1. 节点私有,比如有10台服务器,每台服务器上的本地缓存都是独立的,如果某台服务器更新了数据,其它9台无法同步数据。 2. 内存限制,每台服务器缓存有限,无法存储海量的 3. 断电清空,它是随应用进程存在,一旦应用重启或崩溃,缓存数据会全部丢失 由此 Redis 闪亮登场: 1. 节点公有:所有服务器访问同一份 Redis 缓存,数据一旦更新,所有节点查询都能拿到最新值 2. 内存无限:通过集群配置,将数据分散到多态服务器,理论上可无限扩容 3. 持久化+高可用:通过 RDB(定期对数据库拍快照) 和 AOF(实时记录所有写操作) 持久化,即使 Redis 重启,也能通过持久化文件恢复数据,同时还支持主从复制、哨兵模式、集群模式,自动切换主从节点,保证缓存服务不中断 ## 相互配合 流程: 1. Caffeine 命中时,直接返回数据; 2. Caffeine 未命中时,会先查询Reids,如果 Redis 有数据则回写到 Caffeine 并返回数据; 3. Reids 没有数据,才去查数据库,然后写到 Redis,再写到 Caffeine ,再返回数据 虽然 Redis 访问需要网络传输,但是通过合理设计(如 Caffeine 缓存高频数据,Redis 存储需要共享的数据),可让 Redis 专注于"一致性保障",而 Caffeine 专注于 "性能加速"。 # 多级缓存的核心挑战与应对策略 ## 缓存穿透 当查询一个不存在的数据(如id = -1),会穿透 Caffeine 和 Redis,直接打到数据库,高并发下可能会拖垮数据库。 解决方案: 1. 合法性检查:先校验查询条件是否合法,非法直接拦截 2. 缓存空值:当数据库查询为空时,在 Caffeine 和 Redis 中缓存 "空值",并设置较短的过期时间如(Caffeine 1分钟, Redis 5分钟),后续相同的查询会被拦截,不再访问数据库 3. 布隆过滤器(海量数据场景):在 Redis 前加一层布隆过滤器,提前判断 "查询的key是否在数据库中"(概率判断,误判率极低) ```latex 1. 初始化:准备一个长度为 m 的二进制数组,均初始化为 0 ,以及 k 个不同的 哈希函数(如哈希函数 A、B、C) 2. 添加元素,对元素用上面的 k 个哈希函数进行哈希,得到 k 个不同的数组下标(如 A 算到 5,B 算到12,C 算到 23),将这几个下标从 0 改成 1 3. 判断元素是否存在,同样使用这 k 个哈希函数进行哈希,如果所有的下标查出来都是 1,那可能存在(允许误判),如果任何一个下标不是 1,那一定不存在 简单举例: 假设有个书架,上面有 1000 个格子,每个格子都贴一个 有/无 标签,要用三个哈希函数来贴标签, 比如要登记西游记、红楼梦,分别计算,得到100,200,300;150,250,350,都贴上有,其它都是无 现在我要查三国演义,计算出来是120,200,300,有一个格子不是,那代表没有,或者碰巧了,这仨恰好都是有,那允许偶尔认错 ``` ## 缓存击穿 当某条高频访问的数据,同时在 Caffeine 和 Redis 过期,大量的并发请求会打到数据库 解决方案: 1. 分布式锁+缓存重建:缓存未命中时,只有一个线程可以获取到分布式锁,其它线程等着,拿锁的线程查到数据重新构建缓存,其它线程从缓存中获取结果 2. 热点数据永不过期+主动更新:对核心热点数据,Caffeine 和 Redis 均不设置过期时间,数据更新通过mq通知更新缓存 3. 逻辑过期+被动更新:不设置过期时间,会导致数据泄露(比如活动过期了,数据也应该过期),在缓存结构中设置逻辑过期时间,到时间后,再次请求,返回原来的旧数据,相数据库发起查询请求,或者进行其它的处理 ## 缓存雪崩 出现原因: 1. 大量 key 集中过期:若缓存中的大量 key 设置了相同的过期时间(如凌晨 3 点统一过期),到时间后所有请求会涌向数据库 2. 缓存服务故障:Reids 集群宕机、网络波动等导致缓存整体不可用,所有请求直接穿透到数据库 解决方案: 1. 过期时间加随机值:在基础过期时间上增加 0-300 秒的随机值,多级缓存过期时间错开 2. 缓存集群化部署:采用主从、哨兵、集群模式部署;采用限流、熔断降级策略 ## 数据一致性 当数据发生更新(如商品价格调整),若只更新数据库,未即时同步到 Redis 或 Caffeine,会导致仍然返回旧数据(也就是脏读) > 首先说明,没有解决数据一致性问题的 "银弹",只有最适合业务的解决方案 > 要一致性,就需要考虑到删除缓存,于是就有两种方式 1. 先删除缓存,再更新数据库 2. 先更新数据库,再删除缓存 我们先看第一种方式,在高并发情况下,假如删除缓存之后,还没来得及更新数据库或者说更新数据库失败了,另一个线程发现没有缓存,进而又去读取数据库,并且回写缓存,就会导致数据不一致。 再看第二种方式,在高并发下,假如更新数据库之后,还没来得及删除缓存或者说删除缓存失败,另一个线程就会读取旧缓存,导致数据不一致 一般情况下选择第二种方式,因为第二种方式,我们可以使用其它的方法来减轻或减缓其问题。 下面均采用这种方式来解决其问题。 解决方案: 1. 延时双删:修改数据库后,先删除 Caffeine,再删除 Redis 缓存,然后设置异步延时再次进行删除Redis 缓存,由于 Caffeine 存在于本进程中,并不是分布式的,且删除 Caffeine 失败概率极小,因此无需延时再次删除本地缓存;  2. 失败重试:但是此时删除缓存可能还是会失败,可以将异步删除放入消息队列中,或者如果失败再放入消息队列中,在消息队列中可以进行重试,以确保最终将缓存删除,Caffeine 在 1 中,已经说明失败概率极小,如果失败了,可能是代码本身有问题,可直接抛异常报错;   3. 订阅变更日志:加入消息队列会产生额外的维护成本,此时可以使用 canal,当成功修改 MySQL 之后,会产生 Binlog,canal 会自动将日志投递到下游的消息队列中  4. 分布式锁:当然,上面所述方案还是不能达到完全一致性,那我们可以为其加入分布式锁,可是一旦加上分布式锁,代表性能会下降,那我们使用缓存的意义又是什么呢,因此不建议采用分布式锁。 5. 过期兜底:虽然上面的方案已经做了很多事情了,但是无论如何,我们都应该为缓存加上过期时间,且本地缓存 < Redis 缓存达到兜底方案,避免中间服务任何一步出现错误,来让数据长期不一致 # 实战(基础) 背景:查询商品 开源地址:[https://github.com/sonshu-tulin/multi-cache](https://github.com/sonshu-tulin/multi-cache) ## 引入依赖 ```xml <!-- Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 本地缓存 Caffeine --> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>3.1.8</version> </dependency> ``` ## 创建表 ```sql -- 商品表:存储商品基本信息(ID、名称、价格、库存等) CREATE TABLE IF NOT EXISTS product ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '商品ID(主键)', name VARCHAR(255) NOT NULL COMMENT '商品名称', price DECIMAL(10, 2) NOT NULL COMMENT '商品售价(精确到分)', stock INT NOT NULL DEFAULT 0 COMMENT '商品库存数量', update_time BIGINT NOT NULL COMMENT '最后更新时间(时间戳,用于版本控制)', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (id), KEY idx_update_time (update_time) COMMENT '索引:按更新时间查询(优化缓存同步场景)' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品信息表'; ``` ## 使用Mybatis-X生成代码结构 在表右键点击MybatisX插件生成  选择目录和表  选择mybatisplus3  这是生成之后的项目结构  ## 代码实现 1. 写 Controller 结构 ```java @RestController @RequestMapping("/products") public class ProductController { @Resource private ProductService productService; // 查询商品详情 @GetMapping("/{id}") public Product getProduct(@PathVariable Long id) { return productService.getProductDetail(id); } } ``` 2. 写 Service 实现 ```java public interface ProductService extends IService<Product> { Product getProductDetail(Long id); } ``` 3. 写 Impl 实现 可以分为 5 步: ```java @Service public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product> implements ProductService { // 1. 创建缓存 // 引入 Redis @Resource private StringRedisTemplate stringRedisTemplate; // 创建本地缓存 public final Cache<String, String> LOCAL_CACHE = Caffeine.newBuilder().initialCapacity(1024) .maximumSize(10000L) // 缓存 5 分钟移除 .expireAfterWrite(5L, TimeUnit.MINUTES) .build(); @Override public Product getProductDetail(Long id) { // 2. 构建缓存key String redisKey = DigestUtils.md5DigestAsHex(id.toString().getBytes()); String cacheKey = "cache" + redisKey; // 3. 从本地缓存中查询 String cacheValue = LOCAL_CACHE.getIfPresent(cacheKey); if (StrUtil.isNotBlank(cacheValue)){ // 如果缓存命中 return JSONUtil.toBean(cacheValue, Product.class); } // 4. 从 Redis 中查询 cacheValue = stringRedisTemplate.opsForValue().get(redisKey); if (StrUtil.isNotBlank(cacheValue)){ // 如果缓存命中, 先回写本地缓存 Product product = JSONUtil.toBean(cacheValue, Product.class); LOCAL_CACHE.put(cacheKey, cacheValue); return product; } // 5. 查询数据库 Product product = this.getById(id); if (product != null ){ // 如果查询到结果,先回写 Redis,再回写 被本地缓存 cacheValue = JSONUtil.toJsonStr(product); stringRedisTemplate.opsForValue().set(redisKey, cacheValue); LOCAL_CACHE.put(cacheKey, cacheValue); return product; } return product; } } ``` ## 结果测试 说明:这里为了展示结果,特意将 redis 、 mysql 数据库均部署在服务器上,以模拟实际环境,若部署在本地,会导致结果不明显 先插入一条数据在数据库中  先点击 controller 中的禁止小绿  得到  进行测试 第一次结果:497ms  第二次结果:18ms  第三次测试:5ms  后面继续测试效果不会有明显提升 ## 结果分析 第一次查询时,本地缓存与 redis 中均没有缓存,会从 mysql 中查询,比较慢 第二次查询时,由于我点击较快,还没有将数据缓存在本地,从 redis 中查询,但速度获得大幅度提升 第三次查询时,缓存已经到本地,查询非常快 后续查询,缓存都在本地,查询速度没有明显提升 # 实战(高级) 基础代码写完后,在实际场景中会遇到 2 中的问题,下面我们分别会在代码中去解决,由于在查询时会用到MQ,因此,我们先讲数据一致性,再讲其它三个 ## 数据一致性 ### 延时双删 在 controller 中写入更新信息 ```java /** * 修改商品信息 * @param product 商品 */ @PostMapping("/update") public void updateProduct(@RequestBody Product product){ productService.updateProduct(product); } ``` 在 Impl 中写入异步延时删除 ```java // 注入异步线程池(避免使用主线程) @Resource private ThreadPoolTaskExecutor asyncTaskExecutor; @Override public void updateProduct(Product product) { // 1. 更新数据库 this.updateById(product); // 2. 构建缓存Key(与查询时保持一致) Long productId = product.getId(); String redisKey = DigestUtils.md5DigestAsHex(productId.toString().getBytes()); String cacheKey = "cache" + redisKey; // 3. 删除 Caffeine LOCAL_CACHE.invalidate(cacheKey); System.out.println("本地缓存已删除:" + cacheKey); // 4. 删除Redis缓存 Boolean delete = stringRedisTemplate.delete(redisKey); if (delete) { System.out.println("Redis缓存已删除:" + redisKey); } // 5. 异步延时双删除 asyncTaskExecutor.execute(() -> { try { TimeUnit.SECONDS.sleep(3000); boolean delayDeleted = stringRedisTemplate.delete(redisKey); if (delayDeleted) { System.out.println("延时删除Redis缓存成功:" + redisKey); } }catch (Exception e) { System.err.println("延时删除发生异常:" + e.getMessage()); } }); } ``` ### 失败重试(使用 MQ) 引入依赖 ```xml <!-- Rabbitmq --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> ``` 配置MQ ```java /** * 配置 RabbitMQ */ @Configuration public class RabbitMQConfig { // 交换机名称(商品数据变更) public static final String PRODUCT_EXCHANGE = "product.exchange"; // 队列名称(缓存失效通知) public static final String PRODUCT_CACHE_QUEUE = "product.cache.queue"; // 路由键(匹配商品ID的变更通知) public static final String PRODUCT_CACHE_ROUTING_KEY = "product.cache.invalid.#"; // 创建交换机 @Bean public TopicExchange productExchange() { return new TopicExchange(PRODUCT_EXCHANGE, true, false); } // 创建队列(持久化) @Bean public Queue productCacheQueue() { return new Queue(PRODUCT_CACHE_QUEUE, true, false, false); } // 绑定交换机和队列 @Bean public Binding bindingProductCacheQueue(TopicExchange productExchange, Queue productCacheQueue) { return BindingBuilder.bind(productCacheQueue).to(productExchange).with(PRODUCT_CACHE_ROUTING_KEY); } } ``` 编写消费者监听代码 ```java @Component public class ProductCacheInvalidListener { @Resource private StringRedisTemplate stringRedisTemplate; // 监听缓存失效队列 @RabbitListener(queues = RabbitMQConfig.PRODUCT_CACHE_QUEUE) public void handleCacheInvalid(String redisKey) { boolean deleted = stringRedisTemplate.delete(redisKey); if (deleted) { System.out.println("Redis缓存删除成功:" + redisKey); } else { // Redis 删除失败,抛出异常触发 MQ 重试 throw new RuntimeException("Redis缓存删除失败,触发MQ重试:" + redisKey); } // 如果均重试失败,还可以将其放入死信队列中,此处不再赘述 } } ``` 编写生产者代码,在删除失败的时候将其放入消息队列中 ```java // 引入 RabbitMQ @Resource private RabbitTemplate rabbitTemplate; // 注入异步线程池(避免使用主线程) @Resource private ThreadPoolTaskExecutor asyncTaskExecutor; @Override public void updateProduct(Product product) { // 1. 更新数据库 this.updateById(product); // 2. 构建缓存Key(与查询时保持一致) Long productId = product.getId(); String redisKey = DigestUtils.md5DigestAsHex(productId.toString().getBytes()); String cacheKey = "cache" + redisKey; // 3. 删除 Caffeine LOCAL_CACHE.invalidate(cacheKey); System.out.println("本地缓存已删除:" + cacheKey); // 4. 删除Redis缓存 Boolean delete = stringRedisTemplate.delete(redisKey); if (delete) { System.out.println("Redis缓存已删除:" + redisKey); } // 5. 异步延时双删除 asyncTaskExecutor.execute(() -> { try { TimeUnit.SECONDS.sleep(3000); boolean delayDeleted = stringRedisTemplate.delete(redisKey); if (delayDeleted) { System.out.println("延时删除Redis缓存成功:" + redisKey); }else { System.out.println("延时删除Redis缓存失败,准备放入MQ重试:" + redisKey); // 若延时删除仍失败,放入MQ重试(确保最终一致性) String routingKey = "product.cache.invalid.retry." + redisKey; rabbitTemplate.convertAndSend( RabbitMQConfig.PRODUCT_EXCHANGE, // 交换机 routingKey, // 路由键 redisKey // 消息 ); } }catch (Exception e) { System.err.println("延时删除发生异常:" + e.getMessage()); } }); } ``` ### 订阅变更日志(使用 Canal) 引入依赖,此处使用1.1.8版本 ```java <!--canal--> <!-- Canal客户端核心依赖 --> <dependency> <groupId>com.alibaba.otter</groupId> <artifactId>canal.client</artifactId> <version>1.1.8</version> </dependency> <!-- 显式引入protocol包(包含异常类定义) --> <dependency> <groupId>com.alibaba.otter</groupId> <artifactId>canal.protocol</artifactId> <version>1.1.8</version> </dependency> ``` 编写配置文件 ```java @Data @Component @ConfigurationProperties(prefix = "canal") // 对应YML中的canal前缀 public class CanalProperties { private Server server = new Server(); private String destination; private String username; private String password; private Listen listen = new Listen(); // 内部类:服务端地址和端口 @Data public static class Server { private String host; private int port; } // 内部类:监听的数据库和表 @Data public static class Listen { private String database; private String table; } } ``` ```java @Configuration public class CanalConfig { @Resource private CanalProperties canalProperties; @Bean public CanalConnector canalConnector() { // 从配置类中获取参数 String host = canalProperties.getServer().getHost(); int port = canalProperties.getServer().getPort(); String destination = canalProperties.getDestination(); String username = canalProperties.getUsername(); String password = canalProperties.getPassword(); // Canal 客户端的用户名/密码用于 Canal 服务端鉴权(通常默认不开启)。 // 若未在 Canal 服务端开启鉴权,应当传 null,否则会因为鉴权失败而无法连接。 // 创建Canal连接 return CanalConnectors.newSingleConnector( new InetSocketAddress(host, port), destination, null, null ); } } ``` 编写 Canal 监听器 ```java @Component public class CanalProductListener { // 本地缓存(Caffeine),假设已定义为全局常量 public static final com.github.benmanes.caffeine.cache.Cache<String, Object> LOCAL_CACHE = com.github.benmanes.caffeine.cache.Caffeine.newBuilder().build(); @Resource private CanalConnector canalConnector; @Resource private StringRedisTemplate stringRedisTemplate; @Resource private CanalProperties canalProperties; @Resource private RabbitTemplate rabbitTemplate; // 线程池处理Canal事件(避免阻塞) private final ExecutorService executor = Executors.newSingleThreadExecutor(); // 记录应用启动时间,用于过滤历史事件 private final long appStartTimeMillis = System.currentTimeMillis(); // 初始化时启动监听 @PostConstruct public void startListener() { executor.submit(() -> { // 持续运行,异常后自动重连 while (true) { try { // 建立连接 canalConnector.connect(); System.out.println("Canal连接成功"); // 从配置中获取监听的数据库和表(动态生成订阅表达式) String database = canalProperties.getListen().getDatabase(); String table = canalProperties.getListen().getTable(); String subscribeTable = database + "\\." + table; // 如:your_db.product canalConnector.subscribe(subscribeTable); // 订阅指定表 canalConnector.rollback(); // 拉取并处理消息 while (true) { Message message = canalConnector.getWithoutAck(100, 5000L, TimeUnit.MILLISECONDS); long batchId = message.getId(); int size = message.getEntries().size(); if (batchId == -1 || size == 0) { Thread.sleep(1000); continue; } handleEntries(message.getEntries()); canalConnector.ack(batchId); } } catch (Exception e) { // 打印完整堆栈,便于定位 Read timed out 等问题 e.printStackTrace(); System.out.println("Canal连接失败或中断,5秒后重试..."); try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException ignored) { } } finally { try { canalConnector.disconnect(); } catch (Exception ignored) { } } } }); } //处理Canal消息条目 private void handleEntries(List<CanalEntry.Entry> entries) { for (CanalEntry.Entry entry : entries) { // 过滤非事务日志类型 if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) { continue; } // 忽略应用启动前产生的历史事件,避免冷启动时清空缓存 if (entry.getHeader() != null && entry.getHeader().getExecuteTime() > 0 && entry.getHeader().getExecuteTime() < appStartTimeMillis) { continue; } try { // 解析binlog日志 CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue()); CanalEntry.EventType eventType = rowChange.getEventType(); // 只处理UPDATE事件(如果需要处理新增/删除,可添加EventType.INSERT/DELETE) if (eventType != CanalEntry.EventType.UPDATE) { continue; } // 处理每行数据的变更 for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) { handleProductUpdate(rowData.getAfterColumnsList()); // 取更新后的数据 } } catch (Exception e) { e.printStackTrace(); } } } // 处理商品更新事件,删除对应缓存 private void handleProductUpdate(List<CanalEntry.Column> afterColumns) { // 从变更数据中获取productId(假设表的主键为id) Long productId = null; for (CanalEntry.Column column : afterColumns) { if ("id".equals(column.getName())) { // 匹配主键字段名 productId = Long.parseLong(column.getValue()); break; } } if (productId == null) { System.out.println("未找到productId,跳过缓存删除"); return; } // 构建缓存Key(与原逻辑保持一致) String redisKey = DigestUtils.md5DigestAsHex(productId.toString().getBytes()); String cacheKey = "cache" + redisKey; // 1. 删除本地缓存Caffeine LOCAL_CACHE.invalidate(cacheKey); System.out.println("Canal触发本地缓存删除:" + cacheKey); // 2. 删除Redis缓存 Boolean delete = stringRedisTemplate.delete(redisKey); if (delete) { System.out.println("Canal触发Redis缓存删除:" + redisKey); } else { System.out.println("Redis缓存不存在或已删除:" + redisKey); } // 3. (可选)保留延时双删逻辑,进一步确保缓存一致性 asyncDelayDelete(redisKey); } // 异步延时删除(与原逻辑一致,可选) private void asyncDelayDelete(String redisKey) { Executors.newSingleThreadExecutor().execute(() -> { try { TimeUnit.SECONDS.sleep(3); // 注意:原代码写的3000秒,这里修正为3秒(根据业务调整) boolean delayDeleted = stringRedisTemplate.delete(redisKey); if (delayDeleted) { System.out.println("Canal延时删除Redis缓存成功:" + redisKey); } else { System.out.println("Canal延时删除Redis缓存失败,放入MQ重试:" + redisKey); // 放入MQ时携带时间戳头,供消费者过滤启动前的旧消息 String routingKey = "product.cache.invalid.retry." + redisKey; rabbitTemplate.convertAndSend( RabbitMQConfig.PRODUCT_EXCHANGE, routingKey, redisKey, message -> { message.getMessageProperties().setHeader("ts", System.currentTimeMillis()); return message; } ); } } catch (Exception e) { System.err.println("Canal延时删除异常:" + e.getMessage()); } }); } } ``` 在编写业务代码时,仅仅需要更新数据库,无入侵代码 ```java @Override public void updateProductByCanal(Product product) { // 1. 更新数据库 this.updateById(product); } ``` ### 分布式锁 由于分布式锁,不建议在此处使用,因此不过多赘述 ### 过期兜底 任何一种方案,都可能会出现错误,于是我们应该在缓存上添加过期时间,且本地缓存 < Reids 本地缓存,当写入后,3分钟会过期 ```java public final Cache<String, String> LOCAL_CACHE = Caffeine.newBuilder() .initialCapacity(1024) // 初始容量 .maximumSize(10000L) // 最大缓存数量(超过后按 LRU 策略淘汰) .expireAfterWrite(3, TimeUnit.MINUTES) // 3 分钟后过期 .recordStats() // 可选:记录缓存命中率等统计信息 .build(); ``` Redis 缓存,当存入缓存的时候可以进行设置 ```java // 设置过期时间 int baseExpire = 5; stringRedisTemplate.opsForValue().set(redisKey, cacheValue, baseExpire, TimeUnit.MINUTES); ``` ## 缓存穿透 ### 合法性检查 此处仅为演示,具体需要修改为业务所需,也可直接报异常 ```java // 1. 合法性检查 if (id == null || id < 0){ log.error("id:{},非法",id); return null; } ``` ### 缓存空值 1. 先定义空值 2. 本地缓存中查询到空值需要判断并做相应的处理 3. Redis 中查询到空值需要判断,回写本地缓存,并做相应的处理 4. 数据库中查询不到数据时,需要将空值回写到 Redis 和本地缓存,并做相应的处理 ```java private static final String NULL_PLACEHOLDER = "NULL_PLACEHOLDER"; @Override public Product getProductDetail(Long id) { // 1. 合法性检查 if (id == null || id < 0){ log.error("id:{},非法",id); return null; } // 2. 构建缓存key String redisKey = DigestUtils.md5DigestAsHex(id.toString().getBytes()); String cacheKey = "cache" + redisKey; // 3. 从本地缓存中查询 String cacheValue = LOCAL_CACHE.getIfPresent(cacheKey); if (StrUtil.isNotBlank(cacheValue)){ // 处理空值标记 if (NULL_PLACEHOLDER.equals(cacheValue)){ log.warn("该值为空"); return null; } // 如果缓存命中 return JSONUtil.toBean(cacheValue, Product.class); } // 4. 从 Redis 中查询 cacheValue = stringRedisTemplate.opsForValue().get(redisKey); if (StrUtil.isNotBlank(cacheValue)){ if (NULL_PLACEHOLDER.equals(cacheValue)){ // 回写本地缓存 LOCAL_CACHE.put(cacheKey, cacheValue); log.warn("该值为空"); return null; } // 如果缓存命中, 先回写本地缓存 Product product = JSONUtil.toBean(cacheValue, Product.class); LOCAL_CACHE.put(cacheKey, cacheValue); return product; } // 5. 查询数据库 Product product = this.getById(id); if (product != null ){ // 如果查询到结果,先回写 Redis,再回写 被本地缓存 cacheValue = JSONUtil.toJsonStr(product); // 设置过期时间 int baseExpire = 5; stringRedisTemplate.opsForValue().set(redisKey, cacheValue, baseExpire, TimeUnit.MINUTES); LOCAL_CACHE.put(cacheKey, cacheValue); return product; }else { // 6. 如果查询数据不存在,缓存空值 int baseExpire = 5; stringRedisTemplate.opsForValue().set(redisKey, NULL_PLACEHOLDER,baseExpire, TimeUnit.MINUTES); LOCAL_CACHE.put(cacheKey, NULL_PLACEHOLDER); return null; } } ``` ### 布隆过滤器 todo 由于该插件版本问题,暂未实践,但上述原理已经讲清楚,也可自己实现一个过滤器,待续..... ## 缓存击穿 ### 分布式锁 引入依赖 ```java <!-- Redisson --> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.50.0</version> </dependency> ``` 添加配置 ```java @Configuration public class RedissonConfig { @Value("${spring.data.redis.host}") private String redisHost; @Value("${spring.data.redis.port}") private Integer redisPort; @Value("${spring.data.redis.password}") private String redisPassword; @Value("${spring.data.redis.database}") private Integer redisDatabase; @Bean public RedissonClient redissonClient() { Config config = new Config(); String address = "redis://" + redisHost + ":" + redisPort; SingleServerConfig singleServerConfig = config.useSingleServer() .setAddress(address) .setDatabase(redisDatabase) .setConnectionMinimumIdleSize(1) .setConnectionPoolSize(10) .setIdleConnectionTimeout(30000) .setConnectTimeout(5000) .setTimeout(3000) .setRetryAttempts(3) .setRetryInterval(1500); // 如果有密码则设置密码 if (redisPassword != null && !redisPassword.isEmpty()) { singleServerConfig.setPassword(redisPassword); } return Redisson.create(config); } } ``` 编写代码 ```java /** * 通过分布式锁查询数据库 * @param id 查询条件 */ private Product getProductByRedisson(Long id, String redisKey, String cacheKey) { String cacheValue; Product product = null; // 定义锁的 key(建议加上业务前缀,避免冲突) String lockKey = "lock:product:detail:" + id; // 获取锁对象 RLock lock = redissonClient.getLock(lockKey); try { // 尝试获取锁:最多等待 100ms,10秒后自动释放(防止死锁) // 注意:Redisson 的看门狗机制会自动续期,只要线程未释放锁且未宕机,锁不会过期 boolean isLocked = lock.tryLock(100, 10, TimeUnit.SECONDS); if (isLocked) { // 成功获取锁后,再次检查 Redis 缓存(避免其他线程已重建缓存) String doubleCheckValue = stringRedisTemplate.opsForValue().get(redisKey); if (StrUtil.isNotBlank(doubleCheckValue)) { return handleCacheHit(cacheKey, doubleCheckValue); } // 真正查询数据库 product = this.getById(id); // 7. 回写缓存(设置随机过期时间,避免缓存雪崩) if (product != null) { cacheValue = JSONUtil.toJsonStr(product); } else { // 缓存空值,避免缓存穿透 cacheValue = NULL_PLACEHOLDER; } // 过期时间设置为 5 分钟 int randomExpire = 5; stringRedisTemplate.opsForValue().set(redisKey, cacheValue, randomExpire, TimeUnit.MINUTES); // 回写本地缓存 LOCAL_CACHE.put(cacheKey, cacheValue); } else { // 未获取到锁,休眠 50-100ms 后重试(避免频繁重试) Thread.sleep(50 + new Random().nextInt(51)); return getProductDetailByRedisson(id); } } catch (InterruptedException e) { log.error("获取锁或重试失败", e); Thread.currentThread().interrupt(); // 恢复中断状态 return null; } finally { // 释放锁(只有持有锁的线程才能释放) if (lock.isHeldByCurrentThread()) { lock.unlock(); } } return product; } // 封装缓存命中处理逻辑(简化代码) private Product handleCacheHit(String cacheKey, String cacheValue) { if (NULL_PLACEHOLDER.equals(cacheValue)) { LOCAL_CACHE.put(cacheKey, cacheValue); log.warn("该值为空"); return null; } Product product = JSONUtil.toBean(cacheValue, Product.class); LOCAL_CACHE.put(cacheKey, cacheValue); return product; } ``` ### 永不过期 + 主动更新 不设置过期时间即可,主动更新上面mq已经写过了 ### 逻辑过期 + 被动更新 定义带逻辑时间的缓存结构 ```java // 定义带逻辑过期时间的缓存结构 @Data private static class LogicExpireCache { private Product data; // 实际业务数据 private long expireTime; // 逻辑过期时间(毫秒时间戳) } ``` 修改缓存命中的处理方法 ```java // 处理缓存命中逻辑(核心:判断逻辑过期并触发异步更新) private Product handleLogicCacheHit(String cacheKey, String cacheValue) { // 空值处理(保持不变) if (NULL_PLACEHOLDER.equals(cacheValue)) { LOCAL_CACHE.put(cacheKey, cacheValue); log.warn("该值为空"); return null; } // 解析带逻辑过期时间的缓存数据 LogicExpireCache cacheData = JSONUtil.toBean(cacheValue, LogicExpireCache.class); Product product = cacheData.getData(); long expireTime = cacheData.getExpireTime(); // 判断是否逻辑过期 if (System.currentTimeMillis() < expireTime) { // 未过期:直接返回数据,回写本地缓存 LOCAL_CACHE.put(cacheKey, cacheValue); return product; } else { // 已过期:返回旧数据,同时异步更新缓存(不阻塞当前请求) LOCAL_CACHE.put(cacheKey, cacheValue); // 本地缓存先保留旧数据 asyncUpdateLogicCache(cacheKey, cacheData.getData().getId()); // 异步更新 return product; // 先返回旧数据,保证响应速度 } } ``` 编写核心流程 ```java @Override public Product getProductDetailByLogicExpire(Long id) { // 1. 合法性检查 if (id == null || id < 0) { log.error("id:{},非法", id); return null; } // 2. 构建缓存key String redisKey = DigestUtils.md5DigestAsHex(id.toString().getBytes()); String cacheKey = "cache" + redisKey; // 3. 从本地缓存中查询(核心:处理逻辑过期) String cacheValue = LOCAL_CACHE.getIfPresent(cacheKey); if (StrUtil.isNotBlank(cacheValue)) { return handleLogicCacheHit(cacheKey, cacheValue); } // 4. 从 Redis 中查询(核心:处理逻辑过期) cacheValue = stringRedisTemplate.opsForValue().get(redisKey); if (StrUtil.isNotBlank(cacheValue)) { return handleLogicCacheHit(cacheKey, cacheValue); } // 5. Redis缓存未命中(首次查询或缓存被意外删除) // 直接查库并初始化逻辑过期缓存(无需加锁,首次查询压力低) Product product = this.getById(id); if (product != null) { // 封装带逻辑过期时间的缓存数据(设置5分钟后逻辑过期) LogicExpireCache cacheData = new LogicExpireCache(); cacheData.setData(product); cacheData.setExpireTime(System.currentTimeMillis() + 5 * 60 * 1000); // 5分钟后过期 String jsonValue = JSONUtil.toJsonStr(cacheData); // Redis存储时不设置物理过期(永不过期) stringRedisTemplate.opsForValue().set(redisKey, jsonValue); // 回写本地缓存 LOCAL_CACHE.put(cacheKey, jsonValue); return product; } else { // 缓存空值(避免缓存穿透,设置物理过期,防止长期占用空间) stringRedisTemplate.opsForValue().set(redisKey, NULL_PLACEHOLDER, 5, TimeUnit.MINUTES); LOCAL_CACHE.put(cacheKey, NULL_PLACEHOLDER); return null; } } // 异步更新逻辑过期缓存(加简单锁避免并发更新) @Async // 需要开启@EnableAsync注解 public void asyncUpdateLogicCache(String cacheKey, Long id) { String redisKey = DigestUtils.md5DigestAsHex(id.toString().getBytes()); String lockKey = "lock:product:update:" + id; // 异步更新的锁 RLock lock = redissonClient.getLock(lockKey); try { // 尝试获取锁,最多等1秒,持有3秒(防止更新逻辑卡住) boolean isLocked = lock.tryLock(1, 3, TimeUnit.SECONDS); if (isLocked) { // 查询最新数据 Product newProduct = this.getById(id); if (newProduct != null) { // 生成新的逻辑过期时间(续5分钟) LogicExpireCache newCacheData = new LogicExpireCache(); newCacheData.setData(newProduct); newCacheData.setExpireTime(System.currentTimeMillis() + 5 * 60 * 1000); String newJsonValue = JSONUtil.toJsonStr(newCacheData); // 更新Redis和本地缓存 stringRedisTemplate.opsForValue().set(redisKey, newJsonValue); LOCAL_CACHE.put(cacheKey, newJsonValue); } else { // 数据已删除,缓存空值(物理过期) stringRedisTemplate.opsForValue().set(redisKey, NULL_PLACEHOLDER, 5, TimeUnit.MINUTES); LOCAL_CACHE.put(cacheKey, NULL_PLACEHOLDER); } } } catch (InterruptedException e) { log.error("异步更新缓存失败", e); Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } ``` ## 缓存雪崩 ### 过期时间加随机值,多级缓存时间错开 由于 Caffeine 没有提供可随意添加过期时间的 put 方法,我自定义了一个工具类,用于添加随机时间 ```java /** * Caffeine 缓存工具类,支持动态设置每个 key 的过期时间 */ public class CaffeineCacheUtil<K, V> { // 底层 Caffeine 缓存 private final Cache<K, V> cache; // 存储每个 key 的过期时间(纳秒),用于动态调整 private final Map<K, Long> keyExpireMap = new ConcurrentHashMap<>(); // 随机数生成器(复用避免性能损耗) private final Random random = new Random(); /** * 初始化缓存 * @param initialCapacity 初始容量 * @param maximumSize 最大容量 */ public CaffeineCacheUtil(int initialCapacity, long maximumSize) { // 构建缓存,使用自定义 Expiry 策略 this.cache = Caffeine.newBuilder() .initialCapacity(initialCapacity) .maximumSize(maximumSize) .expireAfter(new CustomExpiry<>()) // 自定义过期策略 .recordStats() .build(); } /** * 添加缓存(永不过期) */ public void put(K key, V value) { // 不设置过期时间,不在 keyExpireMap 中存储,视为永不过期 cache.put(key, value); } /** * 添加缓存(使用默认过期时间:3分钟 + 0-300秒随机) */ public void putWithDefaultExpire(K key, V value) { // 生成默认随机过期时间(3-8分钟) long baseExpire = TimeUnit.MINUTES.toNanos(3); // 3分钟(纳秒) long randomOffset = TimeUnit.SECONDS.toNanos(random.nextInt(301)); // 0-300秒随机 long expireNanos = baseExpire + randomOffset; put(key, value, expireNanos, TimeUnit.NANOSECONDS); } /** * 添加缓存(自定义过期时间) */ public void put(K key, V value, long duration, TimeUnit unit) { // 转换为纳秒并存储 long expireNanos = unit.toNanos(duration); keyExpireMap.put(key, expireNanos); // 放入缓存 cache.put(key, value); } /** * 获取缓存 */ @Nullable public V getIfPresent(K key) { return cache.getIfPresent(key); } /** * 移除缓存 */ public void invalidate(K key) { cache.invalidate(key); keyExpireMap.remove(key); } /** * 清空缓存 */ public void invalidateAll() { cache.invalidateAll(); keyExpireMap.clear(); } /** * 获取缓存统计信息 */ public String stats() { return cache.stats().toString(); } /** * 自定义过期策略:从 keyExpireMap 中获取每个 key 的过期时间 */ private class CustomExpiry<K, V> implements Expiry<K, V> { @Override public long expireAfterCreate(@NonNull K key, @NonNull V value, long currentTime) { // 创建时,从 map 中获取预设的过期时间(默认 0 表示立即过期,避免未设置的 key 永不过期) return keyExpireMap.getOrDefault(key, 0L); } @Override public long expireAfterUpdate(@NonNull K key, @NonNull V value, long currentTime, @NonNegative long currentDuration) { // 更新时,使用新设置的过期时间(若未重新设置则沿用旧值) return keyExpireMap.getOrDefault(key, currentDuration); } @Override public long expireAfterRead(@NonNull K key, @NonNull V value, long currentTime, @NonNegative long currentDuration) { // 读取时不改变过期时间(可根据需求改为延长过期) return currentDuration; } } } ``` 封装生成随机过期时间的方法 ```java /** * 生成随机过期时间(秒) * @param maxRandomSeconds 随机数的最大范围(秒),必须为非负数 * @return 0 到 maxRandomSeconds(包含)之间的随机秒数 */ public static int generateRandomExpireSeconds(int maxRandomSeconds) { // 校验参数:若传入负数,默认返回0(避免异常) if (maxRandomSeconds < 0) { return 0; } // 生成 0 到 maxRandomSeconds(包含)的随机整数 return RANDOM.nextInt(maxRandomSeconds + 1); } ``` 修改创建 Caffeine 的代码 ```java // 使用自定义本地缓存工具类创建本地缓存 public final CaffeineCacheUtil<String, String> LOCAL_CACHE = new CaffeineCacheUtil<>(1024, 10000L); ``` 在添加 Caffeine 缓存的代码中加入随机过期时间 ```java LOCAL_CACHE.put(cacheKey, cacheValue, 180 + generateRandomExpireSeconds(180),TimeUnit.SECONDS); ``` ### 缓存集群化部署 todo 文字描述繁琐,待补 # 总结 ## 针对查询 其实使用查询的流程可以是一个模板方法,现在我们用图的方式来一步步深刻理解其流程。  ### 分布式锁策略 特性: 缓存穿透:合法性检查、缓存空值 缓存击穿:分布式锁 缓存雪崩:过期时间随机化,Caffeine < Redis  ### 逻辑过期策略 特性: 缓存穿透:合法性检查、缓存空值 缓存击穿:逻辑过期 缓存雪崩:需要通过其它方式(集群部署、降级熔断)  异步更新缓存,这个操作是异步的,且需要上分布式锁,但是这个影响没有前面那么大:  ## 针对修改 在第 2 章节,已经对其做了详细的讲解,因此,本节做最后的总结 ### MQ 在更新数据时使用延时双删,删除失败后,生产消息给 MQ 消费者,处理消息,就进行删除缓存  ### Canal 需要进行配置Canal才行 
七牛云秋招实战项目
# 七牛云秋招实战项目 项目链接:[Novel2Comic - 基于AI的小说漫画生成平台](https://github.com/lmqvq/mq-ai-Novel2Comic) 演示 Demo:[七牛云第二批次议题一比赛作品《novel2comic》_哔哩哔哩_bilibili](https://www.bilibili.com/video/BV124szzeEkD/?vd_source=d744e94da62cdbcaaf09c8b3455dbed0) ## 第二批议题  ## 开发时间 10月20日 00:00 --- 10月26日 23:59 ## 项目效果 ### 主页  ### 上传小说  ### 角色信息  ### 分镜预览  ### 漫画完成页  ### 漫画预览  ### 个人作品 ![转存失败,请重新上传图片]() GLM-4-flash 模型生成日系漫画还可以,国风漫画与写实风格真的一言难尽 ## 优化 因为时间原因只完成这些基础的功能,后续有时间会继续优化这个项目,佬们有什么优化建议可以说说 后续优化方向: 1. 角色一致性 2. 提高生成速度 3. 公共作品展示 4. 创作排行榜
Dubbo框架服务间RPC接口调用超时处理方案
## 问题背景: <font style="color:rgb(51, 51, 51);">在我的微服务中,有A服务做定时任务,B服务做数据收集。服务间通过dubbo框架进行通信。 现在我用A服务通过dubbo的rpc接口调用B服务的数据收集任务。因为任务耗时久的原因,A服务会报错:</font> <font style="color:rgba(0, 0, 0, 0.9);background-color:rgb(243, 243, 243);">`Timeout: 30000ms`</font> 在此基础上,有两个思路进行优化 1. 在调用接口时添加超时设置 ```java @DubboReference( timeout = 7200000, // 2小时超时,根据实际需要调整 ) ``` 2. 将同步调用改为异步调用 因为我的服务端方法是一个数据收集解析的长时间任务,更具数据量大小,最大执行时间可能达到小时级别。所以第一种方案被我弃掉。采用了异步调用的改造方案。 ## 一.异步改造 ### 服务端改造: 在原有代码基础上添加`@Async`注解 ```java @Override @Async // 使用Spring的异步执行 public CompletableFuture<String> addDatas() { try { log.info("开始执行长时间数据收集任务..."); dataService.addDatas(); log.info("数据收集任务完成"); } catch (Exception e) { log.error("数据收集任务失败", e); } } ``` ### 客户端改造: 客户端则需要在`@DubboReference`注解中指定` async = true`开启异步调用 ```java @DubboReference( async = true, timeout = 7200000, // 2小时超时,根据实际需要调整 ) private DataRpcService dataRpcService; @Override public void run() { log.info("定时任务 - 数据收集"); // 发起异步调用 dataRpcService.addDatas(); } ``` 这个时候,我们的服务就不会出现接口超时的报错了。但是这样会引发一个日志记录错误的问题。就是任务日志会在我们发起异步调用后,直接记录结束。即使这时服务端还在执行任务。为了解决这个问题。需要引入一个<font style="color:rgb(0, 0, 0);">核心组件</font><font style="color:rgb(0, 0, 0);">`CompletableFuture`</font><font style="color:rgb(0, 0, 0);">。</font> ## <font style="color:rgb(0, 0, 0);">一.核心组件</font><font style="color:rgb(0, 0, 0);">CompletableFuture</font>,解决日志记录不准问题 通过与AI交互。发现了Dubbo框架支持基于<font style="color:rgb(0, 0, 0);">`CompletableFuture`</font><font style="color:rgb(0, 0, 0);">接口的异步调用。</font><font style="color:rgb(0, 0, 0);">而不会阻塞当前线程。该`Future`对象相当于一个"凭证",消费者可以:</font> ### 工作原理: 1. **<font style="color:rgb(0, 0, 0);">立即返回</font>**<font style="color:rgb(0, 0, 0);">:A服务发起调用后,Dubbo框架会立即构造并返回一个</font><font style="color:rgb(0, 0, 0);">`CompletableFuture`</font><font style="color:rgb(0, 0, 0);">对象,而不会阻塞等待B服务的业务逻辑执行完毕。这时,A服务发起的这次RPC(远程过程调用)在网络层面可以很快结束,避免了因长时间等待而触发的超时。</font> 2. **<font style="color:rgb(0, 0, 0);">后台执行</font>**<font style="color:rgb(0, 0, 0);">:B服务在接收到请求后,开始执行实际耗时很长的</font><font style="color:rgb(0, 0, 0);">`addDatas`</font><font style="color:rgb(0, 0, 0);">方法。这个执行过程与A服务已经解耦。</font> 3. **<font style="color:rgb(0, 0, 0);">结果回调</font>**<font style="color:rgb(0, 0, 0);">:当B服务的方法执行完毕后,Dubbo框架会隐式地将结果设置回之前A服务拿到的那个</font><font style="color:rgb(0, 0, 0);">`CompletableFuture`</font><font style="color:rgb(0, 0, 0);">对象。此时,在A服务中,通过</font><font style="color:rgb(0, 0, 0);">`future.get()`</font><font style="color:rgb(0, 0, 0);">等待这个结果,或者通过</font><font style="color:rgb(0, 0, 0);">`future.whenComplete()`</font><font style="color:rgb(0, 0, 0);">添加的回调函数就会被触发,从而获取到最终的执行结果("SUCCESS"或错误信息)。</font> ### 服务端接口实现: ```java @Override @Async // 使用Spring的异步执行 public CompletableFuture<String> addDatas() { try { log.info("开始执行长时间数据收集任务..."); dataService.addDatas(); log.info("数据收集任务完成"); return CompletableFuture.completedFuture("SUCCESS"); } catch (Exception e) { log.error("数据收集任务失败", e); return CompletableFuture.completedFuture("FAILED: " + e.getMessage()); } } ``` ### 客户端实现: <font style="color:rgb(0, 0, 0);">`CompletableFuture`</font><font style="color:rgb(0, 0, 0);">提供了</font><font style="color:rgb(0, 0, 0);">`future.get()`</font><font style="color:rgb(0, 0, 0);">和</font><font style="color:rgb(0, 0, 0);">`future.whenComplete()`</font><font style="color:rgb(0, 0, 0);">两种方法去处理。</font> **<font style="color:rgb(0, 0, 0);">同步等待机制</font>**<font style="color:rgb(0, 0, 0);">:</font><font style="color:rgb(0, 0, 0);">`future.get()`</font><font style="color:rgb(0, 0, 0);">会阻塞当前线程,直到B服务中的</font><font style="color:rgb(0, 0, 0);">`addData`</font><font style="color:rgb(0, 0, 0);">方法执行完成并返回结果。这使得</font><font style="color:rgb(0, 0, 0);"></font><font style="color:rgb(0, 0, 0);">能够准确记录从任务开始到结束的完整时间段。</font> ```java @DubboReference( async = true, timeout = 7200000, // 2小时超时,根据实际需要调整 retries = 0 // 异步调用不重试 ) private PdcUserLogRpcService pdcUserLogRpcService; @Override public void run() { log.info("定时任务 - UserLog 数据收集"); // 发起异步调用 pdcUserLogRpcService.addPdcUserLog(); CompletableFuture<String> future = RpcContext.getContext().getCompletableFuture(); try { // 同步等待异步任务完成(会阻塞当前线程,但不会超时) String result = future.get(7100, TimeUnit.SECONDS); // 略小于2小时,给日志记录留时间 if ("SUCCESS".equals(result)) { log.info("数据收集任务执行成功"); } else { log.warn("数据收集任务完成但有异常: {}", result); // 抛出异常让LoggableJobWrapper捕获并记录失败状态 throw new RuntimeException("远程任务返回失败: " + result); } } catch (Exception e) { log.error("任务失败", e); throw new RuntimeException("任务失败", e); } } ``` **<font style="color:rgb(0, 0, 0);">回调函数机制:</font>**`future.whenComplete()`会在任务结束后被唤醒,并拿到返回值。便于后续操作。 ```java @DubboReference(async = true, timeout = 7200000, retries = 0) private DataRpcService dataRpcService; // 用于跟踪长时间运行的任务 private final Map<String, CompletableFuture<String>> runningTasks = new ConcurrentHashMap<>(); @Override public void run() { String taskId = "DataTask-" + System.currentTimeMillis(); log.info("开始异步调用数据收集任务, 任务ID: {}", taskId); dataRpcService.addDatas(); CompletableFuture<String> future = RpcContext.getContext().getCompletableFuture(); // 保存任务引用 runningTasks.put(taskId, future); future.whenComplete((result, exception) -> { // 任务完成,从运行列表中移除 runningTasks.remove(taskId); if (exception != null) { log.error("任务 {} 执行失败", taskId, exception); } else { log.info("任务 {} 完成,结果: {}", taskId, result); } }); log.info("任务 {} 已提交,当前运行中的任务数量: {}", taskId, runningTasks.size()); } // 获取当前运行任务状态的方法 public int getRunningTaskCount() { return runningTasks.size(); } ```
Disruptor:高性能队列的颠覆者
# **<font style="color:rgb(0, 0, 0) !important;">为什么需要 Disruptor?</font>** ## 场景 1. 高性能日志收集与处理(如日志聚合系统) 2. 金融交易系统中高频交易数据的异步处理 3. 游戏领域高并发事件的分发与处理 4. 大数据厂家下的数据流式处理(如实时数据清洗、转换) 5. 文档中实时协同编辑 6. 需要高效传递消息的各种场景...... ## 传统解决方案 使用线程池处理高并发的线程交互 核心组件: 1. 阻塞队列:如ArrayBlockingQueue(数组实现,有界),LinkedBlockingQueue(链表实现,无界)等,负责存储待处理事件 、任务。 2. 线程池:负责从队列中获取任务并异步处理(如写入磁盘、网络请求)。 处理流程: 1. 业务线程(生产者)将任务(日志事件、消息)放入阻塞队列,立即返回,不等待处理完成。 2. 线程池中的工作线程(消费者)从队列中取任务并执行,实现生成与消费解耦。 ## 局限性 1. 锁竞争开销:阻塞队列内部依赖 ReentrantLock 保证线程安全,高并发下生产者与消费者抢锁会导致大量上下文切换,内核态用户态相互转换,吞吐量下降。 2. GC频繁:任务对象(如 Runable、事件对象)动态创建,频繁入队、出队,会产生大量临时对象,触发 JVM 频繁 GC,增加风险。 3. 无界队列风险:若使用无界队列,当消费速度跟不上生产速度时,队列无限膨胀,可能导致 OOM。 4. 线程调度成本:线程池的工作线程由操作系统调度,多线程切换会消耗 CPU 资源,尤其是任务粒度较小时,调度开销占比更高 # 它的优势是什么? Disruptor(颠覆者) 是一个基于内存的高性能异步处理队列,本质上是线程之间无锁传递消息的队列。颠覆了传统高并发消息交互问题解决方案。 优势: 1. 环形缓冲区:一个固定大小的数组,启动时创建所有事件对象,运行时循环复用,无序频繁GC; 2. 序号机制:生产者与消费者通过 "序号" 跟踪进度 3. 无锁设计,生产者和消费者通过 CAS 获取环形缓冲区的序号,获取到之后,直接在该位置写入或者读取数据; > 实际上它还采用了缓冲行填充(在关键变量前后添加占位符,确保 其独占一个缓冲行),彻底避免伪共享,由于其涉及知识较多,不再赘述,如需深入了解,请看[美团技术团队Disruptor原文](https://tech.meituan.com/2016/11/18/disruptor.html)。 > # 核心组件 1. RingBuffer:环形缓冲区,存储事件对象的环形数组 2. Event:传递的数据载体,也就是事件 3. EventFactory:预分配 Event 对象的工厂 4. EventHandler:消费者逻辑(实现 onEvent() 方法) 5. Sequence:跟踪生产者、消费者的处理进度 6. SequenceBarrier:协调生产者与消费者的序号同步 7. WaitStrategy:缓冲区满时的等待策略(如阻塞、自旋、休眠) # 工作流程 ## 初始化 1. 定义 Event 事件 2. 创建 EventFactory 工厂 3. 配置 Disruptor(大小、线程池、等待策略)初始化生产者 Sequeuce 序号 ## 生产 1. 业务线程先通过 CAS 获取下一个可用的写入序号,序号是递增的 2. 根据序号对缓冲区大小取模获取数组索引,填充数据 3. 发布事件,将该序号标记为 "已发布",此时 <font style="color:rgba(0, 0, 0, 0.85);">SequenceBarrier 感知到新的可处理序号</font> ## 消费 1. 消费者线程通过 SequenceBarrier.waitFor(nextSequence) 等待可处理的事件 + nextSequence:是消费者期望处理的下一个序号(例如,消费者当前已经处理到10,期望处理11) + SequenceBarrier 会检查:该序号是否 ≤ 生产者已发布的最大序号,且是否满足消费者依赖(如存在依赖链,需等待前置消费者处理完成)。若满足返回可处理的序号范围 2. 消费者循环处理该范围内的事件 3. 处理完成后,更新自身的 Sequeuce 序号,告知其它依赖的消费者,该序号已经处理完成 消费者线程从 RingBuffer 读取事件,执行写入文件、网络等操作 ## 总结 1. 环形缓冲区初始化:创建一个固定大小(如 8)的RingBuffer(索引范围为8),初始化序号为 0 2. 生产者写入数据:生产者申请序号 0,将数据写入事件对象,提交后序号递增为 1,用于下次申请 3. 消费者读取数据:消费者通过 SequenceBarrier 获取序号为 0 的事件,处理完成后提交,序号递增为1 4. 环形缓冲区会循环使用,序号每次均是递增区分先后顺序,通过对 8 取模来获取环形缓冲区的索引 5. 如果生产者追上消费者,消费者没有处理数据,则会根据等待策略进行相应的等待 # 实战 模拟高并发日志处理场景,[Github 代码](https://github.com/sonshu-tulin/logger-disruptor) ## 引入依赖 ```xml <dependency> <groupId>com.lmax</groupId> <artifactId>disruptor</artifactId> <version>3.4.4</version> <!-- 稳定版本 --> </dependency> ``` ## 定义事件 ```java package top.xiaoyijun.loggerdisruptor; import lombok.Data; /** * 日志事件 */ @Data public class LogEvent { private long id; // 日志ID private String message; // 日志内容 } ``` ## 创建事件工厂 ```java package top.xiaoyijun.loggerdisruptor; import com.lmax.disruptor.EventFactory; /** * 日志事件工厂:负责创建日志事件实例 */ public class LoggerEventFactory implements EventFactory<LogEvent> { @Override public LogEvent newInstance() { // 预创建事件(Disruptor 会预先分配缓冲区大小的事件) return new LogEvent(); } } ``` ## 消费者实现 ```java package top.xiaoyijun.loggerdisruptor.handler; import com.lmax.disruptor.EventHandler; import org.springframework.stereotype.Component; import top.xiaoyijun.loggerdisruptor.event.LogEvent; /** * 消费者 1:打印日志消费者实现,需要实现 EventHandler 接口 */ @Component public class PrintLogHandler implements EventHandler<LogEvent> { @Override public void onEvent(LogEvent logEvent, long sequence, boolean b) { // 处理事件,打印日志 System.out.println("[打印日志] ID = " + logEvent.getId() + ", 内容:" + logEvent.getMessage()); // 模拟处理耗时 try { Thread.sleep(10); }catch (Exception e){ Thread.currentThread().interrupt(); } } } ``` ```java package top.xiaoyijun.loggerdisruptor.handler; import com.lmax.disruptor.EventHandler; import org.springframework.stereotype.Component; import top.xiaoyijun.loggerdisruptor.event.LogEvent; import java.util.concurrent.atomic.AtomicLong; /** * 消费者2:统计日志总数 */ @Component public class CountLogHandler implements EventHandler<LogEvent> { private final AtomicLong count = new AtomicLong(0); // 原子类保证线程安全 @Override public void onEvent(LogEvent event, long sequence, boolean endOfBatch) { // 累加计数 count.incrementAndGet(); // 若到达最后一批事件,输出统计结果 if (endOfBatch) { System.out.println("\n[统计结果] 总日志数:" + count.get()); } } } ``` ## 环形缓冲区配置 此次案例使用单生产者,多消费者实现,如需配置多生产者修改配置即可,测试时使用多个线程进行测试 ```java package top.xiaoyijun.loggerdisruptor.config; import com.lmax.disruptor.YieldingWaitStrategy; import com.lmax.disruptor.dsl.Disruptor; import com.lmax.disruptor.dsl.ProducerType; import jakarta.annotation.Resource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import top.xiaoyijun.loggerdisruptor.factory.LoggerEventFactory; import top.xiaoyijun.loggerdisruptor.event.LogEvent; import top.xiaoyijun.loggerdisruptor.handler.CountLogHandler; import top.xiaoyijun.loggerdisruptor.handler.PrintLogHandler; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @Configuration public class EventDisruptorConfig { @Resource private PrintLogHandler printLogHandler; @Resource private CountLogHandler countLogHandler; @Bean("eventDisruptor") public Disruptor<LogEvent> messageModelRingBuffer() { // 1. 配置参数 int bufferSize = 1024; // 环形缓冲区大小(必须是2的幂,如1024、2048) ExecutorService executor = Executors.newCachedThreadPool(); // 消费者线程池 //2. 创建Disruptor实例 Disruptor<LogEvent> disruptor = new Disruptor<>( new LoggerEventFactory(), // 事件工厂 bufferSize, executor, ProducerType.SINGLE, // 单生产者(多生产者改为MULTI) new YieldingWaitStrategy() //等待策略 ); // 设置消费者 disruptor.handleEventsWith(printLogHandler, countLogHandler); // 开启 disruptor disruptor.start(); return disruptor; } } ``` ## 生产者实现 实现生产者时,需要先获取序号,才可以填充事件,但是如果此时出现异常,将会导致该序列号对应事件会被永久占用,环形缓冲区会被耗尽,生产者阻塞,因此要使用 Translator 模式发布事件 ```java package top.xiaoyijun.loggerdisruptor.producer; import com.lmax.disruptor.RingBuffer; import com.lmax.disruptor.EventTranslatorOneArg; import com.lmax.disruptor.dsl.Disruptor; import jakarta.annotation.PreDestroy; import jakarta.annotation.Resource; import org.springframework.stereotype.Component; import top.xiaoyijun.loggerdisruptor.event.LogEvent; /** * 日志事件生产者:发布日志到Disruptor */ @Component public class LogEventProducer { @Resource(name = "eventDisruptor") private Disruptor<LogEvent> logEventDisruptor; /** * 发布事件 * @param id 消息id * @param message 消息内容 */ public void publishEvent(long id, String message){ // 获取 Disruptor 的环形缓冲区 RingBuffer<LogEvent> ringBuffer = logEventDisruptor.getRingBuffer(); // 获取可以生成的位置(手动获取可以生成的位置,如果出现异常,该序列号对应事件会被永久占用,环形缓冲区会被耗尽,生产者阻塞) long next = ringBuffer.next(); LogEvent logEvent = ringBuffer.get(next); logEvent.setId(id); logEvent.setMessage(message); // 发布事件 ringBuffer.publish(next); } /** * 优雅停机 */ @PreDestroy public void close(){ logEventDisruptor.shutdown(); } } ``` ```java package top.xiaoyijun.loggerdisruptor.producer; import com.lmax.disruptor.EventTranslatorOneArg; import com.lmax.disruptor.RingBuffer; import com.lmax.disruptor.dsl.Disruptor; import jakarta.annotation.PreDestroy; import jakarta.annotation.Resource; import org.springframework.stereotype.Component; import top.xiaoyijun.loggerdisruptor.event.LogEvent; /** * 日志事件生产者:发布日志到 Disruptor, 使用Translator模式发布事件 */ @Component public class LogEventProducer2 { @Resource(name = "eventDisruptor") private Disruptor<LogEvent> logEventDisruptor; /** * 事件转换器:封装事件设置逻辑,避免手动操作序列号 */ public static final EventTranslatorOneArg<LogEvent, LogData> TRANSLATOR = ((logEvent, l, logData) -> { logEvent.setId(logData.id); logEvent.setMessage(logData.message); }); /** * 发布事件 * * @param id 消息id * @param message 消息内容 */ public void publishEvent(long id, String message) { // 获取 Disruptor 的环形缓冲区 RingBuffer<LogEvent> ringBuffer = logEventDisruptor.getRingBuffer(); // 发布事件 ringBuffer.publishEvent(TRANSLATOR, new LogData(id, message)); } // 内部数据载体:封装待发布的参数 private static class LogData { private final long id; private final String message; public LogData(long id, String message) { this.id = id; this.message = message; } } /** * 优雅停机 */ @PreDestroy public void close(){ logEventDisruptor.shutdown(); } } ``` ## 测试 ```java package top.xiaoyijun.loggerdisruptor; import jakarta.annotation.Resource; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; import top.xiaoyijun.loggerdisruptor.producer.LogEventProducer; import top.xiaoyijun.loggerdisruptor.producer.LogEventProducer2; @Component public class EventPublishTest implements CommandLineRunner { // 注入生产者 @Resource private LogEventProducer logEventProducer; @Resource private LogEventProducer2 logEventProducer2; @Override public void run(String... args) throws Exception { // 模拟发布10条日志 for (int i = 1; i <= 10; i++) { //logEventProducer.publishEvent(i, "这是第" + i + "条日志"); logEventProducer2.publishEvent(i, "这是第" + i + "条日志"); // 间隔50ms Thread.sleep(50); } } } ``` ## 测试结果 ```java [统计结果] 总日志数:1 [打印日志] ID = 1, 内容:这是第1条日志 [打印日志] ID = 2, 内容:这是第2条日志 [统计结果] 总日志数:2 [统计结果] 总日志数:3 [打印日志] ID = 3, 内容:这是第3条日志 [统计结果] 总日志数:4 [打印日志] ID = 4, 内容:这是第4条日志 [统计结果] 总日志数:5 [打印日志] ID = 5, 内容:这是第5条日志 [统计结果] 总日志数:6 [打印日志] ID = 6, 内容:这是第6条日志 [统计结果] 总日志数:7 [打印日志] ID = 7, 内容:这是第7条日志 [统计结果] 总日志数:8 [打印日志] ID = 8, 内容:这是第8条日志 [统计结果] 总日志数:9 [打印日志] ID = 9, 内容:这是第9条日志 [打印日志] ID = 10, 内容:这是第10条日志 [统计结果] 总日志数:10 ``` # 参考文献 [<font style="background-color:rgb(253, 253, 253);">高性能队列——Disruptor</font>](https://tech.meituan.com/2016/11/18/disruptor.html) [<font style="background-color:rgb(253, 253, 253);">鱼皮编程导航云图库项目</font>](https://www.codefather.cn/course/1864210260732116994)
宝塔 PostgreSQL 安装 pgvector 插件实现向量存储
## 1. 宝塔安装 1. 首先确保你的宝塔已经安装了 <font style="color:rgb(34, 34, 38);">PostgreSQL。 </font>  安装好后是能看到上面这个界面的。 我安装的是 16.1 版本,下面的教程讲的也是 16.1 版本的。  2.开放防火墙的端口号 5432 3.允许外部访问所有数据库  4.设置超级管理员用户密码  用户名默认为:postgres 后续连接数据库可用超级用户连接 username:postgres password:就是你刚刚设置的 ## 2. 安装 pgvector 插件 ### 2.1 查看安装状态 **1. 首先去到控制台,输入命令连接 postgreSQL 数据库。** ```bash /www/server/pgsql/bin/psql -U postgres -h localhost -p 5432 ```  显示 postgres=# 就是连接上了数据库,可以敲 sql 语句了。 **2. 在psql命令行中,执行创建扩展的命令:** ```bash CREATE EXTENSION vector; ``` 如果没有安装过插件就会报错。 **3.验证是否安装成功:** ```bash \dx vector ``` 如果成功,它会列出 vector 扩展的信息。如下图:  ### 2.2 安装pgvector **下载pgvector插件:** 下载地址: [https://github.com/pgvector/pgvector/archive/refs/tags/v0.7.4.zip](https://github.com/pgvector/pgvector/archive/refs/tags/v0.7.4.zip) postgresql14 对应 pgvector0.5 的版本 postgresql15 对应 pgvector0.6 的版本 postgresql15 对应 pgvector0.7 的版本 将压缩包本地解压好,去宝塔建个文件夹,直接把文件都传进去。  ### 2.3 编译安装 进入刚刚上传文件夹的目录,准备编译安装。 ```bash # 进入你的 pgvector 源代码目录 cd /www/wwwroot/pgvector # 清理之前的编译尝试(可选,但推荐) make clean # 使用找到的 pg_config 路径进行编译 make PG_CONFIG=/www/server/pgsql/bin/pg_config # 安装编译好的扩展(需要 root 权限) sudo make install PG_CONFIG=/www/server/pgsql/bin/pg_config ``` 然后重复 2.1 查看安装状态,查看是否安装成功。 ### 2.4 设置环境变量 (建议设置) **1.找到PostgreSQL的可执行工具路径** 我的 PostgreSQL 安装目录是/www/server/pgsql。这个目录下的 bin 子目录包含了所有 PostgreSQL的命令行工具,包括 psql (PostgreSQL 交互终端)和 pg-ctl (服务控制工具)。 重要工具路径: psql:/www/server/pgsql/bin/psql(用于连接数据库和执行 SQL 命令) pg-ctl:/www/server/pgsql/bin/pg-ctl(用于管理PostgreSQL 服务,如启动、停止) 检查工具是否存在: ```bash ls -la /www/server/pgsql/bin/psql ls -la /www/server/pgsql/bin/pg-ctl ``` 如果这些文件存在(应该有可执行权限),你就可以使用它们了。 **2.设置环境变量** 为了方便使用,建议将 PostgreSQL 的 bin目录添加到你的 PATH 环境变量中。这样你就可以直接在命令行中输入 psql、pg-ctl 等命令,而不需要每次都输入完整路径。 ```bash #临时添加(当前终端会话有效): export PATH=/www/server/pgsql/bin:$PATH #永久添加(推荐): #将上面的export 命令添加到你的 shell配置文件中(如~/.bashrc 或~/.bash_profile): echo 'export PATH=/www/server/pgsql/bin:$PATH' >> ~/.bashrc Source ~/.bashrc #验证路径是否设置成功: which psql # 应该输出: /www/server/pgsql/bin/psql ``` **3.使用psql 连接到 PostgreSQL 数据库** psql 是 PostgreSQL 的交互式终端程序,允许执行 SQL 命令和管理数据库。 以postgres用户连接到数据库: ```bash #使用完整路径(如果未设置PATH) /www/server/pgsql/bin/psql -U postgres -h localhost -p 5432 #或者如果已设置PATH psql -U postgres -h localhost -p 5432 ``` 说明: + -U postgres:指定用户名(PostgreSQL 的默认超级用户通常叫postgres) + -h localhost:指定主机 (这里连接本地数据库) + -p 5432:指定端口(PostgreSQL 默认端口是54321,如果你的配置不同请修改) 如果连接失败: + 权限问题:检查/www/server/pgsql/data/pg_hba.conf 文件,确保有允许 postgres 用户从本地连接的配置。 + 端口问题:确认你的 PostgreSQL 实际监听端口 (检查/www/server/pgsql/data/postgresql.conf 中的 port 设置)。 **4.在数据库中启用pgvector扩展** 成功连接to PostgreSQL 后,你会看到提示符变为postgres=#,这表示你现在可以执行 SQL 命令。 创建并启用扩展: ```bash -- 启用 vector 扩展 CREATE EXTENSION vector; -- 验证扩展是否安装成功 \dx vector ``` ## 3.解决PostgreSQL中“vector"类型不存在的问题 问题原因分析 扩展已安装但未启用:PostgreSQL 扩展需要在每个要使用它的数据库中单独创建。即使服务器层面安装了 pgvector,不执行 CREATE EXTENSION 命令,当前数据库也无法识别vector 数据类型。 权限问题:当前数据库用户可能没有创建扩展的权限。 搜索路径问题:扩展可能安装在了非默认模式(schema)下,而该模式不在你的search_path中。 **解决方法:** 步骤1:连接到你的数据库并创建扩展 使用有权限的用户(如postgres或具有超级用户权限的用户)连接到你需要使用vector 数据类型的数据库,然后执行以下 SQL 命令: ```sql -- 连接到你的数据库后,执行以下命令创建扩展 CREATE EXTENSION IF NOT EXISTS vector; ``` 步骤2:验证扩展是否创建成功 创建扩展后,可以通过以下命令验证是否成功: ```sql -- 查看当前数据库已安装的扩展列表,检查 vector 是否在列 \dx -- 或者查询扩展详情 SELECT * FROM pg_extension WHERE extname = 'vector'; ``` ## 4.解决 Spring AI PgVectorStore 的 hstore 扩展问题 错误信息显示: ```plain ERROR: extension "hstore" is not available 详细:Could not open extension control file "/www/server/pgsql/share/extension/hstore.control": No such file or directory. 建议:The extension must first be installed on the system where PostgreSQL is running. ``` `<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">hstore</font>`<font style="color:rgb(15, 17, 21);"> 是 PostgreSQL 的一个扩展,用于存储键值对数据。Spring AI 的 PgVectorStore 依赖这个扩展来存储元数据。</font> ### <font style="color:rgb(15, 17, 21);">安装 hstore 扩展</font> **<font style="color:rgb(15, 17, 21);">安装 PostgreSQL contrib 包</font>**<font style="color:rgb(15, 17, 21);">(包含 hstore 扩展):</font> ```bash 指定版本(假设你使用的是 PostgreSQL 16) sudo yum install postgresql16-contrib ``` **<font style="color:rgb(15, 17, 21);">让 PostgreSQL 找到 hstore 扩展</font>** <font style="color:rgb(15, 17, 21);">手动将 </font>`<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">contrib</font>`<font style="color:rgb(15, 17, 21);"> 包提供的扩展文件复制或链接到 PostgreSQL 的搜索路径中。</font> ### <font style="color:rgb(15, 17, 21);">步骤 1: 查找 hstore 扩展文件的位置</font> <font style="color:rgb(15, 17, 21);">首先,我们需要找到</font><font style="color:rgb(15, 17, 21);"> </font>`<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">postgresql16-contrib</font>`<font style="color:rgb(15, 17, 21);"> </font><font style="color:rgb(15, 17, 21);">包安装后,</font>`<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">hstore</font>`<font style="color:rgb(15, 17, 21);"> </font><font style="color:rgb(15, 17, 21);">扩展文件实际存放的位置。</font> **<font style="color:rgb(15, 17, 21);">使用 find 命令搜索</font>**<font style="color:rgb(15, 17, 21);">:</font><font style="color:rgb(15, 17, 21);"> </font><font style="color:rgb(15, 17, 21);">在终端中执行以下命令,搜索</font><font style="color:rgb(15, 17, 21);"> </font>`<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">hstore.control</font>`<font style="color:rgb(15, 17, 21);"> </font><font style="color:rgb(15, 17, 21);">文件:</font> 1. <font style="color:rgb(15, 17, 21);">bash</font><font style="color:rgb(15, 17, 21);">sudo find / -name "hstore.control" 2>/dev/null</font> <font style="color:rgb(15, 17, 21);">或者更精确地搜索</font><font style="color:rgb(15, 17, 21);"> </font>`<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">contrib</font>`<font style="color:rgb(15, 17, 21);"> </font><font style="color:rgb(15, 17, 21);">相关的目录:</font> 2. <font style="color:rgb(15, 17, 21);">bash</font><font style="color:rgb(15, 17, 21);">sudo find /usr -path "*/contrib/*" -name "hstore.control" 2>/dev/null</font> 3. **<font style="color:rgb(15, 17, 21);">检查常见的安装路径</font>**<font style="color:rgb(15, 17, 21);">:</font><font style="color:rgb(15, 17, 21);"> </font><font style="color:rgb(15, 17, 21);">根据 OpenCloudOS 和 PostgreSQL 的常见安装路径,</font>`<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">hstore.control</font>`<font style="color:rgb(15, 17, 21);"> </font><font style="color:rgb(15, 17, 21);">文件很可能在以下位置之一:</font> - `<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">/usr/share/pgsql/extension/hstore.control</font>` - `<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">/usr/share/postgresql16/extension/hstore.control</font>` - `<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">/usr/pgsql-16/share/extension/hstore.control</font>` ### <font style="color:rgb(15, 17, 21);">步骤 2: 将扩展文件链接到 PostgreSQL 目录</font> <font style="color:rgb(15, 17, 21);">找到</font><font style="color:rgb(15, 17, 21);"> </font>`<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">hstore.control</font>`<font style="color:rgb(15, 17, 21);"> </font><font style="color:rgb(15, 17, 21);">文件及其同目录下的相关文件(通常是</font><font style="color:rgb(15, 17, 21);"> </font>`<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">.sql</font>`<font style="color:rgb(15, 17, 21);"> </font><font style="color:rgb(15, 17, 21);">和</font><font style="color:rgb(15, 17, 21);"> </font>`<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">.so</font>`<font style="color:rgb(15, 17, 21);"> </font><font style="color:rgb(15, 17, 21);">文件)后,您有两种方法让 PostgreSQL 找到它们:</font> **<font style="color:rgb(15, 17, 21);"> 创建符号链接(推荐,更灵活)</font>** <font style="color:rgb(15, 17, 21);">假设您找到的路径是 </font>`<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">/usr/share/pgsql/extension/hstore.control</font>`<font style="color:rgb(15, 17, 21);">:</font> ```bash # 创建扩展目录(如果不存在) sudo mkdir -p /www/server/pgsql/share/extension/ # 为 hstore.control 创建符号链接 sudo ln -s /usr/share/pgsql/extension/hstore.control /www/server/pgsql/share/extension/hstore.control # 为 hstore--1.x.sql 文件创建符号链接(请替换 x 为实际版本号,或用通配符) sudo ln -s /usr/share/pgsql/extension/hstore--1*.sql /www/server/pgsql/share/extension/ # 为 hstore.so 共享库创建符号链接(库文件可能在 lib 目录下) # 先找到 hstore.so 文件 sudo find / -name "hstore.so" 2>/dev/null # 假设找到的路径是 /usr/lib64/pgsql/hstore.so sudo ln -s /usr/lib64/pgsql/hstore.so /www/server/pgsql/lib/hstore.so ``` ### <font style="color:rgb(15, 17, 21);">步骤 3: 设置文件权限</font> <font style="color:rgb(15, 17, 21);">确保 PostgreSQL 用户(通常是</font><font style="color:rgb(15, 17, 21);"> </font>`<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">postgres</font>`<font style="color:rgb(15, 17, 21);">)有权限读取这些文件:</font> <font style="color:rgb(15, 17, 21);">bash</font> ```bash sudo chown postgres:postgres /www/server/pgsql/share/extension/hstore.* sudo chmod 644 /www/server/pgsql/share/extension/hstore.* sudo chown postgres:postgres /www/server/pgsql/lib/hstore.so sudo chmod 755 /www/server/pgsql/lib/hstore.so ``` <font style="color:rgb(15, 17, 21);">步骤 4: 重启 PostgreSQL 服务并启用扩展</font> <font style="color:rgb(15, 17, 21);">完成文件链接或复制后,重启 PostgreSQL 服务以使更改生效,然后启用扩展:</font> <font style="color:rgb(15, 17, 21);">先手动去宝塔面板重启 PostgreSQL 服务</font> ```bash # 连接到 PostgreSQL psql -U postgres -h localhost # 在 psql 中创建扩展 CREATE EXTENSION hstore; # 验证扩展是否安装成功 \dx hstore ``` 最后确认一下<font style="color:rgb(15, 17, 21);"> PostgreSQL 配置文件有没有问题:</font>  ## <font style="color:rgb(15, 17, 21);">5.PostgreSQL 数据库中缺少 </font>**<font style="color:rgb(15, 17, 21);">uuid-ossp</font>**<font style="color:rgb(15, 17, 21);"> 扩展</font> 1. 创建符号链接 ```bash # 为扩展控制文件和SQL文件创建符号链接 sudo ln -s /usr/share/pgsql/extension/uuid-ossp.control /www/server/pgsql/share/extension/ sudo ln -s /usr/share/pgsql/extension/uuid-ossp--1.0--1.1.sql /www/server/pgsql/share/extension/ sudo ln -s /usr/share/pgsql/extension/uuid-ossp--1.1.sql /www/server/pgsql/share/extension/ # 为共享库文件创建符号链接 sudo ln -s /usr/lib64/pgsql/uuid-ossp.so /www/server/pgsql/lib/ ``` 2. 验证符号链接: ```bash ls -la /www/server/pgsql/share/extension/uuid-ossp* ls -la /www/server/pgsql/lib/uuid-ossp.so ``` 3.重启<font style="color:rgb(15, 17, 21);"> PostgreSQL 服务(手动)</font> <font style="color:rgb(15, 17, 21);">4.</font><font style="color:rgb(15, 17, 21);">在 PostgreSQL 中创建扩展</font> ```bash CREATE EXTENSION "uuid-ossp"; ``` <font style="color:rgb(15, 17, 21);"></font>
