Java后端架构师面试题详解

Cosolar 2 阅读 后端求职面试

本文档汇总了 Java 后端方向(含并发、Spring、MySQL、JVM、Linux、Redis、监控稳定性)的高频面试题,并给出结合真实开发场景的详细答案。对有必要的问题配以 Mermaid 流程图、架构图辅助理解。

一、幂等性

什么是幂等?什么情况下需要考虑幂等?你怎么解决幂等的问题?

幂等(Idempotent) 的数学定义是:一个操作执行一次和执行多次,对系统状态产生的影响完全一致。通俗讲就是"重复调用不会产生副作用"。HTTP 规范里 GETPUTDELETE 天然要求幂等,而 POST 通常不是。

什么情况下需要考虑幂等: 凡是可能出现"重复请求/重复消费"的场景都必须考虑,典型包括:

  • 前端重复提交:用户连点按钮、网络抖动导致重试。
  • RPC/重试机制:Feign、Dubbo 调用超时后框架自动重试,可能产生重复下单、重复扣款。
  • 消息队列重复消费:MQ 的 At-Least-Once 语义下,消费者宕机重启后消息会被重新投递。
  • 支付/对账回调:第三方支付平台可能对同一笔交易发起多次异步通知。
  • 定时任务重复执行:调度器在故障恢复后可能重复触发。

如何解决幂等问题,常见方案:

  1. 唯一索引(数据库层兜底):对业务唯一键(如订单号)建唯一索引,重复插入直接抛 DuplicateKeyException,是最可靠的最后一道防线。
  2. Token 机制(防重令牌):前端先请求一个一次性 token,提交时带上,服务端校验后立即删除(用 Redis DEL 返回值判断)。适合表单提交。
  3. 乐观锁(版本号)UPDATE account SET balance=balance-100, version=version+1 WHERE id=? AND version=?,更新行数为 0 即重复请求。
  4. 状态机校验:业务本身有状态流转(如订单 待支付→已支付),更新时带上状态条件 WHERE status='待支付',重复请求因状态已变更而失败。
  5. 分布式锁:对同一业务键加 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 + 不可变对象 配置项整体替换 读无锁,写时整体替换引用

最佳实践:

  • 字典/配置类数据量小、写极少 → CopyOnWriteArrayListvolatile 引用替换。
  • 缓存类、写有一定频率 → ReentrantReadWriteLock,并且注意锁降级(先持写锁更新数据,再获取读锁,最后释放写锁),避免更新完立刻被别的读线程读到半成品。
  • 对吞吐要求极高且读占比 95%+ → StampedLock 的乐观读(tryOptimisticRead + 校验 validate)。
flowchart LR
    subgraph 读多写少场景
        R1[读线程] -->|读读共享 不阻塞| RES[(共享数据)]
        R2[读线程] -->|读读共享 不阻塞| RES
        W[写线程] -->|读写 写写互斥| RES
    end

JAVA 的 AQS 是否了解,它是干嘛的?

AQS(AbstractQueuedSynchronizer)java.util.concurrent 下大多数同步工具的基石框架。ReentrantLockReentrantReadWriteLockSemaphoreCountDownLatchCyclicBarrier、线程池的 Worker 等都基于它实现。

核心设计:

  • 一个 volatile int state 表示同步状态(语义由子类定义:锁的持有数、许可数、计数等)。
  • 一个内部的 CLH FIFO 双向队列 管理被阻塞的线程节点。
  • 模板方法模式:AQS 负责入队/出队/阻塞/唤醒的通用流程,子类只需实现 tryAcquire/tryRelease(独占)或 tryAcquireShared/tryReleaseShared(共享)。

独占 vs 共享模式:

  • 独占(ReentrantLock):同一时刻只有一个线程能拿到 state,state 表示重入次数。
  • 共享(Semaphore/CountDownLatch):多个线程可同时拿到,CountDownLatchstate 表示还剩多少个计数。

工作流程(以 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 只是其中最重的一种:

  1. 无同步方案(最佳)
    • 线程封闭:局部变量天然线程安全(栈封闭),ThreadLocal 让每个线程拥有一份独立副本。
    • 不可变对象final + 构造完成后不可变(如 StringLocalDateTime),天然线程安全。
  2. CAS / 原子类AtomicIntegerLongAdderAtomicReference,基于 Unsafe.compareAndSwap 无锁更新,适合计数器。LongAdder 在高并发下用分段累加,性能远超 AtomicLong
  3. 并发容器ConcurrentHashMap(分段锁/CAS+synchronized 桶)、CopyOnWriteArrayListConcurrentLinkedQueueBlockingQueue 系列。
  4. 显式锁ReentrantLock(可中断、可超时、可公平)、ReadWriteLockStampedLock
  5. 同步工具Semaphore 限流、CountDownLatch 等待、CyclicBarrier 集合点。
  6. volatile:保证可见性和禁止指令重排序,但不保证复合操作的原子性。

选择原则: 能无锁就无锁(原子类/并发容器)→ 能用 volatile 就不用锁 → 读多写少用读写锁 → 一般互斥用 synchronized(JVM 偏向锁/轻量级锁优化后够用)或 ReentrantLock(需要高级特性时)。

什么时候需要加 volatile?它能保证线程安全吗?

什么时候需要 volatile:

  • 状态标志位:如 boolean running = true;,一个线程改、其他线程读,需要可见性。
  • 双重检查锁定(DCL)单例private static volatile Singleton instance;,防止"指令重排序"导致拿到未初始化完成的对象。
  • 一次性安全发布:对象初始化完成后用 volatile 引用发布,保证其他线程看到构造完成的状态。

能否保证线程安全:不能完全保证。 volatile 只保证两点:

  1. 可见性:写入后立即刷新到主内存,读取强制从主内存读,其他线程立刻能看到。
  2. 禁止指令重排序:通过内存屏障。

volatile 不保证原子性。例如 count++ 是"读-改-写"三步操作,即使 count 是 volatile,两个线程同时 ++ 仍会丢更新。这种场景要用 AtomicInteger 或加锁。只有当变量是"一个线程写、其他线程只读"的纯赋值场景,volatile 才足够。

线程池内线程全忙,提交新任务会发生什么?队列塞满后还是忙,再提交会怎样?

这取决于线程池的工作流程和参数配置。线程池核心参数:corePoolSizemaximumPoolSizeworkQueuethreadFactoryrejectedExecutionHandler(拒绝策略)。

提交流程(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?线程数?下游?),再调 maxThreadsacceptCount。配合 -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 指定的实现?

多种方式:

  1. @Qualifier 指定 Bean 名称(最常用):
    @Autowired
    @Qualifier("redisServiceImpl")
    private CacheService cacheService;
    
  2. 字段名与 Bean 名一致private CacheService redisServiceImpl;,Spring 会按名称匹配。
  3. @Primary 标注默认实现:在某个实现类上加 @Primary@Autowired 时优先选它。
  4. @Resource(name="..."):JSR-250 注解,直接按名称注入。
  5. 通过配置动态选择:用 @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 方法上(代理不到,事务不生效)。

是否会自动回滚:默认只对 RuntimeExceptionError 回滚,对受检异常(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 生成并装配完毕后执行自己的逻辑,有哪些方式?

  1. @PostConstruct 注解(JSR-250):在依赖注入完成后、初始化阶段执行,最常用。
    @PostConstruct
    public void init() { /* 初始化逻辑 */ }
    
  2. 实现 InitializingBean 接口:重写 afterPropertiesSet(),效果同上,与 @PostConstruct 二选一。
  3. @Bean(initMethod="..."):声明 Bean 时指定初始化方法。
  4. 构造器注入 + 在构造器里做:如果只依赖构造器注入的参数,可直接在构造器中初始化(Spring 推荐构造器注入)。
  5. BeanPostProcessor:对所有 Bean 的初始化前后做统一拦截处理,适合框架级需求。

执行顺序: 构造器 → @PostConstructInitializingBean.afterPropertiesSetinitMethodBeanPostProcessor.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,怎么弄?

  1. @PropertySource 加载额外 properties 文件(注意:不支持 yml,除非自定义):
    @PropertySource("classpath:custom.properties")
    @Configuration
    public class CustomConfig {}
    
  2. spring.config.additional-location / spring.config.name:在启动时指定外部配置目录或文件名。
  3. @ConfigurationProperties + @Configuration:将自定义文件中的属性绑定到对象。
  4. 命令行 / 环境变量覆盖--server.port=9090SERVER_PORT=9090,优先级高于 jar 内配置。
  5. YamlPropertySourceLoader 自定义加载 yml:通过 EnvironmentPostProcessor 把 yml 注入 Environment。

配置加载优先级(高到低): 命令行参数 > 环境变量 > jar 包外 application-{profile}.yml > jar 包内 application-{profile}.yml > jar 包外 application.yml > jar 包内 application.yml。线上常把配置放外部目录覆盖打包配置,做到改配置不重新打包。

SpringMVC 中 RequestMapping 可以指定 GET、POST 方法么?怎么指定?

可以。@RequestMappingmethod 属性指定,或者直接用组合注解:

// 方式一: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),返回的对象会被 HttpMessageConverterMappingJackson2HttpMessageConverter)自动序列化为 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 并打印完整堆栈。
  • 对外不暴露敏感堆栈信息,统一包装成 Result JSON 返回。
  • 可配合 @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)。如果 typeALL(全表扫描),基本就是缺索引或索引失效。

索引是干嘛的: 索引是一种排好序的数据结构(MySQL InnoDB 用 B+ 树),让数据库查询不必全表扫描,而是通过树形结构快速定位数据,把 O(n) 的扫描降到 O(log n)。同时索引本身有序,也能加速 ORDER BYGROUP 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, cWHERE 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=ONlong_query_time=1(线上常设 1s 甚至 500ms)。
  • Prometheus + mysqld_exporter + Grafana:采集 QPS、TPS、连接数、buffer 命中率、复制延迟等指标,设告警。
  • Percona PMM阿里云 DAS 等数据库监控平台,自动抓慢 SQL 和 TOP SQL。
  • SHOW GLOBAL STATUSSHOW 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 和部分生僻字)。

操作步骤:

  1. 修改库/表/列字符集为 utf8mb4,排序规则用 utf8mb4_unicode_ciutf8mb4_0900_ai_ci(8.0):
    ALTER DATABASE db CHARACTER SET utf8mb4;
    ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4;
    
  2. JDBC 连接串去掉 characterEncoding=utf8(或显式设 characterEncoding=utf8mb4),MySQL 8 驱动自动识别。
  3. 确认 max_allowed_packet 足够。
  4. 升级 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 长、StateSending data/Copying to tmp table/Locked 的会话。也可查 information_schema.PROCESSLIST 或 Performance Schema。

找出来之后:

  1. 确认是否真的异常(是不是合理的批量任务)。
  2. 必要时 KILL <id>; 终止该会话(注意:KILL 会回滚其事务,大事务回滚也慢)。
  3. 记录 SQL,事后用 EXPLAIN 分析优化。
  4. SHOW ENGINE INNODB STATUS 看是否有锁等待/死锁,定位持锁会话。
  5. 排查根因:缺索引、大事务、全表扫、长事务持锁等,从根本上修复避免复发。

读写分离是怎么做的?中间件怎么操作?跟事务有什么关系?

读写分离:主库(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[迁移完成]

关键步骤:

  1. 存量同步:用 DataX/Canal/自研工具把老库全量导到新分片,并用 binlog 实时同步增量。
  2. 双写:改造应用,同时写老库和新库(先写老库为主,新库失败可告警不阻断,保证业务不中断)。
  3. 数据校验:定期对账,按主键哈希比对老库和新库的行数、关键字段,发现不一致修复。
  4. 灰度切读:按用户/租户灰度把读切到新库,观察。
  5. 全量切读:稳定后全部读走新库。
  6. 停双写下线老库:确认无误后停止写老库,保留一段时间作为回滚兜底,最后下线。

如何确定数据正确:

  • 全量数据对账:对比老库每张表的总行数、分片后的行数和;按抽样主键逐行比对关键字段哈希值。
  • 增量对账:实时比对 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

双亲委派机制: 类加载请求先委派给父加载器,父加载器加载不到再由当前加载器自己加载。即自底向上检查是否已加载、自顶向下尝试加载。

好处:

  1. 安全:核心类(如 java.lang.String)只能被 Bootstrap 加载,防止用户自定义同名类替换核心类(沙箱安全)。
  2. 避免重复加载:同一个类只会被加载一次,保证类的唯一性(同一个 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 已移到堆)、方法信息等。

会引起 OOMjava.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[回收或保留]

详细过程:

  1. 新对象一般分配在 Eden(大对象直接进老年代,避免在 Survivor 间复制开销)。
  2. Eden 满 → Minor GC(新生代 GC):存活对象复制到一块 Survivor(S0),清空 Eden 和另一块 Survivor。
  3. 下次 Minor GC,Eden + S0 的存活对象复制到 S1(保证始终有一块 Survivor 空着),年龄+1。
  4. 对象年龄达到阈值(-XX:MaxTenuringThreshold,默认 15)→ 晋升老年代。动态年龄判定:Survivor 中相同年龄对象大小总和超过 Survivor 一半,该年龄及以上都晋升。
  5. 老年代满 → 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 $PATHenvprintenv

给文件设置权限比如 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: 连接建立,可双向传输数据

为什么三次:双方都要确认对方的收发能力正常,两次无法确认客户端的接收能力,四次可合并为三次。

怀疑第三方库发送的数据有问题,验证方法:

  1. 抓包:用 tcpdump 在客户端或服务端抓包。
    tcpdump -i eth0 -w out.pcap host 10.0.0.1 and port 8080
    
  2. 分析:用 Wireshark 打开 .pcap 文件,按协议解析,查看实际发送的报文内容、字段、顺序是否符合预期。
  3. 对比库"应该发的"和"实际发的",定位是库的问题还是网络问题。
  4. 也可在代码层加拦截器/日志打印发送前的数据,与抓包结果交叉验证。

tcpdump 文件分析: .pcap 用 Wireshark 打开,看 TCP 流(Follow TCP Stream 还原完整会话),检查包的标志位、载荷内容、时序、重传、乱序。命令行可用 tshark -r out.pcaptcpdump -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 宕机

穿透的解决方案:

  1. 缓存空值/缺省值:DB 查不到也把结果(如 null)写入缓存并设短 TTL(如 60s),下次直接返回。注意可能存大量空值占内存,可设独立短 TTL。
  2. 布隆过滤器(BloomFilter):在缓存前加一层布隆过滤器,存所有合法 key。请求先过布隆过滤器,不存在直接拦截,不查 DB/缓存。高效省空间,有少量误判率(说存在可能不存在,说不存在一定不存在)。
  3. 参数校验 + 限流:非法参数(如负数 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 高性能原因:

  1. 纯内存操作,读写都在内存,无磁盘 IO,亚毫秒级延迟。
  2. 单线程命令处理(核心执行),避免多线程上下文切换和锁竞争,且 IO 多路复用(epoll)单线程处理大量连接。Redis 6 引入多线程只用于网络 IO 读写,命令执行仍单线程,保持无锁。
  3. IO 多路复用:epoll 机制,一个线程监听大量 socket,事件驱动非阻塞。
  4. 高效数据结构:SDS、跳表(zset)、压缩列表(ziplist)、quicklist 等,针对场景优化。
  5. 通信协议简单: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 keyHVALS 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: 持续发送新写命令

流程要点:

  1. 从库连主库发 PSYNC,首次同步主库判断需全量。
  2. 主库 BGSAVE 生成 RDB,同时把期间的新写命令存到复制积压缓冲区
  3. 主库发 RDB 给从库,从库加载恢复。
  4. 主库发缓冲区增量命令,之后持续命令传播(增量)。
  5. 断线重连:若 offset 在积压缓冲区内则部分重同步(只发缺失部分),否则全量。2.8+ 的部分重同步大幅减少全量同步开销。

bgsave 执行过程:

  1. 主进程 fork() 出子进程(fork 时会短暂阻塞,大内存实例 fork 慢)。
  2. 子进程利用**写时复制(COW)**遍历内存生成 RDB 文件写磁盘。
  3. 期间父进程正常处理命令,修改的数据页被 COW 复制,不影响子进程的快照。
  4. 子进程写完发信号给父进程,父进程更新统计信息,完成。
  5. fork 期间和磁盘 IO 是主要开销,大内存实例需注意 fork 耗时和内存翻倍风险。

大量 KV 数据存 Redis 内存不足,怎么缩减内存?为什么能缩小?

缩减内存的方式:

  1. 用更省内存的数据结构/编码
    • 小 Hash/List/ZSet 用 ziplist/listpack 编码(连续内存,省指针开销),通过调大 hash-max-ziplist-entries 等阈值让更多元素走紧凑编码。
    • 用 Hash 存对象比多个 String key 省(ziplist 极省内存)。
  2. 压缩 value:存入前用 gzip/snappy/LZ4 压缩大 value,读取时解压。
  3. 缩短 key:key 名简短(但注意可读性权衡)。
  4. 数据分片/集群:用 Redis Cluster 把数据分散到多节点,单机内存不够就横向扩展。
  5. 冷热分离:冷数据迁到磁盘/DB,只留热数据在 Redis。
  6. 合理设过期:及时清理无用数据,避免内存堆积。

为什么能缩小: Redis 内存存储有大量元数据开销(每个 key/object/sds 都有 RedisObject 头、哈希表桶指针等)。ziplist/listpack 把多个元素紧凑存在一段连续内存里,去掉了每个元素单独的 RedisObject 和指针开销,对小数据量、小元素的集合能节省大量内存(可能省 50%+)。代价是元素多时修改性能下降(需重新分配内存),所以有阈值限制。

Redis 中 List、HashTable 都用到 ZipList,为什么选它?

ZipList(压缩列表) 是一种紧凑的连续内存结构,专为小数据量、小元素优化。

选择原因:

  1. 省内存:连续存储,无指针/RedisObject 元数据开销,对小集合内存占用远低于普通结构(如 quicklist、dict)。
  2. 访问局部性好:连续内存,CPU 缓存命中率高。
  3. 小数据量下性能足够:元素少时遍历开销可接受。

适用条件(阈值控制): 当元素数量或单个元素大小超过阈值(如 hash-max-ziplist-entries=128hash-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[返回兜底数据]

最佳实践: 把第三方调用当作"不可信依赖"——超时控制、熔断隔离、监控告警、降级兜底四件套缺一不可,防止第三方抖动引发级联故障。