
02 盘点2025遇到的各大厂面试真题总结
写在前面的话
已写Java后端完整版学习路线(仔细讲解每个技术栈怎么学,学到什么程度可以投实习/面试,怎么选择简历项目等内容)+ Redis核心面试考点深度梳理 + MySQL八股深度梳理 3篇长文,如有需要欢迎大佬们自取,后续还会更新MySQL、RabbitMQ、SSM等其他部分的面试高频八股梳理,欢迎关注。 01 非科班转码拿下大厂Offer,花费一天整理的Java后端完整版学习路线 03 一文吃透 Redis 核心考点,面试真题深度梳理 04 MySQL面试八股看这一篇就够了——深度梳理MySQL面试问题
百某度篇
1、JVM OOM的排查与内存泄露情况
JVM OOM的可能原因:
JVM的OOM错误根据发生的内存区域不同,原因也各不相同。Java 8之后,内存区域主要分为以下几部分,对应的OOM原因如下:
1. Java堆空间(Heap Space)OOM:
- 原因:对象实例占用的内存总量达到了堆内存的最大值(通过-Xmx设定)。
- 具体场景:
- 内存泄漏:这是最核心的原因。对象已经不再使用,但由于错误的引用,垃圾收集器无法回收它们,导致这些无用对象持续堆积,最终耗尽内存;
- 内存设置过小:-Xmx设置的值相对于应用程序的正常需求来说太小了;
- 数据量激增:某一时刻数据量异常庞大(如大文件导入、高并发请求),瞬间创建了大量对象,超出了堆的承载能力。
2.元空间(Metaspace)OOM:
- 原因:加载的类、方法等元数据信息占用的本地内存(Native Memory)
-XX:MaxMetaspaceSize; - 具体场景:
- 动态类生成:大量使用了CGLIB动态代理(如Spring AOP)等技术,运行时生成了大量的新类;
- 热部署/热加载:应用频繁重启或热部署,旧的类加载器未被回收,导致其加载的类元数据也无法卸载,从而造成堆积;
- Tomcat部署过多应用:同一个Tomcat实例下部署了多个应用,每个应用都有自己的类库;
- 元空间大小设置不合理:默认不限制(仅受本地内存大小限制),但如果设置了最大值且设置过小,则容易触发。
3.GC Overhead Limit Exceeded:
- 原因:这是一种“保护机制”式的OOM。JVM花费了超过98%的时间进行垃圾收集,但只回收了不到2%的堆内存。这意味着GC效率极低,几乎在做无用功。
- 本质:这通常是堆内存OOM的一个前兆,根本原因还是堆内存快满了,而且充满了难以回收的对象(很可能是内存泄漏)。
4.无法创建本地线程:
- 原因:应用程序创建的线程数超过了操作系统或JVM进程的限制。
- 具体场景:
- 线程池配置不当:创建了无限数量的线程(如没有上限的cachedThreadPool);
- 应用确实需要极多线程:如超高并发场景;
- 操作系统限制:设置的用户最大进程/线程数过低;
- 内存不足:每个线程都需要为其栈分配一定的内存(通过-Xss设置),如果本地内存(栈所需内存属于本地内存)不足,也无法创建新线程。
2、 内存泄漏的常见场景
常见的内存泄露情况包括:
- 静态集合,业务代码不断向其中添加对象,但是从不移除(因为静态集合被其所属的类(Class对象)直接引用,而类本身又被加载它的类加载器(ClassLoader)引用。类加载器作为GC Roots的一部分,是JVM垃圾回收机制绝不会回收的对象。因此,这条强大的引用链保护了静态集合不会被回收)
- 使用了本地缓存,但没有设置合理的过期时间或大小限制;
- 资源未关闭,数据库连接、文件流等使用完之后没有关闭;
- 生命周期过长的对象持有短生命周期对象的引用,例如,一个全局的单例对象持有了一个HttpRequest对象的引用。
3、 JVM OOM排查思路:
- 确认OOM类型:第一时间查看错误日志的第一行,确认是哪种OutOfMemory。这直接决定了后续的排查方向。例如,是堆空间还是元空间。
- 立即保留现场:在启动脚本中预先加入以下JVM参数,以便在发生OOM时自动生成dump文件:
-XX:+HeapDumpOnOutOfMemoryError # 发生OOM时记录dump文件 -XX:HeapDumpPath=/path/to/save/dump.hprof dump文件的保存路径 -XX:OnOutOfMemoryError="your_script.sh" # 可选,用于发生OOM后执行一些命令,如发告警 如果之前没配置,但进程还在,尝试用jmap命令立即dump当前内存状态注意:在生产环境执行jmap可能会引发STW(Stop-The-World),需谨慎。 - 分析现场信息:
查看GC日志:如果配置了GC日志(
-Xloggc:/path/to/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps),分析GC频率、耗时、内存回收效果。如果看到老年代使用率持续上升且Full GC后回收效果很差,基本断定是内存泄漏。 使用工具分析Heap Dump文件: Eclipse MAT (Memory Analyzer Tool):这是最强大、最常用的离线堆转储文件分析工具; JVisualVM:JDK自带的工具,功能较MAT弱一些,但方便快捷; - 复现问题: 如果问题无法在线上立即解决,需要尝试在预发布或测试环境复现。可以使用压力测试工具模拟线上流量,进行问题复现。
4、内存泄漏专项排查
内存泄漏的排查是OOM问题中最复杂和最常见的。其核心思路是:找出哪些本该被回收的对象,仍然被意外地引用着(GC Roots),从而无法被GC回收。
使用Eclipse MAT进行分析:
- 打开Heap Dump文件:使用MAT加载.hprof文件。
- 寻找嫌疑对象:查看概览:打开后MAT通常会给出一个泄漏嫌疑报告。它会直接指出占用内存最大的对象和线程,这是第一线索。使用直方图(Histogram):查看各个类的实例数量(Objects)和总大小(Shallow + Retained Heap)。重点关注那些数量异常多或者占用总空间巨大的类。与正常情况对比尤其有效。
- 定位GC Root:找到某个疑似泄漏的对象实例后,排除弱引用等,只显示强引用。这条引用链就是阻止该对象被回收的“罪魁祸首”!
- 分析常见泄漏模式: 即问题2中介绍的内存泄露的常见场景
5、上线需要配置哪些JVM参数
核心基础参数(必须配置)
这些参数定义了JVM最基本的行为,通常必须设置。
- 堆内存大小(
-Xms, -Xmx)-Xms 4g -Xmx 4g:强烈建议将初始堆(-Xms)和最大堆(-Xmx)设置为相同值。这样可以避免运行时动态调整堆大小带来的性能开销,同时防止在扩容时因内存不足而发生的停顿。 - 元空间大小(
-XX:MetaspaceSize, -XX:MaxMetaspaceSize)-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m:设置元空间初始大小和最大值。元空间使用本地内存,默认只受限于系统内存,设置上限可以防止应用因类加载器泄漏等问题耗尽系统内存。 - 垃圾收集器
JDK 8及以前:-XX:+UseConcMarkSweepGC(CMS)或-XX:+UseG1GC(G1)
JDK 9及以后:默认就是G1。对于新项目,G1是绝大多数场景的最佳选择。
-XX:+UseG1GC:显式指定使用G1垃圾收集器。 - GC日志(必须配置)
这是排查线上问题最重要的依据。-Xloggc:/path/to/your/logs/gc-%t.log:指定GC日志输出路径,%t会增加时间戳,避免覆盖。-XX:+PrintGCDetails:打印详细的GC信息。-XX:+PrintGCDateStamps:在GC日志中增加日期时间戳,便于定位。-XX:+UseGCLogFileRotation:开启GC日志滚动。-XX:NumberOfGCLogFiles=5:保留的GC日志文件数量。-XX:GCLogFileSize=10M:每个GC日志文件的大小。
- OOM时自动转储堆快照(必须配置)
-XX:+HeapDumpOnOutOfMemoryError:在发生OOM时自动生成Heap Dump文件。
-XX:HeapDumpPath=/path/to/your/logs/java_heapdump.hprof:指定Heap Dump文件的存放路径。 - 字符集(选择性)
-Dfile.encoding=UTF-8:确保应用使用统一的UTF-8编码,避免乱码问题。
6、 内存与GC调优参数
在核心参数基础上,根据具体应用特性(如响应优先还是吞吐优先、内存大小等)进行微调。以下以G1垃圾收集器为例:
G1相关调优
-XX:MaxGCPauseMillis=200:设置期望的最大GC停顿时间目标(毫秒)。G1会尽力实现,但不保证。默认200ms,对于响应敏感的应用可以设为100甚至50。
-XX:InitiatingHeapOccupancyPercent=45:设置触发Mixed GC的堆占用率阈值(老年代占整个堆的比例)。默认45%,如果GC频繁可以适当降低此值,让G1更早开始回收。
-XX:ConcGCThreads= / -XX:ParallelGCThreads=:可以手动设置并发和并行GC的线程数,通常不需要调整。
其他内存区域
-Xss256k:设置每个线程的栈大小。默认1M,对于微服务等线程数多的应用,可以适当减小(如256k/512k)以创建更多线程,但要保证不出现StackOverflowError。
-XX:MaxDirectMemorySize=1g:设置堆外内存(Direct Buffer)的大小上限,防止Netty等框架耗尽堆外内存。
7、 hashMap有哪些线程安全问题
死循环(JDK 1.7及之前版本特有)
这是最著名、最严重的问题,主要发生在JDK 1.7及更早版本中。原因是这些版本的HashMap在扩容时采用“头插法”来转移节点。
触发场景:多个线程同时对一个HashMap进行put操作,触发了扩容。在扩容过程中,链表中的节点会倒序转移。如果有两个线程A和B同时执行转移操作,线程A在执行到一半时被挂起,线程B完成转移。当线程A恢复后继续执行,就可能形成一个循环链表。当下次有线程对这个链表进行查询(get)或插入时,就有可能进入这个无限循环。 注意:在JDK 1.8中,HashMap的链表转移改为了“尾插法”,并且引入了红黑树优化。“尾插法”理论上避免了循环链表的问题,但并不意味着HashMap在1.8中就变得线程安全了,它仍然存在其他严重的并发问题。
数据覆盖(所有版本都存在)
这是最常见的并发问题,发生在put操作中。
触发场景:两个线程A和B同时对一个HashMap执行put操作,并且计算出的哈希值对应的是同一个桶(bucket)。 问题根源:假设线程A和B都执行到步骤1,发现桶是空的。于是它们都认为自己可以插入新节点。线程A先将新节点插入到桶中,但随后线程B也将其新节点插入到同一个位置,覆盖了线程A插入的数据。最终,线程A放入的数据就丢失了。
数据不一致/脏读(所有版本都存在)
即使没有发生数据覆盖,由于没有内存可见性(Volatile)保证,一个线程的修改可能不会立即对其他线程可见。
触发场景:线程A成功put了一个值,但线程B在执行get时,可能由于CPU缓存没有及时刷新,读取到的是旧的、未更新的值,而不是线程A刚刚放入的新值。
8、 怎么去解决HashMap的线程安全问题
为了解决线程安全问题,有几种方案:
1. 使用Collections.synchronizedMap
它会返回一个同步包装器,所有方法(get,put,size等)都被synchronized关键字保护,相当于给整个对象加了一把大锁。
优点:实现简单,代码修改成本低(只需包装一下)。 缺点:性能是瓶颈,因为所有操作都需要竞争同一把锁,并发度低。
2. 使用ConcurrentHashMap(推荐)
这是JUC(java.util.concurrent)包中提供的专门用于高并发的Map实现。
JDK 1.7采用分段锁(Segment)机制,将整个数据结构分成多个段(Segment),每个段一把锁。写操作只锁住对应的段,允许多个线程同时访问不同的段,提高了并发度。
JDK 1.8及之后做了巨大优化,放弃了分段锁,改用synchronized + CAS(Compare-And-Swap) + volatile的实现方式。锁的粒度更细,变成了对单个数组元素(桶的头节点)加锁,并发性能得到了极大的提升。
优点:高并发、高性能、线程安全。是当前多线程环境下的首选。
实现原理具体介绍:
1. put操作流程
计算哈希:计算key的哈希值((h ^ (h >>> 16)) & HASH_BITS),spread后的哈希值。
判断表是否为空:如果数组未初始化,先调用initTable()进行初始化(使用CAS控制并发初始化)。
定位桶:根据哈希值找到对应的数组下标i。
插入节点:
- 情况一(桶为空):如果table[i]为null,直接使用CAS操作将新节点放入该位置。成功则结束,失败则意味着发生了竞争,重试。
- 情况二(正在扩容):如果发现该桶的头节点是ForwardingNode(一个特殊的节点,哈希值为MOVED),则说明当前哈希表正在扩容。当前线程会协助进行数据迁移。
- 情况三(桶非空):对桶的头节点f加synchronized锁。
- 如果是链表,则遍历链表,找到key相同的节点则更新value,否则在链表尾部插入新节点。
- 如果是红黑树,则调用红黑树的插入方法。
判断是否需要树化:插入链表后,如果链表长度达到8,会尝试调用treeifyBin方法。该方法会进一步判断当前数组容量是否达到64,如果达到则进行树化;否则只是触发扩容。
计数:调用addCount()方法增加元素个数,并检查是否需要进行扩容。
2. get操作
get操作是完全无锁的,这也是它高性能的原因。
计算哈希,定位到数组下标。
如果头节点就是要找的key,直接返回。
如果头节点的哈希值小于0,说明该节点是特殊节点(可能是TreeBin或ForwardingNode),则调用这些节点对应的find方法来查找。
如果是链表,则遍历链表查找。
之所以不需要加锁,是因为Node的val和next属性都使用了volatile修饰,保证了多线程下的可见性。虽然get可能读到正在扩容或迁移过程中的中间状态,但设计的find方法能保证在并发环境下也能正确地找到数据。
3. 扩容机制
这是最复杂的设计之一。它通过一个volatile变量sizeCtl来控制整个扩容过程。
- 负数:表示正在初始化或扩容。-1表示正在初始化,-N表示有N-1个线程正在协助扩容。
- 正数:扩容的阈值(容量*负载因子)。
- 0:默认初始值。
扩容触发时机:在put后调用addCount时,如果发现元素数量超过阈值,就会触发transfer方法进行扩容。
扩容特点:
- 多线程协助:扩容时,原有数组的每个桶上会放置一个ForwardingNode标记。其他线程在进行put或remove操作时,如果遇到这个标记,就会先暂停自己的操作,来帮助进行当前桶的数据迁移。迁移完成后,再继续自己的操作。这大大加快了扩容速度。
- 步长(Stride):每个协助线程会领取一个“步长”的任务(比如一次迁移 16 个桶),完成后再继续领取,直到所有桶都迁移完毕。
3. 使用Hashtable(已过时,不推荐)
Hashtable是一个古老的线程安全类,它通过在所有方法上添加synchronized关键字来实现线程安全。
缺点:和Collections.synchronizedMap一样,性能极差,因为它也是全局锁。这是一个设计上就被淘汰的类,在新代码中不应再使用。
9、 java实现的锁有哪些
(1)由JVM直接提供的内置锁-synchronized关键字
使用方式:
同步代码块:手动指定锁对象(可以是任何对象实例或Class对象)。
同步实例方法:锁是当前对象实例(this)。
同步静态方法:锁是当前类的Class对象(如MyClass.class)。
特点:
互斥性:同一时刻只有一个线程可以进入临界区。
可重入:同一个线程可以多次获取同一把锁(锁计数器+1)。
不公平:默认是非公平锁,不保证等待时间最长的线程优先获取锁。
不可中断:在等待获取锁的过程中,线程无法被中断。
自动释放:线程执行完同步代码或发生异常时,JVM会自动释放锁。
synchronized关键字的核心思想是:每一个Java对象都可以关联一个“监视器锁”(Monitor),也称为“内部锁”或“互斥锁”。当线程进入synchronized修饰的代码块时,会自动尝试获取与之关联的对象的Monitor锁。如果获取成功,则该线程成为该Monitor的所有者,可以执行代码块;其他线程则会被阻塞,直到锁被释放。
(2)由JDK java.util.concurrent.locks包提供的显式锁

(3)使用场景总结
| 场景 | 推荐选择 | 理由 |
|---|---|---|
| 通用的互斥场景 | synchronized | 代码简洁,由JVM自动优化和管理,性能优异,不易出错。 |
| 需要高级功能(如可中断、超时、公平锁) | ReentrantLock | 提供synchronized不具备的灵活性。 |
| 读多写少 | ReentrantReadWriteLock 或StampedLock | 显著提升读并发性能。StampedLock性能更优,但API更复杂。 |
| 纯粹的计数器、状态标志更新 | 原子变量(如AtomicInteger) | 基于CAS,无锁操作,性能最高。 |
| 控制同时访问的线程数 | Semaphore(信号量) | 是一种更广义的“共享锁”。 |
10、 重载和重写方法本质的区别
重载
发生在同一个类中,方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。
如果多个方法有相同的名字、不同的参数,便产生了重载。
编译器通过用各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出相应的方法。
Java 允许重载任何方法,而不只是构造器方法。
综上:重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。
重写
重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。
方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。
如果父类方法访问修饰符为private/final/static则子类就不能重写该方法,但是被static修饰的方法能够被再次声明。
构造方法无法被重写
综上:重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变。

11、 静态方法为什么不能调用非静态成员
静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。
在类的非静态成员不存在的时候静态方法就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。
12、 线程池怎么创建&默认线程池为啥一般不用
方式一:通过ThreadPoolExecutor构造函数来创建(推荐)。
方式二:通过Executor框架的工具类Executors来创建。
通过Executors工具类可以创建多种类型的线程池,包括:
FixedThreadPool:固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
SingleThreadExecutor:只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
CachedThreadPool: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
ScheduledThreadPool:给定的延迟后运行任务或者定期执行任务的线程池。
Executors返回线程池对象的弊端如下:
FixedThreadPool和SingleThreadExecutor:使用的是阻塞队列LinkedBlockingQueue,任务队列最大长度为Integer.MAX_VALUE,可以看作是无界的,可能堆积大量的请求,从而导致OOM。
CachedThreadPool:使用的是同步队列SynchronousQueue,允许创建的线程数量为Integer.MAX_VALUE,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致OOM。
ScheduledThreadPool和SingleThreadScheduledExecutor:使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为Integer.MAX_VALUE,可能堆积大量的请求,从而导致OOM。
美某团篇
1、接口和抽象类的区别和各自的应用场景
接口和抽象类的共同点
实例化:接口和抽象类都不能直接实例化,只能被实现(接口)或继承(抽象类)后才能创建具体的对象;
抽象方法:接口和抽象类都可以包含抽象方法。抽象方法没有方法体,必须在子类或实现类中实现。
接口和抽象类的区别
设计目的:接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
继承和实现:一个类只能继承一个类(包括抽象类),因为Java不支持多继承。但一个类可以实现多个接口。
成员变量:接口中的成员变量只能是public static final类型的,不能被修改且必须有初始值。抽象类的成员变量可以有任何修饰符(private, protected, public),可以在子类中被重新定义或赋值。
方法:Java 8之前,接口中的方法默认是public abstract,也就是只能有方法声明。自Java 8起,可以在接口中定义默认方法和静态方法。自Java 9起,接口可以包含private方法。
抽象类可以包含抽象方法和非抽象方法。抽象方法没有方法体,必须在子类中实现。非抽象方法有具体实现,可以直接在抽象类中使用或在子类中重写。
Java 8引入的default方法用于提供接口方法的默认实现,可以在实现类中被覆盖。这样就可以在不修改实现类的情况下向现有接口添加新功能,从而增强接口的扩展性和向后兼容性。
2、进程之间的通讯方式
- 匿名管道:用于具有亲缘关系的进程之间的通信,比如父子进程、兄弟进程,遵循先进先出原则,数据一旦被读出,便不在管道中存在,不可反复读取;容量有限,如果管道满了,写操作会阻塞;如果管道空了,读操作会阻塞;
- 命名管道:突破了亲缘关系的限制,任何无关进程都可以通过访问该路径进行通信,其他和匿名管道一样;
- 消息队列:支持无关进程间通信。消息有类型,读进程可以根据类型有选择地接收消息,而不必须是FIFO顺序。内核中的消息队列有最大长度和最大消息数的限制。
- 共享内存:允许多个进程共享同一块物理内存区域。进程通过系统调用创建一块共享内存区,并将其映射到自己的虚拟地址空间。之后,进程就可以像访问普通内存一样读写这块区域。速度极快:因为数据不需要在内核和用户空间之间来回拷贝。需要同步机制(如信号量或互斥锁):因为多个进程同时读写同一块内存,会产生竞争,必须由程序员自己控制同步。
- 信号量:本质不是用来传递数据的,而是一个计数器,用于实现进程间的同步与互斥(防止多个进程同时访问一个共享资源)。它主要用于控制多个进程对共享资源(如共享内存)的访问。信号量的值表示可用资源的数量。通常与共享内存等方式配合使用,解决其同步问题。
- 信号:异步通信机制,用于通知接收进程某个事件已经发生。进程可以发送信号给自身或其他进程,内核也可以在特定事件(如除零错误、用户按下Ctrl+C)发生时发送信号给进程。特点是开销小,但能携带的信息量极少(只有一个信号编号)。用于处理异常、中断或简单的进程控制(如终止、暂停、继续)。
- 套接字socket:不仅可以用于同一台主机的进程间通信,更主要用于不同主机(网络)上的进程间通信。套接字是一种通信端点,通过IP地址、端口号和协议类型来定义。它支持多种协议,最常见的是TCP(面向连接、可靠)和UDP(无连接、不可靠)。适用范围最广(跨网络)。功能强大但设置相对复杂。
3、分布式场景,全局限流器是怎么添加的?(实习相关)
需要对“账号池”这个资源进行全局性的速率控制。关键在于:
- 全局性:限流状态必须被所有发起请求的JVM实例共享(比如您部署了多个服务实例)。
- 高效率:限流器的判断必须非常快,不能成为性能瓶颈。
- 准确性:在分布式环境下,限流要尽可能准确,避免在时间窗口切换时产生超出限额的请求(毛刺现象)。
首先,可以直接回答我们方案里这个全局限流器是通过现成的限流中间件(类似阿里的Sentinel)来实现的。

其次,可以介绍下redis + Lua脚本的方式:


4 Java线程安全的List



5 Lua脚本是不是一定能够保证原子性



6 MySQL可重复读为什么还会有幻读问题?



7 RabbitMQ整体的系统架构
生产者:消息的发送方,创建消息并发布到RabbitMQ服务器(Broker)上的一个交换机;
消费者:消息的接收方,可以订阅一个或多个队列,当队列有消息时,Broker会将消息推送给消费者,消费者处理这些消息;
交换机:消息的路由中心,生产者将消息发送到交换机,而不是直接发送到队列,交换机会根据路由规则决定把消息投递到哪个消息队列上;
- 如果是Direct交换机,是精确匹配的路由键,会把消息投递到绑定键与之完全匹配的队列,点对点的精确发送;
- 如果是Fanout:广播模式,将消息投递到所有绑定到该交换机上的队列,忽略路由键;
- 如果是Topic:模式匹配路由键,使用通配符(*匹配一个词,#匹配零或多个词)进行匹配。常用于基于模式的多播。
- Headers:通过匹配消息的Headers属性(而非路由键)来路由消息。不常用。
队列:消息的存储缓冲区,本质上是一个FIFO(先进先出)的数据结构。职责是存储被交换机路由过来的消息,直到消息被消费者安全地处理(ACK)或丢弃。队列可以持久化(确保Broker重启后仍存在),也可以是独占的(只对声明它的连接可见,连接关闭后自动删除)或自动删除(当最后一个消费者断开连接后自动删除)。
Broker:指RabbitMQ服务器本身,是以上所有组件的运行容器。接收客户端连接、管理消息的路由和分发、实现AMQP协议、提供权限管理和插件系统等。
8 RabbitMQ消息堆积怎么处理


9 优惠劵场景,RabbitMQ保证一致性问题
本地消息表的核心思想是将分布式事务拆分为两个本地事务,通过数据库的事务特性和重试机制保证最终一致性:
第一个本地事务:在业务数据库中同时完成订单状态更新和消息记录
第二个本地事务:通过定时任务将记录的消息可靠地发送到MQ
具体实现流程:
第一阶段:业务处理与消息记录
开启数据库事务:当优惠券被抢后,系统开始处理优惠券状态更新
更新订单状态:在同一个数据库事务中,将优惠券状态从"待发放"改为"已发放"
写入本地消息表:在同一事务中,向专门设计的消息表插入一条记录,包含:
消息ID(唯一标识)、消息内容(JSON格式的优惠券数据)、消息状态(初始为"待发送")、创建时间、重试次数(初始为0)
提交事务:只有当优惠券更新和消息记录都成功后才提交事务
第二阶段:消息发送与状态更新
定时任务扫描:系统有一个独立的定时任务,定期(如每5秒)扫描本地消息表中状态为"待发送"的记录
发送MQ消息:对于每条待发送记录:尝试将消息内容发送到消息队列。如果发送成功:更新该消息记录状态为"已发送";如果发送失败:增加重试次数、记录错误日志、保持状态为"待发送"
重试机制:对于发送失败的消息,定时任务会在下次扫描时重新尝试发送,直到:发送成功或达到最大重试次数(如5次),此时可将状态改为"发送失败"并报警。
10 redis怎么去添加分布式锁,应该注意什么



11 Java 17的新特性,G1之后的新垃圾回收器
G1之后的新一代垃圾回收器
(G1)自Java 9起成为默认回收器,它的设计目标是在延迟和吞吐量之间取得良好平衡。但在它之后,为了应对超大内存(多TB级别)和极致低延迟(停顿时间<10ms)的需求,Oracle推出了更为先进的回收器:ZGC。
核心目标是:尽可能缩短垃圾回收引起的停顿时间(STW),并且停顿时间不会随着堆大小的增加而显著增长。
关键技术与特点:
- 并发处理:ZGC的几乎所有工作(标记、转移、重定位)都是并发执行的,仅在最初有一个极短的STW进行根扫描。
- 染色指针:它在指针的64位地址中存储了额外的元数据,用于标记对象的状态(可达、已转发等)。这使得垃圾回收器在并发阶段能直接通过指针判断对象状态,而无需访问对象本身。
- 负载屏障(Load Barrier):当应用程序线程从堆中加载引用时,会触发一个“负载屏障”。这个屏障检查指针上的元数据,如果需要,会在应用程序线程继续执行之前完成一些轻量的回收工作(如对象重定位)。
其他值得一提的GC
- Serial GC:依然是单线程、小内存场景(如客户端程序、嵌入式设备)的最高效选择。
- Parallel GC:又名吞吐量收集器,在Java 8及之前是默认的,适合需要最大化计算吞吐量、对停顿不敏感的后台处理应用。
13 数据冷热库分库,如果冷库数据突然变热怎么办?



14 索引下推
索引下推的核心思想是:将原本在Server层进行的部分数据过滤操作,“下推”到存储引擎的索引层面来完成。
这样做的最大好处是减少了存储引擎必须回表查询的次数和返回给Server层的数据行数,从而显著提升查询性能,尤其是对于包含模糊查询或范围查询的复合索引场景。
为什么需要索引下推?—— 通过一个例子对比
假设我们有一张user表,并建立了一个复合索引idx_name_age (name, dep_id, age)。
现在我们要执行这样一个查询:查找所有姓“张”并且年龄大于15岁的用户。
SELECT \* FROM user WHERE name LIKE '张%' AND age > 15;


15 RabbitMQ怎么保证消息的顺序性
方案一:单队列单消费者(FIFO最简单模式)
这是最直接但也限制最大的方法。
原理:
- 生产者:将需要保证顺序的所有消息发送到同一个队列中。
- 消费者:该队列只能有一个消费者,并且设置channel.basicQos(1),即每次只取一条消息,处理完一条再取下一条。
优点:
- 实现简单,充分利用了RabbitMQ队列本身的FIFO(先进先出)特性。
缺点:
- 无法水平扩展:单个消费者是性能瓶颈,吞吐量低。
- 单点风险:如果该消费者宕机,虽然消息不会丢,但处理会完全停止。
- 适用场景:消息量非常小,对吞吐量要求不高的场景。
方案二:根据消息ID或业务键进行分组(路由到同队列)
这是最常用且合理的方案,核心思想是:将需要保证顺序的消息通过路由键确保它们进入同一个队列,并被同一个消费者顺序处理。
原理:
- 消息分组:对消息进行分组。例如,订单ID为order_123的所有消息(创建、付款、发货)必须顺序处理;而订单ID为order_456的消息是另一组,它们之间没有顺序要求。
- 生产端:使用一致性哈希交换器或自定义路由键,将同一组的消息总是路由到同一个队列。
例如:使用x-modulus-hash这类交换器,以订单ID order_123作为路由键,计算出的哈希值总会将其路由到队列queue_2。
- 消费端:为每个队列启动一个消费者(可以是多个队列,即多个消费者实例,每个实例处理不同组的消息)。同样,每个消费者需要设置prefetchCount=1。
工作流程:
- 订单A(ID=1)的所有消息 → 路由键order_1→始终发往队列1→ 由消费者1处理。
- 订单B(ID=2)的所有消息 → 路由键order_2→ 始终发往队列2→ 由消费者2处理。
优点:
- 高性能且可扩展:不同的组(如不同的订单)可以被不同的消费者并行处理,解决了方案一的瓶颈问题。
- 逻辑清晰:符合大部分业务场景(如订单、会话跟踪)。
缺点:
- 需要提前规划好消息的分组逻辑。
- 如果某个组的消息特别多(“热点订单”),对应的队列和消费者可能会有压力,但通常这种情况较少。
16 MySQL持久化机制;redolog日志的作用
MySQL的持久化机制的核心目标是:确保一旦事务提交,它对数据库所作的更改就是永久性的,即使发生系统崩溃、断电等故障,数据也不会丢失。
这个机制主要依赖于以下几个核心组件和技术协同工作:
InnoDB缓冲池 (Buffer Pool)
重做日志(Redo Log)
二进制日志(Binlog)
双写缓冲区(Doublewrite Buffer)
日志刷盘机制
1. 核心组件介绍
a.InnoDB缓冲池
这是InnoDB引擎的核心内存区域,用于缓存数据和索引。所有的数据修改(增、删、改)都不是直接写入磁盘的数据文件,而是首先在Buffer Pool中修改对应的“数据页”。这极大地提升了性能。
脏页(Dirty Page):在Buffer Pool中被修改过但与磁盘上数据文件内容不一致的数据页,被称为“脏页”。
刷脏(Flushing):后台有专门的线程负责在特定时机将“脏页”刷新到磁盘的数据文件中,使内存和磁盘的数据保持一致。
b.重做日志 (Redo Log)
这是InnoDB引擎特有的日志,是实现崩溃恢复(Crash Recovery)的关键。它是一个物理日志,记录的是物理数据页的修改内容(例如:“在表空间A、页面B、偏移量C处写入数据D”)。
- 循环写入:Redo Log文件是固定大小的,通常由两个文件(ib_logfile0, ib_logfile1)组成,以循环写入的方式工作。
- 顺序I/O:写入Redo Log是顺序追加的,速度远快于随机写入数据文件。
- 作用:当发生崩溃时,InnoDB会重放Redo Log中从最后一个检查点(Checkpoint)开始的所有日志记录,将数据库恢复到崩溃前的状态,从而保证已提交事务的持久性。
c.二进制日志(Binlog)
这是MySQL Server层实现的日志,所有存储引擎都可以使用。它是一种逻辑日志,记录的是逻辑性的SQL语句(Statement模式)或行的更改(Row模式)。
主要用途:用于主从复制(Replication)和基于时间点的数据恢复(Point-in-Time Recovery)。
追加写入:Binlog文件会不断增长,不会循环覆盖。
d.双写缓冲区(Doublewrite Buffer)
这是 InnoDB 的一个安全特性,用于解决部分页写入问题。
问题:操作系统磁盘I/O的单位是页(通常是4KB),而InnoDB数据页的大小是16KB。这意味着写一个数据页需要4次磁盘I/O。如果在写入过程中系统崩溃,可能导致只有部分4KB被写入(页数据损坏)。
解决方案:在将“脏页”刷到数据文件的实际位置之前,InnoDB会先将它们顺序地、批量地写入磁盘上一个叫“双写缓冲区”的连续区域。完成后,再将这些页分散写入到数据文件中各自的真正位置。
恢复:如果发生崩溃,InnoDB在恢复过程中可以通过比较双写缓冲区中的数据页和数据文件中的页来发现损坏的页,并用双写缓冲区中完整的副本来修复它。
2. 持久化流程(以更新操作为例)
事务执行:应用程序发出UPDATE语句。
修改缓冲池:InnoDB从磁盘(或Buffer Pool)加载对应的数据页到Buffer Pool,并在内存中修改它,使其成为“脏页”。
写入Redo Log Buffer:将产生这次修改的Redo Log 记录写入内存中的Redo Log Buffer。
事务提交(关键步骤):应用程序发出COMMIT。
此时,根据innodb_flush_log_at_trx_commit的设置,会有不同的行为:
- = 1 (默认,最安全):强制将Redo Log Buffer中的所有内容刷新(fsync)到磁盘的Redo Log文件。确保只要事务提交成功,其对应的Redo Log一定已经物理写入磁盘。这是真正的持久化保证。
- = 2:将Redo Log Buffer中的内容写入到操作系统内核的页面缓存(Page Cache),但不立即执行fsync。如果MySQL进程挂了,但操作系统没挂,数据不会丢;如果机器断电,由于数据还在Page Cache中,可能会丢失约1秒的数据。
- = 0:每秒一次地将Redo Log Buffer的内容写入Page Cache并刷盘。事务提交时不会主动触发写入。性能最好,但崩溃时最多会丢失1秒钟的事务。
写入Binlog:在事务提交的过程中,也会将本次修改以逻辑格式写入Binlog Cache,然后根据sync_binlog参数的设置,决定何时将Binlog刷盘(sync_binlog=1表示每次提交事务都刷盘,最安全)。
两阶段提交(2PC):为了确保Redo Log和Binlog的逻辑一致性(即两者要么都记录了这个事务,要么都没记录),MySQL使用了内部的两阶段提交协议。这是另一个复杂但至关重要的机制。
返回成功:完成上述步骤后,才向客户端返回“提交成功”。
后台刷脏:提交成功后,数据仍在Buffer Pool的“脏页”中。InnoDB的后台线程会在之后某个时间点(根据负载、脏页比例等因素)将“脏页”通过双写缓冲区安全地刷新到磁盘的数据文件中。
如何保证持久性?
在内存中的脏页被刷盘之前,一定要先保证其对应的Redo Log已经持久化到磁盘。
这意味着,数据的修改不是立即写数据文件,而是先写日志(Redo Log)。只要Redo Log被安全地写入磁盘,即使数据页还没有刷盘,系统崩溃后依然可以通过Redo Log来重做恢复。而提交事务时强制刷Redo Log(innodb_flush_log_at_trx_commit=1)这个动作,就是持久性承诺兑现的时刻。
因此,配置MySQL实现真正的持久化,通常建议:
- innodb_flush_log_at_trx_commit = 1
- sync_binlog = 1
- innodb_doublewrite = ON(默认)
虽然这样配置会牺牲一些写性能(因为每次提交都要等待磁盘I/O),但它提供了最高级别的数据安全保证。在实际生产中,会根据业务对数据安全和性能的要求进行权衡。
RedoLog日志的作用
为什么需要Redo Log?
要理解它的作用,首先要明白它要解决什么问题:
性能问题:直接写磁盘上的数据文件是随机I/O,速度很慢。如果每次事务提交都要把修改的数据页写回磁盘,数据库的性能会极其低下。
安全性问题:如果不在事务提交时做点什么,万一此时数据库崩溃,内存中已提交但未写入磁盘的数据就会永远丢失,这违反了事务的“持久性”原则。
InnoDB的解决方案是:
修改数据先在内存中完成:所有数据的修改都在内存中的**缓冲池(Buffer Pool)**里进行,这样可以极大地提升速度。
采用“预写日志”策略:但为了保证持久性,它遵循WAL (Write-Ahead Logging)原则,即在内存中的脏页被刷回磁盘之前,必须先把描述这个修改的日志记录持久化到磁盘。这个“描述修改的日志”就是Redo Log。
Redo Log 是如何工作的?
可以把Redo Log想象成一个高效的“记账本”。
记账内容:它记录的是物理日志,内容类似于:“在表空间A、页面号B、偏移量C的位置,将数据更新为D”。这种记录方式非常紧凑且高效。
顺序写入:Redo Log文件是循环写入、顺序追加的。顺序写的速度远快于随机写数据文件。
流程比喻:
当你要修改一条数据时(比如更新金额),InnoDB并不会直接去金库(数据文件)里翻箱倒柜。
它先把“某某账户增加100元”这个操作记录到“记账本”(Redo Log Buffer)上。
当你确认交易完成(事务提交)时,InnoDB会立刻让账房先生把这条记账内容用钢笔(fsync)正式地、永久地誊写到账本上(将Redo Log刷盘)。
至于真正去金库里搬动金币(将脏页刷回数据文件)这个体力活,可以交给后台伙计稍后闲的时候批量完成。
这样,即使伙计还没来得及去金库搬钱(脏页未刷盘),突然房子塌了(数据库崩溃),重启后只要查一下记账本(重放 Redo Log),就知道哪些交易完成了但还没入库,然后根据记录再去金库把该搬的钱搬好,数据就恢复了。
Redo Log的两个主要作用
基于上述原理,Redo Log 具体负责两件事:
1.崩溃恢复(Crash Recovery)
这是它最核心的职责。当MySQL实例意外宕机重启后,InnoDB会自动执行恢复流程:
- 检查最后一个检查点(Checkpoint)。
- 从检查点开始,读取Redo Log文件中的记录。
- 将日志记录中描述的所有修改**重新应用(重做)**到数据页上,将数据库恢复到宕机前的状态。
- 这样就保证了所有已提交事务的数据都完好无损。
2.确保提交事务的持久性
通过配置参数innodb_flush_log_at_trx_commit,你可以控制事务提交时Redo Log的刷盘行为:
= 1 (默认值,最安全):每次事务提交时,都强制将Redo Log从内存缓冲区(Log Buffer)刷新到磁盘。这确保了即使系统崩溃,已提交的事务也绝不会丢失。
= 2:每次事务提交时,只将Redo Log写入操作系统的页面缓存(Page Cache)。如果只是MySQL进程崩溃,操作系统没崩,数据不会丢;但如果机器断电,数据可能丢失。
= 0:每秒刷一次日志到磁盘。事务提交时不会主动触发写入。性能最好,但崩溃时最多会丢失1秒钟的事务。
只要这个参数设置为 1,事务的持久性就得到了绝对保证。
17 千万请求量的文件处理系统怎么去设计架构










18 分布式熔断(Sentinel)


| 熔断策略 | 触发条件 | 统计时长(可配置) | 适用场景 |
|---|---|---|---|
慢调用 比例 | 单位统计时长内请求数>minRequestAmount且慢调用比例>slowRatioThreshold | 支持 | 依赖服务响应变慢(如数据库慢查询、第三方API延迟增高) |
| 异常比例 | 单位统计时长内请求数>minRequestAmount且异常比例>count | 支持 | 依赖服务出现错误(如接口抛异常、超时) |
| 异常数 | 单位统计时长内异常数>count | 支持 | 需要关注绝对异常数量的场景(注意timeWindow需配置得较大一些) |



19 分布式锁是可重入锁吗
- 分布式锁:指的是锁的作用范围,它是一种跨进程、跨机器的同步机制,用于在分布式系统中控制对共享资源的访问。
- 可重入锁:指的是锁的一种行为特性(也称为递归锁)。它允许同一个线程在已经持有锁的情况下,再次成功获取该锁,而不会被阻塞。
| 实现方式 | 是否通常可重入? | 说明 |
|---|---|---|
| 数据库(如MySQL)悲观锁 | 通常不可重入 | 基于SELECT ... FOR UPDATE。同一个事务内再次执行此语句会被阻塞,因为MySQL的行锁在事务内不可重入。需要应用层自己模拟(例如记录线程ID和计数)。 |
| Redis(SETNX + Lua) | 可实现为可重入 | 原生的SETNX命令本身不支持。但成熟的客户端(如Redisson)通过Lua 脚本实现了可重入锁。它在锁的Value中存储线程标识和重入计数。 |
| ZooKeeper | 原生支持可重入 | ZooKeeper的序列节点特性可以很自然地实现可重入锁。客户端在创建节点时记录自己的信息,再次尝试获取锁时会检查当前节点是否已经是持有锁的节点。Curator框架提供的InterProcessMutex就是可重入的。 |
| Etcd | 可实现为可重入 | 类似于ZooKeeper,基于租约和KV存储,可以通过在Value中存储持有者信息和计数器来实现可重入。 |



B某站篇
1 SQL查询,平时正常,但是偶发性的变慢SQL,可能是什么情况
(1)数据量和统计信息的不稳定性(最常见)
这是最可能的原因。MySQL的查询优化器依赖于对表的统计信息(如行数、索引分布、基数等)来决定使用哪个索引和执行计划。
这些统计信息不是实时更新的。当表中数据发生大量增删改(特别是UPDATE和DELETE)后,统计信息会变得过时。优化器基于过时的信息,可能选择了一个非最优的索引(甚至全表扫描),导致查询突然变慢。
为什么是间歇性的:UPDATE和DELETE操作积累到一定程度,或者触发了自动更新统计信息的阈值后,统计信息被更新,新的、可能更差的执行计划被生成,慢SQL就出现了。或者,当表数据量突破某个临界点时,原来的好计划突然就不好了。
之所以又可以自愈:

排查方法:
- 在慢SQL出现时,使用EXPLAIN语句分析当前的执行计划。
- 与正常时的EXPLAIN结果进行对比,看是否使用了不同的索引(key字段)或访问方式(type字段)。
- 检查表的统计信息收集设置:SHOW CREATE TABLE your_table_name;查看STATS_PERSISTENT设置。
(2)缓存失效
MySQL有多层缓存,缓存失效会导致查询需要重新处理。
InnoDB缓冲池(Buffer Pool):这是最重要的缓存,缓存了表数据和索引。如果系统有重启、缓冲池大小设置不合理、或者突然有一个大型查询/报表任务扫走了大量缓存数据,那么你的查询所需的数据就可能被挤出缓存,导致必须从磁盘读取,速度急剧下降。
查询缓存(Query Cache)(注意:MySQL 8.0中已移除):如果启用了QC,一旦表有任何修改,所有基于该表的查询缓存都会失效。后续的查询需要重新执行。如果你的表每隔一段时间才被更新一次,那么更新后的第一次查询就会变慢。
排查方法:
- 监控缓冲池命中率:SHOW STATUS LIKE 'Innodb_buffer_pool_read%’;。理想情况下,Innodb_buffer_pool_read_requests(总请求数)远大于Innodb_buffer_pool_reads (从磁盘读取的次数),命中率应接近99%。
- 检查缓冲池大小:SHOW VARIABLES LIKE 'innodb_buffer_pool_size’;,确保它设置得合理(通常是系统内存的50%-70%)。
(3)系统资源竞争
数据库服务器本身可能正在经历周期性的资源瓶颈。
周期性任务:是否存在定时任务(如每日/每周报表生成、ETL作业、批量数据处理)?这些任务会消耗大量的CPU、内存和磁盘I/O资源,与你的常规查询争抢资源,导致其变慢。
邻居吵闹:如果数据库是部署在虚拟机或云服务器上,可能存在“邻居吵闹”问题。同一台物理机上的其他虚拟机在某段时间内资源使用激增,影响到了你的数据库性能。
排查方法:
- 检查慢SQL发生时间点系统的监控指标:CPU使用率、内存使用率、磁盘I/O使用率(特别是await和util指标)、网络流量。看是否有明显的关联性。
- 检查MySQL的进程列表:在变慢时执行SHOW PROCESSLIST;,看看是否有其他大型查询正在运行。
(4)锁竞争
你的查询可能正在等待获取锁。
- 行锁/表锁:你的应用程序可能在某些业务场景下(如定期结算、批量更新)持有锁的时间过长,导致其他查询被阻塞。虽然你的查询本身很快,但等待锁的时间被计入执行时间,从而成为“慢SQL”。
- 元数据锁(Metadata Lock):有一个长时间运行的事务(甚至是一个未提交的SELECT)或者正在执行ALTER TABLE等DDL操作,会阻塞其他需要获取元数据锁的查询。
(5)应用程序层面的周期性模式
- 缓存穿透:应用层的缓存(如Redis)可能定期失效。失效后,大量请求直接打到数据库,造成数据库瞬时压力过大,其中一些查询响应变慢。
- 特定时间的高流量:例如,每周一的早上用户活跃度最高,并发请求数增加,数据库负载加重,导致个别查询性能下降。
如何系统性地排查和解决?
开启并监控慢查询日志
- 确保MySQL的慢查询日志(slow query log)是开启的,并设置一个合理的阈值(如long_query_time = 2秒)。
- 使用工具(如mysqldumpslow, pt-query-digest)定期分析慢日志,找出TOP N的慢SQL及其出现的时间规律。
- 在问题发生时现场排查
- 第一步:使用SHOW PROCESSLIST; 查看当前所有连接状态,有没有阻塞、有没有状态不正常的查询。
- 第二步:对变慢的SQL立即执行EXPLAIN,分析其执行计划。保存下来与正常时对比。
- 第三步:查看服务器资源状态(top, htop,iostat -x 1)和MySQL状态变量(SHOW GLOBAL STATUS LIKE 'Threads_running’; 查看并发数,SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%’; 查看行锁情况)。
2、分库分表怎么保证一致性,分布式事务具体应该怎么做?
分库分表下的一致性挑战
在单库单表时代,我们依赖数据库本身的ACID事务(特别是原子性和隔离性)来保证一致性。所有操作在一个数据库事务内,要么全部成功,要么全部失败。
但在分库分表后,数据被分散到不同的数据库(分库)甚至不同的服务器上。一个业务逻辑可能涉及更新多个数据库(例如:订单库和库存库),这就产生了分布式事务问题。你无法再用一个本地数据库事务来涵盖所有操作。
核心挑战在于CAP理论:
C (一致性): 所有节点在同一时间看到的数据是一致的。
A (可用性): 每个请求都能得到响应(不保证是最新数据)。
P (分区容错性): 系统在遇到网络分区时仍然能正常工作。
在分布式系统中,P(网络分区)是必须接受的,因此我们只能在C和A之间做权衡。分布式事务的各种方案,其实就是根据业务场景在一致性和可用性之间做出不同的选择。
分布式事务的具体做法(解决方案)
分布式事务的解决方案主要分为两大类:强一致性和最终一致性。
方案一:强一致性方案
这类方案追求数据的实时一致性,通常性能开销较大,实现复杂。适用于对数据一致性要求极高的金融、支付等场景。
1.两阶段提交(2PC-Two-PhaseCommit) 这是一种经典的分布式事务协议,包含一个协调者(Coordinator)和多个参与者(Participant)。
第一阶段:准备阶段(PreparePhase)
- 协调者向所有参与者发送事务内容,询问是否可以提交。
- 每个参与者执行事务但不提交,写入undo和redo日志,然后锁定资源。
- 参与者回复协调者:“可以提交”(Yes)或“不能提交”(No)。
第二阶段:提交/回滚阶段(Commit/RollbackPhase)
- 如果所有参与者都回复Yes:协调者向所有参与者发送Commit命令。参与者正式提交事务,释放锁资源。
- 如果任何一个参与者回复No或超时:协调者向所有参与者发送Rollback命令。参与者利用undo日志回滚事务,释放锁资源。
优点:最大限度地保证了数据的强一致性。
缺点:
- 同步阻塞:所有参与者在等待协调者指令时都处于阻塞状态,资源被锁定,性能差。
- 单点问题:协调者宕机会导致整个事务阻塞或数据不一致。
- 数据不一致:在第二阶段,如果只有部分参与者收到了Commit请求,会导致数据不一致。
2. 三阶段提交 (3PC)
针对2PC的优化,引入了超时机制和预提交阶段,超时机制一定程度解决了单点故障的问题,但是依然无法彻底解决数据不一致问题。
方案二:最终一致性方案(主流推荐)
这是互联网公司更常用的模式,它不追求实时强一致,而是允许系统存在一个短暂的“不一致”状态,通过一些补偿措施,最终达到一致。它的可用性高,性能好。
1.TCC (Try-Confirm-Cancel)
TCC是一种业务侵入性较强的补偿型方案,需要业务逻辑本身提供三个接口:
Try 阶段:尝试执行。完成所有业务的检查,并预留必要的业务资源(例如:冻结库存、预扣优惠券、预生成订单状态为“处理中”)。
Confirm阶段:确认执行。真正执行业务操作,使用Try阶段预留的资源。此操作默认会成功。
Cancel阶段:取消执行。释放Try阶段预留的资源(例如:解冻库存、返还优惠券)。
工作流程:
- 事务协调器依次调用所有服务的 Try 接口。
- 如果所有 Try 都成功,协调器再调用所有服务的 Confirm 接口,事务提交。
- 如果任何一个 Try 失败,协调器则调用所有已完成 Try 服务的 Cancel 接口,事务回滚。
优点: 性能比 2PC 好,数据最终一致。
缺点: 对代码侵入性强,需要为每个业务逻辑设计 try/confirm/cancel 三个方法。需要处理网络重试、空回滚、幂等、防悬挂等复杂问题。
代表框架:阿里的Seata(AT模式是TCC的变种,无侵入)
2.本地消息表(异步确保)
这是利用消息队列实现最终一致性的经典模式,非常常用。
工作流程:
- 步骤1 & 2:系统A在执行本地事务时,将需要发送的消息和业务数据放在同一个数据库事务里,存入一张“消息表”(状态为“发送中”)。
- 步骤3:系统A有一个定时任务,轮询消息表中状态为“发送中”的消息,并将其发送给消息队列(如 RocketMQ, Kafka)。
- 步骤4:消息队列将消息投递给消费者系统B。
- 步骤5:系统B消费消息,执行本地事务。执行成功后,通知消息队列ack确认。
- 步骤6:如果系统B消费失败,消息队列会重试投递。系统A的定时任务也会对长时间未确认的消息进行重发。
- 优点:方案简单,依赖MQ的可靠性,保证了消息最终一定会被消费。
- 缺点:消息表会耦合在业务数据库中;存在重复消费的可能,要求消费者接口是幂等的。
- 消息事务 (e.g., RocketMQ事务消息)
这是对本地消息表的优化,由消息队列中间件直接提供事务支持,避免了自建消息表。
工作流程:
- 生产者发送一个“半消息”到MQ,此时消费者不可见。
- MQ回复“半消息”发送成功。
- 生产者执行本地事务。
- 生产者根据本地事务执行结果(成功/失败),向MQ发送Commit或Rollback指令。
- 如果MQ收到Commit,则“半消息”变为正式消息,可供消费者消费。如果是Rollback,则丢弃消息。
- 补偿流程:如果MQ长时间未收到生产者的确认,会回查生产者的本地事务状态,据此决定提交或回滚消息。
优点:解决了本地消息表与业务耦合的问题。
缺点:需要消息中间件支持此功能(RocketMQ支持)。
- Saga 模式
一种长事务解决方案,将一个大事务拆分为多个本地小事务,每个小事务都有对应的补偿操作。
工作流程:
- 按顺序执行所有子事务。
- 如果所有子事务都成功,事务完成。
- 如果其中一个子事务失败,则反向依次调用前面所有已成功子事务的补偿操作进行回滚。
优点:一阶段直接提交本地事务,无锁,性能好。
缺点:不保证隔离性,可能出现“脏写”(一个事务覆盖了另一个未完成事务的更改),需要业务层自己处理(例如:版本号控制)。
模式:
- choreography(协同):每个服务产生事件并监听其他服务的事件,自己决定是否执行补偿。
- orchestration(编排):由一个协调器(orchestrator)集中负责指挥服务的调用和补偿。
总结与选型建议
| 方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 低 | 中 | 传统金融、数据库代理层 |
| TCC | 最终一致 | 中 | 高(业务侵入) | 对一致性有较高要求,如资金、积分 |
| 本地消息表 | 最终一致 | 高 | 中 | 几乎所有异步场景,如扣库存后发积分 |
| MQ事务消息 | 最终一致 | 高 | 低 | 同本地消息表,是它的升级版 |
| Saga | 最终一致 | 很高 | 高(补偿逻辑) | 长流程业务,如旅行订票(订机票、酒店、租车) |
给你的建议:
优先选择最终一致性:99%的业务场景都可以通过最终一致性方案来解决。它保证了高可用和高性能。
代码侵入性:如果不想大幅修改代码,可以选择Seata AT 模式或RocketMQ 事务消息。
强一致性场景:如果必须是强一致(如银行转账核心链路),再考虑2PC,但要深知其性能代价。
幂等性:在分布式环境下,网络超时、重试是家常便饭,任何服务的接口都必须设计为幂等的(即多次调用产生的结果与一次调用相同)。
人工补偿:任何系统都不能保证100%可靠,必须要有对账和人工补偿机制,用于处理极端的异常情况。
3、分库分表,什么时候垂直分什么时候水平分?
垂直分库分表
核心思想:“按业务模块拆分”或“按列拆分”。将一张宽表或者一个完整的数据库,拆分成多个不同的、更小的表或数据库。
它主要解决的是:单库/单表因字段过多导致的业务耦合和性能问题。
1. 垂直分表
基于列进行拆分。将一个包含很多字段的大表,根据访问频次和业务关联性,拆分成多个扩展表。
常见做法:
冷热分离:将“常用字段(热数据)”和“不常用字段(冷数据)”分开。
大字段分离:将TEXT, BLOB等占用空间大的字段单独拆到一张扩展表中。
例子:一张t_user表有50个字段,其中id, name, email, phone是查询最频繁的,而personal_info, description等文本字段不常查询。
拆分后:
t_user (核心表): id, name, email, phone, ...
t_user_ext (扩展表): id, user_id, personal_info, description, ...
何时使用:
表字段非常多,但每次查询只涉及其中一部分。
存在超长文本(TEXT/BLOB)字段,影响了核心表的查询和运维效率(如慢SQL、备份慢)。
2.垂直分库
基于业务模块进行拆分。将一个大而全的数据库,拆分成多个小而专的数据库。
例子:一个电商数据库shop_db包含了用户、商品、订单、支付等所有数据。
拆分后:
user_db (用户库): 存放用户、会员等级等相关表。
product_db (商品库): 存放商品、品类、库存等相关表。
order_db (订单库): 存放订单、购物车等相关表。
pay_db (支付库): 存放支付、账户等相关表。
何时使用:
业务系统模块清晰,耦合度低。
应用程序本身已经按照微服务进行了拆分,数据库自然也需要跟随服务进行隔离。
希望不同的业务团队能独立拥有和管理自己的数据库,减少相互影响。
垂直分库/分表的优点:
解耦业务:降低系统的复杂度和耦合度。
提升性能:减少单次I/O的数据量(垂直分表)、减少单库的连接数和处理压力(垂直分库)。
便于维护:不同团队可以专注于自己的数据库,升级、运维更灵活。
垂直分库/分表的缺点:
无法解决单表数据量过大的问题。order_db里的订单表依然会无限增长。
可能带来跨库Join的问题。原本一个Join查询就能搞定的事,现在需要业务代码或中间件做多次查询和结果聚合,复杂度增加。
分布式事务问题开始出现。例如:下单操作需要同时操作 order_db 和 pay_db,需要引入分布式事务方案。
水平分库分表
核心思想:“按数据行拆分”。将同一个表中的数据,按照某种规则(分片键)分散到多个结构相同的数据库或表中。
它主要解决的是:单库单表数据量过大、读写性能瓶颈的问题。
水平分表:将一张表的数据分到同一个数据库的多个表中。例如:order_0,order_1, ...order_n。
水平分库:将一张表的数据分到多个数据库的多个表中。例如:db0.order_0, db1.order_1, ... dbn.order_n。(水平分库是水平分表的进阶,通常一起进行)
分片规则(Sharding Key):
哈希取模:根据某个字段(如user_id)的哈希值对分片数量取模,决定数据落在哪个分片。优点:数据分布均匀。缺点:扩容时需要迁移大量数据(一致性哈希可以缓解)。
范围分片:根据某个字段的范围进行分片,如按时间(月/年)、按id区间。优点:易于扩容。缺点:容易产生数据热点(最新的分片读写频繁)。
地理分片:根据用户所在地等地理位置信息分片。
自定义规则:根据业务特点自定义复杂规则。
何时使用:
单表数据量巨大,预计未来会超过千万甚至亿级别,导致索引膨胀,查询性能下降。
数据库的CPU、内存、I/O或连接数即将达到瓶颈,且无法通过升级硬件解决。
需要解决写操作的性能瓶颈(因为读操作还可以通过读写分离缓解)。
水平分库/分表的优点:
根本性解决大数据量存储和性能问题。
大幅提升系统的扩展性和吞吐量。
水平分库/分表的缺点:
复杂度急剧上升:
- 跨库Join:几乎无法执行,需要在业务层模拟。
- 跨库聚合排序:count(), order by, group by变得异常复杂。
- 全局主键ID:不能再用数据库自增ID,需要分布式ID生成方案(雪花算法等)。
- 分片键的选择至关重要,选错可能导致数据倾斜和热点。
- 运维复杂度高:数据迁移、扩容、缩容都非常麻烦。
总结与决策流程图
核心原则:能不分则不分,优先选择简单的优化方案(如优化SQL、索引、读写分离、缓存),只有当这些手段都无法满足时,再考虑分库分表。
如何选择?
| 维度 | 垂直分库/分表 | 水平分库/分表 |
|---|---|---|
| 拆分角度 | 业务/字段维度 | 数据行维度 |
| 目标 | 解耦,专库专用 | 扩容,解决性能瓶颈 |
| 影响 | 表结构不同 | 表结构相同 |
| 适用场景 | 业务清晰,系统耦合度高;字段多且有冷热之分 | 单表数据量巨大,写操作频繁 |
4、自己有使用过哪些JAVA的设计模式
(1)单例设计模式:全局配置管理、工具类
使用场景:项目中的全局配置类(如AppConfig)、日志工具类(LoggerUtil),这类组件需要全局唯一实例,避免重复加载资源或创建冗余对象。
(2)工厂模式
有简单工厂和抽象工厂,简单工厂就是我们就只有一个工厂类,然后通过if-else的判断来确定创建什么对象,但是这种方式是不符合开闭原则的,每次新增一个对象,都需要去修改工厂。
更常用的其实是抽象工厂,比如支付业务,就可以通过抽象工厂模式去实现不同支付方式的创建逻辑:
我们可以先声明一个统一的支付接口,里面通过一个抽象方法来约束所有支付方法的形参和返回值。然后多种支付方式类,比如微信支付,支付宝支付,银行卡支付等等,都去实现这个接口,每个支付方式类都去重写接口的支付方法实现自己的支付逻辑。然后我们再去声明一个支付工厂接口,每种支付方式的工厂类都去实现统一的支付工厂接口,这些工厂类都有自己的支付方式类的对象创建逻辑。
我们在使用的时候,就可以直接根据需要去new对应的工厂类,通过工厂类调用统一的方法来实现支付方式对象的获取,通过多态的方式,我们不需要去关心每种支付方式的具体实现类,直接通过统一的支付接口进行声明即可。
(3)策略模式
比如我的项目里实现答题评分,就使用了策略模式,通过声明一个统一的策略接口,约束评分策略的入参和返回值。然后让多个具体的评分策略类去实现我们的策略接口,在每个类中具体去实现自己的评分逻辑。然后我们通过一个自定义注解,来进行不同类型的应用的区分,因为不同应用类型就对应不同的评分逻辑。所以在调用的时候,就可以根据我们创建应用的类型,来动态获取对应的评分策略,避免了大量if-else的判断。
(4)观察者模式
一般是指某一个模块状态发送变化时,会去通知其他多个模块,同步的去改变状态。
比如,电商场景下,我们订单发送变化,可能库存、物流模块都需要相应的去改变状态。具体的实现,比如我们去声明一个被观察者的接口,里面定义几个方法,分别是添加观察者,移除观察者,和通知观察者;再去定义一个观察者的接口,主要是接收通知并更新状态的方法。然后我们可以在具体的被观察者中,通过一个List,去把所有的观察者加进List中,然后如果被观察者某个量发送改变了,我们就可以通过通知观察者的方法,在里面去调用观察者的接收通知并更新状态的方法,实现对观察者状态的同步改变。
具体我使用的场景,其实是利用了现成的RxJava库,来实现对AI流式生成的结果进行实时的事件流的观测,这里的被观察者就是AI流式接口,观察者就是我们接收返回结果的接口,被观察者产生事件流之后会主动调用我们的结果处理接口,对流式接口的结果进行单道题目的拼接。
5、上传文件切片后,服务器端是怎么进行处理的,如果因为网络问题,服务端实际收到了分片,但客户端以为没收到,要怎么处理
客户端在上传分片之前,会先发送一个“初始化的请求”,携带文件元信息,比如文件的唯一标识(如文件名、文件大小、MD5等),文件的总分片数量,以及每个分片的大小;服务器收到之后会去创建一个临时目录,存储所有的分片。
客户端携带文件唯一标识,分片的索引、具体分片数据以及分片的MD5等上传时,服务器会首先去验证这个文件的ID是否存在,然后验证分片的索引是否合法(比如是否是在0到总分片数-1的范围内),通过MD5校验分片数据完整性等,如果验证通过就会把分片保存到临时目录中,并返回成功的响应,当所有的分片上传完,客户端会再去发送一个合并请求,服务器去检查分片是否完整,如果完整,就按照索引顺序去读取文件,完整拼接,并保存到目标目录中,删掉临时目录和分片文件,并返回合并成功的响应。
解决服务端收到但是客户端以为没收到重传的方案:核心是服务器端实现幂等性处理。
具体实现:
唯一标识分片:每个分片必须有唯一标识(文件 ID + 分片索引 + 分片 MD5)。服务器收到分片时,先根据这个标识检查 “该分片是否已正确存储”。
- 若已存储且完整(MD5匹配),直接返回成功响应(无需重复写入磁盘)。
- 若已存储但不完整(如上次上传中断导致文件损坏),则覆盖旧分片,重新存储新数据。
状态记录持久化:已接收的分片索引和状态(如 MD5、大小)必须记录在可靠存储中(如数据库或Redis,而非内存),避免服务器重启后状态丢失。
客户端:主动校验与断点续传
客户端发现某个分片上传 “疑似失败”(如超时、未收到响应)时,不应直接重试,而应先查询服务器状态:
分片状态查询接口:服务器提供一个接口,客户端可传入文件ID,获取“已接收的分片索引列表”。
- 例如:客户端上传分片3超时后,调用GET/upload/status?fileId=xxx,服务器返回[0,1,2,3](说明分片3已被接收),客户端则跳过该分片,继续上传下一个。若查询发现分片3未被接收,客户端再重试上传。
快某手篇
1 七层网络模型,Http在哪一层,http的协议在哪一层
| OSI七层模型 | TCP/IP四层模型 | 五层混合模型 | 功能概述 | 典型协议 | 典型设备 |
|---|---|---|---|---|---|
| 7.应用层 | 4.应用层 | 5.应用层 | 为应用程序提供网络服务接口 | HTTP, HTTPS, DNS, SSH | 终端设备PC、手机 |
| 6.表示层 | 数据格式转换、加密解密 | SSL/TLS(初期)、JPEG, ASCII | |||
| 5.会话层 | 建立、管理和终止会话 | RPC, SIP | |||
| 4.传输层 | 3.传输层 | 4.传输层 | 提供端到端的可靠或不可靠数据传输 | TCP, UDP | 防火墙 |
| 3.网络层 | 2.网络层 | 3.网络层 | 寻址和路由选择,实现网络互连 | IP, ICMP, RIP, OSPF | 路由器 |
| 2.数据链路层 | 1.网络接口层 | 2.数据链路层 | 在物理介质上提供可靠的数据帧传输 | Ethernet, PPP, Switch, MAC | 交换机, 网桥 |
| 1.物理层 | 1.物理层 | 传输原始比特流,定义电气和物理特性 | RJ45, 802.3, 光缆, 双绞线 | 集线器, 中继器, 网卡 |
2 RabbitMQ的channel和Queue的关系
Connection是TCP连接,Channel是连接里的“虚拟连接”或“轻量级线程”,而Queue是消息的存储队列。所有对Queue的操作(声明、绑定、消费、发布消息到该队列等)都必须通过一个Channel来进行。
Queue(队列)
是什么:队列是消息的存储缓冲区。它是一个命名的、有序的、先进先出(FIFO)的数据结构,生产者(Producer)将消息发送到队列,消费者(Consumer)从队列中获取消息。
作用:消息的最终目的地,用于存储消息,直到被消费者处理。
特点:
- 队列是建立在RabbitMQ服务器上的。
- 队列的元数据(名称、属性等)和其中的消息都存储在RabbitMQ服务端。
- 队列需要被声明(创建)后才能使用。如果它不存在,声明会创建它;如果已存在且属性匹配,则直接使用。
Channel(信道)
是什么:Channel是在一个AMQP(RabbitMQ使用的协议)TCP连接(Connection)中开辟的逻辑上的、轻量级的双向数据流通道。一个Connection可以包含多个Channel。
作用:在保持一个TCP连接的基础上,提供多个独立的、隔离的会话通道。几乎所有具体的AMQP命令(如声明队列、发送消息、消费消息)都是通过Channel对象来执行的。
特点:
- Channel是建立在客户端(你的应用程序)的。
- 它是“轻量级”的,因为创建和销毁它的开销远比创建和销毁一个TCP连接要小得多。
- 每个Channel都有自己独立的通信流,多个Channel之间是隔离的。
| 特性 | Connection | Channel | Queue |
|---|---|---|---|
| 本质 | 物理的TCP连接 | 逻辑上的虚拟连接 | 消息存储实体 |
| 层级 | 位于最底层,是通信的基础设施 | 建立在Connection之上 | 位于RabbitMQ服务器端 |
| 资源消耗 | 重量级,开销大 | 轻量级,开销极小 | 服务器资源,与客户端连接数无关 |
| 主要作用 | 提供网络联通保障 | 执行AMQP命令(操作Queue等) | 存储和传递消息 |
| 数量关系 | 一个应用通常维护一个或少量的Connection | 一个Connection可包含多个Channel | 一个队列可以被多个Channel操作 |
| 线程安全 | 一般不是线程安全的 | 通常要求Channel非线程共享(一个线程一个Channel) | 线程安全,由RabbitMQ内部管理 |
3 Java虚拟线程




4 SSE技术和HTTP的区别


5 SSE要在前端设置打字机效果应该怎么做

6 HTTP怎么实现流量控制(滑动窗口算法)

字某节篇
1 http连接,如果中间路由器断开一秒再恢复,原来的连接还存在吗?
这个问题要区分几个层次来看:
HTTP本身的特点
- HTTP/1.0:默认是“短连接”,每个请求-响应完成后,TCP连接就断开了。此时路由器中断一秒,影响不大,因为本来就每次重建TCP连接。
- HTTP/1.1:引入了Keep-Alive长连接,多个请求可以复用一个TCP连接。此时TCP连接能否继续存在,取决于底层TCP状态。
- HTTP/2/HTTP/3:支持多路复用,底层连接(TCP)断开就会导致整个HTTP连接失效。
TCP连接在路由器断开时的表现
TCP连接的状态由两端的主机维护,路由器只是转发数据包,它不保存连接状态。如果路由器仅仅“断开一秒再恢复”,而且这期间没有数据包需要传输,那么两端主机的TCP连接表里依然是ESTABLISHED状态。
如果这期间恰好有数据传输:
- 数据包会丢失,触发TCP重传。
- TCP会等待ACK,如果超时重传时间比较短,连接依然存在。
- 如果丢包过多导致超时重传时间超过上限,最终主机会判定连接断开。
实践中的情况
短暂(1秒左右)的路由器掉线:绝大多数情况TCP连接仍然存在,HTTP长连接可以继续使用,只是那一瞬间的请求可能会延迟。
如果有请求正在传输:可能造成请求失败,需要客户端重试,但底层TCP连接仍可能存活。
如果路由器重启(NAT表丢失):NAT环境下(家庭路由器、运营商网关),即使只掉线一秒,NAT映射表可能清空,导致外部连接无法路由,这种情况下连接会彻底断掉。
结论:
如果只是“物理层转发中断一秒”但路由器没有重置NAT表,原有的HTTP长连接通常还在。
如果路由器断电或NAT表清空,则连接会失效,需要重新建立。
2 现在有一个用户登陆的日志文件,20G的大小,如果我们的内存只有1G,要选出日志里面用户登陆次数最多的TOP10,应该怎么做?
步骤1:分割大文件
**目标:**将20G的大文件分割成多个小文件,使得每个小文件的大小适合内存处理(例如,每个小文件小于500MB),并确保同一个用户ID的所有记录都分配到同一个文件中。
方法:
- 选择一个哈希函数(如MD5或SHA-1)并确定桶的数量(例如100个桶)。桶的数量应足够多,以确保每个小文件的大小和其中的用户数量都在内存可处理范围内。计算哈希值:hash(user_id) % N,其中N是桶的数量。
- 逐行读取大文件,对于每条日志记录,提取用户ID,计算哈希值并取模,根据结果将记录写入对应的桶文件(如bucket_0.txt, bucket_1.txt, ..., bucket_99.txt)。
注意:由于哈希分布可能不均匀,有些桶文件可能较大,但通过选择足够的桶数,即使个别文件较大,其内容统计时所需内存(基于不同用户数)也远小于2G。
步骤2: 处理每个桶文件,统计用户登陆次数
**目标:**对每个桶文件,统计每个用户的登陆次数(由于同一个用户的所有记录都在一个文件中,这里统计的是全局次数)。
方法:
- 逐个处理每个桶文件。读取一个桶文件到内存中,使用哈希表来统计每个用户ID的出现次数。键是用户ID,值是登陆次数。
- 统计完成后,将结果写入一个输出文件(如count_bucket_0.txt, count_bucket_1.txt, ...),每行包含用户ID和对应的登陆次数。
步骤3: 合并所有统计结果,找出TOP 10
**目标:**从所有输出文件中找出登陆次数最多的10个用户。
方法:
- 初始化一个最小堆(大小为10)用于维护TOP 10用户。堆中元素是(登陆次数, 用户ID)对,堆顶是当前TOP 10中的最小次数。
- 逐个读取所有输出文件(count_bucket_*.txt),对于每行(用户ID和次数),比较当前次数与堆顶的次数:
- 如果堆中元素不足10个,直接将(次数, 用户ID)插入堆。
- 如果堆已满(10个元素),且当前次数大于堆顶次数,则弹出堆顶元素并插入当前元素。
- 处理完所有文件后,堆中的10个元素就是全局登陆次数最多的TOP10用户。
优化和考虑
哈希函数选择:选择分布均匀的哈希函数(如MurmurHash)以减少文件大小倾斜。
桶数量调整:如果预计用户ID分布极不均匀(如少数用户有大量记录),可以增加桶数(如1000个)以确保每个桶文件更小。
处理大桶文件:如果在处理每个桶的过程中遇到某个桶文件过大(如超过2G),可以进一步对该桶文件进行哈希分割,但通常桶数足够时不会出现这种情况。
3、GC Roots包含哪些对象



