Java 后端面试复习重点清单

1. 第一优先级:必须背熟

Java 基础与集合

重点背:

  • HashMap 底层结构、put 流程、扩容机制

    1.7是数组加链表,链表采用头插法

    1.8以及之后,底层是数组链表加红黑树,链表采用尾插法,扩容因子是0.75,初始长度是16,新添加元素时候,首先使用Object类的hashcode方法计算新添加元素的hash值,拿hash值取余当前数组长度,得到数组的下标索引,然后判断当前下标索引处是否有元素,如果没元素直接添加,如果有元素则使用object的equals方法判断两个元素的值是否相等,如果相等则不添加,如果不等则使用拉链法解决hash冲突,将元素继续往下挂,当链表长度大于8,数组长度大于等于64,将自动转为红黑树。如果树节点数量小于等于6,红黑树会退化为链表

  • ConcurrentHashMap 如何保证线程安全

    1.7之前采用分段锁

    1.7之后使用cas+syncharonized锁

  • ArrayList 扩容机制

    初始容量是0,第一次添加元素的时候,初始化容量为10,新添加元素时,首先判断当前元素+1是否大于当前数组长度,如果大于当前数组长度,则触发扩容,,扩容后的数组长度是当前数组长度的1.5被,新的数组用Array.copyof()把旧数组的元素全部复制进去,再把新元素存入新数组尾部

  • String 为什么不可变

    因为string底层使用private fanil修饰的,他是不可被修改的,也是不可变的,对他进行修改时,其实是创建了一个新的string字符串,然后然后将变量引用地址指向新的字符串达到修改的效果,不能被继承,避免子类破坏不可变的特性

  • equals 和 hashCode 的关系

    两个对象 equals 相等,hashCode 一定要相等;但 hashCode 相等,equals 不一定相等,这就是哈希冲突。所以重写 equals 必须重写 hashCode,否则把对象存进 HashMap、HashSet 这种基于哈希的集合时,两个 equals 相等的对象算出不同的 hash,会被当成两个不同的 key,导致存进去取不出来。

  • 反射、注解、泛型擦除

    反射:运行时动态拿到类的信息(属性、方法、构造器),还能创建对象、调用方法。Spring 的 IOC、动态代理底层都依赖反射,缺点是性能比直接调用差,还会破坏封装。

    注解:本质是一种标记、元数据,本身不做事,配合反射或 AOP 在运行时被读取后再处理,比如 @Autowired、自定义注解。通过 @Retention 分为源码级、编译级、运行时三种。

    泛型擦除:Java 泛型只是编译期的语法糖,编译后类型参数会被擦除成 Object 或上界类型,运行时其实没有泛型信息。所以 List<String>List<Integer> 运行时是同一个 class,也不能对泛型做 instanceof、不能 new T[]。

  • Java 异常体系

    顶层是 Throwable,往下分两大类。

    Error:JVM 层面的严重错误,程序自己处理不了,比如 OutOfMemoryError、StackOverflowError。

    Exception:程序可以处理的异常,又分两类。一类是编译期异常,也叫受检异常,必须显式 try-catch 或 throws,比如 IOException、SQLException;另一类是运行时异常 RuntimeException,编译器不强制处理,比如空指针、数组越界、类型转换异常。

常见场景题:

  • HashMap 为什么线程不安全?

    他没有添加任何同步锁,所以说在多线程并发读写,扩容,插入时会产生数据丢失,死循环,数据错乱等问题,所以线程不安全,解决方案的话,使用hashtable,他底层使用的是syncharonized关键字,但是粒度太大,性能差,或者使用concurrentHashMap,底层使用分段锁和cas+syncharonized优化,高并发首选

  • ConcurrentHashMap 和 HashMap 有什么区别?

    concurrentHashMap线程安全的,1.7底层使用分段锁。1.8使用cas+syncharonized关键字进行优化

    hashmap是线程不安全的,单线程的情况下使用效率比较高,多线程并发put,扩容,查询不安全

  • equals 必须重写 hashCode?

    重写 equals 必须重写 hashCode,为了遵守 Java 对象相等规范,防止对象存入 HashMap、HashSet 等哈希集合时出现存取异常。

  • 自定义注解 + AOP 在项目里怎么用?

    项目里用自定义注解配合aop处理横切逻辑,例如操作日志,权限校验,防重复提交,先定义运行时注解做标识,Aop通过@annotation切点拦截带注解的方法,再通知中读取注解配置执行公共逻辑,业务方法添加注解完成增强,实现业务与通用代码解耦


2. Spring / Spring Boot

重点背:

  • IOC / DI 原理

    ioc是将创建和管理对象的能力交给springIoc容器进行管理,di是依赖注入,是ioc的具体实现,容器自动装配bean之间的依赖,解耦对象关系

  • Bean 生命周期

    第一步进行实例化,spring通过反射调用构造方法创建出bean对象

    第二步进行依赖注入,给带@autowired的属性自动赋值填充

    第三步执行初始化相关逻辑,处理完这个bean就完全就绪,可以正常拿出来使用

    第四步就是等到spring容器关闭的时候,会执行对应的销毁方法,释放资源

  • 三级缓存解决循环依赖

    一级缓存放的是已经创建好,能直接使用的成品对象

    二级缓存放的是刚new出来,还没完善的半成品bena,防止重复创建代理对象

    三级缓存是存对象的工厂,需要时生成普通对象或者是Aop代理对象

    解决循环依赖的方法是,a依赖b,b依赖a,先创建a的空对象,把a的工厂放入三级缓存,给a赋值的时候发现需要b,开始创建b,同理把b的工厂放进三级缓存,发现b赋值的时候需要a,从三级缓存取出a的早期对象放到二级缓存,完成b的注入,b创建完成之后放入一级缓存,回头给a注入b,a就初始化完成进入一级缓存,循环依赖的问题就解决了

  • Spring AOP 原理

    springAOP就是面向切面编程,用来抽取重复的代码,不改动原有业务方法,对方法只做增强,不做改变,底层依靠动态代理来实现

    有两种代理规则:

    1:目标实现了接口,就使用jdk动态代理,运行时生成接口实现类拦截方法

    2:目标没实现接口,就使用cglib,运行时生成目标类的子类重写方法拦截

    aop底层基于动态代理,有接口用jdk,没接口用cglib代理,容器创建bean时候判断是否需要切面增强,需要就生成代理对象,实现方法前置,后置等增强逻辑,用来统一处理日志,权限这类公共逻辑。

  • JDK 动态代理和 CGLIB 区别

    jdk要求目标类必须实现接口,运行时生成一个实现该接口的代理类来拦截方法

    cglib不需要目标类实现代理接口,运行时,生成一个该类的代理对象

    有接口默认就是jdk代理,没有实现接口就是cglib代理

  • Spring 事务传播机制

    事务的传播机制指的就是,多个事务之间互相调用时,如何处理已有事务,新开事务的规则,一共7种,

    required 默认 有事务就加入事务,没有就创建一个事务,也是最常见

    required-new不管外面有没有事务,一定新开独立事务,内外事务不影响,互不回滚

    supports (苏ps)有事务就跟着跑,没事务就以非实物运行

    not-supported(苏pd)以无实物 运行

    never(奈沃)必须不能有实物,有事务就报错

  • Spring 事务失效场景

    方法不是public修饰的,事务的底层是aop,aop只能拦截public方法生成的代理,解决方法就是在方法上添加@Transaction

    try捕获异常,并没有把异常往上抛,spring事务没感知异常,所以不生效,解决方案就是catch里面手动new runtimeException()抛出异常

    事务传播属性错误,not-supported和never等,是不支持事务的,解决方法是根据业务改成required或者required-new

  • Spring MVC 执行流程

    浏览器发送请求,前端控制器拦截所有请求,调用handerMapping,根据请求url,找到对应的controller,调用controller里面的业务方法,执行controller处理请求,调用service,封装数据,返回数据,视图解析器拿到视图名称,定位真实页面路径,进行视图渲染,然后将渲染完毕的页面响应给客户端展示。

  • 拦截器和过滤器区别

    Filter过滤器,拦截所有请求,包括静态资源,jsp,图片,接口都拦

    Interceptor拦截器,只拦截进入controller的请求,静态资源默认不拦截

  • 全局异常处理、参数校验

    实现方法就是@RestControllerAdvice,全局切面,捕获所有@RestController抛出的异常

    @ExceptionHandler,指定拦截某种异常,统一封装返回给前端格式

常见场景题:

  • Spring 三级缓存为什么能解决循环依赖?

    Spring 三级缓存核心是提前暴露实例化后的半成品 Bean。创建 A 时先把 A 的工厂放入三级缓存,A 需要 B 就去创建 B;B 又需要 A,此时从三级缓存取出 A 的提前引用完成 B 装配;B 创建完再回来装配 A,解决循环引用。引入三级缓存主要是为了适配 AOP 代理,避免对象不一致问题。

  • 为什么构造器循环依赖解决不了?

    构造器循环依赖无法解决,是因为创建对象时必须在构造方法入参拿到依赖,双方对象都还没实例化,进不了三级缓存做提前暴露;而 set 注入可以先实例化空对象存入缓存,再填充属性,从而解决循环依赖。

  • AOP 是怎么实现的?

    底层基于动态代理,有两种代理方式,目标类实现了接口的,使用jdk代理,运行时创建实现接口的代理类,如果目标类没有实现接口,就使用cglib,运行时生成目标类的代理对象,spring启动时,容器创建bean时,先看下是否需要切面增强,如果需要的话再根据前置,后置,和一些公共配置,实现日志控制,权限控制等等。

  • 同类方法调用为什么事务失效?

    事务是依靠动态代理的,同类调用的话,是调用的原生对象,不走代理拦截,事务逻辑无法切入,所以事务失效,改用自身注入,通过代理对象调用就能解决

  • 异常被 catch 后事务为什么不回滚?

    异常被捕获后,并没有往上继续抛,所以事务感知不到,如果try-catch捕获异常之后不抛出,代理拿不到异常,判断业务执行正常吗,就会提交事务,不会回滚。

  • JWT 登录拦截器怎么实现?

    用户登录成功之后生成jwt令牌返回给前端,自定义拦截器,判断非空校验合法性,有效就执行,无效就拦截返回401,在mvc配置类注册拦截器,排除登录等白名单接口,同时用treadlocal存放当前登录的用户,用完及时清理。


3. MySQL

重点背:

  • B+ 树索引

    叶子节点存储的是表中具体的数据,非叶子节点存储的是主键和索引的对应关系,通过非叶子节点,可以快速定位到叶子节点,所以查询效率快,只有叶子存数据,层级更低,范围查询高效,分页友好,mysql默认就是选用b+树

  • 聚簇索引和非聚簇索引

    聚簇索引也被称为主键索引,叶子节点存放整条完整行数据,索引和数据捆绑在一起,一张表只能有一个聚簇索引

    非聚簇索引也被称为二级索引,叶子节点只存储主键值,如果需要查询表中具体的数据,需要回表

  • 回表

    回表就是查询时,用的是非聚簇索引,但是非聚簇索引只能查询出主键值,如果需要查询表中具体的数据,就需要拿着主键,使用聚簇索引去查询具体的数据,这个过程就被称为回表

  • 最左前缀原则

    联合索引,遵循最左匹配原则,按照创建时字段顺序排序,查询必须使用索引最左侧第一个字段,索引才会生效,如果中间出现范围查询,范围后面的字段索引全部失效

    针对联合索引的情况

    比如按照a b c三个字段的顺序设置了索引

    查询条件中,只要包含a字段,索引就会生效,与判断条件的顺序无关

  • 索引失效场景

    不满足最左匹配原则

    使用模糊查询时候,以通配符开头

    字段做运算,函数操作

    隐式类型转换

    使用不等于。not in,or等不合理写法

  • explain 分析 SQL

    explain加在sql语句前面用来查询,sql执行情况,是否走索引,索引好不好,是否慢查询,定位sql性能问题

  • 事务隔离级别(从低到高)

    读未提交(Read Uncommitted):最低,几乎不隔离,脏读、不可重复读、幻读都会出现

    读已提交(Read Committed):解决脏读,还存在不可重复读、幻读,Oracle 默认这个级别

    可重复读(Repeatable Read):解决脏读、不可重复读,理论上还有幻读,但 InnoDB 靠 MVCC 加间隙锁基本把幻读也解决了,MySQL 默认这个级别

    串行化(Serializable):最高,所有问题都解决,但相当于事务串行执行,并发性能最差

    三个问题解释:脏读是读到别的事务还没提交的数据;不可重复读是同一个事务里两次读同一行,值被别人改了导致不一样;幻读是同一个事务里两次按条件查询,行数变了,别人插入或删除了符合条件的行

  • MVCC

    多版本并发控制,修改数据时,保留旧版本,普通查询读旧版本不加锁,修改删除走最新数据加锁,读写互不堵塞,依靠undo log存旧版本,readview判断数据可见性,存在可重复读隔离级别下避免脏读和不可重复读,提升数据库并发能力

  • undo log / redo log / binlog

    undolog是回滚日志,用于事务失败时撤销修改,同时存储数据历史版本职称mvcc

    redolog重做日志,修改先写该日志,宕机后依靠他恢复未落地的数据,保证事务的持久性

    binlog 服务层的二进制日志,记录所有增删改的逻辑语句,主要用于主从复制和误删后的时间点数据恢复

  • 深分页优化

    用游标分页,也可以使用延迟关联优化回表,极端场景改为es,业务上禁止随意跳页也能规避问题

常见场景题:

  • 一个接口查询慢怎么排查?

    可以使用explain分析执行计划,查看是否出现了全表扫描,索引失效,深分页,回表过多,文件排序等问题

  • explain 主要看哪些字段?

    type

    key

    key_len

    rows

    extra

  • 哪些情况会导致索引失效?

    联合索引不满足最左前缀

    模糊查询时,以通配符开头

    隐式类型转换

    使用!=,><,notin,is not null,

    索引字段做计算

  • 为什么深分页慢?

    偏移量offset很大时,mysql必须从头一条条扫描并丢弃前面的offset条数据,直到找到需要取的数据,扫描行数极多,io开销大所以很慢。

  • 异步导出为什么不能直接 limit offset?

    异步导出数据量大,使用 limit offset 分页一方面偏移量持续变大形成深分页,查询越来越慢;另一方面导出期间表数据发生增删改,会造成分页错位,出现数据重复或漏导;一般采用主键 id 游标分页分批查询解决该问题。


4. Redis

重点背:

  • Redis 常用数据结构

    string

    zset

    set

    hash

    list

  • String、Hash、ZSet 使用场景

    String 适合简单缓存、计数器、分布式锁;Hash 适合存储对象,支持局部字段更新,避免整对象序列化;ZSet 自带分值排序,多用于排行榜、延时队列等需要有序场景。

  • Redis 为什么快

    Redis 速度快主要因为数据基于内存读写;采用单线程避免锁竞争,搭配 IO 多路复用高效处理大量连接;底层优化的数据结构查询效率高,整体执行开销很小。

  • 缓存穿透、击穿、雪崩

    穿透:查询的数据数据库中不存在,缓存也不存在,每次查询都打到数据库,简单来说就是一直查询不存在的数据,解决方案,使用布隆过滤器,判断是否一定不存在,或者缓存中存短期的空数值

    击穿:热门key突然过期,大量请求直接打到数据库,造成数据库压力倍增,解决方案,热门key不设置过期时间,加分布式锁,让同一时间只有一个请求查询数据库,其他请求在外等待,查到的数据回写到数据库。

    雪崩:短时间内大量key过期,大量请求打到数据库,解决方案,key设置随机过期时间,加锁

  • 缓存一致性

    缓存一般只做最终一致性,优先采用先更数据库,再删缓存的方案,并发问题可以采用延迟双删缩小不一致的时间,分布式环境通过redisson分布式锁,串行修改,redisson具备可重入,看门狗续期,lua脚本保证原子性,等特性,也可以借助mq重试,canal监听binlog异步同步缓存做兜底

  • Redisson 分布式锁

    原生redis锁,通过setnx自己实现锁,但是存在很多缺陷,死锁,不可重入,锁释放不是原子性操作,业务超市,锁提前释放等问题

    核心特征:

    可重入,同一个线程可以多次加同一把锁,内部用hash结构记录加锁次数,解锁次数减一,计数器到0释放锁,避免把自己锁死

    看门狗:默认锁30秒到期,但是看门狗,每10秒检查下业务执行完了没,后台每隔十秒就会查询下锁的状态,防止任务没跑完锁释放

    lua脚本:加锁,判断锁属性,释放锁等逻辑,全部写在一段lua脚本中,执行多条命令不会打断,保证操作的原子性,防止误删别人的锁,支持异步加锁,除了普通可重入锁,还有读写锁,联锁,红锁,公平锁等适配不同场景

  • 看门狗机制

    看门狗:默认锁30秒到期,但是看门狗,每10秒检查下业务执行完了没,后台每隔十秒就会查询下锁的状态,防止任务没跑完锁释放

  • RDB / AOF

    RDB是快照,将数据以快照的形式进行存储,恢复比较快,但是缺点是可能数据丢失会比较多

    AOF是日志,将数据以日志的形式追加记录,数据更安全,文件偏大且恢复慢,线上通常两者配合使用兼顾安全性和恢复效率

常见场景题:

  • 热点产品信息为什么放 Redis?

    热点商品,访问量大,如果不放在缓存,那么大量请求都打到数据库,数据库压力太大,存在缓存中,缓存是在内存中,多线程读写快,秒杀等场景,redis高并发能力更强,起到流量削峰,防止流量冲击数据库造成雪崩,

  • 缓存和数据库不一致怎么办?

    先采用先改库再删缓存的基础方案,并发问题用延迟双删缓解;删除失败借助 MQ 重试或 Canal 监听 binlog 异步同步;高并发加分布式锁规避读写竞争;最后定时巡检校对做兜底,实现数据最终一致性。

  • 先删缓存还是先更新数据库?

    通常情况下,先更新数据库,再删缓存,该方案并发脏数据概率最低,如果是先删缓存,再更新数据库的话,极易出现别的线程读取旧数据回填缓存,造成长期不一致,删除缓存失败的话可以使用mq或者canal进行兜底,并发高的场景可以使用延时双删

  • Redisson 分布式锁怎么实现?

    基于lua脚本保证原子性,采用hash结构实现可重入,加上看门狗机制,实现锁的自动续期,避免锁提前释放,获取失败自动自选,安全解决分布式并发争抢问题

  • 防重复下单锁 key 怎么设计?

    生成防重令牌,下单前接口生成全局唯一的token返回前端,下单时必须携带该token,订单支付成功,删除该key,同一个token只能成功一次,拦截重复点击,重复提交,数据库层面的话,设计业务订单表,唯一主键,如果重复提交就抛异常,先查在更新,进行操作之前,先查订单状态,如果订单状态已经完成,就抛异常,没有完成就正常执行业务逻辑

  • JWT 为什么要配合 Redis?

    原生jwt生成之后,无法主动失效,退出登录,改密,这些场景无法立刻作废令牌,搭配redis可以存储有效令牌或维护失效黑名单,实现主动注销,账号强制下线,单点登录,弥补jwt不可撤销的短板


5. RabbitMQ

重点背:

  • Exchange、Queue、RoutingKey

    Exchange 交换机:生产者的消息先发到交换机,交换机本身不存消息,只负责按规则把消息转发到队列。

    Queue 队列:真正存消息的地方,消费者从队列里取消息。

    RoutingKey 路由键:生产者发消息时带的一个字符串,交换机根据它和绑定关系 BindingKey 决定把消息投到哪个队列。

  • 交换机类型

    direct

    topic

    fanout

    head

  • 支持的一些工作模式

    普通队列:一个生产者对应一个队列,一个队列对应一个消费者

    工厂模式:一个队列有多个消费者。但一条消息只能被一个消费者消费,轮询,公平分配

    发布订阅模式:fanout,多个队列,同一个消息可以发送给多个消费者

    路由模式:direct,多个队列,路由按照key,将消息发送给不同的队列

    主题模式:topic,多个队列,路由key使用通配符,单个单词用*,多个单词或0个用#

  • Rabbitmq如何保证消息不丢失

    主要分为三个部分:生产者,队列,消费者

    生产者开启消费确认,消息发送到borker之后,如果成功就返回ack,失败的话就重试,记录失败日志做兜底

    队列,开启消息持久化,和交换机持久化,防止数据丢失,关闭自动删除队列

    消费者,关闭自动ack,开启手动ack,自动ack是消息发送之后,自动删除,如果出现服务不可用的情况,可能会造成数据丢失,开启手动ack,只有执行成功之后,才手动ack

    同时使用兜底,使用消息幂等,根据全局唯一id,存储信息,解决重复投递副作用

    Rabbitmq如何保证消息不被重复消费

    先说原因,为什么会重复消费,消息已经处理完了,但是ack回执丢了,broker收不到确认,一段时间后重复投递该消息

    不是mq的bug,是网络超时,服务宕机导致的必然现象,mq本身就无法杜绝重试投递,只能靠业务做幂等

    有三种解决方案,

    数据库唯一索引,也是最稳妥,最常用的方案

    发送消息时,携带全局唯一业务号,执行更新和插入操作时,设置唯一索引,如果重复插入,就直接抛异常

    二是用redis,执行之前先查redis中订单是否存在,如果已经存在,就直接return,不做处理,如果不存在,先写入redis,然后执行相应的业务逻辑,执行完成之后不需要额外的操作,如果失败的话允许删除key重试

    第三种的话使用条件更新sql实现业务逻辑幂等,设置条件更新sql,只有满足前置状态才修改,重复执行无副作用

  • 死信队列

    死信队列就是一个消息一直失败,达到最大重试次数之后,不会继续重试,这条消息就变成死信,不能再进原队列消费,准们存放死信的队列就叫死信队列

  • 延迟队列

    消息发送成功之后,不会被立马消费,而是等到指定时间,时间到了之后才会被消费者获取,常用于定时类业务,场景的话就是订单超时不支付,自动关单

  • 消息幂等

    解决的方案有多种,第一种使用数据库唯一索引,设置全局唯一业务单号,为唯一索引,在执行更新或者插入操作时,如果重复插入就抛异常

    第二种使用redis,简单老说就是先插后操作,先查询redis中业务订单是否存在,如果存在直接return,不做处理,如果不存在,先将订单存入redis,然后设置过期时间,在执行相应的业务逻辑,执行成功不做额外操作,如果执行失败允许删除key进行重试

    第三种就是使用条件更新sql做天然幂等,使用条件更新sql,必须满足前置条件才允许操作,重复操作不影响数据

  • 消息堆积处理

    堆积一般是消费速度跟不上生产速度。先定位是消费者挂了还是消费太慢:消费者故障就先修复重启;消费太慢就临时扩容——加消费者实例、调大并发 prefetch、优化消费逻辑,比如批量处理、异步落库。实在堆太多,可以新建一个临时队列和一批临时消费者,先把消息快速转移出来慢慢处理。事后要加监控和限流,从根上避免。

常见场景题:

  • RabbitMQ 如何实现 15 分钟超时关单?

    下单成功后往延迟队列发一条消息,延迟 15 分钟。到点消息被消费者拿到,先查这笔订单状态:如果还是待支付,就关单、回滚库存;如果已经支付了,直接忽略。实现方式两种:一种是 TTL 加死信队列,给消息设 15 分钟过期时间,过期后转发到死信交换机再被消费;另一种是装 rabbitmq-delayed-message-exchange 延迟插件,直接发延迟消息,更简单直观。

  • 为什么不用定时任务扫表?

    定时任务扫表有几个问题:一是有延迟,比如每分钟扫一次,关单最多差一分钟,不够精准;二是数据量大时全表扫 status 等于待支付 很慢,给数据库压力大;三是多实例部署要额外加分布式锁防止重复扫。延迟队列是订单级精准触发,到点就处理,不用轮询全表,实时性和性能都更好。

  • 支付成功和超时关单同时发生怎么办?

    靠订单状态加锁保证只有一个成功。关单和支付回调都要先判断当前状态再改:关单用条件更新 update 订单 set status=已关闭 where id=? and status=待支付,如果这时候已经支付了,status 不是待支付,更新影响 0 行,关单失败,不会覆盖已支付状态;支付回调同理也校验状态。再加数据库行锁或 Redisson 分布式锁串行化,杜绝并发改坏。

  • MQ 消息丢了怎么办?

    分三段保证:生产端开启 confirm 确认机制,消息到了 broker 才算成功,失败重试加记日志兜底;broker 端队列、交换机、消息都开持久化,防止宕机丢数据;消费端关掉自动 ack、改手动 ack,业务处理成功才 ack,失败重试或进死信。三段都保住,消息基本不会丢。

  • MQ 重复消费怎么办?

    重复消费没法从 MQ 层面根治,网络超时、ack 丢失都会导致重投,只能靠消费端做幂等。常用三种:数据库唯一索引,带全局唯一业务号,重复插入直接报错;Redis 先查后做,存过就 return;条件更新 SQL,满足前置状态才改,重复执行无副作用。最稳的是数据库唯一索引。

  • 消息堆积怎么排查?

    先看管理台或监控的队列积压数和消费速率。判断原因:是消费者全挂了,消费速率为 0,还是消费太慢,生产大于消费。消费者挂了就修复重启;消费太慢就扩容消费者、调大 prefetch 并发、优化消费逻辑;如果是某条消息一直失败卡住,把它扔进死信队列别阻塞后面的。堆积太严重可以临时加机器加临时队列快速泄洪。


6. 项目核心场景:订单、幂等、状态机

重点背:

  • 状态机是什么

    状态机就是把一个业务对象所有可能的状态,以及状态之间允许怎么流转,提前定义成一套规则。核心是「当前状态 + 触发事件 = 下一个状态」,不符合规则的流转直接拦掉。订单就是典型:待支付、已支付、已发货、已完成、已取消,每一步只能按规则走。

  • 订单状态流转

    典型链路:待支付 →(支付成功)→ 已支付 →(发货)→ 已发货 →(确认收货)→ 已完成;待支付 →(超时或用户取消)→ 已取消。每个箭头都是一个允许的流转,其他跳法都算非法。

  • 非法状态拦截

    改状态前先校验当前状态是否允许流转到目标状态。比如已完成的订单不能再取消,已取消的不能再支付。实现上要么用状态机框架,比如 Spring StateMachine,要么简单点用条件更新 SQL:update 订单 set status=新状态 where id=? and status=允许的前置状态,影响 0 行就说明状态不对,抛异常。

  • 重复下单处理

    防重令牌加数据库唯一约束。进下单页先发一个全局唯一 token,下单时带上,后端用 Redis 校验 token 只能用一次,Lua 保证原子;同时订单表对业务唯一键,比如用户加商品加活动,加唯一索引,重复插入直接报错兜底。

  • 支付回调幂等

    第三方支付回调可能重复通知。处理时先查这笔订单状态,如果已经是已支付就直接返回成功、不重复处理;改状态用条件更新 where status=待支付,保证只成功一次。再配合支付流水号唯一索引防重复入账。

  • MQ 消费幂等

    消息可能重复投递,消费端做幂等:全局唯一消息 id 或业务号,配数据库唯一索引、Redis 去重、或条件更新 SQL,重复消费不产生副作用。

  • 分布式锁防并发

    同一笔订单的并发操作,比如重复支付、支付和关单同时来,用 Redisson 分布式锁,按订单 id 加锁,串行处理,避免并发把状态改乱。锁只是兜底,核心还是状态判断加条件更新。

常见场景题:

  • 订单有哪些状态?

    一般是:待支付、已支付(待发货)、已发货、已完成、已取消或已关闭,退款场景再加退款中、已退款。具体看业务,但核心就这几个。

  • 待支付到已支付怎么流转?

    用户支付成功后,支付平台回调通知,后端校验签名和金额,确认无误后用条件更新把订单从待支付改成已支付:update set status=已支付 where id=? and status=待支付,同时记录支付流水、扣减库存,再发 MQ 通知后续发货等流程。

  • 已支付订单还能取消吗?

    能,但走的是退款、取消流程,不是简单改状态。已支付订单取消要触发退款,状态流转到退款中,再到已退款或已取消,还要回滚库存。跟未支付订单直接关单不是一回事。

  • 用户重复点击下单怎么防?

    前端按钮置灰是最外层;核心靠后端:防重 token 一次性、订单业务唯一索引、分布式锁,三层保证同一次下单只生成一个订单。

  • 支付回调重复通知怎么处理?

    幂等处理:先查订单状态,已支付就直接返回成功;没支付才处理,并用条件更新 where status=待支付 保证只成功一次;支付流水号加唯一索引防重复入账。这样回调来多少次结果都一样。

  • 状态机相比 if else 有什么好处?

    if-else 散落各处,状态一多就变成一堆嵌套判断,容易漏、难维护,加状态就要改一片。状态机把哪些状态能流转到哪些状态集中成一份规则,流转合法性统一校验,新增状态改配置就行,清晰、可控、不容易出非法流转。


7. ElasticSearch

重点背:

  • 倒排索引

    普通索引是文档到词,倒排索引反过来是词到文档列表。ES 先把文档内容分词,然后记录每个词出现在哪些文档里。搜的时候拿关键词直接查到文档列表,不用全表扫,所以全文检索特别快,这是 ES 快的核心。

  • text 和 keyword 区别

    text 会被分词,适合全文搜索,比如商品标题、描述,支持模糊匹配,但不能直接排序聚合;keyword 不分词,整个字段当一个整体,适合精确匹配、排序、聚合,比如分类、品牌、状态、id。实际经常一个字段同时建 text 加 keyword,用 fields 子字段兼顾两种需求。

  • 分词器

    把一段文本切成一个个词的组件。英文按空格切,中文要专门的分词器,常用 IK 分词器,分 ik_max_word 细粒度尽可能多切、和 ik_smart 粗粒度智能切。建索引和查询用的分词器要匹配,不然搜不准。

  • 分片和副本

    分片 shard:把一个索引的数据水平拆成多份,分布到不同节点,解决单机存不下、提升并发,主分片数创建后不能改。副本 replica:主分片的备份,主挂了副本顶上保证高可用,同时能分担查询压力,副本数可以随时改。

  • refresh 机制

    ES 写入不是实时可见的。数据先进内存缓冲区,默认每 1 秒 refresh 一次,把缓冲区数据生成 segment 变成可搜索状态。所以 ES 是近实时 NRT,写完大概 1 秒后才搜得到。可以手动 refresh,但频繁 refresh 影响性能。

  • ES 深分页问题

    from 加 size 深分页时,每个分片都要取 from+size 条再汇总排序,翻到很后面代价极大,ES 默认限制 from+size 不能超过 10000。解决:实时翻页用 search_after,大批量导出用 scroll。

  • search_after

    深分页方案,不用 from,而是带上上一页最后一条的排序值作为游标,查排在它后面的数据。每次只取一页量,没有深分页的性能问题,适合下一页式无限翻页,缺点是不能随机跳页。

  • MySQL 和 ES 数据同步

    常见几种:同步双写,改库时同步写 ES,简单但耦合、影响性能;MQ 异步,改库后发消息,消费端更新 ES,解耦;订阅 binlog,用 Canal 监听 MySQL binlog 同步到 ES,业务无侵入,最常用;定时任务全量或增量兜底。一般 Canal 加 MQ 组合,再加定时全量兜底。

常见场景题:

  • 为什么家具搜索用 ES?

    商品搜索要按标题、描述做全文模糊匹配,还要分词、相关度排序、高亮,MySQL 的 like 百分号关键词百分号 用不了索引、全表扫、性能差,也不支持分词和相关度。ES 天生为全文检索设计,倒排索引加分词加打分排序,还能扛高并发,所以搜索走 ES。

  • 关键词、分类、品牌、价格区间怎么组合查询?

    用 bool 组合查询:关键词用 must 加 match 做全文匹配、参与打分;分类、品牌这种精确条件用 filter 加 term 不打分、可缓存、快;价格区间用 filter 加 range。filter 不算相关度还能缓存,所以精确过滤都放 filter,只有关键词放 must,性能最好。

  • ES 索引怎么设计?

    按业务定字段和类型:需要搜索的标题、描述用 text 加 IK 分词;需要精确过滤、排序、聚合的分类、品牌、价格、状态、id 用 keyword、integer、double;不搜的字段设 index:false 省空间。合理设分片副本数,别过度分片。字段类型一旦建好不能改,要提前规划,改了得重建索引。

  • MySQL 数据怎么同步到 ES?

    主流用 Canal 监听 MySQL 的 binlog,把增删改事件发到 MQ,消费端更新 ES,业务代码零侵入、准实时。再配一个定时全量同步做兜底,防止中间漏消息导致长期不一致。小项目也可以直接在业务里发 MQ 异步双写。

  • ES 和 MySQL 不一致怎么办?

    ES 本身只保证最终一致。做法:同步失败的消息进 MQ 重试、进死信人工处理;加定时任务全量或增量比对,把 ES 校准回来;以 MySQL 为准,ES 只做搜索,查详情还是回 MySQL,降低不一致影响。

  • ES 查询慢怎么排查?

    先看是不是深分页 from 太大,换 search_after;看查询有没有滥用 wildcard、正则、脚本这些慢查询;精确过滤是不是放进了 filter 能缓存;分片是否过多或数据倾斜;是不是 text 字段做了聚合、排序,该用 keyword。还可以用 profile API 看每一步耗时定位。


8. 异步导出 / FastExcel / MinIO / SSE

重点背:

  • 同步导出为什么会超时

    数据量大时,一次请求里要查几十万上百万行、写 Excel、再返回,整个过程几十秒甚至几分钟。HTTP 请求、Nginx、网关都有超时时间,一般几十秒,还没导完连接就断了,所以同步导出扛不住大数据量。

  • 同步导出为什么会 OOM

    传统 POI 导出会把所有数据和整个 Excel 对象全加载到内存里,几十万行一次性堆在堆内存,很容易把堆撑爆触发 OOM,数据量越大越危险。

  • 异步导出整体架构

    用户点导出,后端立刻建一条导出任务记录、状态待处理、返回任务 id,请求秒回;真正的导出丢给异步线程或 MQ 后台慢慢做:分页查数据,FastExcel 流式写文件,上传 MinIO,更新任务状态为成功并存下载地址;前端通过 SSE 或轮询拿进度和结果,完成后给下载链接。核心就是提交和执行分离。

  • export_task 表设计

    记录每个导出任务。核心字段:任务 id、用户 id、任务类型或导出条件(存查询参数)、状态(待处理、处理中、成功、失败)、进度、文件地址(MinIO 的 url)、失败原因、总条数、创建时间、完成时间。前端靠它查进度,服务重启靠它恢复。

  • 任务状态流转

    待处理 →(开始执行)→ 处理中 →(成功)→ 已完成;处理中 →(异常)→ 失败。失败的可以重试重新回到处理中。每一步都更新 export_task 的状态和进度。

  • 分页读取

    绝不能一次性把全部数据查进内存。用分页分批查,每次查一批比如 5000 条,写一批,释放一批。而且要用游标分页 where id 大于 上次最大 id,而不是 limit offset,避免深分页越来越慢。

  • FastExcel 流式写入

    FastExcel 是 EasyExcel 的升级,流式写 Excel,不把整个文件加载到内存,一批数据写一批、刷到输出流就释放,内存占用恒定很低,几百万行也不 OOM。这是异步导出不爆内存的关键。

  • MinIO 上传

    导出的文件不落在应用服务器本地,多实例、重启会丢,而是上传到 MinIO 对象存储,拿到一个文件地址存进 export_task。用户下载时从 MinIO 拿,支持大文件、可扩展。

  • SSE 推送

    导出进度和完成通知用 SSE,Server-Sent Events,服务端单向持续往前端推进度,比前端一直轮询更实时、更省资源。导完推一条完成消息带下载链接。

  • 轮询降级

    SSE 依赖长连接,可能被网关、代理断开或不支持。所以要兜底:SSE 连不上或断了,前端自动降级成定时轮询接口查任务状态,保证无论如何都能拿到最终结果。

常见场景题:

  • 异步导出整体流程是什么?

    用户点导出,后端建 export_task 记录、待处理、返回任务 id、请求立即返回;异步任务开始:改状态处理中,游标分页分批查数据,FastExcel 流式写入,写完上传 MinIO,更新状态成功并存下载地址;前端 SSE 收到完成通知,或轮询查到成功,拿到下载链接下载。

  • export_task 表有哪些字段?

    任务 id、用户 id、导出类型、查询条件参数、状态、进度、总条数、文件地址、失败原因、创建时间、完成时间。关键是状态和文件地址,前端查进度、服务重启恢复都靠它。

  • 为什么用 FastExcel?

    传统 POI 全量加载内存,大数据量 OOM;FastExcel 是流式写入,内存占用恒定,一批写一批释放,几百万行也不爆内存,而且 API 简单、性能好。所以大数据量导出用它。

  • 导出中途失败怎么办?

    捕获异常,把 export_task 状态改成失败并记下失败原因,已上传的半成品文件清理掉;前端收到失败状态提示用户;支持手动或自动重试,从头重新执行该任务。因为查询条件都存在任务表里,重试很方便。

  • 服务重启后任务怎么恢复?

    任务状态都持久化在 export_task 表。服务启动时扫一遍状态是处理中的任务,也就是重启前没跑完的,重新拉起继续执行,或者先标记失败再重试。因为导出条件、进度都存库了,不依赖内存,所以能恢复。

  • SSE 断开怎么办?

    前端监听到 SSE 连接断开 onerror,自动重连;重连也不行就降级成轮询查任务状态。同时任务结果都在 export_task 表里,SSE 只是通知手段,断了也不影响最终能拿到结果。

  • 为什么还要轮询兜底?

    SSE 是长连接,可能被 Nginx、网关超时断开,或某些环境不支持,不能保证百分百送达。轮询作为兜底:SSE 失效就定时查任务状态接口,保证用户最终一定能知道结果、拿到文件。可靠性优先。

  • MinIO 上传失败怎么办?

    上传做重试几次,还失败就把任务标记失败、记原因,别让任务卡在处理中;文件生成成功但上传失败的,重试上传即可,不用重新导。也可以先本地暂存再异步补传。关键是状态要落到失败,前端能感知。


9. JWT / RBAC / 登录权限

重点背:

  • JWT 三段结构

    三段用点分隔:Header 头部,声明算法和类型;Payload 载荷,存用户 id、角色、过期时间等业务信息,注意它不加密只是 Base64,别放敏感数据;Signature 签名,把前两段用密钥按指定算法算出来的。前两段能被解开看,安全靠签名。

  • JWT 防篡改原理

    签名等于用服务端密钥对 Header 加 Payload 做加密算法。别人改了 Payload,比如把角色改成 admin,但没有密钥算不出新签名,服务端用密钥重新算一遍对不上,就判定被篡改、拒绝。所以能防篡改,但防不了被看到,别放密码这类敏感信息。

  • JWT 过期与续期

    Payload 里有过期时间 exp,过期就失效。续期常用双 token:一个短期 access token 用于请求,一个长期 refresh token 用于刷新;access 过期时用 refresh 换新的 access,避免用户频繁重新登录。也可以无感刷新:快过期时后端返回新 token。

  • Redis 存 token 的作用

    JWT 一旦签发,服务端无状态、没法主动失效。把 token 或登录态存 Redis 就能主动控制:退出登录删掉、改密码强制下线、单点登录顶掉旧的、做黑名单。相当于给无状态的 JWT 加了一个可控的开关,弥补不能撤销的短板。

  • 拦截器鉴权流程

    自定义拦截器拦所有需要登录的接口:从请求头 Authorization 取 token,校验签名和是否过期,再查 Redis 确认 token 有效没被踢,通过就解析出用户信息放进 ThreadLocal 用户上下文,放行;任何一步不过返回 401。登录、注册等接口加白名单放行。请求结束清理 ThreadLocal。

  • RBAC 权限模型

    基于角色的权限控制。核心三张表:用户、角色、权限,权限可以是菜单、按钮、接口,用户和角色多对多、角色和权限多对多。用户通过角色间接拥有权限,不直接给用户配权限。加人、调权限只改关联关系,灵活好维护。

  • 用户上下文

    当前登录用户的信息,id、用户名、角色等。拦截器解析 token 后把用户信息存进 ThreadLocal,后续业务任何地方都能直接取当前用户,不用层层传参。注意请求结束一定要 remove 清理,防止线程池复用导致数据串了、内存泄漏。

常见场景题:

  • 登录认证流程怎么设计?

    用户提交账号密码,后端校验,密码要加盐哈希比如 BCrypt 比对,通过就生成 JWT,把登录态存 Redis,返回 token 给前端;前端存起来比如 localStorage,之后每次请求头带 token;拦截器校验 token 加 Redis 放行。退出就删 Redis 里的 token。

  • 为什么 JWT 还要存 Redis?

    纯 JWT 无状态、不能主动失效,退出登录、改密码、强制下线这些场景没法立刻作废旧 token。存 Redis 后校验时多查一步 Redis,退出就删、改密就清、单点登录就覆盖,实现主动注销和强制下线,弥补 JWT 不可撤销的短板。

  • 用户退出登录怎么让 token 失效?

    退出时把 Redis 里存的这个用户的 token 或登录态删掉,或加入黑名单。拦截器校验时发现 Redis 里没有或在黑名单,即使 JWT 本身签名有效、没过期,也判定失效返回 401,这样就实现了主动失效。

  • 权限标识怎么返回给前端?

    登录后把用户拥有的角色和权限标识,比如 user:add、order:export 这种字符串,一起返回给前端。前端根据权限标识控制菜单、按钮的显示隐藏;但这只是前端体验,真正的拦截还得靠后端接口鉴权,前端能绕过。

  • 后端如何获取当前登录用户?

    拦截器解析 token 后把用户信息放进 ThreadLocal,封装个工具类比如 UserContext.getCurrentUser 在业务任意层直接取,不用一路把 userId 当参数传。请求结束在拦截器 afterCompletion 里 remove 清理。

  • 管理员和普通用户权限如何隔离?

    靠 RBAC:给管理员和普通用户分不同角色,角色绑不同权限。接口层做鉴权,常用注解加 AOP,比如 @PreAuthorize 或自定义 @RequirePermission,方法执行前校验当前用户有没有这个权限,没有就拦掉。数据层面还可以做数据权限,普通用户只能看自己的数据。


10. SpringAI / RAG

重点背:

  • SpringAI 是什么

    Spring 官方的 AI 应用开发框架,把调用大模型、向量数据库、RAG、Function Calling 这些能力封装成统一的 Spring 风格 API,像用普通 Spring 组件一样接入 OpenAI、通义千问等各家大模型,屏蔽底层差异,不用自己拼 HTTP 请求。

  • 大模型调用流程

    构造提示词 prompt,通过 SpringAI 的 ChatClient 把 prompt 发给大模型,模型返回结果,解析加工后返回给用户。可以配上下文多轮、system 角色设定、参数比如温度。底层就是调各家大模型的 API。

  • 多轮对话上下文

    大模型本身无记忆,每次请求都是独立的。要实现多轮,得把历史对话,用户问加 AI 答,一起拼进这次的 prompt 发过去,模型才知道上下文。历史一般按会话 id 存 Redis;太长会超 token 上限,要做截断或摘要。

  • RAG 是什么

    检索增强生成。大模型不懂你的私有数据比如公司知识库,RAG 的做法是:先把用户问题去知识库检索出相关内容,再把这些内容加问题一起给大模型,让它基于检索到的资料回答。既利用了模型的表达能力,又保证答案基于真实资料、不瞎编。

  • 文档切分

    知识库文档太长不能整篇丢给模型,超 token、检索也不准,要切成一小块一小块 chunk。按段落、句子或固定长度切,块之间留一点重叠,避免把完整语义切断。切分粒度直接影响检索质量。

  • 向量化

    把文字通过 Embedding 模型转成一串数字向量,语义相近的文本向量距离也近。文档切块后每块都向量化存进向量库;用户提问也向量化,然后去向量库找最相近的块。这是语义检索的基础,比关键词匹配更懂意思。

  • 知识库检索

    用户问题向量化后,去向量数据库比如 Milvus、Redis、PGVector 做相似度检索,取出最相关的 topN 个文档块,作为参考资料拼进 prompt。这一步决定了喂给模型的资料准不准。

  • 提示词工程

    设计好 prompt 让模型输出更符合预期。常用:system 角色设定,比如你是家居客服只根据资料回答;把检索到的资料放进去;明确要求,不知道就说不知道、别编;给格式示例。RAG 里提示词很关键,直接影响回答质量和是否幻觉。

  • 转人工机制

    AI 答不了或用户不满意时转人工。触发条件:模型置信度低、检索不到相关资料、用户明确要人工、涉及投诉售后等敏感场景。做法是识别到这些情况就把会话转给人工客服,比如接入工单或客服系统,保证体验兜底。

常见场景题:

  • AI 客服整体流程是什么?

    用户提问,问题向量化,去知识库向量检索出相关资料,把资料加历史对话加问题拼成 prompt,调大模型生成回答,返回用户。答不了或命中敏感场景就转人工。这就是一个 RAG 加多轮加转人工的客服闭环。

  • RAG 知识库怎么搭建?

    四步:一收集整理文档,商品资料、售后政策等;二文档切分成 chunk;三每块用 Embedding 向量化,存进向量数据库;四提问时问题向量化、检索 topN、拼进 prompt 给大模型。后续文档更新要同步更新向量库。

  • 知识库文档怎么切分?

    按语义切,常见按段落或标题切,或固定字数比如每 300 到 500 字切,块之间保留一定重叠 overlap 避免切断上下文。切太大检索不精准、浪费 token,切太小丢失语义,要结合文档结构调。

  • 如何避免大模型胡说?

    核心靠 RAG:强制它基于检索到的资料回答,prompt 里明确要求只根据给定资料回答,资料里没有就说不知道、不要编造。再配合降低温度参数、检索不到就不硬答直接转人工或兜底话术、对关键信息做校验。RAG 加约束提示词是防幻觉的主要手段。

  • 多轮对话上下文怎么保存?

    按会话 id 把历史对话存 Redis 设过期时间,每次请求带上历史一起发给模型。对话太长超 token 就做处理:只保留最近 N 轮,或把早期对话用模型总结成摘要再拼,兼顾上下文和 token 限制。

  • AI 接口超时怎么降级?

    大模型接口慢或挂了要兜底:设合理超时时间,超时或失败就降级,返回预设话术比如客服繁忙请稍后、转人工、或走缓存的常见问答;加重试限次数和熔断,别让 AI 拖垮整个服务。异步加流式返回也能改善体验。

  • Function Calling 怎么调用业务接口?

    给大模型注册一批可调用的函数工具,描述清楚每个函数干嘛、参数是什么。模型理解用户意图后不直接答,而是返回要调哪个函数、传什么参数,后端据此调真实业务接口比如查订单、查物流,把结果再给模型组织成自然语言回答。这样 AI 就能查实时业务数据,而不只是聊天。


11. WebSocket / SSE

重点背:

  • WebSocket 原理

    基于 TCP 的全双工通信协议。先通过一次 HTTP 请求带 Upgrade 头握手,把连接升级成 WebSocket,之后这条连接一直保持,客户端和服务端可以随时互相主动发消息,不用每次都重新建连接,适合实时双向通信。

  • WebSocket 和 HTTP 区别

    HTTP 是请求响应、单向、短连接,只能客户端发起、服务端被动答,答完就断;WebSocket 是长连接、全双工,握手后一直连着,双方都能主动推消息。HTTP 每次都带完整头开销大,WebSocket 建立后数据帧很轻。要服务端主动推、频繁实时交互就用 WebSocket。

  • WebSocket 心跳

    长连接可能因为网络问题、代理超时悄悄断了但双方不知道,也就是假死。心跳就是定时发一个 ping,对方回 pong,确认连接还活着;一段时间收不到就判定断开、触发重连。同时心跳还能防止空闲连接被中间设备、Nginx 超时关掉。

  • WebSocket 鉴权

    WebSocket 握手是 HTTP,可以在握手阶段鉴权:通过 URL 参数或 header 带上 token,握手时校验,不通过就拒绝连接。因为浏览器 WebSocket 不好加自定义 header,常把 token 放连接 URL 的参数里,或连上后第一条消息发 token 认证。

  • SSE 原理

    Server-Sent Events,基于 HTTP 的服务端单向推送。客户端发一个请求建立连接,服务端保持连接不关闭,持续往客户端推数据流 text/event-stream。只能服务端到客户端单向,浏览器原生支持自动重连,适合服务端单向通知的场景。

  • SSE 和 WebSocket 区别

    方向:SSE 单向只服务端推客户端,WebSocket 双向。协议:SSE 就是普通 HTTP,轻量、自带重连、实现简单;WebSocket 是独立协议要握手升级。场景:只需服务端推比如进度、通知、行情用 SSE 就够;需要双向频繁交互比如聊天、游戏、协同用 WebSocket。SSE 更简单,WebSocket 更强。

  • 断线重连

    长连接都要处理断线。客户端监听到连接断开就自动重连,一般加退避策略,隔几秒重试、逐渐拉长间隔,别猛重连打垮服务;SSE 浏览器原生自动重连,WebSocket 要自己写重连逻辑。重连后可能要补拉断线期间漏掉的消息。

常见场景题:

  • 安装派单为什么用 WebSocket?

    派单要服务端主动、实时把新订单推给对应的安装师傅,而且师傅端可能要回应,接单或拒单,是双向实时交互。轮询有延迟又浪费,SSE 只能单向推,所以用 WebSocket 全双工长连接,推得快、还能双向沟通。

  • 导出通知为什么用 SSE?

    导出进度、完成通知是服务端单向推给前端,前端不用往回发,场景简单。SSE 就是普通 HTTP、实现轻量、浏览器自带重连,完全够用,没必要上更重的 WebSocket。单向推送首选 SSE。

  • SSE 和 WebSocket 区别是什么?

    一句话:SSE 是 HTTP 上的服务端单向推送、轻量自带重连;WebSocket 是独立协议的全双工双向通信、更强但更重。单向通知用 SSE,双向交互用 WebSocket。

  • WebSocket 连接断了怎么办?

    靠心跳检测 ping pong 及时发现断开,客户端自动重连带退避,重连后重新鉴权、恢复订阅,并补拉断线期间的消息。服务端也要清理断开连接对应的会话资源。

  • 多实例部署下 WebSocket 怎么推送?

    连接是有状态的,用户 A 连在实例 1,消息可能从实例 2 产生,实例 2 找不到 A 的连接。解决:用 Redis 发布订阅或 MQ 做广播,消息发出来所有实例都收到,持有该用户连接的实例负责推给他;或者用专门的推送网关、中间件统一管理连接。核心是解决跨实例找连接的问题。

  • 为什么 SSE 还要配合轮询兜底?

    SSE 是长连接,可能被 Nginx、网关超时断开,或个别浏览器、环境不支持,不能百分百保证送达。所以兜底:SSE 失效就降级成定时轮询查状态,保证用户最终一定能拿到结果,比如导出完成、下载链接。可靠性优先。


12. JVM 和线上排查

重点背:

  • JVM 内存结构

    主要分几块:堆,存对象实例,是 GC 主战场,分年轻代 Eden 加 Survivor 和老年代;方法区或元空间,存类信息、常量、静态变量,JDK8 后元空间用本地内存;虚拟机栈,每个线程私有,存栈帧、局部变量、方法调用;本地方法栈;程序计数器,记录当前执行位置。堆和方法区线程共享,栈和计数器线程私有。

  • 类加载机制

    类从被加载到卸载的生命周期:加载、验证、准备、解析、初始化、使用、卸载。加载是把 class 字节码读进来生成 Class 对象;验证保证字节码合法;准备给静态变量分配内存赋默认值;解析把符号引用换成直接引用;初始化执行静态代码块、静态变量赋值。用到才加载,也就是懒加载。

  • 双亲委派

    类加载器加载类时,先把请求往上委托给父加载器,父加载器能加载就加载,加载不了才自己来。顺序往上委托:自定义、应用类加载器、扩展类加载器、启动类加载器,再逐级往下尝试。好处是避免类被重复加载,保证核心类比如 java.lang.String 不会被篡改替换,更安全。

  • Minor GC / Full GC

    Minor GC 也叫 YGC:只回收年轻代,对象在 Eden 放不下就触发,频繁但快。Full GC:回收整个堆,年轻代加老年代,再加方法区,慢、STW 停顿长。老年代满、方法区满、大对象直接进老年代放不下等会触发 Full GC。调优目标就是减少 Full GC 频率和停顿。

  • OOM 排查

    先看是哪种 OOM:堆 OOM,Java heap space,一般是对象太多或内存泄漏;元空间 OOM 是类加载太多。排查:加 -XX:+HeapDumpOnOutOfMemoryError 让它 OOM 时自动导 dump,再用 MAT 或 jvisualvm 分析 dump,找占内存最多的对象和引用链,定位泄漏点。也可以 jmap 手动导堆。

  • CPU 飙高排查

    经典四步:top 找到 CPU 高的 Java 进程 pid,top -Hp pid 找到该进程里 CPU 高的线程 tid,把 tid 转成 16 进制,jstack pid 打印线程栈、搜这个 16 进制线程,看它卡在哪段代码。常见原因:死循环、频繁 GC、大量计算、锁竞争。

  • jstack、jmap、jstat 基本用法

    jstack:打印线程堆栈,排查 CPU 高、死锁、线程卡住。jmap:导出堆内存快照 jmap -dump 或看对象统计 jmap -histo,排查内存问题。jstat:看 JVM 运行时统计,尤其 GC 情况,jstat -gc pid 1000 看各代内存和 GC 次数、耗时,判断 GC 是否频繁。

常见场景题:

  • 线上接口突然变慢怎么排查?

    分层排查:先看监控,接口耗时、QPS、错误率,定位是哪个接口或环节。常见方向:数据库慢 SQL,查慢查询日志、explain;缓存失效或穿透;下游接口超时;GC 频繁导致停顿,jstat 看 GC;CPU 或内存打满;线程池满、锁等待,jstack 看。从 SQL 和 GC 这两个高发点先查,再逐步缩小。

  • CPU 飙高怎么排查?

    top 定位高 CPU 的进程 pid,top -Hp pid 定位高 CPU 的线程,printf 把线程 id 转 16 进制,jstack pid 找到对应线程栈看卡在哪。一般是死循环、频繁 Full GC、大量计算或序列化、锁自旋。如果是 GC 引起的,再去查内存和 GC。

  • 内存一直上涨怎么排查?

    大概率内存泄漏,对象一直被引用无法回收。用 jstat 看 GC 后老年代是否降不下来,jmap 导 dump,用 MAT 分析找占用大、数量异常的对象和 GC Root 引用链,定位是谁一直持有。常见:静态集合只加不删、ThreadLocal 没 remove、连接或流没关、缓存无上限。

  • Full GC 频繁怎么处理?

    先 jstat -gc 确认 Full GC 频率和耗时,再分析原因:堆太小、内存泄漏导致老年代一直满、大对象频繁进老年代、元空间不足、或代码疯狂创建对象。对应处理:调堆大小和比例、修内存泄漏、优化大对象、调元空间、必要时换更合适的 GC 收集器比如 G1、ZGC。根因还是别让老年代快速被填满。

  • OOM 后怎么分析 dump 文件?

    提前配 -XX:+HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPath 让 OOM 自动生成堆 dump。用 MAT,Memory Analyzer,打开 dump,看 Leak Suspects 报告、Dominator Tree 找占内存最大的对象,顺着 GC Root 引用链找到是谁持有它不放,定位泄漏代码。也可以 jmap -histo 快速看对象数量分布。


推荐复习顺序

第 1 阶段:必须先背

  1. Spring 事务和 AOP
  2. Redis 缓存和分布式锁
  3. RabbitMQ 延迟队列和幂等
  4. MySQL 索引和慢 SQL
  5. 订单状态机和支付并发

第 2 阶段:结合项目背

  1. 异步导出
  2. ES 搜索
  3. JWT 登录鉴权
  4. WebSocket / SSE
  5. SpringAI / RAG

第 3 阶段:补基础

  1. Java 集合
  2. 并发基础
  3. JVM
  4. Spring Cloud 基础

最应该重点准备的 10 道场景题

  1. 你们订单超时关单是怎么实现的?
  2. 支付成功和超时关单同时发生怎么办?
  3. 用户重复点击下单怎么防止重复订单?
  4. RabbitMQ 消息重复消费怎么保证幂等?
  5. 异步导出为什么不会 OOM?
  6. SSE 断开后怎么处理?
  7. Redis 缓存和 MySQL 数据不一致怎么办?
  8. ES 和 MySQL 数据不一致怎么办?
  9. JWT + Redis 登录流程怎么设计?
  10. RAG 知识库是怎么搭建的?

最危险的雷点

如果没准备好,简历里这些点容易被问崩:

  • SpringAI / RAG
  • RabbitMQ 延迟队列
  • 支付回调幂等
  • Redisson 分布式锁
  • 异步导出
  • FastExcel
  • ES 索引同步
  • SSE / WebSocket
  • Spring 事务失效
  • MySQL 慢 SQL 优化

建议先把这些讲熟,再去背其他低频八股。