面试面经优化回答

面试面经优化回答
mengnankkzhouSpring框架
1.SpringBoot的配置加载优先级
首先我们先确定一下配置加载优先级是按照我以下的顺序,由高到低的。分别是:
先是命令行参数(
--server.port=9000或java -jar app.jar --spring.config.location=...)然后是我们的系统的环境变量和JVM系统属性,比如设置端口为8080,比如我们在这里设置API的KEY
然后
RandomValuePropertySource(random.*占位符,用于生成随机数/字符串,可在配置中引用)接着是外部配置文件(properties / yml)
JAR 包外部的
./config/JAR 包外部的
./JAR 包内部的
classpath:/config/JAR 包内部的
classpath:/
接着是我们@PropertySource注解指定的配置
最后是我们Springboot默认的配置
然后在配置文件中,properties的配置大于yml,因为springboot是按加载顺序来的,后加载的properties把yml的值给覆盖了
对于外部配置文件,查找路径的优先级为:
./config/(当前目录下的config目录)./(当前目录)classpath:/config/classpath:/
实际应用:
基础配置:放在 classpath:/application.yml
环境特定配置:使用 application-{profile}.yml(如 application-prod.yml),通过 --spring.profiles.active=prod 激活
敏感信息:放在环境变量或外部化配置文件(避免入库)
临时调试/测试:使用命令行参数临时覆盖
多环境冲突处理:利用 profile 合并特性,公共配置放在 application.yml,环境差异放在对应 profile 文件
2.Springboot是如何解决跨域问题的?
基本都是基于CORS(跨域资源共享)通过设置响应头(如 Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers)告诉浏览器允许访问。
对于复杂跨域请求(非 GET/POST/HEAD 或自定义头),浏览器会先发 OPTIONS 预检请求。
- 局部注解,用@CrossOrigin标记单个接口,秒开跨域权限,适合快速测试。简单高效,优先级高于全局配置
- 全局配置,使用WebMvcConfigurer接口,统一设定允许的域名,请求方法,头信息。统一配置,但是不适合动态的控制
- 用CorsFilter手动处理跨域逻辑处理,适合需要动态校验权限等特殊场景,比如不同权限开放不同接口,在过滤器中动态判断,但是实现成本较高
- 在微服务架构中,也可以在网关层(如 Spring Cloud Gateway、Nginx)统一处理跨域,减少业务服务配置。
优先级:
1 | @CrossOrigin` > `WebMvcConfigurer` > `CorsFilter |
3.Spring 解决循环依赖
既然Spring能解决循环依赖,那为什么我们还经常听说‘构造器注入无法解决循环依赖’?三级缓存对构造器注入为什么无效?
您问到了Spring循环依赖解决方案的一个核心前提。三级缓存之所以能工作,其根本在于它将Bean的实例化(Instantiation)\和*属性填充(Population)*这两个阶段**分离开来了。
- 第一步:实例化。Spring首先通过无参构造函数创建了Bean A的一个“空壳”实例。这个实例已经有了自己的内存地址。
- 第二步:暴露早期引用。紧接着,Spring立即将这个“空壳”实例的工厂(ObjectFactory)放入三级缓存,从而提前暴露了A的引用。
第三步:属性填充。然后Spring才开始尝试为A注入属性,此时发现需要B,就去创建B。当B需要A时,可以从三级缓存中获取到A的早期引用,从而打破循环。
构造器注入的工作流程:
对于构造器注入,Bean的实例化和属性填充这两个阶段是合并在一起的,是原子性的。
- 当Spring尝试创建Bean A时,它必须调用A的构造函数。而A的构造函数需要一个Bean B的实例作为参数。
- 为了满足这个参数,Spring必须先去创建Bean B。
- 而当Spring尝试创建Bean B时,又发现B的构造函数需要一个Bean A的实例作为参数。
- 此时,Bean A的实例根本还没有被创建出来(它还卡在等待B的阶段),内存中不存在任何A的“空壳”实例,三级缓存中自然也就不可能有任何关于A的引用。
- 这就形成了一个无法解开的死结:A的创建依赖B的创建,B的创建又依赖A的创建。因此,Spring会直接抛出
BeanCurrentlyInCreationException。
4.Bean的生命周期
依赖注入,三级缓存
流程:
- 实例化 (Instantiation): Spring 通过反射创建 Bean 的实例。
- 填充属性 (Populate Properties): Spring 注入 Bean 的依赖(DI)。
- 初始化 (Initialization):
- 调用各种 Aware 接口(如
BeanNameAware,BeanFactoryAware)。 - 调用
BeanPostProcessor的前置处理方法 (postProcessBeforeInitialization)。 - 调用
@PostConstruct注解的方法或InitializingBean的afterPropertiesSet方法。 - 调用自定义的
init-method。 - 调用
BeanPostProcessor的后置处理方法 (postProcessAfterInitialization)。<- AOP 代理发生在这里
- 调用各种 Aware 接口(如
- 使用 (In Use): Bean 处于可用状态。
- 销毁 (Destruction):
- 调用
@PreDestroy注解的方法或DisposableBean的destroy方法。 - 调用自定义的
destroy-method。
- 调用
5.@Bean 和 @Component 的区别?
面试官您好,@Component 和 @Bean 都是向Spring IoC容器注册Bean的方式,但它们在使用场景和控制粒度上有本质区别:
注解目标不同:
@Component是一个类级别的注解,Spring通过包扫描发现并自动注册为Bean。它还有三个衍生的注解@Service,@Repository,@Controller,用于更清晰地划分业务分层。@Bean是一个方法级别的注解,通常用在@Configuration注解的配置类中。这个方法需要返回一个对象,Spring会将这个返回的对象注册为Bean。
使用场景不同:
@Component用于我们自己编写的类,希望Spring自动管理它们时使用。@Bean主要用于第三方库的组件。因为我们无法修改第三方库的源码去添加@Component注解,所以通过@Bean方法可以显式地将其实例化并交给Spring管理。此外,当一个Bean的创建过程比较复杂,需要一些前置逻辑判断时,也适合用@Bean。
总结来说,
@Component是让Spring自动发现,控制权在Spring;而@Bean是我们主动声明,控制权在我们开发者手中,更加灵活。
引出 @Configuration: @Bean 必须在被 @Configuration 或 @Component 注解的类中使用。可以进一步说明 @Configuration 的 proxyBeanMethods 属性,来体现你对Spring底层代理的理解。
关于@Configuration的proxyBeanMethods属性,这其实是深入理解Spring IoC容器核心原理的一个关键点。它控制着Spring是否要为我们的配置类创建一个CGLIB代理,从而影响Bean之间的依赖注入行为
我们可以分两种情况来看,也就是proxyBeanMethods为true(默认值)和false时,Spring的行为有何不同。
proxyBeanMethods = true (Full模式)
这是@Configuration的默认行为。在这种模式下,Spring在启动时会使用CGLIB动态代理技术,为我们的配置类(比如AppConfig)创建一个代理子类,并把这个代理子类放入IoC容器中。这个代理的核心作用是拦截所有对@Bean方法的调用。
当Spring容器初始化beanA时,它会调用beanA()方法。当代码执行到beanB()时,因为AppConfig是一个代理对象,这个调用会被代理拦截。代理会检查容器里是否已经存在一个名为beanB的单例Bean。
- 如果存在,代理会直接返回容器中那个已经存在的
beanB实例。 - 如果不存在,它才会执行真正的
beanB()方法体,创建一个新的BeanB实例,将它注册到容器中,然后再返回。
在Full模式下,无论你在配置类内部调用@Bean方法多少次,Spring总能保证你拿到的是容器中那个唯一的、正确的单例Bean实例。这保证了Bean依赖关系的正确性,我们称之为‘容器内的单例保证’。
proxyBeanMethods = false (Lite模式)
当我们将它设置为false时,情况就完全不同了。Spring不会为配置类创建CGLIB代理,容器中的AppConfig就是一个普通的Java对象。
在这种模式下,当Spring初始化beanA时,调用beanA()方法。当代码执行到beanB()时,由于没有代理拦截,这就变成了一次普通的Java方法调用。它会直接执行new BeanB(),创建一个全新的BeanB对象。”
这意味着,beanA所依赖的那个BeanB实例,和Spring容器中独立注册的那个名为beanB的Bean实例,是两个完全不同的对象!这就破坏了Bean的单例作用域。
- 当你的配置类中,Bean之间存在相互依赖关系时,比如
beanA的创建依赖于调用beanB()方法。你必须使用默认的true来保证依赖注入的是容器中的单例Bean。 - 当你的配置类中,所有的
@Bean方法都是独立的,彼此之间没有任何调用关系。在这种情况下,设置为false可以跳过CGLIB代理的创建过程,能够提升Spring的启动性能,减少内存占用。事实上,Spring Boot的很多自动配置类(Auto-Configuration)在可能的情况下都会选择使用Lite模式来优化性能。
JUC
1.线程池常见的坑
线程池的参数配置:核心线程的数量,和最大线程的数量是业务场景来的,CPU密集型,比如数据的计算业务,就是CPU的数量+1。
IO密集型根据业务压测的值来决定的,最佳线程数=((线程等待时间+线程CPU时间)/线程CPU时间)*CPU数量
比如,我们服务器CPU核数为8核,任务线程CPU耗时20ms,线程等待等等耗时80ms,那么最佳线程数=(80+20)/20*8=40线程,那我们最大线程数就是80个
- 共享线程池,次要的逻辑拖垮主要的逻辑。避免所有的业务都共享一个线程池,防止一个次要的业务一直在执行业务,占用线程池。而主要的业务并没有足够的线程数来执行,影响到了我们主要的服务。这样做是不合理的。我们应该要做线程池的隔离,使用Future.get方法的时候,使用带超时时间的,因为他是阻塞的,防止被其他抢占。
- @Async是Spring中一个注解,他不是线程池,他其实是SimpleAsyncTaskExecutor,不会复用线程,适合执行大量短时间的线程。还是尽量自己定义一个异步的线程池,然后使用@EnableAsync来注册
- 使用线程池的时候,不使用threadfactory参数来自定义命名,这样导致后期不好排查问题和回溯问题
- 使用submit提交任务,不会把异常直接抛出来。最好我们在submit之中进行try-catch进行捕获,或者是在
Future.get()时捕获并记录异常。 - 线程池使用完之后,记得关闭,防止内存泄漏的问题。最好线程池设计成单例的模式。长期运行的全局线程池(如 Spring 管理的)不需手动关闭,临时线程池需在 finally 中调用
shutdown()。 - 线程池不要和事务一起使用,使用@Transtation的时候,依赖于当前线程的线程上下文,而线程池的线程和当前事务的线程不是一个线程,事务的上下文不会传递,导致线程池中的业务代码不在事务中执行,事务就失效了。我们可以将事务放在线程池之外进行,这是最好的方法,或者是使用支持事务上下文传递的机制(如
TransactionAwareDataSourceProxy、消息队列保证一致性) - 我们要负责监控线程池状态,比如当前活跃的线程池的数量,队列的长度,拒绝的次数
- 要配置合理的拒绝策略,比如一个需要快速获取结果的线程,就需要胚子和callerrunpolicy,这样的话,谁提交谁执行,回退给调用的线程。
- 执行过程:
- Step 1: 判断核心线程数是否已满?
- 当前运行的线程数 <
corePoolSize? - 是: 则直接创建一个新的核心线程来执行任务,即使其他核心线程现在是空闲的。
- 否: 进入 Step 2。
- 当前运行的线程数 <
- Step 2: 判断任务队列是否已满?
workQueue.offer(task)是否成功?- 是: 任务入队成功,等待空闲线程来处理。
- 否: 进入 Step 3。
- Step 3: 判断最大线程数是否已满?
- 当前运行的线程数 <
maximumPoolSize? - 是: 则创建一个新的非核心线程来执行任务。
- 否: 进入 Step 4。
- 当前运行的线程数 <
- Step 4: 执行拒绝策略。
- 调用
rejectedExecutionHandler.rejectedExecution(task, this)。
- 调用
2.AQS的大局解析
AQS是JUC里面的一个抽象同步框架,核心的作用就是统一分装了线程的等待唤醒排队机制。
底层通过一个volatile的state变量+FIFO的队列来实现线程安全的资源性抢夺
state表示资源的状态,独占锁里面0没人占,1就是已经上锁。可重入锁里面数字代表可重入的次数
线程要抢不到锁,就会被挂到队列里面进行排队,队列是双向链表实现的CLH队列,节点记录了等待状态,信息等
他只是一个框架,真正的锁逻辑交给实现类自己决定
AQS获取锁和解锁的过程:
- 获取锁 (acquire):
- 尝试用 CAS 修改
state从 0 到 1。 - 如果成功,则获取锁成功,将锁持有者设为当前线程。
- 如果失败,说明锁被占用。则将当前线程包装成一个 Node 节点,加入到 CLH 队列的尾部。
- 加入队列后,线程会自旋一小会儿,再次尝试获取锁。如果还是失败,则调用
LockSupport.park()挂起当前线程,等待被唤醒。
- 尝试用 CAS 修改
- 释放锁 (release):
- 修改
state的值(比如减1)。 - 如果
state变为 0,说明锁已完全释放。 - 则找到 CLH 队列头节点的下一个节点,调用
LockSupport.unpark()唤醒它,让它去竞争锁。
- 修改
3.wait和sleep的区别
wait()和sleep()的主要区别在于:1. 所属类不同,wait()是Object类的方法,sleep()是Thread类的静态方法;2. wait()会释放对象锁,而sleep()保持锁不释放;3. wait()必须在同步代码块中调用,sleep()没有此限制;4. wait()需要notify()或notifyAll()来唤醒,而sleep()在超时或被中断时自动恢复;5. 使用场景上,wait()用于线程间的协作,sleep()用于简单的延时操作。
wait()方法使当前线程进入等待状态,将其从运行状态转变为等待状态,并将其加入到等待池中。
4.异步编排
在我看来,异步编排的核心思想是,将多个独立的、耗时的异步任务(尤其是I/O密集型任务)组合、编排起来,让它们尽可能地并行执行,最终汇总结果,从而极大地缩短整体的响应时间。 这在微服务架构中尤其重要。
在现代Java开发中,实现异步编排最核心的工具就是 CompletableFuture
举一个我们项目中非常典型的例子:获取‘商品详情页’数据。一个商品详情页通常需要展示多种信息,而这些信息可能来自不同的微服务或数据库表:
- 任务A:调用商品服务,获取商品基本信息。
- 任务B:调用用户服务,获取当前用户的优惠券信息。
- 任务C:调用评论服务,获取商品的热门评论。
- 任务D:调用推荐服务,获取相关商品推荐。
如果采用传统的同步调用方式,总耗时将是 A + B + C + D 的累加。但实际上,这四个任务没有任何依赖关系,完全可以并行执行。通过异步编排,理想情况下的总耗时将仅仅取决于耗时最长的那一个任务,即 Max(A, B, C, D),性能会得到指数级的提升。
实现:
- 任务并行化:为每一个独立的调用任务创建一个
CompletableFuture实例。关键是使用supplyAsync(Supplier<U> supplier, Executor executor)方法,并为其提供一个自定义的线程池。这可以避免耗尽Web服务器(如Tomcat)的业务线程池。 - 结果编排与组合:当所有并行的任务都完成后,我需要将它们的结果组合成一个最终的
ProductDetailPageDTO。我会使用CompletableFuture.allOf()来等待所有任务完成。 - 最终结果处理:在
allOf()完成后,通过thenApply()或thenAccept()来执行最终的组装逻辑。 - 异常处理与超时控制:在生产环境中,还需要考虑健壮性。我会使用
exceptionally()来处理任何一个异步任务的失败,返回一个默认值或降级数据。同时,使用orTimeout()为整个编排流程设置一个最大等待时间,防止因为某个下游服务缓慢而导致整个请求长时间阻塞。
5.synchronized 锁升级的“细节追问
1.线程是如何从‘偏向锁’升级到‘轻量级锁’的?JVM是如何判断‘偏向’失效的
“偏向锁的核心思想是,它‘偏向’于第一个获取它的线程,认为在接下来的执行中,锁将一直被这个线程持有。
- 偏向状态:当一个线程第一次获取锁时,JVM会通过CAS操作,尝试将锁对象头(Mark Word)中的线程ID指向当前线程。如果成功,就获取了偏向锁。
- 升级触发点:当另一个线程(线程B)尝试获取这个已经被线程A持有的偏向锁时,升级过程就被触发了。
- 偏向锁的撤销:
- 首先,线程B的CAS操作会失败。JVM会检查Mark Word中记录的线程ID是否是线程A。
- JVM会暂停线程A(在一个全局安全点),然后检查线程A是否还存活。
- 如果线程A已经执行完毕,那么锁对象恢复到无锁状态,线程B可以重新尝试获取。
- 如果线程A仍然存活且还在同步块内,说明发生了真正的竞争。此时,偏向锁就会被撤销(Revoke)。锁对象头的Mark Word会被修改,清除偏向锁标志,并升级为轻量级锁的状态。同时,线程A的栈帧中会创建锁记录(Lock Record),指向锁对象。
- 之后,线程A和线程B都会在轻量级锁的状态下进行竞争(通过自旋)。
- 只不过目前在JDK 15 中被 默认禁用,并在 JDK 18 被 完全移除。因为偏向锁的撤销消耗的性能是比较大的
2.那轻量级锁又是如何升级到重量级锁的?‘自旋’失败后发生了什么?
轻量级锁的核心思想是,它认为锁的竞争时间会非常短,线程只需要‘稍等一下’(自旋),就可以拿到锁,从而避免了线程阻塞和唤醒带来的内核态切换开销。
- 轻量级锁的获取:线程在自己的栈帧中创建锁记录(Lock Record),然后通过CAS操作尝试将锁对象的Mark Word指向这个锁记录。如果成功,就获取了轻量级锁。
- 自旋等待:如果CAS失败,说明锁已被其他线程持有。当前线程并不会立即阻塞,而是会进行自旋,即执行一个空循环,不断地重试CAS操作。
- 升级触发点:升级到重量级锁主要有两种情况:
- 自旋失败:自旋的次数是有限的(JVM会动态调整,比如10次)。如果一个线程自旋了指定次数后,仍然没有获取到锁,JVM就认为竞争已经非常激烈了,不适合再空耗CPU。
- 竞争者过多:如果在自旋过程中,又有第三个线程也来竞争这把锁,那么也会立即触发升级。
- 锁膨胀(Inflation):
- 一旦触发升级,锁就会膨胀为重量级锁。
- 锁对象的Mark Word会被修改,指向一个重量级锁的监视器对象(Monitor)。
- 所有等待锁的线程(包括正在自旋的线程和后来者)都不再自旋,而是会被阻塞,并放入Monitor的等待队列中。
- 当持有锁的线程释放锁时,会唤醒等待队列中的一个线程,进行新一轮的锁竞争。这个过程就涉及到了操作系统的互斥量(Mutex)和线程的上下文切换。
锁的升级是单向的,只能从低级别到高级别,不能降级(在HotSpot JVM的实现中)。
6.ThreadLocal
既然key用弱引用会导致内存泄漏,那为什么ThreadLocalMap的设计者不把key也设计成强引用呢?或者,为什么不把value也设计成弱引用
1.为什么Key不能是强引用?
- 假设Key是强引用。那么
Thread对象会通过threadLocals这个Map强引用着ThreadLocal对象(Key)。只要线程本身不消亡,这个强引用链(Thread->ThreadLocalMap->Entry->ThreadLocal对象)就一直存在。 - 这意味着,即使我们在业务代码中已经不再使用某个
ThreadLocal对象了(比如,myThreadLocal = null;),只要这个线程还在线程池中被复用,这个ThreadLocal对象本身就永远无法被GC回收。这会导致ThreadLocal对象本身的泄漏,比现在的情况更糟糕。”
2.为什么Value不能是弱引用?
ThreadLocal的核心目的就是让我们存放一些与线程绑定的数据(Value)。这些数据通常是我们业务逻辑中需要用到的对象,比如用户信息对象、数据库连接等。- 如果我们把Value也设计成弱引用,那么当一次GC发生时,只要这个Value对象在其他地方没有被强引用,它就可能被意外地回收掉。
- 这会导致我们调用
threadLocal.get()时,突然得到一个null值,这完全违背了ThreadLocal的设计初衷,会引发严重的业务逻辑错误。我们存放进去的对象,必须保证在remove()之前是可靠存在的。所以,Value必须是强引用。”
因此只能做出了个权衡:
- Key使用弱引用:是为了当
ThreadLocal对象本身在外部不再被使用时,GC能够回收它,从而让Map中的Entry的key变为null,为后续的清理(expungeStaleEntry)提供了可能性。 - Value使用强引用:是为了保证我们存放的数据的生命周期是可控的,不会被GC意外回收。
7.谈谈怎么理解线程安全的
线程安全指的是当多个线程同时访问一个对象或方法时,无论操作系统如何调度这些线程,也无需调用方在代码中去做额外的同步处理,都能保证程序的正确性,不会出现数据损坏或不一致的情况。
线程不安全的问题通常会表现在三个方面
- 原子性:一个或多个操作作为一个不可分割的整体来进行,要去这个操作序列,必须由一个线程独占完整的去执行,不能被其他线程所干扰,调不可被中断。i++
- 可见性:一个线程修改了一个共享变量的值,这个修改的值能够被其他线程看到。但是实际在CPU的高速缓存下,对指令做出的重排序操作,导致共享变量的值,对其他线程不是立即课件的。缓存读的旧值
- 有序性:写的代码的顺序和实际代码的顺序不一致,是由于编译器和处理器层面对指令重排优化导致的,可能会导致可见性问题
我们可以使用voliate或者是直接加synchronized,或者是直接加锁
或者使用原子类的CAS,或者是线程安全的ThreadLocal
8.@ConditionalOnClass设计内涵
面试官提出了一个非常精妙的问题:“@ConditionalOnClass(User.class)这行代码能编译通过,说明User.class肯定存在于classpath中,那为什么还需要这个注解呢?
未能理解@Conditional系列注解是为了解决通用starter模块在不同应用环境下的适配性问题,而不是为了解决当前项目中的类是否存在的问题。
确实,如果在我当前的项目中写@ConditionalOnClass(User.class),这个条件判断看起来是多余的。因为User.class如果不存在,我的项目根本无法编译通过。
这个注解的真正威力体现在开发通用的starter模块时。想象一下,我们正在开发一个my-sms-spring-boot-starter,这个starter希望能够同时支持阿里云短信和腾讯云短信。
- 我们的starter会提供两个自动配置类:
AliyunSmsAutoConfiguration和TencentSmsAutoConfiguration。 AliyunSmsAutoConfiguration负责创建阿里云短信服务的Bean。TencentSmsAutoConfiguration负责创建腾讯云短信服务的Bean。一个使用者(应用项目)\在他的项目中引入了我们的starter。他可能只想使用阿里云短信,所以他只会在他的
pom.xml中添加*阿里云的SDK依赖*,而不会添加腾讯云的。- 这时,我们的starter如何智能地判断只加载阿里云的Bean,而不去加载腾讯云的Bean呢?(如果去加载腾讯云的Bean,会因为缺少腾讯云SDK的jar包而直接抛出
ClassNotFoundException,导致应用启动失败) - 我们就是使用@ConditionalOnClass
在AliyunSmsAutoConfiguration上,我们会这样写,@ConditionalOnClass(com.aliyun.sms.sdk.SmsClient.class)
- 当使用者的应用启动时,Spring Boot会解析我们starter中的这两个自动配置类。
- 在解析
AliyunSmsAutoConfiguration时,它会检查当前应用的classpath中是否存在com.aliyun.sms.sdk.SmsClient.class。因为使用者添加了阿里云的SDK依赖,所以这个类存在,条件满足,这个配置类就会被加载,阿里云的Bean就会被创建。 - 在解析
TencentSmsAutoConfiguration时,它会检查classpath中是否存在com.tencent.cloud.sms.sdk.SmsSender.class。因为使用者没有添加腾讯云的SDK依赖,所以这个类不存在,条件不满足,这个配置类就会被优雅地跳过,不会被加载,从而避免了ClassNotFoundException。
@ConditionalOnClass并不是为了判断我们自己项目里的类是否存在,而是为了让我们开发的通用模块(starter)\能够*智能地感知和适配它所运行的应用环境*,根据应用环境中引入了哪些依赖,来动态地决定哪些功能应该被激活。这是Spring Boot实现‘约定大于配置’和‘开箱即用’的关键魔法之一
9.ThreadLocal 在线程池中的失效问题
InheritableThreadLocal之所以能够实现父子线程间的数据传递,是因为在new Thread()创建子线程时,子线程的构造函数会检查父线程的inheritableThreadLocals这个Map。如果它不为空,子线程就会将父线程Map中的所有值拷贝一份到自己的inheritableThreadLocals中。- 关键在于:这个值的拷贝动作,只发生在子线程被创建的那一瞬间。
- 在线程池的场景下,工作线程通常在系统启动时就已经被预先创建好了,并存放在池中。当我们提交一个任务时,线程池只是从池中取出一个已经存在的线程来执行我们的任务,并没有
new Thread()这个动作。
为了解决这个问题,阿里巴巴开源了一个非常强大的工具——TransmittableThreadLocal(TTL)。它专门用于解决在使用线程池等会池化线程的组件时,实现父子线程、任务提交者与任务执行者之间的上下文传递问题。
TTL的优点在于它通过Java Agent或手动包装的方式,对线程池的submit/execute等方法以及Runnable/Callable任务进行了装饰(Decorate)。”
- 任务提交时(
submit):当我们调用被装饰过的threadPool.submit(myRunnable)时,TTL会捕获当前线程(父线程)的ThreadLocal值,并将其‘打包’进一个TtlRunnable或TtlCallable对象中。 - 任务执行前(
run):当线程池中的某个工作线程开始执行这个被包装过的TtlRunnable时,在其run方法的try块开始处,TTL会将被‘打包’的父线程ThreadLocal值,‘回放’(replay)到当前工作线程的ThreadLocal中。 - 任务执行后(
finally):在finally块中,TTL会清理当前工作线程的ThreadLocal,将其恢复到执行任务之前的状态,从而避免了数据串扰。
10.如何保证三个线程有序执行任务
方案1:使用wait/notify 方案
你需要自己管理锁(synchronized)、状态变量(volatile int state)、while循环(防止伪唤醒)、try-finally(保证锁释放),代码量大且极易出错。
使用notifyAll()会唤醒所有等待的线程,造成不必要的CPU竞争。而使用notify()又存在风险:如果错误地唤醒了不该被唤醒的线程(比如T1唤醒了T3而不是T2),信号就可能丢失,导致程序死锁。
方案2:升级版 wait/notify - ReentrantLock + Condition
ReentrantLock提供了比synchronized更强大的功能。Condition对象则将wait/notify机制从“一个锁只有一个等待队列”升级为“一个锁可以有多个独立的等待队列”,我们可以为每个线程的“等待室”创建一个Condition,实现精准的“点对点”唤醒,彻底避免了notify()的信号丢失问题。
- 创建一个
ReentrantLock实例。 - 创建一个
volatile状态变量,例如volatile int state = 1;,用于标识当前应该哪个线程执行。 - 为每个线程创建一个
Condition对象:Condition c1 = lock.newCondition(); Condition c2 = lock.newCondition(); Condition c3 = lock.newCondition(); - 线程T1的逻辑:
- 获取锁
lock.lock()。 - 在
try...finally中执行,finally块中lock.unlock()。 while (state != 1),c1.await()。- 执行任务1。
- 更新状态
state = 2。 - 精准唤醒线程T2:
c2.signal()。
- 获取锁
- 线程T2和T3的逻辑与T1类似,分别在自己的
Condition上await,并在执行完任务后,更新state并signal下一个线程的Condition。
实现了有序执行,通过signal()实现了精准唤醒,比notifyAll()更高效,比notify()更安全。但还是比较复杂
方案3:信号量接力 - Semaphore
Semaphore(信号量)是控制同时访问特定资源的线程数量的工具。我们可以创建两个初始许可为0的信号量,作为两个线程之间的“接力棒”。
- 创建两个信号量:
Semaphore sem2 = new Semaphore(0);和Semaphore sem3 = new Semaphore(0);。 - 线程T1的逻辑:
- 执行任务1。
- 执行完毕后,释放一个“给T2的许可”:
sem2.release()。
- 线程T2的逻辑:
- 首先尝试获取“来自T1的许可”,如果许可未被释放,T2将在此阻塞:
sem2.acquire()。 - 获取到许可后,执行任务2。
- 执行完毕后,释放一个“给T3的许可”:
sem3.release()。
- 首先尝试获取“来自T1的许可”,如果许可未被释放,T2将在此阻塞:
- 线程T3的逻辑:
- 首先尝试获取“来自T2的许可”:
sem3.acquire()。 - 获取到许可后,执行任务3。
- 首先尝试获取“来自T2的许可”:
代码清晰简单,但需要创建N-1个Semaphore对象,如果线程数量很多,会增加一些对象管理的开销。
方案4:SingleThreadExecutor
Executors.newSingleThreadExecutor()会创建一个单线程的线程池。这个线程池的核心特性是:它内部有一个无界的LinkedBlockingQueue\来存放任务,并且*永远只有一个工作线程来从队列中取出并执行任务。这就天然地保证了所有提交给它的任务,都会严格按照提交的顺序(FIFO)来串行执行*。
创建一个单线程执行器:
ExecutorService executor = Executors.newSingleThreadExecutor();定义三个任务(
Runnable或Callable):task1,task2,task3。按顺序提交任务:java
1
2
3executor.submit(task1);
executor.submit(task2);
executor.submit(task3);关闭线程池:
executor.shutdown()。
- 严格来说,这是“三个任务有序执行”,而不是“三个不同的线程有序执行”。因为所有任务都是由同一个工作线程来执行的。如果面试官的题目严格要求必须是三个不同的、预先创建好的线程,那么这个方案就不完全符合字面要求。
11.ReentrantLock 和 synchronized 在性能上到底差异在哪?
synchronized:- 实现:它是Java的关键字,由JVM层面直接实现。其核心依赖于操作系统底层的
Mutex Lock(互斥量)。 - 开销:获取和释放
Mutex Lock需要进行用户态到内核态的切换,这是一个非常昂贵的操作,涉及到线程上下文的切换和调度,会消耗大量的CPU时间。
- 实现:它是Java的关键字,由JVM层面直接实现。其核心依赖于操作系统底层的
ReentrantLock:- 实现:它是一个Java类,位于
java.util.concurrent.locks包下。其核心是基于AQS(AbstractQueuedSynchronizer)框架实现的。 - 开销:AQS在底层利用了CAS(Compare-And-Swap)\这一CPU原子指令和*
volatile*关键字。在*无竞争或低竞争*的情况下,ReentrantLock可以通过CAS操作直接在用户态完成锁的获取,完全避免了内核态的切换,因此性能极高。
- 实现:它是一个Java类,位于
ReentrantLock性能一定优于synchronized”这个说法,在JDK 1.6之前是成立的。但在1.6之后,JVM对synchronized进行了翻天覆地的优化,引入了锁升级(Lock Escalation)机制,使其性能在很多场景下已经不输甚至优于ReentrantLock。
- 偏向锁:在只有一个线程访问同步块的场景下,
synchronized几乎没有同步开销,性能极高。 - 轻量级锁:当出现少量线程交替竞争时,
synchronized会使用自旋(Spinning)的方式尝试获取锁。自旋也是在用户态完成的,避免了线程阻塞和内核态切换,性能同样很高。 - 重量级锁:只有当竞争非常激烈,自旋多次仍无法获取锁时,
synchronized才会升级为重量级锁,退化到依赖操作系统的Mutex Lock。
只不过ReetrantLock有更多的功能,
- 可中断等待:
lockInterruptibly()允许线程在等待锁的过程中响应中断。 - 可超时等待:
tryLock(long timeout, TimeUnit unit)可以避免死等。 - 多条件变量:一个
ReentrantLock可以创建多个Condition对象,实现更精细的线程通信。
Mybatis
1.UserMappe这个类为啥要是接口呢?
MyBatis的Mapper之所以必须定义为接口,其根本原因在于MyBatis框架在底层使用了JDK动态代理(JDK Dynamic Proxy)技术,来为我们自动地生成这个接口的实现类。
只定义了UserMapper接口,并在XML文件中写了SQL,但我们从来没有手动编写过一个class UserMapperImpl implements UserMapper。然而,在Service层,我们却可以直接@Autowired注入一个UserMapper的实例并调用它的方法。
- 启动时扫描:当Spring容器启动时,MyBatis的
MapperScannerConfigurer会扫描指定的包路径(如com.example.mapper),找到所有被@Mapper注解标记的接口,或者所有继承了特定标记接口的接口。 - 注册Bean定义:对于找到的每一个Mapper接口(比如
UserMapper.class),MyBatis并不会去创建一个真实的实现类,而是在Spring容器中注册一个特殊类型的Bean定义——MapperFactoryBean。 - 创建代理对象:当Service层需要注入
UserMapper时,Spring会向MapperFactoryBean请求获取Bean实例。此时,MapperFactoryBean就会调用JDK动态代理,在内存中动态地生成一个UserMapper接口的代理实现对象。
“这个动态生成的代理对象,它的内部有一个
InvocationHandler。当我们调用代理对象的任何方法时(比如userMapper.selectById(1)),这个调用都会被InvocationHandler拦截。”InvocationHandler的逻辑大致是:”
- 它会获取到我们调用的方法名(
selectById)和参数(1)。 - 它会将方法名与Mapper XML文件中配置的SQL语句的
id进行映射和绑定。 - 它会从连接池获取一个数据库连接,将参数设置到SQL语句中,然后通过JDBC执行这条SQL。
- 最后,它会将查询结果封装成我们方法签名中定义好的返回类型(如
User对象),并返回。
- 它会获取到我们调用的方法名(
正是因为MyBatis依赖于JDK动态代理,而JDK动态代理技术本身就要求被代理的目标必须是一个接口。它无法为一个具体的类或抽象类创建代理。这就是为什么Mapper必须是接口的根本技术原因。
2.mybatis工作原理
将SQL语句的执行从繁琐的JDBC样板代码中解耦出来,通过XML或注解的方式进行配置,并利用Java的反射和动态代理技术,优雅地将接口方法与SQL语句绑定起来。
那我们先来说说他的执行周期:
- 初始化
- 首先,通过
SqlSessionFactoryBuilder,MyBatis会读取全局配置文件mybatis-config.xml。这个文件里定义了数据源(DataSource)、事务管理器(TransactionManager)、别名(typeAliases)、插件(plugins)以及Mapper映射文件的路径等核心信息。 - 接着,根据映射文件路径,MyBatis会逐一加载并解析所有的Mapper XML文件(例如
UserMapper.xml)。
然后解析并构建Configuration对象,解析的所有信息,无论是全局配置还是每个SQL语句的细节,都会被封装到一个极其核心的Configuration对象中。
在解析Mapper XML时,我们所有的标签都会解析成一个MappedStatement,他是一个完整sql语句的封装
所有的MappedStatement都会被存放在Configuration对象的一个Map里,其key就是Mapper接口的全限定名 + 方法名(例如com.example.mapper.UserMapper.selectUserById),value就是对应的MappedStatement实例。
- 当
Configuration对象构建完毕后,SqlSessionFactoryBuilder会用它来创建一个SqlSessionFactory的实例。 SqlSessionFactory是一个重量级、线程安全的对象,它在应用的生命周期中通常只需要一个实例。它的作用就像一个“数据库连接池工厂”,专门用于创建SqlSession。
- 执行
如果我们执行了下面的语句,User user = userMapper.selectUserById(1);
获取Mapper代理对象,
- 我们从
SqlSession中通过sqlSession.getMapper(UserMapper.class)获取到的userMapper实例,并不是UserMapper接口的实现类,而是一个由MyBatis通过JDK动态代理创建的代理对象。这是MyBatis最核心的魔法之一。 当我们调用代理对象的
selectUserById(1)方法时,这个调用会被代理对象拦截。代理对象的
InvocationHandler实现是MapperProxy。它在invoke方法中接收到方法调用后,并不会去执行任何具体的业务逻辑。- 相反,它会根据被调用的接口名和方法名(
com.example.mapper.UserMapper.selectUserById),去第一阶段构建好的Configuration对象中,找到对应的MappedStatement。 MapperProxy会将请求转发给SqlSession,而SqlSession的真正工作是委托给一个Executor(执行器)来完成的。Executor是MyBatis中负责SQL执行、事务管理和缓存维护的核心组件。它有多种实现,如SimpleExecutor(默认)、ReuseExecutor、BatchExecutor。Executor会接收到MappedStatement和传入的参数(1)。Executor会通过一个ParameterHandler,使用JDBC的PreparedStatement,安全地将我们的参数(1)设置到SQL语句的?占位符上,防止SQL注入。Executor执行PreparedStatement,从数据库获取到ResultSet结果集。- 接着,
Executor会通过一个ResultSetHandler来处理这个结果集。 ResultSetHandler会根据MappedStatement中配置的resultType或resultMap,利用Java反射机制,创建出目标对象(如User对象),然后从ResultSet中逐列取出数据,调用User对象的setter方法,将数据填充进去。resultMap是这里一个非常强大的功能,它可以处理数据库列名和Java对象属性名不匹配的情况,以及复杂的嵌套查询和关联查询。ResultSetHandler将封装好的Java对象(User实例)返回给调用方,一次完整的MyBatis查询流程就结束了。
总结一下:
- 加载配置:解析XML和注解,将所有配置信息和SQL语句封装到
Configuration和MappedStatement中。 - 创建会话工厂:基于
Configuration构建SqlSessionFactory。 - 动态代理:当调用Mapper接口方法时,通过JDK动态代理拦截调用,并找到对应的
MappedStatement。 - 委托执行器:将请求交给
Executor,由它负责底层的JDBC操作、事务和缓存。 - 参数与结果映射:通过
ParameterHandler和ResultSetHandler,利用反射机制,完成Java对象与PreparedStatement参数以及ResultSet结果集之间的映射。
JVM
1.那有哪些对象是可以直接在栈上分配呢?
在Java中,并不是特定类型的对象能够直接在栈上分配,而是取决于该对象的作用域。JVM通过一种叫做“逃逸分析”(Escape Analysis)的技术来判断一个对象是否可以安全地在栈上分配。
如果一个对象的引用没有“逃逸”出它被创建的方法之外,那么它就可能被优化为在栈上分配。这样做的好处是,当方法执行结束时,栈帧被弹出,对象的内存会立即被回收,无需等待垃圾回收(GC),从而提高性能。
- 逃逸分析是JIT(即时编译器)的一项优化技术,默认在现代JVM中是开启的。只有那些生命周期完全局限于单个方法调用内、体积较小且线程安全的对象,才最有可能被优化到栈上进行分配。
未逃逸的定义:
- 仅在方法内部使用:对象的引用完全封装在方法体内,没有被方法返回。
- 未赋值给外部变量:没有将该对象的引用赋值给任何类变量(
static字段)或实例变量。 - 未传递给可能逃逸的方法:没有将该对象的引用作为参数传递给其他方法,或者传递给了但能确定其他方法也不会让它“逃逸”。
逃逸的例子:
比如对象作为方法的返回值,他就是逃离了这个方法的作用域
对象引用赋值给实例变量,也是逃离这个方法的作用域
2.JMM和一个对象的生命周期
JMM划分:
线程共享:方法区 堆
线程私有:程序计数器,虚拟机栈,本地方法栈
生命周期:
创建: 类加载检查 -> 堆内存分配(指针碰撞/空闲列表)-> 初始化零值 -> 设置对象头 -> 执行 init 方法。
进行使用
回收:可达性分析 -> 垃圾回收算法 -> 分代回收(Minor GC, Full GC)
优化手段:
逃逸分析、栈上分配、TLAB(线程本地分配缓冲)等优化手段
逃逸分析、栈上分配和TLAB是JVM为了自动化地提升对象分配效率、降低GC压力而设计的一套协同工作的优化组合拳
逃逸分析是决策入口,它决定了一个不逃逸的对象是否有资格享受栈上分配这一‘特权’,从而完全避免GC。对于必须在堆上分配的逃逸对象,TLAB则为它们提供了线程私有的‘VIP通道’,避免了并发分配时的锁竞争。
- 当我们在代码中写下
new User()时,这个User对象在JVM中并不是“无脑地”直接被分配到堆上。它会经历一个由JVM JIT(即时编译器)主导的、充满优化的“审批流程” - 逃逸分析:逃逸分析是一种编译期优化技术,它不是直接的优化手段,而是一种分析手段。JIT编译器会分析一个对象的动态作用域,判断这个对象是否有可能“逃逸”出它的创建方法或当前线程。
- 如果逃逸分析的结果是:“这个对象完全不逃逸!”,那么JVM就会启用一个颠覆性的优化。栈上分配是指将那些不逃逸的小对象,直接在当前线程的虚拟机栈(Stack)上进行分配,而不是在堆(Heap)上。
- 如果逃逸分析的结果是:“这个对象逃逸了,必须在堆上分配”,那么JVM并不会立刻去抢占全局的堆内存,而是会尝试一个更高效的策略。
- TLAB(线程本地分配缓冲)\是JVM为了*提升对象在堆上分配的效率而设计的一种机制。JVM会在堆的新生代(Eden区)*为*每个线程预先分配一小块私有的内存区域,这个区域就叫TLAB。避免并发冲突:堆是所有线程共享的。如果没有TLAB,那么每次
new一个对象,多个线程都需要去竞争同一块Eden区的内存*。这个过程需要加锁(比如CAS)来保证分配的原子性,在高并发下会成为性能瓶颈。 - 当一个线程需要分配一个新对象时,它会首先尝试在自己的TLAB中进行分配。
- 因为TLAB是线程私有的,所以在这个区域内分配对象完全不需要加锁,速度极快,这是一个简单的指针碰撞(Bump the Pointer)操作。
- 只有当TLAB的空间用完了,或者要分配的对象太大TLAB放不下时,线程才会去申请一个新的TLAB,或者在全局的Eden区(此时需要加锁)进行分配。
- 然后在堆中是如何分配的呢?
内存分配方式:
- 指针碰撞 (Bump-the-Pointer)
- 适用场景:当Java堆内存是绝对规整的时候使用。这种情况通常由带有压缩功能的垃圾收集器产生,如
Serial、Parallel Scavenge、G1等。 - 底层原理:所有已用内存都放在一边,所有未用内存放在另一边,中间有一个指针作为分界点指示器。当需要分配内存时,仅仅是把该指针向空闲空间那边挪动一段与对象大小相等的距离。这个过程非常高效,分配内存的动作等同于一次指针移动。
- 适用场景:当Java堆内存是绝对规整的时候使用。这种情况通常由带有压缩功能的垃圾收集器产生,如
- 空闲列表 (Free List)
- 适用场景:当Java堆内存不是规整的,已用内存和空闲内存相互交错时使用。这种情况通常由不带压缩功能的垃圾收集器产生,如
CMS(Concurrent Mark Sweep)。 - 底层原理:虚拟机内部会维护一个列表,记录着哪些内存块是可用的。当需要分配内存时,会从这个列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。寻找合适空间的过程会比指针碰撞慢。
- 适用场景:当Java堆内存不是规整的,已用内存和空闲内存相互交错时使用。这种情况通常由不带压缩功能的垃圾收集器产生,如
并发处理:在多线程并发创建对象时,如何保证堆上分配的线程安全?
- 线程本地分配缓冲 (TLAB - Thread Local Allocation Buffer)
- 核心思想:空间换时间,避免竞争。这是首选和主要的解决方案。
- 底层原理:在JVM的Eden区,会为每一个新创建的线程预先分配一小块私有内存,这块内存就是TLAB。当一个线程需要为新对象分配内存时,它会首先在自己的TLAB中进行分配。因为这是线程私有的,所以这个分配过程完全不需要任何同步或加锁,可以直接使用“指针碰撞”的方式快速完成。这极大地提升了分配效率。
- 只有当线程的TLAB用完,需要申请新的TLAB时,或者要分配一个大于TLAB剩余空间的大对象时,才会触发下面的同步机制。
- CAS + 失败重试 (Compare-And-Swap)
- 核心思想:乐观锁,失败则重试。这是备用和辅助的解决方案。
- 底层原理:当一个线程的TLAB用完需要申请新TLAB,或者虚拟机禁用了TLAB(可以通过
-XX:-UseTLAB关闭),线程就必须在共享的Eden区进行内存分配。为了保证线程安全,虚拟机会采用CAS原子操作来尝试更新内存分配指针。 - 具体过程是:线程先读取指针的当前位置,计算出分配后的新位置,然后通过CAS指令尝试将指针的原位置更新为新位置。如果更新成功,说明分配成功;如果更新失败,说明在操作期间有其他线程修改了指针,当前线程就会自旋(Spinning)重试,直到成功为止。
除了这些JMM内还有一个优化的策略就是堆外内存,它是一种手动管理的内存区域,不属于JVM GC的管理范畴。
通过NIO的ByteBuffer.allocateDirect()方法分配的内存。这块内存并不在Java堆上,而是直接向操作系统申请的本地内存。
| 特性 | 堆内存 (Heap) | 堆外内存 (Off-Heap) |
|---|---|---|
| 管理者 | JVM (GC自动管理) | 开发者 (手动管理) / Cleaner机制 |
| 分配速度 | 快 (TLAB) | 慢 (系统调用) |
| 访问速度 | 快 | 极快 (与I/O交互时) |
| GC影响 | 受GC影响,可能STW | 不受GC影响 |
| 大小限制 | 受-Xmx参数限制 |
受物理内存限制 |
我们可以使用他来完成零拷贝的操作
- 当进行网络或文件I/O操作时,如果数据在堆内存中,需要先从堆内存拷贝到内核缓冲区,再由操作系统发送出去。
- 如果数据直接在堆外内存中,JVM可以直接将这块内存的地址交给操作系统,省去了从用户态到内核态的这次数据拷贝,实现了“零拷贝”,极大地提升了I/O性能。
使用:
- Netty、RocketMQ等高性能网络/消息框架,大量使用堆外内存作为网络通信的缓冲区。
- 需要缓存大量数据,且不希望对GC造成巨大压力的场景(例如,本地缓存框架)。
- 但是,容易出现内存泄漏和排查困难的问题
如何定位和分析内存问题的?
通过监控工具如 Arthas, VisualVM, 或者日志,通过 jmap dump 堆内存,再用 MAT 等工具分析,是优化了数据结构减少内存占用,还是调整了 JVM 参数,比如 -Xmx, -Xms
回收:
每个对象从诞生之初,JVM就在它的对象头(Object Header)里,为它设置了一个‘年龄计数器’(Age),占4个bit。这个‘年龄’是对象晋升老年代的主要依据
- 绝大多数新创建的对象,首先会被分配在新生代的Eden区。此时,它们的年龄为0。
- 当Eden区满了之后,会触发第一次Minor GC。
- GC会扫描Eden区,将所有存活的对象复制到新生代的Survivor区中的一个(我们称之为To-Survivor区)。
- 在这个复制的过程中,这些幸存对象的年龄会加1。
- Eden区中所有未被复制的(被判定为垃圾的)对象,都会被一次性清空。
- 新生代有两个Survivor区,我们通常称之为
S0和S1。在任何时刻,总有一个是空的(To-Survivor),另一个是有数据的(From-Survivor)。 - 当下一次Minor GC发生时,GC会同时扫描Eden区和From-Survivor区(即上次存放幸存对象的那个区)。
- 所有存活的对象,都会被再次复制到那个空的To-Survivor区。
- 同样,在复制过程中,这些对象的年龄会再次加1。
- 清空Eden区和From-Survivor区。然后,
S0和S1的角色互换,为下一次GC做准备。 - 这个过程会一直重复。对象就在
S0和S1之间来回“倒腾”,每经历一次Minor GC,只要它还活着,年龄就会加1。 - 当一个对象在Survivor区中不断地“倒腾”,其年龄达到一个设定的阈值时,在下一次Minor GC中,它将不再被复制到另一个Survivor区,而是被直接晋升(Promote)到老年代。
- 这个年龄阈值可以通过JVM参数
-XX:MaxTenuringThreshold来设置。默认是15,因为对象头中的年龄计数器只有4个bit,最大能表示的数字就是15(二进制1111)。
一个新生代的晋升流程Eden -> S0 -> S1 -> … -> Old,年龄为15的时候进入老年代
除了年龄达到阈值,还有一种情况会触发晋升:如果在 Survivor 区中,相同年龄的所有对象大小的总和,大于 Survivor 空间的一半,那么年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到 MaxTenuringThreshold。这个规则是为了防止Survivor区被过度填充。如果大量同龄的对象在某次GC后集中幸存,可能会导致Survivor区空间不足,进而触发更复杂的分配担保机制。动态年龄判断可以在这种情况发生前,提前将一些“年长”的对象送到老年代,为更“年轻”的对象腾出空间。
大对象直接晋升,这个对象的大小超过了由-XX:PretenureSizeThreshold参数设定的阈值,那么这个大对象将不会被分配在新生代的Eden区,而是会被直接分配到老年代。
在执行Minor GC之前,JVM会检查老年代的连续可用空间是否大于新生代所有对象的总大小(或者大于历次晋升到老年代的对象的平均大小)。
- 如果是,那么这次Minor GC是安全的。如果否,JVM会进行一次Full GC来清理老年代,以腾出更多空间。
- 如果在Minor GC过程中,Survivor区确实无法容纳所有存活对象,那么多余的对象就会通过这个分配担保机制,被直接移入老年代。
3.GC
对象死亡的三个方法,引用计数器,可达性分析,finalize方法,可达性分析需要两次标记,第一次看是不是没用跟引用链相连,第二次看队列中的是不是还没有相连
CMS 选择标记-清除的核心原因是,它是一个并发(Concurrent)收集器,在垃圾收集的大部分阶段,用户线程(Mutator)和 GC 线程是可以同时运行的。而标记-整理算法需要移动对象,这个过程非常复杂,很难与用户线程并发执行,所以 CMS 只能选择实现相对简单的标记-清除。这也正是 CMS 产生内存碎片的根本原因。
CMS 的核心优势恰恰在于它的并发标记(Concurrent Mark)\和*并发清除(Concurrent Sweep)*阶段,是*可以和用户线程一起运行的,从而大大缩短了 STW 时间。CMS 的 STW 主要发生在初始标记(Initial Mark)*和*重新标记(Remark)\这两个非常短暂的阶段。你应该强调 CMS 的 STW \*总时长很短**,但*不可预测*;
而 G1 的 STW 虽然也是分阶段的,但其总时长可以在一个目标范围内被预测和控制。
4.Tomcat如何打破双亲委派?Tomcat类加载顺序?
- 为何打破: 核心原因是为了实现Web应用之间的隔离性。一个标准的Tomcat服务器可以同时部署多个Web应用(多个
.war包)。如果遵循标准的双亲委派模型,所有应用都会共享同一个父类加载器(AppClassLoader)。这会导致:- 依赖冲突:应用A可能依赖
Spring 4.x,而应用B依赖Spring 5.x。如果都由父加载器加载,那么只有一个版本的Spring能被加载,另一个应用必然会因类版本不匹配而崩溃。 - 隔离失效:一个应用的类可以被另一个应用访问到,无法实现真正的隔离。
- 依赖冲突:应用A可能依赖
如何打破: Tomcat通过自定义一个WebAppClassLoader来打破双亲委派模型。这个类加载器的loadClass()方法颠倒了标准的加载顺序:
- 先在自己这里找:优先在Web应用自己的目录下(
/WEB-INF/classes和/WEB-INF/lib)查找类。 - 自己找不到,再交给爹:如果自己找不到,才会遵循双亲委派模型,向上委托给父加载器。
这种“先己后亲”的模式,保证了每个Web应用优先使用自己打包的类库,从而实现了应用间的完美隔离
类的加载顺序?
Tomcat的类加载器体系是一个层次化的结构,其加载顺序如下:
- Bootstrap (引导类加载器):加载JVM自身的核心类库,如
java.lang.*。这是顶层,无法被Java程序直接访问。 - System (系统类加载器):加载JVM系统级别的类,如
CLASSPATH环境变量中指定的类。 - Common (公共类加载器):加载Tomcat和所有Web应用共享的类库,位于Tomcat安装目录的
/lib下。如数据库驱动jar包通常放在这里。 - WebApp (Web应用类加载器):每个Web应用都有一个独立的
WebAppClassLoader实例。它负责加载当前应用的类,路径是/WEB-INF/classes和/WEB-INF/lib。它正是打破双亲委派的关键。
当一个类需要被加载时,Tomcat的查找顺序是:WebApp自己的目录 -> Common -> System -> Bootstrap。但有一个例外,对于Java核心类库(java.*, javax.*),为了防止覆盖JVM的核心API,WebAppClassLoader仍然会优先委托给父加载器。
那么为什么内嵌的Tomcat仍要打破双亲委派?
在一个Spring Boot应用中,通常只有一个Web应用,所以上面提到的“多应用隔离”的理由似乎不再成立了。但内嵌的Tomcat仍然保留了打破双亲委派的WebAppClassLoader,其原因已经从“隔离”转变为“适配”和“兼容”:
- 适配Spring Boot的“胖Jar”结构:
- 一个标准的Spring Boot可执行
jar文件,其内部结构是BOOT-INF/classes和BOOT-INF/lib。标准的AppClassLoader是无法读取嵌套jar文件(即BOOT-INF/lib里的jar)中的类的。 - Spring Boot通过一个自定义的
LaunchedURLClassLoader来启动应用,这个类加载器懂得如何从嵌套jar中加载类。 - 然而,Tomcat的
WebAppClassLoader被设计为从标准的Web目录结构(文件系统路径或war包结构)中加载类。它不认识Spring Boot的胖jar结构。 - 解决方案:Spring Boot在启动嵌入式Tomcat时,会创建一个Tomcat的
WebAppClassLoader实例,但会巧妙地将其“喂食”的源头指向LaunchedURLClassLoader能够解析的路径。本质上,WebAppClassLoader仍然在工作,但它的“工作目录”被Spring Boot动态地设置为了BOOT-INF下的资源。
- 一个标准的Spring Boot可执行
- 遵循Servlet规范和保持Tomcat内部机制的兼容性:
- Servlet规范中定义了类加载的逻辑,比如
ServletContext.getResourceAsStream()等API的行为都与类加载器紧密相关。 - Tomcat内部有很多机制,如JSP的编译和热加载,都严重依赖于
WebAppClassLoader的存在和其特定的加载机制。 - Spring Boot选择嵌入Tomcat,而不是重写它。为了不破坏Tomcat这个成熟容器的内部工作原理,最安全、最可靠的方式就是保留其原有的类加载架构,并在此基础上进行适配,而不是推倒重来。
- Servlet规范中定义了类加载的逻辑,比如
总结:在Spring Boot内嵌场景下,Tomcat打破双亲委派的WebAppClassLoader,其核心作用已经不再是为了隔离多个Web应用,而是为了作为一个“适配器”,优雅地桥接Spring Boot独特的胖Jar类加载机制和标准Servlet容器对类加载环境的期望,从而保证了整个Web服务的正确、稳定运行。
5.G1回收器怎么处理大对象?
关于G1回收器如何处理大对象,这涉及到G1一个非常特殊的设计——Humongous Region。
整个过程可以分为“是什么”、“如何分配”和“如何回收”三个部分来理解。
- 首先,G1中不叫“大对象”,而是有一个专门的术语,叫做“巨型对象”(Humongous Object)。
- 定义:一个对象的大小如果超过了单个Region容量的50%,就会被判定为Humongous Object。
- Region的大小:G1在启动时会根据堆大小将整个堆划分为大约2048个大小相等的、不连续的Region。每个Region的大小在1MB到32MB之间,是2的N次幂。例如,一个Region是2MB,那么任何大于1MB的对象都会被视为Humongous Object。
- 当G1遇到一个Humongous Object时,它的分配策略和普通对象完全不同:
- 不进Eden区:它不会在年轻代的Eden区进行分配,而是直接在老年代寻找连续的空闲Region来存放。
- 寻找Humongous Region:G1会专门开辟一类特殊的Region,称为Humongous Region,来存储这些巨型对象。这些Region在逻辑上属于老年代。
- 跨Region存储:
- 如果一个Humongous Object的大小小于一个完整的Region,它就会被放入一个单独的Humongous Region中。这个Region中剩余的空间将会被浪费,无法再分配给其他对象。这就是内存碎片的来源之一。
- 如果一个对象的大小超过了单个Region的容量,G1会寻找N个连续的空闲Region来存储它,并将这些Region都标记为Humongous Region。
总结分配过程就是:G1会为巨型对象在老年代直接分配连续的、专门的Humongous Region来存放。
那么如何回收呢?
Humongous Object的回收有以下几个特点:
- 不参与年轻代GC (Young GC):因为它们直接分配在老年代,所以任何一次Young GC都不会扫描和回收它们。
- 在并发标记周期中被识别:G1的并发标记(Concurrent Marking)会扫描整个堆,包括Humongous Region。如果一个Humongous Object在这个阶段被识别为不再存活,它就会被标记为垃圾。
- 回收时机:
- 混合GC (Mixed GC):在并发标记之后,如果G1发现某个Humongous Region中的巨型对象已经完全是垃圾,那么在下一次的Mixed GC中,这个Region有可能会被直接回收。G1会评估回收它的收益(释放了大量空间)和成本(几乎为零,因为整个Region都是垃圾),如果划算,就会把它加入到回收集合(CSet)中一并清理。
- Full GC:这是最后的手段。如果Humongous对象的分配和回收导致了严重的内存碎片,使得后续的对象(无论是普通对象还是巨型对象)都找不到足够的连续空间进行分配时,G1会触发一次Full GC。Full GC会进行空间压缩整理,彻底清理这些碎片,但这会导致长时间的“Stop-the-World”暂停,是我们极力要避免的。
- JDK 8u60+ 的优化 - 巨型对象回收的增强:为了缓解Full GC的压力,从JDK 8u60开始,G1引入了一个优化。在并发标记周期结束后,如果发现某个Humongous Object是垃圾,G1可以在下一次Young GC发生时,顺便回收这个Humongous Region,而不需要等到Mixed GC。这被称为“Eager Reclamation of Humongous Objects”(巨型对象的积极回收)。
G1将大小超过Region容量一半的对象定义为Humongous Object。它会跳过年轻代,直接在老年代分配连续的、专门的Humongous Region来存储。这些对象的回收不发生在Young GC中,而是在并发标记确认其死亡后,可以在Mixed GC甚至Young GC(得益于Eager Reclamation优化)中被高效地整体回收。但是,Humongous对象的分配和回收容易导致内存碎片,如果碎片问题严重,最终可能会退化为代价高昂的Full GC。因此,在实践中,我们应该尽量避免创建生命周期很短的巨型对象,或者通过调整 -XX:G1HeapRegionSize 参数来减少巨型对象的产生。
6.如果发现 young GC频繁,我该怎么定位,怎么用 jvm 的指令定位。
会按照一个“提出假设 -> 工具验证 -> 分析解决”的实战流程来回答。
当发现Young GC(也称Minor GC)频繁时,根本原因只有一个:年轻代空间被快速填满。这通常由以下两种情况导致:
- 原因一:年轻代(特别是Eden区)设置过小。应用的正常运行就需要一定的内存分配速率,如果Eden区太小,很快就会被填满,自然导致频繁YGC。
- 原因二:应用存在内存分配速率过高的问题。代码中可能存在“内存泄漏”或“内存抖动”,在短时间内创建了大量对象,即使这些对象很快就死亡,也会瞬间占满Eden区。
使用jstat进行宏观监控,jstat是定位GC问题的首选命令行工具,它轻量、无侵入,可以实时监控GC活动。
1 | # 1. 先用 jps 或 ps -ef | grep java 找到Java进程的PID |
如何分析:
jstat -gcutil的输出包含以下关键列:S0,S1: Survivor 0和1区的使用率。E: Eden区的使用率。O: 老年代使用率。M: 元空间使用率。YGC: 年轻代GC的总次数。YGCT: 年轻代GC的总耗时。FGC: Full GC的总次数。FGCT: Full GC的总耗时。
观察
YGC列:如果这个数字在短时间内(比如几秒钟)快速、稳定地增长,就证实了YGC确实非常频繁。- 观察
E列:你会看到Eden区的使用率像心电图一样,在短时间内从一个较低的值飙升到接近100%,然后一次YGC后又瞬间降下来,周而复始。这个“心跳”的频率越高,说明YGC越频繁。
使用jmap定位内存中的“元凶”,它能生成堆转储快照(heap dump)或查看堆中对象的统计信息。
1 | jmap -histo <PID> | head -n 20 |
- 如何分析:
jmap -histo的输出包含四列:num(序号),instances(实例数量),bytes(总字节数),class name(类名)。- 重点关注:
instances和bytes这两列。排在最前面的类,就是当前在堆中数量最多或占用空间最大的对象。 - 关联YGC问题: 如果YGC频繁,通常意味着有大量的短生命周期对象被创建。这些对象可能在
jmap运行时已经被回收了。但我们仍然可以从列表中找到那些创建速率极高的类的蛛丝马迹。比如,你发现java.lang.String、byte[]、或者某个业务DTO类的实例数量异常地高,那么问题很可能就出在创建这些对象的代码上。
- 重点关注:
进行代码审查:
代码审查: 拿着
jmap找到的嫌疑类名,去代码库中搜索,看看哪些业务逻辑在大量、循环地创建这些类的实例。- 常见问题场景:
- 在一个循环中,反复创建
String对象进行拼接(应该使用StringBuilder)。 - 不合理地使用了
new关键字,本可以复用的对象却在循环中反复创建。 - 日志级别过低(如DEBUG),在生产环境输出了大量不必要的字符串对象。
- 从数据库或缓存中查询出了巨大的数据集合,并创建了大量的DTO/VO对象。
- 在一个循环中,反复创建
- 常见问题场景:
解决方案:
优化代码 (首选): 如果是代码问题,根本的解决方案是优化代码。比如使用对象池、优化算法、减少循环中的对象创建等。
JVM调优 (次选): 如果代码中的内存分配速率是业务所必须的、合理的,但YGC依然频繁,那么就应该考虑调整JVM参数来
增大年轻代的空间。
-Xms/-Xmx: 设置堆的总大小。-Xmn: 直接设置年轻代的大小。增大这个值可以有效降低YGC的频率。-XX:NewRatio=N: 设置老年代与年轻代的比例(老年代/年轻代)。减小这个值,相当于增大了年轻代的占比。-XX:SurvivorRatio=N: 设置Eden区与单个Survivor区的比例。
7.GC回收时对象地址的拷贝是怎么实现的
对象地址的拷贝,或者更准确地说,是对象的移动(Moving),是所有“复制”(Copying)和“标记-整理”(Mark-Compact)算法的GC中必不可少的一环。它的实现方式直接关系到GC的效率。
这个过程的实现,可以从“为什么需要拷贝”、“拷贝的是什么”以及“如何高效地实现拷贝”这三个层面来理解。
为什么?拷贝对象的核心目的是为了解决内存碎片化问题。
通过将所有存活的对象拷贝(移动)\到内存的一端,使其紧凑排列,GC就可以在另一端获得一块*完整、连续的空闲空间*。这不仅解决了碎片问题,还使得后续的内存分配可以恢复使用高效的“指针碰撞”方式。
是什么?
一个Java对象在HotSpot虚拟机中的内存布局。它主要由三部分组成:
- 对象头 (Header):
- Mark Word: 存储了对象的哈希码、GC分代年龄、锁状态标志等。
- Klass Pointer: 指向该对象对应的类元数据(Klass)的指针。
- 数组长度: 如果是数组对象,还会有这部分。
- 实例数据 (Instance Data):对象真正存储有效信息的字段内容。
- 对齐填充 (Padding):HotSpot VM要求对象起始地址必须是8字节的整数倍,如果对象大小不是8字节的倍数,就需要这部分来补齐。
GC拷贝的就是这整个对象(对象头 + 实例数据 + 对齐填充)的完整内存块。
如何高效地实现拷贝?以G1为例的底层实现
G1的年轻代和老年代回收,都是基于“复制”算法。它会将一个或多个Region(称为CSet,Collection Set)中的存活对象,拷贝到新的空闲Region中。这个过程发生在“Stop-the-World”(STW)暂停期间。
会基于以下的步骤:
- GC线程会遍历CSet中所有Region。对于每个Region,它会查找那些在并发标记阶段被标记为存活的对象。
- 分配新空间:在目标空闲Region中,为该对象申请一块大小相同的新内存空间。这通常通过高效的线程本地分配缓冲(TLAB)来完成,以避免多线程竞争。
- 拷贝对象内容:执行一次内存块的批量拷贝。这通常是通过调用底层高效的
memcpy之类的函数来完成的,将旧地址的整个对象内容原封不动地复制到新分配的内存地址。 - 对象被拷贝到新地址后,必须在旧对象的对象头(Mark Word)中,留下一个指向新地址的“转发指针”。
- 这个转发指针的作用是:在GC的后续工作中,如果其他对象仍然持有指向这个旧对象的引用,当GC扫描到这个引用时,它会通过旧对象头里的转发指针,发现该对象已经“搬家”了,并直接将这个引用更新为指向新地址。
- 当一个对象被拷贝后,GC线程会立即扫描这个新拷贝的对象内部的所有引用字段。
- 如果该对象已经被拷贝(即其旧地址的对象头里已经有了转发指针),则直接将当前对象的引用更新为转发指针指向的新地址。
- 如果该对象还未被拷贝,则递归地对这个被引用的对象执行上述的拷贝、设置转发指针、更新引用的完整流程。
- 这个过程就像一个深度优先或广度优先的图遍历。从根对象(GC Roots)出发,一边拷贝存活对象,一边修复指向它们的引用,直到所有可达的存活对象都被拷贝到新的Region中,并且所有相关的引用都已更新为新地址。
当CSet中所有的存活对象都被拷贝完毕后,这些旧的Region就可以被整体视为完全空闲,并被回收以备后续使用。
总结以下:
- 通过内存批量拷贝(如
memcpy)高效地移动对象内容。 - 利用转发指针机制,在旧对象的对象头记录新地址,作为后续引用更新的依据。
- 通过递归或迭代的方式,遍历对象图,一边拷贝对象,一边修复指向已移动对象的引用。
- 在多线程GC中,通常会使用线程本地分配缓冲(TLAB)\来加速新空间的分配,并使用*CAS*等同步原语来保证多线程操作的正确性。
JavaSE
1.流式输出和非流式输出
| 对比点 | 流式输出 | 非流式输出 |
|---|---|---|
| 数据传输 | 边生产边传输 | 生成完后一次传输 |
| 响应延迟 | 首字节快,用户能尽快看到结果 | 必须等所有数据生成后才能看到 |
| 内存占用 | 占用更少内存(分段处理) | 可能占用大量内存(一次性加载) |
| 实现复杂度 | 较高(需要支持分段协议/推送机制) | 较低(一次性返回) |
| 应用场景 | 视频流、日志实时消费、AI Chat逐字打印 | 小文件下载、查询一次性返回结果 |
非流式输出是等数据全部生成后一次性返回,而流式输出则是边生成边返回,能降低延迟和内存占用,更适合大数据量和实时场景。
2.HashMap remove 方法的实现细节
- 首先,
remove(key)方法会计算key的hash值。 - 根据
hash值定位到它在底层table数组中的索引位置(即bucket)。 - 如果该bucket为空,直接返回
null。 - 如果bucket不为空,则遍历该位置的链表或红黑树,逐个节点使用
hash值和equals()方法进行比较,直到找到要删除的目标节点。如果遍历完没找到,也返回null。
如果当前是链表结构,是头节点的话,即让头节点的下一个节点成为新的头节点。
p是中间节点或尾节点。那么就跳过这个节点,GC将自动回收这个不再被引用的节点
- 红黑树的删除操作要复杂得多,因为它必须在删除节点后,通过一系列的旋转(Rotation)和重新着色(Recoloring)\操作,来*维持红黑树的5条性质*(例如,根是黑的、不能有连续的红节点、任何节点到其每个叶子节点的所有路径都包含相同数目的黑色节点等),从而保证树的平衡性。
HashMap会调用内部的removeTreeNode方法来执行这个复杂的过程。- 在红黑树中删除了一个节点后,
HashMap还会检查该bucket的节点数量。如果数量减少到了一个阈值(UNTREEIFY_THRESHOLD,默认为6),为了节省内存和在节点数少时提升性能,这棵红黑树会退化(untreeify)变回普通的链表结构。 - 删除成功后,
HashMap的size会减1。 - 方法会返回被删除节点的
value值。
3.说说 ArrayList、LinkedList、CopyOnWriteArrayList 这三者的适用场景与关键差异
1. ArrayList:
- 底层结构: 基于动态数组实现,内存是连续的。它实现了
RandomAccess标记接口。 - 关键差异:
- 读性能: 支持高效的随机访问,
get(index)操作的时间复杂度是 O(1)。 - 写性能: 尾部添加(
add(e))均摊复杂度是 O(1),但在中间插入或删除元素,需要移动后续所有元素,时间复杂度是 O(n),开销很大。
- 读性能: 支持高效的随机访问,
- 迭代一致性: 它的迭代器是快速失败(Fail-fast)的。如果在迭代过程中,集合结构被其他线程修改,会立刻抛出
ConcurrentModificationException。
2. LinkedList:
- 底层结构: 基于双向链表实现。
- 关键差异:
- 读性能: 不支持高效的随机访问,访问一个元素需要从头或尾遍历,时间复杂度是 O(n)。
- 写性能: 在头部或尾部进行增删操作,时间复杂度是 O(1),效率极高。但在中间位置操作,需要先遍历定位,所以复杂度也是 O(n)。
- 迭代一致性: 和 ArrayList 一样,是快速失败(Fail-fast)的。
3. CopyOnWriteArrayList (COWArrayList):
- 底层结构: 同样基于数组。
- 关键差异:
- 并发安全: 它是线程安全的,核心思想是“写时复制”。
- 读性能: 读操作完全不加锁,直接访问底层数组,性能和 ArrayList 相当,非常高效。
- 写性能: 写操作(增删改)开销巨大。它需要先加锁,然后完整地拷贝一份新数组,在新数组上修改,最后再将引用指向新数组。
- 迭代一致性: 它的迭代器是快照(Snapshot)模式。迭代器创建时会引用当时的底层数组快照,后续的修改对该迭代器不可见,不会抛出异常,保证了迭代的绝对安全,但牺牲了数据的实时性。
4.反射的原理&&应用
反射机制允许程序在运行时动态地获取任意一个类的信息(如属性、方法、构造器)并进行操作。它的优点是极大地增加了程序的灵活性,是很多框架(如 Spring IoC)的实现基石。
在 JDK 动态代理中,反射主要用在最关键的一步——创建代理对象实例。
整个流程是:我们调用 Proxy.newProxyInstance() 方法来创建代理对象。在这个方法内部,它会:
- 在运行时动态地创建一个新的代理类(
.class文件)。 - 然后,它会使用反射,通过
proxyClass.getConstructor(InvocationHandler.class)获取到这个新代理类的构造器。 - 最后,再通过反射调用
constructor.newInstance(invocationHandler),传入我们自己实现的InvocationHandler,来实例化这个代理对象。 - 比如代理模式的实现就是在对象进行初始化的时候,在bootpostproffer的后置处理的时候,将原先的bean换成我们代理的bean
所以,反射是用在了获取代理类的构造器并创建其实例这最核心的一步。
Mysql
1.多表join的时候,小表驱动大表
在Mysql的 Nested Loop Join 中
驱动表(outer table):首先被扫描的表。
被驱动表(inner table):对驱动表每一行,根据 Join 条件去查找匹配行的表。
核心原则:过滤后剩余行数少的表,应该作为驱动表,这样可以减少被驱动表的访问次数。这就是小表
执行过程:
扫描驱动表(全表扫描或索引扫描)。
对驱动表的每一行,根据连接条件在被驱动表中查找(通常用索引 B+Tree 查找)。
如果被驱动表使用二级索引且需要回表,则访问主键索引。
小表驱动大表,大表负责命中索引。
比如
1 | select * from A straight_join B on A.a = B.a; |
数据库会全表扫A,然后每拿到一行就去比较条件 A.a=B.a,去B表里面查,B表命中索引的查询。实际上就是一个搜索树,查询的时间复杂度近似log2^B^,然后加上一次回表,可能就是2Log2 ^B^,所以总体的时间复杂度为A+2log2^B^*A,如果是覆盖索引的话,复杂度可降为 O(A + log₂(B) × A)
所以我的们A越小越好,join的本质就是查驱动表,然后扫被驱动表,当然是查的越少越好了
2.一条 UPDATE 语句发过来,从网络接收开始,到最终落盘,会经过哪些核心模块的处理
Mysql是一个分层的,核心模块包括网络层、SQL层和存储引擎层。
如果以一条 UPDATE t SET c = 2 WHERE id = 1; 语句为例,它的生命周期是这样的:
网络层:
首先,客户端通过TCP连接发送这条SQL。我的网络模块基于Java NIO实现,会接收这个请求,并将其传递给SQL层。
SQL层 - 解析与执行:
- SQL解析器:SQL层会解析这条字符串,生成一个抽象语法树(AST)。
- 执行器:然后,执行器会解释这棵树。对于这条
UPDATE语句,它知道要去表t中找到id=1的行,并更新c列。
存储引擎层 - 事务与数据处理:这是最核心的部分。
- 事务管理器:执行器会向事务管理器申请开启一个事务。
- 访问数据:执行器请求存储引擎去获取
id=1的行。存储引擎会先去 Buffer Pool(内存缓冲池)里查找,如果数据页不在内存,会通过 IO模块 从磁盘加载。 - 并发控制:在读取和修改数据时,为了保证隔离性,这里会涉及到 MVCC 和 锁管理器。
UPDATE是一种“当前读”,所以它会读取最新的已提交版本,并在这行数据上加一个 排他锁(X Lock),防止其他事务同时修改。 - 执行修改:获取到锁之后,执行器会在 Buffer Pool 中修改对应的数据页。但它不是直接覆盖旧数据,而是会生成一个 undo日志,记录下修改前的样子,用于回滚和支持MVCC。
- 记录日志:在修改内存数据页之前,必须先将这次操作的详细信息写入 redo日志(WAL) 的内存缓冲区。这是为了保证持久性。
- 提交事务:当客户端发起
COMMIT时,日志管理器 会确保对应的 redo 日志被刷入磁盘。只要 redo 日志落盘了,即使此时宕机,数据也能恢复,所以我们就可以认为事务提交成功了。 - 数据落盘:至于 Buffer Pool 里的脏数据页,则由一个后台线程根据一定的策略(比如LRU)异步地刷回磁盘,这个过程不影响事务的提交响应。
3.为什么选择 WAL?它相比于直接写数据文件,核心优势是什么?写日志和更新内存数据页的顺序是怎样的?
选择 WAL 的核心优势在于将随机IO转换为了顺序IO,极大地提升了写入性能并保证了数据不丢失。
- 性能提升:数据库的数据页在磁盘上是离散存储的,修改它们需要大量的随机磁盘寻址,非常慢。而日志文件是追加写入的,是顺序IO,速度比随机IO快几个数量级。通过 WAL,事务提交时只需要保证日志落盘即可,脏数据页可以异步、批量地刷回磁盘,大大降低了事务提交的延迟。
- 顺序保证:这个顺序是绝对不能颠倒的,必须是先写日志(Log),再更新内存页(Buffer Pool)。这就是“Write-Ahead Logging”(预写日志)这个名字的由来。
- 原因:如果反过来,先修改了内存中的数据页,然后系统在写日志之前宕机了。那么当系统重启时,内存中的修改会全部丢失,而日志里又没有记录这次操作,这个更新就永远地丢失了,这违反了事务的持久性(Durability)。而只要保证日志先写入,即使系统在数据页刷盘前宕机,重启后也可以通过扫描 redo 日志来恢复数据,保证了数据的完整性。”
4.当一个叶子节点分裂时,具体逻辑是怎样的?如何处理并发问题?
当向一个叶子节点插入数据,发现它已经满了的时候,会触发分裂操作,逻辑如下:
- 叶子节点分裂: 找到中间位置的 key,将节点平分成两个。将这个中间 key 连同指向新节点的指针一起“上提”到父节点中。
- 内部节点分裂: 如果因为子节点的“上提”导致父节点也满了,那么父节点(内部节点)也需要分裂。找到中间位置的 key,将该 key 单独“上提”到它的父节点中,而该 key 左右两侧的 key 和指针则分别构成两个新的内部节点。这个过程可能会一直递归到根节点。
- 根节点分裂: 如果根节点也需要分裂,那么分裂后会产生一个新的根节点,此时 B+ 树的高度加一。
关于并发问题,这是一个非常关键的点。对B+树的这种结构性修改(如分裂或合并)必须是原子的,否则可能导致树的结构被破坏。
- 当一个线程需要修改一个B+树节点时,它会先获取这个节点的 Latch。在分裂过程中,它会同时持有父节点和要分裂的子节点的 Latch,操作完成后再释放。这种方式只锁定了必要的节点,允许其他不相关的读写操作继续进行。
Lock 和 Latch 区别
- 保护对象:Lock(锁) 是在事务层面,用来保护逻辑数据,比如表中的一行记录。它的目的是保证事务的隔离性。Latch(闩锁) 是在线程层面,用来保护内存中的物理数据结构,比如 Buffer Pool 中的一个数据页、B+树的一个节点或者一个共享的内存链表。它的目的是保证多线程访问共享内存结构时的线程安全。
- 持有时间:Lock 的持有时间很长,可能会贯穿整个事务,直到事务提交或回滚才释放。Latch 的持有时间非常短,通常只在一次原子操作的临界区内持有,比如修改一个 B+ 树节点,操作一完成马上就释放。
- 死锁:Lock 会涉及到死锁问题,需要数据库有专门的死锁检测机制。而 Latch 通常通过规定获取顺序(比如在B+树中总是从父节点到子节点获取)来避免死锁,所以一般认为 Latch 是无死锁的。
简单来说,Lock 是给数据库用户(事务)用的,保证业务逻辑的正确性;Latch 是给数据库内核开发者用的,保证内核数据结构的正确性。”
为什么选择B+树?在IO上看
- 关键在于“高扇出” (High Fan-out): 数据库的数据是存储在磁盘上的,I/O 操作非常昂贵。我们需要一种“矮胖”的数据结构,而不是“瘦高”的。
- 平衡二叉树为什么不行? 因为它是二叉的,每个节点最多两个子节点。一棵存储百万数据的 AVL 树,深度会非常高(约 log₂(n)),导致需要进行很多次磁盘 I/O 才能找到数据。
- B+ 树为什么行? B+ 树的非叶子节点只存储索引(key)而不存储数据(data)。这意味着在同样大小的磁盘页(比如 16KB)中,B+ 树的非叶子节点可以存放成百上千个索引指针,这就是“高扇出”。因此,一棵三到四层的 B+ 树就能存储上千万甚至上亿的数据,查询时只需要 3-4 次磁盘 I/O。
- B 树相比 B+ 树的劣势: B 树的非叶子节点也存数据,导致其“扇出”没有 B+ 树那么高,树的高度会相对更高,I/O 次数更多。
- 哈希表的另一个致命缺点: 除了哈希冲突,哈希索引不支持范围查询。而数据库中
WHERE age > 20这样的范围查询非常普遍,这是 B+ 树的叶子节点通过双向链表连接起来所能高效支持的。
5.慢查询的的过程
- 第一步:开启慢查询日志。 在 MySQL 中配置
slow_query_log和long_query_time,让数据库自动记录超过阈值的慢 SQL。 - 第二步:分析慢查询日志。 使用
mysqldumpslow等工具,对日志文件进行分析,找出出现频率最高、查询时间最长的 SQL。 - 第三步:使用
EXPLAIN分析执行计划。 针对找到的慢 SQL,使用EXPLAIN查看其执行计划,重点关注type(是否为ALL全表扫描)、key(是否用上了索引)、Extra(是否出现了Using filesort,Using temporary)等关键字段。
通过在 xxx 字段上增加联合索引,并利用索引覆盖,我们将这条 SQL 的查询时间从 2 秒优化到了 50 毫秒,接口的 P99 响应时间也从 2.2 秒降低到了 200 毫秒。
6.SQL优化
1.查询前xxx
- 使用limit
- 使用rank函数,
ROW_NUMBER(): 不考虑并列,给出连续排名 (1, 2, 3, 4)。RANK(): 考虑并列,但会跳过排名。比如两个第二名,下一个就是第四名 (1, 2, 2, 4)。DENSE_RANK(): 考虑并列,且不跳过排名 (1, 2, 2, 3)。 在“取 Top N”的场景下,RANK()或DENSE_RANK()通常是更合适的选择。
2.联合索引怎么走?
A,B,C,where a > ? and b = ? c != ?,怎么走
只会使用到联合索引的 A 部分,而 B 和 C 部分将无法有效地利用索引来缩小查询范围。
优化器首先会使用索引来处理 a > ? 这个条件。它会在 (A, B, C) 索引树上进行 范围扫描 (range scan),找到所有满足 a 大于给定值的索引记录。这部分是高效的。
这是最关键的一点。当索引遇到了一个范围查询(如 >、<、BETWEEN),那么这个范围查询列(也就是 A)右边的所有索引列(也就是 B 和 C)都会失效,无法再用于进一步的索引查找。
- 为什么会失效? 联合索引的排序是严格按照
A,B,C的顺序来的。它首先按A排序,在A值相同的情况下,再按B排序,以此类推。当你执行a > ?时,你筛选出的是一个A值的范围。在这个范围里,B的值是无序的(或者说,只是在每个单独的A值内部有序,但整体是无序的)。因此,数据库无法利用索引去快速定位满足b = ?的记录,只能一条一条地去过滤。
3.分页查询优化
如果数据量特别大的时候,分页查询慢该怎么办?
首先我们先去分析下为什么数据量大的情况下,分页查询会很慢
- 第一数据量太大
- 第二数据库处理分页的方法太笨
比如LIMIT 10000 10
- 第一步: 把整张表的数据全捞出来(全表扫描),按年龄排好序(文件排序)。
- 第二步: 吭哧吭哧数到第100010条,再给你返回最后10条。
不仅如此,如果用了普通索引,还需要去先查索引,这个很快,再去回表查,这个很慢,更何况我们有那么多的回表
还有排序呢?大多数时候,分页查询都会带有排序,比如按时间、按ID排序。
数据库不仅要查数据,还得根据你的排序要求重新排一次,特别是在数据量大的时候,排序的开销就变得非常大。
优化场景:单表limit优化
- 子查询分页,绕过全表扫描,直接定位到目标数据!
1 | -- 先查索引定位ID,再捞数据 |
用覆盖索引快速找到第100000条的ID,直接从这个ID开始拿数据,跳过前面10万次回表。但是不适用于结果集不以ID连续自增的分页场景。实际情况不可能是ID连续的,加上过滤字段的话
- JOIN联表
1 | SELECT * FROM user t1 |
先用索引快速拿到10个目标ID,再一次性联表查完整数据,减少回表次数。 跟子查询差不多的
- 覆盖索引
1 | SELECT age, name FROM user ORDER BY age LIMIT 100000,10; |
查询什么,什么加联合索引
优化场景:分库分表查询
假设订单表分了3个库,每个库分了2张表(共6张表),按用户ID分片。
1 | SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 10; |
实际执行:
1、 每张表都老老实实查100万+10条数据(共600万+60条);
2、 把所有数据汇总到内存,重新排序(600万条数据排序,内存直接炸穿);
3、 最后忍痛扔掉前100万条,给你10条结果;
所存在的问题:
1:数据分散,全局排序难
各分片数据独立排序,合并后可能乱序,必须全量捞数据重排。
2:深分页=分片全量扫描
每张表都要查 offset + limit 条数据,性能随分片数量指数级下降。
3:内存归并压力大
100万条数据 × 6个分片 = 600万条数据在内存排序,分分钟OOM!
优化方案:
1.禁止跳页,只能一页一页的翻找
按时间倒序,拿前10条 ,记住上一页最后一条的时间 ,这样依次类推
2.二次查询法
第一轮查询:每张分片查缩小范围的数据;
1 | -- 每张分片查 (offset / 分片数量) + limit 条 |
从所有分片结果中,找到最小的 create_time (比如 2023-09-20 08:00:00 )。 2、 第二轮查询:根据最小时间戳查全量数据;
1 | SELECT * FROM orders |
3.直接使用ES进行查询
7.Limit
LIMIT子句主要用于限制查询结果集返回的行数。它有两个核心的应用场景:
数据分页:这是LIMIT最广为人知的用途。通过结合OFFSET(或使用LIMIT的逗号语法),我们可以实现数据的分页展示。
获取Top N记录:当我们需要获取排序后的前N条记录时,LIMIT与ORDER BY结合使用就非常强大。
实现原理:
没有ORDER BY的情况
如果查询语句中没有ORDER BY,比如 SELECT * FROM users LIMIT 10;
- 实现原理: 这种情况下,MySQL的执行非常简单高效。它会按照存储引擎中数据的物理顺序(对于InnoDB通常是主键顺序)或者某个它认为最快的扫描顺序,顺序地读取数据行,并进行计数。当读取到
LIMIT指定的行数(这里是10条)后,MySQL会立即停止扫描,直接返回结果。
带有ORDER BY的情况
- 情况A:
ORDER BY的字段有索引- 查询示例:
SELECT * FROM articles ORDER BY publish_time DESC LIMIT 10;(假设publish_time有索引) - 实现原理: 这是最高效的方式。MySQL优化器会直接利用
publish_time的索引。因为索引本身就是有序的,所以MySQL可以直接在索引上进行倒序扫描,找到前10个满足条件的索引条目,然后通过回表(如果需要)获取完整的数据行。 - 特点: 速度非常快,因为它避免了全表扫描和文件排序,扫描的范围被索引精确地限定在了所需的前10条记录。
- 查询示例:
- 情况B:
ORDER BY的字段没有索引- 查询示例:
SELECT * FROM users ORDER BY score DESC LIMIT 10;(假设score没有索引) - 实现原理: 这是性能最低效的方式。MySQL无法利用索引来获取有序数据,只能:
- 全表扫描: 读取表中的所有数据行。
- 文件排序 (Filesort): 将读取到的所有数据行加载到内存(如果内存足够大,即
sort_buffer_size)或临时磁盘文件(如果内存不够)中,按照score字段进行排序。 - 取前N条: 在排序完成后的结果集中,取出前10条记录返回。
- 特点: 性能开销巨大,因为它需要读取全表数据并进行代价高昂的排序操作。这正是
LIMIT导致全表扫描的典型场景。
- 查询示例:
全表扫描,没有ORDER BY的LIMIT查询,ORDER BY的字段上有合适的索引
全表扫描,ORDER BY的字段没有索引,深度分页问题 ,WHERE条件与ORDER BY字段冲突,导致无法使用索引
8.如何查询表中最新的五百条数据
要查询表中最新的500条数据,最高效且最常用的方法是利用索引进行倒序排序,并结合 LIMIT 子句。
- 根据自增主键
id查询,直接进行order by ,因为是主键,所以有聚簇索引,查询效率高 - 根据创建时间
create_time查询,要进行加索引,MySQL会利用create_time的B+树索引。同样,它会从索引的末端开始倒序扫描,找到500个索引条目,然后通过回表(如果需要)获取完整的数据行。
9.海量数据如何分页查询
我们传统的分页:
1 | -- 查询第100页,每页20条 |
为什么它在海量数据下会崩溃?
这个查询的性能瓶頸在于OFFSET。当OFFSET的值非常大时(比如OFFSET 1000000,即深度分页),数据库的执行过程是:
- 扫描数据: 数据库需要从头开始,扫描出
OFFSET + LIMIT条记录(即2000 + 20 = 2020条)。如果ORDER BY的字段没有索引,这里就是全表扫描+文件排序;即使有索引,也需要进行大量的索引扫描。 - 丢弃数据: 然后,数据库会丢弃掉前面的
OFFSET条记录(2000条),只保留最后的LIMIT条(20条)作为结果返回。
解决办法:
- 键集分页/游标分页
不再使用OFFSET来“跳过”记录,而是使用上一页最后一条记录的唯一值(或有序值)作为“书签”或“游标”,来定位下一页的起始位置。
1 | SELECT * FROM massive_table |
性能比较快,但是不能指定页码
2.延迟关联 ,这是一种针对LIMIT OFFSET的优化方案,适用于必须保留“跳转到第N页”功能的场景。
将扫描和丢弃大量数据的过程,在开销更小的索引层面完成,而不是在包含所有字段的主表上完成。
- 子查询: 先在覆盖索引(Covering Index)上使用
LIMIT OFFSET快速定位到目标数据行的主键ID。这个过程很快,因为只操作索引,不涉及主表数据。 - JOIN关联: 然后将子查询的结果(只包含少量主键ID)与原表进行
JOIN,获取完整的行数据。
1 | SELECT t1.* |
虽然性能提示比较大,但是offset过大还是性能会下降
3.使用ES,等一些搜索引擎来完成
10.全文搜索怎么实现
我会从“最不应该用的方法”开始,逐步讲到“数据库内置方案”,最后再介绍“业界标准的专业方案”。
最原始的“土方法” - LIKE '%keyword%'
- 性能灾难:
LIKE查询,特别是以%开头的模糊查询,无法有效利用数据库的B-Tree索引。对于海量数据,这将导致全表扫描,查询性能会随着数据量的增长而急剧下降,最终拖垮整个数据库。 - 它只是简单的字符串匹配,完全不具备“全文搜索”的核心能力,没有分词,没有词干提取,没有相关性排序
数据库内置的“半专业”方案 - FULLTEXT 索引
倒排索引 (Inverted Index) 这是全文搜索的技术基石。与我们常用的B-Tree索引(数据 -> 索引)不同,倒排索引是(关键词 -> 数据)的映射。
在创建FULLTEXT索引时,数据库会对指定的文本列进行分词,将长文本拆解成一个个独立的词语(token)。索引构建: 然后,它会创建一个索引,记录下每个词语出现在哪些文档(数据行)中当我们搜索“搜索引擎”时,数据库会先对搜索词进行分词,得到“搜索”和“引擎”,然后去倒排索引中查找同时包含这两个词的文档列表(文档1),并根据相关性算法(如TF-IDF)进行排序,最后返回结果。
- 但是他也是有性能瓶颈的,全文搜索的计算仍然会消耗数据库的大量CPU和I/O资源
- 他的功能也是有限的,对中文的搜索效果不好
- 无法水平扩展,不能增强搜索能力
专用搜索引擎 (Elasticsearch)
- 分布式架构: ES天生就是分布式的。它会将索引数据分割成多个分片(Shard),并将这些分片均匀地分布在集群的多个节点上。这使得ES可以通过增加节点来水平扩展,轻松应对PB级别的数据和高并发的查询请求。
- 极其强大的分析器 (Analyzer):ES提供了高度可定制的文本分析流程。一个分析器由字符过滤器(Character Filters)、分词器(Tokenizer)和词元过滤器(Token Filters)组成,可以实现非常复杂的文本处理,如去除HTML标签、使用IK等中文分词器、同义词转换、拼音转换等。
- 先进的相关性排序: ES使用更先进的BM25等相关性算法,能够提供远比数据库更精准的搜索结果排序。
- 丰富的功能: 除了全文搜索,ES还提供了强大的聚合(Aggregations)、地理位置搜索、自动补全等高级功能。
在这个里面最重要的就是数据同步,可以使用Canal或者Logstash
Redis
1.多级缓存数据一致性与失败回滚
当被问及如何保证Redis和本地缓存更新的原子性,以及在更新失败时如何回滚,你的回答提到了不甚准确的“编程式事务”,并最终倾向于人工处理。
方案1:引入消息队列(MQ)进行可靠的异步处理
- 修改架构:Canal不再直接调用消费逻辑,而是将解析后的binlog事件作为消息发送到MQ的一个Topic中。
- 消费者逻辑:消费者服务从MQ拉取消息。其处理逻辑是:先失效Redis缓存,再发布一个广播消息(如通过Redis Pub/Sub)通知所有应用实例失效本地Caffeine缓存。
- 失败处理:只有当所有步骤成功后,消费者才向MQ发送ACK。如果处理过程中任何一步失败(如Redis连接超时),消费者不发送ACK。MQ会在超时后将该消息重新投递给其他消费者,实现自动重试。
方案2 死信队列
- 在Canal的消费者逻辑中,使用
Spring Retry等框架对缓存失效操作进行封装。 - 配置重试策略,例如重试3次,每次间隔采用指数退避(如1s, 2s, 4s),避免在故障期间频繁冲击下游服务。
- 配置一个
RecoveryCallback。当所有重试都失败后,将这条失败的binlog事件(包含表名、主键、操作类型等信息)发送到一个专门的死信队列(Dead Letter Queue)或记录到数据库的失败任务表中。 - 部署一个独立的监控程序或定时任务,消费DLQ中的消息,并发送告警(邮件、短信、钉钉)。
如果重试逻辑设计不当,可能会在短时间内放大故障。死信队列需要有完善的监控,否则会成为被遗忘的角落。
方案3 先更新缓存,再更新数据库”的策略
- 写请求:先更新(或失效)Redis缓存,然后更新数据库。
- 为了解决并发更新导致的不一致问题,可以引入“延时双删”:先删缓存 -> 更新数据库 -> 延迟一段时间(如500ms)后再次删除缓存。
- 本地Caffeine缓存仍然可以通过监听Redis的key失效事件(Keyspace Notifications)或消息广播来同步失效。
非常不推荐。延时双删的延迟时间很难确定,无法100%保证一致性。代码侵入性强,业务逻辑与缓存逻辑耦合严重,维护困难。
2.什么情况下,就是两个线程会持有同一把锁
两个不同的线程在同一时刻是不可能持有同一把锁的,这是锁的互斥性基本原则所保证的。如果出现了这种情况,那一定是锁的实现出了严重的问题。
您这个问题可能是在考察一个非常重要的特性——锁的可重入性。可重入性指的是同一个线程可以多次成功获取同一把锁,而不会自己把自己锁死。在释放锁时,也需要释放相应次数后,锁才会被真正释放。”
比如:在一个复杂的业务方法A中,它获取了锁。然后它又调用了另一个方法B,而方法B也需要获取同一个锁。如果没有可重入性,那么在方法B中,当前线程会因为无法获取一个已经被自己持有的锁而陷入死锁。
实现:Redisson巧妙地使用了Redis的Hash数据结构来实现。
- 当一个线程第一次获取锁时,它会在Redis中创建一个Hash。这个Hash的Key是锁的名称(例如
myLock)。 - 这个Hash结构内部会存储两个关键信息:
- 一个field存储持有锁的线程标识(例如,UUID + ThreadId)。
- 另一个field存储一个计数器,表示该线程重入的次数,初始值为1。
- 当同一个线程再次尝试获取这把锁时,Redisson会检查Hash中存储的线程标识。如果与当前线程标识匹配,它就不会阻塞,而是直接将计数器的值加1,表示又重入了一次。
- 当线程释放锁时,它会去将计数器减1。只有当计数器的值减到0时,Redisson才会真正地从Redis中删除这个Hash(即释放锁),这样其他线程才有机会获取。
3.如果Canal挂了怎么办?或者Canal到消费端的链路出现长时间中断,会发生什么?有什么容灾方案吗?
您提的这个问题非常关键,它涉及到整个数据同步链路的高可用性。
- Canal自身的高可用:首先,Canal自身是可以部署成高可用集群的。通过Zookeeper进行集群管理和主备选举,当主节点宕机时,备用节点可以自动接管,从而保证了数据订阅服务的连续性。
- 链路中断的影响:如果Canal到消费端的链路中断,确实会导致缓存与数据库在中断期间的数据不一致窗口期变长。新写入的数据无法触发缓存失效,用户可能会在一段时间内读到旧的缓存数据。
- 我们的容灾与补偿策略:
- 监控与告警:我们必须对Canal的消费位点(Position)与MySQL主库的最新binlog位点之间的延迟做严格的监控。一旦延迟超过阈值(比如1分钟),就立即触发高级别告警,通知SRE和开发团队介入。
- 设置合理的缓存TTL:即使同步链路中断,我们缓存中的数据也不是永久有效的。通过为所有缓存设置一个合理的兜底过期时间(TTL),比如1小时,可以保证即使在最坏的情况下,数据不一致的时间也不会无限延长。这是一种自愈机制。
- 手动全量/增量校准:对于极端重要的数据,我们会准备一个手动触发的数据校准脚本。当链路长时间中断并恢复后,可以运行这个脚本,根据时间戳或版本号,主动查询数据库,强制刷新Redis中的核心数据,确保最终一致性。”
4.你提到用Redis的Pub/Sub来广播失效Caffeine本地缓存。
Pub/Sub是‘fire-and-forget’(即发即忘)模式,不保证消息必达。如果某个应用实例因为网络抖动没收到失效消息,怎么办?
您观察得非常仔细,Pub/Sub确实存在消息丢失的风险。对于这个问题,我们有分层级的解决方案
- 接受短暂不一致:对于大部分业务场景,单台服务器上短暂的本地缓存不一致是可以接受的。因为流量通常会通过负载均衡打到多台服务器上,只有一小部分用户请求会命中这台机器的旧缓存,且Caffeine本身也有过期机制,影响是可控的。
- 引入更可靠的消息总线:如果业务对一致性要求极高,我们会放弃轻量级的Pub/Sub,转而使用更可靠的消息中间件(如RocketMQ)的广播消费模式。每个应用实例都作为一个消费者组内的广播消费者,订阅失效通知。MQ的ACK机制可以保证每个实例都可靠地收到失效消息。
- 版本号机制:我们可以在缓存的对象中增加一个版本号或时间戳字段。当应用从缓存中获取到数据后,可以(在某些关键操作前)与数据库中的版本号进行一次快速比对。如果发现缓存版本落后,就主动失效本地缓存并重新加载。这是一种主动校验的补偿机制。”
5.缓存三问题
布隆过滤器和缓存空值,这两种方案在你的项目中,你会如何选择?它们各自有什么优缺点和需要注意的地方?
方案一:缓存空值(Cache Null Values)
优点:
- 实现简单:逻辑清晰,开发和维护成本极低。
- 效果直接:能100%拦截住对同一个不存在的key的重复攻击。
缺点与注意事项:
- 消耗额外的缓存空间:如果被恶意攻击,攻击者不断变换不存在的key来查询,会导致Redis中存储大量的空值key,造成内存浪费。
- 数据一致性问题:如果这个之前不存在的数据,后来又在数据库中被创建了(例如,一个新用户注册了),缓存中的空值需要有一种机制被及时地更新或失效,否则会导致用户刚注册完却查不到自己的信息。
适用于不存在的key的集合相对固定,或者重复查询率高的场景。例如,查询一个已经下架的商品
方案二:布隆过滤器(Bloom Filter)
- 优点:
- 空间效率极高:它使用位图(bitmap)来存储数据,占用的内存空间远小于缓存空值方案,非常适合处理海量数据。
- 缺点与注意事项:
- 存在误判率(False Positive):布隆过滤器判断“不存在”是100%准确的,但判断“存在”时,有一定概率会把一个不存在的key误判为存在。这意味着它无法完全拦截所有穿透请求,会有一小部分漏网之鱼打到数据库。
- 无法删除元素:标准的布隆过滤器不支持删除操作。如果数据需要频繁地增删,就需要使用Counting Bloom Filter等变种,实现更复杂。
- 初始化和重建成本:需要在系统启动时,将全量数据加载到布隆过滤器中,这个过程可能比较耗时。当数据发生变化时,也需要有机制来同步更新过滤器。
- 适用场景:适用于数据量巨大,但数据相对稳定,且对误判率有一定容忍度的场景。例如,防止恶意用户用随机生成的ID来攻击用户查询接口。
6.用户在10分钟之内连续输错三次密码,就禁止其登录”。如果使用 Redis,你会选择哪种数据结构来实现
方案1:使用String
Redis的INCR命令是原子性的,可以保证在并发环境下计数的准确性。EXPIRE命令可以为一个key设置生存时间(TTL),完美地契合了“10分钟之内”这个时间窗口的需求。
- 定义Key:为每个用户的登录失败计数定义一个清晰的Key,例如:
login:fail:count:{userId}。 - 登录失败逻辑:当用户登录失败时,执行以下操作:
- 对该用户的Key执行
INCR命令,获取增长后的计数值:count = redis.incr("login:fail:count:{userId}")。 - 判断是否是第一次失败:如果
count等于1,说明这是10分钟窗口内的第一次失败。此时,必须为这个Key设置过期时间:redis.expire("login:fail:count:{userId}", 600)(600秒 = 10分钟)。 - 检查是否达到阈值:判断
count是否大于等于3。如果是,则触发锁定用户的逻辑(例如,在数据库中更新用户状态,或在另一个Redis Key中设置一个锁定标记)。
- 对该用户的Key执行
- 登录成功逻辑:当用户登录成功时,应该立即删除这个计数Key:
redis.del("login:fail:count:{userId}"),以清除之前的失败记录。
问题:
- 存在一个微小的竞态条件(Race Condition):在
INCR和EXPIRE两个命令之间,如果服务器恰好宕机或重启,可能会导致一个计数Key被创建但没有设置过期时间,从而变成一个永久的计数器。虽然概率极低,但在高并发系统中仍需考虑。 - 解决方案:可以使用Lua脚本将
INCR和EXPIRE两个操作打包成一个原子操作,或者使用一条Redis命令完成
方案2:灵活精确 - List 作为失败记录队列
Redis的List是一个双向链表,可以作为队列使用。通过LPUSH在队头插入元素,LTRIM修剪队列长度,可以非常高效地维护一个固定大小的事件窗口。
- 定义Key:
login:fail:log:{userId}。 - 登录失败逻辑:
- 获取当前时间戳(秒或毫秒),并将其作为元素
LPUSH到List的头部:redis.lpush("login:fail:log:{userId}", System.currentTimeMillis())。 - 检查当前失败次数:获取List的长度
llen。 - 如果
llen大于等于3,说明已经发生了至少3次失败。此时,获取List中第3个元素(即最早的那次失败记录,索引为2):third_attempt_time = redis.lindex("login:fail:log:{userId}", 2)。 - 判断时间窗口:计算当前时间与
third_attempt_time的时间差。如果差值小于10分钟,则说明在10分钟内发生了3次失败,触发锁定逻辑。
- 获取当前时间戳(秒或毫秒),并将其作为元素
- 队列维护:为了防止List无限增长,可以在每次
LPUSH后,使用LTRIM命令只保留最近的3条记录:redis.ltrim("login:fail:log:{userId}", 0, 2)。同时,为整个Key设置一个比10分钟稍长的过期时间,如11分钟,用于自动清理冷数据。 - 登录成功逻辑:同方案一,
DEL掉对应的Key。
- 实现了精确的时间窗口判断。
- 内存占用非常小,因为每个用户的Key最多只存储3个时间戳。
方案三:功能强大 - ZSET (Sorted Set) 实现滑动时间窗口
Redis的ZSET是一个有序集合,每个成员都关联一个score。我们可以用score来存储事件发生的时间戳,利用ZSET按分数范围查询和删除的特性,完美地实现滑动时间窗口。
- 定义Key:
login:fail:zset:{userId}。 - 登录失败逻辑:
- 获取当前时间戳
now。 - 为了防止成员重复,可以给每个成员一个唯一的值,例如
now + ":" + Math.random()。 - 将新的失败记录添加到ZSET中,
score和member都使用时间戳(或score是时间戳,member是唯一ID):redis.zadd("login:fail:zset:{userId}", now, now)。 - 清理过期记录:移除所有10分钟之前的记录,这是一个非常关键的步骤,保证了窗口的滑动:
redis.zremrangebyscore("login:fail:zset:{userId}", 0, now - 600000)(假设now是毫秒)。 - 统计窗口内次数:获取当前ZSET中的成员数量:
count = redis.zcard("login:fail:zset:{userId}")。 - 检查阈值:如果
count大于等于3,触发锁定逻辑。
- 获取当前时间戳
- 登录成功逻辑:同方案一,
DEL掉对应的Key。
7.Redis持久化
RDB 是“快照”模式,AOF 是“指令日志”模式,并理解了它们都是为了解决 Redis 宕机后的数据恢复问题。
你提到了 RDB 文件小、恢复快,但可能丢失数据;AOF 文件大、恢复慢,但数据更完整。
这是一个非常关键的知识点。当 RDB 和 AOF 文件同时存在时,Redis 会优先选择 AOF 文件来恢复数据。
- 为什么? 因为 AOF 文件通常记录的数据比 RDB 文件更完整、更新。AOF 的默认策略是每秒写一次盘,而 RDB 默认是几分钟甚至更久才生成一次快照。为了尽可能少地丢失数据,Redis 的设计者选择了优先使用数据更全的 AOF。
AOF 重写(AOF Rewrite): 你提到了 AOF 文件会很大,这是一个很重要的缺点。但你没有提到解决这个问题的关键机制——AOF 重写。Redis 会在后台定期地对 AOF 文件进行重写,将多条冗余的命令(比如对一个 key 多次 set)合并成一条最终的命令,从而大大压缩 AOF 文件的大小。这个机制是 AOF 能够被长期使用的重要保障。
RDB 的触发方式: RDB 是“一段时间触发一次”,可以更具体地说明其触发方式,主要有:
save命令: 同步阻塞式保存,会阻塞主线程,生产环境禁用。bgsave命令: 异步非阻塞式保存,Redis 会fork一个子进程来执行快照,这是我们手动执行或配置自动执行的主要方式。配置文件自动触发: 比如
save 900 1(900秒内有1次写入)、save 300 10(300秒内有10次写入)等。“RDB-AOF 模式”,这个概念是对的,它叫混合持久化 (Mixed Persistence)。但它的工作方式可以描述得更清晰:当触发 AOF 重写时,Redis 不再简单地写入指令,而是将重写那一刻的内存数据,以 RDB 的格式写入到新的 AOF 文件的开头,然后再将重写期间产生的增量命令,以 AOF 格式追加到文件末尾。这样做的好处是,重启恢复时,可以先像 RDB 一样快速加载内存快照,然后再重放增量命令,兼顾了 RDB 的恢复速度和 AOF 的数据完整性。
8.Redis底层数据结构
ziplist:
它不是一个真正的列表,而是一块连续的内存区域。这块内存中,将多个数据项(entry)紧凑地排列在一起,从而极大地节省内存。每个entry包含三个部分:previous_entry_length(前一个节点的长度)、encoding(当前节点内容的编码方式和长度)、content(实际内容)。
- 极致的内存效率:由于是连续内存,没有指针开销,内存利用率极高。
- 但是有连锁更新的问题,由于每个节点都记录了前一个节点的长度,当我们在一个
ziplist的中间插入或删除了一个元素,如果这个元素的大小发生了变化(比如从一个小整数变成一个长字符串),就可能导致其后所有节点的previous_entry_length字段都需要被级联修改。
listpack:
与ziplist类似,也是一块连续的内存区域,用于紧凑地存储数据项。listpack的每个entry不再记录前一个节点的长度。取而代之的是,它记录了当前节点的总长度(encoding字段中包含了长度信息)。当需要从后向前遍历时,它会先读取当前节点的前一个节点的尾部,那里记录了那个节点的总长度,然后再跳到那个节点的起始位置。
依然是连续内存,内存利用率很高,但解决了连锁更新的问题。成为小数据量Hash和Zset的底层实现。
skiplist:
Zset(有序集合)需要一种既能高效查找又能高效增删的数据结构。平衡树(如红黑树)实现复杂,而skiplist是一种概率性的、实现相对简单且性能媲美平衡树的数据结构。
从最高层的链表开始,向右查找,直到找到一个大于等于目标值的节点的前驱。然后从这个前驱节点下降一层,继续向右查找。重复此过程,直到到达最底层的链表,最终找到目标元素。
底层是链表,可以方便地进行范围遍历。增删改查效率都是O(log N)。
9.渐进式hash
“渐进式哈希”(Incremental Hashing),也常被称为“渐进式 Rehash”,它是一种优化哈希表(Hash Table)在扩容或缩容时数据迁移过程的技术。
它解决了传统哈希表在扩容时,因需要一次性迁移所有数据而导致的“服务阻塞”或“卡顿”(Stop-the-World)问题。
渐进式哈希巧妙地将这个集中的、一次性的迁移任务分摊到多次操作中去完成。Redis的Rehash机制是渐进式哈希最经典的实现:
- 准备阶段: 当触发扩容时,Redis会为字典(dict)分配一个新的、更大的哈希表(内部称为
ht[1]),而旧的哈希表(ht[0])仍然保留。此时,字典同时持有新旧两个哈希表。 - 迁移阶段(“渐进式”的体现): 数据迁移不是一次性完成的,而是通过两种方式分批、逐步进行:
- 被动迁移 (Passive Rehash): 在Rehash期间,每当有客户端对字典进行增、删、改、查操作时,除了完成指定的操作外,Redis还会顺带将被操作的键所在的整个哈希桶(bucket)中的所有键值对,从旧表(
ht[0])迁移到新表(ht[1])。这相当于把迁移的成本分摊到了每一次客户端请求中。 - 主动迁移 (Active Rehash): 为了防止字典在长期没有被访问的情况下Rehash过程一直无法完成,Redis有一个后台定时任务(每秒执行10次)。这个任务会主动地、每次只花费1毫秒的时间,从旧表(
ht[0])中迁移一部分数据到新表(ht[1]),确保即使在空闲时期,Rehash过程也能稳步推进。
- 被动迁移 (Passive Rehash): 在Rehash期间,每当有客户端对字典进行增、删、改、查操作时,除了完成指定的操作外,Redis还会顺带将被操作的键所在的整个哈希桶(bucket)中的所有键值对,从旧表(
- 服务期间的访问: 在整个Rehash过程中,字典的读写操作会同时兼顾新旧两个哈希表:
- 查询/删除/更新: 会先在旧表(
ht[0])中查找,如果找不到,再去新表(ht[1])中查找。 - 新增: 只会添加到新表(
ht[1])中。这保证了旧表的数据只会减少,不会增加,最终一定能迁移完毕。
- 查询/删除/更新: 会先在旧表(
- 完成阶段: 当旧表(
ht[0])中的所有数据都迁移到新表(ht[1])后,Rehash过程结束。此时会释放旧表的内存,并将新表设置为字典的默认哈希表(ht[0] = ht[1]),为下一次Rehash做准备。
渐进式哈希通过将庞大的数据迁移任务“化整为零”,均摊到每一次的日常操作和后台的少量定时任务中,从而避免了集中的、长时间的计算。它以一种平滑、对用户几乎无感知的方式完成了哈希表的扩容,极大地保证了系统(如Redis)的高可用性和响应速度。
MQ
1.消息队列(MQ)消息积压处理
当被问及线上Topic消息积压如何处理时,你的第一反应是“清空队列,然后恢复”,这在线上环境中是绝对禁止的操作。在引导下,你提到了扩容消费者。
方案1 紧急扩容消费者并监控下游依赖
- 监控分析:在扩容前,必须先快速查看消费者应用的CPU、内存、GC情况,以及其下游依赖(如数据库、外部API)的负载情况。确认瓶颈在于消费者本身,而不是下游。
- 水平扩容:如果瓶颈在消费者,立即增加消费者实例数量。在Kubernetes等云原生环境中,可以通过调整Deployment的replica数量快速实现。
- 注意Partition数量:确保消费者实例数不超过Topic的Partition数量,因为多余的消费者将处于空闲状态。
方案2 消息转储与异步回补
- 编写转储程序:快速开发一个简单的程序,它的唯一作用就是消费积压Topic中的消息,然后原封不动地存储到另一个临时Topic或一个临时存储(如文件、数据库)中。
- 启动转储:启动该程序,快速将积压消息“搬空”。
- 修复与回补:在修复了原始消费者的Bug或性能问题后,再编写一个回补程序,以一个受控的速率,从临时Topic或存储中读取消息,重新发送回原始Topic进行处理。
以空间换时间,快速恢复线上新消息的处理能力,为修复问题和处理积压数据赢得时间。
方案3 优化消费逻辑并临时提升处理能力
- 代码审查:快速排查消费逻辑,寻找性能瓶颈。常见的优化点包括:
- 将单条处理改为批量处理。
- 将同步调用外部API改为异步并行调用。
- 优化SQL查询,减少不必要的数据库交互。
- 紧急上线:快速修复并上线优化后的代码。
比如说:
你提到扩容消费者来解决积压。假设现在是双十一零点,流量洪峰导致了严重积压,而下游的数据库集群负载也已经很高了。此时你作为负责人,应该如何决策?直接扩容消费者吗?
面试官,这是一个非常经典的‘雪崩前兆’场景,决策的核心是‘止损和降级’,而不是盲目地增加压力。我的决策流程会是这样的
- 立即止损,保护核心系统,绝对不能直接扩容消费者! 因为监控显示下游数据库已经高负载,扩容消费者只会变成压垮数据库的最后一根稻草,导致核心系统崩溃,造成更大的故障。 立即对消费者进行限流甚至暂停*。我会立即调整消费者的消费速率,甚至在极端情况下,通过配置中心或运维指令,暂停非核心业务的消费,优先保住数据库的稳定。
- 业务降级,保障核心链路 我会立即与产品和业务方沟通,启动业务降级预案。例如: 关闭非核心功能:暂时关闭‘实时用户积分更新’、‘推荐商品刷新’等非核心功能的消费,将MQ资源和数据库资源全部让给核心交易链路(如下单、支付)。 *异步转同步:对于某些可以接受延迟的业务,可以暂时将消息积-压在MQ中,等高峰期过后,系统负载降低了再慢慢处理。
- 流量削峰与后续处理 利用MQ的积压能力:此时,MQ本身就扮演了一个天然的流量削峰器的角色。大量的请求被积压在队列中,而不是直接冲击后端系统,这正是我们使用MQ的一个重要原因。 高峰后恢复:等到流量洪峰过去,数据库负载下降后,我们再逐步、分批地恢复被暂停的消费者,并可以适当地增加消费者实例,以一个受控的速率,慢慢地将积压的消息消费完毕。
- 复盘与改进 * 事后,我们会进行深入复盘。分析是数据库容量预估不足,还是SQL存在性能问题,或者是消费者逻辑有待优化。并根据分析结果,进行数据库扩容、SQL优化、或引入更精细化的流量控制策略,为下一次大促做好准备。
我的核心决策原则是:牺牲非核心业务的实时性,来换取核心系统的稳定性和可用性。
2.消费者组的对应
你刚刚说的就是一个消费者端,然后去对应一个相当于一个partition,然后为什么要一一对应呢?
核心原因:保证分区内的消息顺序性(Message Ordering Guarantee)
‘一个Partition在同一个消费者组内,同一时间只能被一个Consumer消费
- 理论依据:Kafka只在单个Partition内部保证消息的有序性。也就是说,生产者以1, 2, 3的顺序发送到同一个Partition的消息,消费者也必须以1, 2, 3的顺序来消费它们。
- 机制实现:为了实现这个保证,Kafka必须规定,一个Partition在任意时刻,只能被一个消费者实例“锁定”并消费。如果允许多个消费者同时消费同一个Partition,那么消息的消费顺序将无法得到保证,因为无法协调哪个消费者先处理哪条消息,这将彻底破坏Kafka的顺序性承诺。
实现高并发:以Partition为并行处理的最小单元
- 理论依据:虽然单个Partition是顺序处理的,但Kafka通过将一个Topic划分为多个Partition来实F现整体的高并发。
- 机制实现:整个Topic的吞吐量等于所有Partition吞吐量的总和。我们可以通过增加Partition的数量,来水平扩展Topic的处理能力。
- 消费者协同:消费者组(Consumer Group)内的多个消费者实例会通过Rebalance(再均衡)\机制,自动协调分配它们各自负责消费的Partition。例如,一个有10个Partition的Topic,如果消费者组有10个消费者,理想情况下就是每个消费者负责一个Partition,此时*并行度达到最大*。
3.消息不丢失&&消息幂等
不丢失:
生产者端 -> Broker:如何确保消息成功发出并被Broker接收?
同步发送 + 有限次重试
- 我们会采用同步发送(Sync Send)的方式。这意味着,生产者线程在发送一条消息后,会阻塞等待,直到收到Broker返回的成功确认(ACK)。如果等待超时或收到错误响应,就证明发送失败
- 一旦发送失败,我们会配置一个有限次的重试机制(例如,重试3次,每次间隔1秒)。通过这种‘确认+重试’的闭环,可以极大地提高消息发送到Broker的成功率。
- RocketMQ的同步发送
send()方法本身就是阻塞等待Broker确认的。对于可靠性要求极高的场景,我们还会配合Broker端的同步刷盘策略,确保消息在持久化到磁盘后才返回ACK。 - 对于需要本地事务与消息发送保持原子性的场景(例如,下单成功后发送扣减库存消息),我们会使用RocketMQ独有的事务消息。它通过两阶段提交(发送Half消息 -> 执行本地事务 -> 提交/回滚Half消息)的机制,从根本上保证了本地操作成功,消息就一定能成功发送。
Broker端如何确保持久化,防止自身宕机导致消息丢失?
持久化刷盘 + 多副本冗余
- 同步刷盘(Sync Flush):这是最可靠的方式。Broker接收到消息后,必须将其写入磁盘文件,才向生产者返回ACK。即使Broker进程或服务器瞬间宕机,消息也不会丢失。
- 异步刷盘(Async Flush):Broker将消息写入操作系统的Page Cache后,就立即返回ACK,由操作系统异步地将数据刷到磁盘。性能最高,但如果服务器在刷盘前掉电,Page Cache中的数据会丢失。
- 我们会为每个Topic或Partition配置多个副本(通常是3个),分布在不同的物理机架上。消息会同时写入主副本(Leader)和备用副本(Follower)。当主副本宕机时,系统可以从备用副本中选举出新的主副本,继续提供服务,保证了数据的高可用和冗余。
- RocketMQ也支持Master-Slave的多副本架构,以及基于Raft协议的Dledger模式,都能实现类似的高可用保障。
Broker -> 消费者端 (Consumer):如何确保消息被消费者成功处理?
手动确认(ACK)/提交消费位点(Offset)
- 消费者从Broker拉取一批消息。
- 先执行我们自己的业务逻辑(例如,更新数据库、调用外部API等)。
- 当且仅当业务逻辑全部成功执行完毕后,我们才向Broker发送ACK,或者提交这批消息的Offset。
这样,如果消费者在处理业务的途中宕机,由于没有提交Offset,它重启后会从上一次已提交的Offset处重新拉取消息,保证了宕机期间正在处理的消息不会丢失。
在RocketMQ中,消费者的监听器MessageListener会返回一个消费状态。我们只有在业务处理成功后,才返回ConsumeConcurrentlyStatus.CONSUME_SUCCESS,RocketMQ才会认为消息消费成功并更新Offset。如果返回RECONSUME_LATER或抛出异常,消息会在稍后被重试。
幂等:
对于同一个业务操作,无论执行多少次,其产生的结果和影响都和执行一次是相同的。我们的实现方案是基于唯一ID + 状态判断
- 为消息赋予全局唯一ID: “我们要求生产者在发送每一条具有业务含义的消息时,都在消息体或Header中附带一个*全局唯一的业务ID。例如,支付成功的消息,就用‘支付流水号’;创建订单的消息,就用‘订单号’。”
- 消费者端实现幂等判断: “消费者在处理消息时,不会立即执行业务逻辑,而是会先根据这个唯一ID,去查询一个*持久化的存储(如Redis或数据库),来判断这个操作是否已经被执行过。
- 方案一:数据库唯一索引:对于插入操作,我们可以直接利用数据库的唯一键(Unique Key)\约束。例如,在处理‘用户注册’消息时,将用户名或手机号作为唯一索引。如果消息重复,尝试插入时会直接触发
DuplicateKeyException,我们捕获这个异常就知道是重复操作,直接ACK消息即可。 - 方案二:Redis
SETNX**:对于一些通用的操作,我们可以利用Redis的SETNX命令。将消息的唯一ID作为Key,尝试写入Redis。如果写入成功(返回1),说明是第一次处理,就执行业务逻辑,并在成功后保留这个Key(可以设置一个过期时间)。如果写入失败(返回0),说明这个ID已经被处理过,直接跳过并ACK。 - 方案三:状态机与版本号:对于更新操作,我们可以在业务表中引入状态字段或版本号。例如,处理订单状态流转的消息。消费者会先查询订单的当前状态,只有当订单状态符合前置条件时(例如,只有‘待支付’状态的订单才能被更新为‘已支付’),才执行更新。如果状态不匹配,说明已经被其他操作处理过,直接忽略。
4.RocketMQ半事务消息
- 第一阶段 (发送半消息): 生产者(订单服务)\先发送一条*半消息(Half Message)*到 Broker。这条消息对消费者是**不可见的。
- 执行本地事务: 生产者发送半消息成功后,立即开始执行自己的本地事务(比如创建订单并写入数据库)。
- 第二阶段 (提交/回滚):
- 如果本地事务执行成功,生产者就向 Broker 发送一个 Commit 命令,Broker 收到后,才将这条半消息对消费者可见。
- 如果本地事务执行失败,生产者就向 Broker 发送一个 Rollback 命令,Broker 就会删除这条半消息。
- 回查机制: 如果生产者在执行完本地事务后宕机,没能发送 Commit/Rollback,Broker 会定期地回调生产者的一个回查接口,询问:“我这里有一条半消息,你对应的本地事务到底成功了没有?” 生产者根据本地事务的状态,告诉 Broker 应该 Commit 还是 Rollback。
5.RocketMQ为什么吞吐量高?
我会从消息存储、读写机制和架构设计这三个核心维度来阐述RocketMQ的高吞吐量设计。
消息存储:
- 顺序写盘 ,我们通常认为磁盘I/O是慢的,但这是基于随机I/O的认知。磁盘的顺序I/O速度非常快,甚至可以媲美内存的随机读写。
RocketMQ将所有Topic的消息都存储在同一个名为CommitLog的物理文件中。当新的消息到达Broker时,它只是简单地在当前CommitLog文件的末尾追加写入 (append)。这个过程完全是顺序的,充分利用了操作系统的页缓存(Page Cache)和磁盘的预读能力,速度极快。避免了传统消息队列为每个Topic/Queue单独建立文件所带来的大量随机I/O开销,将消息写入的性能发挥到了极致。
- 内存映射,RocketMQ巧妙地利用了操作系统的内存映射文件(
mmap)机制。
- Broker会将
CommitLog文件直接映射到进程的虚拟内存地址空间。这样,对文件的读写操作,在代码层面看起来就像是直接操作内存数组一样,非常简单高效。 - 拷贝 (Zero-Copy): 数据的读写完全由操作系统内核在Page Cache和磁盘之间处理,避免了传统I/O中,数据在内核态和用户态之间来回复制的开销。
- 充分利用Page Cache: 读写操作会命中Page Cache,进一步提升性能。即使Broker进程宕机,只要操作系统没关机,Page Cache中的数据依然存在,重启后可以快速恢复。
- 分离的逻辑队列 ,消费者如何只消费自己关心的Topic呢?答案是
ConsumeQueue。
ConsumeQueue是一个逻辑队列,它不存储完整的消息数据。对于每个Topic的每个Message Queue,都有一个对应的ConsumeQueue文件。存储内容,ConsumeQueue
中只存储固定长度的条目,每个条目包含三部分信息:
- 消息在
CommitLog中的物理偏移量 (8字节) - 消息的总长度 (4字节)
- 消息Tag的哈希码 (8字节)
- 消息在
带来的好处:
- 轻量且高效:
ConsumeQueue文件非常小,并且大部分内容可以被轻松地加载到内存中。 - 随机读变顺序读: 消费者消费消息时,首先是顺序读取
ConsumeQueue(因为消费是按顺序进行的),这是一个高效的顺序I/O操作。然后,根据从ConsumeQueue中获取到的物理偏移量,再去CommitLog中进行一次随机读取,以获取完整的消息体。这个设计巧妙地将对消息的随机访问,转化为了对一个轻量级索引文件的顺序访问。
- 轻量且高效:
读写机制:
- 异步刷盘,RocketMQ提供了多种刷盘策略,默认采用异步刷盘。
消息写入Page Cache后,就立刻向生产者返回成功ACK。真正的刷盘操作由一个后台线程异步地、批量地完成。
- 读写分离,RocketMQ的架构天然支持读写分离。
- 主写从读: 在主从(Master-Slave)架构中,消息写入由Master节点负责,而消费可以由Slave节点来分担,从而分散读压力。
- 零拷贝读: 消费者拉取消息时,如果数据还在Page Cache中,可以直接通过
sendfile系统调用实现零拷贝,将数据从Page Cache直接发送到网卡,效率极高。
高扩展:
- Broker的可水平扩展,RocketMQ的Broker集群是无状态的(消息数据存储在文件中,不依赖Broker内存),可以轻松地进行水平扩展。当一个Broker集群的吞吐量达到瓶颈时,只需要简单地增加更多的Broker节点,并将Topic的队列(Message Queue)均匀地分布到新的节点上,就可以线性地提升整个集群的处理能力。
- NameServer:轻量级的路由中心,只负责Broker的动态注册与发现,以及提供路由信息(某个Topic的队列分布在哪些Broker上)。
- 无状态: NameServer之间互不通信,任何一台宕机都不会影响其他NameServer和整个集群。
- 近乎无限的水平扩展: 可以部署任意多台NameServer来提高可用性和查询性能。
- 低压力: 客户端和Broker只会定时向NameServer拉取和上报信息,压力非常小
6.消费者的推拉模型
我将从它们的定义、工作原理、优缺点对比以及主流框架(如RocketMQ和Kafka)的选择这几个方面来详细阐明。
推模型:由消息中间件(Broker)主动将消息推送给消费者。
- 消费者与Broker建立长连接。
- 消费者向Broker注册一个监听器(Listener)或回调函数。
- 当Broker上有新的消息到达时,Broker会主动调用这个注册好的监听器,将消息作为参数传递给消费者进行处理。
及时性高,消费者端处理简单,但是消费者容易被压垮,可能需要流量控制来处理
拉模型:由消费者主动向消息中间件(Broker)拉取消息。
- 消费者在一个循环中,主动调用
pull()或fetch()方法,向Broker发起拉取消息的请求。 - Broker收到请求后,返回一批(可能为空)消息给消费者。
- 消费者处理完这批消息后,再次发起拉取请求。
消费者掌握主动权,简化Broker设计,但是可能即使性降低了,可能会产生无意义的轮询
实际应用:
RocketMQ的
DefaultMQPushConsumer,底层是拉模型DefaultMQPushConsumer在内部启动了一个后台线程池。- 这些后台线程会不断地向Broker发起长轮询(Long Polling)的拉取请求。
- 长轮询是拉模型的一个重要优化:当消费者向Broker拉取消息时,如果队列中没有消息,Broker不会立即返回空结果,而是会hold住这个连接一段时间(比如30秒)。
- 在这段时间内,一旦有新消息到达,Broker会立刻将消息返回给消费者。如果超时了仍然没有消息,才返回一个空结果。
- 消费者的后台线程拿到消息后,会将其提交给另一个业务线程池,并异步调用用户注册的
MessageListener。
分布式
1.分布式事务设计
TCC、Saga、本地消息表、事务消息
本地消息表
这是一种实现“最终一致性”的常用方案,核心思想是将业务操作和发送消息这两个步骤,放在同一个本地事务里来保证原子性。
- 事务发起方:在执行核心业务逻辑时(例如:创建订单),会在同一个数据库事务中,向一张本地的“消息表”插入一条消息记录,这条记录的状态初始为“待发送”。
- 事务提交:当本地事务成功提交后,订单数据和“待发送”的消息记录会同时落库。
- 消息投递:我会用一个独立的、可靠的后台任务(比如使用定时任务调度框架如XXL-Job)去轮询这张消息表,把所有“待发送”状态的消息发送到消息队列(MQ)中。
- 状态确认:消息成功投递到MQ后,后台任务会更新消息表中的记录状态为“已发送”或直接删除。如果投递失败,它会进行重试。
- 事务消费方:下游服务消费MQ中的消息,并执行相应的业务逻辑。为了防止重复消费,消费方必须保证接口的幂等性。
消息的发送不是实时的,存在一定的延迟。需要额外维护一个后台任务。
事务消息
- 第一阶段 (Prepare Message):生产者先向MQ Server发送一条“半消息”或“预备消息”。这条消息对消费者是不可见的。
- 第二阶段 (执行本地事务):发送“半消息”成功后,生产者开始执行本地的数据库事务。
- 第三阶段 (Commit/Rollback):
- 如果本地事务成功,生产者会向MQ Server发送一个
Commit指令,MQ Server收到后会将之前的“半消息”标记为可投递,消费者此时才能消费到。 - 如果本地事务失败,生产者会发送一个
Rollback指令,MQ Server会删除这条“半消息”。
- 如果本地事务成功,生产者会向MQ Server发送一个
- 超时回调检查:如果生产者在执行完本地事务后宕机,没有发送
Commit或Rollback指令,MQ Server会在超时后,主动回调生产者应用提供的一个接口,来查询该事务的最终状态,并根据查询结果来决定是Commit还是Rollback。
需要消息中间件本身支持事务消息这个特性
TCC (Try-Confirm-Cancel)
- Try阶段:这是准备阶段。对各个服务的资源进行检查和预留。比如,库存服务
Try阶段就是冻结指定数量的库存,而不是直接扣减。 - Confirm阶段:如果所有服务的
Try阶段都成功,协调器就会调用所有服务的Confirm方法,执行真正的业务逻辑。比如,库存服务Confirm阶段就是将之前冻结的库存进行扣减。 - Cancel阶段:如果任何一个服务的
Try阶段失败,协调器会调用所有已经执行过Try成功的服务的Cancel方法,释放预留的资源。比如,库存服务Cancel阶段就是解冻之前冻结的库存。
- 优点:性能较高,因为它不像2PC那样在整个事务过程中都持有锁。能够实现数据的强一致性。
- 缺点:对业务代码的侵入性非常强,开发成本高,每个业务操作都需要实现
Try-Confirm-Cancel三个接口,并且要保证它们的幂等性。
Saga
Saga是一种长事务解决方案,核心思想是将一个大的分布式事务拆分成一系列的本地事务,由Saga事务协调器来协调。如果某个步骤失败,则会调用前面已执行步骤的补偿操作。
- 一个Saga由一系列子事务
T1, T2, ..., Tn组成。 - 每个子事务
Ti都有一个对应的补偿事务Ci。 执行顺序是
T1, T2, ..., Tn。如果其中任意一个Ti失败,则会按逆序执行补偿事务C(i-1), ..., C2, C1。优点:适用于长流程、业务复杂的场景,一阶段提交,没有锁,系统吞吐量高。
- 缺点:不保证事务的隔离性,因为在补偿发生前,其他事务可能已经看到了
T1,T2等操作产生的不一致的中间状态。
设计模式
1.当被问及如何在多个接口中统一管理以避免代码重复时
你的初步想法是提取一个公共方法。面试官进一步引导你思考过滤器和拦截器。
方案1:使用Spring MVC的HandlerInterceptor(拦截器)
HandlerInterceptor是Spring MVC提供的AOP实现,专门用于在Controller方法执行前后进行预处理和后处理。它与请求生命周期紧密耦合,是处理用户认证、日志记录、上下文设置等横切关注点的标准方式。
- 创建一个类实现
HandlerInterceptor接口。 - 在
preHandle方法中,从请求(如Header)中获取Token,解析出用户信息,然后调用工具类的set()方法将用户信息存入ThreadLocal。 - 在
afterCompletion方法中,无论Controller方法执行成功还是失败,都调用工具类的remove()方法清理ThreadLocal,通常放在finally块中以确保执行。 - 创建一个配置类实现
WebMvcConfigurer,重写addInterceptors方法,将你的拦截器注册到Spring容器中,并配置其拦截路径(如/api/**)。
方案2:使用Servlet的Filter(过滤器)
- 创建一个类实现
javax.servlet.Filter接口。 - 在
doFilter方法中,在调用chain.doFilter(request, response)之前,执行ThreadLocal的set()操作。 - 使用
try...finally结构,在finally块中执行ThreadLocal的remove()操作,确保无论后续处理是否异常,都能清理资源。 - 使用
@Component和@Order注解(或通过FilterRegistrationBean)将Filter注册为Spring Bean。
与Interceptor类似,实现了解耦和统一管理。由于作用范围更广,可以拦截静态资源等非Spring MVC处理的请求。
方案3:使用自定义AOP切面(@Aspect)
- 创建一个类,并使用
@Aspect和@Component注解。 - 定义一个切点(Pointcut),例如
@Pointcut("within(@org.springframework.web.bind.annotation.RestController *)"),用于匹配所有RestController类中的方法。 - 创建一个
@Around环绕通知。在通知方法的try块中,执行ThreadLocal的set()操作,然后调用proceedingJoinPoint.proceed()执行目标方法。在finally块中,执行remove()操作。
功能上与前两者类似,但提供了最大的灵活性,可以切入到Service层甚至任意Bean的方法。@Around通知需要手动调用proceed(),如果忘记调用,目标方法将不会被执行。
2.策略方法怎么去解决具体调用哪一个策略
- 为了避免在业务代码中使用大量的
if-else或switch来选择策略,我们创建了一个策略工厂(Strategy Factory)。
- 在项目启动时,Spring容器会扫描并加载所有
CouponStrategy的实现类。 - 我们创建一个
CouponStrategyFactory类,它在构造时注入一个Map<String, CouponStrategy>。Spring会自动将所有策略实现类注入到这个Map中,其中Key是Bean的名称(例如"fullDiscountStrategy"),Value是Bean实例。 - 我们约定优惠券类型(例如
"FULL_DISCOUNT","PERCENTAGE_DISCOUNT")与Bean名称有映射关系。 - 工厂类提供一个
getStrategy(String couponType)方法。当业务代码需要使用某个策略时,它只需要传入优惠券类型字符串,工厂就会从Map中返回对应的策略对象。 - 我们完全消除了业务代码中的
if-else判断。当未来需要增加一种新的优惠券时,我们只需要新增一个策略实现类,而不需要修改任何现有的业务逻辑代码,这完全符合开闭原则,使得系 - 统非常易于扩展和维护。”
3.100个有序文件,如何拼接保证整体有序?
我们有100个已经内部有序的数据源(文件),需要将它们合并成一个单一的、全局有序的输出。这正是归并排序中“归并(Merge)”这一步的经典应用。由于文件可能很大,无法一次性全部读入内存,所以这是一个外部排序问题。
我们可以使用最小堆来解决
- 创建一个大小为100的最小堆。
- 为100个文件,每个文件都打开一个文件读取流(Reader)。
- 从每个文件中读取第一个数字,并将这个数字连同它所属的文件源信息(例如,文件索引)一起,封装成一个对象(如
Node(value, fileIndex)),放入最小堆中。此时,堆中有100个元素。 - 循环执行以下操作,直到堆为空: a. 取出最小元素:从最小堆的堆顶取出一个
Node。这个Node的value就是当前全局最小的数字。 b. 写入输出文件:将这个value写入到最终的输出文件中。 c. 补充新元素:根据取出的Node中的fileIndex,我们知道这个数字来自哪个文件。我们立即从那个文件中读取下一个数字。 d. 处理文件结束:如果那个文件已经读完,则什么也不做。如果还能读到新数字,就将这个新数字和它的fileIndex再次封装成一个新的Node,插入到最小堆中。 - 当最小堆为空时,意味着所有文件都已被读取完毕,输出文件也就包含了所有数字,并且是全局有序的。
这个问题本质上是一个典型的多路归并排序问题,特别是在处理无法完全加载到内存的大文件时,属于外部排序的范畴
4.设计一个高并发的系统
面试官您好,设计一个高并发秒杀系统,核心挑战在于如何在瞬时巨大流量下,保证库存扣减的绝对正确性和系统的整体高可用。我的设计方案将围绕“层层过滤、异步处理、最终一致”的核心思想展开,严格遵循题目要求的几个方面进行阐述。
整体架构:
首先我会将整个秒杀流程进行动静分离和垂直分层,构建一个清晰的数据流。
- 前端层:商品详情页静态化,通过CDN分发,降低服务器压力。秒杀按钮在倒计时结束前置灰,并通过定时器从服务端获取最新时间,防止客户端时间不准导致提前请求。
- 接入层:
- Nginx/网关:负责反向代理、初步限流、过滤恶意请求。
- 秒杀服务(独立部署):这是核心业务逻辑所在,与普通商品服务物理隔离,避免秒杀流量冲垮主站。
- 数据处理流:
- 用户请求首先到达Nginx/网管。
- 通过限流后,请求进入秒杀服务。
- 秒杀服务在Redis中完成资格校验和库存预扣减。
- 预扣减成功后,立即向用户返回“排队中”或“抢购成功”的提示,并将订单信息异步发送到RocketMQ。
- 订单服务作为消费者,从MQ拉取消息,进行数据库层面的订单创建和库存真实扣减。
- 后续的支付、履约流程由订单服务驱动。
数据模型:
在Mysql中会有一个promo_stock (秒杀库存表),promo_id (秒杀活动ID, 索引),item_id (商品ID, 索引),version (int, 乐观锁版本号)
Redis换成设计:
promo:stock:{promo_id}(String): 存储秒杀活动的总库存数量。用于快速判断库存是否售罄。promo:soldout:{promo_id}(String/Bitmap): 一个售罄标记。一旦库存为0,设置此标记,后续请求可以直接在接入层拦截,无需再访问Redis。promo:user:history:{promo_id}(Set/HyperLogLog): 存储已成功抢购的userId,用于防止用户重复下单。
数据一致性:
在秒杀场景下,我们采用‘缓存预扣减,数据库异步更新’的策略,追求的是最终一致性
- 库存预热:秒杀活动开始前,通过定时任务将MySQL中的库存数量加载到Redis的
promo:stock:{promo_id}中。 - 缓存预扣减:用户的抢购请求直接在Redis中通过
DECR原子操作进行库存扣减。 - 异步更新数据库:Redis扣减成功后,将订单信息发送到MQ。订单服务消费消息后,再对MySQL中的
stock_count进行UPDATE ... SET stock_count = stock_count - 1操作。 - 数据不一致的风险与兜底:
- 风险:如果消息丢失或订单服务消费失败,会导致Redis库存减少,而MySQL库存未变。
- 兜底:我们会有一个定时对账任务,定期(如每5分钟)比对Redis中的已售数量和MySQL中的已创建订单数量,如果不一致,则进行修复或告警。
限流和短融:
限流是保护系统的第一道防线,必须在多层级部署
- 前端层限流:通过JS控制,用户在点击秒杀按钮后,按钮会置灰一段时间,防止用户疯狂点击,造成不必要的请求。
- Nginx/网关层限流:
limit_req_zone:基于漏桶算法,对用户的IP或UID进行请求速率限制,例如,限制单个用户每秒只能请求1次。limit_conn_zone:限制单个IP的最大连接数,防止恶意攻击。
- 业务服务层限流:
- 使用Sentinel或Guava RateLimiter,对秒杀接口本身进行QPS限制。这个值应该根据压测结果设定,略高于系统的最大处理能力,作为最后的保险丝。
- 熔断:
- 同样使用Sentinel,我们会对秒杀服务依赖的下游服务(如订单服务、用户服务)的调用进行熔断配置。
- 策略:当在指定时间窗口内,对订单服务的调用错误率或平均响应时间超过阈值时,熔断器会打开。在接下来的一个时间窗口内,所有对订单服务的调用都会被直接拒绝,并快速失败(返回“系统繁忙”),避免因下游故障导致的秒杀服务线程池耗尽和雪崩。
热点和超卖的数据处理:
这是秒杀系统的核心,我采用了‘Redis原子操作 + 分布式锁 + 数据库乐观锁’的三重保障来彻底杜绝超卖。
- 热点数据处理:
- 库存预热:已在一致性策略中提及,将MySQL的热点库存数据提前加载到Redis中,所有读写操作都在Redis完成,避免直接冲击数据库。
- 防超卖机制(核心流程):
- 第一重防护:Redis原子操作:
- 在用户请求到达时,首先检查Redis中的售罄标记
promo:soldout:{promo_id}。如果存在,直接返回“已售罄”。 - 然后,使用
DECR promo:stock:{promo_id}进行库存预扣减。这是一个原子操作,天然地避免了多线程下的并发问题。如果DECR后的返回值小于0,说明库存已不足,我们将库存INCR加回去,并返回“已售罄”。
- 在用户请求到达时,首先检查Redis中的售罄标记
- 第二重防护:分布式锁(可选,用于更复杂逻辑):
- 如果扣减库存的逻辑不仅仅是
DECR,还包含了用户资格校验(如检查是否重复购买),那么“校验+扣减”这两个操作就不是原子的。 - 此时,我们会使用Redisson分布式锁 + Lua脚本。将“检查用户是否在
promo:user:history集合中”和“DECR库存”这两个逻辑封装在一个Lua脚本中,然后在获取到分布式锁后,原子化地执行这个脚本。
- 如果扣减库存的逻辑不仅仅是
- 第三重防护(最终兜底):数据库乐观锁:
- 订单服务在消费MQ消息,准备真实扣减MySQL库存时,会使用乐观锁。
- SQL语句为:
UPDATE promo_stock SET stock_count = stock_count - 1, version = version + 1 WHERE promo_id = ? AND stock_count > 0 AND version = ?。 - 如果这条SQL执行后返回的影响行数为0,说明在并发情况下,库存已被其他事务修改(
stock_count变为0或version不匹配)。此时,我们会认为这是一个无效的订单,进行记录并丢弃,不会创建订单。这确保了数据库层面的最终正确性。
- 第一重防护:Redis原子操作:
异步队列+补偿处理:
- 异步队列(RocketMQ)的作用:
- 流量削峰:秒杀的瞬时流量是巨大的,但后端数据库的处理能力是有限的。MQ像一个蓄水池,将瞬时的写请求缓冲起来,让下游的订单服务可以按照自己的节奏平稳地进行消费,保护了数据库。
- 业务解耦:秒杀服务只负责最核心的库存预扣减,成功后即可返回。创建订单、发送通知等非核心、耗时的操作被解耦到下游服务,大大降低了秒杀接口的响应时间。
- 补偿机制:
- 消息可靠性:我们会使用RocketMQ的事务消息或生产者发送确认+重试机制,确保库存预扣减成功的消息一定能被发送到MQ。
- 消费失败处理:如果订单服务消费消息失败(例如,数据库暂时不可用),我们会让消息进入重试队列。
- 死信队列(DLQ):如果经过多次重试后仍然失败,消息会被投递到死信队列。我们会有一个专门的后台任务或告警系统来监控死信队列,一旦有消息进入,就立即通知开发人员进行人工介入和补偿。
压测:
- 工具:使用JMeter或nGrinder等分布式压测工具。
- 压测目标:模拟秒杀开始瞬间,在极短时间内(如1秒内)发起远超系统处理能力的并发请求(例如,模拟10万用户同时抢购1000件商品)。
- 监控指标:
- 业务指标:下单成功率、最终创建的订单数是否与库存数严格相等(验证正确性)。
- 性能指标:系统的QPS/TPS、接口的平均响应时间和99%分位线。
- 资源指标:压测过程中,密切监控所有组件(Nginx, Redis, 秒杀服务, 数据库)的CPU、内存、网络、磁盘I/O等资源使用率。
- 瓶颈定位:通过观察各个环节的监控指标,找出最先达到瓶颈的组件,然后针对性地进行优化(例如,升级Redis集群、优化SQL、增加秒杀服务实例等),再进行下一轮压测,如此循环,直到系统达到预期的性能目标。
5.模板方法的回答
模板方法模式定义了一个操作中的算法骨架,而将一些可变的步骤延迟到子类中去实现。
在一个抽象的父类中,会有一个 final 的模板方法,它定义了整个流程的执行顺序。这个模板方法会调用一系列的抽象方法(由子类实现)和具体方法(父类实现)。
优点是复用了算法的公共部分,并将变化的部分进行隔离。比如,AbstractList 中的 addAll 方法就是一个模板方法,它定义了批量添加的流程,而具体的 add(index, element) 则由子类 ArrayList 或 LinkedList 去实现。
6.30分钟自动关闭
- 下单时: 用户下单成功后,除了创建订单,我们还会向 RocketMQ 发送一条延时等级为 30 分钟的延时消息,消息内容包含订单号。
- 消费者: 我们有一个专门的消费者来消费这些延时消息。
- 30分钟后: Broker 会将这条消息投递给消费者。
- 处理逻辑:消费者收到消息后,会根据订单号去查询数据库中该订单的支付状态。
- 如果订单状态仍是“未支付”,则执行关单操作。
- 如果订单状态已经是“已支付”,则直接忽略这条消息。
7.如何设计全局统一异常处理
全局统一异常处理是Spring Boot项目中用于解耦业务代码和异常处理逻辑、并向前端提供统一响应格式的重要机制。它的实现主要依赖两个核心注解:
@RestControllerAdvice: 我会创建一个类,并使用这个注解。它是一个组合注解,相当于@ControllerAdvice+@ResponseBody,表示这个类是一个全局的AOP切面,用于增强所有被@RestController注解的控制器,并会将方法的返回值序列化为JSON。@ExceptionHandler: 在这个类里面,我会定义多个方法,每个方法使用1
@ExceptionHandler
注解并指定它能处理的异常类型。例如:
- 一个方法处理自定义的业务异常,如
@ExceptionHandler(BusinessException.class)。 - 一个方法处理参数校验异常,如
@ExceptionHandler(MethodArgumentNotValidException.class)。 - 一个兜底的方法处理所有其他未被捕获的异常,如
@ExceptionHandler(Exception.class)。
- 一个方法处理自定义的业务异常,如
在这些方法内部,我会构建一个统一的响应对象(例如ApiResult),包含状态码、错误信息等,然后通过ResponseEntity包装后返回。这样做的好处是,业务代码中只需要专注于业务逻辑,当发生错误时直接throw new BusinessException(...)即可,异常的捕获和格式化响应都由这个全局处理器统一完成,代码非常清晰和易于维护。
8.如何设计一个订单表
主表:
| 字段名 (Column) | 数据类型 (Type) | 约束/备注 (Constraint/Note) | 设计目的 |
|---|---|---|---|
id |
BIGINT UNSIGNED |
PRIMARY KEY, AUTO_INCREMENT |
唯一标识:作为主键,保证每条订单的唯一性。使用BIGINT以应对海量订单。 |
order_no |
VARCHAR(64) |
UNIQUE, NOT NULL |
业务订单号:给用户和客服看的订单号,通常包含日期、随机数等信息,具有业务含义,且必须唯一。 |
user_id |
BIGINT UNSIGNED |
NOT NULL, INDEX |
关联用户:外键关联到用户表,标识下单用户。加索引以加速按用户查询订单。 |
total_amount |
DECIMAL(10, 2) |
NOT NULL |
订单总金额:商品总价。使用DECIMAL避免浮点数精度问题。 |
discount_amount |
DECIMAL(10, 2) |
DEFAULT 0.00 |
优惠金额:记录使用的优惠券、活动折扣等总金额。 |
pay_amount |
DECIMAL(10, 2) |
NOT NULL |
实际支付金额:total_amount - discount_amount,冗余存储以方便查询和对账。 |
payment_method |
TINYINT |
NOT NULL |
支付方式:例如 1-微信支付, 2-支付宝, 3-银行卡。使用数字代码节省空间,并便于扩展。 |
order_status |
TINYINT |
NOT NULL, INDEX |
订单状态:核心字段。例如 10-待支付, 20-已支付, 30-已发货, 40-已完成, 50-已取消, 60-退款中。状态流转是订单系统的关键,加索引以加速按状态查询。 |
receiver_name |
VARCHAR(50) |
NOT NULL |
收货人姓名:收货信息。 |
receiver_phone |
VARCHAR(20) |
NOT NULL |
收货人电话。 |
receiver_address |
VARCHAR(255) |
NOT NULL |
收货人地址:省市区详细地址。 |
remark |
VARCHAR(255) |
用户备注:用户下单时填写的备注信息。 | |
create_time |
DATETIME |
NOT NULL |
下单时间:记录订单创建时间,用于统计和超时未支付等场景。 |
payment_time |
DATETIME |
支付时间:用户支付成功的时间。 | |
shipping_time |
DATETIME |
发货时间。 | |
finish_time |
DATETIME |
完成时间:用户确认收货或系统自动确认的时间。 | |
is_deleted |
TINYINT(1) |
NOT NULL, DEFAULT 0 |
逻辑删除:用于软删除,保护数据。0-未删除, 1-已删除。 |
- 地址信息冗余:为什么不直接关联用户地址表?因为用户的默认地址是会变的。订单生成时必须“快照”当前的收货地址,保证交易契约的有效性,即使之后用户修改了地址,也不影响这笔订单。
- 金额字段冗余:
pay_amount可以通过计算得出,但冗余存储可以简化查询,避免每次都做计算,尤其是在报表统计时性能更好。
关联表:
一个订单通常包含多个商品,所以需要将订单与商品的关系拆分出来。
| 字段名 (Column) | 数据类型 (Type) | 约束/备注 (Constraint/Note) | 设计目的 |
|---|---|---|---|
id |
BIGINT UNSIGNED |
PRIMARY KEY, AUTO_INCREMENT |
唯一标识。 |
order_no |
VARCHAR(64) |
NOT NULL, INDEX |
关联订单:关联到订单主表的order_no,加索引以加速查询。 |
product_id |
BIGINT UNSIGNED |
NOT NULL |
关联商品:关联到商品表的ID。 |
product_name |
VARCHAR(100) |
NOT NULL |
商品名称快照:冗余存储商品名称,防止商品信息变更影响历史订单。 |
product_image |
VARCHAR(255) |
商品图片快照:同上。 | |
product_price |
DECIMAL(10, 2) |
NOT NULL |
下单时单价快照:记录下单时的商品价格,防止价格变动。 |
quantity |
INT |
NOT NULL |
购买数量。 |
total_price |
DECIMAL(10, 2) |
NOT NULL |
商品总价:product_price * quantity,冗余存储方便计算。 |
- 商品信息快照:这是
order_item表设计的核心思想。商品的名称、价格、图片等信息都可能在后台被运营人员修改。为了保证订单的历史记录是准确的,必须在下单的瞬间将这些信息冗余存储到订单商品表中,形成交易快照。
可扩展点:
- 订单支付流水表 (
order_payment): 如果支持分期支付或多种支付方式组合支付,需要一个单独的表来记录每一笔支付流水,关联到订单号。 - 订单物流表 (
order_shipping): 记录订单的物流信息,包括物流公司、运单号、物流状态等。一个订单可能分多个包裹发出,所以order_shipping和orders可以是一对多的关系。 - 订单状态流转日志表 (
order_status_log): 记录每一次订单状态的变更(谁、在什么时间、从什么状态变成了什么状态),用于问题排查和数据分析。
性能优化:
- 索引:在
user_id,order_no,order_status等频繁用于查询条件的字段上建立索引。 - 分库分表:当订单量达到千万甚至亿级别时,单表性能会急剧下降。需要考虑垂直拆分(将订单信息、物流信息等拆到不同库)和水平拆分(按
user_id或时间进行分片)来分散压力。 - 冷热数据分离:对于已完成或已取消超过一定时间(如一年)的历史订单,可以将其归档到历史订单表中,保持主表的“瘦身”,提升查询性能。
9.一大批量用户或者订单变多,如何保证服务器不爆炸
要保证服务器在瞬时大批量请求下不“爆炸”,绝不能依赖单一的技术,而必须构建一个多层次、纵深化的防御体系。
我会从“流量进来前”、“流量进来时”和“流量进来后”这三个阶段,来系统性地阐述我的设计思路。
进来之前:
这个阶段的核心是预测和分流,在流量到达我们的核心业务服务器之前,就对其进行过滤和疏导。
CDN (内容分发网络):
- 作用: 将网站的静态资源(如图片、CSS、JS文件)分发到离用户最近的边缘节点。
- 效果: 大部分静态资源请求由CDN直接处理,不会到达我们的源站服务器。这能过滤掉至少70%以上的流量,是保护服务器的第一道、也是最有效的防线。
浏览器端/客户端优化:
前端限流: 在秒杀、抢购等场景,可以在前端按钮上设置一个短暂的“冷却时间”(disable状态),防止用户因手抖或焦虑而在一秒内发起多次无效请求,从源头上减少请求量。
验证码:增加人机验证,有效拦截恶意脚本和机器人发起的瞬时批量请求。
流量进来:
接入层防御 (Nginx/API Gateway),
- 负载均衡 (Load Balancing): 这是必须的。使用Nginx、F5等设备,将流量均匀地分发到后端的多个无状态应用服务器上,避免单点过载。
- 接入层限流: 这是保护后端的第一道硬性关卡。我们可以使用Nginx的
limit_req_module模块,基于IP或用户ID等维度,设置一个请求速率阈值(如单个IP每秒最多5次请求)。超过阈值的请求,可以直接返回503 Service Unavailable错误,或者放入漏桶/令牌桶中平滑处理。这能有效拦截恶意的DDoS攻击或接口滥用。
应用层优化,
动静分离: 将动态业务逻辑(需要查询数据库、计算)和静态数据(如商品详情页)彻底分开。静态数据可以提前预热到CDN或分布式缓存(如Redis)中,应用服务器只需提供动态接口。
缓存大法 (Cache is King):
这是应对读请求洪峰的“银弹”。
- 多级缓存: 构建“CDN缓存 -> Nginx本地缓存 -> 分布式缓存(Redis) -> 数据库”的多级缓存体系。力求95%以上的读请求都能在缓存层命中并返回,最大限度地减少对数据库的访问。
- 缓存预热: 对于可预见的活动,提前将热点数据(如秒杀商品信息)加载到Redis中,避免活动开始瞬间大量请求穿透缓存导致“缓存雪崩”。
异步削峰,将同步的写操作,变为异步的消息投递。
- 应用服务器在接收到创建订单的请求后,不直接去操作数据库。
- 而是快速地进行一些基本校验,然后将这个请求封装成一个消息,丢到消息队列(如RocketMQ, Kafka)中。这个过程非常快,内存操作,可以轻松应对极高的并发。
- 应用服务器立刻向用户返回一个“排队中/处理中”的友好提示。
- 下游的订单处理服务(消费者),则根据自己的实际处理能力(特别是数据库的承受能力),按照自己的节奏,平滑地从MQ中拉取消息进行消费,并持久化到数据库。
流量进来后:
- 业务降级:
- 目的: 牺牲非核心功能,保全核心功能。
- 实现: 通过配置中心(如Nacos, Apollo)设置降级开关。当系统压力过大时,可以手动或自动关闭一些非核心服务。例如:
- 关闭商品评论、推荐系统、用户积分计算等。
- 只保留浏览商品、加入购物车、下单这三个最核心的交易链路。
- 熔断与限流:
- 目的: 防止单个服务的故障引发整个系统的“雪崩效应”。
- 实现: 使用Sentinel,Hystrix等服务治理框架。
- 熔断 (Circuit Breaker): 当某个下游服务(如库存服务)的错误率或响应时间超过阈值时,熔断器会“跳闸”,在接下来的一段时间内,所有对该服务的调用都会直接失败并快速返回,而不是去调用那个已经出问题的服务,给它恢复的时间。
- 应用级限流: 除了接入层的限流,在业务应用内部也可以做更精细化的限流。比如限制某个核心接口的总QPS不能超过2000,保护其依赖的数据库或其他资源。
网络编程
1.Netty 如何封装 NIO
Netty 是对Java原生NIO的一个高度封装和增强的框架,它解决了原生NIO在使用上非常复杂、功能有限、且容易出错的痛点。
- 封装Selector与事件循环:
- 原生NIO需要我们手动编写一个死循环,不断地调用
selector.select(),然后遍历selectedKeys,再根据key的类型(OP_ACCEPT,OP_READ等)进行if-else判断,代码繁琐且容易出错。 - Netty将其封装成了
EventLoop。每个EventLoop内部都包含一个Selector和一个线程。这个EventLoop线程会自动地、高效地执行事件轮询和分发,我们开发者完全不需要关心底层的Selector操作。
- 原生NIO需要我们手动编写一个死循环,不断地调用
- 封装Channel与Buffer:
- 原生NIO的
Buffer使用起来非常反直觉,需要我们手动flip()、clear()、rewind(),很容易出错。 - Netty提供了自己的
ByteBuf,它通过读写指针分离的设计,彻底告别了flip()操作,使用起来非常方便。它还提供了零拷贝(Zero-Copy)、池化(Pooling)\和*堆外内存*等高级功能,性能远超原生Buffer。 - Netty的
Channel接口也比原生的更统一、更易用。
- 原生NIO的
- 封装责任链与业务逻辑解耦:
- 原生NIO的所有I/O处理逻辑都混杂在一起。
- Netty引入了
ChannelPipeline和ChannelHandler的设计,这是一个经典的责任链模式。我们可以将网络处理逻辑(如解码、编码、业务处理)拆分成一个个独立的Handler,然后像“搭积木”一样将它们组织在Pipeline中。这使得代码结构清晰、高度解耦、易于扩展和复用。
多Reactor 模型
Netty的线程模型正是经典多Reactor模型的实现,通常是主从Reactor模式(Master-Slave Reactor
主Reactor(Boss Group):
- 通常只配置一个线程(
EventLoop)。 - 它的唯一职责就是监听服务端的连接请求(
OP_ACCEPT事件)。 - 当接收到一个新的客户端连接后,主Reactor会将这个新建立的
SocketChannel注册到从Reactor上,然后继续回去监听新的连接。它不处理任何I/O读写。
- 通常只配置一个线程(
从Reactor(Worker Group):
- 通常配置多个线程(
EventLoop),数量一般是CPU核心数的1倍或2倍。 - 它的职责是处理所有已连接
Channel的I/O读写事件(OP_READ,OP_WRITE)。 - 一个
Channel的整个生命周期内的所有I/O操作,都会被绑定在同一个从Reactor线程上执行,这避免了多线程并发处理同一个连接时需要加锁的问题。
2.
- 通常配置多个线程(
计网
1.对比一下 HTTP/1.0, HTTP/1.1, 和 HTTP/2.0 这三个版本的主要区别。
请从连接管理、性能优化、头部处理等角度展开,并说明每一个版本的演进分别解决了上一代的什么核心痛点?
1.0->1.1
- 长链接 (Keep-Alive): 是 HTTP/1.1 相对于 HTTP/1.0 最核心的改进之一。HTTP/1.0 默认是短连接,每个请求/响应对都需要一次 TCP 连接。而 HTTP/1.1 默认开启了长链接,允许在一个 TCP 连接上发送多个 HTTP 请求,极大地减少了 TCP 连接建立和关闭的开销。
- HTTP/1.1 还引入了管道机制 (Pipelining),允许客户端在收到上一个响应之前就发送下一个请求。但这只是部分解决了队头阻塞(Head-of-Line Blocking)问题,因为服务端的响应仍然必须按顺序返回。
2.0
- 多路复用 (Multiplexing): 这是 HTTP/2.0 最核心的优势。它允许在一个 TCP 连接上,同时、并行地收发多个请求和响应,并且不按顺序。这彻底解决了 HTTP/1.1 的队头阻塞问题。
- 头部压缩 (Header Compression): HTTP/2.0 使用 HPACK 算法来压缩请求和响应的头部。对于多个请求,很多头部字段是重复的,HPACK 可以极大地减少这部分的数据传输量。
- 服务器推送 (Server Push): 服务器可以主动地将客户端未来可能会用到的资源(如 CSS, JS 文件)提前推送到客户端缓存中,减少了请求的 RTT(往返时间)。
- 二进制分帧 (Binary Framing): 这是 HTTP/2.0 的革命性变化。HTTP/1.0 和 1.1 都是基于文本的协议,而 HTTP/2.0 将所有传输的信息分割为更小的消息和帧,并对它们采用二进制格式的编码。这解决了 1.x 时代基于文本的协议解析效率低的问题。
- HTTP/1.0 -> HTTP/1.1: 解决了什么?连接无法复用的问题。通过什么解决?默认开启长链接 (Keep-Alive)。
- HTTP/1.1 -> HTTP/2.0: 解决了什么?队头阻塞和头部冗余的问题。通过什么解决?二进制分帧、多路复用、头部压缩。
2.从用户在浏览器输入 URL 到页面渲染完成,请按网络与系统角度分层讲解关键路径。
我将其分为请求准备阶段、网络通信阶段、和浏览器渲染阶段
当我在URL栏输入地址的话,首先要知道这个域名对应的IP地址是啥
请求准备:
- 浏览器首先会解析URL,判断协议(HTTPS)、域名www.google.com)、端口(默认为443)等信息。接着,它会查询自己的浏览器缓存,看之前是否已经解析过这个域名并且缓存还未过期。如果命中,就直接使用缓存的IP地址,跳过后续的DNS查询。
- 如果浏览器缓存未命中,操作系统会启动一个DNS查询流程,这是一个从近到远、层层递归的查询过程,核心目标是将域名转换为IP地址。先是操作系统&host文件,然后是本地DNS服务器,再是根域名服务器,顶级域名服务器,权威域名服务器。依次类推
- LDNS拿到IP地址后,会将其缓存起来,并返回给操作系统,操作系统再返回给浏览器。至此,DNS解析完成。
网络通信:
这个过程涉及到TCP、TLS和HTTP三个核心协议
TCP三次握手
- 第一次握手 (SYN):客户端随机选择一个初始序列号
client_isn,将TCP报文段的SYN标志位置为1,然后发送给服务器。此时客户端进入SYN_SENT状态。 - 第二次握手 (SYN+ACK):服务器收到SYN包后,必须确认客户端的
SYN。它将报文段的SYN和ACK标志位都置为1,确认号ack设为client_isn + 1,同时自己也选择一个初始序列号server_isn,然后发送给客户端。此时服务器进入SYN_RCVD状态。 - 第三次握手 (ACK):客户端收到服务器的SYN+ACK包后,检查确认号是否正确。如果正确,它会将
ACK标志位置为1,确认号ack设为server_isn + 1,然后发送给服务器。这个ACK包可以携带数据。发送后,客户端和服务器都进入ESTABLISHED状态,连接建立成功。
TLS四次挥手:
- Client Hello:客户端发送支持的TLS版本、加密套件列表、以及一个随机数
client_random。 - Server Hello & Certificate:服务器选择一个加密套件,返回自己的数字证书、以及一个随机数
server_random。 - 客户端验证与密钥交换:客户端验证服务器证书的有效性。验证通过后,生成一个预主密钥
pre-master secret,用服务器证书中的公钥加密后发送给服务器。 - 服务器解密与会话密钥生成:服务器用自己的私钥解密,得到
pre-master secret。至此,客户端和服务器双方都拥有了client_random、server_random和pre-master secret,它们使用相同的算法,各自独立地生成一个对称的会话密钥。 - Finished:双方互发
Finished消息,用生成的会话密钥加密,验证握手过程是否成功。握手结束后,后续所有的HTTP数据都将使用这个对称的会话密钥进行加密传输。
Http请求和相应:
- 发送HTTP请求:浏览器构建一个HTTP请求报文,包含请求行(
GET / HTTP/1.1)、请求头(Host,User-Agent,Cookie等)和请求体(GET请求通常为空),然后通过建立好的TCP/TLS通道发送给服务器。 - 请求到达服务器后,可能会先经过负载均衡器(如Nginx/SLB),它会将请求转发到后端的某一台应用服务器。
- 应用服务器(如Tomcat)接收到请求后,Web容器会解析HTTP报文,将其封装成
HttpServletRequest对象。 - 业务代码(如Spring MVC的Controller)被调用,它可能会查询缓存(Redis)、数据库(MySQL),执行业务逻辑,最终生成数据。
- 服务器将数据渲染进HTML模板,构建一个HTTP响应报文,包含状态行(
HTTP/1.1 200 OK)、响应头(Content-Type,Set-Cookie等)和响应体(HTML内容)。 - 接收HTTP响应:浏览器接收到服务器的响应报文。
浏览器渲染:
- 浏览器自上而下解析HTML文档,生成DOM树(Document Object Model)。
- 在解析过程中,如果遇到
<link>标签引用的CSS文件,会异步下载并解析,生成CSSOM树(CSS Object Model)。 - 如果遇到
<script>标签,会阻塞DOM的解析,立即下载并执行JavaScript代码(除非script标签有async或defer属性)。 - 构建渲染树(Render Tree):将DOM树和CSSOM树结合起来,生成渲染树。渲染树只包含需要被显示的节点及其样式信息(例如,
display:none的节点就不会在渲染树中)。 - 布局(Layout/Reflow):浏览器根据渲染树,计算出每个节点在屏幕上的精确位置和大小。
- 绘制(Paint/Rasterizing):浏览器调用GPU,根据布局信息,将每个节点绘制成屏幕上的实际像素。
- 合成(Composite):对于复杂的页面(如使用了
transform或opacity),浏览器会将页面分层,独立绘制,最后再合成到一起,以提升性能。
所有资源加载完成,或者是空闲超时了之后,就会开始断开请求TCP的四次挥手
- 第一次挥手 (FIN):主动关闭方(如客户端)发送一个
FIN报文,表示自己的数据已发送完毕。进入FIN_WAIT_1状态。 - 第二次挥手 (ACK):被动关闭方(服务器)收到
FIN后,回复一个ACK报文。此时,连接处于半关闭状态,服务器仍然可以向客户端发送数据。 - 第三次挥手 (FIN):服务器也准备好关闭连接时,发送一个
FIN报文给客户端。进入LAST_ACK状态。 - 第四次挥手 (ACK):客户端收到服务器的
FIN后,回复一个ACK报文。发送后,客户端进入TIME_WAIT状态。服务器收到这个ACK后,立即关闭连接。
TIME_WAIT状态?
- 可靠地终止TCP连接:这是最主要的原因。四次挥手中的最后一个ACK报文是由主动关闭方(客户端)发出的。这个ACK报文有可能会在网络中丢失。如果丢失,被动关闭方(服务器)就收不到确认,它会超时重传它的FIN报文。如果此时客户端已经彻底关闭连接,它将无法响应这个重传的FIN,导致服务器永远无法正常关闭。而处于
TIME_WAIT状态的客户端,仍然能接收到这个重传的FIN,并重新发送一次ACK,从而确保服务器能够正常关闭。 - 防止已失效的报文段被新连接误接收:考虑一个场景:一个TCP连接(由
源IP:源端口, 目的IP:目的端口这个四元组唯一标识)关闭后,马上又用完全相同的四元组建立了一个新的连接。此时,网络中可能还存在上一个旧连接中延迟到达的报文段。如果没有TIME_WAIT状态,这些“迷路”的旧报文段就可能会被这个新连接误认为是合法数据并接收,造成数据错乱。
为什么等待时间是 2MSL?
- MSL(Maximum Segment Lifetime)\是指一个TCP报文段在网络中可能存活的*最长时间*。任何报文在超过MSL后,都会被网络丢弃。
2MSL的时间足以保证在一个连接的一去一回两个方向上,所有的报文段都能在网络中自然消失。当TIME_WAIT状态结束后,可以保证网络中不再有任何与旧连接相关的“幽灵”报文段,此时再建立新的连接就是完全安全的。
3.在 TCP 三次握手过程中,如果第三次握手的 ACK 报文丢失了,会发生什么?
三次握手分别是:
1 | SYN` -> `SYN+ACK` -> `ACK |
- 服务端状态: 当服务端发送完
SYN+ACK之后,它会进入SYN_RCVD状态,并启动一个定时器,等待客户端的第三次ACK。 - 客户端状态: 当客户端发送完第三次
ACK之后,它单方面认为连接已经建立,状态会变为ESTABLISHED。
因为是服务端在 SYN_RCVD 状态下等待第三次 ACK 超时了。当定时器超时后,服务端会重新发送 SYN+ACK 包给客户端。重传的次数由系统参数(如 net.ipv4.tcp_synack_retries)控制。
在 SYN_RCVD 状态下,连接并未完全建立。对于服务端应用层来说,它通过 accept() 拿到的连接还处于一个“半连接队列”中,应用层是无法使用这个连接的,所以服务端应用层无感知。
因为客户端在发送完第三次 ACK 后,其内核协议栈就认为连接已建立(ESTABLISHED 状态),所以对于客户端应用层来说,connect() 系统调用会立即返回成功。此时,客户端应用层会认为连接已经建立成功,并开始发送数据。
处理:
- 客户端应用层发送的数据,会和因为第三次 ACK 丢失而重传的
SYN+ACK在网络中交汇。 - 当客户端收到服务端重传的
SYN+ACK后,它的内核会意识到自己之前发送的ACK可能丢失了,于是会再次发送一个ACK给服务端。 - 当服务端收到了这个新的
ACK后(无论是客户端重发的,还是伴随着数据包一起过来的),服务端状态才会变为ESTABLISHED,连接才真正建立,之前客户端发送的数据才会被服务端应用层接收。
TCP状态机转变:CLOSED->SYN_SENT->SYN_RCVD->ESTABLISHE
异常解决
1.就比如说你这个部署到线上了,然后他抛了一个异常,然后那你这个应该怎么排查呢
线上出现异常,我会遵循一套从宏观到微观、由表及里的排查SOP(标准作业程序)来定位和解决问题。
第一步:信息收集与初步判断
确认影响范围:首先,快速判断这个异常的影响面有多大。是影响了所有用户,还是部分用户?是核心功能还是边缘功能?这决定了问题的紧急程度。
查看监控告警:立即查看监控系统(如Prometheus/Grafana, Zabbix)的告警信息。检查应用的
关键指标,如:
- 应用层面:QPS、响应时间(RT)、错误率(Error Rate)是否突增?
- JVM层面:CPU使用率、内存占用、GC活动是否异常?
- 主机层面:服务器的CPU、内存、磁盘I/O、网络流量是否正常?
- 依赖服务:数据库、Redis、MQ等中间件的健康状况如何?
- 这一步的目标是快速定位问题是出在应用本身,还是外部依赖。
第二步:日志分析与精准定位
- 聚合日志平台检索:登录ELK(Elasticsearch, Logstash, Kibana)或类似日志平台,根据告警信息中的时间点、错误信息关键字(如
RuntimeException)进行检索。 - 利用Trace ID进行链路追踪:如果系统接入了分布式追踪系统(如SkyWalking, Zipkin),这是最强大的工具。我会根据报错信息找到一个Trace ID,然后用这个ID查询完整的请求调用链。这可以清晰地看到请求经过了哪些服务,在哪一个环节耗时最长,又是在哪个服务的具体代码行抛出了异常。
- Linux服务器手动排查(作为补充):如果日志平台不完善,我会登录到具体的服务器上进行排查。
- 使用
grep命令根据关键字快速过滤日志:grep -C 10 'ExceptionNameToFind' /path/to/app.log。-C 10可以显示异常上下文的10行,帮助理解问题背景。 - 如果需要根据Trace ID查,我会用:
grep 'your-trace-id' /path/to/app.log。 - 对于实时滚动的日志,我会用
tail -f /path/to/app.log | grep 'ERROR'来实时监控错误输出。
- 使用
第三步:根因分析与问题复现
- 代码分析:定位到具体的异常代码后,分析代码逻辑,判断是业务逻辑错误、空指针、并发问题还是资源未释放等。
- 环境复现:如果可能,尝试在测试环境或预发环境,构造相同的参数和条件,复现这个问题,以便于调试和验证修复方案。
第四步:问题解决与复盘
- 紧急修复:如果是严重Bug,立即进行Hotfix修复并上线。如果是资源问题,进行扩容或配置调整。
- 复盘总结:问题解决后,必须进行复盘。分析问题发生的根本原因,是代码缺陷、设计不合理、还是容量预估不足?并制定改进措施,例如增加单元测试、完善监控告警、优化架构等,防止同类问题再次发生。
2.考察线上问题排查
- 第一步:紧急止血(恢复服务优先)。
- 第二步:定位根因(Root Cause)。
第三步:复盘总结(避免再犯)。
\1. 看监控,定范围:
- 看应用自身监控: 接口的 QPS、P99 响应时间、JVM(GC次数/时间、线程数)、线程池监控(队列长度、活跃线程数)。首先确认是自身应用的问题还是外部问题。
- 看主机监控: CPU 使用率、内存占用、网络 I/O、磁盘 I/O。确认是不是机器资源被打满了。
- \2. 分析线程,找瓶颈:
- 使用
jstack命令 dump 线程堆栈。分析是否有大量线程处于BLOCKED状态(锁竞争)、WAITING状态(等待外部资源,如 HTTP 调用、数据库连接)。这是定位问题的最核心手段。
- 使用
- \3. 查GC,判影响:
- 使用
jstat -gcutil查看 GC 情况。确认是否发生了频繁的 Full GC,导致 STW(Stop-The-World),从而影响接口响应。
- 使用
- \4. 查依赖,判外部:
- 检查所有下游服务(RPC 调用)的响应时间。是不是某个下游服务变慢,拖垮了你。
- 检查数据库和缓存(Redis)的慢查询日志和响应时间。是不是因为慢 SQL 或 Redis 大 Key 导致的阻塞。
- \5. 看网络,做补充:
- 如果以上都正常,再考虑网络问题,比如丢包、重传等。
恢复手段:
- 重启大法: 最简单粗暴但有效。
- 服务降级: 通过配置中心,暂时关闭一些非核心功能。
- 服务限流: 立即调低接口的 QPS 阈值,避免被流量打垮。
- 扩容: 如果是资源不足,立即进行水平扩容。
3.线上问题卡顿
提出了一个“自顶向下”的排查思路:先通过监控工具(宝塔)看服务器资源(CPU、内存),定位到具体程序,再通过程序的日志(Docker日志)定位到具体组件和代码异常。
Linux 命令行工具(如 top, jstack, jmap)的提及。对于一个硬核的技术面试,面试官更希望听到你如何使用这些底层工具进行排查。此外,排查的维度不够全面,没有考虑到网络问题、数据库慢查询、下游服务拖累等常见原因。
AI
1.设计一个可扩展的架构,并说明如何实现 1-2 秒 P95 的延迟指标。
召回、重排、向量库更新、上下文窗口管理、长对话状态持久化,以及延迟预算分配几个维度,
RAG 的核心流程:文档切分 -> 向量化入库 -> 用户问题向量化 -> Top K 相似度检索 -> 结果送入 LLM 生成答案。RAG 是为了解决 LLM 没有“记忆”和无法利用私有知识的问题。
- 召回(Recall): 你只提到了向量相似度检索。但一个生产级的 RAG 系统,召回层通常是混合检索,比如 向量检索 + 关键词检索(如 BM25),以应对不同类型的问题。
- 重排(Rerank): 你提到了重排模型,但没有说明它的作用。Rerank 模型(如 Cohere Rerank)通常是一个轻量级的交叉编码器模型,它会对召回的 Top N(比如 N=50)个文档,进行更精细化的相关性打分,再选出最终的 Top K(比如 K=5)送给 LLM,能显著提升最终答案的质量。
- 向量库更新: 这是一个工程难题,你完全没有提及。如何处理知识的增量更新、修改和删除?是定期全量重建索引,还是采用支持实时更新的向量数据库?
时间分配:
- 用户问题预处理:50ms
- 向量化(Embedding): 100ms
- 向量检索(Recall): 150ms
- 重排(Rerank): 200ms
- LLM 生成(Generation): 1000ms (这是大头)
- 网络开销等:500ms 然后你需要思考如何优化每个环节。比如,Embedding 模型和 Rerank 模型需要选择轻量级、高性能的版本;向量检索需要对索引进行优化(如 HNSW 索引的参数调优);LLM 需要采用流式输出(Streaming),让用户能更快地看到第一个 Token。
SpringCloud
Nacos
1.Nacos动态配置刷新的原理是什么?
核心机制: 长轮询(Long Polling)。
客户端行为: 应用启动后,客户端向Nacos Server请求配置,并建立一个长轮询连接,询问配置是否有更新。
服务端行为: 如果配置无变更,服务端会hold住请求30秒(默认);如果期间配置发生变更,立即响应;如果超时,也返回一个空响应。
刷新流程: 客户端收到变更响应后,拉取最新配置,发布EnvironmentChangeEvent事件。
Spring侧响应: @RefreshScope注解的Bean监听到事件后,会销毁并重新创建,从而加载到新配置。
2.Nacos 1.x 和 2.x 有什么核心区别?
通信模型升级: 最大的变化是从HTTP短连接轮询模型升级为gRPC长连接模型。
性能提升: gRPC基于HTTP/2,使用长连接和二进制协议,大大降低了通信开销和服务器压力,服务注册/发现和配置推送的性能提升了一个数量级。
推送机制: 从1.x的UDP推送通知+HTTP拉取数据,变为2.x的gRPC直接推送数据,实时性更强,更可靠。
架构演进: 2.x引入了统一的连接管理和请求处理模型,为后续的架构演进打下了更好的基础。
客户端兼容: Nacos 2.x服务端兼容1.x的客户端,保证了平滑升级。
3.Nacos集群是如何保证高可用的?
部署架构: 生产环境至少部署3个(或更多奇数个)Nacos节点,构成集群。
流量入口: 前端通过一个统一的入口(如Nginx、SLB)将客户端请求反向代理到后端的Nacos集群,实现负载均衡和故障转移。
数据持久化: 集群所有节点共享同一个外部数据源(通常是高可用的MySQL集群),保证了配置等强一致性数据的统一存储。
节点间通信: 节点间会互相通信,同步服务实例等信息。
客户端容灾: Nacos客户端会配置集群中所有节点的地址。当某个节点宕机时,客户端会自动切换到其他健康的节点上,实现Failover。
Sentinel
1.介绍一下Sentinel的滑动窗口算法 (LeapArray)。
目的: 实现精确、实时的QPS等指标统计。
结构: 基于一个环形数组,每个数组元素是一个“时间桶”(Bucket),用于存储一小段时间内的统计数据。
时间窗口: 整个数组代表一个完整的时间窗口(如1秒)。
滑动机制: 随着时间流逝,一个指针会向前移动,过时的时间桶会被清空并复用于记录新的数据,从而实现窗口的“滑动”效果。
聚合: 统计总QPS时,会聚合当前时间窗口内所有有效时间桶的数据。
2.在@SentinelResource中,blockHandler和fallback有什么区别?
- 触发条件不同:
blockHandler:仅当请求被Sentinel的规则(流控、熔断、系统保护等)阻止时调用。fallback:当被注解的方法内部抛出任何业务异常(Throwable)时调用。
- 优先级: 如果同时配置了两者,并且发生了业务异常,
fallback会优先被调用。只有在没有业务异常,但触发了Sentinel规则时,blockHandler才会生效。 - 参数签名不同:
blockHandler的方法签名需要与原方法保持一致,但可以在末尾额外添加一个BlockException类型的参数。fallback的方法签名也需要与原方法一致,但可以在末尾额外添加一个Throwable类型的参数。
3.生产环境中,如何对Sentinel的规则进行管理和持久化?
核心方案: 使用Sentinel的DataSource扩展机制。
推荐组合: Sentinel + Nacos。
步骤:
- 在应用中引入
sentinel-datasource-nacos依赖。 - 在
application.yml中配置Nacos数据源信息(服务器地址、Data ID、Group等)。 - 将JSON格式的规则内容配置在Nacos中。
效果: 实现规则的集中管理、持久化存储和动态实时刷新,是生产环境下的最佳实践。












