八股(九)杂七杂八

Spring 的设计

Spring 的设计目标就是:解耦 + 简化开发 + 提高可维护性

核心思想:

(1)IoC(控制反转):把"控制权"从程序员手里拿走,对象的创建 & 依赖关系交给容器管理。

java 复制代码
@Autowired
UserService userService;

IOC 底层 = 工厂模式 + 反射 + 配置解析(读配置 → 用反射创建对象 → 放进容器里管理

  1. 配置解析 (XML / 注解):知道要创建哪些类、属性值是什么
    读注解 @Service、@Component、@Controller 得到类的全限定名:com.xxx.UserService
  2. 反射 (Reflection):创建对象、注入属性、调用方法
    反射:根据类名创建对象。Spring IOC 不直接使用 new 关键字创建对象,而是在运行时通过Class.forName(全类名) 加载类的字节码信息,再通过构造器反射动态实例化 Bean;同时利用 Field、Method 反射机制,完成私有属性依赖注入,最终实现对象创建与依赖装配的完全自动化、配置化。
    Class<?> clazz = Class.forName("com.xxx.UserService");
    Object bean = clazz.newInstance(); // 反射创建对象
  3. BeanFactory / ApplicationContext :放对象的容器(工厂)
    把反射创建好的对象放进一个 Map 里,你要拿的时候直接从 map 取,不用自己 new。

(2)把"重复代码"抽离出来(AOP)

AOP的底层实现本质是动态代理,Spring通过JDK动态代理或CGLIB代理为目标对象生成代理对象,在方法调用前后插入增强逻辑。

  1. JDK动态代理(接口代理):必须有接口。
  2. CGLIB代理(子类继承代理):继承目标类,重写方法。

创建代理对象--方法调用拦截--执行增强逻辑

不修改原有业务源码,对方法进行统一功能增强,解耦通用非业务代码。

  • 日志记录
  • 事务控制
  • 权限校验
  • 性能监控、缓存、异常统一处理

除了 IoC 和 AOP 这两个核心功能,Spring 还提供了:

(3)数据访问支持

(4)Web 开发支持

(5)测试支持

HTTP、RPC 和 MQ

在实际生产系统中,通常会采用 Nginx、HTTP、RPC 和 MQ 组合的架构模式。

Nginx 作为系统的统一入口,负责接收外部 HTTP 请求,并提供反向代理、负载均衡以及静态资源分发等能力,从而屏蔽后端服务细节并提升系统性能和可扩展性。

HTTP 主要用于前端或移动端与后端系统之间的交互,适用于需要同步返回结果的场景,例如用户下单请求。

RPC 主要用于微服务之间的内部调用,相比 HTTP 具有更高的性能和更低的通信开销,适用于订单服务调用库存服务、用户服务等高频内部通信。

MQ 主要用于异步解耦和削峰填谷,例如订单创建成功后,通过消息队列异步通知短信服务、推荐系统和日志系统,从而提高系统的吞吐能力和稳定性。

因此在典型的电商系统中,整体架构通常是:Nginx 作为入口,HTTP 负责外部请求,RPC 负责内部服务调用,MQ 负责异步扩展。

@Autowired 和 @Resource

@Autowired 是 Spring 内置的注解,默认注入逻辑为先按类型(byType)匹配,若存在多个同类型 Bean,则再尝试按名称(byName)筛选

@Resource 默认取字段名(Field Name)作为 bean 的名称去容器中查找。如果找到了该名称的 Bean,则直接注入。 如果没有 找到同名的 Bean,Spring 会退而求其次,尝试根据字段的类型去查找。

考虑到 @Resource 的语义更清晰(名称优先),并且是 Java 标准,能减少对 Spring 框架的强耦合,我们通常更推荐使用 @Resource,尤其是在需要按名称注入的场景下。

但在实际开发中,更多使用 @Autowired

主要原因是 @Autowired 按类型注入,更符合现代开发习惯,并且支持 @Qualifier 实现更灵活的依赖选择。同时,@Autowired 支持构造器注入,这是 Spring 官方推荐的方式,能够提高代码的安全性和可测试性。

注入 Bean 的方式

依赖注入 (Dependency Injection, DI) 的常见方式:

  1. 构造函数注入:通过类的构造函数来注入依赖项。
  2. Setter 注入:通过类的 Setter 方法来注入依赖项。
  3. Field(字段) 注入:直接在类的字段上使用注解(如 @Autowired@Resource)来注入依赖项。

构造器注入:对象创建时,依赖必须传入。

依赖完整(不会找不到)、支持 final(不可变)、线程安全、易测试(Field 注入的问题就在于强依赖 Spring 容器,不利于测试,因为对象在容器里,测试还要起spring,把单元测试变成集成测试,要么就要自己写反射,总之很不好)。

构造器注入在测试中更有优势,因为它可以在不依赖 Spring 容器的情况下,通过 new 关键字手动创建对象,并传入 mock 依赖,从而实现纯粹的单元测试。

Mock 依赖是指在单元测试中,用一个模拟对象替代真实依赖对象,从而隔离被测试类与外部系统的依赖。通过 Mock,可以避免数据库、网络等外部资源的影响,使测试更加快速、稳定和可控。常用的 Mock 工具有 Mockito,可以动态生成模拟对象并指定返回结果。when-then

测试 = JUnit + Mockito

Bean 的作用域有哪些?

Spring 中 Bean 的作用域通常有下面几种:

  • singleton : IoC 容器中只有唯一的 bean 实例。Spring 中的 bean 默认都是单例的,是对单例设计模式的应用。
  • prototype : 每次获取都会创建一个新的 bean 实例。也就是说,连续 getBean() 两次,得到的是不同的 Bean 实例。
  • request (仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。
  • session (仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。
  • application/global-session (仅 Web 应用可用):每个 Web 应用在启动时创建一个 Bean(应用 Bean),该 bean 仅在当前应用启动时间内有效。
  • websocket (仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。

⭐Spring框架中的单例bean是线程安全的吗?

不是线程安全的。当多用户同时请求一个服务时,容器会给每个请求分配一个线程,这些线程会并发执行业务逻辑。

  • Spring 单例 bean 若无状态(没有可修改的成员变量),就是线程安全;
  • 有状态(有会被修改的全局属性),就不安全,需要自己加锁。

解决方案:改为 prototype 多例作用域、加锁或使用 ThreadLocal。

最简单的解决办法是将单例bean的作用域由"singleton"变更为"prototype"。每次请求 / 每次注入 都新建一个全新 Bean,彻底杜绝线程安全问题,代码不用改动。但是大量创建对象,频繁 GC、占用内存;破坏 Spring 单例设计初衷,事务、缓存等部分特性会受影响。

加锁(synchronized / ReentrantLock)简单粗暴、改动小。并发变串行,接口性能暴跌,高并发项目不推荐。

ThreadLocal(最优)给每个线程单独分配一份独立变量 ,线程之间完全隔离,各自用自己的变量,互不干扰,不用加锁。无锁、并发高性能,必须手动 remove(),否则容易**内存泄漏,**不能解决线程间需要共享数据的场景。

Spring中的循环引用?

循环依赖发生在两个或两个以上的bean互相持有对方,形成闭环。Spring框架允许循环依赖存在,并通过三级缓存解决大部分循环依赖问题:

  1. 一级缓存:单例池,缓存已完成初始化的bean对象。

  2. 二级缓存:缓存尚未完成生命周期的早期bean对象。

  3. 三级缓存:缓存ObjectFactory,用于创建bean对象。

构造方法出现了循环依赖怎么解决?

由于构造函数是bean生命周期中最先执行的,Spring框架无法解决构造方法的循环依赖问题。可以使用@Lazy懒加载注解,延迟bean的创建直到实际需要时。

AOP 常见的通知类型有哪些?

  • Before(前置通知):目标对象的方法调用之前触发
  • After (后置通知):目标对象的方法调用之后触发
  • AfterReturning(返回通知):目标对象的方法调用完成,在返回结果值之后触发。
  • AfterThrowing(异常通知):目标对象的方法运行中抛出 / 触发异常后触发。AfterReturning 和 AfterThrowing 两者互斥。如果方法调用成功无异常,则会有返回值;如果方法抛出了异常,则不会有返回值。
  • Around (环绕通知):编程式控制目标对象的方法调用。环绕通知是所有通知类型中可操作范围最大的一种,因为它可以直接拿到目标对象,以及要执行的方法,所以环绕通知可以任意的在目标对象的方法调用前后搞事,甚至不调用目标对象的方法。

通常使用 @Order 注解直接定义切面顺序。

什么是AOP?

AOP,即面向切面编程,在Spring中用于将那些与业务无关但对多个对象产生影响的公共行为和逻辑抽取出来,实现公共模块复用,降低耦合。常见的应用场景包括公共日志保存和事务处理。

⭐你们项目中有没有使用到AOP?

我们之前在后台管理系统中使用AOP来记录系统操作日志。主要思路是使用AOP的环绕通知和切点表达式,找到需要记录日志的方法,然后通过环绕通知的参数获取请求方法的参数,例如类信息、方法信息、注解、请求方式等,再从 ThreadLocal 拿到当前登录用户信息,统一封装操作日志实体,保存到数据库。

这样把日志记录的通用逻辑抽离到切面,和业务代码完全解耦,不用每个接口手动写日志,极大简化代码。

Spring中的事务是如何实现的?

Spring实现事务的本质是利用AOP完成的。它对方法前后进行拦截,在执行方法前开启事务,在执行完目标方法后根据执行情况提交或回滚事务。

Spring中事务失效的场景有哪些?

如果方法内部捕获并处理了异常,没有将异常抛出,会导致事务失效。因此,处理异常后应该确保异常能够被抛出。

@SpringBootApplication

用于标注主启动类。

  • @SpringBootConfiguration:允许注册额外的 Spring Bean 或导入其他配置类。
  • @EnableAutoConfiguration:启用 Spring Boot 的自动配置机制。
  • @ComponentScan:扫描 @Component@Service@Repository@Controller 等注解的类。

Springboot自动配置原理?

@EnableAutoConfiguration 是实现自动配置的核心,它通过 @Import 导入配置选择器,读取文件中的类名,根据条件注解决定是否将配置类中的 Bean 导入到 Spring 容器中。

MyBatis执行流程?

MyBatis的执行流程如下:

  1. 读取MyBatis配置文件 mybatis-config.xml
  2. 构造会话工厂 SqlSessionFactory
  3. 会话工厂创建 SqlSession对象。
  4. 操作数据库的接口,Executor执行器。
  5. Executor执行方法中的 MappedStatement参数。
  6. 输入参数映射。
  7. 输出结果映射。

MyBatisPlus vs MyBatis

MyBatis 是一款半自动的 ORM 框架,需要手动编写 SQL 语句,SQL 写在 XML / 注解中,易维护、好优化。自动结果映射:数据库字段 → 实体类属性,省去手动封装。

而 MyBatisPlus 是 MyBatis 的增强工具,只增强不改变,通过继承 BaseMapper 就能直接使用内置增删改查方法;提供条件构造器,用链式代码拼接查询条件,避免硬编码 SQL;同时自带分页、逻辑删除、主键自增、自动填充等通用功能,大幅减少重复代码,提升开发效率,复杂业务仍可自定义 SQL,兼顾高效与灵活。

Spring的对象依赖怎么处理

首先,什么是对象依赖?

A 类需要使用 B 类的实例 / 方法才能完成自身功能,就叫:A 依赖 B

传统写法:自己 new B() 耦合严重,改代码就要改 A。Spring 核心:不用自己 new,Spring 帮你创建对象、自动装配依赖

其次,Spring 处理依赖的两大核心步骤:

  1. Bean 注册:把所有对象交给 Spring 容器管理(变成 Bean)。
  2. 依赖注入 DI:容器自动把依赖的对象塞给当前 Bean。

再具体点,依赖注入三种方式:

  1. 构造器注入(官方推荐、Spring 首选)

依赖完整(不会找不到)、支持 final(不可变)、线程安全、易测试(不依赖 Spring 容器)。

  1. 字段注入 @Autowired、@Resourse

优点:写法简单。

缺点:强依赖 Spring 容器,不利于测试,因为对象在容器里,测试还要起spring,把单元测试变成集成测试,要么就要自己写反射,总之很不好。不能用 final,对象可被随意修改。

  1. setter方法

为什么只有字段注入依赖Spring容器?

Bean 注册、依赖注入Spring 容器内部工作流程,三种注入都要;

构造器 / Setter :只是让 Spring 帮忙自动注入,本身是纯 Java 语法,不用 Spring 也能手动传参正常运行;

字段注入:完全靠 @Autowired + Spring 反射魔法赋值,没有 Spring 容器就彻底用不了,所以说它强依赖 Spring 容器。

ConcurrentHashMap

ConcurrentHashMap 的高并发能力 = 分段锁(JDK1.7) + CAS(JDK1.8)

ConcurrentHashMap 在 JDK1.7 中采用分段锁机制,将整个 HashMap 分成多个 Segment,每个 Segment 独立加锁,从而提高并发能力。

在 JDK1.8 中,ConcurrentHashMap 采用 CAS 和 synchronized 结合的方式实现线程安全。在无竞争情况下,通过 CAS 实现无锁操作;在有竞争时,仅对局部桶加锁,从而减少锁粒度,提高并发性能。桶为空用 CAS 保证"只有一个线程写入成功",桶不为空必须加锁,只锁当前 bucket,因为涉及链表/树结构的多步修改,无法保证原子性。

CAS 是一种无锁并发机制,通过比较当前值和期望值是否一致来决定是否更新,属于乐观锁思想。

复制代码
put(key, value)

↓
定位桶(数组位置)

↓
桶为空 → CAS 插入 ✔

↓
不为空 → synchronized 锁该桶 🔒

↓
插入链表 / 红黑树

synchronized 和 Lock

synchronized 是 Java 提供的内置锁机制,是个关键字,用于控制多个线程对共享资源的访问,其底层通过对象头中的 Monitor 实现,属于悲观锁。在获取锁失败时线程会阻塞等待。

Lock 是 Java 提供的显式锁接口(如 ReentrantLock),是个类,有自己的方法,相比 synchronized 更加灵活,支持可中断、可公平锁以及 tryLock 等特性,但需要手动释放锁。

多线程并发问题

1️⃣ 共享 + 排队 → synchronized 悲观锁,通过加锁保证同一时间只有一个线程执行临界区代码,本质是线程排队执行。

2️⃣ 共享 + 不锁 → CAS 乐观锁,通过比较并交换的方式实现无锁操作,适用于冲突较少的场景,失败时通过自旋重试。可能会有ABA问题(值轮回 (A→B→A) + CAS 只比数值,不知道已经改变了),解决:版本号 / 标记位。

3️⃣ 不共享 → ThreadLocal,通过为每个线程提供独立的数据副本,从根本上避免共享资源,实现线程隔离。ThreadLocal 是一种线程隔离机制,用于为每个线程提供独立的变量副本,从而避免多线程并发访问共享变量带来的线程安全问题。其底层是每个线程维护一个 ThreadLocalMap,通过 ThreadLocal 作为 key 存储对应的值。

java 和 c# 的区别

一、相同的语法

以下语法两者一模一样,学会一门直接通用:

  • 基础数据类型:int、double、boolean、char、long

  • 流程控制:if、else、for、while、do-while、switch

  • 面向对象:类、对象、继承、重写、重载、多态、封装

  • 访问修饰符:public、private、protected、默认包访问权限(C#无)

  • 异常处理:try-catch-finally、throw、自定义异常

  • 循环关键字:break、continue、return

  • 逻辑运算符:&& || ! 、位运算、三元运算符

二、不同的语法

|-----------|-----------------------------------------------------|----------------------------------|
| 分类 | Java | C# |
| 输出打印 | System.out.println(); | Console.WriteLine(); |
| 字符串 | String 不可变,无字符串插值 | string 可简写,支持插值 $"name={name}" |
| 继承关键字 | extends(类)、implements(接口) | : 统一冒号,类/接口都用冒号 |
| 常量定义 | final | const / readonly |
| 命名空间 | package 包机制 | namespace 命名空间 |
| 导入包 | import | using |
| 特殊语法糖 | 包含foreach、泛型、Lambda、try-with-resources等基础语法糖,语法保守严谨 | Lambda、表达式体、自动属性、可空类型等,语法糖丰富且轻量化 |

三、Java & C# 底层本质差异

1. 编译运行机制

  • Java :源代码→class字节码→JVM虚拟机运行,跨平台极强

  • C# :源码编译为IL中间代码; 1. 旧版(.NET Framework):CLR运行时,仅支持Windows; 2. 新版(.NET Core/.NET5+):CoreCLR跨平台运行时,支持Windows/Linux/Mac。 通俗直白注解:CLR、CoreCLR 就是C#的虚拟机,对标Java的JVM。CLR(老旧、只能Windows),CoreCLR(新版、跨平台)。 生态:国外很强,国内互联网行业使用偏少。

2. 垃圾回收GC

  • Java:GC成熟、分代回收、专门优化大并发服务

  • C#:GC简洁高效、延迟低,适合桌面、游戏

3. 语法设计理念

  • Java:严谨规范、语法保守,自带少量基础语法糖,严控语法标准,适合大型团队分布式项目

  • C#:灵活、语法糖极多、简洁微软风,开发效率高,适合快速开发、桌面、游戏

4. 关键字关键区别

  • Java:extends、implements、final、import、@Override

  • C#::、override、const、using、readonly、?

5. 应用场景差异

  • Java:互联网后端、微服务、分布式、金融、大数据、安卓

  • C#:Unity游戏、Windows桌面软件、企业内部管理系统、ASP.NET网站

四、主流框架对比(SpringBoot vs ASP.NET Core)

|------------|---------------------------------|----------------------------------|
| 对比维度 | SpringBoot(Java) | ASP.NET Core(C#) |
| 开发风格 | 代码繁琐、配置较多、规范严格 | 极简语法、代码量少、开发效率高 |
| 运行性能 | 高并发强悍、吞吐量大、占用内存高 | 启动快、内存低、响应延迟更小 |
| 适用项目 | 互联网平台、商城、秒杀、大数据 | 企业管理、办公系统、后台内网 |
| 生态圈 | 国内垄断、开源插件极其丰富 | 国外强势、外企工业软件常用 |
| 上手难度 | 偏难、注解多、层级复杂 | 简单直白、新手友好 |
| 底层运行原理 | 依托JVM,默认预装大量组件,内存开销大,专为超高并发承压设计 | 依托CoreCLR,按需加载组件,轻量化启动,偏向低并发商用项目 |

五、总结

语法相似度85%;Java死板规范、跨平台无敌、后端生态最强;C#灵活简洁、语法糖多、游戏和Windows生态无敌。 生态圈方面,Spring(Java)主打互联网高并发,国内垄断;.NET(C#)主打工业、企业商用软件,欧美外企、车企、银行、游戏行业使用率极高。Java适合互联网项目,C#适合轻量化、低维护的商用软件。

vibe coding

我理解的 Vibe Coding,其实就是一种 AI 协同开发方式。

开发过程中,不再只是自己从零手写代码,而是通过自然语言和 AI 交互,让 AI 帮助完成:

  • 代码生成
  • Bug 排查
  • 接口设计
  • 重构优化
  • 文档编写
  • 单元测试生成

但核心还是开发者自己掌控架构和业务逻辑。

我平时会把 AI 当成一个"辅助开发工具",而不是完全依赖它。

听你说,你会用不同的ai,比如gemini、deep seek、kimi、chatgpt、豆包,你感觉有什么区别吗?你会怎么选?

我平时会混合使用不同 AI,因为不同模型的优势不太一样。我一般会按"任务类型"来选择。

架构 / 论文理解:ChatGPT

长代码 / 大项目:Claude

写代码:Gemini

DeepSeek:数学推理

中文长文档:Kimi

日常助手:豆包

我觉得:

  • 网页输入框,本质是"AI聊天"
  • 插件,本质是"AI代码补全"
  • Trae,本质是"AI协同开发IDE"

它们最大的区别在于 AI 能否真正理解整个工程上下文,以及是否具备多文件协同和 Agent 能力。

对于后端项目来说,随着项目规模变大,Trae 这类 AI IDE 的优势会明显大于普通插件。

**如果上下文满了怎么办?**让 AI 帮你总结当前进度。

  1. 模块化开发:一个对话只处理一个模块。
  2. 定期让 AI 总结项目状态,包括:技术栈、当前进度、已完成功能、待开发内容。然后在新对话里重新建立上下文。
  3. 维护项目文档。比如:README、PROJECT_CONTEXT、API文档,让 AI 能快速恢复工程背景。

比较棘手的问题?

在开发点评系统的时候,我遇到过一个比较棘手的问题是首页接口查询特别慢。刚开始数据量比较小的时候没有明显感觉,但后面随着店铺、评论和用户数据增加,首页加载时间明显变长。后来我通过慢查询日志和 EXPLAIN 去分析 SQL,发现接口里存在比较严重的 N+1 查询问题。比如先查询店铺列表,然后每个店铺又单独查询评论数、标签和用户信息,导致一次请求会执行很多条 SQL。之后我对查询逻辑做了优化,把部分循环查询改成了批量查询和 JOIN,同时根据实际查询条件增加了联合索引,还调整了一些会导致索引失效的写法,比如把函数查询改成范围查询。优化之后,SQL 数量和接口响应时间都下降了很多。这次经历让我对 MySQL 索引、执行计划和后端接口性能优化有了更深的理解。

在开发点评系统的时候,我遇到过一次比较印象深的上线问题。当时有用户反馈会偶尔出现重复点赞的情况,本地测试一直没有复现,后来我通过模拟并发请求才发现问题。原因是当时点赞逻辑采用的是"先查询是否点赞,再插入点赞记录"的方式,在高并发情况下,两个请求可能同时查询到"未点赞",然后同时执行插入,导致数据库里出现重复数据。本质上是因为"先查再改"不是原子操作。后来我先在数据库层面给用户 ID 和店铺 ID 建了联合唯一索引,保证从数据库层面不允许重复点赞,同时又在业务层结合 Redis 做了一层防重复判断,减少无效请求。这个问题让我意识到很多功能在单机、本地环境下看起来没问题,但真正上线后在并发场景下会暴露很多细节问题,所以后面我在开发时会更关注并发安全和数据一致性。

缓存预热怎么知道热点数据

缓存预热里的"热点数据"一般不是随便猜的,而是通过业务访问情况统计出来的。比如在点评系统里,像热门店铺、首页推荐、热门分类这些数据,本身访问量就会明显高于普通数据,这类通常会优先做缓存预热。实际开发中,我会结合几种方式判断热点数据:第一种是根据业务场景直接判断,比如首页固定展示的数据天然就是热点;第二种是通过数据库或者接口日志统计访问次数,比如统计最近一段时间查询量最高的店铺 ID;第三种是通过 Redis 的访问频率或者监控系统观察高频 Key。确定热点数据后,会在系统启动或者定时任务里提前加载到 Redis,避免用户第一次访问时直接打到数据库。另外热点数据通常还会配合较长的过期时间、逻辑过期或者异步刷新一起使用,减少缓存击穿的问题。

有了解rag吗?

RAG(Retrieval-Augmented Generation,检索增强生成)本质上是一种"让大模型先查资料,再生成回答"的方案。因为传统大模型虽然生成能力很强,但它的知识主要来自训练数据,存在知识过期、容易幻觉、不了解私有数据的问题,所以 RAG 的核心思路就是:先从外部知识库里检索相关内容,再把检索结果一起交给大模型生成答案。

比如一个企业内部问答系统,如果只靠大模型,它并不知道公司的内部文档、接口规范或者业务流程;但用了 RAG 后,可以先把公司的 PDF、Word、数据库内容做向量化存储,用户提问时先去向量数据库里检索最相关的内容,再把这些内容作为上下文发给模型,这样生成的回答会更准确,也能基于最新数据。

一个典型的 RAG 流程一般包括几个部分:首先是数据清洗和切分,比如把文档按段落拆分;然后使用 Embedding 模型把文本转成向量;接着存入向量数据库,比如 Milvus、FAISS、ChromaDB 这些;用户提问时,再把问题也转成向量做相似度检索,找到最相关的 TopK 文本片段,最后再和 Prompt 一起发给大模型生成最终答案。

我觉得 RAG 最大的价值在于两点:第一是解决大模型知识更新问题,不需要重新训练模型;第二是能结合企业私有知识库做垂直领域应用。现在很多 AI 客服、知识库问答、代码助手,其实底层都会结合 RAG。

agent是干什么的?

Agent 可以理解成不仅会回答问题,还会主动执行任务的 AI 系统。

它不仅能生成内容,还能:自己拆解任务、调用工具、读取文件、写代码、查数据库、调 API、执行命令、根据结果继续下一步。

从技术角度看,一个 Agent 通常包括几个核心部分:首先是大模型负责理解任务和推理;然后是 Memory,用来保存上下文和历史状态;再加上 Tool Use,也就是调用外部工具的能力,比如搜索、数据库、终端、浏览器;最后还有 Planning,负责把复杂任务拆成多个步骤逐步执行。

说说ReAct

Reason + Act,ReAct 会让模型在中间显式地产生:

  • Thought(思考)
  • Action(行动)
  • Observation(观察结果)

然后循环迭代。

怎么用ai code review

实际开发里,我会让 AI 帮我做检查,重点看几个方向:比如有没有空指针风险、事务问题、并发安全、SQL 性能问题、异常处理遗漏、重复代码、命名不规范这些。

怎么防止超卖?

防止超卖本质是保证高并发下库存扣减的原子性问题。

最基础的方式是数据库加锁,比如 select for update,但性能较差。更常见的是使用乐观锁,通过 version 或库存条件控制更新,利用 CAS 保证并发安全。

在高并发场景,比如秒杀系统,一般会采用 Redis 预扣库存,通过 Lua 脚本保证原子性,然后结合消息队列进行异步下单,数据库最终落库,从而实现削峰和高并发处理。

用户请求--Redis扣库存--MQ排队--异步创建订单--写数据库

同时还需要配合幂等设计和失败补偿机制,保证最终一致性。幂等就是保证"重复请求不会产生重复结果",核心手段是唯一标识 + 状态控制 + 去重机制。失败补偿就是在分布式系统无法保证强一致的情况下,通过重试、对账、回滚等方式保证最终数据一致性。

代码重构

一般做重构时,我会先从几个角度去考虑。

第一是代码结构层面,比如是否违反单一职责原则,一个方法里是不是做了太多事情。如果发现一个 service 方法既做数据库查询,又做缓存处理,还做业务计算,我会拆分成更清晰的模块,让职责更清晰。

第二是性能角度,主要看有没有明显的问题,比如 重复查询、或者循环里查数据库的情况,这类通常会通过批量查询或者缓存优化来解决。

第三是可维护性,如果逻辑比较复杂,我会考虑用策略模式或者模板方法去拆分,让逻辑更清晰。

第四是一致性和安全性,比如缓存更新策略是否一致、事务边界是否合理、有没有并发问题,这些在重构时也会顺带优化。

在这个过程中,其实我也会结合 AI 来辅助重构。比如我会把一段比较复杂的 service 代码交给 AI,让它帮我做结构拆分建议,或者指出潜在的设计问题,比如是否可以拆成多个函数、有没有可以抽象的公共逻辑。

在实际做重构或者设计时,我通常会先看当前代码有没有一些明显的"坏味道",比如大量 if-else 分支、业务逻辑耦合在一个 service 里、或者不同业务流程混在一起。如果出现这些情况,我才会考虑引入设计模式来拆分结构。

比如在点评系统里做一些业务处理时,不同类型的业务逻辑(比如普通点评、带图片点评、带奖励的点评)如果都写在一个方法里,就会变得很难维护,这个时候我可能会用策略模式把不同处理逻辑拆开,每种类型一个策略类,通过工厂去统一管理,这样后续扩展新类型就不会影响原有代码。

再比如如果有一些统一的流程,比如"校验 → 处理 → 记录日志 → 返回结果",但中间某些步骤会变化,我可能会用模板方法模式去抽象公共流程,把可变部分留给子类实现,这样既保证流程一致,又保证扩展性。

还有在一些对象创建比较复杂的场景,比如构建查询条件或者复杂 DTO,我会考虑用建造者模式来避免构造方法参数过多的问题,提高可读性。

另外像单例模式、工厂模式这种在 Spring 体系里其实已经大量内置了,比如 Bean 管理本身就是一种工厂模式的体现,所以更多时候不是"我要不要用设计模式",而是"Spring 已经帮我用了一部分"。

不过我也会刻意避免过度设计,因为设计模式如果用得太早或者不合适,会导致代码层级变多、阅读成本变高。所以一般原则是:先实现业务,再在重构阶段引入设计模式做结构优化

service放业务逻辑,那什么是业务逻辑呢?

为了完成某个业务目标所需要的"规则 + 判断 + 流程处理"。

举个最直观的例子(点评系统),比如"用户点赞店铺"。

❌ 不是业务逻辑的部分(技术逻辑):查数据库、调 Redis、发 MQ、调外部接口、日志打印、参数解析。

真正的业务逻辑是:

这个用户能不能点赞?是否已经点过赞?点赞后状态怎么变化?是否需要加积分?是否需要限制频率?

SOLID

SOLID 是面向对象设计的五大原则,用来指导我们写出低耦合、高内聚、易扩展的代码。

比如单一职责原则要求一个类只负责一类功能,开闭原则强调对扩展开放对修改关闭,依赖倒置原则强调依赖抽象而不是具体实现,这些原则在实际项目中可以帮助我们减少代码耦合,提高系统可维护性。

RESTful

RESTful 是一种接口设计风格,它强调将系统中的资源抽象出来,通过统一的 HTTP 方法来表达操作,比如 GET 表示查询,POST 表示新增,PUT 表示更新,DELETE 表示删除,同时 URL 只表示资源而不是行为,这样可以让接口更加规范和易于理解。

有没有自己写skills?

我理解 Skill 不只是 prompt 模板,而更像一个轻量级的 Agent 工作流,它会包含输入结构定义、处理步骤以及工具或知识引用机制。

比如在会议处理 Skill 里,我尝试加入过一种基于关键词触发的 reference 机制,当会议内容中出现"出差""预算"等关键词时,会动态加载对应的差旅政策或财务规则作为外部知识补充,从而辅助模型做合规判断。

同时这个过程是渐进式披露的,不是一次性把所有 policy 都输入给模型,而是先完成会议内容理解,再按需加载相关 reference,这样可以减少上下文噪声,提高推理聚焦度。

我觉得这种设计本质上是把 prompt 从静态描述升级成"按需执行的流程系统",也是 Skill 和普通 prompt 最大的区别。

讲讲HashMap底层结构,扩容机制与线程不安全问题

数组 + 链表 + 红黑树的结构,通过数组定位桶位置,链表解决 hash 冲突,当链表长度超过 8 时转为红黑树以提升查询效率。

HashMap 的扩容触发条件是 size 大于 capacity × loadFactor,默认负载因子是 0.75。扩容时容量会扩大为原来的两倍,并对元素进行重新分布。

HashMap 不是线程安全的,在多线程环境下可能出现数据覆盖、size 计算不准确以及 JDK1.7 中扩容导致的死循环问题,因此在并发场景中通常使用 ConcurrentHashMap 来替代。

JDK1.8 的 ConcurrentHashMap = CAS + synchronized(桶级锁)+ volatile 可见性控制,实现了"细粒度并发安全"。

接口和抽象类的差异,final关键字用法

接口和抽象类都是用于抽象设计的方式,但侧重点不同。

接口更强调"能力规范",用于定义一组行为约束,类可以实现多个接口,从而支持多继承的效果。接口中的方法默认是抽象的(JDK8之后支持默认方法和静态方法),不保存状态,主要用于解耦和统一规范。

抽象类更强调"is-a关系",用于表达一类事物的共性抽象,可以包含抽象方法和具体实现方法,同时可以保存成员变量,用于共享状态和代码复用。但Java中类只能单继承,因此抽象类适合表示更强的层级关系。

简单来说:

  • 接口 = 行为规范(能做什么)
  • 抽象类 = 共同模板(是什么)

在实际开发中,如果强调扩展能力和多实现,优先使用接口;如果强调代码复用和强关系建模,则使用抽象类。

final 在 Java 中用于表示"不可改变",可以作用于类、方法和变量。

当 final 修饰类时,该类不能被继承,常用于工具类或不希望被扩展的核心类。

当 final 修饰方法时,该方法不能被子类重写,用于保证核心逻辑不被修改。

当 final 修饰变量时,表示变量一旦赋值后不可再修改。如果是基本类型,值不可变;如果是引用类型,则引用地址不可变,但对象内部状态仍然可以改变。

在实际开发中,final 常用于保证不可变性、线程安全以及防止关键逻辑被覆盖,例如常量定义、工具类设计以及并发场景中的不可变对象。

构造方法注入是可以配合 final 使用的,这也是推荐的依赖注入方式之一。

因为通过构造器注入后,依赖对象只会在对象创建时被赋值一次,之后无法再被修改,因此可以将依赖字段声明为 final,从而保证对象的不可变性。

这种方式相比 Setter 注入的优势在于:

  • 可以保证依赖不可变,避免运行时被修改
  • 更符合"依赖一旦确定就不应该改变"的设计思想
  • 有利于线程安全(对象状态更稳定)
  • 也更利于单元测试,因为依赖在构造时就已经明确

在 Spring 中,构造器注入也是推荐方式,尤其是在依赖比较明确且不需要动态变更的情况下。

JVM简单内存分区,垃圾回收基础认知

JVM 内存主要可以分为线程共享区和线程私有区两大部分。

线程私有区域包括虚拟机栈、程序计数器。

程序计数器用于记录当前线程执行的字节码行号,是线程切换时恢复执行位置的依据。

虚拟机栈用于方法调用,每个方法执行都会创建一个栈帧,存储局部变量、操作数栈、方法出口等信息,随着方法的入栈和出栈自动释放。

线程共享区域主要包括堆和方法区。

堆是 JVM 中最大的一块内存区域,用于存放对象实例,是垃圾回收的主要区域。

方法区用于存放类信息、常量、静态变量以及即时编译后的代码,在 JDK8 之后方法区由元空间(Metaspace)实现,使用本地内存。

集合类常用特性

Java 集合主要分为 Collection 和 Map 两大体系

Collection 体系下包括 List、Set、Queue 等。

List 的特点是有序、可重复,常见实现有 ArrayList 和 LinkedList。

Set 的特点是无序且不可重复,常见实现有 HashSet、LinkedHashSet、TreeSet。

Queue 是队列结构,支持 FIFO,常见实现有 LinkedList、PriorityQueue。

Map 体系用于存储键值对,key 不可重复,常见实现有 HashMap、LinkedHashMap、TreeMap 和 ConcurrentHashMap。

在实际开发中,ArrayList 适用于查询多、插入删除少的场景,HashMap 适用于快速查找键值映射关系,而 LinkedList 更适合频繁插入删除的场景。

ArrayList和LinkedList区别

ArrayList 和 LinkedList 都是 List 接口的实现类,但底层结构不同。

ArrayList 底层基于动态数组实现,支持随机访问,查询效率为 O(1),但在中间插入或删除元素时需要移动元素,时间复杂度为 O(n)。它的扩容机制是在容量不足时会进行 1.5 倍扩容,并复制原数组到新数组。

LinkedList 底层基于双向链表实现,每个节点保存前驱和后继指针,不支持随机访问,查询需要遍历,时间复杂度为 O(n),但在插入和删除操作上只需要修改指针,时间复杂度为 O(1)。

因此在实际使用中,如果以查询为主,优先选择 ArrayList;如果以频繁插入和删除为主,选择 LinkedList。但在真实业务中,ArrayList 使用更广泛。

TCP三次握手、四次挥手过程,为什么不能两次握手

TCP 三次握手的目的是建立一个可靠的双向连接

具体过程如下:

第一次握手:客户端发送 SYN 报文给服务端,请求建立连接,此时客户端进入 SYN_SENT 状态。

第二次握手:服务端收到 SYN 后,会返回 SYN + ACK 报文,表示同意建立连接,并确认客户端的请求,此时服务端进入 SYN_RECEIVED 状态。

第三次握手:客户端收到服务端的 SYN + ACK 后,再发送 ACK 报文进行确认,双方都进入 ESTABLISHED 状态,连接建立完成。

三次握手的核心目的是确认双方的发送和接收能力都是正常的

第一次握手确认客户端的发送能力正常;

第二次握手确认服务端的发送和接收能力正常;

第三次握手确认客户端的接收能力正常。

通过三次交互,双方才能确保"发送没问题 + 接收没问题 + 双向通信没问题"。

TCP 四次挥手用于可靠关闭双向连接

第一次挥手:主动关闭方发送 FIN 报文,表示自己没有数据要发送了。

第二次挥手:被动关闭方收到 FIN 后返回 ACK,表示已收到关闭请求,但可能还有数据未发送完。

第三次挥手:被动关闭方发送 FIN,表示自己也数据发送完毕,可以关闭连接。

第四次挥手:主动关闭方返回 ACK,连接正式关闭。

因为 TCP 是全双工通信,两个方向的数据流需要分别关闭。

当一方关闭发送通道时,只表示自己不再发送数据,但仍可能接收数据,因此必须分别关闭两个方向,所以需要四次挥手。

TCP和UDP核心区别,各自适用场景

TCP 和 UDP 都是传输层协议,但设计目标完全不同。TCP 是面向连接的可靠传输协议 ,UDP 是无连接的不可靠传输协议

1️⃣ 是否面向连接

TCP 在通信前需要通过三次握手建立连接,通信结束后需要四次挥手断开连接,因此是面向连接的协议。

UDP 不需要建立连接,直接发送数据报,因此是无连接的。

2️⃣ 可靠性

TCP 提供可靠传输机制,包括确认应答、超时重传、流量控制和拥塞控制,保证数据不丢失、不重复、按序到达。

UDP 不保证可靠性,不提供重传机制,数据可能丢失、乱序或重复。

3️⃣ 传输效率

TCP 因为有连接建立、确认机制和复杂控制,开销较大,传输效率相对较低。

UDP 结构简单,没有额外控制机制,因此传输效率更高、延迟更低。

4️⃣ 是否保证顺序

TCP 保证数据按发送顺序到达。

UDP 不保证顺序,可能出现乱序情况。

5️⃣ 数据结构

TCP 是面向字节流的协议,没有明确报文边界。

UDP 是面向报文的协议,每个数据包独立存在。

TCP 适用于对可靠性要求高的场景,例如:

  • HTTP/HTTPS 网页请求
  • 数据库连接
  • 文件传输(FTP)
  • 消息队列通信
  • 邮件传输

这些场景不能接受数据丢失或错误,因此需要 TCP 保证可靠性。

UDP 适用于对实时性要求高、允许少量丢包的场景,例如:

  • 视频直播
  • 语音通话
  • 在线游戏
  • DNS 查询
  • 监控数据上报

这些场景更关注延迟,而不是绝对可靠性。

HTTP常见状态码,GET和POST请求差异

  1. 200 OK:请求成功
  2. 400 Bad Request:请求参数错误
  3. 401 Unauthorized:未认证或认证失败
  4. 404 Not Found:资源不存在
  5. 500 Internal Server Error:服务器内部错误

GET和POST的区别在于GET用于获取资源,参数拼接在URL中,具有幂等性且可缓存,但不适合传输敏感数据;POST用于提交数据,参数放在请求体中,通常不具备幂等性,不会被缓存,且更适合传输敏感或大量数据。

  • GET 的幂等性
    • 多次请求同一个 URL(不带副作用):
      • 第1次:获取数据
      • 第2次、第3次:仍然只是获取数据
    • 不会改变服务器状态,所以结果一致
  • PUT/DELETE(也是幂等)
    • PUT:多次更新同一个资源为同一个值,结果一样
    • DELETE:第一次删除后资源不存在,后续再删除也是同样结果(无变化)
  • POST(通常非幂等)
    • 多次提交可能导致:
      • 创建多个订单 / 多条记录
      • 产生不同结果(副作用累积)

简单说说Socket通信流程

服务端先创建 Socket,然后绑定端口(bind),接着监听连接(listen),进入阻塞等待(accept);当客户端创建 Socket 后,通过 connect 发起连接请求,完成 TCP 三次握手后建立连接;之后双方就可以通过 send/recv 进行数据传输;通信结束后,客户端和服务端依次关闭连接(close),完成四次挥手释放资源。

进程与线程区别,线程优势

进程是操作系统资源分配的基本单位,每个进程有独立的内存空间;线程是CPU调度的基本单位,属于进程内部的执行单元,同一进程内的多个线程共享进程的内存资源(如堆、方法区),但各自拥有独立的栈空间。

进程之间相互独立,通信需要通过IPC(如管道、Socket等),开销较大;线程之间可以直接读写共享内存,通信更高效,但需要处理同步与线程安全问题。

线程的优势在于:创建和切换开销更小、资源占用更少、数据共享方便,能够更高效地实现并发执行,提高程序的响应速度和吞吐量。

线程同步有哪些实现方式

首先是synchronized 关键字,这是 Java 内置的互斥锁机制,可以修饰方法或代码块,保证同一时刻只有一个线程执行临界区代码,实现简单但功能相对基础。

其次是ReentrantLock(显式锁),相比 synchronized 更灵活,支持公平锁、可中断、尝试加锁(tryLock)以及多个 Condition 条件队列,适用于复杂并发控制场景。

第三类是volatile 关键字,它不保证原子性,但保证可见性和禁止指令重排序,适用于状态标志类同步控制。

第四类是线程通信机制,如 wait/notify/notifyAll,通过对象监视器实现线程间协作。

了解的Linux常用操作命令

1. 文件与目录操作

  • ls:查看目录内容
  • cd:切换目录
  • pwd:查看当前路径
  • mkdir:创建目录
  • rm:删除文件/目录(-r递归,-f强制)
  • cp:复制文件/目录
  • mv:移动或重命名

2. 文件内容查看与编辑

  • cat:查看文件内容
  • more / less:分页查看
  • head / tail:查看前/后几行
  • vim / vi:文本编辑

3. 权限相关

  • chmod:修改权限
  • chown:修改文件所属用户
  • ls -l:查看权限信息

4. 进程与系统

  • ps:查看进程
  • top:动态监控进程
  • kill:终止进程
  • df -h:查看磁盘使用
  • free -h:查看内存

5. 网络相关

  • ping:测试连通性
  • netstat / ss:查看端口
  • curl / wget:请求或下载资源

6. 压缩与解压

  • tar -czvf:打包压缩
  • tar -xzvf:解压

7. 查找

  • find:按条件查找文件
  • grep:文本搜索

Linux常用命令主要包括文件操作、权限管理、进程管理、网络工具、文本处理和压缩解压等,用于完成日常开发和系统运维操作。

MySQL索引原理,B+树特点

MySQL 的索引本质是一种用于加速数据查询的数据结构 ,InnoDB 存储引擎中默认使用的是 B+ 树索引

在 B+ 树中,所有数据都存储在叶子节点,非叶子节点只存储索引键值,用于目录导航;叶子节点之间通过链表相连,形成有序链表结构,因此非常适合范围查询和排序操作。查询时从根节点开始逐层向下查找,时间复杂度为 O(log n)。

B+ 树的特点包括:第一,多路平衡树 ,层级较低,减少磁盘 IO 次数;第二,非叶子节点不存数据,只做索引,分支因子更大 ,树更"矮胖",查询更快;第三,叶子节点有序且通过链表连接 ,支持高效范围查询;第四,查询稳定性高,任何查询都需要走到叶子节点,性能更加可控。

MySQL索引类型

索引分为聚簇索引和非聚簇索引。

聚簇索引一般是主键索引,其叶子节点存储整行数据,数据本身按主键顺序组织,一张表只有一个聚簇索引;非聚簇索引也叫辅助索引或二级索引,其叶子节点存储的是主键值而不是完整数据,因此通过二级索引查询到主键后,还需要再通过聚簇索引查询整行数据,这个过程称为"回表"。联合索引属于非聚簇索引的一种,遵循最左前缀原则。总体来说,聚簇索引查询效率更高但维护成本较大,非聚簇索引灵活性更强但在查询时可能涉及回表操作。

事务四大特性,基础隔离级别

A(原子性):事务中的操作要么全部成功,要么全部失败,不会出现部分执行的情况。

C(一致性):事务执行前后,数据库必须从一个一致状态转换到另一个一致状态。

I(隔离性):多个事务并发执行时,相互之间不会互相干扰。

D(持久性):事务一旦提交,数据的修改就是永久保存的,即使系统崩溃也不会丢失。

MySQL 的基础隔离级别有四种,从低到高分别是:

  1. 读未提交(Read Uncommitted):可以读到其他事务未提交的数据,存在脏读问题。
  2. 读已提交(Read Committed):只能读到已提交的数据,避免脏读,但可能出现不可重复读。
  3. 可重复读(Repeatable Read):同一事务内多次读取结果一致,避免不可重复读,在 MySQL InnoDB 中默认级别,还能通过间隙锁一定程度避免幻读。
  4. 串行化(Serializable):最高隔离级别,所有事务串行执行,完全避免并发问题,但性能最低。

日常写SQL如何做简单优化

首先尽量避免 SELECT *,只查询必要字段,减少 IO 开销;其次合理使用索引,尤其是 where、join、order by、group by 涉及的字段,优先命中索引,避免全表扫描。对于联合索引,要遵循最左前缀原则,并尽量让查询条件走覆盖索引,减少回表。

同时要避免索引失效,比如在索引列上使用函数、隐式类型转换、like 以 % 开头等情况。分页查询时尽量避免深度 offset,可以用"延迟关联"或基于主键的游标分页优化。

在 join 操作中,小表驱动大表,并确保 join 字段有索引;对于 order by 和 group by,尽量利用索引排序,避免 filesort 和临时表。

最后可以通过 EXPLAIN 分析执行计划,重点关注是否走索引、扫描行数(rows)、是否回表等指标来持续优化 SQL。

开发功能自测没问题,联调出现报错如何处理

联调出现报错时,首先要做的是快速定位问题边界,确认是前端、后端还是接口契约问题。先看报错信息和日志,复现问题,明确是请求参数不对、接口返回异常,还是环境或依赖问题。

如果是接口问题,优先对照接口文档,检查字段、类型、必填项是否一致,重点排查是否存在参数缺失、命名不一致或数据格式错误。如果是后端问题,则通过日志和断点定位具体异常位置,检查是否为空指针、逻辑分支遗漏或数据库查询异常。

如果是环境或依赖问题,需要确认配置是否一致,比如测试环境配置、缓存、权限、数据库数据是否完整。

定位问题后要及时分级处理:能快速修复的直接修复并重新联调;涉及多模块或接口变更的,需要同步前后端并调整接口契约,避免重复问题。

最后修复后要做回归验证,确保不仅修复当前问题,还不会引入新的联调风险。

一句话总结就是:先定位责任边界,再根据日志和接口逐层排查,最后修复并回归验证。

Spring Cloud 5大组件有哪些?

服务注册中心、客户端负载均衡器、声明式的服务调用、服务熔断器、API网关。

服务注册与发现主要包含三个核心功能:服务注册、服务发现和服务状态监控。

使用Spring Cloud的Ribbon组件来实现客户端负载均衡。

  • RoundRobinRule:简单的轮询策略。
  • WeightedResponseTimeRule:根据响应时间加权选择服务器。
  • RandomRule:随机选择服务器。
  • ZoneAvoidanceRule:区域感知的负载均衡,优先选择同一区域中可用的服务器。(默认)

什么是服务雪崩,怎么解决这个问题?

服务雪崩是指一个服务的失败导致整个链路的服务相继失败。

我们通常通过服务降级和服务熔断来解决这个问题:

  • 服务降级:在请求量突增时,主动降低服务的级别,确保核心服务可用。
  • 服务熔断:当服务调用失败率达到一定阈值时,熔断机制会启动,防止系统过载。

微服务是怎么监控的?

我们项目中采用了SkyWalking进行微服务监控:

  1. SkyWalking能够监控接口、服务和物理实例的状态,帮助我们识别和优化慢服务。
  2. 我们还设置了告警规则,一旦检测到异常,系统会通过短信或邮件通知相关负责人。

你们项目中有没有做过限流?

  • 版本1 :使用Nginx进行限流,通过漏桶算法控制请求处理速率,按照IP进行限流。
  • 版本2 :使用Spring Cloud GatewayRequestRateLimiter过滤器进行限流,采用令牌桶算法,可以基于IP或路径进行限流。

什么是CAP理论?

CAP理论是分布式系统设计的基础理论,包含一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)。在网络分区发生时,系统只能在一致性和可用性之间选择其一。

为什么分布式系统中无法同时保证一致性和可用性?

在分布式系统中,为了保证分区容错性,我们通常需要在一致性和可用性之间做出选择。如果系统优先保证一致性,可能需要牺牲可用性,反之亦然。

BASE 理论?

  • B asically Available(基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
  • Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态。
  • Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。

BASE理论是分布式系统设计中对CAP理论中AP方案的延伸,强调通过基本可用、软状态和最终一致性来实现系统设计。

分布式服务的接口幂等性如何设计?

幂等:多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致。

需要幂等场景:

  • 用户重复点击 (网络波动)
  • MQ 消息重复
  • 应用使用失败或超时重试机制

我们通过Token和Redis来实现接口幂等性。用户操作时,系统生成一个Token并存储在Redis中,当用户提交操作时,系统会验证Token的存在性,并在验证通过后删除Token,确保每个Token只被处理一次。

RabbitMQ如何保证消息不丢失?(异步、解耦、削峰)

  1. 开启生产者确认机制,确保消息能被送达队列,如有错误则记录日志并修复数据。
  2. 启用持久化功能,保证消息在未消费前不会在队列中丢失,需要对交换机、队列和消息本身都进行持久化。
  3. 对消费者开启自动确认机制,并设置重试次数。例如,我们设置了3次重试,若失败则将消息发送至异常交换机,由人工处理。

RabbitMQ消息的重复消费问题如何解决?

我们遇到过消息重复消费的问题,处理方法是:

设置消费者为自动确认模式,如果服务在确认前宕机,重启后可能会再次消费同一消息。通过业务唯一标识检查数据库中数据是否存在,若不存在则处理消息,若存在则忽略,避免重复消费。

死信交换机(DLX)

当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter)

  • 消费者使用basic.rejectbasic.nack声明消费失败,并且消息的requeue参数设置为false。
  • 消息是一个过期消息,超时无人消费。
  • 要投递的队列消息堆积满了,最早的消息可能成为死信。

本质是给 "处理失败 / 无法处理的消息" 提供一个兜底路径,避免消息丢失,常见用途包括:

  1. 重试补偿:消费失败的消息转入死信队列,后续可通过人工 / 定时任务处理。
  2. 延迟队列实现:利用消息超时进入死信队列的特性,实现延迟任务(如订单超时取消)。
  3. 消息死信监控:统一收集死信消息,用于问题排查和告警。

延迟队列 = 死信交换机 + TTL

  • **TTL(Time To Live,消息过期时间)**给消息 / 队列设置一个过期时间,消息发送到队列后不会被立刻消费,而是等待指定时长。
  • 死信交换机(DLX)兜底当消息在队列中过期(TTL 到期),如果没有被消费,就会被自动投递到预先配置好的死信交换机。
  • 死信队列做目标队列死信交换机会把这些过期消息路由到对应的死信队列,消费者直接监听这个死信队列,就能收到 "延迟指定时间后" 的消息,从而实现延迟任务。

举个例子-订单超时取消:

  1. 下单后,发送一条带 TTL=30分钟 的消息到延迟队列 A。
  2. 消息在队列 A 中等待 30 分钟,期间如果用户完成支付,就主动删除这条消息。
  3. 若超时未支付,消息过期,被路由到死信队列 B。
  4. 消费者监听队列 B,收到消息后执行 "关闭订单、恢复库存" 的逻辑。

如果有100万消息堆积在MQ,如何解决?

  1. 提高消费者消费能力,如使用多线程。
  2. 增加消费者数量,采用工作队列模式,让多个消费者并行消费同一队列。
  3. 扩大队列容量,使用RabbitMQ的惰性队列,支持数百万条消息存储,直接存盘而非内存。

Collection

复制代码
Collection(单列)
├─ List
│   ├─ ArrayList
│   └─ LinkedList
├─ Set
│   ├─ HashSet
│   └─ TreeSet
└─ Queue
    ├─ LinkedList
    └─ ArrayDeque

Map(双列)
├─ HashMap
├─ TreeMap
└─ ConcurrentHashMap

ArrayList

构造方法

1. 有参构造(指定初始容量)

传入容量 > 0:直接创建对应长度数组;

传入容量 = 0:赋值空数组。

2. 无参构造

直接赋值空数组,初始容量为 0,懒加载。

3. Collection 集合构造

把传入集合转数组,赋值给 elementData

如果传入集合为空,赋值 EMPTY_ELEMENTDATA

扩容规则

  1. 无参构造空数组,第一次添加元素 :直接初始化容量为 10
  2. 后续每次扩容:原容量的 1.5 倍
  3. 底层通过 Arrays.copyOf() 复制原数组元素到新大容量数组。

如何实现数组和List之间的转换

  • 数组转List ,使用JDK中java.util.Arrays工具类的asList方法
  • List转数组,使用List的toArray方法。无参toArray方法返回 Object数组,传入初始化长度的数组对象,返回该对象数组

Q:用Arrays.asList转List后,如果修改了数组内容,list受影响吗?

A:Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址。

Q:List用toArray转数组后,如果修改了List内容,数组受影响吗?

A:list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响。

ArrayList和LinkedList的区别是什么?

  1. 底层数据结构
  • ArrayList 是动态数组的数据结构实现。
  • LinkedList 是双向链表的数据结构实现。
  1. 操作数据效率
  • ArrayList按照下标查询的时间复杂度O(1)【内存是连续的,根据寻址公式】, LinkedList不支持下标查询。
  • 查找(未知索引): ArrayList需要遍历,链表也需要遍历,时间复杂度都是O(n)。
  • 新增和删除:
    ArrayList尾部插入和删除,时间复杂度是O(1);其他部分增删需要挪动数组,时间复杂度是O(n)。
    LinkedList头尾节点增删时间复杂度是O(1),其他都需要遍历链表,时间复杂度是O(n)。
  1. 内存空间占用
  • ArrayList底层是数组,内存连续,节省内存。
  • LinkedList 是双向链表需要存储数据,和两个指针,更占用内存。
  1. 线程安全

ArrayList和LinkedList都不是线程安全的,如果需要保证线程安全,有两种方案:

  • 在方法内使用,局部变量则是线程安全的。
  • 使用线程安全的ArrayList和LinkedList。

HashMap实现原理

HashMap的数据结构: 底层使用数组和链表或红黑树。

  1. 当我们往HashMap中put元素时,利用key的hashCode进行二次哈希运算,计算出当前对象的元素在数组中的下标。

  2. 存储时,先看对应下标位置:

a. 如果该下标位置为空,直接存入新节点;

b. 如果下标位置已有元素(出现冲突),说明发生哈希冲突,遍历当前桶的链表或红黑树并逐个对比 key:①key 完全相同,直接覆盖原有 value;②如果key不同,则将当前的key-value放入链表或红黑树中。

  1. 获取元素时,先通过 key 的哈希值定位到数组对应下标,再在该桶内逐个比对 key,key 匹配成功后返回对应 value。

HashMap 常见属性

  1. 默认容量 16(容量永远是 2 的幂次,方便用 (n-1) & hash 快速计算数组下标,定位快)
  2. 默认加载因子 0.75(控制什么时候扩容的系数)
  3. 链表转红黑树阈值 8(链表长度达到 8 个节点,并且数组长度≥64,就会链表转红黑树)
  4. 扩容阈值 = 容量 × 加载因子(扩容阈值 = 16 × 0.75 = 12,当 HashMap 里元素个数达到 12 个,就会自动扩容,数组长度翻倍变成 32,重新散列迁移元素。)

HashMap 是懒加载:new HashMap () 时不初始化数组,第一次 put 才初始化。

put 流程

  1. 判断数组是否为 null 或空,为空则调用 resize() 初始化数组
  2. 根据 key 的 hash 计算数组下标
  3. 如果下标位置为空,直接新建节点放入。
  4. 如果下标位置不为空 (发生冲突):
    判断首节点 key 是否相同,相同则直接覆盖 value
    判断是否是红黑树 ,是则在树中插入或更新。
    如果是链表,遍历到尾部插入,若链表长度 ≥8,且底层数组容量≥64,转为红黑树(数组容量不足 64 则只扩容、不树化);遍历中发现 key 相同则覆盖。
  5. 插入成功后,判断 size 是否超过扩容阈值 ,超过则扩容

HashMap 的扩容机制

  1. 在添加元素或初始化(HashMap 是懒加载)的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75);

  2. 每次扩容的时候,都是扩容之前容量的2倍;

  3. 扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中:

  • 没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置。
  • 如果是链表,则需要遍历链表,利用(e.hash & oldCap)是否为0拆分高低位链表:
    结果为 0:留在原下标 位置;
    结果不为 0:搬到 原下标 + 旧容量 位置。
  • 如果是红黑树,按和链表一样的规则拆分高低两组,分别放到原下标、原下标 + 旧容量位置;拆分后节点数≤6退化成链表。

HashMap 寻址算法

第一步:扰动函数 hash (key)。

先拿到 key 的 hashCode,再做高 16 位与低 16 位异或(右移 16 位 ^ 原 hashCode)。

作用:把高位特征打散到低位,让 hash 值分布更均匀,减少哈希冲突。

第二步:计算数组下标。

公式:(n - 1) & hash,其中 n 是底层数组长度,用位与运算代替普通取模 hash % n,位运算性能远高于取模。

HashTable vs HashMap

第一,数据结构不一样,hashtable是数组+链表,hashmap在1.8之后改为了数组+链表+红黑树。

第二,hashtable存储数据的时候都不能为null,而hashmap是可以的。

第三,hash算法不同,hashtable是用本地修饰的hashcode值,而hashmap二次hash。

第四,扩容方式不同,hashtable是当前容量翻倍+1,hashmap是当前容量翻倍。

第五,hashtable是线程安全的,操作数据的时候加了锁synchronized,hashmap不是线程安全的,效率更高一些。

在实际开中不建议使用HashTable,在多线程环境下可以使用ConcurrentHashMap类。

多线程

线程

创建线程的四种方式

继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程。通常情况下,我们项目中都会采用线程池的方式创建线程。

runnable 和 callable 有什么区别?

  • 有无返回值: Runnable无返回值 。Callable有返回值,泛型返回结果。
  • 是否抛异常: Runnable 的 run 方法不能抛出受检异常 ,只能内部 try-catch。Callable 的 call 方法可以直接抛出异常
  • 适用场景: 不需要返回结果、简单任务 → 用 Runnable ,需要拿到执行结果、捕获任务异常 → 用 Callable

线程的 run() vs start()

start(): 用来启动线程,该线程会调用run方法,执行run方法中所定义的逻辑代码。start方法只能被调用一次。

run(): 封装了要被线程执行的代码,就是一个普通方法,可以被调用多次。

线程包括哪些状态,状态之间是如何变化的

为什么 JVM 把「就绪、运行」合并叫 可运行状态 (Runnable)

因为就绪和运行状态由操作系统 CPU 时间片频繁切换 ,切换速度极快,JVM 没必要、也没法实时精准区分;对 Java 开发者来说,区分这两个状态没有实际业务意义,所以 JVM 把就绪、运行统一合并为可运行 (Runnable) 状态

WAITING 和 TIMED_WAITING 都是线程的等待状态,不占用 CPU 时间,核心区别是是否有超时限制

  • WAITING 是无时限等待,由 wait()、join() 等方法触发,线程会一直等待,直到被其他线程主动唤醒(notify/notifyAll),才会回到可运行状态。
  • TIMED_WAITING 是有时限等待,由 sleep(ms)、wait(ms) 等方法触发,线程等待指定时间后会自动唤醒,也可以被其他线程提前唤醒。其中 wait(ms) 会释放锁,sleep(ms) 不会释放锁,这也是两者在锁行为上的关键差异。

notifyAll:唤醒所有wait的线程。notify:只随机唤醒一个 wait 线程。

新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?

可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。

比如说使用join方法,T3调用T2,T2调用T1,这样就能确保T1就会先完成而T3最后完成

wait vs sleep

共同点:wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态。

不同点:

  1. 方法归属不同:
    sleep(long) 是 Thread 的静态方法。
    wait(),wait(long) 都是 Object 的成员方法,每个对象都有。
  2. 醒来时机不同:
    执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来。
    wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去。
    它们都可以被打断唤醒。
  3. 锁特性不同(重点):
    wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制。
    wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)。
    sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)。

如何停止一个正在运行的线程?

  1. 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止。

在线程内部定义一个 volatile 修饰的布尔变量作为退出标志,循环里判断这个标志,一旦为 true 就退出 run() 方法,线程正常结束。

优点:安全、优雅,让线程执行完收尾工作。

  1. 使用interrupt方法中断线程。

调用线程的 interrupt() 方法设置中断标志位。

  • 如果线程正在 sleep() / wait(),会立刻抛出 InterruptedException,从而唤醒并退出;
  • 如果线程正在正常运行,通过判断 isInterrupted() 来响应中断并停止。interrupt() 只是设置中断标记、发一个信号,不会直接杀死线程,由线程自己检测标志、自主决定退出,属于优雅的协商式中断。

优点:能打断阻塞状态,强制唤醒线程。

线程中并发锁

讲一下synchronized关键字的底层原理?

synchronized 底层使用的JVM级别中的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized 属于悲观锁。

synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低。

monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因。

Monitor内部内部维护了三个变量:

  • Owner:存储当前获取锁的线程的,只能有一个线程可以获取。
  • EntryList:关联没有抢到锁的线程,处于Blocked状态的线程。
  • WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程。

只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner

在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。

JMM

JMM 也就是 Java 内存模型,是 JVM 规范里定义的一套内存访问规则,主要用来约束多线程共享变量的读写行为。

JMM 分为主内存和线程工作内存:所有共享的实例变量、静态变量都存放在主内存 ;每个线程有自己独立的工作内存,会缓存共享变量的副本。

线程对变量的读写,都只能在自己工作内存里操作,不能直接操作主内存;线程之间也不能互相访问对方的工作内存,要想传递变量值,必须通过主内存来同步。

JMM 主要解决多线程三大问题:可见性、原子性、有序性;靠 volatile、synchronized、锁、final 等指令规则来保障。

  • 可见性:指一个线程修改共享变量后,其他线程能立马看到最新值。因为 JMM 有主内存和线程工作内存,线程会缓存变量副本,不及时刷新就会有可见性问题;可以用 volatile、synchronized 保证可见性。
  • 原子性:指一组操作不可被拆分、不可被线程插队,要么全部执行完,要么不执行。像 i++ 这种复合操作本身不具备原子性,需要用 synchronized 或者 Lock 锁来保证,volatile 是保证不了原子性的。
  • 有序性:防止编译器和 CPU 为了优化性能做指令重排序,打乱多线程执行逻辑。可以用 volatile 加内存屏障禁止重排序,也可以用 synchronized、final 来保障有序性。

CAS

Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。

AbstractQueuedSynchronizer(AQS框架)、AtomicXXX类都用到了CAS。

一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当旧的预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。如果CAS操作失败,通过自旋的方式等待并再次尝试,直到成功。

需要不断尝试获取共享内存V中最新的值,然后再在新的值的基础上进行更新操作,如果失败就继续尝试获取新的值,直到更新成功。

乐观锁和悲观锁

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。

  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

volatile

volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能

第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。

第二: 禁止进行指令重排序,可以保证代码执行有序性 。底层实现原理是,添加了一个内存屏障 ,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。

Spring 设计模式

工厂模式

Spring 用工厂模式解决:不自己 new 对象 → 交给容器创建。

Spring 中使用工厂模式实现 IoC 容器,主要通过 BeanFactory 和 ApplicationContext 来管理 Bean 对象的创建。

  • BeanFactory 是基础容器,采用懒加载机制,在获取 Bean 时才会创建对象,内存占用较小,但功能较弱。
  • ApplicationContext 是 BeanFactory 的增强版本,采用预加载机制,在容器启动时就会创建所有 Bean,并且提供国际化、事件机制和 AOP 支持,是企业开发中的主流容器。

👉 好处:

  • 解耦(不依赖具体实现)
  • 统一管理对象生命周期

单例模式

单例模式 = 一个类在系统中只有一个实例,Bean 默认是单例(singleton)

Spring 通过一个基于 ConcurrentHashMap 的单例池来实现单例模式,在获取 Bean 时先从缓存中查找,如果不存在则创建并放入缓存中,从而保证全局唯一。

**单例 Bean 存在线程安全问题吗?**单例 Bean 存在线程问题,主要是因为当多个线程操作同一个对象的时候是存在资源竞争的。一般通过设计无状态 Bean 或使用 ThreadLocal 来解决。

代理模式

代理模式在 AOP 中的应用:AOP(Aspect-Oriented Programming,面向切面编程) 能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。

目标对象 提供一个代理对象,由代理对象控制对原对象的访问,在不修改原有业务代码的基础上,对方法进行增强(前置、后置、异常、环绕操作)。

两种动态代理(Spring AOP 底层):JDK 动态代理(基于接口,运行时生成接口的实现类作为代理)、CGLIB 动态代理(基于继承,运行时生成目标类的子类作为代理)。

数据库

非关系型数据库NoSQL(为互联网而生)

互联网带来了海量数据、高并发、数据结构多变,NoSQL核心思想就是用一致性换性能,用结构换灵活性。去事务或弱事务、分布式存储、最终一致性(BASE)。适用于缓存、大数据、日志等场景。

实际开发中怎么用? 通常是混合使用。MySQL → 核心业务数据,Redis → 缓存

扩展方式:

  • 关系型数据库:垂直(使用性能更强大的服务器进行扩展)、读写分离、分库分表
  • 非关系型数据库:横向(增加服务器的方式横向扩展,通常是基于分片机制)

Q:为什么会出现 NoSQL?

A:因为关系型数据库在海量数据和高并发场景下扩展性不足,NoSQL通过牺牲部分事务特性换取高性能和水平扩展能力。

ACID 是数据库事务的四大特性:

A:Atomicity 原子性:事务是最小单位,不可分割。

C:Consistency 一致性:执行前后,数据库的完整性约束不变。

I:Isolation 隔离性:多个事务同时跑时,互相不干扰。

D:Durability 持久性:事务一旦提交成功,数据就永久保存,断电、重启也不会丢。

ER图

Entity Relationship Diagram(实体联系图),提供了表示实体类型、属性和联系的方法。

数据库范式

👉 一套"设计规范",用于减少数据冗余、避免数据异常,范式 = 让表结构更合理的规则。

1NF 管字段,2NF 管部分依赖,3NF 管传递依赖。

主键 ID

  • 数据库自增 ID

    缺点:分库分表环境下,无法保证全局唯一,不适合分布式场景。

  • UUID

    自动生成一串全球唯一 的字符串。

    缺点:无序字符串类型,索引效率低、查询慢,占用存储空间大。

  • 雪花算法(Snowflake)
    时间戳 + 机器 ID + 序列号

    解决:生成全局唯一、趋势递增的长整型 ID,索引友好、高性能,适配高并发与分库分表分布式场景。

为什么很多公司不用外键?

因为外键会增加数据库操作的性能开销(每次做 INSERT、DELETE 或者 UPDATE 都必须考虑外键约束,需要额外检查,增加数据库负担),并且在分库分表场景下难以维护(分库分表下外键无法生效),且外键使表之间高度耦合,不利于后续系统扩展和维护。因此通常在应用层保证数据一致性。

但是但是但是,外键也是有很多好处的:

①保证了数据库数据的一致性和完整性。②级联操作方便,减轻了程序代码量。

不要一股脑的就抛弃了外键这个概念,既然它存在就有它存在的道理,如果系统不涉及分库分表,并发量不是很高的情况还是可以考虑使用外键的。

DROP vs DELETE vs TRUNCATE

对应命令 操作
DROP 直接删除整个表(连结构一起删),属于DDL操作。
TRUNCATE 删除所有数据,但保留表结构,属于DDL操作。
DELETE 逐行删除数据,可以带条件WHERE、可以回滚(事务中),属于DML操作。

Q:TRUNCATE 为什么不能回滚?

A:因为 TRUNCATE 属于DDL操作,会直接释放数据页,不记录逐行undo日志,因此无法回滚。

Q:DELETE 为什么比 TRUNCATE 慢?

A:DELETE 是逐行删除,并且需要记录日志和维护索引,而 TRUNCATE 是直接释放数据页。

GROUP BY 分组

group by ... having ...:

  • having 一般都是和 group by 连用。
  • having 用于对汇总的 group by 结果进行过滤
  • where 和 having 可以在相同的查询中。
  • 通常涉及聚合函数:COUNT() 统计数量、SUM() 求和、AVG() 平均值、MAX() 最大值、MIN() 最小值。
sql 复制代码
SELECT c.name, COUNT(o.id) AS NumberOfOrders
FROM Customers c
JOIN Orders o ON c.id = o.cust_id
GROUP BY c.name
HAVING COUNT(o.id) > 1;

Q:COUNT 是什么时候执行的?

A:在 GROUP BY 之后,对每个分组单独计算。

Q:WHERE为什么不能用聚合函数?(COUNT 为例)

A:WHERE 在 GROUP BY 前执行,COUNT 在 GROUP BY 后才产生。

执行顺序:FROM(先找数据) → WHERE(过滤行) → GROUP BY(分组) → HAVING(过滤组) → SELECT(选字段 + 计算) → ORDER BY(排序) → LIMIT(截取)

数据库设计

需求 → ER图 (实体、属性、联系)

表设计 (实体-表,属性-字段,联系-外键,三大范式)

性能设计 (数据库、redis、索引)

实施 (建库建表)

运维(性能优化、监控、备份恢复和系统维护)

分库分表

分库 就是将数据库中的数据分散到不同的数据库上,可以垂直分库,也可以水平分库。

垂直分库 就是把单一数据库按照业务进行划分,不同的业务使用不同的数据库,进而将一个数据库的压力分担到多个数据库。eg:将数据库中的用户表、订单表和商品表分别单独拆分为用户数据库、订单数据库和商品数据库。

水平分库 是把同一个表按一定规则拆分到不同的数据库中,每个库可以位于不同的服务器上,这样就实现了水平扩展,解决了单表的存储和性能瓶颈的问题。eg:订单表数据量太大,你对订单表进行了水平切分(水平分表),然后将切分后的 2 张订单表分别放在两个不同的数据库。

分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。

垂直分表 是对数据表列的拆分,把一张列比较多的表拆分为多张表。eg:我们可以将用户信息表中的一些列单独抽出来作为一个表。

水平分表 是对数据表行的拆分,把一张行比较多的表拆分为多张表,可以解决单一表数据量过大的问题。eg:我们可以将用户信息表拆分成多个用户信息表,这样就可以避免单一表数据量过大对性能造成影响。

水平拆分只能解决单表数据量大的问题,为了提升性能,我们通常会选择将拆分后的多张表放在不同的数据库中。也就是说,水平分表通常和水平分库同时出现。

遇到下面几种场景可以考虑分库分表:

  • 单表的数据量达到千万级别以上(具体阈值取决于表结构复杂度、索引数量、硬件配置等),数据库读写速度明显下降。
  • 数据库中的数据占用的空间越来越大,备份时间越来越长。

不过,分库分表的成本太高,如非必要尽量不要采用。

ZSET

Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+ 树?

ZSet(有序集合)底层:skiplist(跳表)+ hashtable。

用跳表保证有序 + 范围查找。用哈希表保证 O (1) 查 member 分值。

跳表(Skip List)本质:多层索引的有序链表。普通链表(慢) + 多级索引(加速)。

平衡树 vs 跳表:平衡树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n)。对于范围查询来说,平衡树也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。跳表诞生的初衷就是为了克服平衡树的一些缺点。跳表使用概率平衡(随机决定节点升几层)而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。跳表的平衡是指:多层节点分布均匀,查找效率不会退化。

红黑树 vs 跳表:相比较于红黑树来说,跳表的实现也更简单一些,不需要通过旋转和染色(红黑变换)来保证黑平衡。并且,按照区间来查找数据这个操作,红黑树的效率没有跳表高。

B+ 树 vs 跳表:B+ 树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+ 树这种方式进行维护,只需按照概率进行随机维护即可,节约内存。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+ 树那样插入时发现失衡时还需要对节点分裂与合并。

手撕 LRU

LRU = Least Recently Used(最近最少使用)

总体数据结构:

  • 哈希表(HashMap):O (1) 快速查找节点。
  • 双向链表 :O (1) 快速删除任意节点 + 移动节点到头部
    • 头部:最近使用
    • 尾部:最久未使用(满了直接删尾部)

1. 节点结构

存 key + value

双向指针:prev / next

2. 虚拟头尾节点

作用:不用判断空指针

真正的头节点 = head.next

真正的尾节点 = tail.prev

3. get 逻辑

查哈希表:

  • 不存在返回 -1
  • 存在就移到头部(标记最近使用),返回值

4. put 逻辑

  • 存在:更新值 + 移到头部
  • 不存在:新建节点 + 加入头部
  • 超容量:删除尾节点 + 哈希表同步删除

5. 链表 4 个工具方法

  • addToHead:添加到最前面
  • removeNode:删除任意节点
  • moveToHead:先删再加(等价移动)
  • removeTail:删除最后面(淘汰)
java 复制代码
import java.util.HashMap;

// LRU 缓存实现
public class LRUCache {
    // 双向链表节点类
    static class Node {
        int key;
        int value;
        Node prev;  // 前驱节点
        Node next;  // 后继节点

        public Node() {}
        public Node(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }

    // 哈希表:key -> Node,O(1) 查找节点
    private HashMap<Integer, Node> cache;
    // 虚拟头节点、虚拟尾节点(简化边界判断,不用判空)
    private Node head, tail;
    // 缓存最大容量
    private int capacity;
    // 当前缓存大小
    private int size;

    // 构造方法:初始化容量、链表、哈希表
    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.size = 0;
        cache = new HashMap<>();

        // 创建虚拟头尾节点,互相指向
        head = new Node();
        tail = new Node();
        head.next = tail;
        tail.prev = head;
    }

    // ==================== 核心 API ====================
    // 获取 key 对应的值
    public int get(int key) {
        Node node = cache.get(key);
        // 不存在返回 -1
        if (node == null) return -1;

        // 存在:把节点移到头部(标记为最近使用)
        moveToHead(node);
        return node.value;
    }

    // 存入 key-value
    public void put(int key, int value) {
        Node node = cache.get(key);

        if (node != null) {
            // 1. key 已存在:更新值 + 移到头部
            node.value = value;
            moveToHead(node);
        } else {
            // 2. key 不存在:新建节点
            Node newNode = new Node(key, value);
            cache.put(key, newNode);
            addToHead(newNode);
            size++;

            // 3. 超容量:删除尾节点(最久未使用)
            if (size > capacity) {
                Node removeNode = removeTail();
                cache.remove(removeNode.key);
                size--;
            }
        }
    }

    // ==================== 链表操作工具方法 ====================
    // 添加节点到头部
    private void addToHead(Node node) {
        node.prev = head;
        node.next = head.next;

        head.next.prev = node;
        head.next = node;
    }

    // 删除任意节点
    private void removeNode(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    // 把节点移到头部 = 删除 + 重新添加到头部
    private void moveToHead(Node node) {
        removeNode(node);
        addToHead(node);
    }

    // 删除尾节点(真正淘汰的数据)
    private Node removeTail() {
        Node res = tail.prev;
        removeNode(res);
        return res;
    }
}
相关推荐
逍遥德6 小时前
Java编程高频的“技术点”-01:自定义全局异常处理器
java·开发语言·spring boot·后端
YsyaaabB6 小时前
ACM 模式通用代码模板
java·c++·python·算法
IT界的老黄牛6 小时前
从 MQ 积压追到事件总线:诊断 4K 线程吃光 7G 内存的实战
java·运维·rocketmq
小旭95276 小时前
商品详情实现与缓存问题(穿透、击穿、雪崩)解决方案
java·数据库·spring boot·后端·缓存
苦逼的猿宝7 小时前
基于springboot的课程作业管理系统(源码+论文)
java·毕业设计·springboot·计算机毕业设计
我本楚狂人www7 小时前
Spring 两大核心思想(一):IoC
java·数据库·spring
迷渡7 小时前
用 Rust 重写的 Bun 有 13365 个 unsafe!
开发语言·后端·rust
九皇叔叔7 小时前
高斯性能分析【第一天】单表执行计划分析
java·数据库·性能分析·执行计划·gauss
苦逼的猿宝7 小时前
基于springboot的社区团购系统设计(源码+论文)
java·毕业设计·springboot·计算机毕业设计