Skip to main content

AI

· 62 min read

向量库

嵌入模型:nomic-embed-text

Qdrant是我们前期调研和测试时使用的方案。PGVector是我们项目中最终选用的方案。

  • 它的最大优势是:它是一个PostgreSQL的扩展插件,而不是一个独立的数据库。这意味着我们可以直接在我们现有的业务数据库(我们用的就是PostgreSQL)上增加向量存储和检索的能力,不需要引入和维护一套全新的数据库系统。这对于我们团队来说,大大降低了运维成本和技术栈的复杂性。
  • 功能上:它支持常见的相似度计算方式,比如余弦相似度、L2距离等。我们通过给存储文本块(chunks)的表增加一个vector类型的列,就把文本的向量 embedding 存进去了。查询的时候,直接在SQL里使用类似于<->这样的操作符,就可以进行向量的相似度查询,并且可以和WHEREJOIN等标准的SQL语句结合使用。比如,我们可以很方便地查询“某个特定业务线下,和用户问题最相似的5个知识点”,业务线筛选用WHERE,相似度查询用向量操作符,非常灵活。
  • 关于索引:为了加速检索,PGVector支持HNSW(分层可导航小世界图)和IVFFlat(倒排文件)索引。我们项目中后期数据量上来之后,就使用了HNSW索引,检索速度得到了几个数量级的提升。

HNSW的全称是 Hierarchical Navigable Small World,中文翻译过来是分层可导航小世界图。它是一种用于在高维空间中进行**近似最近邻(ANN)**搜索的算法和数据结构。

简单来说,它的目标不是100%精确地找到“最”相似的那个向量,而是在牺牲极小的精度的情况下,换取搜索速度成百上千倍的提升。这对于需要实时响应的AI应用来说是至关重要的。

RAG 的核心逻辑:

  1. 用户问题 → 转成 embedding
  2. 在向量数据库里检索最相似的文档片段
  3. 将这些文档作为上下文 prompt 给 LLM,让模型基于检索结果生成答案

具体实现

  1. Embedding
    • 使用 OpenAI Embedding 或本地 Sentence-BERT,将规则文档、历史案例、风控知识库中的条目都转成向量。
    • 存入 PGVector,建立索引(cosine similarity 或 inner product)。
  2. 检索流程
    • 用户输入问题 → 转 embedding → PGVector 搜索 top-K(比如 top-5)
    • 对检索结果做简单过滤:去掉低相似度或过时条目,保证上下文质量。
  3. Prompt 构建
    • 将检索到的 top-K 文档片段拼成上下文 prompt
    • 加上问题 → 输入 LLM(例如 GPT-3.5/4)生成答案
  4. 结果后处理
    • 对生成内容做规则校验(关键字段、数值合法性)
    • 如果生成答案与检索结果明显冲突,触发 fallback 或重生成

生成内容仍然存在偏差

使用Rerank模型,重排序模型将根据候选文档列表与用户问题语义匹配度进行重新排序,从而改进语义排序的结果

调节相似度阈值,以及Top K的值

MCP

MCP(Model Context Protocol)的作用

  • 规范化 LLM 与外部工具的交互方式
  • 减少幻觉. LLM 本质上可能会编造事实,或生成看似合理但实则错误的信息(产生幻觉),因为其回答基于训练数据而非实时信息。MCP 提供清晰路径,使 LLM 能访问外部可靠数据源,从而提高回答的真实性并减少幻觉。
  • 将操作封装为可序列化的消息(带上下文、身份、权限信息)
  • 支持多 Agent 协作,保持状态可追踪
  • 提供安全检查和错误回退机制

MCP 的工作原理是什么?

Model Context Protocol 的核心功能是允许 LLM 请求外部工具协助回答查询或完成任务。假设您向 AI 助理发出以下指令:“在我们的数据库中查找最新的销售报告,并将其通过电子邮件发送给我的经理。”

以下简要说明 MCP 将如何处理这种情况:

  1. **请求与工具发现:**LLM 明白其本身无法访问数据库或发送电子邮件。它使用 MCP 客户端搜索可用工具,并在 MCP 服务器上找到了两个相关工具:database_query 工具和 email_sender 工具。
  2. **工具调用:**LLM 生成结构化请求来使用这些工具。首先,它会调用 database_query 工具,并指定报告的名称。然后,MCP 客户端会将此请求发送到相应的 MCP 服务器。
  3. **外部操作与数据返回:**MCP 服务器接收请求,将其转换为面向公司数据库的安全 SQL 查询,并检索销售报告。然后,它会将这些数据格式化后发送回 LLM。
  4. **第二个操作与响应生成:**现在已获得报告数据,LLM 调用 email_sender 工具,提供经理的邮箱地址和报告内容。邮件发送后,MCP 服务器会确认操作已完成。
  5. **最终确认:**LLM 向您提供最终回应:“我已找到最新的销售报告,并通过邮件发送给您的经理。”
功能Model Context Protocol (MCP)检索增强生成 (RAG)
主要目标标准化 LLM 的双向通信,使其能够访问并与外部工具、数据源和服务交互,从而在检索信息的同时执行操作。在生成回答之前,从权威知识库中检索相关信息,以增强 LLM 的回答。
机制定义了用于 LLM 应用调用外部函数或从专用服务器请求结构化数据的标准化协议,从而实现操作和动态上下文集成。包含一个信息检索组件,用于根据用户查询从知识库或数据源中拉取信息。然后,检索到的信息将增强 LLM 的提示内容。
输出类型使 LLM 能够生成结构化调用以调用工具、接收结果,并根据这些结果和操作生成可供人类阅读的文本。也可以涉及实时数据和函数。LLM 根据其训练数据生成回答,该数据已通过外部文档中与查询相关的文本进行增强。通常重点关注事实准确性。
互动专为主动交互和在外部系统中执行任务而设计,为 LLM“使用”外部功能提供“语法”。主要用于被动检索信息,为文本生成提供依据;通常不用于在外部系统中执行操作。
标准化这是一种开放标准,规范 AI 应用为 LLM 提供上下文的方式,从而实现集成标准化并减少对自定义 API 的依赖。这是一种用于改进 LLM 的技术或框架,但并非适用于不同供应商或系统之间工具交互的通用协议。
使用场景AI 智能体可执行任务(例如预订航班、更新 CRM、运行代码)、提取实时数据,并实现高级集成。问答系统、能提供最新事实信息的聊天机器人、文档摘要功能,以及降低文本生成中幻觉内容的出现。

实现流程

定义 MCP Action—》LLM 生成 MCP 请求—》MCP 中间层处理请求—》执行数据库操作—》LLM 使用返回结果生成回答

定义 MCP Action

  • 每个数据库操作封装成一个 MCP Action,例如 QueryUserTransactions(userId, startDate, endDate)
  • Action 包含:
    • 操作类型(SELECT / INSERT / UPDATE)
    • 参数(用户ID、日期范围等)
    • 上下文 ID(标记当前对话或任务)

LLM 生成 MCP 请求

  • 模型通过 prompt 生成 MCP JSON 消息,而不是直接生成 SQL
{
"action": "QueryUserTransactions",
"parameters": {"userId": 123, "startDate": "2025-01-01", "endDate": "2025-06-30"},
"contextId": "session_456"
}

MCP 中间层处理请求

  • MCP 接收 JSON,做两件事:
    1. 校验参数合法性和权限
    2. 转换为安全 SQL 查询或 ORM 调用
  • 防止 SQL 注入,统一日志记录

执行数据库操作

  • 调用 Postgres / Oracle / Redis 等后端服务
  • 返回结果给 MCP 层,MCP 层统一序列化成 JSON 返回给 LLM

LLM 使用返回结果生成回答

  • 模型根据查询结果生成风控判断或报告
  • MCP 层可记录所有操作,保证可追踪、可回滚

上下文工程

解决的问题

信息过载与上下文溢出:传统的提示工程往往依赖于静态的提示词,容易导致信息过载或上下文溢出。而上下文工程通过动态管理上下文信息,确保模型在处理任务时能够获取最相关的信息,从而提高任务的准确性和效率。

多智能体协作中的上下文共享:在多智能体系统中,不同 Agent 可能需要共享上下文信息。上下文工程提供了一种机制,允许 Agent 之间有效地共享和更新上下文信息,避免了信息孤岛的情况,提高了系统的协同能力。

上下文的动态更新与优化:上下文工程强调上下文的动态更新和优化,采用模块化的生成、反思和策划过程,防止上下文信息的丢失和衰退,从而保持系统的长期有效性和适应性arXiv

实践

  • 上下文检索与生成(Context Retrieval & Generation):通过检索和生成机制,动态组装最相关的信息,以引导模型生成更准确的输出gongjiyun.com
  • 上下文压缩与隔离:在上下文窗口限制的情况下,采用压缩和隔离策略,保留最关键的信息,避免信息丢失ihower.tw
  • 上下文的模块化与优化:将上下文信息拆分为多个模块,通过优化策略,选择最合适的上下文组合,以提高模型的性能和效率blog.csdn.net

保证信息不丢失

优先级排序(打分) + 压缩表示 + 分层隔离

分层隔离(上下文模块化)

  • 把上下文拆成多个层次,每层独立管理,保证核心信息不被覆盖:
    • 核心层:必须保留的信息,如关键业务参数、重要规则
    • 辅助层:相关信息,可按需要加载
    • 历史层:长远历史,低优先级,可压缩或摘要

不同 Agent 可以各自管理自己的核心层和辅助层,保证信息不丢失

Spring Boot 的启动流程

构建 SpringApplication → 初始化环境 → 创建上下文 → 加载配置与 Bean → 启动容器 → 发布事件 → 就绪运行。

一、构建阶段:创建 SpringApplication 实例

当你写下

SpringApplication.run(App.class, args);

其实做了两件事:

  1. 构造 SpringApplication 对象
    • 扫描传入的主类(一般带 @SpringBootApplication 注解),确定启动上下文类型(普通应用用 AnnotationConfigServletWebServerApplicationContext)。
    • 加载一堆策略类(如 ApplicationContextInitializer、ApplicationListener)用于后续扩展点。
    • 识别是否是 Web 应用(Servlet、Reactive、或纯 CLI)。

可以理解为这一步是在确定启动的“剧本”


二、启动阶段:调用 run() 方法

.run() 被调用,正式进入启动主流程。这里大致可以分成以下步骤:

  1. 准备环境(Environment)
    • 创建并配置 Environment,加载命令行参数、系统变量、配置文件(application.yml / properties)、Profiles 等。
    • 触发事件 ApplicationEnvironmentPreparedEvent,此时监听器(比如日志系统初始化器)会开始动作。
  2. 创建应用上下文(ApplicationContext)
    • 根据环境类型创建对应的上下文,比如 Web 环境用 AnnotationConfigServletWebServerApplicationContext
    • 把主类、配置类注册进容器中。
  3. 准备上下文(prepareContext)
    • EnvironmentApplicationListenersInitializers 注入到上下文。
    • 初始化 BeanFactory。
    • 此时上下文还没刷新(Bean 还没实例化)。

三、装配阶段:refresh() 核心流程

这是 Spring Boot “魔法”所在,也是 Spring IOC 的心脏refresh() 方法定义在 AbstractApplicationContext,包含以下关键动作:

  1. BeanDefinition 加载
    • 扫描带注解的类(@Component, @Configuration, @Bean 等),生成 BeanDefinition。
    • 自动装配机制会在这里加载一堆配置类。
  2. 执行 BeanFactoryPostProcessor
    • 比如 PropertySourcesPlaceholderConfigurer 用来处理 ${} 占位符。
  3. 实例化单例 Bean(Singleton)
    • 调用构造函数或工厂方法创建 Bean 实例。
    • 处理依赖注入、AOP 代理、生命周期回调。
  4. 启动内嵌 Web 容器
    • 若是 Web 应用,会自动启动 Tomcat、Jetty 或 Undertow。
    • DispatcherServlet、HandlerMapping、Controller 等组件随之初始化。
  5. 发布 ContextRefreshedEvent
    • 这是 Spring 应用上下文准备完毕的信号。

四、运行阶段:应用启动完成

  1. 执行 ApplicationRunnerCommandLineRunner
    • 这是程序员常用的“启动后逻辑钩子”,可在系统启动完立即执行一些任务。
  2. 发布 ApplicationReadyEvent
    • 表示整个 Spring Boot 应用完成启动,进入“稳定运行态”。

五、自动装配机制(核心亮点)

@SpringBootApplication 其实是三个注解的合体:

  • @SpringBootConfiguration
  • @EnableAutoConfiguration
  • @ComponentScan

@EnableAutoConfiguration 是关键。它利用 SpringFactoriesLoaderMETA-INF/spring.factories 中加载所有配置类(例如 DataSourceAutoConfiguration、WebMvcAutoConfiguration 等)。

Spring Boot 会根据当前 Classpath、环境变量、配置条件 来自动决定要加载哪些 Bean(这就是 @ConditionalOnClass@ConditionalOnMissingBean 等的作用)。

多线程

首先,我们需要明确一点,Spring的声明式事务(也就是我们常用的@Transactional注解)是基于AOP和ThreadLocal来实现的。这意味着事务的上下文是和当前线程绑定的。如果我们在一个带有@Transactional的方法里,手动创建了一个新的子线程,那么这个子线程是无法继承父线程的事务上下文的。它们会处于两个完全不同的事务(甚至子线程可能根本没有事务)中,这就无法保证操作的原子性。

针对这个问题,我的处理思路通常分为两种情况:

第一种情况,也是我优先考虑的方案:尽量避免在同一个事务中开启多线程执行数据库写操作。

我会重新审视业务逻辑,看是否可以进行设计上的优化。比如,在我之前提到的风控系统案例中,多个线程是并行地去读取计算数据,这些都是只读操作,不涉及事务。我会用CompletableFuture等待所有并行的读操作都完成,拿到所有需要的数据后,回到主线程,在主线程中一次性地完成所有数据库的写入和更新操作。这样,整个事务的边界就清晰地控制在主线程内部,从根本上避免了多线程事务的问题。

第二种情况,如果业务场景非常复杂,确实需要在子线程中执行独立的数据库写操作,并且需要事务保证。

这种情况下,我会让子线程中的业务逻辑单独开启一个新事务。具体实现上,我会将子线程要执行的业务逻辑封装到一个独立的Spring Bean的方法中,并给这个方法标注@Transactional(propagation = Propagation.REQUIRES_NEW)

这样,当主线程调用这个异步方法时,REQUIRES_NEW会确保子线程在执行时,总是会挂起当前可能存在的任何事务(虽然实际上从父线程传不过来),并开启一个全新的、独立的事务。

但这里有一个非常重要的问题需要处理:数据一致性。因为父子线程的事务是独立的,子线程的事务可能会先于父线程提交。如果父线程在后续操作中发生了异常并回滚,但子线程的事务已经提交了,就会导致数据不一致。

要解决这个问题,就需要引入额外的补偿机制或者状态协调机制。比如:

  1. 可靠消息队列:主线程完成自己的操作后,发送一个消息到MQ,由消费者服务来执行那些原本需要在子线程中处理的事务操作,利用MQ的ACK机制和重试来保证最终一致性。
  2. 状态机与补偿事务:记录下主流程和子流程的执行状态。如果主流程失败回滚,需要有一个补偿任务(比如通过定时任务扫描或消息触发)去调用子流程的回滚接口(TCC模式),来撤销子线程已提交的操作。

问题:

  1. 你在多线程变成的时候有没有遇到过棘手的问题,最终怎么解决的

之前我们有一个批量导入Excel并进行数据处理的功能,用的是一个线程池来加速处理。在功能上线初期运行平稳,但是到了月底结算高峰期,系统突然变得非常卡顿,甚至出现了几次OOM(内存溢出)导致服务宕机。

一开始排查,我们检查了日志,没有发现明显的业务异常。后来通过jstack对线上服务的线程堆栈进行分析,我们发现系统创建了远超预期的线程数量,几千个线程都处于等待状态,占用了大量内存。

经过深入排查代码,我们定位到问题根源在于线程池的创建方式。当时为了图方便,开发同学直接使用了Executors.newCachedThreadPool()。这种线程池的特点是,当有新任务时,如果池中没有空闲线程,它会立即创建一个新线程,而且它的最大线程数是Integer.MAX_VALUE,几乎可以说是无上限的。在月底高峰期,大量的导入任务并发请求,导致系统在短时间内创建了海量的线程,最终耗尽了服务器的内存资源。

解决这个问题的过程是这样的:

  1. 紧急修复:我们首先将newCachedThreadPool替换为手动创建的ThreadPoolExecutor。根据服务器的CPU核心数(比如8核),我们将核心线程数(corePoolSize)设置为8,最大线程数(maximumPoolSize)设置为16,并配置了一个有界队列(LinkedBlockingQueue)来缓冲任务。同时,我们设置了拒绝策略为CallerRunsPolicy,这样当线程池和队列都满了之后,新的任务会由提交任务的那个线程(通常是处理HTTP请求的线程)自己来执行,这相当于一种降级和限流,虽然会慢一点,但能保证系统不会因为线程过多而崩溃。
  2. 长期优化:上线紧急修复后,我们对这个功能进行了压测和参数调优,找到了一个在性能和资源消耗上更平衡的线程池配置。并且,我们团队内部也制定了规范,禁止使用Executors的几个快捷创建方法,要求所有线程池都必须通过ThreadPoolExecutor的构造函数来显式创建,并且要明确指定每一个参数,让资源的使用变得可控。

wait和sleep方法

  1. 所属的类不同
    • sleep()java.lang.Thread 类的静态方法,它作用于当前正在执行的线程。我们可以直接通过 Thread.sleep() 来调用。
    • wait()java.lang.Object 类的方法,任何一个对象都可以调用它。它必须在同步代码块(synchronized block)或者同步方法中被调用,而且是由锁对象来调用。
  2. 对锁(Monitor)的处理机制不同
    • sleep() 不会释放锁。当一个线程在持有锁的情况下调用 sleep() 方法,它会进入休眠状态,但它并不会释放它所持有的锁。这意味着其他线程仍然无法进入这个同步代码块。
    • wait() 会释放锁。当线程在一个同步代码块中调用了锁对象的 wait() 方法,它会立即释放当前持有的这个锁,并进入该对象的等待队列(Waiting Set)中。这样,其他等待这个锁的线程就有机会获取到锁并执行。
  3. 被唤醒的方式不同
    • sleep() 方法在休眠指定的时间后,会自动苏醒,然后进入就绪状态,等待CPU调度。它也可以被 interrupt() 方法中断,并抛出 InterruptedException
    • wait() 方法必须由其他线程调用同一个锁对象的 notify()notifyAll() 方法来唤醒。被唤醒后,它不会立即执行,而是会进入锁池(Entry Set),重新去竞争这个锁,只有当它再次获取到锁之后,才能从 wait() 的地方继续往下执行。它同样可以被 interrupt() 中断。
  4. 使用的场景不同
    • sleep() 通常用于暂停当前线程的执行,比如在模拟某个操作耗时、或者为了防止CPU占用率过高而让出时间片等场景,它不涉及到线程间的通信。
    • wait() / notify() / notifyAll() 这一组方法是Java提供的经典线程间通信机制,专门用于协调多个线程的执行顺序,比如经典的“生产者-消费者”模型。生产者生产完数据后调用 notify() 唤醒消费者,消费者消费完数据后调用 notify() 唤醒生产者,如果缓冲区是满的或空的,就调用 wait() 进入等待。

Lock接口和synchronized的区别

  1. 实现层面和用法

    • synchronized 是关键字,用法非常简单,直接修饰方法或代码块即可。JVM会自动负责锁的获取和释放,即使代码块中出现异常,JVM也能保证锁会被正确释放,所以不容易出错。
    • Lock 是一个接口,需要手动实例化具体的实现类(如 ReentrantLock)。它的加锁和解锁操作需要显式调用 lock()unlock() 方法。为了保证锁一定能被释放,通常必须在 finally 块中调用 unlock() 方法,这在使用上比 synchronized 更灵活但更复杂。
  2. 功能丰富性

    • synchronized 的功能相对单一,它只提供了最基本的互斥同步功能。

    • Lock

      接口提供了比

      synchronized

      丰富得多的功能:

      • 可中断的锁获取lockInterruptibly() 方法允许线程在等待锁的过程中响应中断,避免了死等。
      • 可超时的锁获取tryLock(long time, TimeUnit unit) 方法允许线程在指定时间内尝试获取锁,如果超时还未获取到就返回 false,可以有效避免死锁。
      • 公平锁与非公平锁ReentrantLock 的构造函数可以接受一个布尔值参数来指定锁是公平的还是非公平的。公平锁会按照线程请求的顺序来分配锁,而非公平锁则允许插队,性能更高。synchronized 只支持非公平锁。
      • 绑定多个条件(Condition):一个 Lock 对象可以关联多个 Condition 对象,通过 newCondition() 创建。这在实现复杂的线程通信时非常有用,比如在“生产者-消费者”模型中,可以为“缓冲区不为空”和“缓冲区不满”创建两个独立的条件,实现精准的线程唤醒。而 synchronized 只能关联一个等待队列,只能通过 notifyAll() 唤醒所有等待线程,效率较低。
  3. 性能

    • 在早期的JDK版本(如1.5之前),ReentrantLock 的性能通常优于 synchronized
    • 但是从JDK 1.6开始,JVM对 synchronized 进行了大量的优化,比如引入了偏向锁、轻量级锁、自旋锁等锁升级机制,使得 synchronized 在大多数情况下的性能已经和 ReentrantLock 不相上下,甚至在锁竞争不激烈的情况下可能更好,因为JVM层面的优化更底层。

JDK动态代理和CGLIB动态代理

JDK动态代理和CGLIB动态代理是Java中实现AOP(面向切面编程)最核心的两种方式,Spring框架就同时集成了这两种代理技术。

JDK动态代理

  1. 实现原理
    • JDK动态代理是Java官方提供的、内置在JDK中的一种代理方式。它利用的是反射机制。
    • 它的核心是 java.lang.reflect.Proxy 类和 java.lang.reflect.InvocationHandler 接口。
    • 当我们为一个目标对象创建代理时,Proxy.newProxyInstance() 方法会在运行时动态地创建一个新的代理类。这个代理类会实现目标对象所实现的接口,并且会继承 java.lang.reflect.Proxy 类。
    • 当我们通过代理对象调用任何一个接口方法时,这个调用会被转发到我们自己实现的 InvocationHandlerinvoke 方法中。在 invoke 方法里,我们就可以在调用真实的目标方法前后,加入自己的增强逻辑,比如日志记录、事务管理等。
  2. 核心要求
    • JDK动态代理有一个非常严格的限制:被代理的目标类必须至少实现一个接口。代理对象是基于接口创建的,它和目标类是兄弟关系,都实现了相同的接口。
  3. 优点与缺点
    • 优点:是JDK原生支持的,无需引入任何第三方库,兼容性好。
    • 缺点:只能代理实现了接口的类,对于没有实现接口的普通类,它就无能为力了。

CGLIB动态代理

  1. 实现原理
    • CGLIB(Code Generation Library)是一个强大的、高性能的代码生成库,它是一个第三方库。
    • 它的原理是通过继承来实现的。它使用了一个叫 ASM 的字节码操作框架,在运行时动态地生成一个被代理类的子类
    • 这个子类会重写父类(也就是目标类)中所有非 final 的方法。
    • 当我们调用代理对象的任何方法时,实际上是调用了这个子类中重写的方法。在这些重写的方法内部,它会通过一个叫 MethodInterceptor 的回调接口,来织入我们自定义的增强逻辑。
  2. 核心要求
    • CGLIB代理的目标类不能是 final,因为它需要被继承。同样,目标类中要被代理的方法也不能是 finalprivate,因为子类无法重写这些方法。
  3. 优点与缺点
    • 优点:可以代理没有实现接口的普通类,弥补了JDK动态代理的不足。在某些情况下,因为它是通过生成子类和方法重写来实现的,避免了反射调用,性能可能比JDK动态代理更高。
    • 缺点:需要引入第三方库。对于 final 类或方法无能为力。

在实际开发中,我们很少直接使用这两种代理,更多的是通过Spring AOP来间接使用。Spring框架会智能地在这两者之间进行选择:

  • 如果目标对象实现了接口,Spring默认会使用 JDK动态代理
  • 如果目标对象没有实现接口,Spring会切换到使用 CGLIB动态代理

消息队列(RocketMQ)

3个核心应用场景:异步处理,应用解耦,流量削峰

消息类型

普通消息,FIFO顺序消息,Delay定时/延时消息,Transaction事务消息

队列类型

死信队列、延迟队列、优先级队列、广播队列

死信队列的主要作用是收集处理失败的消息,方便我们后续对这些异常消息进行分析、排查问题,或者进行人工干预和重试

延迟队列这种队列里的消息不会立即被消费,而是会等待一个预设的时间后才变得可见,然后才能被消费者获取

优先级队列 这种队列允许我们为消息设置不同的优先级。当消费者来获取消息时,会优先获取优先级高的消息

广播队列这其实就是发布/订阅模型的一种体现

问题:

消息重复消费问题

通常需要从消费端来保证幂等性。也就是说,对于同一个消息,即使消费者多次接收和处理,产生的结果也应该和只处理一次是完全一样的。

我通常会采用以下几种方法来保证幂等性:

  1. 数据库唯一键约束:这是最直接有效的方法。比如,在处理一个“创建订单”的消息时,我们可以利用订单号(OrderID)作为数据库表的主键或者唯一索引。当消费者第一次处理这个消息时,订单会成功插入。如果之后又收到同样订单号的消息,再次尝试插入时,数据库会因为唯一键冲突而报错,从而阻止了重复创建订单。我们可以捕获这个异常,然后认为这个消息已经处理过了,直接手动确认(ack)即可。
  2. 版本号(乐观锁):对于更新操作,可以使用版本号机制。比如在更新一个账户余额时,数据库表中增加一个 version 字段。每次更新前,先查询出当前的 version,然后在更新的 SQL 语句中加入 WHERE version = a 的条件,并且在 SET 子句中让 version 加一。UPDATE table SET balance = balance - 10, version = version + 1 WHERE id = ? AND version = ?。如果重复的消息过来,由于 version 已经改变,第二次更新的 WHERE 条件将不成立,更新操作会失败。
  3. 全局唯一ID + 状态机:这种方式适用性更广。我们可以在消息体中加入一个全局唯一的ID,比如使用雪花算法生成的ID,或者直接用业务ID。消费者端准备一个存储介质,比如 Redis 或者数据库,用来记录已经处理过的消息ID。
    • 每次消费消息前,先拿着这个唯一ID去查一下,看是否已经处理过。
    • 如果查到了,说明是重复消息,直接丢弃并确认消息。
    • 如果没查到,就正常执行业务逻辑。
    • 业务逻辑执行成功后,将这个唯一ID写入存储中。 为了保证原子性,查询、执行、写入这三个步骤最好放在一个事务里。如果使用 Redis,可以用 SETNX 命令,既能判断是否存在,又能写入,是原子操作。
  4. 业务层面的状态判断:有些业务逻辑天然就支持幂等。比如,将用户的状态从“未激活”更新为“已激活”。这个操作无论执行多少次,最终结果都是“已激活”,所以不需要做额外的幂等处理。

消息丢失

需要从三个关键环节入手:生产者端消息队列服务端(Broker)消费者端,确保消息在整个生命周期中都是可靠的

1. 生产者端:确保消息成功发送到 Broker

  • 问题:生产者发送消息后,可能因为网络问题或者 Broker 宕机,导致消息并没有真正到达 Broker。

  • 解决方案

    • 使用事务消息:这种方式可以保证生产者本地的业务操作和发送消息这两个动作在一个事务里,要么都成功,要么都失败。但它的性能开销比较大,会降低吞吐量,所以一般在对一致性要求极高的场景下使用。

    • 使用 Publisher Confirms 机制(发送方确认机制)

      :这是更常用、性能更好的方式。生产者发送消息后,可以设置一个回调。当 Broker 成功接收并处理了消息后,会回调生产者的

      ack

      方法;如果失败,则会回调

      nack

      方法。

      • 我们可以在发送消息后,将消息ID和状态(例如“发送中”)存入数据库或 Redis。
      • 收到 ack 回调后,更新状态为“成功”。
      • 如果收到 nack 回调,或者长时间没有收到回调(超时),我们就可以触发重试机制,重新发送这条消息。为了防止应用重启导致内存中的重试逻辑丢失,我们通常会有一个定时任务去扫描数据库里那些长时间处于“发送中”状态的消息,进行补偿重发。

2. Broker 端:确保消息在 Broker 内部不丢失

  • 问题:消息已经到达 Broker,但存储在内存中。如果此时 Broker 宕机,内存中的消息就会丢失。

  • 解决方案

    • 开启持久化:这是最基本的操作。我们需要将交换机(Exchange)、队列(Queue)和消息(Message)都设置为持久化的。这样即使 Broker 重启,数据也能从磁盘恢复。
    • 集群化部署:单点的 Broker 始终存在风险。通过搭建 MQ 集群(比如 RabbitMQ 的镜像集群模式),消息会被同步到多个节点。当主节点宕机时,从节点可以接替工作,保证了服务的高可用性和数据的冗余备份。

3. 消费者端:确保消息被成功消费

  • 问题:消费者获取到消息后,还没来得及处理完业务逻辑就宕机了,而 Broker 以为消息已经被消费,就将消息删除了。

  • 解决方案

    • 关闭自动确认(Auto Ack),使用手动确认(Manual Ack):这是至关重要的一步。我们必须在消费者的代码中,等待业务逻辑完全成功处理完毕后,再手动调用 ack 方法,通知 Broker 这条消息可以删除了。
    • 处理异常:如果在处理业务逻辑时发生异常,我们不能直接 ack。可以根据情况选择 nackreject,并决定是否将消息重新入队(requeue)。如果是一些可重试的错误(比如调用下游服务超时),可以重新入队让其他消费者尝试;如果是一些确定无法处理的错误(比如数据格式错误),就不应该重新入队,而是应该将消息发送到前面提到的死信队列,进行后续的人工排查和处理,避免无效消息占满队列,影响正常流程。

分布式缓存

主要使用 Redis 作为分布式缓存解决方案。在系统中,我们将一些高频访问但更新不频繁的数据放入缓存,比如基础数据字典、用户权限信息、以及部分计算结果。这有效降低了数据库的压力,提升了接口响应速度

  1. 缓存设计与应用场景: 在系统中,我们将一些高频访问但更新不频繁的数据放入缓存,比如基础数据字典、用户权限信息、以及部分计算结果。这有效降低了数据库的压力,提升了接口响应速度。
  2. 缓存策略与数据一致性: 我们最常用的是 Cache-Aside 模式。在查询时,先查缓存,命中则返回;未命中则查数据库,并将结果写入缓存。更新数据时,我们先更新数据库,再删除缓存,而不是直接更新缓存,这样可以避免在并发写时可能出现的复杂的数据不一致问题。我们也会为缓存Key设置合理的过期时间,作为最终一致性的兜底策略。
  3. 高可用与集群架构: 为了保证缓存的可用性,我们使用了 Redis Sentinel(哨兵) 主从架构。哨兵机制可以实现主节点的故障自动切换,当主节点宕机时,能自动将一个从节点提升为主节点,保证服务不间断。这在实际生产环境中为我们提供了稳定的缓存服务。
  4. 实战问题与优化
    • 缓存穿透:我们遇到过使用不存在的Key频繁请求的情况。我们的解决方案是,即使从数据库查询不到数据,也把一个空值(比如null)写入缓存,并设置一个较短的过期时间,这样后续的请求在短时间内就会直接拿到空值,而不会持续穿透到数据库。
    • 缓存雪崩:为了避免大量缓存Key在同一时间点失效导致请求全部打到数据库,我们采用了给缓存过期时间加上一个随机因子的方法,让Key的失效时间均匀分布。
    • 热点Key:对于某些访问量特别大的热点数据,我们通过提前加载、永不过期(或长过期时间)并结合定时任务异步更新的方式来保障性能。

缓存雪崩、击穿、穿透

Redis缓存

问题根因特征解决方案
雪崩同时过期一片塌随机 TTL、预热、多级缓存
击穿热点失效一点炸加锁、逻辑过期、异步刷新
穿透数据不存在永远 MISS空值缓存、布隆过滤器、参数校验
  1. 缓存穿透

    • 问题:指的是查询一个数据库中根本不存在的数据。这时缓存中自然也没有,所以每次请求都会直接穿透缓存,直接查询数据库。如果有人恶意用大量不存在的Key发起攻击,数据库就可能被打垮。
    • 解决方案:我们采用的方案是 “缓存空对象” 。即使从数据库查不到数据,我们也把这个Key和对应的空值(比如 null)存入缓存,并设置一个较短的过期时间(比如3-5分钟)。这样后续的请求在短时间内就能在缓存层拿到结果,从而保护数据库。此外,还可以结合布隆过滤器,在查询缓存前先进行一层过滤,判断数据是否存在,从根本上拦截掉不存在的Key请求。
  2. 缓存击穿

    • 问题:指的是某个热点Key在缓存中过期的瞬间,同时有大量的请求这个Key的请求进来。由于缓存失效,这些请求会同时去查询数据库,瞬间给数据库带来巨大的压力。
    • 解决方案:核心思路是避免大量请求同时去数据库重建缓存。我们常用的方法是 “互斥锁” 。当第一个发现缓存失效的请求到来时,它会先去获取一个分布式锁(比如用Redis的SETNX命令),获取到锁的线程去数据库查询数据并重建缓存,而其他没拿到锁的线程则等待一段时间后重试缓存查询。这样可以确保只有一个线程去执行数据库查询,其他线程等待并使用重建后的缓存。
  3. 缓存雪崩

    • 问题:比“击穿”更严重的情况。指的是在某个时间点,大量的缓存Key同时失效,或者Redis缓存服务本身直接宕机了。导致所有的请求瞬间都涌向数据库,造成数据库压力激增甚至宕机,就像雪崩一样。

    • 解决方案

      • 对于大量Key同时失效:我们主要采取 “设置不同的过期时间” 的策略。在给缓存设置过期时间时,加上一个随机的偏移量(比如基础时间+随机几分钟),让Key的失效时间点尽量均匀分布,避免在同一时刻集体失效。
      • 对于Redis服务宕机:这就需要从架构层面保证高可用。我们当时使用了 Redis Sentinel(哨兵模式) 或者 Redis Cluster(集群模式),当主节点宕机时,可以自动进行故障转移,确保缓存服务不中断。

JVM调优

在系统中我们遇到过JVM调优的情况。当时系统在月初高峰期频繁出现Full GC,导致响应时间明显变长。

我通过jstat命令监控到老年代使用率在高峰时会快速达到98%以上,然后触发Full GC。使用jmap生成堆转储文件后,通过MAT工具分析发现是资金计划报表导出功能中存在大量临时对象堆积。

具体优化措施包括:

  1. 将报表导出中的EasyExcel写入方式从多次append改为批量写入,减少中间对象的创建
  2. 调整JVM参数,将年轻代大小从默认的1/3增加到1/2,并设置-XX:+UseG1GC使用G1垃圾收集器
  3. 对资金计划查询接口中的大对象进行缓存,避免重复计算

优化后,Full GC频率从每天几十次降低到1-2次,系统在高峰期的响应时间也稳定在可接受范围内。

高并发,高可用

我们系统就是一个基于 Spring Cloud Alibaba 的分布式微服务架构项目。我以这个项目为例,详细说明一下我们在高并发、高可用和高扩展性方面的具体实践。

1. 高并发处理:

  • 服务拆分与异步化: 我们将系统拆分为用户中心、审批流、资金计划、报表服务等多个微服务。这本身就将压力分散了。对于一些非核心或耗时操作,我们大量使用了异步处理。例如,在资金计划审批完成后,需要更新多个关联数据和生成通知,我们不是同步等待所有操作完成,而是将后续任务通过消息队列(如 RocketMQ)发出,由相应的服务异步消费,从而快速释放请求线程,响应用户。
  • 缓存策略: 为了应对高并发读取,我们大量使用了 Redis 作为分布式缓存。比如,用户的权限信息、审批流的配置信息、以及一些频繁访问但更新不频繁的字典数据,都缓存在 Redis 中,极大地减轻了数据库(Oracle)的压力。
  • 数据库优化: 在数据库层面,除了建立合适的索引,对于一些复杂的报表查询,我们会进行 SQL 优化,并利用 Echarts 在前端做数据聚合和展示,避免在数据库中进行过于沉重的联表和多维度统计。

2. 高可用保障:

  • 注册中心与配置中心: 我们使用 Nacos 作为服务注册中心和配置中心。服务实例都注册到 Nacos,消费者通过服务名进行调用,实现了服务的自动发现和负载均衡。当某个服务实例宕机时,Nacos 会将其剔除,流量会自动切换到健康的实例上,保证了服务的持续可用。配置中心则让我们能动态调整参数(如线程池大小、超时时间),无需重启服务。
  • 容错与降级: 我们使用了 Sentinel 作为流量控制组件。为核心服务设置了 QPS 和线程数的限流规则,防止突发流量打垮服务。同时,对于非核心的依赖服务(比如,一个调用外部系统获取汇率的功能),我们设置了降级规则。当调用失败或超时时,会自动返回一个预设的默认值或友好提示,保证主流程不受影响,提高了系统的韧性。
  • 分布式任务调度: 我们使用 XXL-JOB 来执行定时任务,比如每天凌晨的数据汇总和报表生成。XXL-JOB 本身支持集群部署和故障转移,如果一个执行器节点宕机,调度中心会自动将任务路由到其他健康的节点上执行,确保了定时任务的高可用。

3. 高扩展性设计:

  • 无状态服务: 我们的微服务在设计时都是无状态的,用户的会话信息等都存储在 Redis 中。这使得我们可以非常方便地通过增加服务实例的方式来水平扩展,以应对业务量的增长。
  • API 网关: 系统的入口是一个统一的 API 网关(我们使用的是 Spring Cloud Gateway)。所有外部请求都先经过网关,由网关负责路由、鉴权、限流和日志记录。这种设计使得内部微服务的调整和扩展对前端是透明的,扩展性非常好。

容器化技术

使用 Docker 来打包和部署应用,比如将 Spring Boot 应用容器化,并配合 Docker Compose 管理多个服务(如应用、Redis、MySQL)。这样能保证环境一致性,简化部署流程

K8s,我了解其基本概念和架构,比如 Pod、Deployment、Service 这些资源对象,以及如何通过 K8s 进行应用的扩缩容和滚动更新。虽然目前项目中没有直接的生产环境 K8s 经验,但我通过本地搭建 Minikube 环境进行过实践,熟悉基本的 kubectl 命令和 YAML 配置。

k8s

控制平面包括:

  • kube-apiserver:集群的入口,所有资源操作的唯一入口,提供 RESTful API。
  • etcd:高可用的键值数据库,存储集群的所有状态数据。
  • kube-scheduler:负责调度 Pod 到合适的 Node 上,依据资源需求、策略等。
  • kube-controller-manager:运行各种控制器,例如 Node Controller、Replication Controller,它们不断调整系统状态至预期状态。
  • cloud-controller-manager:与底层云服务商交互,管理节点、路由等。

工作节点包括:

  • kubelet:与 API Server 通信,管理本节点上 Pod 的生命周期和容器。
  • kube-proxy:维护节点上的网络规则,实现 Service 的负载均衡和服务发现。
  • 容器运行时(如 Docker、containerd):负责运行容器。

关于容器编排,Kubernetes 采用的是“声明式 API”和“控制器模式”。 用户通过 YAML 或 JSON 文件声明应用的期望状态(例如需要 3 个副本)。控制器(如 Deployment Controller)会持续监听 API Server,当发现实际状态(当前只有 2 个副本)与期望状态不符时,它会调用 API Server 发起创建 Pod 的请求。调度器(kube-scheduler)则为这个新 Pod 选择一个最合适的节点,该节点的 kubelet 最终负责拉起容器。

至于灰度发布,我了解几种常见方式:

  1. 金丝雀发布 (Canary Release):通过 Deployment 先部署一个新版本(金丝雀),并手动调整 Service 的标签选择器,将一小部分流量导入新版本。验证无误后,再逐步替换所有旧版本。
  2. 蓝绿部署 (Blue-Green Deployment):同时部署两套完全相同的环境(蓝和绿)。当前线上流量指向蓝环境。部署新版本到绿环境,测试通过后,将负载均衡器的流量一次性从蓝环境切换到绿环境。

Mysql数据库

B+树和B树区别

  1. 数据存储位置:B树的所有节点都存储数据,而B+树只有叶子节点存储数据,非叶子节点只存储索引信息
  2. 叶子节点连接:B+树的叶子节点通过指针连接成有序链表,支持范围查询,而B树没有这个特性
  3. 查询稳定性:B+树每次查询都要走到叶子节点,查询路径长度稳定,B树的查询路径长度可能不同
  4. 存储效率:由于B+树非叶子节点不存数据,可以存储更多的索引项,树的高度更低,磁盘IO次数更少

在MySQL中采用B+树作为索引结构,主要是因为:

  • 范围查询性能更好,通过叶子节点的链表可以快速遍历
  • 更适合磁盘存储,减少了磁盘IO次数
  • 查询性能更稳定,所有查询都要走到叶子节点

索引失效问题

  1. 使用函数或表达式:比如 WHERE YEAR(create_time) = 2024,应该在字段上使用函数前先计算好值
  2. 隐式类型转换:比如字符串字段用数字查询 WHERE user_id = 123,而user_id是varchar类型
  3. 前导模糊查询LIKE '%keyword' 无法使用索引,但 LIKE 'keyword%' 可以使用
  4. OR条件不当:当OR条件中有的列没有索引时,可能导致全表扫描
  5. 不符合最左前缀原则:在复合索引中,没有使用最左边的列
  6. 使用不等于操作!=<> 通常无法使用索引
  7. 数据分布问题:当优化器判断全表扫描比使用索引更快时,比如数据量很小或者索引选择性很差