Java后端架构师面试题详解
本文档汇总了 Java 后端方向(含并发、Spring、MySQL、JVM、Linux、Redis、监控稳定性)的高频面试题,并给出结合真实开发场景的详细答案。对有必要的问题配以 Mermaid 流程图、架构图辅助理解。
一、幂等性
什么是幂等?什么情况下需要考虑幂等?你怎么解决幂等的问题?
幂等(Idempotent) 的数学定义是:一个操作执行一次和执行多次,对系统状态产生的影响完全一致。通俗讲就是"重复调用不会产生副作用"。HTTP 规范里 GET、PUT、DELETE 天然要求幂等,而 POST 通常不是。
什么情况下需要考虑幂等: 凡是可能出现"重复请求/重复消费"的场景都必须考虑,典型包括:
- 前端重复提交:用户连点按钮、网络抖动导致重试。
- RPC/重试机制:Feign、Dubbo 调用超时后框架自动重试,可能产生重复下单、重复扣款。
- 消息队列重复消费:MQ 的 At-Least-Once 语义下,消费者宕机重启后消息会被重新投递。
- 支付/对账回调:第三方支付平台可能对同一笔交易发起多次异步通知。
- 定时任务重复执行:调度器在故障恢复后可能重复触发。
如何解决幂等问题,常见方案:
- 唯一索引(数据库层兜底):对业务唯一键(如订单号)建唯一索引,重复插入直接抛
DuplicateKeyException,是最可靠的最后一道防线。 - Token 机制(防重令牌):前端先请求一个一次性 token,提交时带上,服务端校验后立即删除(用 Redis
DEL返回值判断)。适合表单提交。 - 乐观锁(版本号):
UPDATE account SET balance=balance-100, version=version+1 WHERE id=? AND version=?,更新行数为 0 即重复请求。 - 状态机校验:业务本身有状态流转(如订单 待支付→已支付),更新时带上状态条件
WHERE status='待支付',重复请求因状态已变更而失败。 - 分布式锁:对同一业务键加 Redis/Zk 锁,串行化处理,处理完释放。注意锁要带唯一标识防止误删。
最佳实践推荐组合: 入口用「Token 或分布式锁」快速拦截 + 数据库「唯一索引」兜底 + 业务「状态机」保证语义正确,多层防护。下面以"重复扣款"为例给出一套典型的幂等处理流程:
flowchart TD
A[请求到达: 扣款 orderNo=123] --> B{Redis 校验/设置 幂等key}
B -->|key已存在, 说明正在处理或已处理| C[直接返回历史结果]
B -->|key不存在, 设置成功 拿到锁| D[查询订单状态]
D --> E{status == 待支付?}
E -->|否, 已支付/已关闭| F[返回:请勿重复操作]
E -->|是| G[执行扣款: 唯一索引 + 状态机更新]
G --> H{更新影响行数 > 0?}
H -->|否 重复请求| I[回滚并返回幂等提示]
H -->|是| J[记录流水, 写入幂等结果缓存]
J --> K[返回成功]
C --> L[结束]
F --> L
I --> L
K --> L
真实场景注意点: 幂等结果要缓存并原样返回(而非简单返回"成功"),因为重试方往往期望拿到第一次处理的真实结果;幂等 key 的过期时间要大于业务最长处理时间,避免锁提前释放导致并发穿透。
二、Java 并发
多个线程同时读写,读线程数量远大于写线程,应如何解决并发问题?选择什么锁?
典型的读多写少场景(如配置缓存、字典表)。核心思路是读读不互斥、读写/写写才互斥,所以应优先选择"读写锁"或"无锁 copy"方案,而不是用 synchronized / ReentrantLock 这种读写都阻塞的排他锁。
可选方案对比:
| 方案 | 适用场景 | 特点 |
|---|---|---|
ReentrantReadWriteLock |
读多写少、写操作较快 | 读读共享,读写/写写互斥;写锁可降级为读锁 |
StampedLock(JDK8) |
读远多于写、追求极致吞吐 | 乐观读不加锁,性能最佳;不可重入、易用错 |
CopyOnWriteArrayList |
读远多于写、写极少、数据量小 | 写时复制整个数组,读完全无锁;写开销大 |
volatile + 不可变对象 |
配置项整体替换 | 读无锁,写时整体替换引用 |
最佳实践:
- 字典/配置类数据量小、写极少 →
CopyOnWriteArrayList或volatile引用替换。 - 缓存类、写有一定频率 →
ReentrantReadWriteLock,并且注意锁降级(先持写锁更新数据,再获取读锁,最后释放写锁),避免更新完立刻被别的读线程读到半成品。 - 对吞吐要求极高且读占比 95%+ →
StampedLock的乐观读(tryOptimisticRead+ 校验validate)。
flowchart LR
subgraph 读多写少场景
R1[读线程] -->|读读共享 不阻塞| RES[(共享数据)]
R2[读线程] -->|读读共享 不阻塞| RES
W[写线程] -->|读写 写写互斥| RES
end
JAVA 的 AQS 是否了解,它是干嘛的?
AQS(AbstractQueuedSynchronizer) 是 java.util.concurrent 下大多数同步工具的基石框架。ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch、CyclicBarrier、线程池的 Worker 等都基于它实现。
核心设计:
- 一个
volatile int state表示同步状态(语义由子类定义:锁的持有数、许可数、计数等)。 - 一个内部的 CLH FIFO 双向队列 管理被阻塞的线程节点。
- 模板方法模式:AQS 负责入队/出队/阻塞/唤醒的通用流程,子类只需实现
tryAcquire/tryRelease(独占)或tryAcquireShared/tryReleaseShared(共享)。
独占 vs 共享模式:
- 独占(
ReentrantLock):同一时刻只有一个线程能拿到 state,state表示重入次数。 - 共享(
Semaphore/CountDownLatch):多个线程可同时拿到,CountDownLatch里state表示还剩多少个计数。
工作流程(以 ReentrantLock 非公平锁为例):
flowchart TD
A[线程调用 lock] --> B{CAS 抢 state}
B -->|成功| C[设置当前线程为持有者]
B -->|失败| D{当前线程是否已持有锁?}
D -->|是 重入| E[state+1]
D -->|否| F[封装为 Node 入 CLH 队列]
F --> G[park 阻塞等待]
G --> H[前驱节点唤醒本线程]
H --> I{再次尝试 CAS 获取}
I -->|成功| J[出队, 返回]
I -->|失败| G
公平与非公平的区别就在第 B 步:公平锁会先调用 hasQueuedPredecessors() 检查队列里有没有排在自己前面的线程,有就不插队;非公平锁直接 CAS 抢,吞吐更高。
除了 synchronized 之外,你是怎么保障线程安全的?
线程安全的保障可以从多个层面入手,synchronized 只是其中最重的一种:
- 无同步方案(最佳):
- 线程封闭:局部变量天然线程安全(栈封闭),
ThreadLocal让每个线程拥有一份独立副本。 - 不可变对象:
final+ 构造完成后不可变(如String、LocalDateTime),天然线程安全。
- 线程封闭:局部变量天然线程安全(栈封闭),
- CAS / 原子类:
AtomicInteger、LongAdder、AtomicReference,基于Unsafe.compareAndSwap无锁更新,适合计数器。LongAdder在高并发下用分段累加,性能远超AtomicLong。 - 并发容器:
ConcurrentHashMap(分段锁/CAS+synchronized 桶)、CopyOnWriteArrayList、ConcurrentLinkedQueue、BlockingQueue系列。 - 显式锁:
ReentrantLock(可中断、可超时、可公平)、ReadWriteLock、StampedLock。 - 同步工具:
Semaphore限流、CountDownLatch等待、CyclicBarrier集合点。 volatile:保证可见性和禁止指令重排序,但不保证复合操作的原子性。
选择原则: 能无锁就无锁(原子类/并发容器)→ 能用 volatile 就不用锁 → 读多写少用读写锁 → 一般互斥用 synchronized(JVM 偏向锁/轻量级锁优化后够用)或 ReentrantLock(需要高级特性时)。
什么时候需要加 volatile?它能保证线程安全吗?
什么时候需要 volatile:
- 状态标志位:如
boolean running = true;,一个线程改、其他线程读,需要可见性。 - 双重检查锁定(DCL)单例:
private static volatile Singleton instance;,防止"指令重排序"导致拿到未初始化完成的对象。 - 一次性安全发布:对象初始化完成后用 volatile 引用发布,保证其他线程看到构造完成的状态。
能否保证线程安全:不能完全保证。 volatile 只保证两点:
- 可见性:写入后立即刷新到主内存,读取强制从主内存读,其他线程立刻能看到。
- 禁止指令重排序:通过内存屏障。
但 volatile 不保证原子性。例如 count++ 是"读-改-写"三步操作,即使 count 是 volatile,两个线程同时 ++ 仍会丢更新。这种场景要用 AtomicInteger 或加锁。只有当变量是"一个线程写、其他线程只读"的纯赋值场景,volatile 才足够。
线程池内线程全忙,提交新任务会发生什么?队列塞满后还是忙,再提交会怎样?
这取决于线程池的工作流程和参数配置。线程池核心参数:corePoolSize、maximumPoolSize、workQueue、threadFactory、rejectedExecutionHandler(拒绝策略)。
提交流程(ThreadPoolExecutor.execute):
flowchart TD
A[提交新任务] --> B{当前线程数 < corePoolSize?}
B -->|是| C[创建核心线程执行]
B -->|否| D{workQueue 能否入队?}
D -->|能| E[任务入队等待]
D -->|否 队列满| F{线程数 < maximumPoolSize?}
F -->|是| G[创建非核心线程执行]
F -->|否 已达最大| H[触发拒绝策略]
H --> I[AbortPolicy: 抛 RejectedExecutionException]
H --> J[CallerRunsPolicy: 调用者线程自己跑]
H --> K[DiscardPolicy: 直接丢弃]
H --> L[DiscardOldestPolicy: 丢掉队首最老任务, 再入队]
所以回答:
- 线程全忙但队列没满 → 任务进入等待队列。
- 队列也满了、还没到 max → 继续创建非核心线程直到
maximumPoolSize。 - 队列满了且线程数已达 max → 触发拒绝策略。
真实场景关键点: 很多人用 Executors.newFixedThreadPool,它用的是无界 LinkedBlockingQueue,队列永远不满,于是 maximumPoolSize 形同虚设,任务堆积导致 OOM。线上务必用 ThreadPoolExecutor 显式构造,并选用有界队列 + 合理的拒绝策略(生产环境常用 CallerRunsPolicy 做背压,或自定义策略降级写 MQ)。
Tomcat 本身的参数你一般会怎么调整?
Tomcat 调优主要围绕连接器(Connector)和容器(Container)的线程模型:
maxThreads:最大工作线程数,处理请求的并发上限。CPU 密集型设小(200~400),IO 密集型(大量 DB/下游调用)可设大(500~800),但受限于内存和下游承载。minSpareThreads:最小空闲线程,预热线程池,避免冷启动延迟。acceptCount:全连接队列(backlog)长度,所有线程忙时还能 accept 的连接数,默认 100,高并发可调到 500~1000。maxConnections:NIO/NIO2 下同时处理的最大连接数,到达后新连接进 acceptCount 排队。connectionTimeout:连接建立后读请求的超时,默认 20s,可适当调小防慢攻击。maxKeepAliveRequests:一个 keep-alive 连接上最大请求数,默认 100,可调大提升长连接复用率。- 协议选
org.apache.coyote.http11.Http11NioProtocol:用 NIO 异步,一个线程管多个连接。 compression/compressionMinSize:开启 gzip,减少带宽。
调优思路: 先压测找到瓶颈(CPU?线程数?下游?),再调 maxThreads 和 acceptCount。配合 -Xms/-Xmx 调大堆、调整 GC 策略一起做。
synchronized 锁住的是什么?字节码和内存中如何表现?
锁住的是什么:对象。 具体来说锁的是对象的"监视器(Monitor)":
- 实例方法(
synchronized void m())→ 锁的是this当前实例。 - 静态方法(
synchronized static void m())→ 锁的是当前类的Class对象,所有实例共用一把锁。 - 同步块(
synchronized(obj))→ 锁的是括号里的obj。
字节码表现:
- 同步块:编译后在 monitorenter / monitorexit 指令之间。
monitorenter尝试获取对象 monitor(计数器+1),monitorexit释放(计数器-1)。正常退出和异常退出都会编译出 monitorexit,保证锁一定释放。 - 同步方法:在方法的
access_flags中设置ACC_SYNCHRONIZED标志位,JVM 调用时自动加锁/解锁,没有显式的 monitorenter/monitorexit。
内存中(对象头)表现: 锁信息存在对象头(Object Header)的 Mark Word 里。HotSpot 的锁升级过程:
flowchart LR
A[无锁] -->|有线程竞争| B[偏向锁<br/>记录线程ID]
B -->|第二个线程竞争| C[轻量级锁<br/>CAS 自旋]
C -->|自旋失败次数超限| D[重量级锁<br/>OS Mutex 互斥量<br/>线程阻塞]
- 偏向锁:Mark Word 存线程 ID,同线程再进入只需 CAS 判断,几乎无开销。
- 轻量级锁:竞争时用 CAS + 自旋,不阻塞线程。
- 重量级锁:自旋拿不到就
park阻塞,交由操作系统互斥量,开销最大。
JDK 15 起偏向锁默认关闭(JEP 374),因为现代 CAS 已经很快,维护偏向锁的复杂度不再划算。
wait/notify/notifyAll 需不需要在 synchronized 块中?为什么?
必须在 synchronized 块中调用,否则会抛 IllegalMonitorStateException。
原因: wait/notify 的语义是"操作对象的 Monitor 等待队列"。wait() 会释放当前持有的 monitor 并把线程加入该对象的等待集;notify() 则从等待集取出一个线程唤醒。如果不持有 monitor(没进 synchronized),JVM 根本不知道你要操作哪个对象的等待队列,也无法释放一个你没持有的锁,所以直接抛异常。
这也保证了等待-通知的原子性:经典的生产消费场景中,判断条件(如 while(queue.isEmpty()) queue.wait())和调用 wait 必须在锁内,否则可能出现"判断完队列非空、刚要 wait,生产者已经 notify 过了"的丢失唤醒。wait() 会自动释放锁、被唤醒后自动重新获取锁。
为什么用 while 不用 if 判断条件? 防止虚假唤醒(spurious wakeup):线程可能在没有 notify 的情况下醒来,用 while 重新校验条件最安全。
ExecutorService 一般怎么用?每个 service 一个还是项目共用一个?有什么好处?
核心原则:按"隔离维度"划分线程池,绝不用一个全局池混跑所有任务。
- 共用一个池的问题:不同任务类型相互影响——一个慢任务(如调外部接口)把池占满,会导致另一个快任务(如发短信)也被阻塞,乃至拖垮整个应用。这就是"线程池隔离"的原因,和 Hystrix 的舱壁隔离思想一致。
- 推荐做法:按业务/资源类型划分独立线程池,每个池配独立的核心数、队列、拒绝策略和监控。例如:订单池、支付池、通知池、异步导入池分开。
使用方式要点:
- 用
ThreadPoolExecutor显式构造,不用Executors的快捷方法(避免无界队列 OOM)。 - 给线程起有意义的名字(自定义
ThreadFactory),方便排查线上问题。 - 通过 Spring 的
@Bean暴露,配合@Async("xxxExecutor")注解使用。 - 应用关闭时调用
shutdown()优雅停机,配合awaitTermination等待任务完成。
最佳实践架构:
flowchart TD
APP[业务应用] --> P1[订单线程池 core=20]
APP --> P2[支付线程池 core=10]
APP --> P3[通知线程池 core=5]
APP --> P4[批量导入池 core=50]
P1 -.互不影响.-> P2
P3 -.互不影响.-> P4
note[一个池打满不会拖垮其他业务]
好处总结:故障隔离、各自调参、独立监控、优雅停机可控。全局单池唯一的"好处"是简单省事,但代价是风险耦合,生产环境应避免。
三、Spring
你有没有用过 Spring 的 AOP?是用来干嘛的?大概怎么使用?
AOP(面向切面编程) 用于将横切关注点(cross-cutting concerns)从业务逻辑中剥离出来,避免在每个业务方法里重复写相同的非业务代码。
典型用途: 日志记录、性能监控、权限校验、事务管理(@Transactional 本身就是 AOP)、限流、缓存(@Cacheable)、链路追踪埋点等。
实现原理: Spring AOP 基于动态代理。目标类有接口时默认用 JDK 动态代理(代理接口),没有接口时用 CGLIB(生成子类)。可通过 spring.aop.proxy-target-class=true 强制全用 CGLIB。
使用方式(注解方式最常用):
// 1. 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogTime {}
// 2. 切面
@Aspect
@Component
public class LogAspect {
@Around("@annotation(LogTime)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
try {
return pjp.proceed();
} finally {
log.info("{} 耗时 {}ms", pjp.getSignature(), System.currentTimeMillis() - start);
}
}
}
// 3. 业务方法标注
@LogTime
public Order createOrder(...) { ... }
真实场景注意: AOP 通过代理生效,所以类内部方法自调用(this.xxx())不会走代理,注解会失效。解决办法:注入自身代理对象调用、用 AopContext.currentProxy()、或拆分到两个 Bean。private 方法也不被代理。
一个接口有 2 个不同实现,怎么 Autowire 指定的实现?
多种方式:
@Qualifier指定 Bean 名称(最常用):@Autowired @Qualifier("redisServiceImpl") private CacheService cacheService;- 字段名与 Bean 名一致:
private CacheService redisServiceImpl;,Spring 会按名称匹配。 @Primary标注默认实现:在某个实现类上加@Primary,@Autowired时优先选它。@Resource(name="..."):JSR-250 注解,直接按名称注入。- 通过配置动态选择:用
@Conditional或工厂方法,根据环境/配置注入不同实现。
flowchart LR
C[CacheService 接口] --> R[RedisServiceImpl]
C --> L[LocalServiceImpl]
IC[注入点] -->|@Qualifier redisServiceImpl| R
IC2[注入点] -->|@Primary 优先| R
IC3[注入点] -->|@Resource name=localServiceImpl| L
Spring 声明式事务 @Transactional 写在什么位置?抛异常会自动回滚吗?如何控制不回滚?
写在哪: 推荐写在Service 层的具体方法上(或类上对所有 public 方法生效)。不要写在接口上(CGLIB 代理时接口注解可能失效),不要写在 private/protected 方法上(代理不到,事务不生效)。
是否会自动回滚:默认只对 RuntimeException 和 Error 回滚,对受检异常(checked exception,如 IOException)不回滚。 这是 Spring 的默认策略(rollbackFor = RuntimeException.class),很多人踩坑:业务抛了 IOException 结果事务没回滚。
如何控制:
- 指定回滚异常:
@Transactional(rollbackFor = Exception.class),让所有异常都回滚(生产推荐写法)。 - 不触发回滚:
@Transactional(noRollbackFor = BusinessException.class)指定某些异常不回滚。- 在方法内
try-catch把异常吞掉(不向外抛出,事务管理器感知不到异常就不会回滚)。注意:吞异常要谨慎,确认业务确实不需要回滚。 @Transactional(propagation = ...)控制传播行为,如NOT_SUPPORTED以非事务方式运行。
事务失效的常见坑:
- 自调用(同类内
this.method()调用带@Transactional的方法)→ 不走代理,失效。 - 方法非 public → 失效。
- 异常被 catch 没抛出 → 失效。
- 数据库引擎不支持事务(如 MyISAM)→ 失效。
- 传播行为
NOT_SUPPORTED/NEVER→ 不在事务中。
想在 Bean 生成并装配完毕后执行自己的逻辑,有哪些方式?
@PostConstruct注解(JSR-250):在依赖注入完成后、初始化阶段执行,最常用。@PostConstruct public void init() { /* 初始化逻辑 */ }- 实现
InitializingBean接口:重写afterPropertiesSet(),效果同上,与@PostConstruct二选一。 @Bean(initMethod="..."):声明 Bean 时指定初始化方法。- 构造器注入 + 在构造器里做:如果只依赖构造器注入的参数,可直接在构造器中初始化(Spring 推荐构造器注入)。
BeanPostProcessor:对所有 Bean 的初始化前后做统一拦截处理,适合框架级需求。
执行顺序: 构造器 → @PostConstruct → InitializingBean.afterPropertiesSet → initMethod → BeanPostProcessor.postProcessAfterInitialization(AOP 代理在此生成)。
如果需要在所有 Bean 都就绪、容器完全启动后执行(如预热缓存、启动定时任务),用:
ApplicationListener<ContextRefreshedEvent>,或@EventListener(ApplicationReadyEvent.class)(SpringBoot 专属,更晚,所有初始化都完成)。
SpringBoot 没放到 web 容器里为什么能跑 HTTP 服务?
因为 SpringBoot 内嵌了 Servlet 容器(Tomcat/Jetty/Undertow)。它不是"没有 Web 容器",而是把容器作为 jar 依赖打进应用里。
启动流程:
flowchart TD
A[main 方法 SpringApplication.run] --> B[创建 ApplicationContext<br/>SERVLET 类型]
B --> C[创建内嵌 Tomcat 对象]
C --> D[注册 DispatcherServlet 到 Tomcat]
D --> E[启动 Tomcat 监听端口]
E --> F[应用就绪 接收请求]
关键类:SpringApplication 启动时根据是否引入 web starter 选择 AnnotationConfigServletWebServerApplicationContext,它会创建 TomcatServletWebServerFactory,实例化 Tomcat,把 DispatcherServlet 注册为 Tomcat 的 Servlet,然后 tomcat.start()。
好处: 不用单独部署 war 到外部 Tomcat,java -jar 即可运行,便于容器化(Docker)和微服务部署。如果不想用内嵌容器,仍可打 war 包部署到外部 Tomcat。
SpringBoot 想用自定义配置文件而不只是 application.properties,怎么弄?
@PropertySource加载额外 properties 文件(注意:不支持 yml,除非自定义):@PropertySource("classpath:custom.properties") @Configuration public class CustomConfig {}spring.config.additional-location/spring.config.name:在启动时指定外部配置目录或文件名。@ConfigurationProperties+@Configuration:将自定义文件中的属性绑定到对象。- 命令行 / 环境变量覆盖:
--server.port=9090或SERVER_PORT=9090,优先级高于 jar 内配置。 YamlPropertySourceLoader自定义加载 yml:通过EnvironmentPostProcessor把 yml 注入 Environment。
配置加载优先级(高到低): 命令行参数 > 环境变量 > jar 包外 application-{profile}.yml > jar 包内 application-{profile}.yml > jar 包外 application.yml > jar 包内 application.yml。线上常把配置放外部目录覆盖打包配置,做到改配置不重新打包。
SpringMVC 中 RequestMapping 可以指定 GET、POST 方法么?怎么指定?
可以。@RequestMapping 的 method 属性指定,或者直接用组合注解:
// 方式一:method 属性
@RequestMapping(value = "/order", method = RequestMethod.GET)
// 方式二:组合注解(推荐)
@GetMapping("/order")
@PostMapping("/order")
@PutMapping("/order")
@DeleteMapping("/order")
Spring 4.3+ 推荐用 @GetMapping/@PostMapping 等组合注解,更简洁。实际开发中规范做法是:查询用 GET,创建用 POST,更新用 PUT/PATCH,删除用 DELETE,并且 GET 不应有副作用(符合幂等与语义)。
SpringMVC 希望把输出的 Object 包装为 JSON 输出,怎么处理?
最简单的方式:引入 jackson-databind(SpringBoot web starter 默认包含),Controller 方法加 @ResponseBody(或类上加 @RestController = @Controller + @ResponseBody),返回的对象会被 HttpMessageConverter(MappingJackson2HttpMessageConverter)自动序列化为 JSON。
@RestController
public class OrderController {
@GetMapping("/order/{id}")
public Result<Order> get(@PathVariable Long id) {
return Result.success(orderService.get(id)); // 自动转 JSON
}
}
统一返回结构最佳实践: 定义统一的 Result<T> 包装类(code/msg/data),配合全局异常处理,所有接口返回统一格式。如需控制序列化细节(日期格式、空值过滤、命名策略),用 @JsonFormat、@JsonInclude、@JsonProperty 注解,或全局配置 ObjectMapper。
怎样拦截 SpringMVC 的异常,做自定义处理(打日志、包装成 JSON)?
用 @RestControllerAdvice + @ExceptionHandler 全局统一处理:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<?> handleBiz(BusinessException e) {
log.warn("业务异常: {}", e.getMessage());
return Result.fail(e.getCode(), e.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<?> handleAll(Exception e) {
log.error("系统异常", e);
return Result.fail("SYSTEM_ERROR", "系统繁忙");
}
}
设计要点:
- 优先匹配更具体的异常类型,最后用
Exception.class兜底。 - 业务异常(可预期)记 warn,系统异常(非预期)记 error 并打印完整堆栈。
- 对外不暴露敏感堆栈信息,统一包装成
ResultJSON 返回。 - 可配合
@ResponseStatus控制 HTTP 状态码。 - 老项目也可用
HandlerExceptionResolver或实现ErrorController处理/error。
flowchart TD
REQ[HTTP 请求] --> CTRL[Controller 方法]
CTRL -->|抛异常| EX[SpringMVC 捕获]
EX --> ADV[RestControllerAdvice]
ADV --> EH1{异常类型匹配}
EH1 -->|BusinessException| R1[记warn 返回业务码]
EH1 -->|其他Exception| R2[记error 返回系统错误]
R1 --> RESP[统一 Result JSON 响应]
R2 --> RESP
四、MySQL
如果有很多数据插入 MySQL,你会选择什么方式?
批量插入优先,避免单条逐行插入(每条都有网络往返和事务开销)。按场景选方案:
| 方式 | 适用场景 | 要点 |
|---|---|---|
| 多值批量 INSERT | 千~万级常规导入 | INSERT INTO t(a,b) VALUES(..),(..),(...) 一条插多行,单事务提交 |
LOAD DATA INFILE |
百万级大文件导入 | 最快,绕过 SQL 解析直接读文件;需 local_infile=ON |
insert ... batch(MyBatis <foreach>) |
业务批量写入 | 拼成多值 SQL,注意单条 SQL 长度上限(max_allowed_packet) |
| 先关索引/约束再导入 | 全量初始化 | 导入前 ALTER TABLE DISABLE KEYS,导入后重建,避免每行维护索引 |
| 分批提交 | 海量数据 | 每 1000~5000 行一批提交一次,平衡内存和事务大小 |
| 并发插入 | 多线程分片 | 多个连接各插不同表/不同分片,注意不要在同一表上互相加锁 |
最佳实践: 用 MyBatis 的 <foreach> 批量插入,每批 1000 行;或直接用 JDBC addBatch + executeBatch,并给连接 URL 加 rewriteBatchedStatements=true(MySQL 驱动会把批处理重写成一条多值 SQL,性能提升数倍)。关闭自动提交、手动控制事务。导入大表时临时去掉二级索引,导完再建。
查询很慢,第一个想到的方式是什么?索引是干嘛的?
第一步用 EXPLAIN 看执行计划,重点看 type(访问类型)、key(实际用的索引)、rows(预估扫描行数)、Extra(是否 Using filesort/Using temporary)。如果 type 是 ALL(全表扫描),基本就是缺索引或索引失效。
索引是干嘛的: 索引是一种排好序的数据结构(MySQL InnoDB 用 B+ 树),让数据库查询不必全表扫描,而是通过树形结构快速定位数据,把 O(n) 的扫描降到 O(log n)。同时索引本身有序,也能加速 ORDER BY、GROUP BY,并支撑唯一约束。
InnoDB 的 B+ 树索引特点:非叶子节点只存键值和指针(扇出大、树矮,3~4 层就能存千万级数据),所有数据都存在叶子节点,叶子节点间用双向链表相连,利于范围扫描。
flowchart TD
Q[查询 SELECT] --> EX[EXPLAIN 执行计划]
EX --> CK{type=ALL 全表扫描?}
CK -->|是| ADD[建合适索引/改写SQL]
CK -->|否| CK2{Extra 有 filesort/temporary?}
CK2 -->|有| OPT[优化排序/分组 加索引或改写]
CK2 -->|无| SLOW[查锁/数据量/硬件/参数]
建了单列索引,查询查出 2 列,会用到这个索引吗?
会用到,但取决于查询条件。 索引用不用取决于 WHERE/JOIN/ORDER BY 条件是否命中索引列,而不是 SELECT 的列。
- 如果
WHERE用到了这个单列索引列 → 走索引。SELECT 的另一列:- 如果该列的数据包含在索引叶子节点里(InnoDB 二级索引叶子存主键 + 索引列)→ 若查的另一列恰好是主键或索引列,则覆盖索引,不用回表;否则需要回表(用主键去聚簇索引取完整行)。
- 回表有额外开销,但比全表扫描快。
- 如果
WHERE完全没用到索引列 → 走全表扫描,索引用不上。
所以"查出 2 列"不影响索引使用,关键是 WHERE 条件。优化时常通过覆盖索引(把查询列都纳入联合索引)避免回表。
建了包含多列的联合索引,查询只用第一列能不能用上?查三列呢?
最左前缀原则: 联合索引 (a, b, c) 按 a→b→c 顺序构建 B+ 树,查询条件必须从最左列开始连续匹配才能用到索引。
- 只用第一列 a:能用上索引(走 a 的前缀)。
WHERE a=?→ 索引生效。 - 查三列 a, b, c(
WHERE a=? AND b=? AND c=?):完美命中,三个字段都能用索引精确匹配。 - 跳过 a 直接用 b 或 c:索引失效(无法从中间开始)。
WHERE b=?→ 全表扫描。 - 用 a 和 c,跳过 b:只能用到 a 这一段,c 用不上(中间断档)。
索引下推(ICP,5.6+): WHERE a=? AND c=? 时,存储引擎在用 a 定位后,会先在索引层用 c 过滤再回表,减少回表次数,但 c 仍不参与索引定位。
范围查询会中断后续列: WHERE a=? AND b>? AND c=? 中,a、b 走索引,b 是范围查询,c 用不上索引(范围后的列无法用索引有序性)。
接上题,where 条件带 i + 5 < 100 会使用到这个索引吗?
不会。 对索引列做函数/算术运算会让索引失效。i + 5 < 100 中 i 被运算了,B+ 树是按 i 本身的值排序的,i+5 的结果无序,无法用索引二分查找。
解决办法:改写为对列不做运算的形式。 i + 5 < 100 等价于 i < 95,这样就能用上 i 的索引了。
同理,以下写法都会让索引失效:
- 函数:
WHERE YEAR(create_time)=2024→ 改写为范围WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01'。 - 隐式类型转换:
WHERE phone=13800138000(phone 是 varchar)→ 传字符串。 WHERE name LIKE '%abc'(左模糊)。
怎么看是否用到了某个索引?
EXPLAIN(或 EXPLAIN ANALYZE 真实执行 8.0+)查看执行计划,关注列:
| 列 | 看什么 |
|---|---|
type |
const/eq_ref/ref/range 都算走索引;index 扫整个索引树;ALL 全表扫描最差 |
key |
实际选用的索引名;NULL 表示没用索引 |
key_len |
用到的索引字段长度,判断联合索引用了几列 |
rows |
预估扫描行数,越小越好 |
Extra |
Using index=覆盖索引不用回表;Using filesort=额外排序;Using temporary=用了临时表 |
注意 possible_keys 列出可用索引但不代表真用了,以 key 为准。也可开 slow_query_log 抓慢 SQL,或用 SHOW PROFILE / Performance Schema 看各阶段耗时。
like '%aaa%' 会用索引吗?like 'aaa%' 呢?
like 'aaa%'(前缀匹配):会用索引。因为 B+ 树有序,aaa%等价于一个范围[aaa, aab),可走索引范围扫描。like '%aaa%'(双模糊)或like '%aaa'(左模糊):不走索引。左边不确定,无法利用 B+ 树有序性,只能全表/全索引扫描。
优化 %aaa% 的方案:
- 全文索引
FULLTEXT+MATCH ... AGAINST(适合文本搜索)。 - ElasticSearch 等搜索引擎(大数据量、复杂搜索首选)。
- 如必须用 like,可配合覆盖索引做全索引扫描(扫索引比扫表快,仍非最优)。
真实场景中,搜索类需求一般不应交给 MySQL like,而用 ES/ClickHouse 等专用引擎。
drop、truncate、delete 的区别?
| 操作 | 作用 | 是否可回滚 | 速度 | 自增列 | 触发器 |
|---|---|---|---|---|---|
DROP |
删除整张表(结构+数据),DDL | 部分场景可(原子DDL) | 快 | - | 不触发 |
TRUNCATE |
清空表数据保留结构,DDL | 不可(立即生效不记binlog行) | 最快 | 重置为1 | 不触发 |
DELETE |
按条件删除行,DML | 可(事务内可回滚) | 最慢(逐行删+记日志) | 不重置 | 触发 |
要点:
TRUNCATE实质是 DROP+CREATE 重建表,直接释放数据页,不记录逐行 undo log,所以快但不能条件删除、不能回滚。DELETE是逐行删除并写 undo/binlog,支持WHERE、事务回滚、触发器,但速度最慢且会产生大量碎片。- 生产清空数据优先 TRUNCATE(确认无误后);按条件删大量数据用
DELETE ... LIMIT分批,避免长事务和大锁。
平时怎么监控数据库?慢 SQL 怎么排查?
监控:
- 慢查询日志:
slow_query_log=ON,long_query_time=1(线上常设 1s 甚至 500ms)。 - Prometheus + mysqld_exporter + Grafana:采集 QPS、TPS、连接数、buffer 命中率、复制延迟等指标,设告警。
- Percona PMM 或 阿里云 DAS 等数据库监控平台,自动抓慢 SQL 和 TOP SQL。
SHOW GLOBAL STATUS、SHOW PROCESSLIST巡检连接和活跃线程。
慢 SQL 排查流程:
flowchart TD
A[发现慢SQL/告警] --> B[从慢日志取完整SQL]
B --> C[EXPLAIN 看执行计划]
C --> D{type=ALL 或 rows巨大?}
D -->|是| E[缺索引? 加合适索引]
D -->|否| F{Extra 有 filesort/temporary?}
F -->|有| G[优化 ORDER BY/GROUP BY 加索引或缩小结果集]
F -->|无| H[查数据量/锁/事务/返回行数过大]
H --> I[是否大事务长锁? 拆分/限流]
E --> J[验证 复测]
G --> J
I --> J
排查要点:先看是不是索引问题(EXPLAIN),再看是不是返回数据量过大(LIMIT 分页、只查必要列),再看是不是锁等待(SHOW ENGINE INNODB STATUS 看死锁和锁等待),最后看参数和硬件。
数据库是否支持 emoji?不支持如何操作?
emoji 等 4 字节字符需要 utf8mb4 字符集(MySQL 的 utf8 是 3 字节,存不了 emoji 和部分生僻字)。
操作步骤:
- 修改库/表/列字符集为
utf8mb4,排序规则用utf8mb4_unicode_ci或utf8mb4_0900_ai_ci(8.0):ALTER DATABASE db CHARACTER SET utf8mb4; ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4; - JDBC 连接串去掉
characterEncoding=utf8(或显式设characterEncoding=utf8mb4),MySQL 8 驱动自动识别。 - 确认
max_allowed_packet足够。 - 升级 MySQL 5.7.7+(
utf8mb4的索引长度限制在 5.7.7 后放宽到 3072 字节,避免建索引报错)。
线上存量数据转换要做停机或在线 DDL(pt-online-schema-change / gh-ost),大表 CONVERT 会锁表。
单表数据量多少开始查询性能急剧下降?
没有绝对阈值,取决于表结构、索引、查询模式。一般经验:单表 1000 万行 / 10GB 以上,B+ 树层级增加、索引维护成本上升,增删改和部分查询开始变慢;5000 万~1 亿行时如索引设计不佳会明显劣化。
关键不是行数而是:
- B+ 树高度:3 层约存千万级,4 层开始 IO 增加。
- 索引是否合理:索引好的表上亿也能毫秒级查;索引差的几十万就慢。
- 是否大量范围扫/回表/排序。
- 热数据是否超出 buffer pool:数据量超过 innodb_buffer_pool_size 时频繁磁盘 IO,性能断崖式下降。
应对: 先优化索引和 SQL;数据量确实大时考虑分表(按时间/业务维度拆分)或分库分表 + 读写分离;冷热数据分离(历史数据归档)。一般单表控制在 2000 万以内较稳妥。
查询死掉了,怎么找出执行进程?找出来之后干嘛?
SHOW PROCESSLIST;(或 SHOW FULL PROCESSLIST; 看完整 SQL)查看所有连接和正在执行的语句,关注 Time 长、State 为 Sending data/Copying to tmp table/Locked 的会话。也可查 information_schema.PROCESSLIST 或 Performance Schema。
找出来之后:
- 确认是否真的异常(是不是合理的批量任务)。
- 必要时
KILL <id>;终止该会话(注意:KILL 会回滚其事务,大事务回滚也慢)。 - 记录 SQL,事后用 EXPLAIN 分析优化。
- 查
SHOW ENGINE INNODB STATUS看是否有锁等待/死锁,定位持锁会话。 - 排查根因:缺索引、大事务、全表扫、长事务持锁等,从根本上修复避免复发。
读写分离是怎么做的?中间件怎么操作?跟事务有什么关系?
读写分离:主库(Master)负责写,从库(Slave)负责读,主从通过 binlog 复制同步数据,分担读压力。
实现方式:
- 中间件:ShardingSphere、MyCat、ProxySQL、Atlas 等,应用无感知,中间件按 SQL 类型路由(写/事务→主,普通读→从)。
- 应用层路由:Spring 多数据源 + AOP/注解,
@Master/@Slave注解决定走哪个库。 - 驱动层:MySQL Connector/J 的 ReplicationDriver。
flowchart LR
APP[应用] --> MW[读写分离中间件]
MW -->|写 INSERT/UPDATE/DELETE| M[(主库 Master)]
MW -->|读 SELECT| S1[(从库1)]
MW -->|读 SELECT| S2[(从库2)]
M -->|binlog 复制| S1
M -->|binlog 复制| S2
与事务的关系(核心难点:主从延迟):
- 主从复制是异步的,写主库后从库有延迟(毫秒~秒级)。如果"写完立刻读从库",可能读到旧数据(“读写不一致”)。
- 事务内强制走主库:同一事务里的读写都路由到主库,保证事务内一致性。中间件通常检测到
begin/事务开启后,该事务所有语句都走主库。 - 写后读强制走主:写操作后一段时间内(如 1s)的读也走主库,等从库追上。
- 跨库无法保证强一致,对一致性要求高的读(如支付后查余额)直接读主库。
分库分表做过吗?线上迁移过程?如何确定数据正确?
分库分表策略:
- 垂直拆分:按业务字段拆表/拆库(用户库、订单库)。
- 水平拆分:按某字段(如 user_id 取模、范围、一致性 hash)把数据分散到多个库/表。
- 用 ShardingSphere 等中间件做路由。
线上迁移流程(双写平滑迁移):
flowchart TD
A[1.存量数据同步<br/>老表→新分片表 全量+增量binlog] --> B[2.开启双写<br/>新代码同时写老库和新分片库]
B --> C[3.双读对比<br/>灰度读新库与老库结果比对]
C --> D{数据一致?}
D -->|否| E[修复差异 回到C]
D -->|是| F[4.切读 全流量读新库]
F --> G{运行稳定?}
G -->|是| H[5.停老库写入 下线双写]
G -->|否| E
H --> I[迁移完成]
关键步骤:
- 存量同步:用 DataX/Canal/自研工具把老库全量导到新分片,并用 binlog 实时同步增量。
- 双写:改造应用,同时写老库和新库(先写老库为主,新库失败可告警不阻断,保证业务不中断)。
- 数据校验:定期对账,按主键哈希比对老库和新库的行数、关键字段,发现不一致修复。
- 灰度切读:按用户/租户灰度把读切到新库,观察。
- 全量切读:稳定后全部读走新库。
- 停双写下线老库:确认无误后停止写老库,保留一段时间作为回滚兜底,最后下线。
如何确定数据正确:
- 全量数据对账:对比老库每张表的总行数、分片后的行数和;按抽样主键逐行比对关键字段哈希值。
- 增量对账:实时比对 binlog 流,确保每条变更在新旧库都一致。
- 业务校验:核心指标(如订单总额、账户余额)新老库汇总比对。
- 切流后保留老库只读一段时间,发现问题可快速回滚。
五、JVM
你知道哪些 GC 策略?各自优势和适用场景?
| GC | 代际 | 优势 | 适用场景 |
|---|---|---|---|
| Serial | 单线程 | 实现简单、开销小 | 客户端、小堆(<100MB) |
| Parallel(吞吐量优先) | 多线程并行 | 高吞吐,GC 时间占比低 | 后台计算、批处理、离线任务 |
| CMS | 并发 | 低停顿(并发标记清除) | 对响应时间敏感的服务(JDK9 起废弃) |
| G1 | 分 Region | 可预测停顿(-XX:MaxGCPauseMillis)、适合大堆 | 大堆(4GB+)服务端,JDK9+ 默认 |
| ZGC | 并发 | 亚毫秒级停顿(<10ms),TB 级堆 | 超低延迟、超大堆(JDK11+,15+ 生产可用) |
| Shenandoah | 并发 | 与 ZGC 类似的超低停顿 | Red Hat OpenJDK,低延迟大堆 |
线上选型:
- JDK 8 服务端常用 CMS(低延迟)或 Parallel(吞吐)。JDK 8 高版本也可用 G1。
- JDK 11/17 默认且推荐 G1,大堆+低延迟需求用 ZGC。
- ZGC 几乎全并发(标记、转移、重定位都并发),停顿不随堆增大而增长,是未来主流。
Java 类加载器有几种?父子关系?双亲委派是什么意思?有什么好处?
核心类加载器(JDK8):
flowchart TD
B[Bootstrap ClassLoader<br/>C++ 实现 加载 rt.jar 核心类] --> E[Extension ClassLoader<br/>加载 ext 目录]
E --> A[Application ClassLoader<br/>加载 classpath 应用类]
A --> C[自定义 ClassLoader]
| 加载器 | 加载范围 | 实现 |
|---|---|---|
| Bootstrap(启动) | rt.jar(java.lang.* 等核心库) |
C++,JVM 内部 |
| Extension(扩展) | ext 目录(JDK9 后改 PlatformClassLoader) |
Java,ExtClassLoader |
| Application(应用/系统) | classpath(应用类、第三方 jar) | Java,AppClassLoader |
| 自定义 | 自定义路径 | 继承 ClassLoader |
双亲委派机制: 类加载请求先委派给父加载器,父加载器加载不到再由当前加载器自己加载。即自底向上检查是否已加载、自顶向下尝试加载。
好处:
- 安全:核心类(如
java.lang.String)只能被 Bootstrap 加载,防止用户自定义同名类替换核心类(沙箱安全)。 - 避免重复加载:同一个类只会被加载一次,保证类的唯一性(同一个 Class 实例)。
打破双亲委派: 重写 loadClass(而非 findClass)即可改变委派顺序。典型场景:Tomcat(每个 webapp 独立类加载器,先加载自己再委派父)、SPI(ServiceLoader 用线程上下文类加载器加载实现类)、OSGi 模块化。
如何自定义类加载器?哪些场景需要?
自定义步骤: 继承 java.lang.ClassLoader,重写 findClass(String name)(推荐,不破坏双亲委派)或 loadClass(破坏双亲委派),通过字节数组调用 defineClass 生成 Class。
public class MyClassLoader extends ClassLoader {
private String basePath;
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] data = loadClassData(name); // 从文件/网络读取字节
return defineClass(name, data, 0, data.length);
}
}
使用场景:
- 类隔离:Tomcat 多 webapp 隔离、OSGi 模块化。
- 热部署/热加载:修改类后用新 ClassLoader 重新加载,实现不停机更新(如 JRebel)。
- 加密保护:class 文件加密,自定义加载器解密后
defineClass,防反编译。 - 从非标准来源加载:网络、数据库、动态生成的字节码(如 ASM/CGLIB 代理)。
- SPI 机制:JDBC 驱动等用上下文类加载器加载实现。
堆内存设置的参数是什么?
| 参数 | 作用 |
|---|---|
-Xms |
堆初始大小(建议与 Xmx 相同,避免运行时扩容抖动) |
-Xmx |
堆最大大小 |
-Xmn |
年轻代大小(或用 -XX:NewRatio 控制新老比例) |
-XX:SurvivorRatio |
Eden 与 Survivor 比例(默认 8,即 8:1:1) |
-XX:MetaspaceSize / -XX:MaxMetaspaceSize |
元空间初始/最大(JDK8+) |
-XX:PermSize / -XX:MaxPermSize |
永久代(JDK7 及以前) |
-Xss |
每个线程栈大小 |
典型生产配置:-Xms4g -Xmx4g -Xmn2g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m。Xms=Xmx 避免堆动态调整带来的停顿。
Perm Space 保存什么数据?会引起 OOM 吗?
Perm Space(永久代,JDK7 及以前) 保存:类的元数据(Class 结构、方法、字段)、常量池(字符串常量、符号引用)、静态变量(JDK7 已移到堆)、方法信息等。
会引起 OOM:java.lang.OutOfMemoryError: PermGen space。常见原因:
- 大量动态生成类(CGLIB/反射代理、JSP 重编译)撑爆永久代。
- 常量池过多(
String.intern()滥用)。 - Perm Space 设置过小。
这也是 JDK8 用元空间(Metaspace,用本地内存)替代永久代的原因——类元数据放在本地内存,不容易被固定大小的 PermGen 限制撑爆。
GC 时对象在内存各 Space 中的移动顺序?
JVM 堆分代:年轻代(Eden + Survivor0 + Survivor1)+ 老年代。对象生命周期流转:
flowchart LR
N[新对象] --> E[Eden]
E -->|Minor GC 存活| S0[Survivor0 年龄+1]
S0 -->|下次GC| S1[Survivor1]
S1 -->|再下次GC| S0
S0 -->|年龄达阈值 默认15| O[老年代 Old]
E -->|大对象| O
O -->|Major/Full GC| R[回收或保留]
详细过程:
- 新对象一般分配在 Eden(大对象直接进老年代,避免在 Survivor 间复制开销)。
- Eden 满 → Minor GC(新生代 GC):存活对象复制到一块 Survivor(S0),清空 Eden 和另一块 Survivor。
- 下次 Minor GC,Eden + S0 的存活对象复制到 S1(保证始终有一块 Survivor 空着),年龄+1。
- 对象年龄达到阈值(
-XX:MaxTenuringThreshold,默认 15)→ 晋升老年代。动态年龄判定:Survivor 中相同年龄对象大小总和超过 Survivor 一半,该年龄及以上都晋升。 - 老年代满 → Major/Full GC(整堆回收,老年代+元空间)。
Minor GC 用复制算法(无碎片、快但费空间);老年代用标记-清除/标记-整理(CMS 是清除有碎片,Parallel/G1 是整理无碎片)。
遇到过 OOM 吗?怎么处理?有什么收获?
OOM 有多种类型,排查思路一致:先拿堆快照,再分析。
类型:
Java heap space:堆内存不足,可能内存泄漏或堆太小。GC overhead limit exceeded:GC 花费 >98% 时间但回收 <2% 内存,堆几乎耗尽。Metaspace:元空间不足,类加载过多。Direct buffer memory:堆外内存不足。unable to create new native thread:线程数过多,超过系统限制。
处理流程:
flowchart TD
A[OOM 发生] --> B[加 JVM 参数<br/>-XX:+HeapDumpOnOutOfMemoryError<br/>-XX:HeapDumpPath=/xxx.hprof]
B --> C[拿到堆快照 hprof]
C --> D[MAT/jprofiler 分析<br/>找大对象 占比最高的对象]
D --> E{是泄漏?}
E -->|是 找到引用链GC Root| F[修复代码 释放引用]
E -->|否 确实需要更多内存| G[调大 Xmx / 优化数据结构]
F --> H[复测验证]
G --> H
真实案例与收获:
- 本地缓存(如 Map)只 put 不 remove → 内存泄漏。教训:缓存必须有淘汰策略(
WeakHashMap/Caffeine 设 TTL/容量)。 - 一次查询返回百万行数据到内存 → 堆撑爆。教训:DB 查询必须分页/流式,用
StreamingResultSet。 - 线程创建过多(线程池配置错误)→ native thread OOM。教训:限制线程数,监控线程数指标。
- 收获:必开
HeapDumpOnOutOfMemoryError;线上接 APM(如 ARMS)自动抓快照;定期压测发现泄漏。
JDK8 之后 Perm Space 有哪些变动?Metaspace 默认无限大吗?怎么指定?
JDK8 变动: 移除永久代(PermGen),类元数据迁移到本地内存(Native Memory)的 Metaspace(元空间);字符串常量池、静态变量移到 Java 堆。这是为了解决 PermGen 容易 OOM 且固定大小难调的问题。
Metaspace 默认不限制大小(只用本地内存,受限于物理内存和系统限制),但实际会一直涨直到耗尽内存。所以线上必须显式限制:
-XX:MetaspaceSize=256m # 触发FullGC的初始阈值,达到后FullGC并动态调整
-XX:MaxMetaspaceSize=512m # 最大上限,防止类加载过多耗尽内存
MetaspaceSize 不是初始大小而是"高水位线",达到就触发 GC 回收无用类,并根据回收情况动态升降。设置合理初始值可避免启动时频繁 Full GC。
jstack 干什么?jstat 呢?线上周期性卡顿怀疑 GC,怎么排查?线程日志看哪部分?
- jstack:打印 JVM 所有线程的堆栈快照(线程在执行什么、锁信息、死锁检测)。用于排查 CPU 高、死锁、线程 BLOCKED 卡住。
- jstat:监控 JVM 内存和 GC 统计。
jstat -gcutil <pid> 1000每秒打印各代使用率和 GC 次数/耗时,用于看 GC 频率和耗时。
周期性卡顿排查(怀疑 GC 导致):
flowchart TD
A[应用周期性卡顿] --> B[jstat -gcutil pid 1s<br/>持续观察各代占用+GC次数]
B --> C{卡顿时是否伴随GC?}
C -->|是| D[jstat -gccause 看GC原因]
D --> E[开GC日志 -Xlog:gc* JDK9+ 或 -XX:+PrintGCDetails]
E --> F[用 GCViewer/GCEasy 分析<br/>停顿时间/频率/吞吐]
F --> G{Full GC频繁?}
G -->|是| H[查老年代为啥涨得快:内存泄漏/大对象/元空间]
G -->|否 碎片化| I[调GC参数/换G1/ZGC]
C -->|否| J[jstack 多次抓栈<br/>看线程是否BLOCKED/等待锁]
J --> K[排查锁竞争/外部依赖超时]
线程日志(jstack)看哪部分:
- 线程 状态(State):
RUNNABLE正常;BLOCKED等锁;WAITING/TIMED_WAITING等待。大量 BLOCKED 说明锁竞争。 - 堆栈顶部:在执行什么方法,是否卡在某个外部调用(DB、HTTP)。
- 锁信息:
- locked <0x...>持有/等待哪个锁,定位死锁和锁竞争。 - 结合
top -Hp <pid>找 CPU 高的线程 id,转 16 进制在 jstack 里对应定位。
StackOverFlow 异常遇到过吗?什么情况触发?怎么指定线程栈大小?一般写多少?
StackOverflowError 在线程栈深度超过允许大小时触发,是栈溢出而非内存不足。
触发场景:
- 无限递归:方法递归调用没有正确终止条件(最常见)。
- 递归层次过深:即便有终止条件,数据规模大导致栈帧累积超限(如深度很大的树遍历)。
- 方法栈帧过大:单个方法局部变量很多,栈帧大,更容易溢出。
- 相互调用循环:A 调 B、B 调 A。
指定线程栈大小: -Xss 参数,如 -Xss512k。每个线程独立栈,栈越大单线程能递归越深,但能创建的线程数越少(总内存有限)。
一般写多少:
- 默认 64 位 JVM 约 1MB(Linux 通常 512KB~1MB)。
- 一般业务应用保持默认或
-Xss512k足够(普通方法调用栈不深)。 - 递归密集型算法可适当调大到
-Xss1m~2m。 - 线程数极多的场景反而调小(如
-Xss256k)以节省总内存。
收获: 优先把递归改迭代;栈溢出先排查逻辑错误(终止条件)而非一味调大栈。
六、Linux 命令
日志特别大只想看最后 100 行?想一直看日志持续输出用什么?
- 看最后 100 行:
tail -n 100 app.log(简写tail -100 app.log)。 - 持续看日志输出:
tail -f app.log(follow,实时滚动新内容)。tail -F(大写)更稳,文件被轮转(rename/重建)后仍能继续跟踪。
日志一边输出,一边实时看到有没有某个关键字怎么弄?
tail -f app.log | grep "ERROR"
grep 默认会缓冲输出,管道里可能看不到实时结果,加 --line-buffered 取消缓冲:
tail -f app.log | grep --line-buffered "ERROR"
想高亮关键字:grep --color=always "ERROR"。想看关键字前后几行上下文用 grep -A 5 -B 5。
grep 忽略大小写怎么弄?正则表达式呢?
- 忽略大小写:
grep -i "error" app.log。 - 正则表达式:
grep默认支持基本正则(BRE),加-E用扩展正则(ERE,推荐,支持+、|、()无需转义),加-P用 Perl 正则(最强,支持\d、 lookahead 等)。grep -E "ERROR|WARN" app.log # 扩展正则 grep -P "\d{4}-\d{2}-\d{2}" app.log # Perl 正则匹配日期 - 常用组合:
-n显示行号,-v反向匹配,-c计数,-r递归目录,-A/-B/-C上下文。
vim 往下一行、往下 30 行、跳到文件末尾、跳回来、向后搜索分别是什么键?
| 操作 | 按键 |
|---|---|
| 往下一行 | j(或回车、方向键↓) |
| 往下 30 行 | 30j(数字+动作=重复) |
| 跳到文件末尾 | G(大写) |
| 跳回文件开头 | gg(或 1G) |
| 向后搜索(向下找) | /关键字 回车,按 n 下一个,N 上一个 |
| 向前搜索(向上找) | ?关键字 |
补充::%s/old/new/g 全局替换;:set number 显示行号;u 撤销。
按空格分列,统计第三列每个单词出现次数怎么弄?
awk '{print $3}' file.txt | sort | uniq -c
awk '{print $3}':按空白分隔,取第三列。sort:排序(uniq 只对相邻重复行去重,所以先排序)。uniq -c:去重并统计每行出现次数。
把上面出现次数排序怎么弄?按数字本身顺序而非字符串顺序怎么弄?
awk '{print $3}' file.txt | sort | uniq -c | sort -rn
sort -n:按数值大小排序(默认按字符串字典序,“10” 会排在 “2” 前面)。-r:降序(从大到小);不加-r升序。- 所以按数字降序排次数用
sort -rn。
Linux 环境变量以什么作为分隔符?通过什么命令设置?
- 分隔符是冒号
:。如PATH=/usr/bin:/bin:/usr/local/bin。 - 设置命令:
- 当前会话生效:
export VARNAME=value。 - 对当前用户永久:写入
~/.bashrc或~/.bash_profile,再source生效。 - 对所有用户:写入
/etc/profile或/etc/environment。
- 当前会话生效:
- 查看环境变量:
echo $PATH、env、printenv。
给文件设置权限比如 64 用什么命令?6 是什么意思?
- 命令:
chmod 64 file(八进制形式)或符号形式chmod u=rw,g=r,o= file。 - 64 即
064:三位分别对应 owner/group/others。- 第一位
6=4(read) + 2(write)= 读写。 - 第二位
4= 读。 - 第三位
0= 无权限。
- 第一位
- 实际更常见的是
644(owner 读写,其他人只读)。755用于可执行文件/目录。
权限数字:r=4, w=2, x=1,相加组合。目录的 x 表示能否进入(cd)。
看某个进程资源占用?系统 load 指什么?线上 load 一般多少?4 核机器多少 load 正常?top 里按 1 会发生什么?
top命令,或top -p <pid>看指定进程。也可用htop(更直观)。- 系统 load(load average):一段时间内运行队列中等待 CPU 的平均进程数(含运行态+不可中断睡眠态 D),分 1/5/15 分钟平均值。它反映系统繁忙程度,不直接等于 CPU 使用率。
- 线上 load:一般控制在 CPU 核数的 0.7~1.0 倍以内较健康。
- 4 核机器:load < 4 属正常,接近 4 表示满载,>4 说明有进程在排队等 CPU,需告警。理想值 < 4×0.7 ≈ 2.8。
- top 按数字 1:展开显示每个 CPU 核心的单独使用情况(默认是所有核的平均)。
top 里所有进程 CPU 使用率加起来超过 100% 怎么回事?
因为 top 默认把多核 CPU 的总能力算作 100% × 核数。在多核机器上,top 显示的 CPU% 是相对单核的百分比,一个进程跑满 4 个核就显示 400%。按 1 展开后会看到每个核各自 100%。或者 top 里按 Shift+I 切换 Irix/Solaris 模式可改变计算方式。所以 4 核机器上各进程 CPU% 加起来最大可达 400%。
还有哪些查看系统性能/发现问题的命令?一般看哪个参数?
| 命令 | 用途 | 关注参数 |
|---|---|---|
top/htop |
进程 CPU/内存 | CPU%、MEM%、load average |
vmstat |
系统整体(CPU/内存/IO/swap) | r(运行队列)、sy(内核态CPU)、si/so(swap)、bi/bo(IO) |
iostat |
磁盘 IO | %util(磁盘繁忙度)、await(IO等待ms)、r/s w/s |
free -m |
内存 | available(可用)、cached/buffer、swap 使用 |
df -h |
磁盘空间 | Use% 使用率 |
sar |
历史性能数据采集 | CPU、内存、网络、IO 各项 |
netstat/ss |
网络连接 | 连接数、各状态数量、监听端口 |
dstat |
综合(替代vmstat+iostat) | 实时多维度 |
pidstat |
按进程看 CPU/内存/IO | 进程级定位 |
一般先 top 看整体和进程,CPU 高用 top -Hp 找线程;IO 高用 iostat/iotop;内存用 free+top;网络用 ss/netstat。
看某个进程打开了哪些网络连接?比较关心哪几种状态?
netstat -anp | grep <pid> # 或
ss -anp | grep <pid>
lsof -i -p <pid>
关心的连接状态:
ESTABLISHED:已建立连接数,过多可能是连接泄漏/被攻击。TIME_WAIT:大量 TIME_WAIT 占用端口资源(短连接频繁建连导致)。CLOSE_WAIT:大量 CLOSE_WAIT 说明应用没及时关闭连接(代码 bug,被动关闭方不调 close),需重点排查。SYN_RECV:大量可能是 SYN Flood 攻击。
做过 Linux 系统参数优化吗?优化过什么?
常见优化(/etc/sysctl.conf + ulimit):
| 参数 | 作用 | 调整 |
|---|---|---|
net.core.somaxconn |
全连接队列上限 | 调大到 32768,配合应用 backlog |
net.ipv4.tcp_max_syn_backlog |
半连接队列 | 调大抗并发 |
net.ipv4.tcp_tw_reuse |
复用 TIME_WAIT | 设 1(客户端场景) |
net.ipv4.tcp_fin_timeout |
FIN_WAIT2 超时 | 调小 |
net.ipv4.ip_local_port_range |
可用端口范围 | 调大 1024 65535 |
fs.file-max |
系统最大文件句柄 | 调大(百万级) |
ulimit -n |
进程最大句柄 | 调大到 65535+,写入 limits.conf |
vm.swappiness |
swap 倾向 | 调低到 10,避免用 swap 拖垮性能 |
net.ipv4.tcp_keepalive_time |
keepalive 探测间隔 | 调小 |
线上高并发服务必调文件句柄数(ulimit -n)和 TCP 相关参数。
系统参数里 backlog 用来干什么?
backlog 是 TCP 全连接队列(accept 队列)的长度。三次握手完成后、应用 accept() 之前的连接存在这个队列里。当应用 accept 不够快、队列满了,新连接会被丢弃或发 RST。
- 内核参数
net.core.somaxconn是全局上限,应用listen(fd, backlog)指定具体值,实际取两者较小值。 - 调大 backlog 可应对突发连接高峰(如 Tomcat 的
acceptCount底层就是 backlog)。 - 配合
tcp_max_syn_backlog(半连接队列)一起调。
看到很多 TIME_WAIT 可能是什么原因?对应用有什么影响?怎么减少?
原因: 主动关闭连接的一方进入 TIME_WAIT(持续 2MSL,约 60s)。大量 TIME_WAIT 通常是短连接频繁建立/关闭(如 HTTP 短连接调下游、没有用连接池、压测)。
影响:
- 每个 TIME_WAIT 占用一个本地端口,过多会耗尽端口导致无法新建连接(
Cannot assign requested address)。 - 占用少量内存和内核结构,一般不影响性能。
减少方式:
- 用长连接/连接池(最根本,减少建连次数)。
net.ipv4.tcp_tw_reuse=1:允许新连接复用 TIME_WAIT 的端口(客户端场景,安全)。net.ipv4.tcp_tw_recycle=1:(NAT 环境下有坑,4.12 内核已移除,不推荐)。- 缩短
tcp_fin_timeout、调大ip_local_port_range。 - 让下游主动关闭连接,把 TIME_WAIT 留给服务端(但服务端 TIME_WAIT 也有同样问题,需权衡)。
- 用长连接 + KeepAlive 是首选方案。
介绍 TCP 三次握手?怀疑第三方库发送数据有问题怎么验证?tcpdump 文件怎么分析?
三次握手:
sequenceDiagram
participant C as 客户端
participant S as 服务端
C->>S: SYN, seq=x (发起连接, 进入 SYN_SENT)
S->>C: SYN+ACK, seq=y, ack=x+1 (进入 SYN_RCVD)
C->>S: ACK, ack=y+1 (进入 ESTABLISHED)
Note over C,S: 连接建立,可双向传输数据
为什么三次:双方都要确认对方的收发能力正常,两次无法确认客户端的接收能力,四次可合并为三次。
怀疑第三方库发送的数据有问题,验证方法:
- 抓包:用
tcpdump在客户端或服务端抓包。tcpdump -i eth0 -w out.pcap host 10.0.0.1 and port 8080 - 分析:用 Wireshark 打开
.pcap文件,按协议解析,查看实际发送的报文内容、字段、顺序是否符合预期。 - 对比库"应该发的"和"实际发的",定位是库的问题还是网络问题。
- 也可在代码层加拦截器/日志打印发送前的数据,与抓包结果交叉验证。
tcpdump 文件分析: .pcap 用 Wireshark 打开,看 TCP 流(Follow TCP Stream 还原完整会话),检查包的标志位、载荷内容、时序、重传、乱序。命令行可用 tshark -r out.pcap 或 tcpdump -r out.pcap -A 看内容。
KeepAlive 用来干什么?好处是什么?
TCP KeepAlive 是 TCP 层的保活机制:连接空闲一段时间后,定期发送探测包,检查对端是否存活。
作用与好处:
- 检测死连接:对端崩溃/断网/重启时,没有 KeepAlive 的话本端一直以为连接还在(半开连接),发数据才发现失败。KeepAlive 能及时发现并回收。
- 保持长连接:防止 NAT/防火墙因长时间无流量而清除连接映射(连接被偷偷掐断)。
- 减少建连开销:复用长连接避免频繁三次握手,降低延迟和资源消耗(HTTP keep-alive 同理但属于应用层)。
参数(/etc/sysctl.conf):
net.ipv4.tcp_keepalive_time=600 # 空闲多久开始探测(秒,默认7200)
net.ipv4.tcp_keepalive_intvl=30 # 探测间隔
net.ipv4.tcp_keepalive_probes=3 # 探测次数,失败则断开
应用层(如 HTTP Client、数据库连接池)通常自己实现心跳,比 TCP KeepAlive 更可控。
七、Redis
缓存穿透可以介绍一下吗?如何解决?
缓存穿透:查询一个根本不存在的数据(如不存在的 id、恶意攻击),缓存和数据库都没有,每次请求都打到数据库,缓存形同虚设。
与相关概念区分:
| 问题 | 现象 | 原因 |
|---|---|---|
| 缓存穿透 | 查不存在的数据,直达 DB | 恶意攻击/bug |
| 缓存击穿 | 单个热点 key 过期瞬间,大量请求打到 DB | 热点 key 失效 |
| 缓存雪崩 | 大量 key 同时过期,DB 瞬间压力暴增 | 过期时间集中/Redis 宕机 |
穿透的解决方案:
- 缓存空值/缺省值:DB 查不到也把结果(如
null)写入缓存并设短 TTL(如 60s),下次直接返回。注意可能存大量空值占内存,可设独立短 TTL。 - 布隆过滤器(BloomFilter):在缓存前加一层布隆过滤器,存所有合法 key。请求先过布隆过滤器,不存在直接拦截,不查 DB/缓存。高效省空间,有少量误判率(说存在可能不存在,说不存在一定不存在)。
- 参数校验 + 限流:非法参数(如负数 id)在入口直接拒绝;对单 IP/单 key 限流防恶意攻击。
flowchart TD
A[查询请求 key] --> B{BloomFilter 是否存在?}
B -->|不存在| C[直接返回空 拦截]
B -->|可能存在| D{Redis 缓存命中?}
D -->|命中| E[返回缓存]
D -->|未命中| F[查 DB]
F -->|查到| G[回写缓存 返回]
F -->|查不到| H[缓存空值短TTL 返回空]
你是怎么触发缓存更新的?被动方式如何控制多个入口同时触发更新?
更新触发方式:
- 被动(Cache Aside,最常用):读时未命中则查 DB 并回写缓存,给 key 设 TTL 兜底过期。
- 主动更新:写 DB 后同步更新/删除缓存。推荐先更新 DB 再删除缓存(Cache Aside 标准模式),而非更新缓存(避免并发写覆盖)。
- 消息驱动:监听 binlog(如 Canal)异步更新缓存,解耦业务代码。
更新策略对比:
| 策略 | 写操作 | 优点 | 缺点 |
|---|---|---|---|
| Cache Aside | 先写DB,再删缓存 | 简单可靠,最常用 | 极端并发下短暂不一致 |
| Read/Write Through | 业务只操作缓存,缓存同步写DB | 一致性好 | 实现复杂 |
| Write Behind | 异步写DB | 写性能极高 | 可能丢数据,一致弱 |
被动方式下多入口并发触发更新的控制:
- 加分布式锁/单飞机制(Singleflight):缓存未命中时,只有一个请求拿锁去查 DB 并回写,其他请求等待或返回旧值,避免大量请求同时打 DB(即防"缓存击穿")。
- 逻辑过期:缓存值里带逻辑过期时间,过期不删数据而是异步触发更新,读请求返回旧值(最终一致,不阻塞)。
- 永不过期 + 后台刷新:热点 key 不设 TTL,后台定时任务主动刷新。
flowchart TD
A[读请求 未命中] --> B{获取分布式锁 成功?}
B -->|成功| C[查DB 回写缓存]
B -->|失败 有人在更新| D[短暂等待/重试读缓存]
D --> E{拿到缓存?}
E -->|是| F[返回]
E -->|否 等不到| G[查DB或返回降级值]
C --> H[释放锁 返回]
你们用 Redis 做什么?为什么不用 Memcached、Cassandra 等?
常见用途:
- 缓存:热点数据、查询结果缓存,减轻 DB 压力(最核心)。
- 分布式锁:基于
SETNX/Redisson。 - 排行榜/计数器:用 zset/incr。
- 消息队列:List/Stream 做轻量队列。
- 会话存储:分布式 Session。
- 限流:incr + 过期时间做计数限流。
为什么选 Redis 而非其他:
- vs Memcached:Redis 支持丰富数据结构(string/hash/list/set/zset/stream)、支持持久化(AOF/RDB)、支持主从复制和集群、单 key 可存更大 value。Memcached 只支持纯 KV 字符串、无持久化、多线程。需要数据结构/持久化/高可用时选 Redis。
- vs Cassandra:Cassandra 是分布式宽表数据库,适合海量结构化数据持久化存储,强在水平扩展和写入吞吐,但延迟不如内存数据库。Redis 是内存存储,延迟亚毫秒级,适合做缓存和实时计算;数据需持久化落库的选 Cassandra/DB。
- 简言之:要低延迟缓存和丰富数据结构选 Redis;要海量持久化存储选 Cassandra;要极简纯缓存且多线程场景 Memcached 也行。
用什么 Redis 客户端?高性能的原因?
Java 客户端:
- Jedis:轻量简单,同步 API,基于连接池。
- Lettuce:基于 Netty 的异步非阻塞,支持响应式,线程安全无需连接池,SpringBoot 2.x 默认。
- Redisson:功能丰富,提供分布式锁、布隆过滤器、限流器等高级对象,适合需要分布式协调的场景。
Redis 高性能原因:
- 纯内存操作,读写都在内存,无磁盘 IO,亚毫秒级延迟。
- 单线程命令处理(核心执行),避免多线程上下文切换和锁竞争,且 IO 多路复用(epoll)单线程处理大量连接。Redis 6 引入多线程只用于网络 IO 读写,命令执行仍单线程,保持无锁。
- IO 多路复用:epoll 机制,一个线程监听大量 socket,事件驱动非阻塞。
- 高效数据结构:SDS、跳表(zset)、压缩列表(ziplist)、quicklist 等,针对场景优化。
- 通信协议简单:RESP 协议轻量解析快。
熟悉哪些 Redis 数据结构?zset 干什么?和 set 区别?
| 类型 | 结构 | 典型用途 |
|---|---|---|
| String | 简单字符串(SDS) | 缓存对象JSON、计数器、分布式锁 |
| Hash | 字段-值映射 | 存对象属性(省序列化)、购物车 |
| List | 双向链表/quicklist | 消息队列、最新列表 |
| Set | 无序集合(去重) | 标签、共同好友、去重 |
| ZSet | 有序集合(带score) | 排行榜、延迟队列、Top N |
| Stream | 日志结构流 | 消息队列(支持消费组) |
| Bitmap | 位操作 | 签到、布隆过滤器、活跃统计 |
| HyperLogLog | 基数估算 | UV 统计(去重计数,误差0.81%) |
| Geo | 地理坐标 | 附近的人/店 |
ZSet(有序集合):每个元素带一个 score,按 score 排序,元素唯一。底层是跳表(skiplist)+ 哈希表,跳表负责有序、哈希表负责 O(1) 查元素分值。
和 Set 区别:
- Set 只有元素去重,无序,无法按分数排序。
- ZSet 元素带 score,可按 score 范围/排名查询,适合排行榜。ZSet 内存开销比 Set 大(多存 score 和跳表指针)。
Redis 的 hash 存储和获取的具体命令叫什么?
- 存单个字段:
HSET key field value - 存多个字段:
HMSET key f1 v1 f2 v2(4.0 后 HMSET 已弃用,HSET 支持多字段) - 取单个字段:
HGET key field - 取多个字段:
HMGET key f1 f2 - 取所有字段值:
HGETALL key、HVALS key - 取所有字段名:
HKEYS key - 删字段:
HDEL key field
Hash 适合存对象,比用 String 存 JSON 更省空间且能局部更新单个字段。
LPOP 和 BLPOP 的区别?
- LPOP key:立即弹出 List 头部元素,没有则返回 nil(非阻塞)。
- BLPOP key [timeout]:阻塞式弹出,List 为空时阻塞等待,直到有元素或超时(timeout=0 表示无限等待)。底层是阻塞队列实现,多个客户端等待同一 key 时按 FIFO 唤醒。
BLPOP 用于实现消费者阻塞等待(轻量消息队列),避免轮询空转。还有 BRPOP(尾部)、BLMOVE 等。注意:阻塞命令会长时间占用连接,生产环境慎用长阻塞,通常设合理 timeout 配合重试。
Redis 包含 SCAN 关键字的命令是干嘛的?SCAN 返回数据量固定吗?
SCAN 系列命令用于增量式遍历,避免 KEYS * 阻塞 Redis:
SCAN遍历 DB 的所有 key。HSCAN遍历 Hash 的字段。SSCAN遍历 Set 的元素。ZSCAN遍历 ZSet 的元素。
用法:SCAN cursor [MATCH pattern] [COUNT n] [TYPE type],返回新游标和这批元素,游标为 0 表示遍历结束。
返回数据量不固定: COUNT 只是建议值(提示返回约多少个),实际返回数量可能少于或多于 COUNT。因为 SCAN 基于底层哈希表的桶(slot)遍历,一次可能扫多个桶,也可能桶为空。需要循环调用直到游标为 0。
特点: 非阻塞、不阻塞 Redis(适合生产);但不保证不重复/不遗漏(遍历期间 key 被修改可能重复返回或漏掉),业务需自行去重。KEYS * 在生产禁用(会阻塞)。
Redis 的 Lua 用过吗?能做什么?为什么能这么用?
EVAL/EVALSHA 执行 Lua 脚本,把多个命令打包成原子操作在服务端执行。
用途:
- 原子性复合操作:如"检查库存+扣减"、“判断锁归属+释放锁”,避免多条命令间的并发问题。
- 分布式锁(Redisson):释放锁时用 Lua 校验 value(防误删)+ DEL。
- 限流:incr + 判断 + expire 用一条脚本完成。
- 复杂业务逻辑下沉:减少网络往返,一次往返完成多步操作。
为什么能这么用: Redis 单线程执行命令,Lua 脚本作为整体在执行期间独占线程,不会被其他命令打断,因此天然原子。但要注意脚本不能执行太久(会阻塞所有其他命令),且脚本里不能有死循环。Redis 用 lua-time-limit(默认 5s)限制,超时可通过 SCRIPT KILL 中止(但若已执行写操作则只能 SHUTDOWN NOSAVE)。
Redis 的 Pipeline 用来干什么?
Pipeline(管道) 用于批量发送命令、批量接收响应,减少网络往返(RTT)开销。
- 普通:每条命令一次 RTT(发→等响应→发下一条),1000 条命令 = 1000 次 RTT。
- Pipeline:把 1000 条命令一次性发出,再一次性接收所有响应,只需 1 次 RTT。
注意:
- Pipeline 是客户端行为,不保证原子性(多条命令间可能插入其他客户端命令),要原子用 Lua/MULTI。
- Pipeline 中的命令不是立即执行,是攒在一起发,结果按发送顺序返回。
- 适合批量写入/读取大量数据,大幅提升吞吐。注意控制单次 Pipeline 大小避免内存占用过大。
Pipeline vs Lua vs MULTI:
| 方式 | 减少RTT | 原子性 | 适用 |
|---|---|---|---|
| Pipeline | 是 | 否 | 批量读写 |
| Lua | 是 | 是 | 原子复合操作 |
| MULTI/EXEC | 是 | 是 | 事务(乐观锁 WATCH) |
Redis 持久化有几种方式?AOF 和 RDB 区别?AOF 优缺点?
两种主流方式:RDB 和 AOF(4.0+ 可混用)。
| 维度 | RDB | AOF |
|---|---|---|
| 原理 | 某时刻全量数据快照(二进制 dump) | 追加每条写命令到日志 |
| 触发 | 定时/手动 BGSAVE |
实时/每秒/每次写 appendfsync |
| 恢复速度 | 快(直接加载二进制) | 慢(回放命令) |
| 数据安全 | 可能丢失最近一次快照后的数据 | 最多丢 1 秒(everysec) |
| 文件大小 | 小(压缩) | 大(命令日志,需 rewrite 重写) |
| 性能影响 | fork 子进程,大内存时 fork 慢 | 追加写日志,everysec 影响小 |
AOF 优缺点:
- 优点:数据安全性高(最多丢 1 秒),可读性好(命令日志),适合做容灾备份。
- 缺点:文件比 RDB 大,恢复慢;重写(rewrite)时 fork 子进程有开销;每秒 fsync 仍有少量性能损耗。
生产推荐: 4.0+ 开启 RDB + AOF 混合持久化(aof-use-rdb-preamble yes),AOF 重写时先以 RDB 格式写全量快照,再追加增量命令,兼顾恢复速度和数据安全。appendfsync everysec 是性能与安全的平衡点。
Redis Replication 大致流程?bgsave 执行过程?
主从复制流程:
sequenceDiagram
participant M as Master
participant S as Slave
S->>M: PSYNC ? -1 (首次同步)
M->>S: +FULLRESYNC runid offset (全量同步)
M->>M: BGSAVE 生成RDB + 记录新写命令到复制缓冲区
M->>S: 发送 RDB 文件
S->>S: 加载 RDB 恢复数据
M->>S: 发送缓冲区的增量命令
Note over M,S: 进入命令传播阶段 增量同步
M->>S: 持续发送新写命令
流程要点:
- 从库连主库发
PSYNC,首次同步主库判断需全量。 - 主库
BGSAVE生成 RDB,同时把期间的新写命令存到复制积压缓冲区。 - 主库发 RDB 给从库,从库加载恢复。
- 主库发缓冲区增量命令,之后持续命令传播(增量)。
- 断线重连:若 offset 在积压缓冲区内则部分重同步(只发缺失部分),否则全量。2.8+ 的部分重同步大幅减少全量同步开销。
bgsave 执行过程:
- 主进程
fork()出子进程(fork 时会短暂阻塞,大内存实例 fork 慢)。 - 子进程利用**写时复制(COW)**遍历内存生成 RDB 文件写磁盘。
- 期间父进程正常处理命令,修改的数据页被 COW 复制,不影响子进程的快照。
- 子进程写完发信号给父进程,父进程更新统计信息,完成。
- fork 期间和磁盘 IO 是主要开销,大内存实例需注意 fork 耗时和内存翻倍风险。
大量 KV 数据存 Redis 内存不足,怎么缩减内存?为什么能缩小?
缩减内存的方式:
- 用更省内存的数据结构/编码:
- 小 Hash/List/ZSet 用 ziplist/listpack 编码(连续内存,省指针开销),通过调大
hash-max-ziplist-entries等阈值让更多元素走紧凑编码。 - 用 Hash 存对象比多个 String key 省(ziplist 极省内存)。
- 小 Hash/List/ZSet 用 ziplist/listpack 编码(连续内存,省指针开销),通过调大
- 压缩 value:存入前用 gzip/snappy/LZ4 压缩大 value,读取时解压。
- 缩短 key:key 名简短(但注意可读性权衡)。
- 数据分片/集群:用 Redis Cluster 把数据分散到多节点,单机内存不够就横向扩展。
- 冷热分离:冷数据迁到磁盘/DB,只留热数据在 Redis。
- 合理设过期:及时清理无用数据,避免内存堆积。
为什么能缩小: Redis 内存存储有大量元数据开销(每个 key/object/sds 都有 RedisObject 头、哈希表桶指针等)。ziplist/listpack 把多个元素紧凑存在一段连续内存里,去掉了每个元素单独的 RedisObject 和指针开销,对小数据量、小元素的集合能节省大量内存(可能省 50%+)。代价是元素多时修改性能下降(需重新分配内存),所以有阈值限制。
Redis 中 List、HashTable 都用到 ZipList,为什么选它?
ZipList(压缩列表) 是一种紧凑的连续内存结构,专为小数据量、小元素优化。
选择原因:
- 省内存:连续存储,无指针/RedisObject 元数据开销,对小集合内存占用远低于普通结构(如 quicklist、dict)。
- 访问局部性好:连续内存,CPU 缓存命中率高。
- 小数据量下性能足够:元素少时遍历开销可接受。
适用条件(阈值控制): 当元素数量或单个元素大小超过阈值(如 hash-max-ziplist-entries=128、hash-max-ziplist-value=64),Redis 自动转换为标准结构(dict/quicklist),因为 ziplist 在数据量大时插入/删除需要移动内存,性能退化,且连锁更新(cascade update)风险。
演进: Redis 7.0 用 listpack 替代 ziplist 解决连锁更新问题(ziplist 的 prevlen 字段可能导致连续多个节点都要更新),结构更紧凑高效。List 的底层是 quicklist(双向链表 + 节点用 listpack),小 Hash/ZSet 直接用 listpack。
八、监控与稳定性
业务日志通过什么方式收集?
生产环境一般用集中式日志收集平台,主流方案是 ELK/EFK 栈:
flowchart LR
APP[应用服务器<br/>输出日志文件] --> A[Filebeat/Fluentd<br/>采集Agent]
A -->|传输| K[Kafka<br/>缓冲解耦]
K --> L[Logstash<br/>解析过滤]
L --> ES[Elasticsearch<br/>存储+全文检索]
ES --> V[Kibana<br/>可视化查询告警]
各组件职责:
- 采集层:
Filebeat(轻量)/Fluentd/Logstash部署在应用机上,tail 日志文件并发送。大流量场景中间加 Kafka 做缓冲,防止下游 ES 被冲垮。 - 处理层:
Logstash解析多行日志、提取字段(如 traceId、level)、过滤。 - 存储层:
Elasticsearch支持全文检索和聚合。 - 展示层:
Kibana查询、聚合、告警。
最佳实践:
- 日志规范:JSON 结构化日志(用 logback/log4j2 的 JSON encoder),便于解析和检索。
- 必带字段:时间、级别、traceId(链路追踪)、服务名、关键字段。
- 日志分级:ERROR 入告警,WARN 入统计,INFO/DEBUG 按需。
- 大日志平台如阿里云 SLS、腾讯云 CLS 等托管方案免去自建运维成本。
- 还可用 Loki + Grafana(轻量,索引少成本低)替代 ELK。
线上机器如何监控?用什么产品?分钟级还是秒级?
监控分层:
flowchart TD
subgraph 基础设施监控
H1[主机 CPU/内存/磁盘/网络]
H2[网络 带宽/丢包/连接数]
end
subgraph 中间件监控
M1[MySQL 慢SQL/QPS/复制]
M2[Redis 命中率/内存/连接]
M3[MQ 堆积/消费速率]
end
subgraph 应用监控 APM
A1[JVM GC/堆内存/线程]
A2[接口 RT/QPS/错误率]
A3[链路追踪 traceId]
end
subgraph 业务监控
B1[核心业务指标 下单量/支付成功率]
B2[业务大盘 大屏]
end
H1 --> P[(Prometheus + Grafana)]
H2 --> P
M1 --> P
M2 --> P
M3 --> P
A1 --> P
A2 --> P
A3 --> T[Jaeger/SkyWalking/Zipkin]
B1 --> B[业务大盘]
产品选择:
- 基础+中间件+应用指标:Prometheus + Grafana(开源事实标准),Exporter 采集(node_exporter、mysqld_exporter、jmx_exporter/micrometer),Alertmanager 告警。
- APM 应用性能监控:SkyWalking(开源,国产,Java 字节码增强)、Pinpoint、Zipkin、Jaeger 做链路追踪;商业有 Dynatrace、阿里云 ARMS。
- 业务监控:自建大盘,把业务指标暴露给 Prometheus 或上报到时序库。
采集频率: 一般 15~30 秒级采集(Prometheus 默认 15s),关键指标可 10s。分钟级用于聚合统计和告警阈值(避免瞬时抖动误报)。秒级监控成本高,仅对极关键 SLI 用。告警配合"持续 N 分钟触发"避免毛刺。
让你收集 Java 后端应用性能数据,在意哪些方面?用什么工具思路?
在意的核心维度(USE/RED 原则):
| 维度 | 关注指标 |
|---|---|
| JVM | 堆/非堆内存使用、GC 次数与耗时(Full GC 频率)、线程数与状态、类加载数 |
| 接口性能 | QPS/TPS、平均/P99 响应时间、错误率/异常率(RED 原则) |
| 资源使用 | CPU 使用率、内存、磁盘 IO、网络 IO、文件句柄数、连接数 |
| 依赖 | DB 连接池使用率、Redis 命中率、下游调用 RT 与错误率 |
| 业务 | 核心业务成功率、关键流程耗时 |
工具与思路:
flowchart LR
APP[Java 应用] -->|Spring Boot Actuator<br/>暴露 /metrics| PM[Micrometer]
PM -->|pull| PRO[Prometheus]
PRO --> G[Grafana 仪表盘]
PRO --> AL[Alertmanager 告警]
APP -->|字节码增强 无侵入| SW[SkyWalking Agent]
SW --> SWO[SkyWalking OAP<br/>链路+拓扑+指标]
APP -->|JMX| JM[jmx_exporter]
JM --> PRO
APP -->|JVM 诊断| J[Arthas 在线诊断]
- Spring Boot Actuator + Micrometer + Prometheus + Grafana:应用层指标(GC、线程、接口、连接池)标准方案,Micrometer 做指标门面,SpringBoot 2.x 内置。
- SkyWalking/Pinpoint:无侵入字节码增强,自动埋点接口耗时、链路追踪、依赖拓扑,定位慢调用和异常。
- JMX:暴露 JVM 内部指标,用 jmx_exporter 接入 Prometheus。
- 在线诊断:Arthas 在不重启应用时查看方法耗时、JVM 状态、动态修改日志级别。
- 日志:错误日志接 ELK,结合 traceId 串联调用链。
思路: 先建黄金信号(延迟、流量、错误、饱和度)大盘,再按服务粒度下钻;设 SLO(如 P99 < 200ms,错误率 < 0.1%)并告警;用链路追踪定位慢在哪一环(DB?下游?自身计算?)。
一般调用第三方时会不会监控调用情况?
会,且必须监控。 调用第三方是不可控的外部依赖,最容易成为系统瓶颈和故障源,必须纳入监控。关注:
- 调用结果:成功/失败率、超时次数、重试次数。
- 性能:响应时间(平均/P99)、超时率。
- 资源:连接池占用、并发调用量、限流触发次数。
- 熔断降级:熔断器状态(开/半开/关闭)、降级次数。
实现方式:
- 用 Resilience4j / Sentinel / Hystrix 做熔断、限流、降级,同时暴露指标到 Prometheus。
- 在 HTTP Client(OkHttp/Apache HttpClient/Feign)加拦截器自动埋点(记录 RT、状态码、异常),上报到监控系统或 SkyWalking。
- 对每个第三方接口设独立告警:错误率超阈值、RT 飙升、熔断开启立即告警。
- 调用必设超时和重试(重试要结合幂等),避免第三方故障拖垮自身(雪崩)。
flowchart TD
BIZ[业务调用第三方] --> CL[Sentinel/Resilience4j<br/>熔断+限流+降级]
CL --> HTTP[HTTP Client 拦截器埋点]
HTTP --> EXT[第三方服务]
HTTP -->|指标| PM[Prometheus]
PM --> AL[告警: 错误率/RT/熔断]
CL -->|降级| F[返回兜底数据]
最佳实践: 把第三方调用当作"不可信依赖"——超时控制、熔断隔离、监控告警、降级兜底四件套缺一不可,防止第三方抖动引发级联故障。