第二阶段:开发框架(吃饭的家伙)

第二阶段:开发框架(吃饭的家伙)

定位: 你简历每个项目都在用 Spring Boot + Spring Cloud Alibaba + MyBatis-Plus,面试官会从这里开始问。这是你的“基本功”区域,必须烂熟于心。

本文档覆盖: Spring Boot 核心 → Spring Cloud Alibaba 全家桶 → MyBatis-Plus → RocketMQ → Redisson → ElasticSearch

学习策略: 先理解原理(为什么这么设计),再记面试题(面试怎么问),最后动手验证(写代码跑一遍)


目录


一、Spring Boot 核心原理

你简历的使用场景: 所有项目的核心框架

面试官心理: “你说你用了 6 年 Spring Boot,那说说自动配置是怎么实现的?”

1.1 IoC 容器与依赖注入

小白讲解

把 IoC 容器想象成一个大厨房的食材柜

  • 没有 IoC 的世界: 你想吃红烧肉,自己去买菜、洗肉、切肉、炒肉——每个对象自己 new 依赖
  • 有 IoC 的世界: 你只需要喊一声“我要五花肉”,厨房管理员(IoC 容器)就把五花肉递给你——对象不自己创建依赖,由容器注入

IoC = 控制反转:对象的创建权从“你自己 new”反转给了“容器”。

DI = 依赖注入:IoC 的具体实现方式,容器主动把依赖“注射”进对象。

代码示例

// ==================== 没有 IoC ====================
// UserController 自己 new UserService,强耦合
public class UserController {
    private UserService userService = new UserServiceImpl(); // 写死了
}

// ==================== 有 IoC ====================
// UserController 不关心 UserService 怎么来的
@Component
public class UserController {
    @Autowired  // 容器,给我注入一个 UserService
    private UserService userService;
}

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private UserMapper userMapper;  // 容器,再给我注入一个 Mapper
}

三种注入方式对比

方式 写法 优点 缺点
字段注入 @Autowired private UserService service; 简洁 不能脱离容器单独测试、循环依赖隐患
构造器注入 ✅ 构造方法加 @Autowired 可脱离容器测试、显式依赖、避免循环依赖 代码稍长
Setter 注入 setter 方法加 @Autowired 可选依赖 容易忘记调用 setter
// ✅ 推荐:构造器注入(Spring 4.3+ 单构造器可省略 @Autowired)
@Component
@RequiredArgsConstructor  // Lombok,自动生成构造器
public class UserController {
    private final UserService userService;  // final 保证不可变
}

面试题

Q1:@Autowired 和 @Resource 的区别?

维度 @Autowired @Resource
来源 Spring JSR-250(JDK 标准)
匹配方式 先按类型,找不到再按名称 先按名称,找不到再按类型
required 支持 @Autowired(required=false) 不支持
推荐场景 Spring 项目通用 需要指定 bean 名称时

Q2:@Autowired 注入同一个接口的多个实现类怎么办?

// 场景:UserService 有两个实现类
@Service("orderUserService")
public class OrderUserServiceImpl implements UserService {}

@Service("vipUserService")
public class VipUserServiceImpl implements UserService {}

// 方式 1:@Qualifier 指定 bean 名称
@Autowired
@Qualifier("vipUserService")
private UserService userService;

// 方式 2:变量名与 bean 名称一致
@Autowired
private UserService vipUserService;  // 自动匹配 vipUserService

// 方式 3:@Primary 标注首选实现
@Primary
@Service
public class VipUserServiceImpl implements UserService {}

Q3:Spring Bean 的生命周期?

完整生命周期(5 大阶段):

1. 实例化(Instantiation)
   → 构造方法被调用,对象在内存中创建出来
   → 类似:房子打地基

2. 属性赋值(Populate Properties)
   → @Autowired / @Value 注入依赖
   → 类似:往房子里搬家具

3. 初始化(Initialization)
   → BeanNameAware / BeanFactoryAware 等回调
   → BeanPostProcessor.postProcessBeforeInitialization
   → @PostConstruct 注解方法
   → InitializingBean.afterPropertiesSet
   → 自定义 init-method
   → BeanPostProcessor.postProcessAfterInitialization
   → 类似:装修完做最后验收

4. 使用(In Use)
   → Bean 存在于单例池中,被其他组件使用

5. 销毁(Destruction)
   → @PreDestroy 注解方法
   → DisposableBean.destroy
   → 自定义 destroy-method
   → 类似:拆迁前搬走

记忆口诀:实例化 → 属性赋值 → 初始化 → 使用 → 销毁(造 → 装 → 验 → 用 → 拆)


1.2 AOP 面向切面编程

小白讲解

想象你开了一家连锁餐厅,每家分店都要做这些事:

  1. 开门前检查卫生
  2. 营业中卖饭
  3. 关门后盘点库存

如果每家分店都自己写“检查卫生”和“盘点库存”的代码,就重复了。AOP 的思路是:把横切关注点(卫生检查、盘点)抽出来,在合适的时机自动执行,核心业务只管卖饭。

横切关注点 = 多个业务都需要的通用逻辑(日志、事务、权限、缓存)

AOP 术语大白话翻译:
- 切面(Aspect)     = 卫生检查规范(一个类)
- 切入点(Pointcut) = 哪些方法需要检查卫生(表达式匹配)
- 通知(Advice)     = 检查卫生的时机(开门前?关门后?还是出了问题?)
- 织入(Weaving)    = Spring 帮你把卫生检查逻辑塞到业务方法里

五种通知类型

@Aspect
@Component
public class LogAspect {

    // 切入点:匹配 service 包下所有方法
    @Pointcut("execution(* com.example.service..*.*(..))")
    public void servicePointcut() {}

    // 1. 前置通知:方法执行前
    @Before("servicePointcut()")
    public void before(JoinPoint jp) {
        System.out.println("【前置】执行: " + jp.getSignature().getName());
    }

    // 2. 后置通知:方法执行后(无论正常或异常都执行)
    @After("servicePointcut()")
    public void after(JoinPoint jp) {
        System.out.println("【后置】结束: " + jp.getSignature().getName());
    }

    // 3. 返回通知:方法正常返回后
    @AfterReturning(pointcut = "servicePointcut()", returning = "result")
    public void afterReturning(JoinPoint jp, Object result) {
        System.out.println("【返回】结果: " + result);
    }

    // 4. 异常通知:方法抛出异常后
    @AfterThrowing(pointcut = "servicePointcut()", throwing = "ex")
    public void afterThrowing(JoinPoint jp, Exception ex) {
        System.out.println("【异常】" + ex.getMessage());
    }

    // 5. 环绕通知:最强大,可以控制是否执行目标方法
    @Around("servicePointcut()")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            Object result = pjp.proceed();  // 执行目标方法
            return result;
        } finally {
            System.out.println("【环绕】耗时: " + (System.currentTimeMillis() - start) + "ms");
        }
    }
}

JDK 动态代理 vs CGLIB(面试必问)

维度 JDK 动态代理 CGLIB 代理
原理 基于接口,生成实现接口的代理类 基于继承,生成目标类的子类
要求 目标类必须实现接口 目标类不能是 final 类
性能 创建快,执行稍慢 创建慢,执行快
Spring 默认 有接口用 JDK 无接口用 CGLIB
Spring Boot 2.x 默认 CGLIB(spring.aop.proxy-target-class=true 同左
// JDK 动态代理原理(简化版)
public class JdkProxy implements InvocationHandler {
    private Object target;

    public Object createProxy(Object target) {
        this.target = target;
        return Proxy.newProxyInstance(
            target.getClass().getClassLoader(),
            target.getClass().getInterfaces(),
            this
        );
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("【前置】日志记录");
        Object result = method.invoke(target, args);  // 执行目标方法
        System.out.println("【后置】日志记录");
        return result;
    }
}

// CGLIB 原理(简化版)
public class CglibProxy implements MethodInterceptor {
    public Object createProxy(Class<?> targetClass) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(targetClass);     // 继承目标类
        enhancer.setCallback(this);
        return enhancer.create();
    }

    @Override
    public Object intercept(Object obj, Method method, Object[] args,
                             MethodProxy proxy) throws Throwable {
        System.out.println("【前置】日志记录");
        Object result = proxy.invokeSuper(obj, args);  // 调用父类方法
        System.out.println("【后置】日志记录");
        return result;
    }
}

面试题

Q1:同一个切面里五种通知的执行顺序?

正常情况:
  @Around(前半段) → @Before → 目标方法 → @AfterReturning → @After → @Around(后半段)

异常情况:
  @Around(前半段) → @Before → 目标方法(抛异常) → @AfterThrowing → @After → @Around(异常传播)

Q2:为什么 @Transactional 注解在 private 方法上不生效?

因为 Spring AOP 使用动态代理,代理类只能拦截 public 方法(JDK 代理基于接口,接口方法默认 public;CGLIB 通过子类重写,private 方法不可重写)。如果需要对 private 方法加事务,可以使用 AspectJ(字节码级织入)。


1.3 AspectJ 深度讲解(面试加分项)

小白理解:Spring AOP vs AspectJ 到底什么关系?

先说一个很多教程没讲清的核心事实:

你平时写的 @Aspect、@Before、@Around 这些注解——

  ❌ 它们不是 Spring AOP 自己发明的
  ✅ 它们是 AspectJ 定义的注解语法(叫 @AspectJ 风格)

Spring 做的事情:
  1. 借用了 AspectJ 的注解语法(让你写切面更方便)
  2. 但底层还是用自己的动态代理来实现(不是 AspectJ 的编译器)

类比:
  Spring AOP = 你用普通话(AspectJ 语法)写了一份菜单,但后厨用的是自己的做法(动态代理)
  AspectJ    = 连菜单带后厨都是自己的一套(自己的语法 + 自己的编译器 ajc)

三者关系一图看清

┌──────────────────────────────────────────────────────┐
│                   AOP 世界                            │
│                                                      │
│  ┌─────────────────┐     ┌────────────────────────┐ │
│  │   Spring AOP    │     │      AspectJ           │ │
│  │                  │     │                        │ │
│  │ 实现:动态代理    │     │ 实现:字节码织入        │ │
│  │ 织入:运行时      │     │ 织入:编译时/加载时     │ │
│  │ 范围:只能拦截    │     │ 范围:方法/字段/构造器  │ │
│  │      Bean 的     │     │       都能拦截          │ │
│  │      public 方法 │     │                        │ │
│  │                  │     │                        │ │
│  │  借用了 AspectJ   │     │  有自己的 .aj 语法      │ │
│  │  的 @AspectJ     │     │  也有 @AspectJ 注解     │ │
│  │  注解语法         │     │                        │ │
│  └────────┬────────┘     └───────────┬────────────┘ │
│           │                          │               │
│           │    @AspectJ 注解语法      │               │
│           └──────────┬───────────────┘               │
│                      │                               │
│              ┌───────▼───────┐                       │
│              │ @Aspect        │                       │
│              │ @Pointcut      │  ← 两边都能用的语法   │
│              │ @Before/@Around│                       │
│              └───────────────┘                       │
└──────────────────────────────────────────────────────┘

AspectJ 的三种织入方式(面试重点)

Spring AOP 只有运行时代理一种方式。AspectJ 有三种,这是面试重点区分:

方式一:编译时织入(Compile-Time Weaving, CTW)

你写的 .java 源码


  AspectJ 编译器 (ajc)  ← 代替 javac
      │  (ajc 在编译时就找到切面,直接把增强代码插入到 .class 字节码里)

  增强后的 .class 文件(已经包含切面代码)


  JVM 运行(运行时零开销,因为代码早就织入好了)

配置方式: Maven 插件

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>aspectj-maven-plugin</artifactId>
    <version>1.14.0</version>
    <configuration>
        <complianceLevel>1.8</complianceLevel>
        <source>1.8</source>
        <target>1.8</target>
        <showWeaveInfo>true</showWeaveInfo>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>compile</goal>       <!-- 编译时织入 -->
                <goal>test-compile</goal>
            </goals>
        </execution>
    </executions>
</plugin>

方式二:编译后织入(Post-Compile Weaving, PCW)

你写的 .java 源码


  javac 编译 → 普通 .class 文件


  AspectJ 织入器 (ajc -inpath)  ← 对已编译的 .class 做增强
      │  (适合第三方 jar 包,你拿不到源码但想加切面)

  增强后的 .class 文件

场景: 你想给第三方库(如某个 jar 包里的方法)加日志,但没有源码 → 先正常编译,再用 ajc 织入。

方式三:加载时织入(Load-Time Weaving, LTW)

JVM 启动


  类加载器加载 .class 文件


  AspectJ 织入代理 (Java Agent)  ← 在类加载的那一刻做增强
      │  (读取 aop.xml 配置,找到切面,织入到正在加载的类)

  增强后的类被 JVM 加载(运行时零代理开销)

配置方式: JVM 参数 + aop.xml

# JVM 启动参数
java -javaagent:aspectjweaver.jar -jar your-app.jar
<!-- src/main/resources/META-INF/aop.xml -->
<aspectj>
    <weaver options="-verbose -showWeaveInfo">
        <!-- 只织入 service 包下的类 -->
        <include within="com.example.service..*"/>
    </weaver>
    <aspects>
        <!-- 指定要使用的切面 -->
        <aspect name="com.example.aspect.LogAspect"/>
    </aspects>
</aspectj>

三种织入方式对比

维度 CTW(编译时) PCW(编译后) LTW(加载时)
织入时机 编译 .java → .class 对已有 .class 织入 JVM 加载 class 时
工具 ajc 编译器 ajc -inpath aspectjweaver.jar (Java Agent)
需要源码 ✅ 需要 ❌ 不需要(适合第三方 jar) ❌ 不需要
运行时开销 零(代码已编入) 极小(类加载时一次性开销)
配置复杂度 中(Maven 插件) 高(JVM 参数 + aop.xml)
适用场景 自己的项目想用 AspectJ 全功能 增强第三方库 不想改编译过程,运行时灵活控制
Spring 支持 支持 支持 支持(@EnableLoadTimeWeaving

AspectJ 原生 .aj 语法(了解即可)

除了 @AspectJ 注解风格,AspectJ 还有自己的原生语法(.aj 文件),功能更强:

// LogAspect.aj — 注意文件后缀是 .aj,不是 .java
public aspect LogAspect {

    // 切入点:和注解风格一样
    pointcut serviceMethod():
        execution(* com.example.service..*.*(..));

    // 前置通知
    before(): serviceMethod() {
        System.out.println("【前置】" + thisJoinPoint.getSignature());
    }

    // 后置通知
    after(): serviceMethod() {
        System.out.println("【后置】" + thisJoinPoint.getSignature());
    }

    // 环绕通知
    Object around(): serviceMethod() {
        long start = System.currentTimeMillis();
        try {
            return proceed();  // proceed 相当于 pjp.proceed()
        } finally {
            System.out.println("耗时: " + (System.currentTimeMillis() - start) + "ms");
        }
    }
}
@AspectJ 注解风格 vs .aj 原生语法:

| 维度     | @AspectJ 注解          | .aj 原生语法           |
|---------|------------------------|------------------------|
| 文件类型 | .java(普通 Java 文件) | .aj(AspectJ 专用文件)  |
| 编译器   | javac 就能编译          | 必须 ajc 编译           |
| IDE 支持 | 全面支持               | 需装 AJDT 插件          |
| 功能     | 方法级拦截             | 方法/字段/构造器全支持    |
| 学习成本 | 低(和写 Spring 一样)   | 高(新语法)             |

结论:99% 的场景用 @AspectJ 注解就够了。.aj 语法了解即可,
面试能说出"AspectJ 有自己的 .aj 语法,功能更强但需要 ajc 编译器"就行。

AspectJ 比 Spring AOP 多能做什么?(面试核心区分点)

这是面试官最想听到的——Spring AOP 做不到但 AspectJ 能做到的事:

1. 拦截字段读写(Spring AOP 做不到)

// AspectJ 可以拦截字段的读取和赋值
@Aspect
public class FieldAspect {

    // 拦截 User.name 字段的读取
    @Before("get(* com.example.model.User.name)")
    public void beforeGetName() {
        System.out.println("有人在读取 name 字段");
    }

    // 拦截 User.name 字段的赋值
    @Before("set(* com.example.model.User.name) && args(newName)")
    public void beforeSetName(String newName) {
        System.out.println("有人在设置 name = " + newName);
    }
}

2. 拦截构造方法(Spring AOP 做不到)

@Aspect
public class ConstructorAspect {

    // 拦截 User 的构造方法调用
    @Before("call(com.example.model.User.new(..))")
    public void beforeNewUser() {
        System.out.println("有人在 new User()");
    }

    // 拦截 User 构造方法执行
    @After("execution(com.example.model.User.new(..))")
    public void afterUserInit() {
        System.out.println("User 对象创建完成");
    }
}

3. 拦截 private/protected 方法(Spring AOP 做不到)

@Aspect
public class PrivateMethodAspect {

    // Spring AOP 拦截不了 private 方法,但 AspectJ 可以
    @Before("execution(* com.example.service.UserService.privateMethod(..))")
    public void beforePrivateMethod() {
        System.out.println("private 方法被调用了");
    }
}

4. 拦截静态方法调用(Spring AOP 受限)

@Aspect
public class StaticMethodAspect {

    // 拦截 Math.random() 的调用
    @Before("call(* java.lang.Math.random())")
    public void beforeRandom() {
        System.out.println("有人在调用 Math.random()");
    }
}

Pointcut 表达式大全(面试必背)

Spring AOP 用的是 AspectJ 的切入点表达式语法,这些表达式面试经常考:

execution(最常用,匹配方法执行)

// 完整语法:
execution(修饰符? 返回类型 包名.类名.方法名(参数类型) 异常?)

// 实例:
execution(public * com.example.service.UserService.*(..))
//  ↳ public 修饰,任意返回类型,UserService 类的所有方法,任意参数

execution(* com.example.service..*.*(..))
//  ↳ 任意修饰符和返回类型,service 包及子包下所有类的所有方法

execution(* save*(..))
//  ↳ 所有以 save 开头的方法

execution(* *(String, ..))
//  ↳ 第一个参数是 String 的方法(.. 表示后面可以有任意个参数)

execution(* *(..) throws java.io.IOException)
//  ↳ 声明抛出 IOException 的方法

其他切入点指示符

// ── target:匹配目标对象类型(运行时类型)
@After("target(com.example.service.UserService)")
//  ↳ 目标对象是 UserService 类型(包括子类)

// ── within:匹配类级别(编译时类型)
@Before("within(com.example.service..*)")
//  ↳ service 包及子包下所有类的方法

// ── @within:匹配类上有特定注解
@Before("@within(org.springframework.stereotype.Service)")
//  ↳ 类上标了 @Service 的所有方法

// ── @annotation:匹配方法上有特定注解
@Around("@annotation(com.example.annotation.MyLog)")
//  ↳ 方法上标了 @MyLog 注解
//  ⭐ 这个最实用!自定义注解 + AOP = 灵活的日志/权限/缓存控制

// ── @args:匹配方法参数上有特定注解
@Before("@args(com.example.annotation.Validated)")
//  ↳ 方法参数上标了 @Validated 注解

// ── bean:Spring 扩展的,匹配 Bean 名称
@After("bean(userService)")
//  ↳ 名叫 userService 的 Bean 的所有方法

// ── this:匹配代理对象类型
@Before("this(com.example.service.UserService)")
//  ↳ 代理对象是 UserService 类型

组合切入点

@Pointcut("execution(* com.example.service..*.*(..))")
public void serviceLayer() {}

@Pointcut("execution(* com.example.controller..*.*(..))")
public void controllerLayer() {}

// && 与:同时满足
@Before("serviceLayer() && args(String, ..)")
public void serviceWithStringParam() {}

// || 或:满足其一
@Around("serviceLayer() || controllerLayer()")
public void logAll() {}

// ! 非:取反
@Before("serviceLayer() && !execution(* com.example.service.UserService.delete*(..))")
public void logExceptDelete() {}

execution vs within vs target vs this 的区别(高频面试题)

这四个最容易混,一张表搞定:

假设有:
  interface UserService { void save(); }
  class UserServiceImpl implements UserService { public void save() {} }

  Spring 创建的代理对象 proxy(JDK 代理,实现了 UserService 接口)
指示符 匹配什么 上面例子的匹配结果 类比
execution 方法签名(静态,看代码长什么样) execution(* UserServiceImpl.save(..)) ✅ 能匹配 “这个方法签名长这样吗?”
within 类的包路径(静态) within(com.example..UserServiceImpl) “这个类在哪个包下?”
target 目标对象的运行时类型 target(UserService) ✅(代理背后的目标对象是 UserServiceImpl) “被代理的那个对象是什么类型?”
this 代理对象的类型 this(UserService) ✅ / this(UserServiceImpl) ❌(JDK 代理不是 UserServiceImpl) “代理对象本身是什么类型?”
关键区别:
  execution / within → 看的是"代码怎么写的"(静态,编译时就能确定)
  target / this      → 看的是"运行时对象是什么类型"(动态)

  this vs target 的区别:
    this  = 代理对象的类型(proxy)
    target = 被代理的目标对象类型(target)

  用 JDK 代理时:this 可能不等于 target(代理只实现了接口,不是目标类的子类)
  用 CGLIB 代理时:this 通常等于 target(代理是目标类的子类)

实战:自定义注解 + AOP 实现操作日志(简历项目可用)

这是面试和实际工作中最常用的 AOP 模式——自定义注解 + 环绕通知:

// 1. 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
    String value() default "";  // 操作描述
}

// 2. 切面实现
@Aspect
@Component
@Slf4j
public class OperationLogAspect {

    @Autowired
    private OperationLogService logService;

    @Around("@annotation(operationLog)")
    public Object around(ProceedingJoinPoint pjp, OperationLog operationLog) throws Throwable {
        // —— 前置:记录操作信息 ——
        String methodName = pjp.getSignature().getName();
        String className = pjp.getTarget().getClass().getSimpleName();
        String args = Arrays.toString(pjp.getArgs());
        String operator = SecurityContextHolder.getContext().getAuthentication().getName();

        log.info("操作开始 | {} | {}.{} | 参数: {}", operationLog.value(), className, methodName, args);

        long start = System.currentTimeMillis();
        Object result = null;
        String status = "成功";
        String errorMsg = null;

        try {
            result = pjp.proceed();  // 执行目标方法
            return result;
        } catch (Throwable e) {
            status = "失败";
            errorMsg = e.getMessage();
            throw e;  // 异常继续抛出,不影响原有逻辑
        } finally {
            long cost = System.currentTimeMillis() - start;

            // —— 后置:异步保存日志 ——
            OperationLogEntity logEntity = new OperationLogEntity();
            logEntity.setOperator(operator);
            logEntity.setOperation(operationLog.value());
            logEntity.setMethod(className + "." + methodName);
            logEntity.setArgs(args);
            logEntity.setStatus(status);
            logEntity.setErrorMsg(errorMsg);
            logEntity.setCostTime(cost);
            logEntity.setOperateTime(LocalDateTime.now());

            // 异步保存,不影响主流程
            CompletableFuture.runAsync(() -> logService.save(logEntity));

            log.info("操作结束 | {} | {} | 耗时: {}ms", operationLog.value(), status, cost);
        }
    }
}

// 3. 使用:在 Controller 方法上加注解
@RestController
@RequestMapping("/api/user")
public class UserController {

    @OperationLog("创建用户")
    @PostMapping("/create")
    public Result create(@RequestBody UserDTO dto) {
        return userService.create(dto);
    }

    @OperationLog("删除用户")
    @DeleteMapping("/{id}")
    public Result delete(@PathVariable Long id) {
        return userService.delete(id);
    }
}

实战:自定义注解 + AOP 实现接口限流(简历项目可用)

// 1. 限流注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    int qps() default 100;           // 每秒允许的请求数
    int timeout() default 500;       // 获取令牌超时时间(毫秒)
    String message() default "请求过于频繁,请稍后重试";
}

// 2. 限流切面(基于 Redis + Lua 或 Guava RateLimiter)
@Aspect
@Component
public class RateLimitAspect {

    private final Map<String, RateLimiter> limiters = new ConcurrentHashMap<>();

    @Around("@annotation(rateLimit)")
    public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
        String key = pjp.getSignature().toLongString();

        RateLimiter limiter = limiters.computeIfAbsent(key,
            k -> RateLimiter.create(rateLimit.qps()));

        if (!limiter.tryAcquire(1, rateLimit.timeout(), TimeUnit.MILLISECONDS)) {
            throw new BusinessException(rateLimit.message());
        }

        return pjp.proceed();
    }
}

// 3. 使用
@RestController
public class LoginController {

    @RateLimit(qps = 10, message = "登录接口限流,每秒最多10次")
    @PostMapping("/login")
    public Result login(@RequestBody LoginDTO dto) {
        return authService.login(dto);
    }
}

Spring 中启用 AspectJ 的三种方式

方式一:Spring AOP + @AspectJ 注解(默认方式,99% 的人在用)

// 启动类或配置类
@Configuration
@EnableAspectJAutoProxy  // ← 开启 @AspectJ 注解支持(Spring Boot 默认已开启)
// @EnableAspectJAutoProxy(proxyTargetClass = true)  // 强制使用 CGLIB
// @EnableAspectJAutoProxy(exposeProxy = true)        // 暴露代理对象,可在类内通过 AopContext 获取
public class AppConfig {}

// 然后正常写 @Aspect 切面就行,Spring 用动态代理实现

方式二:Spring + AspectJ LTW(加载时织入)

@Configuration
@EnableLoadTimeWeaving  // ← 开启 AspectJ 加载时织入
public class AppConfig {}

// JVM 参数:-javaagent:aspectjweaver.jar
// 还需要 META-INF/aop.xml 配置文件

方式三:纯 AspectJ(CTW,不依赖 Spring)

完全使用 AspectJ 编译器,不依赖 Spring 的代理机制。适合对性能要求极高或需要拦截字段/构造器的场景。

AspectJ 面试题

Q1:Spring AOP 和 AspectJ 的区别?(高频)

维度 Spring AOP AspectJ
实现方式 动态代理(JDK/CGLIB) 字节码织入(ajc 编译器)
织入时机 运行时 编译时(CTW) / 编译后(PCW) / 加载时(LTW)
性能 有代理方法调用开销 零运行时开销(代码已编入字节码)
功能范围 仅方法级(public),仅 Spring Bean 方法 + 字段 + 构造器 + 静态方法,任意类
拦截 private ❌ 不支持 ✅ 支持
拦截字段读写 ❌ 不支持 ✅ 支持
拦截构造方法 ❌ 不支持 ✅ 支持
自调用问题 ❌ 内部调用不经过代理 ✅ 直接织入字节码,无此问题
使用复杂度 低(注解 + Spring 管理) 高(需 ajc 编译器 / Java Agent)
适用场景 企业应用开发(日志/事务/权限) 对性能要求高、需要深层拦截的场景

Q2:Spring 为什么不直接用 AspectJ,而是自己实现动态代理?

三个原因:

  1. 易用性:AspectJ 需要单独的 ajc 编译器和构建配置,Spring AOP 只需加依赖和注解,零侵入
  2. 够用原则:企业开发 80% 的 AOP 需求(事务、日志、权限、缓存)都是方法级拦截,Spring AOP 足够
  3. 容器集成:Spring AOP 与 IoC 容器深度集成,切面本身也是 Bean,可以注入依赖;AspectJ 的切面由 ajc 管理,不在 Spring 容器中

Q3:Spring AOP 存在“自调用问题”,AspectJ 为什么没有?

Spring AOP 的自调用问题:

@Service
public class OrderService {
    public void methodA() {
        this.methodB();  // ❌ this 是目标对象本身,不是代理对象
                         //    不经过代理,methodB 的 @Transactional 失效
    }
    @Transactional
    public void methodB() {}
}

AspectJ 没有这个问题:

因为 AspectJ 是直接把增强代码"写"进了 methodB 的字节码里,
methodB 这个方法本身就是被增强过的,不管谁调用它都会执行增强逻辑。
不存在"代理对象 vs 目标对象"的区分。

Spring AOP 的修复方式(不是真正修复,是绕过):

// 方式1:注入自己
@Service
public class OrderService {
    @Autowired
    private OrderService self;  // 注入的是代理对象
    public void methodA() {
        self.methodB();  // ✅ 通过代理调用
    }
}

// 方式2:暴露代理对象
@EnableAspectJAutoProxy(exposeProxy = true)
// 在代码中获取当前代理对象
((OrderService) AopContext.currentProxy()).methodB();

Q4:AspectJ 的三种织入方式分别什么场景用?

方式 场景 例子
CTW(编译时) 自己的项目,想要 AspectJ 全功能且运行时零开销 微服务项目想拦截 private 方法和字段访问
PCW(编译后) 增强第三方 jar 包,没有源码 给某个开源库的方法加监控埋点
LTW(加载时) 不想改编译过程,但运行时需要灵活控制 应用部署后动态调整织入范围(改 aop.xml 即可)

Q5:execution 和 within 的区别?

  • execution 匹配的是方法签名(修饰符、返回类型、包名、类名、方法名、参数),粒度更细
  • within 匹配的是类所在的包路径,粒度更粗,只能匹配到类级别
  • execution(* com.example.service.UserService.save(..)) 能精确到某个类的某个方法
  • within(com.example.service.UserService) 只能匹配到 UserService 类,不能区分哪个方法

Q6:this 和 target 的区别?什么时候两者不一样?

  • this 匹配的是代理对象的类型
  • target 匹配的是目标对象(被代理的对象)的类型
  • 使用 JDK 动态代理时:代理对象只实现了接口,不是目标类的子类,所以 this(目标类) 匹配不到,this(接口) 能匹配到
  • 使用 CGLIB 代理时:代理对象是目标类的子类,this(目标类) 也能匹配到
  • 结论:面试时说“this 看代理对象类型,target 看目标对象类型,JDK 代理时 this 和 target 可能不同”

Q7:@annotation 注解切入点为什么最常用?

因为它最灵活、最精确。你不需要写复杂的 execution 表达式去匹配包名和方法名,只需要在目标方法上加一个自定义注解,切面就自动生效。这意味着:

  1. 精确控制哪些方法需要增强(只有加了注解的才增强)
  2. 不依赖包结构(方法在哪个包无所谓)
  3. 业务代码只需加一行注解,零侵入
  4. 注解可以携带参数(如操作描述、限流 QPS),切面直接读取

常见应用:操作日志(@OperationLog)、接口限流(@RateLimit)、数据权限(@DataPermission)、分布式锁(@DistributedLock)、缓存(@Cacheable)

Q8:Spring AOP 中 exposeProxy = true 有什么用?

开启后,Spring 会把当前代理对象存入 ThreadLocal,在业务代码中可以通过 AopContext.currentProxy() 获取当前代理对象。主要用于解决自调用问题——类内部 A 方法调用 B 方法时,通过 ((MyService) AopContext.currentProxy()).methodB() 确保调用经过代理,使 B 方法上的 @Transactional 等注解生效。

副作用:每层代理都增加一次 ThreadLocal 操作,有微小性能开销;且只有最外层的代理会被暴露,多层代理嵌套时可能拿不到正确的代理对象。


1.3 自动配置原理(Spring Boot 的灵魂)

小白讲解

Spring Boot 的自动配置就像一个智能管家

  • 传统 Spring:你告诉管家“我要个碗、我要个筷子、我要个勺子”——写一堆 XML
  • Spring Boot:管家看了一眼厨房,发现有大米,自动给你盛了饭——根据类路径下有什么 jar 包,自动配置对应的 Bean

核心入口: @SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan

自动配置执行流程

1. 启动类 @SpringBootApplication
2. @EnableAutoConfiguration
3. 通过 @Import(AutoConfigurationImportSelector.class) 导入选择器
4. AutoConfigurationImportSelector.selectImports()
5. 读取 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件
   (Spring Boot 2.7 之前是 spring.factories)
6. 拿到一堆 AutoConfiguration 类的全限定名
7. 逐个检查 @Conditional 条件
   → @ConditionalOnClass:类路径有这个类吗?没有就跳过
   → @ConditionalOnBean:容器里有这个 Bean 吗?没有就跳过
   → @ConditionalOnProperty:配置文件里有这个属性吗?
8. 条件满足 → 创建对应 Bean → 注册到容器
   条件不满足 → 跳过

代码示例:自己写一个 Starter

my-spring-boot-starter/
├── pom.xml
└── src/main/java/com/example/autoconfig/
    ├── HelloProperties.java     # 配置属性绑定
    ├── HelloService.java         # 核心服务
    ├── HelloAutoConfiguration.java  # 自动配置类
    └── resources/META-INF/
        └── spring/
            └── org.springframework.boot.autoconfigure.AutoConfiguration.imports
// 1. 配置属性绑定
@ConfigurationProperties(prefix = "hello")
@Data
public class HelloProperties {
    private String name = "World";
    private int times = 1;
}

// 2. 核心服务
public class HelloService {
    private HelloProperties properties;
    public HelloService(HelloProperties properties) {
        this.properties = properties;
    }
    public String sayHello() {
        return "Hello, " + properties.getName() + "!".repeat(properties.getTimes());
    }
}

// 3. 自动配置类
@AutoConfiguration
@ConditionalOnProperty(prefix = "hello", name = "enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(HelloProperties.class)
public class HelloAutoConfiguration {
    @Bean
    @ConditionalOnMissingBean(HelloService.class)  // 用户没自己定义才创建
    public HelloService helloService(HelloProperties properties) {
        return new HelloService(properties);
    }
}

// 4. imports 文件内容
// resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
// 内容:com.example.autoconfig.HelloAutoConfiguration

// 5. 使用方 application.yml
// hello:
//   name: 朱宏晖
//   times: 3

// 6. 使用方直接注入
@Autowired
private HelloService helloService;  // 不需要任何配置,直接可用

面试题

Q1:Spring Boot 自动配置的原理是什么?

启动类 @SpringBootApplication 中的 @EnableAutoConfiguration 通过 @Import 导入 AutoConfigurationImportSelector,该类调用 selectImports() 方法,从 META-INF/spring/...AutoConfiguration.imports(2.7+)或 spring.factories(2.7 之前)文件中加载所有自动配置类的全限定名。然后 Spring Boot 根据 @Conditional 系列注解(@ConditionalOnClass@ConditionalOnBean@ConditionalOnProperty 等)判断每个配置类是否应该生效。只有条件满足的配置类才会被注册到 IoC 容器中,从而实现“引入什么依赖就自动配置什么功能”。

Q2:如何禁用某个自动配置?

// 方式 1:启动类排除
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class App {}

// 方式 2:配置文件
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration

// 方式 3:@ConditionalOnProperty 控制开关
hello.enabled=false

Q3:@Conditional 系列注解有哪些?

注解 条件
@ConditionalOnClass 类路径存在指定类
@ConditionalOnMissingClass 类路径不存在指定类
@ConditionalOnBean 容器中存在指定 Bean
@ConditionalOnMissingBean 容器中不存在指定 Bean
@ConditionalOnProperty 配置文件有指定属性
@ConditionalOnWebApplication 是 Web 应用
@ConditionalOnNotWebApplication 不是 Web 应用
@ConditionalOnExpression SpEL 表达式为 true

1.4 事务管理 @Transactional(简历面试必问)

小白讲解

事务就是要么全做,要么全不做。想象银行转账:

  • A 扣 100 块 → B 加 100 块
  • 如果 A 扣了钱后系统崩溃了,B 没收到钱——这 100 块就消失了
  • 事务保证:A 扣和 B 加要么同时成功,要么同时失败

ACID 四大特性

特性 含义 例子
Atomicity 原子性 不可分割,要么全做要么全不做 转账扣款和加款是一个整体
Consistency 一致性 事务前后数据状态一致 A+B 总额不变
Isolation 隔离性 并发事务互不干扰 两人同时转账不影响对方
Durability 持久性 提交后永久保存 断电不丢数据

@Transactional 底层原理

调用方 → 代理对象 → TransactionInterceptor(拦截器)

                   1. 获取事务管理器 PlatformTransactionManager
                   2. 调用 getTransaction() 开启/加入事务(根据传播行为)
                   3. 将数据库连接绑定到 ThreadLocal(DataSourceUtils)

                   4. 执行目标方法(业务代码)

                   5a. 正常返回 → commit()
                   5b. 抛出异常 → 判断是否匹配回滚规则 → rollback()

核心组件:

  • TransactionInterceptor:实现了 MethodInterceptor,是事务的入口拦截器
  • PlatformTransactionManager:事务管理器,如 DataSourceTransactionManager(JDBC)、JpaTransactionManager(JPA)
  • TransactionSynchronizationManager:通过 ThreadLocal 将数据库连接与当前线程绑定,保证同一事务中的多个 DAO 操作使用同一个 Connection

七种传播行为(面试重点 + 必须会举例)

什么是传播行为?

传播行为解决的问题是: 当方法 A(有事务)调用方法 B 时,方法 B 应该用 A 的事务,还是自己开一个新事务,还是不开事务?

@Service
public class OrderService {
    @Autowired
    private UserService userService;

    @Transactional  // 外层方法有事务
    public void createOrder() {
        orderMapper.insert(order);        // 步骤1:写订单
        userService.updateUser();          // 步骤2:更新用户(用哪种传播行为?)
        pointService.addPoints();          // 步骤3:加积分(用哪种传播行为?)
    }
}

七种传播行为详解


① REQUIRED(默认,最常用)

含义: 当前有事务就加入,没有就新建一个。

@Service
public class UserService {
    @Transactional(propagation = Propagation.REQUIRED)  // 默认就是这个
    public void updateUser() {
        userMapper.update(user);
    }
}

场景一:外层有事务 → 加入外层事务

OrderService.createOrder()  ← 开启事务 T1
    ├── insertOrder()        ← 使用 T1
    ├── UserService.updateUser()  ← 加入 T1(不新开事务)
    └── addPoints()          ← 使用 T1
                             ← T1 提交(三个操作一起成功或一起失败)

场景二:外层无事务 → 自己新建一个

UserController.query()      ← 无事务
    └── UserService.updateUser()  ← 新建事务 T1
                                   ← T1 提交

适用场景: 绝大多数业务方法。比如“创建订单”需要同时写订单表、扣库存、加积分,这三个操作必须在一个事务中,任何一个失败全部回滚。

面试关键句: “REQUIRED 是默认传播行为,有事务加入、没事务新建,保证方法始终在事务中执行。”


② REQUIRES_NEW(独立事务,常用)

含义: 无论当前有没有事务,都新开一个独立事务。如果当前有事务,先把当前事务挂起,等新事务结束后再恢复。

@Service
public class LogService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveLog(String action) {
        logMapper.insert(new Log(action));
    }
}

场景:订单失败也要记日志

@Service
public class OrderService {
    @Autowired
    private LogService logService;

    @Transactional  // 事务 T1
    public void createOrder() {
        try {
            orderMapper.insert(order);       // 写订单
            inventoryService.deduct();        // 扣库存(可能失败)
        } catch (Exception e) {
            // T1 即将回滚,但日志必须保存下来!
            logService.saveLog("订单创建失败: " + e.getMessage());
            // saveLog 用 REQUIRES_NEW → 新开事务 T2,独立于 T1
            // T1 回滚不影响 T2,日志成功保存
            throw e;  // 重新抛出,T1 回滚
        }
    }
}

执行时序:

T1 开始 → insertOrder → deduct 失败
         → T1 挂起 → T2 开始 → saveLog → T2 提交
         → T1 恢复 → T1 回滚
结果:订单没写入(回滚),日志保存了(独立提交)

适用场景:

  • 日志记录:主业务失败也要留痕
  • 审计记录:操作失败也要记录谁操作了什么
  • 通知发送:消息发送失败不应阻止主业务回滚
  • 性能监控:记录方法耗时,不应被业务事务回滚

⚠️ 注意事项:

  • REQUIRES_NEW 会占用两个数据库连接,连接池小时可能死锁
  • 外层事务挂起后,新事务和旧事务是完全独立的,互相不可见

面试关键句: “REQUIRES_NEW 总是开启独立事务,外层事务会被挂起。适合日志、审计等即使主业务失败也需要保存的场景。”


③ NESTED(嵌套事务,常用)

含义: 当前有事务就在当前事务内创建一个**保存点(Savepoint)**的子事务;没有就新建一个(等同于 REQUIRED)。子事务回滚不影响父事务,但父事务回滚会连带子事务一起回滚。

@Service
public class PointService {
    @Transactional(propagation = Propagation.NESTED)
    public void addPoints(Long userId, int points) {
        pointMapper.insert(new Point(userId, points));
    }
}

场景:批量发积分,部分失败不影响整体

@Service
public class BatchService {
    @Autowired
    private PointService pointService;

    @Transactional  // 父事务 T1
    public void batchAddPoints(List<Long> userIds) {
        for (Long userId : userIds) {
            try {
                pointService.addPoints(userId, 100);  // 子事务
            } catch (Exception e) {
                log.warn("用户{}积分发放失败: {}", userId, e.getMessage());
                // 子事务回滚到保存点,但父事务 T1 不受影响,继续循环
            }
        }
        // 所有成功的都保留,失败的跳过
    }
}

执行时序:

T1 开始
  → 用户1:Savepoint S1 → addPoints → 成功 → 释放 S1
  → 用户2:Savepoint S2 → addPoints → 失败 → 回滚到 S2(只回滚用户2的操作)
  → 用户3:Savepoint S3 → addPoints → 成功 → 释放 S3
T1 提交(用户1和3的积分生效,用户2的被回滚)

如果 T1 最终回滚 → 所有子事务(用户1、3)也一起回滚

NESTED vs REQUIRES_NEW 对比:

维度 NESTED REQUIRES_NEW
事务数量 1个事务,用保存点实现 2个独立事务
数据库连接 1个 2个
子事务回滚 → 父事务 ❌ 不影响 ❌ 不影响
父事务回滚 → 子事务 ✅ 一起回滚 ❌ 不影响(已提交的不会撤销)
数据库支持 需要 JDBC 驱动支持 Savepoint 所有数据库都支持
适用场景 部分失败容错(批量操作) 完全独立的操作(日志/审计)

面试关键句: “NESTED 基于数据库 Savepoint 实现嵌套子事务,子事务回滚不影响父事务,但父事务回滚会连带子事务。适合批量操作中部分失败容错。”


④ SUPPORTS(跟随模式)

含义: 有事务就加入,没有就以非事务方式运行。

@Service
public class QueryService {
    @Transactional(propagation = Propagation.SUPPORTS)
    public List<Order> queryOrders() {
        return orderMapper.selectList(null);
    }
}

适用场景: 纯查询方法,不强制需要事务,但如果调用方有事务就复用(可以读到未提交的数据)。

面试关键句: “SUPPORTS 是佛系传播行为,有事务就用,没事务也无所谓,适合纯查询方法。”


⑤ NOT_SUPPORTED(挂起模式)

含义: 以非事务方式运行,如果当前有事务就挂起。

@Service
public class ReportService {
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void generateBigReport() {
        // 生成超大报表,耗时很长
        // 不需要事务,挂起外层事务释放连接,避免长时间占用
        reportMapper.queryMillionRows();
    }
}

适用场景:

  • 耗时极长的操作(大报表生成、批量数据导出),不需要事务保护,避免长时间占用数据库连接
  • 调用外部系统(HTTP/RPC),避免外部长时间不返回导致数据库连接被占满

面试关键句: “NOT_SUPPORTED 挂起当前事务,以非事务方式执行,适合长时间运行或调用外部系统的场景,避免数据库连接被长时间占用。”


⑥ MANDATORY(强制事务)

含义: 必须在事务中运行。如果当前没有事务,直接抛出 IllegalTransactionStateException

@Service
public class TransferService {
    @Transactional(propagation = Propagation.MANDATORY)
    public void transfer(Long from, Long to, BigDecimal amount) {
        // 转账操作必须在事务中,否则数据不一致
        accountMapper.deduct(from, amount);
        accountMapper.add(to, amount);
    }
}

适用场景: 核心写操作,强制要求调用方必须开启事务,防止误用。相当于一个断言——“你必须给我事务,否则我不干活”。

面试关键句: “MANDATORY 强制要求在事务中运行,没事务就报错,用于保护核心写操作不被误调用。”


⑦ NEVER(禁止事务)

含义: 以非事务方式运行。如果当前有事务,直接抛出 IllegalTransactionStateException

@Service
public class ExternalService {
    @Transactional(propagation = Propagation.NEVER)
    public void callThirdPartyAPI() {
        // 调用第三方支付接口
        // 如果在事务中调用,第三方超时会导致数据库连接长时间被占用
        // 所以禁止在事务中调用
    }
}

适用场景: 调用外部不可控的接口(第三方 API),禁止在事务中执行,防止外部超时拖垮数据库连接池。

面试关键句: “NEVER 禁止在事务中运行,有事务就报错,用于保护数据库连接不被外部调用拖垮。”


七种传播行为总结速记表

传播行为 外层有事务 外层无事务 适用场景
REQUIRED(默认) 加入当前事务 新建事务 绝大多数业务方法
REQUIRES_NEW 挂起当前,新建独立事务 新建事务 日志/审计(主业务失败也要保存)
NESTED 在当前事务内建保存点子事务 新建事务 批量操作部分失败容错
SUPPORTS 加入当前事务 非事务运行 纯查询方法
NOT_SUPPORTED 挂起当前,非事务运行 非事务运行 耗时操作/调用外部系统
MANDATORY 加入当前事务 抛异常 核心写操作强制要求事务
NEVER 抛异常 非事务运行 禁止在事务中调用的外部接口

面试记忆口诀:

  • REQUIRED = 顺其自然(有就加入,没有就自己开)
  • REQUIRES_NEW = 独立自主(不管你,我自己开)
  • NESTED = 寄人篱下(在你家开个铺,你塌了我跟着塌,我塌了你没事)
  • SUPPORTS = 随遇而安(有就用,没有也行)
  • NOT_SUPPORTED = 我不需要(你有我也不用,给我挂起)
  • MANDATORY = 必须有(没事务我就报错)
  • NEVER = 绝不能有(有事务我就报错)

事务隔离级别详解(面试必考 + 必须能举例)

为什么要隔离?

多个事务并发执行时,如果不加隔离,会出现三种问题:

问题 一句话解释 严重程度
脏读 读到了别人还没提交的数据,结果别人回滚了 🔴 最严重
不可重复读 同一事务中两次读同一行数据,结果不一样(别人改了并提交了) 🟡 中等
幻读 同一事务中两次范围查询,结果条数不一样(别人新增/删除了并提交了) 🟡 中等

① 脏读(Dirty Read)—— 读到了“假数据”

定义: 事务 A 读到了事务 B 尚未提交的修改,如果 B 回滚了,A 读到的就是从未存在过的“脏数据”。

模拟场景:

时间线    事务A(查询余额)         事务B(转账)
─────────────────────────────────────────────────
T1       BEGIN                     
T2                                  BEGIN
T3                                  UPDATE account SET balance = balance - 100 WHERE id = 1;
T4       SELECT balance FROM account WHERE id = 1;
         → 读到 900(事务B还没提交!)
T5                                  ROLLBACK(转账失败,回滚)
T6       → 余额实际上是 1000,但 A 读到的是 900
         → A 基于这个错误数据做了业务决策(比如判断余额<1000就发警告)
         → 警告发了,但余额其实没变 ← 脏读!

代码模拟:

// 事务A:查询余额(READ_UNCOMMITTED 隔离级别下会出现脏读)
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public void checkBalance() {
    Integer balance = accountMapper.getBalance(1L);  // T4: 读到 900(脏数据)
    if (balance < 1000) {
        alertService.sendLowBalanceAlert();  // 发了不该发的警告
    }
}

// 事务B:转账(还没提交就回滚了)
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public void transfer() {
    accountMapper.deduct(1L, 100);  // T3: 扣了100但没提交
    // 模拟业务异常
    throw new RuntimeException("转账失败");  // T5: 回滚,余额恢复1000
}

危害: 基于不存在的数据做决策,比如给用户发了不该发的通知、做了不该做的扣款。

解决: 提升隔离级别到 READ_COMMITTED 及以上。


② 不可重复读(Non-Repeatable Read)—— 同一行数据两次读不一样

定义: 事务 A 两次读取同一行数据,中间事务 B 修改并提交了这行数据,导致 A 两次读到的值不同。

模拟场景:

时间线    事务A(查询余额)         事务B(修改余额)
─────────────────────────────────────────────────
T1       BEGIN
T2       SELECT balance FROM account WHERE id = 1;
         → 读到 1000(第一次读)
T3                                  BEGIN
T4                                  UPDATE account SET balance = 500 WHERE id = 1;
T5                                  COMMIT(修改已提交)
T6       SELECT balance FROM account WHERE id = 1;
         → 读到 500(第二次读,变了!)
         → 同一事务中两次读同一行结果不同 ← 不可重复读!

代码模拟:

@Transactional(isolation = Isolation.READ_COMMITTED)  // Oracle/PostgreSQL 默认
public void checkBalanceTwice() {
    Integer balance1 = accountMapper.getBalance(1L);  // 第一次读:1000
    // ... 一些业务处理 ...
    Integer balance2 = accountMapper.getBalance(1L);  // 第二次读:500(被别的事务改了)
    // balance1 != balance2,业务逻辑可能出错
    if (balance1.equals(balance2)) {
        // 本以为余额没变,结果变了
        doSomething();  // 执行了不该执行的逻辑
    }
}

危害: 同一事务中基于多次读取的数据做比较/计算时,结果不可靠。

解决: 提升隔离级别到 REPEATABLE_READ 及以上。


③ 幻读(Phantom Read)—— 范围查询条数变了

定义: 事务 A 两次执行相同的范围查询,中间事务 B 新增或删除了符合条件的记录并提交,导致 A 两次查询的结果条数不同。

模拟场景:

时间线    事务A(统计订单数)       事务B(新增订单)
─────────────────────────────────────────────────
T1       BEGIN
T2       SELECT COUNT(*) FROM orders WHERE amount > 100;
         → 10 条(第一次查)
T3                                  BEGIN
T4                                  INSERT INTO orders(amount) VALUES(200);
T5                                  COMMIT(新增已提交)
T6       SELECT COUNT(*) FROM orders WHERE amount > 100;
         → 11 条(第二次查,多了1条"幻影"记录)
         → 同一事务中范围查询条数变了 ← 幻读!

代码模拟:

@Transactional(isolation = Isolation.READ_COMMITTED)
public void countBigOrders() {
    int count1 = orderMapper.countByAmount(100);  // 第一次:10条
    // ... 业务处理 ...
    int count2 = orderMapper.countByAmount(100);  // 第二次:11条(别的事务插入了一条)
    // count1 != count2,统计结果不一致
}

不可重复读 vs 幻读的区别:

维度 不可重复读 幻读
针对操作 UPDATE/DELETE(修改已有行) INSERT/DELETE(新增/删除行)
针对粒度 同一行数据内容变了 范围查询的条数变了
解决方式 行级锁(共享锁) 间隙锁(Gap Lock)/ MVCC

解决: MySQL InnoDB 的 REPEATABLE_READ 通过 MVCC + Next-Key Lock 基本解决了幻读问题。


四种隔离级别总览

隔离级别 脏读 不可重复读 幻读 性能 说明
READ_UNCOMMITTED ⚠️ 可能 ⚠️ 可能 ⚠️ 可能 最高 几乎不用,能看到未提交数据
READ_COMMITTED ✅ 不可能 ⚠️ 可能 ⚠️ 可能 较高 Oracle/PostgreSQL 默认,每次读都获取最新已提交数据
REPEATABLE_READ ✅ 不可能 ✅ 不可能 ✅ InnoDB基本不会 中等 MySQL 默认,MVCC 保证同一事务多次读结果一致
SERIALIZABLE ✅ 不可能 ✅ 不可能 ✅ 不可能 最低 完全串行化,事务排队执行,性能差

Spring 中设置隔离级别:

@Transactional(isolation = Isolation.READ_COMMITTED)
public void queryOrder() {}

Spring 隔离级别枚举与数据库对应关系:

Spring Isolation 对应数据库级别 说明
DEFAULT 使用数据库默认 MySQL → REPEATABLE_READ,Oracle → READ_COMMITTED
READ_UNCOMMITTED READ UNCOMMITTED 最低隔离
READ_COMMITTED READ COMMITTED Oracle/PG 默认
REPEATABLE_READ REPEATABLE READ MySQL 默认
SERIALIZABLE SERIALIZABLE 最高隔离

MySQL InnoDB 如何解决幻读?(面试加分项)

MySQL InnoDB 在 REPEATABLE_READ 级别下,通过两个机制基本解决了幻读:

1. 快照读(普通 SELECT)—— MVCC 解决

-- 普通查询走 MVCC,读到的是事务开始时的快照
SELECT * FROM orders WHERE amount > 100;  -- 即使别的事务插入了新数据,这里读到的还是旧快照

原理: 每行数据维护多个版本(undo log),事务第一次查询时生成一个 Read View(读视图),后续相同查询复用这个 Read View,保证多次读取结果一致。

2. 当前读(加锁查询)—— Next-Key Lock 解决

-- 加锁查询走当前读,会对查询范围加 Next-Key Lock(Record Lock + Gap Lock)
SELECT * FROM orders WHERE amount > 100 FOR UPDATE;  -- 锁住 amount > 100 的范围
-- 此时别的事务想 INSERT amount=200 的记录会被阻塞,直到锁释放

Next-Key Lock = Record Lock(行锁)+ Gap Lock(间隙锁):

  • Record Lock:锁住已有行的索引
  • Gap Lock:锁住索引之间的“间隙”,防止新数据插入
  • 合在一起就是 Next-Key Lock,锁住一个左开右闭区间

⚠️ 注意: 快照读和当前读混用时仍可能出现“幻读”现象:

@Transactional  // REPEATABLE_READ
public void demo() {
    // 快照读:读到 10 条
    List<Order> orders1 = orderMapper.selectList(...);  // 10条

    // 当前读:读到 11 条(别的事务插入了一条已提交)
    Order order = orderMapper.selectByIdForUpdate(999L);  // 能查到新插入的

    // 再快照读:还是 10 条
    List<Order> orders2 = orderMapper.selectList(...);  // 10条
    // orders1 和 orders2 一致(MVCC),但 selectByIdForUpdate 看到了新数据 → 混用导致的不一致
}

@Transactional 失效的 8 种场景(面试必背)

// ❌ 1. 方法非 public(动态代理只能拦截 public)
@Transactional
private void updateOrder() {}  // 不生效

// ❌ 2. 同类内部方法调用(this 调用不经过代理)
@Service
public class OrderService {
    public void methodA() {
        this.methodB();  // 直接 this 调用,不经过代理,事务失效
    }
    @Transactional
    public void methodB() {}
}
// ✅ 修复方式1:注入自己
// @Autowired private OrderService self;  self.methodB();
// ✅ 修复方式2:使用 AopContext.currentProxy()
// ((OrderService) AopContext.currentProxy()).methodB();
// ✅ 修复方式3:将 methodB 拆到另一个 Service 中

// ❌ 3. 异常被 catch 吞掉
@Transactional
public void createOrder() {
    try {
        // 业务逻辑
    } catch (Exception e) {
        log.error("出错了", e);  // 异常被吞,事务不回滚
    }
}
// ✅ 修复:catch 后手动 throw 或手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()

// ❌ 4. 抛出非 RuntimeException(默认只回滚 RuntimeException 和 Error)
@Transactional
public void createOrder() throws Exception {
    throw new Exception("检查异常");  // 不回滚!
}
// ✅ 修复:@Transactional(rollbackFor = Exception.class)

// ❌ 5. 数据库不支持事务(如 MyISAM 引擎)
// MySQL InnoDB 支持事务,MyISAM 不支持

// ❌ 6. 传播行为配置不当(如 NOT_SUPPORTED)
@Transactional
public void createOrder() {
    logService.saveLog();  // saveLog 配了 NOT_SUPPORTED → 挂起当前事务,无事务执行
}

// ❌ 7. Bean 没有被 Spring 管理(没加 @Service 等)
// 普通 new 出来的对象,不经过 Spring 代理,事务不生效

// ❌ 8. 多线程调用(不同线程不在同一事务)
@Transactional
public void createOrder() {
    new Thread(() -> {
        orderMapper.insert(order);  // 新线程,不在当前事务中
    }).start();
}
// 原因:Spring 事务通过 ThreadLocal 绑定数据库连接,不同线程拿不到同一个连接

面试题

Q1:@Transactional 注解的底层原理是什么?

Spring 基于 AOP 实现,调用事务方法时实际调用的是代理对象的方法。代理在方法执行前:通过 TransactionInterceptor 调用 TransactionManager 开启事务 → 执行目标方法 → 正常返回则提交、异常则回滚。核心入口是 TransactionInterceptor(实现了 MethodInterceptor),底层依赖 PlatformTransactionManager(如 DataSourceTransactionManager)来管理 JDBC 连接的 commit()rollback()。事务通过 TransactionSynchronizationManager 使用 ThreadLocal 将数据库连接绑定到当前线程。

Q2:@Transactional(rollbackFor = Exception.class) 为什么要加 rollbackFor?

Spring 事务默认只在抛出 RuntimeExceptionError 时回滚。如果业务代码抛出的是受检异常(如 IOExceptionSQLException),默认不会回滚。通过 rollbackFor = Exception.class 可以让所有异常都触发回滚。

Q3:说说七种传播行为,各自适用什么场景?

  • REQUIRED(默认):有事务加入,没有就新建。适合绝大多数业务方法。
  • REQUIRES_NEW:总是新开独立事务,外层挂起。适合日志/审计——即使主业务回滚,日志也要保存。
  • NESTED:基于 Savepoint 的嵌套子事务,子回滚不影响父,父回滚连带子。适合批量操作中部分失败容错。
  • SUPPORTS:有事务就加入,没有就非事务运行。适合纯查询方法。
  • NOT_SUPPORTED:非事务运行,有事务就挂起。适合耗时操作,避免长时间占用连接。
  • MANDATORY:必须在事务中运行,没有就报错。用于保护核心写操作。
  • NEVER:禁止在事务中运行,有事务就报错。用于调用外部接口防止连接被占满。

Q4:REQUIRES_NEW 和 NESTED 有什么区别?

维度 REQUIRES_NEW NESTED
事务数量 两个独立事务 一个事务 + 保存点
数据库连接 占用 2 个 占用 1 个
子事务提交后父事务回滚 子事务不回滚 子事务跟着回滚
子事务回滚 父事务不受影响 父事务不受影响
底层实现 新开 Connection,独立 commit/rollback JDBC Savepoint

简单记忆:REQUIRES_NEW 是“两个完全独立的人”,NESTED 是“父子关系——子出事不影响父,父出事全家遭殃”。

Q5:脏读、不可重复读、幻读分别是什么?怎么解决?

  • 脏读:读到了别人未提交的数据,别人回滚后这数据就不存在了。解决:提升到 READ_COMMITTED。
  • 不可重复读:同一事务中两次读同一行,中间别人修改并提交了,结果不同。解决:提升到 REPEATABLE_READ。
  • 幻读:同一事务中两次范围查询,中间别人新增/删除了记录,结果条数不同。解决:MySQL InnoDB 的 REPEATABLE_READ 通过 MVCC(快照读)+ Next-Key Lock(当前读)基本解决。

Q6:MySQL 默认隔离级别是什么?为什么不用 SERIALIZABLE?

MySQL InnoDB 默认是 REPEATABLE_READ。不用 SERIALIZABLE 是因为 SERIALIZABLE 完全串行化执行事务,性能极差,并发度极低。InnoDB 在 REPEATABLE_READ 级别下通过 MVCC + Next-Key Lock 已经解决了脏读、不可重复读和大部分幻读问题,在数据一致性和性能之间取得了最佳平衡。大多数业务场景下 REPEATABLE_READ 就够用了。

Q7:MySQL InnoDB 在 REPEATABLE_READ 下是怎么解决幻读的?

两种机制配合:

  1. 快照读(普通 SELECT):通过 MVCC(多版本并发控制),事务第一次查询时生成 Read View,后续查询复用同一 Read View,始终读取事务开始时的快照数据,不受其他事务插入的影响。
  2. 当前读(SELECT … FOR UPDATE / UPDATE / DELETE):通过 Next-Key Lock(Record Lock + Gap Lock)锁住查询范围,阻止其他事务在范围内插入新数据。

⚠️ 但快照读和当前读混用时仍可能出现不一致(不严格叫“幻读”),所以不是 100% 完全消除。

Q8:你在项目里怎么用的事务?说一下传播行为的实际应用。

参考回答(结合简历项目): “在神州数码托管系统里,创建银行指令时需要同时写指令表、更新头寸表、记录操作日志。核心操作用默认的 REQUIRED 保证原子性。但操作日志用 REQUIRES_NEW 单独开事务——这样即使指令创建失败回滚了,失败日志也能保存下来用于事后排查。另外批量导入指令时,每条指令的导入用 NESTED 传播行为,单条失败回滚到保存点不影响其他指令,导入完成后统一汇总成功/失败条数。”


1.5 全局异常处理与配置体系

全局异常处理

@RestControllerAdvice  // @ControllerAdvice + @ResponseBody
@Slf4j
public class GlobalExceptionHandler {

    // 处理业务异常
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusiness(BusinessException e) {
        log.warn("业务异常: {}", e.getMessage());
        return Result.fail(e.getCode(), e.getMessage());
    }

    // 处理参数校验异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Void> handleValid(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
            .map(f -> f.getField() + ": " + f.getDefaultMessage())
            .collect(Collectors.joining("; "));
        return Result.fail(400, msg);
    }

    // 兜底:处理所有未捕获异常
    @ExceptionHandler(Exception.class)
    public Result<Void> handleAll(Exception e) {
        log.error("系统异常", e);
        return Result.fail(500, "系统繁忙,请稍后重试");
    }
}

多环境配置

# application.yml(公共配置)
spring:
  profiles:
    active: dev  # 默认激活 dev 环境

# application-dev.yml(开发环境)
server:
  port: 8080
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/dev_db

# application-prod.yml(生产环境)
server:
  port: 80
spring:
  datasource:
    url: jdbc:mysql://prod-mysql:3306/prod_db
# 启动时切换环境
java -jar app.jar --spring.profiles.active=prod

面试题

Q1:@Configuration 和 @Component 的区别?

@Configuration 标注的类是“全模式配置类”(proxyBeanMethods=true),Spring 会用 CGLIB 生成代理类,保证 @Bean 方法调用时返回的是容器中的单例 Bean。@Component 是“轻量模式”(proxyBeanMethods=false),@Bean 方法之间的调用会创建新对象。

@Configuration  // 全模式:myBean() 返回容器中的单例
public class AppConfig {
    @Bean
    public A a() { return new A(b()); }  // b() 返回容器中已有的 B
    @Bean
    public B b() { return new B(); }
}

二、Spring Cloud Alibaba 全家桶

你简历的使用场景: 零碳能源云平台微服务底座(Nacos + Sentinel),项目四社区平台(Spring Cloud + RocketMQ)

面试官心理: “你说搭建了微服务底座,Nacos 注册发现原理讲讲?Sentinel 限流怎么做的?”

2.1 Nacos 服务注册与配置中心

小白讲解

Nacos = Naming and Configuration Service,干两件事:

  1. 服务注册发现(黄页电话簿):每个微服务启动时到 Nacos 登记“我叫 order-service,住在 192.168.1.10:8080”,消费方需要调用时查黄页获取地址
  2. 动态配置管理(小区公告栏):配置统一放 Nacos 管理,改了配置不用重启服务,自动推送

服务注册原理

服务提供者启动流程:
1. 服务启动 → Nacos Client 发起注册请求
2. Nacos Server 收到 → 写入注册表(ConcurrentHashMap)
3. Server 返回成功 → Client 开启心跳任务(5 秒一次)
4. Client 定时发送心跳 → Server 更新实例最后心跳时间
5. 超过 15 秒无心跳 → Server 标记为不健康
6. 超过 30 秒无心跳 → Server 从注册表移除

服务消费者流程:
1. 启动时拉取服务列表 → 本地缓存
2. 定时(10 秒)拉取最新服务列表 → 更新本地缓存
3. 调用时从本地缓存获取实例列表 → 负载均衡选一个

临时实例 vs 持久实例

维度 临时实例(默认) 持久实例
注册方式 Client 主动注册 Server 端 API 注册
心跳 Client 定时发 Server 主动探测(TCP HTTP)
下线 30 秒无心跳自动移除 需主动删除
适用 微服务应用 数据库、缓存等基础设施

代码示例

# application.yml
spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
        namespace: dev          # 命名空间隔离
        group: DEFAULT_GROUP
      config:
        server-addr: 127.0.0.1:8848
        file-extension: yaml    # 配置文件后缀
        namespace: dev
// 服务注册(自动完成,加注解即可)
@SpringBootApplication
@EnableDiscoveryClient
public class OrderServiceApp {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApp.class, args);
    }
}

// 服务发现 + 负载均衡调用
@RestController
public class OrderController {

    @LoadBalanced  // 开启负载均衡
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }

    @Autowired
    private RestTemplate restTemplate;

    @GetMapping("/order/{id}")
    public String getOrder(@PathVariable Long id) {
        // 用服务名替代 IP:Port,Ribbon 自动负载均衡
        return restTemplate.getForObject(
            "http://user-service/user/" + id, String.class);
    }
}

配置动态刷新

@RestController
@RefreshScope  // 配置变更后自动刷新
public class ConfigController {

    @Value("${order.timeout:3000}")
    private int timeout;  // Nacos 改了配置,这里自动更新

    @GetMapping("/timeout")
    public int getTimeout() {
        return timeout;
    }
}

面试题

Q1:Nacos 的注册表是怎么存储的?

Nacos Server 使用 ConcurrentHashMap 双层结构存储注册表:

  • 外层 Key = namespace@@group@@serviceName(如 public@@DEFAULT_GROUP@@order-service
  • 外层 Value = 内层 ConcurrentHashMap
  • 内层 Key = ip:port,Value = Instance 对象(包含 IP、端口、权重、元数据、心跳时间等)

使用 ConcurrentHashMap 保证并发安全,读多写少场景下性能好。Nacos 还通过 CopyOnWrite 和异步队列实现服务变更通知。

Q2:Nacos 是 AP 还是 CP?

Nacos 默认是 AP 模式(临时实例),通过 Distro 协议实现最终一致。也支持切换为 CP 模式(持久实例),通过 Raft 协议实现强一致。

AP 模式:服务节点数据不需要强一致,可用性优先(注册快、查询快) CP 模式:配置数据需要强一致,一致性优先

微服务场景一般用 AP(可用性比强一致更重要),配置管理用 CP(配置不能出错)。

Nacos 配置层级模型(面试重点)

Nacos 配置管理采用三层隔离模型:

Namespace(命名空间)—— 环境隔离(dev / test / prod)
  └── Group(分组)—— 业务/项目隔离(DEFAULT_GROUP / ORDER_GROUP / PAY_GROUP)
       └── Data ID(配置集)—— 具体配置文件(order-service.yaml / order-service-dev.yaml)
            └── 配置内容(YAML / Properties / JSON)

命名空间设计建议:

命名空间 用途 说明
dev 开发环境 开发人员共用,配置随意改
test 测试环境 测试团队使用,配置需审批
prod 生产环境 生产配置,严格权限控制
public(默认) 公共配置 所有环境共享的配置

Data ID 命名规则:

${prefix}-${spring.profiles.active}.${file-extension}

# 示例:
order-service-dev.yaml    → 开发环境配置
order-service-prod.yaml   → 生产环境配置
order-service.yaml        → 默认配置(无 profile 时加载)

配置加载优先级(从高到低):

1. ${prefix}-${profile}.${extension}    # 精确匹配 profile
2. ${prefix}.${extension}               # 无 profile 的默认配置
3. application.yml(本地)              # 本地配置兜底

Nacos 集群架构

                    ┌─────────────────────────────┐
                    │        Nacos Cluster         │
                    │                              │
    ┌───────────────┼──────────┬──────────┬────────┼───────────────┐
    │               │          │          │        │               │
    ▼               ▼          ▼          ▼        ▼               ▼
┌────────┐    ┌────────┐  ┌────────┐  ┌────────┐  ┌────────┐  ┌────────┐
│ Node 1 │◄──►│ Node 2 │◄►│ Node 3 │◄►│ Node 4 │◄►│ Node 5 │  │ MySQL  │
│(Leader)│    │(Follower│  │(Follower│  │(Follower│  │(Follower│  │ (集群)  │
│  Raft  │    │  Raft  │  │  Raft  │  │  Distro│  │  Distro│  │        │
└────────┘    └────────┘  └────────┘  └────────┘  └────────┘  └────────┘
    │               │          │          │           │
    │     Raft 协议(CP)       │    Distro 协议(AP)    │
    │  配置数据 / 持久实例       │    临时服务实例           │
    │  过半写成功才算写入        │    各节点独立处理,异步同步  │
    └──────────────────────────└──────────┴───────────┘

两种数据同步协议对比:

维度 Distro(AP) Raft(CP)
数据类型 临时实例(服务注册) 配置数据 / 持久实例
一致性 最终一致 强一致
选举 无 Leader,各节点平等 有 Leader,过半选举
写入 任意节点可写,异步同步 只有 Leader 可写,过半确认
可用性 节点挂了不影响注册 Leader 挂了需重新选举(短暂不可用)
场景 服务发现(可用性优先) 配置管理(一致性优先)

Nacos 2.x 长连接机制

Nacos 2.x 用 gRPC 长连接替代了 1.x 的 HTTP 短连接:

维度 Nacos 1.x(HTTP) Nacos 2.x(gRPC)
连接方式 HTTP 短连接 + 定时心跳 gRPC 长连接(双向流)
心跳 Client 每 5 秒发 HTTP 心跳 连接保持即代表存活,无需心跳
配置变更 Client 每 30 秒长轮询拉取 Server 主动推送(毫秒级)
资源消耗 连接频繁创建销毁,CPU 高 长连接复用,资源消耗低
故障感知 15-30 秒才发现实例下线 连接断开立即感知(秒级)
// Nacos 2.x 自动使用 gRPC,无需额外配置
// 只需确保依赖版本 >= 2.x
spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
        # 2.x 自动走 gRPC(端口 9848,由 8848+1000 自动计算)

实战:多环境配置隔离

# bootstrap.yml(必须用 bootstrap,优先级高于 application)
spring:
  application:
    name: order-service
  profiles:
    active: dev  # 当前环境
  cloud:
    nacos:
      config:
        server-addr: 192.168.1.100:8848
        namespace: ${NACOS_NAMESPACE:dev}  # 环境变量注入命名空间 ID
        group: ORDER_GROUP
        file-extension: yaml
        # 共享配置(多个微服务共用的配置)
        shared-configs:
          - data-id: common-db.yaml        # 数据库公共配置
            group: COMMON_GROUP
            refresh: true                  # 支持动态刷新
          - data-id: common-redis.yaml     # Redis 公共配置
            group: COMMON_GROUP
            refresh: true
        # 扩展配置(优先级低于主配置)
        extension-configs:
          - data-id: order-special.yaml
            group: ORDER_GROUP
            refresh: true

配置加载优先级(从高到低):

主配置 (order-service-dev.yaml)
  > 扩展配置 (extension-configs,按数组顺序,后面的优先级高)
    > 共享配置 (shared-configs)
      > application.yml(本地)

Nacos 配置灰度发布(面试高频)

什么是灰度发布?

灰度发布 = 改了配置后,先让一部分机器生效,观察没问题再逐步扩大到全部机器

类比:不是一次性把灯全打开,而是先开一盏,看看会不会跳闸,没问题再全开。

假设你有 10 台机器(IP: 192.168.1.101 ~ 110),要把超时时间从 3000ms 改成 5000ms:

  • 不灰度:直接改 Nacos 配置 → 10 台同时生效 → 如果新配置有问题,10 台全炸
  • 灰度:先只让 101、102 两台生效 → 观察 30 分钟没问题 → 再推给全部 10 台 → 有问题只有 2 台受影响

方式一:Nacos 自带 Beta 发布(最简单,不改代码)

在 Nacos 控制台操作:

1. 进入配置管理 → 编辑配置
2. 修改超时时间: 3000 → 5000
3. 不点"发布",点"Beta 发布"
4. 填入灰度 IP: 192.168.1.101, 192.168.1.102
5. 点击确认

此时:
  192.168.1.101 → 收到新配置 (timeout=5000)  ← 灰度机器
  192.168.1.102 → 收到新配置 (timeout=5000)  ← 灰度机器
  192.168.1.103~110 → 仍然用旧配置 (timeout=3000)  ← 正式机器

6. 观察 30 分钟,101 和 102 运行正常
7. 点击"正式发布" → 全部 10 台生效

适用场景:临时验证某个配置改动,不需要改代码,操作完即走。

方式二:Group 隔离(最常用,适合多环境管理)

在 Nacos 中创建两个 Group,灰度机器和正式机器读不同 Group:

Namespace: prod
├── Group: DEFAULT_GROUP     → 正式配置(8 台机器读这里)
│   └── order-service.yaml   → timeout: 3000
└── Group: GRAY_GROUP        → 灰度配置(2 台机器读这里)
    └── order-service.yaml   → timeout: 5000

正式机器的 application.yml

spring:
  cloud:
    nacos:
      config:
        server-addr: 192.168.1.50:8848
        namespace: prod
        group: DEFAULT_GROUP          # 正式组
        data-id: order-service.yaml

灰度机器的 application.yml(只改 group):

spring:
  cloud:
    nacos:
      config:
        server-addr: 192.168.1.50:8848
        namespace: prod
        group: GRAY_GROUP             # 灰度组
        data-id: order-service.yaml

灰度流程:

1. 在 Nacos 中创建 GRAY_GROUP,复制 DEFAULT_GROUP 的配置
2. 修改 GRAY_GROUP 中的 timeout: 3000 → 5000
3. 192.168.1.101 和 102 重启(或热刷新)→ 读取 GRAY_GROUP 配置
4. 其他 8 台不受影响,仍然读 DEFAULT_GROUP
5. 验证 OK → 把 GRAY_GROUP 的配置同步到 DEFAULT_GROUP
6. 101 和 102 改回 DEFAULT_GROUP

适用场景:灰度周期较长(几天甚至几周),需要独立管理灰度配置。

方式三:配置开关 + 代码判断(最灵活,秒级回滚)

在 Nacos 配置里加一个开关,代码里根据开关决定走新逻辑还是旧逻辑:

Nacos 配置 order-service.yaml

feature:
  new-timeout:
    enabled: false          # 默认关闭,所有机器走旧逻辑
    timeout-ms: 5000        # 新超时时间

代码监听配置变更:

@RestController
@RefreshScope
public class OrderConfig {

    @Value("${feature.new-timeout.enabled:false}")
    private boolean newTimeoutEnabled;

    @Value("${feature.new-timeout.timeout-ms:3000}")
    private int newTimeoutMs;

    @Value("${order.timeout:3000}")
    private int defaultTimeout;

    // 对外暴露当前生效的超时时间
    public int getEffectiveTimeout() {
        return newTimeoutEnabled ? newTimeoutMs : defaultTimeout;
    }
}

灰度流程:

1. 所有 10 台机器 (101~110) 启动时读取 enabled=false → 全部走旧逻辑 (3000ms)

2. 在 Nacos 控制台修改配置:
   feature.new-timeout.enabled: false → true

   Nacos 2.x 通过 gRPC 长连接实时推送到全部 10 台机器

   @RefreshScope 自动刷新,newTimeoutEnabled 变成 true

3. 此时所有机器都走新逻辑 (5000ms)

   如果只想灰度 2 台?结合方式二:
   - GRAY_GROUP 的配置: enabled=true
   - DEFAULT_GROUP 的配置: enabled=false
   - 101、102 读 GRAY_GROUP → 走新逻辑
   - 103~110 读 DEFAULT_GROUP → 走旧逻辑

4. 出问题了?在 Nacos 把 enabled 改回 false → 秒级回滚,不用重新部署

适用场景:需要代码层面控制新旧逻辑切换,且要求秒级回滚能力。

三种方式对比

维度 Beta 发布 Group 隔离 配置开关
改代码 不需要 不需要 需要提前埋开关
操作方式 控制台点按钮 改机器配置的 group 改 Nacos 配置项
灰度粒度 按 IP 按机器组 全量或按 Group 组合
回滚速度 改回旧配置正式发布 切回 DEFAULT_GROUP 改 false,秒级
适用场景 临时验证 长周期灰度 新旧逻辑切换
生产推荐 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐

生产环境最常见的组合:Group 隔离 + 配置开关。Group 控制哪些机器是灰度,开关控制新旧逻辑切换,两者配合既能灰度又能秒级回滚。

组合实战:订单超时时间灰度调整

业务场景:原订单超时时间 3000ms,新需求要改成 5000ms + 新的失败重试逻辑。不能一次性切,先灰度 2 台验证。


第一步:Nacos 控制台创建两份配置

# Data ID: order-service.yaml
# Group: DEFAULT_GROUP  → 8 台正式机器读这个
order:
  timeout: 3000
  retry:
    enabled: false
    maxAttempts: 1
  feature:
    new-timeout-logic:
      enabled: false    # 正式机器:新逻辑关闭

# Data ID: order-service.yaml
# Group: GRAY_GROUP     → 2 台灰度机器读这个
order:
  timeout: 5000
  retry:
    enabled: true
    maxAttempts: 3
  feature:
    new-timeout-logic:
      enabled: true     # 灰度机器:新逻辑开启

两个 Data ID 相同,但 Group 不同——Nacos 允许同名配置存在于不同 Group 中。


第二步:机器配置(决定读哪个 Group)

# === 192.168.1.101、192.168.1.102(灰度机器)application.yml ===
spring:
  cloud:
    nacos:
      config:
        server-addr: 192.168.1.50:8848
        namespace: prod
        group: GRAY_GROUP          # ← 关键:读灰度配置
        file-extension: yaml

# === 192.168.1.103 ~ 110(正式机器)application.yml ===
spring:
  cloud:
    nacos:
      config:
        server-addr: 192.168.1.50:8848
        namespace: prod
        group: DEFAULT_GROUP        # ← 关键:读正式配置
        file-extension: yaml

第三步:业务代码(根据开关走新旧逻辑)

@RestController
@RefreshScope   // Nacos 配置变更后自动重建 Bean,timeout 和 enabled 实时更新
public class OrderController {

    @Value("${order.timeout:3000}")
    private int timeout;

    @Value("${order.retry.enabled:false}")
    private boolean retryEnabled;

    @Value("${order.retry.maxAttempts:1}")
    private int maxAttempts;

    @Value("${order.feature.new-timeout-logic.enabled:false}")
    private boolean newLogicEnabled;   // 灰度开关

    @GetMapping("/order/{id}")
    public Result<Order> queryOrder(@PathVariable Long id) {
        if (newLogicEnabled) {
            // —— 新逻辑(灰度机器走这里)——
            // 超时 5000ms + 失败重试 3 次
            return orderService.queryWithRetry(id, timeout, maxAttempts);
        } else {
            // —— 旧逻辑(正式机器走这里)——
            // 超时 3000ms + 不重试
            return orderService.query(id, timeout);
        }
    }
}

第四步:灰度发布流程

时间轴                  操作                                  效果
─────────────────────────────────────────────────────────────────────────
T+0min    Nacos 控制台创建 GRAY_GROUP 配置                    灰度配置就绪
T+0min    101、102 机器启动(配了 group: GRAY_GROUP)           2 台读到新配置
T+0min    103~110 机器不受影响                                 8 台仍用旧配置

T+0~30min 观察 101、102 的日志、监控、错误率                    灰度验证

          情况A:没问题 ✅
          → 把 GRAY_GROUP 的配置同步到 DEFAULT_GROUP
          → 8 台正式机器秒级收到推送,新逻辑全部生效
          → 灰度完成

          情况B:有问题 ❌(比如错误率上升)
          → 把 GRAY_GROUP 里 enabled 改成 false
          → 101、102 秒级切回旧逻辑
          → 不用重启、不用重新部署、不用改代码
          → 排查问题后再次开启

第五步:如果灰度验证通过,全量切换

在 Nacos 控制台把 DEFAULT_GROUP 的配置改成和 GRAY_GROUP 一样:

# DEFAULT_GROUP / order-service.yaml(修改后)
order:
  timeout: 5000              # 3000 → 5000
  retry:
    enabled: true             # false → true
    maxAttempts: 3            # 1 → 3
  feature:
    new-timeout-logic:
      enabled: true           # false → true

8 台正式机器通过 Nacos 长连接秒级收到推送,@RefreshScope 自动重建 Bean,newLogicEnabled 变成 true——全量生效。

为什么用组合而不是单独用一种?

  • 单用 Group 隔离:灰度机器和正式机器配置不同,但代码里没有开关,切回旧逻辑要改配置再推送,不够快
  • 单用配置开关:所有机器读同一份配置,改 enabled=true 后 10 台同时生效,没有灰度过程
  • 组合用:Group 控制灰度范围(先 2 台),开关控制逻辑切换(秒级开关),两者配合 = 既安全又快速

代码监听配置变更(通用)

无论哪种方式,代码侧都可以监听配置变更做自定义处理:

// 方式1:@NacosConfigListener(Nacos 原生注解)
@NacosConfigListener(dataId = "order-service.yaml", groupId = "ORDER_GROUP")
public void onConfigChange(String config) {
    log.info("配置变更: {}", config);
    // 重新初始化连接池等
    refreshDataSource();
}

// 方式2:@RefreshScope + @Value(Spring Cloud 原生,最常用)
@RestController
@RefreshScope  // 配置变更后自动重建 Bean
public class OrderController {

    @Value("${order.timeout:3000}")
    private int timeout;  // Nacos 配置变更后,这个值会自动更新

    @GetMapping("/order/{id}")
    public Result getOrder(@PathVariable Long id) {
        // 直接用最新的 timeout 值,无需重启
        return orderService.query(id, timeout);
    }
}

// 方式3:ConfigService 主动监听(最底层,灵活度最高)
@PostConstruct
public void initListener() throws NacosException {
    ConfigService configService = NacosFactory.createConfigService(
        "192.168.1.50:8848"
    );
    configService.addListener("order-service.yaml", "ORDER_GROUP",
        new Listener() {
            @Override
            public void receiveConfigInfo(String config) {
                log.info("收到配置变更: {}", config);
                // 手动解析 JSON 并刷新本地缓存
                refreshLocalConfig(config);
            }

            @Override
            public Executor getExecutor() {
                return Executors.newSingleThreadExecutor();
            }
        }
    );
}

面试题

Q3:Nacos 和 Eureka 的区别?

维度 Nacos Eureka
CAP AP + CP 可切换 AP
注册方式 Client 推送 + Server 推拉 Client 推送
健康检查 心跳 + TCP/HTTP 探测 心跳
配置中心 支持 不支持
一致性协议 Distro(AP) + Raft(CP) P2P 复制
社区活跃度 活跃(阿里维护) 停更(2.x 后闭源)

Q4:Nacos 集群中一个节点宕机了会怎样?

分两种情况:

临时实例(Distro/AP): 每个节点都保存全量数据,任意节点宕机不影响注册和发现。Client 会自动重连其他健康节点。

配置数据(Raft/CP): 如果宕机的是 Follower,不影响读写(Leader 还在)。如果宕机的是 Leader,Raft 会触发重新选举(随机超时机制,150-300ms),选举期间集群短暂不可写(约 1-2 秒),但读不受影响。选举完成后恢复。

生产环境建议至少 3 个节点,保证 Raft 协议的过半机制可用(3 节点容忍 1 个宕机,5 节点容忍 2 个)。

Q5:Nacos 的配置变更怎么推送到客户端?

Nacos 1.x: 客户端通过长轮询(Long Polling)拉取配置。客户端发起 HTTP 请求,Server 端 hold 住 30 秒,如果期间配置没变就 30 秒后返回空,如果变了立即返回变更的 Data ID。客户端再发请求拉取具体配置内容。

Nacos 2.x: 使用 gRPC 长连接,Server 端配置变更后直接通过 gRPC 流推送到客户端,延迟从 30 秒降到毫秒级。

Q6:Namespace、Group、Data ID 怎么设计?

  • Namespace: 按环境隔离,dev / test / prod / uat
  • Group: 按业务线或项目隔离,ORDER_GROUP / PAY_GROUP / COMMON_GROUP
  • Data ID: 按微服务隔离,服务名-profile.后缀,如 order-service-prod.yaml

公共配置(数据库、Redis 等所有服务共用的)放 COMMON_GROUP,通过 shared-configs 引用。

Q7:Nacos 2.x 为什么用 gRPC 替代 HTTP?

三个核心原因:

  1. 降低资源消耗: HTTP 短连接频繁创建/销毁,大量心跳请求导致 CPU 和文件描述符消耗高;gRPC 基于长连接,连接复用,资源消耗低
  2. 提升实时性: 1.x 长轮询有 30 秒延迟,2.x gRPC 流式推送毫秒级到达
  3. 统一协议: 2.x 统一用 gRPC 做注册发现和配置推送,简化了实现

Q8:Nacos 怎么做配置灰度发布?

我们生产环境用的是 Group 隔离 + 配置开关 的组合方式。

假设有 10 台机器(192.168.1.101 ~ 110),要把超时时间从 3000ms 改成 5000ms:

  1. 在 Nacos 的 prod 命名空间下创建 GRAY_GROUP,复制 DEFAULT_GROUP 的配置并修改 timeout=5000
  2. 灰度机器(101、102)的 application.yml 配置 group: GRAY_GROUP,其余 8 台仍读 DEFAULT_GROUP
  3. 配置里同时埋一个开关 feature.new-timeout.enabled,GRAY 组设 true,DEFAULT 组设 false
  4. 代码用 @RefreshScope + @Value 监听开关,true 走新逻辑,false 走旧逻辑
  5. 观察 30 分钟没问题 → 把 GRAY_GROUP 配置同步到 DEFAULT_GROUP → 全部生效
  6. 出问题?改回 enabled=false 秒级回滚,不用重新部署

如果只是临时验证一个小改动,也可以用 Nacos 控制台自带的 Beta 发布——直接指定灰度 IP 列表(如 192.168.1.101,192.168.1.102),不用改代码也不用改 Group。

Q9:@RefreshScope 的原理是什么?配置变更后 Bean 是怎么更新的?

@RefreshScope 会使 Bean 被代理,代理对象持有一个 RefreshScopedProxyBean。当 Nacos 配置变更时:

  1. Spring Cloud 收到 RefreshEvent 事件
  2. ContextRefresher 调用 Environment.getPropertySources() 更新配置源
  3. 广播 EnvironmentChangeEvent,所有 @ConfigurationProperties 的 Bean 自动重新绑定
  4. @RefreshScope 的 Bean 被标记为 stale(过期)
  5. 下次访问该 Bean 时,代理对象检测到过期 → 销毁旧实例 → 创建新实例(用最新配置值)

注意:@RefreshScope 只对 @ConfigurationProperties@Value 生效,@NacosConfigListener 是 Nacos 自己的监听机制,不走 Spring Cloud 的 Refresh 流程。


2.2 Sentinel 流控熔断降级(简历项目三直接写了)

小白讲解

Sentinel 就像地铁站的客流控制系统

  • 流控(限流):地铁站高峰期限制每分钟进多少人——超过阈值就排队等候或直接拒绝
  • 熔断:某条线路频繁出故障,暂时关掉这条线让大家改乘——下游服务异常率达到阈值,暂时停止调用
  • 降级:高峰期关掉部分非核心功能(如关掉电梯只走楼梯)——非核心功能超时或异常时返回兜底数据
  • 热点参数限流:某个大V的帖子流量太大,单独限制——针对特定参数值限流
  • 系统自适应限流:根据整台机器的 CPU、Load 自动调整——地铁站根据整体拥挤程度自动限流

核心概念

概念 说明 类比
资源(Resource) 需要保护的代码/方法/接口 地铁入口
规则(Rule) 流控/熔断/降级/系统规则 限流策略
槽位(Slot) SlotChain 处理链,每个槽负责一类功能 安检流程的每道关卡
上下文(Context) 调用链上下文,标识一次调用入口 乘客的行程信息
Entry 资源访问的入口,每次访问创建 进站刷卡

Sentinel 处理链(SlotChain)——核心架构

一个请求进来,依次经过这些 Slot(每个 Slot 负责一件事):

  请求 → ┌──────────────────────────────────────────────────────┐
         │  NodeSelectorSlot    → 构建 Context 树(调用链路)      │
         │  ClusterBuilderSlot  → 构建集群节点(统计来源维度的数据)  │
         │  StatisticSlot       → 实时统计 QPS / RT / 异常数       │
         │  AuthoritySlot       → 黑白名单授权检查                  │
         │  SystemSlot          → 系统自适应限流(CPU/Load)       │
         │  FlowSlot            → 流控规则检查(QPS / 线程数)     │
         │  DegradeSlot         → 熔断降级检查(慢调用/异常)      │
         └──────────────────────┬───────────────────────────────┘

                          通过 → 执行业务方法
                          拦截 → 抛出 BlockException

一、流控规则(FlowRule)——最常用

1.1 两种限流阈值类型

// ─── 方式一:QPS 限流(每秒请求数)───
// 场景:保护接口不被打爆,适合突发流量
FlowRule qpsRule = new FlowRule();
qpsRule.setResource("queryOrder");
qpsRule.setGrade(RuleConstant.FLOW_GRADE_QPS);   // 按 QPS 统计
qpsRule.setCount(100);                             // 阈值 100 QPS
// 含义:queryOrder 接口每秒最多允许 100 次请求

// ─── 方式二:线程数限流 ───
// 场景:保护线程池不被打满,适合慢调用场景
FlowRule threadRule = new FlowRule();
threadRule.setResource("createOrder");
threadRule.setGrade(RuleConstant.FLOW_GRADE_THREAD);  // 按线程数统计
threadRule.setCount(10);                               // 阈值 10 个线程
// 含义:createOrder 同时执行的线程数不超过 10
// 区别:QPS 限流看"请求频率",线程数限流看"并发占用"
//       如果接口 RT 很短(5ms),QPS=200 也只占 1 个线程
//       如果接口 RT 很长(5s),QPS=2 就可能占 10 个线程
QPS 限流 vs 线程数限流 怎么选?

  接口 RT 短(< 100ms)→ QPS 限流(频率控制即可,线程不会堆积)
  接口 RT 长(> 1s)  → 线程数限流(防止线程池被慢调用占满)
  不确定               → QPS 限流(Sentinel 默认)

  实际项目中:
    查询接口 → QPS 限流
    写入/计算密集型接口 → 线程数限流

1.2 三种流控行为(ControlBehavior)——面试重点

// ─── 行为一:直接拒绝(快速失败,默认)───
// 超过阈值的请求直接抛出 FlowException
FlowRule directRule = new FlowRule();
directRule.setResource("queryOrder");
directRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
directRule.setCount(100);
directRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT);  // 0 = 直接拒绝
// 效果:第 101 个请求直接被拒绝,返回 "系统繁忙"

// 场景示例:支付接口 QPS 限流 500
//   QPS <= 500 → 正常处理
//   QPS > 500  → 直接返回 "支付系统繁忙,请稍后重试"
//   适合:大多数接口,简单粗暴有效

// ─── 行为二:预热(Warm Up,冷启动)───
// 系统刚启动时,缓存还没热,直接放大量请求会打垮 DB
// 预热模式:从 阈值/3 开始, gradually 上升到设定阈值
FlowRule warmUpRule = new FlowRule();
warmUpRule.setResource("queryOrder");
warmUpRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
warmUpRule.setCount(100);
warmUpRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_WARM_UP);  // 1 = 预热
warmUpRule.setWarmUpPeriodSec(10);  // 预热时长 10 秒
// 效果:
//   0-3 秒:阈值 = 100/3 ≈ 33 QPS
//   3-7 秒:阈值逐渐上升
//   10 秒后:阈值 = 100 QPS

// 场景示例:应用启动后的秒杀活动
//   服务刚启动,Redis 缓存为空,DB 压力大
//   预热模式让 QPS 从 33 慢慢升到 100,给缓存填充时间
//   适合:需要预热的场景(缓存冷启动、DB 连接池初始化)

// ─── 行为三:匀速排队(漏桶算法)───
// 请求不会直接拒绝,而是排队匀速通过
FlowRule queueRule = new FlowRule();
queueRule.setResource("createOrder");
queueRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
queueRule.setCount(100);
queueRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER);  // 2 = 匀速排队
queueRule.setMaxQueueingTimeMs(5000);  // 最大排队时间 5 秒
// 效果:
//   瞬时来 500 个请求 → 不拒绝,排队匀速通过
//   每秒处理 100 个 → 500 个需要 5 秒处理完
//   超过 5 秒还没轮到的请求 → 才被拒绝

// 场景示例:消息消费、批量任务提交
//   突发 1000 条消息涌入 → 不拒绝,匀速消费
//   适合:可以容忍延迟但不能丢数据的场景(削峰填谷)
三种流控行为对比:

  行为        | 超出阈值的请求    | 适用场景              | 类比
  ─────────────────────────────────────────────────────────────
  直接拒绝    | 立即返回错误       | 大多数接口保护         | 地铁限流,满了不让进
  预热        | 初期限流,后期放开 | 冷启动/缓存预热        | 地铁刚开门,慢慢放人进
  匀速排队    | 排队等待,超时才拒 | 削峰填谷/消息消费      | 排队安检,匀速通过

1.3 流控来源(limitApp)——针对调用方限流

// 场景:order-service 同时被 app-web 和 app-admin 调用
// 需求:app-web 限流 200 QPS,app-admin 限流 50 QPS

FlowRule webRule = new FlowRule();
webRule.setResource("queryOrder");
webRule.setLimitApp("app-web");   // 只针对 app-web 来源
webRule.setCount(200);

FlowRule adminRule = new FlowRule();
adminRule.setResource("queryOrder");
adminRule.setLimitApp("app-admin");  // 只针对 app-admin 来源
adminRule.setCount(50);

FlowRule defaultRule = new FlowRule();
defaultRule.setResource("queryOrder");
defaultRule.setLimitApp("default");   // 其他来源
defaultRule.setCount(100);

// 来源标识:调用方在请求头中传递(Sentinel 通过 RequestOriginParser 解析)
@Component
public class CustomOriginParser implements RequestOriginParser {
    @Override
    public String parseOrigin(HttpServletRequest request) {
        return request.getHeader("app-name");  // 从请求头获取来源标识
    }
}

1.4 流控效果——关联限流和链路限流

// ─── 关联限流:A 被限流时,限制 B ───
// 场景:查询接口和写入接口操作同一个表
//       写入接口慢了 → 限制查询接口,保护 DB
FlowRule relationRule = new FlowRule();
relationRule.setResource("queryOrder");      // 限流的目标
relationRule.setStrategy(RuleConstant.STRATEGY_RELATE);  // 关联模式
relationRule.setRefResource("createOrder");   // 关联的资源
relationRule.setCount(100);
// 含义:当 createOrder 的 QPS 超过 100 时,限制 queryOrder

// ─── 链路限流:只限制从某个入口进来的调用 ───
// 场景:queryOrder 被 /api/order 和 /api/admin 两个入口调用
//       只想限制 /api/order 的调用
FlowRule chainRule = new FlowRule();
chainRule.setResource("queryOrder");
chainRule.setStrategy(RuleConstant.STRATEGY_CHAIN);  // 链路模式
chainRule.setRefResource("api_order");  // 入口资源名
chainRule.setCount(100);
// 含义:只有从 api_order 入口调用 queryOrder 时才限流

二、熔断降级规则(DegradeRule)——面试重点

2.1 三种熔断策略

// ─── 策略一:慢调用比例(RT)───
// 场景:下游服务变慢,不能让所有请求都等
DegradeRule slowRule = new DegradeRule();
slowRule.setResource("queryOrder");
slowRule.setGrade(CircuitBreakerStrategy.SLOW_REQUEST_RATIO.getType());  // 慢调用比例
slowRule.setCount(500);              // 最大容忍 RT = 500ms(超过 500ms 算慢调用)
slowRule.setSlowRatioThreshold(0.5); // 慢调用比例阈值 = 50%
slowRule.setMinRequestAmount(5);     // 至少 5 个请求才统计
slowRule.setStatIntervalMs(10000);   // 统计窗口 10 秒
slowRule.setTimeWindow(10);          // 熔断时长 10 秒
// 触发条件:10 秒内至少 5 个请求,且慢调用(RT > 500ms)比例超过 50%
// 触发效果:熔断 10 秒,期间所有请求直接返回降级

// 场景示例:你简历中的托管系统调用清算接口
//   清算接口正常 RT = 200ms
//   某天清算系统性能下降,RT 飙到 800ms
//   10 秒内来了 20 个请求,12 个超过 500ms(60% > 50%)
//   → 熔断 10 秒,不再调用清算接口,返回 "清算服务繁忙"

// ─── 策略二:异常比例 ───
// 场景:下游服务报错率飙升
DegradeRule errorRatioRule = new DegradeRule();
errorRatioRule.setResource("queryOrder");
errorRatioRule.setGrade(CircuitBreakerStrategy.ERROR_RATIO.getType());  // 异常比例
errorRatioRule.setCount(0.5);             // 异常比例阈值 50%
errorRatioRule.setMinRequestAmount(5);    // 至少 5 个请求
errorRatioRule.setStatIntervalMs(10000);  // 统计窗口 10 秒
errorRatioRule.setTimeWindow(10);         // 熔断 10 秒
// 触发条件:10 秒内至少 5 个请求,异常比例超过 50%
// 触发效果:熔断 10 秒

// 场景示例:调用第三方天气 API
//   平时成功率 99.9%
//   天气服务挂了,10 秒内 20 个请求 12 个报错(60% > 50%)
//   → 熔断 10 秒,期间返回缓存的天气数据

// ─── 策略三:异常数 ───
// 场景:对错误零容忍的核心接口
DegradeRule errorCountRule = new DegradeRule();
errorCountRule.setResource("paymentCallback");
errorCountRule.setGrade(CircuitBreakerStrategy.ERROR_COUNT.getType());  // 异常数
errorCountRule.setCount(3);               // 异常数阈值 3
errorCountRule.setMinRequestAmount(5);
errorCountRule.setStatIntervalMs(60000);  // 统计窗口 60 秒
errorCountRule.setTimeWindow(30);         // 熔断 30 秒
// 触发条件:60 秒内至少 5 个请求,异常数达到 3 个
// 触发效果:熔断 30 秒

// 场景示例:支付回调接口
//   60 秒内报错 3 次 → 直接熔断 30 秒
//   适合:错误敏感接口,少量错误就熔断保护

2.2 熔断器三种状态详解

                         满足熔断条件
                         (慢调用比例/异常比例/异常数 达标)
  ┌──────────────┐ ──────────────────────────────→ ┌──────────────┐
  │   CLOSED     │                                  │    OPEN      │
  │  (关闭状态)   │                                  │  (打开状态)   │
  │              │ ←────────────────────────────── │              │
  │  正常放行     │    半开探测成功 → 恢复 CLOSED      │  直接拒绝     │
  │  统计指标     │                                  │  执行降级     │
  └──────────────┘                                  └──────┬───────┘
       ↑                                                   │
       │                                          熔断超时(timeWindow)
       │                                                   │
       │                                                   ▼
       │                                           ┌──────────────┐
       │              探测请求成功 → CLOSED           │  HALF_OPEN   │
       └─────────────────────────────────────────── │  (半开状态)   │
                                                     │              │
                                                     │ 放行 1 个    │
                                                     │ 探测请求      │
                                                     └──────┬───────┘

                                                     探测请求失败


                                                     回到 OPEN
                                                     (重新计时)
三种状态详解:

  CLOSED(关闭):
    - 正常放行所有请求
    - 持续统计 RT、异常数、QPS
    - 当指标达到熔断条件时 → 切换到 OPEN

  OPEN(打开):
    - 直接拒绝所有请求(不调用下游)
    - 执行降级逻辑(fallback)
    - 等待 timeWindow 秒后 → 切换到 HALF_OPEN

  HALF_OPEN(半开):
    - 只放行 1 个探测请求
    - 探测成功 → 切换回 CLOSED(恢复正常)
    - 探测失败 → 切换回 OPEN(重新计时等待)
    - 目的:试探下游是否恢复,避免盲目放开导致二次崩溃

2.3 熔断恢复的实际时序示例

时间轴示例:慢调用比例熔断(阈值50%,RT>500ms算慢调用,熔断10秒)

T=0s    请求1 RT=200ms ✅
T=1s    请求2 RT=600ms ⚠️ 慢调用
T=2s    请求3 RT=800ms ⚠️ 慢调用
T=3s    请求4 RT=550ms ⚠️ 慢调用
T=4s    请求5 RT=700ms ⚠️ 慢调用
        → 5个请求,4个慢调用(80% > 50%)
        → 触发熔断!状态:CLOSED → OPEN

T=4s~14s  熔断期间(10秒)
        请求6~20 → 全部直接拒绝,返回降级数据
        不实际调用下游

T=14s   熔断超时 → 状态:OPEN → HALF_OPEN
        放行 1 个探测请求
        请求21 RT=300ms ✅ 探测成功
        → 状态:HALF_OPEN → CLOSED,恢复正常

如果 T=14s 探测请求 RT=900ms ⚠️
        → 探测失败
        → 状态:HALF_OPEN → OPEN,再熔断 10 秒

三、blockHandler vs fallback——面试必问

// @SentinelResource 有两个降级处理属性,容易混淆

@SentinelResource(
    value = "queryOrder",
    blockHandler = "queryOrderBlockHandler",   // 处理 Sentinel 规则触发的拦截
    fallback = "queryOrderFallback"             // 处理业务代码抛出的异常
)
@GetMapping("/order/{id}")
public Result<Order> queryOrder(@PathVariable Long id) {
    return Result.success(orderService.getById(id));
}

// ─── blockHandler:处理 Sentinel 拦截 ───
// 触发条件:流控、熔断、系统规则被触发
// 异常类型:BlockException(FlowException / DegradeException 等)
// 方法签名:与原方法一致,最后多一个 BlockException 参数
public Result<Order> queryOrderBlockHandler(Long id, BlockException ex) {
    if (ex instanceof FlowException) {
        return Result.fail(429, "请求太频繁,请稍后重试");
    }
    if (ex instanceof DegradeException) {
        return Result.fail(503, "服务降级中,请稍后重试");
    }
    return Result.fail(503, "系统繁忙");
}

// ─── fallback:处理业务异常 ───
// 触发条件:业务代码抛出异常(非 BlockException)
// 异常类型:Throwable
// 方法签名:与原方法一致,最后多一个 Throwable 参数
public Result<Order> queryOrderFallback(Long id, Throwable e) {
    log.error("查询订单异常, id={}", id, e);
    // 返回兜底数据(缓存/默认值)
    Order defaultOrder = new Order();
    defaultOrder.setId(id);
    defaultOrder.setStatus("未知");
    return Result.success(defaultOrder);
}
blockHandler vs fallback 对比:

  维度          | blockHandler              | fallback
  ──────────────────────────────────────────────────────────────
  触发条件      | Sentinel 规则拦截          | 业务代码抛异常
  异常类型      | BlockException            | Throwable(非 BlockException)
  典型场景      | QPS 超限、熔断打开          | NPE、DB超时、远程调用异常
  返回内容      | 提示性信息("系统繁忙")    | 兜底数据(缓存/默认值)
  优先级        | 高(先检查规则)            | 低(规则通过后才执行业务)

  执行顺序:
    请求进来
      → FlowSlot 检查流控 → 不通过 → blockHandler
      → DegradeSlot 检查熔断 → 不通过 → blockHandler
      → 都通过 → 执行业务方法
        → 业务方法抛异常 → fallback
        → 业务方法正常 → 返回结果

  特殊情况:如果同时配置了 blockHandler 和 fallback
    → 先判断是否被 Sentinel 拦截
    → 被拦截走 blockHandler(不走 fallback)
    → 没被拦截但业务报错 → 走 fallback

  还有一个 exceptionsToIgnore 属性:
    @SentinelResource(
        value = "queryOrder",
        fallback = "queryOrderFallback",
        exceptionsToIgnore = {BusinessException.class}  // 这些异常不触发 fallback
    )
    → 如果业务抛出 BusinessException,不走 fallback,直接抛给上层

四、热点参数限流(ParamFlowRule)——实战常用

// 场景:查询商品详情接口,大部分商品 QPS 正常
//       但某个爆款商品(如 iPhone)QPS 飙升,需要单独限制

@SentinelResource(value = "queryProduct", blockHandler = "queryProductBlockHandler")
@GetMapping("/product/{id}")
public Result<Product> queryProduct(@PathVariable Long id) {
    return Result.success(productService.getById(id));
}

// 热点参数限流规则
ParamFlowRule rule = new ParamFlowRule("queryProduct")
    .setParamIdx(0)           // 限流参数索引(第 0 个参数 = id)
    .setGrade(RuleConstant.FLOW_GRADE_QPS)
    .setCount(50);             // 默认 QPS 50

// 对特定参数值设置单独的阈值
ParamFlowItem item = new ParamFlowItem();
item.setObject("10086");       // 商品 ID = 10086(爆款)
item.setClassType(String.class.getName());
item.setCount(10);             // 爆款商品 QPS 只允许 10
rule.setParamFlowItemList(Collections.singletonList(item));

ParamFlowRuleManager.loadRules(Collections.singletonList(rule));

// 限流处理
public Result<Product> queryProductBlockHandler(Long id, BlockException ex) {
    return Result.fail(429, "商品 " + id + " 查询太频繁");
}
热点参数限流效果:

  /product/1001 → QPS 50(默认阈值)
  /product/1002 → QPS 50(默认阈值)
  /product/10086 → QPS 10(特例,爆款商品单独限流)
  /product/10087 → QPS 10(可以配多个特例)

  适用场景:
    - 商品详情页(爆款单独限流)
    - 用户主页(大V单独限流)
    - 文章详情(热文单独限流)

五、系统自适应限流(SystemRule)——全局保底

// 场景:不关心单个接口,根据整机指标自动限流
// Sentinel 会根据系统 Load、CPU 使用率、入口 QPS、入口 RT 自动判断

SystemRule cpuRule = new SystemRule();
cpuRule.setHighestCpuUsage(0.8);  // CPU 使用率超过 80% 触发限流

SystemRule loadRule = new SystemRule();
loadRule.setHighestSystemLoad(5.0);  // 系统 Load1 超过 5 触发(Linux)

SystemRule qpsRule = new SystemRule();
qpsRule.setQps(1000);  // 入口总 QPS 超过 1000 触发

SystemRule rtRule = new SystemRule();
rtRule.setAvgRt(200);   // 入口平均 RT 超过 200ms 触发

SystemRule threadRule = new SystemRule();
threadRule.setMaxThread(100);  // 入口总线程数超过 100 触发

SystemRuleManager.loadRules(Arrays.asList(cpuRule, loadRule, qpsRule, rtRule, threadRule));
系统自适应限流的特点:

  1. 不针对单个资源,而是针对整个应用入口
  2. 多维度指标组合判断(不是单一指标)
  3. 适合作为"最后一道防线"——前面的流控/熔断都没拦住时的兜底
  4. 仅支持 Linux(Load1 指标依赖 /proc/loadavg)

  实际使用建议:
    - 生产环境必须配 CPU 限流(0.75~0.85)
    - 不要只依赖系统规则,应与接口级流控配合使用

六、授权规则(AuthorityRule)——黑白名单

// 场景:某些接口只允许内部服务调用,外部 IP 禁止访问

AuthorityRule rule = new AuthorityRule();
rule.setResource("internalApi");
rule.setStrategy(AuthorityConstant.AUTH_WHITE);  // 白名单模式
rule.setLimitApp("app-web,app-admin");  // 只允许 app-web 和 app-admin 调用

// AUTH_WHITE = 白名单(只允许列表中的来源)
// AUTH_BLACK = 黑名单(禁止列表中的来源)

AuthorityRuleManager.loadRules(Collections.singletonList(rule));

七、Sentinel 控制台 + Nacos 持久化(生产标配)

# application.yml
spring:
  cloud:
    sentinel:
      transport:
        dashboard: localhost:8080  # Sentinel 控制台地址
        port: 8719                  # 客户端与控制台通信端口
      eager: true                   # 立即与控制台建立连接
      datasource:                   # 规则持久化到 Nacos
        flow:
          nacos:
            server-addr: localhost:8848
            dataId: ${spring.application.name}-flow-rules
            groupId: SENTINEL_GROUP
            rule-type: flow         # 规则类型
        degrade:
          nacos:
            server-addr: localhost:8848
            dataId: ${spring.application.name}-degrade-rules
            groupId: SENTINEL_GROUP
            rule-type: degrade
        param-flow:
          nacos:
            server-addr: localhost:8848
            dataId: ${spring.application.name}-param-flow-rules
            groupId: SENTINEL_GROUP
            rule-type: param-flow
生产环境架构:

  ┌─────────────┐     推送规则      ┌──────────────┐
  │ Sentinel    │ ───────────────→ │  应用实例 1   │
  │ Dashboard   │                   │  应用实例 2   │
  │ (可视化配置) │ ←─────────────── │  应用实例 N   │
  └──────┬──────┘    上报监控数据    └──────────────┘
         │                                    │
         │  规则持久化                          │  本地缓存规则
         ▼                                    │
  ┌─────────────┐                             │
  │   Nacos     │ ──── 规则变更推送 ──────────→│
  │ (规则存储)   │                             │
  └─────────────┘                             │

  工作流程:
    1. 运维在 Dashboard 配置流控/熔断规则
    2. 规则持久化到 Nacos
    3. Nacos 推送到所有应用实例
    4. 应用实例上报监控数据到 Dashboard
    5. 应用重启后从 Nacos 拉取规则(不会丢失配置)

八、完整实战示例(结合简历项目)

// 场景:零碳能源云平台的设备数据查询接口
// 需求:
//   1. 默认 QPS 限制 200
//   2. 某些高频设备单独限流
//   3. 下游 TDengine 慢了自动熔断
//   4. 熔断期间返回缓存数据

@RestController
@RequestMapping("/api/device")
@Slf4j
public class DeviceController {

    @Autowired
    private DeviceService deviceService;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 查询设备实时数据
     * - 流控:QPS 200,热点设备单独限流
     * - 熔断:慢调用比例(RT > 1s 算慢调用,比例 > 50% 熔断 10s)
     * - 降级:返回 Redis 缓存的最后一次数据
     */
    @SentinelResource(
        value = "queryDeviceData",
        blockHandler = "queryDeviceDataBlockHandler",
        fallback = "queryDeviceDataFallback"
    )
    @GetMapping("/data/{deviceId}")
    public Result<DeviceData> queryDeviceData(
            @PathVariable String deviceId,
            @RequestParam(required = false) String metric) {

        return Result.success(deviceService.queryRealtimeData(deviceId, metric));
    }

    /**
     * Sentinel 规则拦截处理(流控/熔断触发)
     */
    public Result<DeviceData> queryDeviceDataBlockHandler(
            String deviceId, String metric, BlockException ex) {

        if (ex instanceof FlowException) {
            log.warn("设备数据查询被限流, deviceId={}", deviceId);
            return Result.fail(429, "查询过于频繁,请稍后重试");
        }

        if (ex instanceof DegradeException) {
            log.warn("设备数据查询被熔断, deviceId={}", deviceId);
            // 熔断时返回缓存数据
            DeviceData cached = (DeviceData) redisTemplate.opsForValue()
                    .get("device:data:" + deviceId);
            if (cached != null) {
                return Result.success(cached);
            }
            return Result.fail(503, "数据服务暂时不可用");
        }

        return Result.fail(503, "系统繁忙");
    }

    /**
     * 业务异常兜底处理
     */
    public Result<DeviceData> queryDeviceDataFallback(
            String deviceId, String metric, Throwable e) {

        log.error("设备数据查询异常, deviceId={}", deviceId, e);

        // 返回缓存兜底
        DeviceData cached = (DeviceData) redisTemplate.opsForValue()
                .get("device:data:" + deviceId);
        if (cached != null) {
            return Result.success(cached);
        }

        // 缓存也没有,返回默认值
        DeviceData defaultData = new DeviceData();
        defaultData.setDeviceId(deviceId);
        defaultData.setStatus("unknown");
        defaultData.setTimestamp(System.currentTimeMillis());
        return Result.success(defaultData);
    }
}

// ─── 规则初始化 ───
@Configuration
public class SentinelRuleConfig {

    @PostConstruct
    public void initRules() {
        // 1. 流控规则:QPS 200,直接拒绝
        FlowRule flowRule = new FlowRule();
        flowRule.setResource("queryDeviceData");
        flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
        flowRule.setCount(200);
        flowRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT);

        // 2. 熔断规则:慢调用比例
        DegradeRule degradeRule = new DegradeRule();
        degradeRule.setResource("queryDeviceData");
        degradeRule.setGrade(CircuitBreakerStrategy.SLOW_REQUEST_RATIO.getType());
        degradeRule.setCount(1000);              // RT > 1000ms 算慢调用
        degradeRule.setSlowRatioThreshold(0.5);  // 慢调用比例 > 50%
        degradeRule.setMinRequestAmount(5);      // 至少 5 个请求
        degradeRule.setStatIntervalMs(10000);    // 10 秒统计窗口
        degradeRule.setTimeWindow(10);           // 熔断 10 秒

        // 3. 热点参数限流:高频设备单独限制
        ParamFlowRule paramRule = new ParamFlowRule("queryDeviceData")
                .setParamIdx(0)          // deviceId 是第 0 个参数
                .setGrade(RuleConstant.FLOW_GRADE_QPS)
                .setCount(200);           // 默认 QPS 200

        // 高频设备限流 20 QPS
        ParamFlowItem hotItem = new ParamFlowItem();
        hotItem.setObject("DEV-HOT-001");
        hotItem.setClassType(String.class.getName());
        hotItem.setCount(20);
        paramRule.setParamFlowItemList(Collections.singletonList(hotItem));

        // 加载规则
        FlowRuleManager.loadRules(Collections.singletonList(flowRule));
        DegradeRuleManager.loadRules(Collections.singletonList(degradeRule));
        ParamFlowRuleManager.loadRules(Collections.singletonList(paramRule));
    }
}

面试题

Q1:Sentinel 和 Hystrix 的区别?(高频)

维度 Sentinel Hystrix
隔离策略 信号量隔离(线程计数) 线程池隔离 / 信号量隔离
熔断策略 慢调用比例 / 异常比例 / 异常数 仅异常比例
限流策略 QPS / 线程数 + 预热 + 排队 不支持限流(仅熔断)
实时统计 滑动窗口(LeapArray) 滑动窗口
动态规则 支持(Nacos/ZK/Apollo 推送) 不支持(配置文件写死)
控制台 丰富(可视化 + 实时监控) 简陋(Hystrix Dashboard)
系统自适应 支持(CPU/Load/RT) 不支持
热点参数限流 支持 不支持
降级方式 blockHandler + fallback fallback
社区状态 活跃(阿里维护) 停更

Q2:Sentinel 滑动窗口原理?(高频)

Sentinel 用 LeapArray 实现滑动窗口。核心思想:把时间轴切成多个等长的 Bucket(样本窗口),每个 Bucket 记录该时间段的请求数、成功数、异常数、RT 等。统计时取当前时间往前推一个统计窗口大小的所有 Bucket,聚合计算。

例如:统计窗口 = 1 秒,样本窗口 = 200ms → 1 秒被分成 5 个 Bucket。统计时取当前时间所在的 Bucket + 前面 4 个 Bucket 聚合。随着时间推移,窗口平滑滑动,避免了固定窗口的边界突刺问题(固定窗口在窗口切换瞬间可能放行 2 倍阈值的请求)。

  固定窗口的问题(窗口边界突刺):

  |---窗口1(0-1s)---|---窗口2(1-2s)---|
  阈值=100           阈值=100

  如果 0.9s 来 100 个 + 1.1s 来 100 个
  → 两个窗口都没超阈值,但 0.2 秒内来了 200 个请求!

  滑动窗口解决:
  |---|---|---|---|---|
  0   0.2 0.4 0.6 0.8 1.0

  0.9s 时统计:取 0~1s 的 5 个 Bucket 聚合 = 100
  1.1s 时统计:取 0.2~1.2s 的 5 个 Bucket 聚合
              = 0.2~1.0 的 80 + 1.0~1.2 的 100 = 180 > 100 → 限流!

Q3:blockHandler 和 fallback 的区别?(高频)

  • blockHandler:处理 Sentinel 规则拦截(流控超限、熔断打开、系统限流),参数为 BlockException
  • fallback:处理业务代码抛出的异常(NPE、超时、远程调用异常等),参数为 Throwable
  • 执行优先级:先检查 Sentinel 规则 → 被拦走 blockHandler → 通过后执行业务 → 业务异常走 fallback
  • 实践:blockHandler 返回提示信息(“系统繁忙”),fallback 返回兜底数据(缓存/默认值)

Q4:Sentinel 的三种流控行为分别什么场景用?

行为 原理 场景
直接拒绝 超阈值直接拒绝 大多数接口,快速失败
预热 从阈值/3 逐渐升到阈值 冷启动(缓存未热/连接池未初始化)
匀速排队 漏桶算法,排队匀速通过 削峰填谷(消息消费/批量任务)

预热的原理:系统刚启动时处理能力低(缓存冷、JIT 未编译),直接放开阈值会打垮系统。预热模式让 QPS 从 threshold / coldFactor(默认 coldFactor=3)开始,在 warmUpPeriodSec 内逐渐升到 threshold。

Q5:Sentinel 熔断器的三种状态怎么流转?

  • CLOSED → OPEN:统计窗口内指标(慢调用比例/异常比例/异常数)达到阈值,且满足最小请求数
  • OPEN → HALF_OPEN:熔断超时(timeWindow 秒)后自动进入半开状态
  • HALF_OPEN → CLOSED:放行 1 个探测请求,成功则恢复正常
  • HALF_OPEN → OPEN:探测请求失败,重新进入熔断

关键点:HALF_OPEN 只放行 1 个探测请求,目的是试探下游是否恢复,避免盲目放开导致二次崩溃。

Q6:Sentinel 信号量隔离和 Hystrix 线程池隔离的区别?

  Hystrix 线程池隔离:
    每个资源用独立的线程池调用
    优点:资源之间完全隔离,一个资源耗尽不影响其他
    缺点:线程切换开销大;线程池数量多,资源占用高

  Sentinel 信号量隔离:
    通过计数器统计并发线程数,超限拒绝
    优点:无线程切换开销,性能好
    缺点:不能实现异步调用(在同一线程执行)

  实际选择:
    对性能要求高 → Sentinel 信号量(推荐,默认)
    需要严格隔离 → Hystrix 线程池(或 Sentinel + 线程池隔离)
    慢调用多的场景 → 线程池隔离(避免阻塞工作线程)

Q7:Sentinel 规则持久化到 Nacos 的原理?

Sentinel 默认将规则存储在内存中,应用重启后规则丢失。生产环境通过 ReadableDataSourceWritableDataSource 将规则持久化到 Nacos:

  1. Dashboard 修改规则 → 调用 Nacos DataSource 写入 Nacos 配置中心
  2. Nacos 配置变更 → 推送到所有应用实例的 ReadableDataSource
  3. 应用监听到配置变更 → 重新加载规则到内存
  4. 应用重启 → 从 Nacos 拉取规则恢复

配置方式:在 application.yml 中配置 spring.cloud.sentinel.datasource 指定 Nacos 的 dataId、groupId 和 rule-type。

Q8:Sentinel 热点参数限流的原理和使用场景?

原理:ParamFlowSlot 在流控检查时,不仅统计资源维度的 QPS,还按参数值分别统计。使用 ParamFlowStatistic 为每个参数值维护独立的计数器(基于 LRU Cache,避免参数值过多导致内存溢出)。

场景:

  • 商品详情页:大部分商品 QPS 正常,爆款商品单独限流
  • 用户主页:大 V 的主页流量远超普通用户
  • 文章详情:热文单独限流,避免影响其他文章的正常访问

2.3 Dubbo RPC 框架(简历技能清单写了)

小白讲解

Dubbo 像你叫外卖:

  • HTTP 调用 = 你亲自跑出去买(慢、开销大)
  • Dubbo RPC = 你跟餐厅建立了专线电话,直接说“来份红烧肉”(快、像本地方法调用)

RPC(Remote Procedure Call)= 远程过程调用,让你调用远程方法像调用本地方法一样自然。

Dubbo 调用流程

┌──────────┐         ┌──────────────────┐         ┌──────────┐
│ Consumer  │ ──1.   │  Registry(Nacos)  │  1.──→ │ Provider  │
│ (消费方)  │   注册  │  (注册中心)        │  注册   │ (提供方)  │
│           │ ←──2.   │                   │  ──2→ │           │
│           │   订阅  │                   │        │           │
│           │       └──────────────────┘        │           │
│           │ ───────────3. 直连调用(TCP)─────────→│           │
│           │ ←──────────4. 返回结果──────────────│           │
└──────────┘                                 ┌──────────┘

Dubbo 架构全貌

Dubbo 不只是“调远程方法”,它有一套完整的微服务治理体系:

                         ┌─────────────────────┐
                         │    Registry (Nacos)  │
                         │    注册中心           │
                         └──────┬───────┬──────┘
                    1.注册  │       │  2.订阅
                           ▼       ▼
┌──────────────────────────────────────────────────────────┐
│                     Consumer (消费方)                      │
│                                                           │
│  ┌─────────┐  ┌──────────┐  ┌──────────┐  ┌───────────┐ │
│  │  Proxy   │→│ Cluster  │→│  Router  │→│LoadBalance│ │
│  │ 代理层    │  │ 集群容错  │  │ 路由规则  │  │ 负载均衡   │ │
│  └─────────┘  └──────────┘  └──────────┘  └───────────┘ │
│       │                                          │       │
│       ▼                                          ▼       │
│  ┌─────────┐  ┌──────────┐  ┌──────────┐  ┌───────────┐ │
│  │  Filter  │→│ Protocol │→│Exchange  │→│ Transport │ │
│  │ 过滤器链   │  │ 协议层    │  │ 交换层    │  │ 传输层     │ │
│  └─────────┘  └──────────┘  └──────────┘  └───────────┘ │
│                                                           │
│  Proxy:     生成接口代理,让远程调用像本地方法               │
│  Cluster:   多个 Provider 时的容错策略                      │
│  Router:    从多个 Provider 中筛选出可调用的                 │
│  LoadBalance: 从可调用列表中选一个                          │
│  Filter:    调用前后做扩展(日志/监控/限流/鉴权)            │
│  Protocol:  封装调用格式(Dubbo/Triple/REST)              │
│  Exchange:  请求-响应模式封装                               │
│  Transport: 网络传输(Netty/MINA)                         │
└──────────────────────────────────────────────────────────┘

服务暴露(Provider 端)完整流程

1. Spring 启动 → 解析 @DubboService 注解
2. ServiceConfig.export() 触发暴露
3. 将接口信息封装成 URL(dubbo://192.168.1.10:20880/com.example.UserService?version=1.0.0)
4. 创建 Invoker(可执行体)
5. 通过 Protocol(默认 DubboProtocol)启动 Netty Server 监听端口
6. 将服务元数据注册到 Nacos(URL → Nacos Registry)
7. Exporter 缓存 Invoker,等待 Consumer 调用

服务引用(Consumer 端)完整流程

1. Spring 启动 → 解析 @DubboReference 注解
2. ReferenceConfig.get() 触发引用
3. 从 Nacos 订阅服务地址列表(可能返回多个 Provider URL)
4. 对每个 URL 创建一个 Invoker(远程代理)
5. 通过 Cluster 将多个 Invoker 包装成一个(容错 + 负载均衡)
6. 通过 ProxyFactory 创建接口代理对象
7. 注入到 @DubboReference 字段,调用时像本地方法一样使用

代码示例

// ============ Provider:服务提供方 ============

// 1. 接口定义(API 模块,Provider 和 Consumer 共享)
public interface UserService {
    User getUser(Long id);
    List<User> listUsers(UserQuery query);
}

// 2. 实现 + 暴露服务
@DubboService(
    version = "1.0.0",           // 版本号(灰度发布用)
    timeout = 3000,              // 超时 3 秒
    retries = 2,                 // 重试 2 次(不含首次调用)
    loadbalance = "roundrobin"   // 负载均衡策略
)
public class UserServiceImpl implements UserService {
    @Override
    public User getUser(Long id) {
        return userMapper.selectById(id);
    }

    @Override
    public List<User> listUsers(UserQuery query) {
        return userMapper.selectList(query);
    }
}

// ============ Consumer:服务消费方 ============

@RestController
public class OrderController {

    @DubboReference(
        version = "1.0.0",
        timeout = 3000,
        retries = 2,
        check = false,              // 启动时不检查 Provider 是否可用
        mock = "return null"        // Provider 不可用时返回 null(降级)
    )
    private UserService userService;

    @GetMapping("/order/{id}")
    public Order createOrder(@PathVariable Long id) {
        User user = userService.getUser(id);  // 看起来是本地调用,实际是远程 TCP
        return orderService.createOrder(user);
    }
}

Dubbo 六种负载均衡策略

// 在 @DubboService 或 @DubboReference 上指定 loadbalance
@DubboReference(loadbalance = "leastactive")
private UserService userService;
策略 名称 原理 适用场景
Random(默认) random 按权重随机 大多数场景,权重均匀时约等于轮询
RoundRobin roundrobin 按权重轮询 机器性能相近,请求均匀分配
LeastActive leastactive 选活跃数最少的(处理最少的) 机器性能不同,快的多处理
ConsistentHash consistenthash 一致性哈希,相同参数总是发到同一 Provider 需要会话保持(同一用户路由到同一节点)
ShortestResponse shortestresponse 选响应时间最短的(Dubbo 2.7+) 对延迟敏感的场景
P2C p2c 随机选两个,挑更好的那个(Dubbo 3.0+) 大规模集群,避免全节点扫描
// 权重配置(Provider 端)
@DubboService(weight = 100)  // 机器性能好,权重高
public class UserServiceImpl implements UserService { }

@DubboService(weight = 50)   // 机器性能差,权重低
public class UserServiceImpl implements UserService { }
// 100:50 = 2:1 的比例分配请求

Dubbo 六种集群容错策略

@DubboReference(cluster = "failover")  // 指定容错策略
private UserService userService;
策略 名称 行为 适用场景
Failover(默认) failover 失败自动切换到其他 Provider 重试 读操作(幂等),默认重试 2 次
Failfast failfast 快速失败,只调用一次,异常直接抛出 非幂等写操作(新增记录)
Failsafe failsafe 失败忽略,不抛异常,返回空结果 写日志、发通知等不重要的操作
Failback failback 失败后异步重试,不阻塞调用方 消息通知等最终一致性场景
Forking forking 并行调用多个 Provider,一个成功就返回 实时性要求极高的读操作
Broadcast broadcast 逐个调用所有 Provider,任一失败就算失败 刷新所有节点的本地缓存
Failover 流程(默认):
  调用 Provider A → 失败
  → 自动切换到 Provider B → 失败
  → 自动切换到 Provider C → 成功 → 返回结果
  (retries=2 表示最多重试 2 次,加上首次共 3 次调用)

⚠️ 注意:Failover 只适合幂等操作!非幂等操作重试会导致重复写入!

Dubbo SPI 机制(面试核心)

Dubbo 有一套自己的 SPI(Service Provider Interface),比 Java SPI 更强大:

对比维度 Java SPI Dubbo SPI
加载方式 一次性全加载 按需加载(用到哪个加载哪个)
配置文件 META-INF/services/接口全名 META-INF/dubbo/接口全名
内容格式 实现类全名 key=实现类全名
IOC 注入 不支持 支持(自动注入依赖)
AOP 包装 不支持 支持(自动包装 Wrapper)
自适应 不支持 支持(@Adaptive 运行时动态选择)
# META-INF/dubbo/org.apache.dubbo.rpc.Protocol
dubbo=org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol
triple=org.apache.dubbo.rpc.protocol.tri.TripleProtocol
rest=org.apache.dubbo.rpc.protocol.rest.RestProtocol
// Dubbo SPI 使用方式
Protocol protocol = ExtensionLoader.getExtensionLoader(Protocol.class)
    .getExtension("dubbo");  // 按 key 获取指定实现

// 自适应扩展(运行时根据 URL 参数决定用哪个实现)
@Adaptive("protocol")
public interface Protocol {
    // URL 中 protocol=dubbo 就用 DubboProtocol
    // URL 中 protocol=triple 就用 TripleProtocol
}

Dubbo Filter 过滤器链

Dubbo 的很多功能(监控、限流、日志、鉴权)都通过 Filter 实现:

// 自定义 Filter:记录每次 RPC 调用耗时
@Activate(group = {CommonConstants.PROVIDER, CommonConstants.CONSUMER})  // 生产者和消费者都生效
public class TimingFilter implements Filter, BaseFilter {

    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        long start = System.currentTimeMillis();
        try {
            return invoker.invoke(invocation);  // 放行,继续执行下一个 Filter
        } finally {
            long elapsed = System.currentTimeMillis() - start;
            String service = invocation.getServiceName();
            String method = invocation.getMethodName();
            log.info("[Dubbo] {}.{} 耗时 {}ms", service, method, elapsed);

            // 上报到监控系统
            Metrics.timer("dubbo.rpc.duration", "service", service, "method", method)
                   .record(elapsed, TimeUnit.MILLISECONDS);
        }
    }
}

# META-INF/dubbo/org.apache.dubbo.rpc.Filter
timing=com.example.filter.TimingFilter

Dubbo 内置 Filter(面试常问):

Filter 作用
ExceptionFilter 统一异常处理
TimeoutFilter 超时日志记录
MonitorFilter 监控数据采集
ContextFilter 传递 RPC 上下文(traceId 等)
AccessLogFilter 访问日志记录
TokenFilter Token 鉴权
TpsLimitFilter TPS 限流

Dubbo Triple 协议(Dubbo 3.0 核心特性)

Dubbo 3.0 引入了 Triple 协议,基于 HTTP/2 + gRPC:

对比 Dubbo 协议(2.x) Triple 协议(3.x)
传输层 TCP HTTP/2
序列化 Hessian2(私有二进制) Protobuf / Hessian2
跨语言 需要各语言 SDK 原生 gRPC 互通(Go/Python/Node)
流式调用 不支持 支持(Unary / Server Stream / Client Stream / BiStream)
穿透性 不能穿防火墙/网关 HTTP/2 可穿透标准网关
// Triple 流式调用示例
public interface UserService {
    // 普通调用(Unary)
    User getUser(Long id);

    // 服务端流(Server Stream):一次请求,多次响应
    void streamUsers(UserQuery query, StreamObserver<User> responseObserver);

    // 客户端流(Client Stream):多次请求,一次响应
    StreamObserver<User> batchSave(StreamObserver<Result> responseObserver);

    // 双向流(BiStream):多次请求多次响应
    StreamObserver<User> chat(StreamObserver<Message> responseObserver);
}

Dubbo 异步调用

// 方式一:CompletableFuture(Dubbo 2.7+ 推荐)
@DubboReference
private UserService userService;

public void asyncCall() {
    // 异步调用,不阻塞当前线程
    CompletableFuture<User> future = userService.getUserAsync(1L);

    // 做其他事情...
    doSomethingElse();

    // 需要结果时再获取
    future.thenAccept(user -> {
        System.out.println("拿到结果: " + user);
    });

    // 组合多个异步调用
    CompletableFuture<User> userFuture = userService.getUserAsync(1L);
    CompletableFuture<Order> orderFuture = orderService.getOrderAsync(1L);

    CompletableFuture.allOf(userFuture, orderFuture)
        .thenRun(() -> {
            User user = userFuture.join();
            Order order = orderFuture.join();
            // 两个都完成了
        });
}

// 方式二:接口直接返回 CompletableFuture
public interface UserService {
    CompletableFuture<User> getUserAsync(Long id);
}

application.yml 完整配置

dubbo:
  application:
    name: order-service
    version: 1.0.0
  registry:
    address: nacos://192.168.1.100:8848
    parameters:
      namespace: dev
      group: DUBBO_GROUP
  protocol:
    name: tri                    # Triple 协议(Dubbo 3.x)
    port: 50051                  # -1 表示随机端口
    threads: 200                 # 线程池大小
  provider:
    timeout: 3000                # 全局超时
    retries: 0                   # Provider 端不重试(Consumer 端控制)
    loadbalance: leastactive     # 全局负载均衡策略
    filter: timing,accesslog     # 全局 Filter
  consumer:
    timeout: 3000
    retries: 2                   # 读操作重试 2 次
    check: false                 # 启动不检查
    loadbalance: roundrobin

面试题

Q1:Dubbo 和 Feign 的区别?

维度 Dubbo Feign
协议 TCP(Dubbo 协议)/ HTTP/2(Triple) HTTP
性能 高(二进制序列化 + 长连接) 中(HTTP + JSON)
使用 @DubboReference @FeignClient
适用 内部服务间高频调用 对外 API、低频调用
生态 Spring Cloud Alibaba Spring Cloud Netflix
功能 SPI 扩展、集群容错、负载均衡、异步 仅声明式 HTTP 调用
跨语言 Triple 协议支持 理论上支持(HTTP)

Q2:Dubbo 支持哪些序列化方式?

序列化 性能 兼容性 说明
Hessian2(默认) 跨语言,Dubbo 默认
Kryo 一般 Java 专用,最快
FST 一般 Java 专用,比 Kryo 稍慢
JSON 可读性好,调试方便
Protobuf 跨语言,Triple 协议默认

Q3:Dubbo SPI 和 Java SPI 的区别?

  1. 按需加载: Java SPI 一次性加载所有实现类,即使不用也会加载(浪费资源);Dubbo SPI 按需加载,用到哪个加载哪个
  2. IOC 支持: Dubbo SPI 支持依赖注入,加载实现类时自动注入其依赖的其他扩展点
  3. AOP 包装: Dubbo SPI 支持 Wrapper 类自动包装,类似 Spring AOP,可以在扩展点前后加逻辑
  4. 自适应扩展: @Adaptive 注解让接口在运行时根据 URL 参数动态选择实现类
  5. 配置格式: Java SPI 是纯类名,Dubbo SPI 是 key=类名,可以按 key 获取

Q4:Dubbo 的 Failover 容错策略为什么不适合非幂等操作?

Failover 在调用失败后会自动切换到其他 Provider 重试。如果操作是非幂等的(如新增记录),第一次调用可能已经成功写入数据库,只是网络超时导致 Consumer 认为失败,重试就会写入重复数据。

非幂等操作应使用 Failfast 策略——只调用一次,失败直接抛异常,由业务层决定是否手动重试。

Q5:Dubbo 3.0 的 Triple 协议解决了什么问题?

  1. 跨语言互通: Dubbo 协议是私有二进制协议,其他语言无法直接调用;Triple 基于 gRPC,Go/Python/Node 等语言可以直接通过 gRPC 调用
  2. 网关穿透: Dubbo 协议走 TCP,无法穿透标准 HTTP 网关;Triple 走 HTTP/2,可以穿透 Nginx/K8s Ingress
  3. 流式调用: Dubbo 协议只支持请求-响应模式;Triple 支持 Server Stream / Client Stream / 双向流,适合实时推送、大文件传输等场景
  4. 生态对接: 可以直接和 gRPC 生态(Envoy、Istio)集成

Q6:Dubbo 服务调用超时了怎么排查?

  1. 确认超时位置: 看 Consumer 端日志,是 timeout 还是其他异常
  2. 看 Provider 端日志: 方法是否真的执行慢,还是根本没收到请求
  3. 网络排查: telnet Provider_IP Provider_Port 检查网络连通性
  4. 线程池满排查: Provider 端 Dubbo 线程池可能满了(日志会有 Thread pool is EXHAUSTED),查看 dubbo.protocol.threads 配置
  5. GC 排查: Provider 或 Consumer 可能 Full GC 导致 STW,看 GC 日志
  6. 合理设置超时: Consumer timeout 应大于 Provider timeout + 网络延迟

Q7:Dubbo 怎么做服务降级?

三种方式:

  1. mock 属性: @DubboReference(mock = "return null")——Provider 不可用时返回 null
  2. mock 类: 实现 UserServiceMock 类,自定义降级逻辑(返回缓存数据/默认值)
  3. Sentinel 集成: Dubbo + Sentinel,在 Consumer 端按 QPS/RT/异常率自动熔断降级

2.4 Spring Cloud Gateway(简历项目三提到了)

小白讲解

Gateway 是微服务的前台保安

  • 所有外部请求先到 Gateway
  • Gateway 根据 URL 路由到对应微服务
  • 可以在网关做鉴权、限流、日志

Gateway 核心架构

客户端请求


┌──────────────────────────────────────────────────────────────┐
│                    Spring Cloud Gateway                       │
│                                                               │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐      │
│  │ RoutePredicate│ → │ GatewayFilter│ → │ GatewayFilter│      │
│  │  Route信息     │    │  (前置Filter) │    │  (后置Filter) │      │
│  │  匹配路由      │    │  改请求头等   │    │  改响应头等   │      │
│  └─────────────┘    └─────────────┘    └─────────────┘      │
│         │                                       │             │
│         ▼                                       ▼             │
│  ┌─────────────────────────────────────────────────────┐     │
│  │              DispatcherHandler (核心分发器)           │     │
│  └──────────────────────┬──────────────────────────────┘     │
│                         │                                     │
│                         ▼                                     │
│  ┌─────────────────────────────────────────────────────┐     │
│  │           Global Filter (全局过滤器)                  │     │
│  │  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐│     │
│  │  │鉴权Filter │→│限流Filter│→│日志Filter │→│转发Filter││     │
│  │  └──────────┘ └──────────┘ └──────────┘ └─────────┘│     │
│  └─────────────────────────────────────────────────────┘     │
│                         │                                     │
│                         ▼                                     │
│                    Proxy Filter                               │
│                         │                                     │
└─────────────────────────┼─────────────────────────────────────┘
                          │  HTTP (负载均衡)

                   ┌──────────────┐
                   │ 后端微服务     │
                   │ (order-service│
                   │  pay-service) │
                   └──────────────┘

处理流程说明:

  1. 请求到达 Gateway,RoutePredicate 匹配路由规则
  2. 匹配成功后,请求依次经过 GatewayFilter(前置)→ GlobalFilterGatewayFilter(后置)
  3. GlobalFilterGatewayFilter 合并后按 @Order 排序执行
  4. 最后由 NettyRoutingFilter 将请求转发到后端微服务

核心概念

概念 说明 类比
Route 路由规则(= Predicate + Filter + URI) 访客指引牌
Predicate 断言(匹配条件),匹配成功才走路由 访客身份检查
Filter 过滤器(修改请求/响应) 安检 + 登记
GlobalFilter 全局过滤器(所有路由都执行) 门口必检项

Predicate 断言大全(面试必背)

spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://order-service
          predicates:
            # === 路径匹配 ===
            - Path=/api/order/**              # 路径匹配(最常用)

            # === HTTP 方法 ===
            - Method=GET,POST                 # 只允许 GET 和 POST

            # === 请求头 ===
            - Header=X-Request-Source,\d+     # 请求头存在且值匹配正则

            # === 请求参数 ===
            - Query=token                     # 必须有 token 参数
            - Query=userId,\d+                # userId 参数值为数字

            # === Host 匹配 ===
            - Host=*.example.com              # 域名匹配

            # === IP 匹配 ===
            - RemoteAddr=192.168.1.0/24       # IP 段匹配

            # === 时间匹配 ===
            - After=2025-01-01T00:00:00+08:00[Asia/Shanghai]   # 某时间后生效
            - Before=2025-12-31T23:59:59+08:00[Asia/Shanghai]  # 某时间前生效
            - Between=2025-01-01T00:00:00+08:00[Asia/Shanghai],2025-12-31T23:59:59+08:00[Asia/Shanghai]

            # === Cookie 匹配 ===
            - Cookie=session,abc123           # Cookie 名为 session 且值匹配正则

            # === 权重匹配(金丝雀发布)===
            - Weight=group1,90                # 90% 流量走这条路由

权重路由实战(灰度发布):

routes:
  # 90% 流量走 v1 版本
  - id: order-service-v1
    uri: lb://order-service-v1
    predicates:
      - Path=/api/order/**
      - Weight=order-group,90

  # 10% 流量走 v2 版本(灰度)
  - id: order-service-v2
    uri: lb://order-service-v2
    predicates:
      - Path=/api/order/**
      - Weight=order-group,10

Filter 过滤器大全

内置 GatewayFilter(路由级别)

routes:
  - id: order-service
    uri: lb://order-service
    filters:
      # === 路径操作 ===
      - StripPrefix=1          # /api/order/list → /order/list(去掉第一层)
      - PrefixPath=/api        # /order/list → /api/order/list(加前缀)
      - RewritePath=/api/(?<segment>.*), /$\{segment}  # 正则重写路径

      # === 请求头操作 ===
      - AddRequestHeader=X-Source,Gateway      # 加请求头
      - RemoveRequestHeader=X-Internal         # 删请求头
      - SetRequestHeader=X-Version,2.0         # 改请求头

      # === 响应头操作 ===
      - AddResponseHeader=X-Response-Time,500  # 加响应头
      - RemoveResponseHeader=X-Internal        # 删响应头

      # === 请求参数操作 ===
      - AddRequestParameter=source,gateway     # 加请求参数

      # === 状态码操作 ===
      - SetStatus=200                          # 强制设置响应状态码

      # === 重试 ===
      - name: Retry
        args:
          retries: 3                           # 重试次数
          statuses: BAD_GATEWAY,GATEWAY_TIMEOUT  # 哪些状态码才重试
          methods: GET                         # 只对 GET 重试

      # === 限流(需要配合 RequestRateLimiter)===
      - name: RequestRateLimiter
        args:
          redis-rate-limiter.replenishRate: 100   # 令牌填充速率 100/s
          redis-rate-limiter.burstCapacity: 200   # 令牌桶容量 200
          key-resolver: "#{@ipKeyResolver}"       # 限流 key(按 IP)

      # === 熔断(配合 CircuitBreaker)===
      - name: CircuitBreaker
        args:
          name: orderCircuitBreaker
          fallbackUri: forward:/fallback         # 熔断后转发到降级接口

自定义全局过滤器(实战核心)

/**
 * 鉴权过滤器 —— 所有请求必须携带有效 Token
 * 全局过滤器对所有路由生效
 */
@Component
@Order(-100)  // 数字越小优先级越高(先执行)
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // 白名单路径(不需要鉴权的)
    private static final List<String> WHITELIST = List.of(
        "/api/auth/login",
        "/api/auth/register",
        "/api/auth/refresh"
    );

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getURI().getPath();

        // 1. 白名单放行
        if (WHITELIST.stream().anyMatch(path::startsWith)) {
            return chain.filter(exchange);
        }

        // 2. 获取 Token
        String token = request.getHeaders().getFirst("Authorization");
        if (StringUtils.isBlank(token) || !token.startsWith("Bearer ")) {
            return unauthorized(exchange, "缺少认证信息");
        }
        token = token.substring(7);

        // 3. 校验 Token(从 Redis 获取)
        String userId = redisTemplate.opsForValue().get("token:" + token);
        if (userId == null) {
            return unauthorized(exchange, "Token 已过期");
        }

        // 4. 将用户信息透传给下游服务
        ServerHttpRequest mutatedRequest = request.mutate()
            .header("X-User-Id", userId)
            .header("X-Request-Id", UUID.randomUUID().toString())  // 链路追踪 ID
            .build();

        return chain.filter(exchange.mutate().request(mutatedRequest).build());
    }

    @Override
    public int getOrder() {
        return -100;  // 最高优先级(最先执行)
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange, String msg) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        String body = "{\"code\":401,\"message\":\"" + msg + "\"}";
        DataBuffer buffer = response.bufferFactory()
            .wrap(body.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }
}
/**
 * 日志过滤器 —— 记录每个请求的方法、路径、状态码、耗时
 */
@Component
@Order(-1)  // 低优先级(后执行),这样能记录到最终状态码
public class LoggingGlobalFilter implements GlobalFilter, Ordered {

    private static final Logger log = LoggerFactory.getLogger(LoggingGlobalFilter.class);

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String method = request.getMethod().name();
        String path = request.getURI().getPath();
        String clientIp = request.getRemoteAddress().getAddress().getHostAddress();
        long startTime = System.currentTimeMillis();

        return chain.filter(exchange).doFinally(signalType -> {
            long duration = System.currentTimeMillis() - startTime;
            HttpStatusCode statusCode = exchange.getResponse().getStatusCode();
            log.info("[Gateway] {} {} {} {} {}ms (IP: {})",
                method, path, statusCode, statusCode.value(), duration, clientIp);
        });
    }

    @Override
    public int getOrder() {
        return -1;
    }
}

Gateway + Sentinel 限流(生产标配)

/**
 * 网关层 Sentinel 限流配置
 * 比应用层限流更早拦截,保护后端所有微服务
 */
@Configuration
public class GatewaySentinelConfig {

    @PostConstruct
    public void init() {
        // 按 API 路径限流
        GatewayFlowRule rule1 = new GatewayFlowRule("order-service_route");
        rule1.setResourceMode(SentinelGatewayConstants.RESOURCE_MODE_ROUTE_ID);
        rule1.setGrade(RuleConstant.FLOW_GRADE_QPS);
        rule1.setCount(500);  // QPS 500

        // 按参数限流(热点参数)
        GatewayFlowRule rule2 = new GatewayFlowRule("order-service_route");
        rule2.setResourceMode(SentinelGatewayConstants.RESOURCE_MODE_ROUTE_ID);
        rule2.setGrade(RuleConstant.FLOW_GRADE_QPS);
        rule2.setCount(100);
        rule2.setParamItem(new GatewayParamItem()
            .setParseStrategy(SentinelGatewayConstants.PARAM_PARSE_STRATEGY_URL_PARAM)
            .setFieldName("productId"));  // 按 productId 参数限流

        GatewayRuleManager.loadRules(List.of(rule1, rule2));
    }

    // 自定义限流响应
    @PostConstruct
    public void initBlockHandler() {
        BlockRequestHandler handler = (exchange, t) -> {
            Map<String, Object> result = new HashMap<>();
            result.put("code", 429);
            result.put("message", "请求过于频繁,请稍后重试");
            return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS)
                .contentType(MediaType.APPLICATION_JSON)
                .body(BodyInserters.fromValue(result));
        };
        GatewayCallbackManager.setBlockHandler(handler);
    }
}

网关统一降级接口

/**
 * 熔断降级统一处理
 * 当后端服务不可用时,CircuitBreaker 会转发到 /fallback
 */
@RestController
public class FallbackController {

    @RequestMapping("/fallback")
    public Result<Object> fallback(ServerWebExchange exchange) {
        String path = exchange.getRequest().getURI().getPath();
        return Result.fail(503, "服务暂时不可用,请稍后重试。路径: " + path);
    }

    // 不同服务可以有不同的降级逻辑
    @RequestMapping("/fallback/order")
    public Result<Object> orderFallback() {
        return Result.fail(503, "订单服务繁忙,请稍后重试");
    }

    @RequestMapping("/fallback/pay")
    public Result<Object> payFallback() {
        return Result.fail(503, "支付服务繁忙,请稍后重试");
    }
}

完整 application.yml 配置

spring:
  cloud:
    gateway:
      # 路由配置
      routes:
        - id: order-service
          uri: lb://order-service
          predicates:
            - Path=/api/order/**
          filters:
            - StripPrefix=1
            - AddRequestHeader=X-Gateway,cloud-gateway
            - name: CircuitBreaker
              args:
                name: orderCircuitBreaker
                fallbackUri: forward:/fallback/order

        - id: pay-service
          uri: lb://pay-service
          predicates:
            - Path=/api/pay/**
          filters:
            - StripPrefix=1
            - name: CircuitBreaker
              args:
                name: payCircuitBreaker
                fallbackUri: forward:/fallback/pay

        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/api/user/**
          filters:
            - StripPrefix=1

      # 全局跨域配置
      default-filters:
        - name: CORS
      globalcors:
        cors-configurations:
          '[/**]':
            allowed-origins: "*"
            allowed-methods: "*"
            allowed-headers: "*"
            allow-credentials: false
            max-age: 3600

      # 服务发现配置
      discovery:
        locator:
          enabled: true          # 自动根据服务名创建路由
          lower-case-service-id: true  # 服务名转小写

# Sentinel 网关限流配置
spring.cloud.sentinel:
  transport:
    dashboard: 127.0.0.1:8858
  filter:
    enabled: false    # 关闭普通 Web 限流,只走网关限流
  scg:
    fallback:
      mode: response
      response-status: 429
      response-body: '{"code":429,"message":"请求过于频繁"}'

面试题

Q1:Gateway 和 Zuul 的区别?

维度 Gateway Zuul 1.x
框架 Spring WebFlux Servlet
模型 非阻塞(Netty) 阻塞
性能
社区 Spring 官方维护 Netflix 停更
长连接 支持(WebSocket) 不支持
限流 内置 RequestRateLimiter(Redis 令牌桶) 需自己实现
编程模型 响应式(Mono/Flux) 传统同步

Q2:Gateway 的 GlobalFilter 和 GatewayFilter 有什么区别?

维度 GatewayFilter GlobalFilter
作用范围 只对配置了该 Filter 的 Route 生效 对所有 Route 都生效
配置方式 在 routes 的 filters 中声明 实现 GlobalFilter 接口 + @Component
典型用途 路径重写、添加请求头等路由级操作 鉴权、日志、限流等全局操作
执行顺序 按声明顺序 按 getOrder() 返回值排序
合并执行 两者合并后统一按 Order 排序执行 同左

Q3:Gateway 为什么用 WebFlux 而不用传统 MVC?

Gateway 作为所有请求的入口,需要处理极高的并发。传统 Spring MVC 基于 Servlet(一个请求一个线程),在高并发下线程数会成为瓶颈。

WebFlux 基于 Reactor + Netty,使用少量线程处理大量连接(非阻塞 I/O)。一个线程可以服务成百上千的请求,不因某个后端服务慢而阻塞线程,天然适合网关场景。

Q4:Gateway 怎么做动态路由?

默认路由配置在 yml 中,改了需要重启。生产环境需要动态路由:

  1. 配合 Nacos: 路由配置存在 Nacos,监听配置变更自动刷新路由表
  2. 数据库 + 内存: 路由规则存数据库,启动时加载到内存,修改后通过接口触发刷新
  3. Redis: 路由规则存 Redis,监听 Key 变更刷新
// 动态路由核心:覆盖 RouteDefinitionRepository
@Component
public class NacosRouteDefinitionRepository implements RouteDefinitionRepository {

    @Override
    public Flux<RouteDefinition> getRouteDefinitions() {
        // 从 Nacos 读取路由配置
        return Flux.fromIterable(loadRoutesFromNacos());
    }

    // Nacos 配置变更时自动刷新
    @NacosConfigListener(dataId = "gateway-routes.yaml")
    public void onRoutesChange(String config) {
        List<RouteDefinition> routes = parseRoutes(config);
        // 通知 Gateway 刷新路由表
        routeDefinitionWriter.delete(Mono.just("")).subscribe();
        routes.forEach(r -> routeDefinitionWriter.save(Mono.just(r)).subscribe());
    }
}

Q5:Gateway 怎么解决跨域问题?

两种方式:

  1. 全局配置(推荐):spring.cloud.gateway.globalcors 中配置,对所有路由生效
  2. ** CorsFilter:** 注册一个 CorsWebFilter Bean

注意:网关配了跨域后,后端微服务就不要再配跨域了,否则会出现 Access-Control-Allow-Origin 重复的问题。

Q6:Gateway 调用后端服务超时怎么设置?

spring:
  cloud:
    gateway:
      httpclient:
        connect-timeout: 3000    # 连接超时 3 秒
        response-timeout: 30s    # 响应超时 30 秒
      # 也可以在具体路由上设置
      routes:
        - id: order-service
          uri: lb://order-service
          metadata:
            response-timeout: 5000   # 该路由响应超时 5 秒
            connect-timeout: 2000    # 该路由连接超时 2 秒

2.5 Seata 分布式事务(简历项目可能涉及,面试必问)

小白讲解

单体应用一个数据库,一个 @Transactional 搞定。微服务多个数据库,跨服务的 @Transactional 管不了别人的数据库——需要分布式事务。

生活类比:

你去银行转账,银行内部系统拆成了三个微服务:

  • 账户服务(账户库):扣你的钱
  • 流水服务(流水库):记一笔转账流水
  • 通知服务(通知库):发短信通知

扣钱成功了,流水也记了,但通知服务挂了——你的钱扣了但没收到短信。Seata 就是保证这三个操作要么全成功,要么全回滚。

Seata 三大角色

┌──────────────────────────────────────────────────────┐
│                    TC (Transaction Coordinator)        │
│                    事务协调器(独立部署)                 │
│                                                       │
│  - 维护全局事务和分支事务的状态                           │
│  - 驱动全局事务提交或回滚                               │
│  - 独立部署,所有 TM 和 RM 都连这个                     │
└──────────┬───────────────────────────────┬────────────┘
           │                               │
    1.开启全局事务                    3.注册分支事务
    2.获取 XID                        4.报告分支状态
           │                               │
           ▼                               ▼
┌──────────────────┐              ┌──────────────────┐
│  TM (Transaction  │              │  RM (Resource     │
│   Manager)        │              │   Manager)        │
│  事务管理器         │              │  资源管理器         │
│                  │              │                  │
│  - 决定全局事务     │              │  - 管理本地事务     │
│    开始/提交/回滚  │              │  - 注册分支事务    │
│  - 通常在发起方     │              │  - 报告事务状态    │
│    服务中          │              │  - 通常在参与方    │
└──────────────────┘              │    服务中          │
                                  │                  │
                                  │  每个数据库 = 1个RM │
                                  └──────────────────┘

完整调用流程:

1. TM 向 TC 申请开启全局事务 → TC 生成全局唯一的 XID
2. XID 通过服务调用链(Dubbo/Feign 的 Header)传播到下游服务
3. 下游服务的 RM 检测到 XID,将本地事务注册为分支事务
4. 各分支执行本地事务并提交(AT 模式一阶段直接提交)
5. TM 收到所有分支的执行结果,向 TC 发起全局提交或回滚
6. TC 通知所有 RM 执行第二阶段(提交删除 undo_log / 回滚用 undo_log 反向补偿)

四种模式对比

模式 原理 侵入性 性能 一致性 适用场景
AT(默认) 两阶段:一阶段拦截 SQL 自动记录 undo_log;二阶段成功删 log、失败用 undo_log 回滚 无侵入(只需加注解) 高(一阶段直接提交) 最终一致 大多数业务(推荐首选)
TCC Try-Confirm-Cancel,三个接口由业务自己实现 高(需写三套接口) 最终一致 需要细粒度控制(如资金扣减)
SAGA 长事务,每步有正向操作和补偿动作 中(需写补偿逻辑) 最终一致 流程长的业务(如订单流程)
XA 数据库 XA 协议,两阶段提交 无侵入 低(长时间锁资源) 强一致 强一致要求高

AT 模式详解(面试重点)

AT(Auto Transaction)是 Seata 默认模式,最大的优点是业务无感知——你只需要加一个 @GlobalTransactional 注解,Seata 自动帮你处理。

AT 模式一阶段流程

1. 拦截业务 SQL

2. 解析 SQL → 查询变更前数据(before image)
   SELECT * FROM account WHERE id = 1  →  before: {id:1, balance:1000}

3. 执行业务 SQL
   UPDATE account SET balance = balance - 100 WHERE id = 1

4. 查询变更后数据(after image)
   SELECT * FROM account WHERE id = 1  →  after: {id:1, balance:900}

5. 将 before image + after image + SQL 信息保存到 undo_log 表
   INSERT INTO undo_log (branch_id, xid, table_name, before_image, after_image) VALUES (...)

6. 向 TC 注册分支事务,并申请全局锁(防止其他全局事务同时修改该行)

7. 本地事务提交(undo_log 和业务数据在同一个本地事务中一起提交)

8. 向 TC 报告分支事务状态

AT 模式二阶段流程

=== 全局提交(所有分支都成功)===

1. TC 通知所有 RM 提交
2. RM 异步删除 undo_log 记录(非常快,几乎不耗时)
3. 释放全局锁

=== 全局回滚(有分支失败)===

1. TC 通知所有 RM 回滚
2. RM 拿到 XID 和 Branch ID,找到对应的 undo_log
3. 根据 undo_log 的 after_image 校验当前数据是否被修改过
   - 如果当前数据 == after_image → 数据没被其他事务改过,安全回滚
   - 如果当前数据 != after_image → 数据被改过了(脏写),需要人工介入
4. 根据 before_image 反向生成补偿 SQL
   UPDATE account SET balance = 1000 WHERE id = 1
5. 执行补偿 SQL + 删除 undo_log
6. 释放全局锁

AT 模式代码示例

// ============ 发起方(TM)============

@Service
public class OrderService {

    @DubboReference
    private StorageService storageService;  // 库存服务(另一个数据库)

    @DubboReference
    private AccountService accountService;  // 账户服务(另一个数据库)

    @GlobalTransactional  // ← 只需加这一个注解!
    public void createOrder(Long userId, Long productId, Integer count) {
        // 1. 创建订单(order-db)
        orderMapper.insert(buildOrder(userId, productId, count));

        // 2. 扣减库存(storage-db,远程调用)
        storageService.decrease(productId, count);

        // 3. 扣减余额(account-db,远程调用)
        accountService.decrease(userId, count * 100);

        // 如果第 3 步抛异常 → 第 1、2 步自动回滚!
        // 如果第 2 步超时 → 第 1 步自动回滚,第 3 步不执行
    }
}
// ============ 参与方(RM)============

@Service
public class AccountServiceImpl implements AccountService {

    @Autowired
    private AccountMapper accountMapper;

    @Override
    public void decrease(Long userId, Integer money) {
        // 普通本地事务即可,Seata 自动将其注册为分支事务
        accountMapper.decreaseBalance(userId, money);
        // 这里不需要 @Transactional,也不需要 @GlobalTransactional
        // Seata 通过数据源代理自动拦截 SQL,记录 undo_log
    }
}
-- 每个参与全局事务的数据库都需要 undo_log 表
CREATE TABLE `undo_log` (
    `id`            BIGINT(20)   NOT NULL AUTO_INCREMENT,
    `branch_id`     BIGINT(20)   NOT NULL COMMENT '分支事务ID',
    `xid`           VARCHAR(100) NOT NULL COMMENT '全局事务ID',
    `context`       VARCHAR(128) NOT NULL COMMENT '上下文',
    `rollback_info` LONGBLOB     NOT NULL COMMENT '回滚信息(before+after image)',
    `log_status`    INT(11)      NOT NULL COMMENT '状态',
    `log_created`   DATETIME     NOT NULL COMMENT '创建时间',
    `log_modified`  DATETIME     NOT NULL COMMENT '修改时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB COMMENT = 'Seata AT模式 undo_log 表';

TCC 模式详解

AT 模式虽然无侵入,但有局限:只能处理数据库操作,如果事务中有调用第三方接口(如银行转账 API),AT 管不了。这时需要 TCC。

TCC = Try - Confirm - Cancel

Try 阶段:预留资源(不真正执行)
  → 冻结余额:UPDATE account SET balance = balance - 100, frozen = frozen + 100 WHERE id = 1
  
Confirm 阶段:确认执行(Try 成功后)
  → 扣减冻结:UPDATE account SET frozen = frozen - 100 WHERE id = 1

Cancel 阶段:取消执行(Try 失败后)
  → 解冻:UPDATE account SET balance = balance + 100, frozen = frozen - 100 WHERE id = 1
// TCC 接口定义
@LocalTCC  // 标记为 TCC 模式
public interface AccountTccAction {

    @TwoPhaseBusinessAction(
        name = "decreaseAccount",
        commitMethod = "confirm",
        rollbackMethod = "cancel"
    )
    @BusinessActionContextParameter(paramName = "userId")
    @BusinessActionContextParameter(paramName = "money")
    boolean tryDecrease(BusinessActionContext ctx);

    boolean confirm(BusinessActionContext ctx);

    boolean cancel(BusinessActionContext ctx);
}

// TCC 实现
@Service
public class AccountTccActionImpl implements AccountTccAction {

    @Override
    public boolean tryDecrease(BusinessActionContext ctx) {
        Long userId = ctx.getActionContext("userId", Long.class);
        Integer money = ctx.getActionContext("money", Integer.class);

        // Try:冻结金额,不真正扣减
        int rows = accountMapper.freezeBalance(userId, money);
        if (rows == 0) {
            throw new RuntimeException("余额不足");  // 抛异常 → 触发 Cancel
        }
        return true;
    }

    @Override
    public boolean confirm(BusinessActionContext ctx) {
        Long userId = ctx.getActionContext("userId", Long.class);
        Integer money = ctx.getActionContext("money", Integer.class);

        // Confirm:扣减冻结金额
        accountMapper.deductFrozenBalance(userId, money);
        return true;
    }

    @Override
    public boolean cancel(BusinessActionContext ctx) {
        Long userId = ctx.getActionContext("userId", Long.class);
        Integer money = ctx.getActionContext("money", Integer.class);

        // Cancel:解冻(把冻结的金额加回余额)
        accountMapper.unfreezeBalance(userId, money);
        return true;
    }
}

TCC 三大问题(面试必问):

问题 说明 解决方案
空回滚 Try 还没执行,Cancel 就被调用了(网络超时导致 TM 以为失败) 记录事务状态表,Cancel 时检查 Try 是否执行过
幂等 Confirm/Cancel 可能被重试调用 记录事务状态表,已执行过的直接返回 true
悬挂 Cancel 先于 Try 执行(超时回滚后,延迟的 Try 才到达) 记录事务状态表,Try 前检查是否已 Cancel
// TCC 三大问题解决:事务状态表
@Service
public class AccountTccActionImpl implements AccountTccAction {

    @Autowired
    private TccTransactionLogMapper tccLogMapper;

    @Override
    public boolean tryDecrease(BusinessActionContext ctx) {
        String xid = ctx.getXid();

        // 1. 防悬挂:检查是否已经 Cancel 过
        if (tccLogMapper.isCancelled(xid)) {
            return false;  // 已经 Cancel 了,拒绝执行 Try
        }

        // 2. 防幂等:检查是否已经 Try 过
        if (tccLogMapper.isTried(xid)) {
            return true;  // 已经 Try 过了,直接返回
        }

        // 3. 执行 Try 逻辑
        accountMapper.freezeBalance(userId, money);

        // 4. 记录 Try 状态
        tccLogMapper.insertLog(xid, "TRY");
        return true;
    }

    @Override
    public boolean confirm(BusinessActionContext ctx) {
        String xid = ctx.getXid();

        // 防幂等
        if (tccLogMapper.isConfirmed(xid)) {
            return true;
        }

        accountMapper.deductFrozenBalance(userId, money);
        tccLogMapper.updateStatus(xid, "CONFIRM");
        return true;
    }

    @Override
    public boolean cancel(BusinessActionContext ctx) {
        String xid = ctx.getXid();

        // 防空回滚:检查 Try 是否执行过
        if (!tccLogMapper.isTried(xid)) {
            // Try 没执行过,记录 Cancel 状态防止悬挂
            tccLogMapper.insertLog(xid, "CANCEL");
            return true;  // 空回滚,直接返回
        }

        // 防幂等
        if (tccLogMapper.isCancelled(xid)) {
            return true;
        }

        accountMapper.unfreezeBalance(userId, money);
        tccLogMapper.updateStatus(xid, "CANCEL");
        return true;
    }
}

SAGA 模式详解

SAGA 适用于长事务(执行时间很长的事务,如一个订单流程涉及多个步骤,每个步骤可能调用外部 API)。

SAGA 模式:每一步都有正向操作和补偿操作

正向流程:
  T1(创建订单) → T2(扣库存) → T3(扣余额) → T4(发通知)

如果 T3 失败,反向补偿:
  C2(加回库存) → C1(取消订单)
  (T4 没执行所以不需要补偿)

注意:SAGA 没有全局锁,中间状态是可见的(其他事务能看到部分执行的结果)
// SAGA 使用状态机引擎定义流程
// saga.json 配置文件
{
  "Name": "createOrderSaga",
  "States": [
    {
      "Name": "createOrder",
      "Type": "ServiceTask",
      "ServiceName": "orderService",
      "ServiceMethod": "create",
      "CompensateState": "compensateOrder"
    },
    {
      "Name": "deductStorage",
      "Type": "ServiceTask",
      "ServiceName": "storageService",
      "ServiceMethod": "decrease",
      "CompensateState": "compensateStorage"
    },
    {
      "Name": "deductAccount",
      "Type": "ServiceTask",
      "ServiceName": "accountService",
      "ServiceMethod": "decrease",
      "CompensateState": "compensateAccount"
    }
  ]
}

全局锁机制(AT 模式核心)

AT 模式通过全局锁来防止多个全局事务同时修改同一行数据导致脏写:

事务 A(XID-A)              事务 B(XID-B)
    │                            │
    │ 1.执行 UPDATE account      │
    │   SET balance=900          │
    │   WHERE id=1               │
    │                            │
    │ 2.向 TC 申请全局锁          │
    │   key = account:1          │
    │   TC: 锁不存在,加锁成功     │
    │                            │ 3.执行 UPDATE account
    │                            │   SET balance=800
    │                            │   WHERE id=1
    │                            │
    │                            │ 4.向 TC 申请全局锁
    │                            │   key = account:1
    │                            │   TC: 锁已被 XID-A 持有!
    │                            │   → B 等待重试(默认 10 次,每次 30ms)
    │                            │
    │ 5.全局事务提交               │
    │   → 释放全局锁               │
    │                            │ 6.锁释放后,B 获取到锁
    │                            │   → B 继续执行

全局锁 vs 本地锁:

维度 全局锁 本地锁(数据库行锁)
持有者 TC(事务协调器) 数据库引擎
粒度 行级(表名:主键值) 行级
作用 防止多个全局事务脏写 防止本地事务并发修改
释放时机 全局事务提交/回滚后 本地事务提交/回滚后

Seata Server 部署配置

# application.yml(Seata Server)
seata:
  config:
    type: nacos  # 配置中心
    nacos:
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace: seata
      data-id: seataServer.properties
  registry:
    type: nacos  # 注册中心
    nacos:
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace: seata
      cluster: default
  store:
    mode: db  # 事务日志存储方式(file/db/redis)
    db:
      datasource: druid
      url: jdbc:mysql://127.0.0.1:3306/seata
      user: root
      password: root
# 微服务端配置(application.yml)
seata:
  enabled: true
  application-id: ${spring.application.name}
  tx-service-group: default_tx_group  # 事务组(要和 Seata Server 一致)
  service:
    vgroup-mapping:
      default_tx_group: default  # 映射到集群名
  registry:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace: seata
  config:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
      namespace: seata

面试题

Q1:Seata 的 AT 模式和 TCC 模式怎么选?

维度 AT 模式 TCC 模式
侵入性 无(加注解即可) 高(需实现 Try/Confirm/Cancel)
适用场景 纯数据库操作 涉及外部 API / 需要细粒度控制
性能 一阶段直接提交,性能好 一阶段预留资源,性能好
隔离性 写隔离(全局锁) 业务自己控制
开发量 极小 大(三套接口 + 空回滚/幂等/悬挂处理)
推荐 首选,90% 场景够用 资金类、需要冻结操作的场景

Q2:AT 模式为什么一阶段就提交本地事务?不会脏读吗?

AT 模式一阶段提交是为了减少资源锁定时间——传统 XA 模式一阶段不提交,会长时间持有数据库锁,性能差。

AT 提交后,其他本地事务确实可以读到已提交的数据(读未提交隔离级别下的“脏读”问题),但 Seata 通过全局锁保证:其他全局事务无法修改这条数据(写隔离),只有非事务操作本地事务能读到中间状态。

如果需要强读隔离,可以用 @GlobalTransactional(lockRetryInterval = ...) + SELECT FOR UPDATE(Seata 会检查全局锁)。

Q3:AT 模式如果 undo_log 表的数据被删了怎么办?

undo_log 是 AT 模式回滚的依据。如果二阶段回滚时找不到 undo_log,Seata 会记录告警日志并跳过该分支的回滚(因为数据可能已经被其他方式修正)。生产环境要确保 undo_log 表和业务表在同一个数据库同一个本地事务中写入,保证原子性。

Q4:Seata 的 XID 是怎么在微服务间传播的?

XID(全局事务 ID)格式为 IP:PORT:TRANSACTION_ID。传播方式取决于 RPC 框架:

  • Dubbo: Seata 通过 Dubbo Filter(SeataFilter)将 XID 放入 RpcContext.getContext() 的 Attachment 中,Consumer → Provider 自动传播
  • Feign: Seata 通过 Feign Interceptor(SeataFeignClientInterceptor)将 XID 放入 HTTP Header(TX_XID),请求时自动传播
  • Spring Cloud Gateway: 需要配置 Gateway Filter 传播 XID Header

下游服务通过 RootContext.bind(xid) 绑定 XID,后续操作自动加入同一全局事务。

Q5:Seata 高可用怎么保证?

TC(事务协调器)是单点风险。Seata 支持集群部署:

  1. 多 TC 节点: 多个 Seata Server 注册到 Nacos,微服务通过 Nacos 发现 TC 集群
  2. 共享存储: 所有 TC 节点共享同一个数据库(存储事务日志),保证状态一致
  3. 负载均衡: 微服务端通过 seata.service.vgroup-mapping 将事务组映射到 TC 集群,TC 间通过 Redis/DB 共享数据
  4. 故障转移: TC 节点宕机后,微服务自动从 Nacos 获取其他健康节点

Q6:分布式事务和消息最终一致性(RocketMQ 事务消息)怎么选?

维度 Seata RocketMQ 事务消息
一致性 强一致(AT/TCC)或最终一致(SAGA) 最终一致
性能 有全局锁,性能有损耗 无锁,性能好
复杂度 中等(需部署 TC) 低(RocketMQ 自带)
适用 同步场景(必须等所有操作完成) 异步场景(允许延迟一致)
典型案例 转账、下单扣库存 注册发邮件、下单发消息

选择建议: 如果业务要求“立即看到结果”用 Seata;如果“晚几秒也没关系”用 MQ 事务消息。你的简历项目用了 RocketMQ 事务消息做活动报名,这是合理的选择——报名结果不需要即时反馈。


三、MyBatis-Plus

你简历的使用场景: 所有项目的 ORM 层

面试官心理: “你简历写了 @BatchSize 解决 N+1,讲讲 N+1 是什么?怎么解决的?”

3.1 核心使用

小白讲解

MyBatis-Plus = MyBatis + 增强插件。MyBatis 是手写 SQL 的 ORM,MyBatis-Plus 在此基础上:

  • 不用写基本 CRUD(自动生成)
  • 条件构造器(链式写 WHERE 条件)
  • 分页插件(加一行配置自动分页)
  • 逻辑删除(改个字段标记删除,不真删)

代码示例

// 实体类
@Data
@TableName("t_user")
public class User {
    @TableId(type = IdType.ASSIGN_ID)  // 雪花算法 ID
    private Long id;
    private String name;
    private Integer age;
    private String email;

    @TableField(fill = FieldFill.INSERT)  // 自动填充
    private LocalDateTime createTime;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;

    @TableLogic  // 逻辑删除字段
    @TableField("is_deleted")
    private Integer deleted;
}

// Mapper:继承 BaseMapper,自动拥有 CRUD
@Mapper
public interface UserMapper extends BaseMapper<User> {
    // BaseMapper 自带方法:
    // insert(user), deleteById(id), updateById(user), selectById(id)
    // selectList(null), selectPage(page, wrapper) 等
}

// Service:继承 IService
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
    // IService 自带:save(), saveBatch(), getById(), list(), page() 等
}

条件构造器

// QueryWrapper(字符串列名)
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("name", "张三")        // name = '张三'
       .ge("age", 18)             // AND age >= 18
       .like("email", "@gmail")   // AND email LIKE '%@gmail%'
       .orderByDesc("create_time")
       .last("LIMIT 10");          // 最后拼接 SQL

List<User> users = userMapper.selectList(wrapper);

// LambdaQueryWrapper(推荐!编译时检查列名,防写错)
LambdaQueryWrapper<User> lambdaWrapper = new LambdaQueryWrapper<>();
lambdaWrapper.eq(User::getName, "张三")
             .ge(User::getAge, 18)
             .like(User::getEmail, "@gmail")
             .orderByDesc(User::getCreateTime);

List<User> users = userMapper.selectList(lambdaWrapper);

分页插件

// 配置分页插件
@Configuration
public class MybatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(
            new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

// 使用分页
Page<User> page = new Page<>(1, 10);  // 第 1 页,每页 10 条
Page<User> result = userMapper.selectPage(page,
    new LambdaQueryWrapper<User>().ge(User::getAge, 18));

result.getRecords();   // 当前页数据
result.getTotal();     // 总记录数
result.getPages();     // 总页数
result.getCurrent();   // 当前页码

3.2 N+1 查询问题(简历直接写了 @BatchSize)

小白讲解

N+1 = 查 1 次主表 + N 次子表。就像你取快递:

  • N+1 问题:你有 10 个快递,跑到快递柜 10 次每次取 1 个——10+1=11 次往返
  • 解决方案:一次取 10 个——1+1=2 次往返

代码示例

// ==================== N+1 问题 ====================
// 场景:查询 10 个订单,每个订单要查对应的用户信息
List<Order> orders = orderMapper.selectList(null);  // 查 1 次订单
for (Order order : orders) {
    User user = userMapper.selectById(order.getUserId());  // 查 N 次用户
}
// 总 SQL:1 + 10 = 11 条

// ==================== 解决方案 1:@BatchSize(你简历用的)====================
@TableName("t_order")
public class Order {
    private Long id;
    private Long userId;

    // 加 @BatchSize 自动按批次查询
    @TableField(exist = false)
    @BatchSize(size = 100)  // 每批 100 个 userId
    private User user;
}
// MyBatis-Plus 看到 @BatchSize 后:
// SELECT * FROM t_user WHERE id IN (id1, id2, ..., id100)  -- 批量查
// 总 SQL:1 + 1 = 2 条(如果订单数 <= 100)

// ==================== 解决方案 2:手写 JOIN ====================
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
    @Select("SELECT o.*, u.name as user_name FROM t_order o " +
            "LEFT JOIN t_user u ON o.user_id = u.id " +
            "WHERE o.id = #{id}")
    OrderVO selectOrderWithUser(@Param("id") Long id);
}

// ==================== 解决方案 3:关联查询 + ResultMap ====================
// 使用 @Results 或 XML resultMap 做一对一/一对多

面试题

Q1:什么是 N+1 问题?有什么危害?

N+1 问题是指查询 N 条主记录后,对每条记录单独查询关联数据,产生 1(主表)+ N(关联表)= N+1 次 SQL。危害是数据库连接频繁、网络往返多、查询慢。在列表查询 N=1000 时就是 1001 条 SQL,严重影响性能。

Q2:@BatchSize 的底层原理是什么?

@BatchSize 是 Hibernate 的注解,MyBatis-Plus 也有类似机制。原理是当遍历关联对象时,收集主表的外键 ID,积累到 size 个后发一条 IN 查询批量获取,然后填充回对应的主对象。比如 @BatchSize(size=100) + 1000 条订单 = 10 条 IN 查询 + 1 条主查询 = 11 条 SQL,远少于 1001 条。

Q3:MyBatis-Plus 的逻辑删除原理?

在实体类字段加 @TableLogic,删除操作时 MyBatis-Plus 自动将 DELETE 改写为 UPDATE SET is_deleted=1,查询时自动追加 WHERE is_deleted=0。对开发者透明,代码不感知。

Q4:MyBatis 的一级缓存和二级缓存?

维度 一级缓存 二级缓存
范围 SqlSession(方法级) namespace(跨 SqlSession)
默认 开启 需手动开启
失效 insert/update/delete/commit/close 同上
分布式 不适用 需分布式缓存(如 Redis)

注意: Spring Boot 中默认每次请求一个 SqlSession,一级缓存基本无效。生产环境一般不用 MyBatis 二级缓存(粒度粗、易脏读),而是用 Redis 做业务缓存。


四、RocketMQ(简历项目四直接用了)

你简历的使用场景: 埃森哲社区平台——RocketMQ 事务消息保障分布式事务最终一致性

面试官心理: “你说用了事务消息,和普通消息有什么区别?事务消息怎么保证一致性?”

4.1 核心概念

小白讲解

RocketMQ 像一个快递分拣中心

  • Producer = 寄件人(发消息的)
  • Consumer = 收件人(收消息的)
  • Broker = 分拣中心(存消息的)
  • NameServer = 快递公司总部(告诉寄件人分拣中心在哪)
  • Topic = 寄往哪个城市
  • Queue = 城市下的不同分区
  • Tag = 快件类型(文件、电子产品)
  • Group = 寄件人分组(同组共同消费消息)

与 Kafka/RabbitMQ 对比

维度 RocketMQ Kafka RabbitMQ
语言 Java Scala/Java Erlang
单机 TPS 10 万+ 百万级 万级
延迟 ms 级 ms 级 μs 级
事务消息 支持 不支持 插件支持
顺序消息 支持 分区内有序 队列有序
消息回溯 支持(按时间) 不支持 不支持
适用 金融/电商 大数据/日志 企业集成

4.2 事务消息(简历写了,面试必问)

小白讲解

普通消息有个致命问题:

场景:下单 → 扣库存 → 发消息 → 加积分

问题:如果"发消息"和"扣库存"是两步操作:
1. 扣库存成功
2. 发消息失败(网络抖动)
→ 库存扣了,积分没加 → 数据不一致

事务消息解决了这个问题:先发"半消息" → 执行本地事务 → 根据结果提交/回滚半消息

事务消息流程

┌──────────┐                              ┌──────────┐
│ Producer  │ ──1. 发送半消息────────────→ │  Broker  │
│           │ ←─2. 半消息发送成功────────── │          │
│           │                              │ (半消息  │
│           │ ──3. 执行本地事务(扣库存)─→ │  对消费  │
│           │                              │  者不可见)│
│           │                              │          │
│           │ ──4. 提交/回滚半消息────→ ──→│          │
│           │    (本地事务成功→提交         │          │
│           │     本地事务失败→回滚)        │          │
└──────────┘                              └──────────┘

异常情况:如果第 4 步没到(网络问题)
→ Broker 定期回查 Producer:"第 3 步的本地事务到底成功没有?"
→ Producer 实现 checkLocalTransaction() 查询本地事务状态
→ 返回 COMMIT / ROLLBACK / UNKNOWN

代码示例

// 事务消息生产者
@Component
public class OrderTransactionProducer {

    @Autowired
    private TransactionMQProducer producer;

    @PostConstruct
    public void init() throws MQClientException {
        producer.setTransactionListener(new TransactionListener() {
            @Override
            public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
                // 步骤 3:执行本地事务
                try {
                    String orderId = (String) arg;
                    orderService.deductStock(orderId);  // 扣库存
                    return LocalTransactionState.COMMIT_MESSAGE;
                } catch (Exception e) {
                    return LocalTransactionState.ROLLBACK_MESSAGE;
                }
            }

            @Override
            public LocalTransactionState checkLocalTransaction(MessageExt msg) {
                // 步骤 4(异常回查):检查本地事务是否成功
                String orderId = msg.getKeys();
                Order order = orderService.getById(orderId);
                if (order != null && order.getStatus() == OrderStatus.PAID) {
                    return LocalTransactionState.COMMIT_MESSAGE;
                }
                return LocalTransactionState.ROLLBACK_MESSAGE;
            }
        });
        producer.start();
    }

    // 发送事务消息
    public void sendOrderMessage(String orderId) {
        Message msg = new Message("order_topic", orderId.getBytes());
        producer.sendMessageInTransaction(msg, orderId);
    }
}

面试题

Q1:RocketMQ 事务消息和 Kafka 事务有什么区别?

RocketMQ 事务消息解决的是本地事务 + 消息发送的原子性(确保本地事务成功消息才可见)。Kafka 事务解决的是多个消息的原子性写入(一批消息要么全成功要么全失败)。本质不同:RocketMQ 是“事务+消息”,Kafka 是“消息+事务”。

Q2:消息消费幂等怎么保证?(简历写了“幂等键 userId+activityId+date”)

@RocketMQMessageListener(topic = "activity_reward", consumerGroup = "reward_group")
public class RewardConsumer implements RocketMQListener<String> {

    @Override
    @Transactional
    public void onMessage(String message) {
        RewardMsg msg = JSON.parseObject(message, RewardMsg.class);
        // 幂等键 = userId + activityId + date
        String idempotentKey = msg.getUserId() + ":" + msg.getActivityId() + ":" + msg.getDate();

        // 1. Redis SETNX 判重(快速拦截)
        Boolean isNew = redis.opsForValue().setIfAbsent(
            "idempotent:" + idempotentKey, "1", 24, TimeUnit.HOURS);
        if (!isNew) return;  // 已处理过,跳过

        try {
            // 2. 数据库唯一索引兜底
            rewardService.grant(msg.getUserId(), msg.getActivityId());
        } catch (DuplicateKeyException e) {
            // 唯一索引冲突 → 已处理过
        }
    }
}

Q3:RocketMQ 怎么保证消息不丢失?

环节 措施
Producer 同步发送 + 重试(retryTimesWhenSendFailed)
Broker 同步刷盘 + 主从复制
Consumer 手动 ACK(消费成功才返回 CONSUME_SUCCESS)

五、Redisson 分布式锁(简历写了)

你简历的使用场景: 社区平台——Redis + Lua 预减库存、DCL 缓存击穿

面试官心理: “分布式锁怎么实现的?Redis SETNX 有什么问题?Redisson 怎么解决的?”

5.1 分布式锁演进

从 SETNX 到 Redisson

// ==================== 第一代:SETNX(有问题)====================
// 问题 1:加锁后程序崩溃 → 锁永远不释放(死锁)
// 问题 2:锁过期了但业务没执行完 → 别人拿到锁,自己还在执行 → 并发问题
// 问题 3:不可重入(同一线程不能再次获取同一把锁)

Boolean locked = redis.opsForValue()
    .setIfAbsent("lock:order:123", "value", 30, TimeUnit.SECONDS);

// ==================== 第二代:SETNX + 唯一值 + Lua 删除(解决误删)====================
// 加锁时设置唯一值(UUID),删除时用 Lua 保证"判断+删除"原子性
String lockValue = UUID.randomUUID().toString();
redis.opsForValue().setIfAbsent("lock:order:123", lockValue, 30, TimeUnit.SECONDS);

// 删除锁(Lua 保证原子性)
String luaScript =
    "if redis.call('get', KEYS[1]) == ARGV[1] then " +
    "  return redis.call('del', KEYS[1]) " +
    "else return 0 end";
redis.execute(new DefaultRedisScript<>(luaScript, Long.class),
    Collections.singletonList("lock:order:123"), lockValue);

// ==================== 第三代:Redisson(推荐!全部解决)====================
RLock lock = redissonClient.getLock("lock:order:123");
try {
    lock.lock(30, TimeUnit.SECONDS);  // 加锁,30 秒自动过期
    // 业务逻辑
} finally {
    lock.unlock();
}

Redisson 看门狗(WatchDog)机制

问题:锁设 30 秒过期,但业务执行了 40 秒怎么办?

Redisson 解决方案:看门狗自动续期

1. lock.lock(30, TimeUnit.SECONDS) 加锁,TTL = 30 秒
2. 启动 WatchDog 定时任务,每 10 秒(TTL/3)检查一次
3. 如果当前线程仍持有锁 → 续期到 30 秒
4. 如果当前线程已释放锁或宕机 → 停止续期,锁到期自动释放

→ 业务执行多久都不会死锁

Redisson 可重入锁原理

// 同一线程可以多次获取同一把锁(可重入)
RLock lock = redissonClient.getLock("myLock");
lock.lock();    // 第一次加锁:count = 1
lock.lock();    // 第二次加锁:count = 2(可重入)
lock.unlock();  // 第一次解锁:count = 1
lock.unlock();  // 第二次解锁:count = 0,真正释放锁

底层用 Redis Hash 结构:{lockName: {threadId: count}},count 记录重入次数。

面试题

Q1:Redis 做分布式锁有什么问题?Redisson 怎么解决?

问题 SETNX Redisson
死锁 崩溃后锁不释放 默认 30 秒自动过期
误删 删了别人的锁 Lua 脚本判断唯一值再删
不可重入 同线程再获取失败 Hash 结构记录重入次数
自动续期 WatchDog 定时续期
公平性 非公平 支持公平锁

Q2:RedLock 算法是什么?有什么争议?

RedLock 由 Redis 作者提出:在 N 个独立 Redis 实例上加锁,超过半数(N/2+1)成功才算加锁成功。用于解决单点 Redis 主从切换丢锁问题。

争议:Martin Kleppmann 指出 RedLock 依赖时钟同步,如果某节点时钟跳变可能导致锁失效。生产环境一般用 Zookeeper 或单 Redis + 容忍少量误差。

Q3:你简历写了 Redis + Lua 预减库存,说说原理?

-- 预减库存 Lua 脚本(原子操作)
local key = KEYS[1]           -- 库存 key
local stock = tonumber(ARGV[1]) -- 要减的数量

local current = tonumber(redis.call('get', key) or '0')
if current >= stock then
    redis.call('decrby', key, stock)  -- 预扣减
    return 1  -- 成功
else
    return 0  -- 库存不足
end

用 Lua 保证“检查库存 + 扣减”的原子性,利用 Redis 单线程模型避免并发超卖。预减库存后异步发 MQ 真正下单,如果下单失败再回滚库存。


六、ElasticSearch(简历写了全文检索)

你简历的使用场景: 社区平台——搭建全文检索引擎,支持内容审核与敏感词过滤

面试官心理: “ES 倒排索引了解吗?跟 MySQL like 有什么区别?”

6.1 核心概念

小白讲解

ES 搜索和 MySQL LIKE 的区别:

  • MySQL LIKE ‘%关键词%’ = 翻每本书从头到尾找这个关键词(慢)
  • ES = 先建了一个目录,告诉你“关键词在第 3 页、第 5 页、第 8 页”(快)

这个目录就叫倒排索引

MySQL ElasticSearch
Database Index(索引)
Table Mapping(类型,7.x 后废弃)
Row Document(文档 JSON)
Column Field(字段)
Schema Mapping

倒排索引原理

正排索引(MySQL):
文档 ID → 内容
  1 → "Java 是最好的语言"
  2 → "Python 也不错"
  3 → "Java 和 Python 都很好"

倒排索引(ES):
关键词 → 文档 ID 列表
  "Java"   → [1, 3]
  "Python" → [2, 3]
  "语言"   → [1]
  "不错"   → [2]

搜索 "Java" → 直接查倒排表 → 返回 [1, 3]
搜索 "Java Python" → 交集 → 返回 [3]

分词器

// 原文:"我是Java开发工程师"
// 标准分词器(对中文不友好):"我", "是", "Java", "开", "发", "工", "程", "师"
// IK 分词器(推荐中文):"我", "是", "Java", "开发", "工程师"

// IK 分词模式
// ik_smart:粗粒度——"Java开发工程师" → "Java", "开发工程师"
// ik_max_word:细粒度——"Java", "开发", "工程师"

代码示例(Spring Data ES)

// 文档实体
@Document(indexName = "article")
@Data
public class Article {
    @Id
    private String id;

    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String title;

    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String content;

    @Field(type = FieldType.Keyword)
    private String author;

    @Field(type = FieldType.Date)
    private LocalDateTime createTime;
}

// Repository
public interface ArticleRepository extends ElasticsearchRepository<Article, String> {
    List<Article> findByTitleOrContent(String title, String content);
}

// 复杂查询
@Service
public class ArticleSearchService {
    @Autowired
    private ElasticsearchRestTemplate esTemplate;

    public SearchHits<Article> search(String keyword, int page, int size) {
        NativeSearchQuery query = new NativeSearchQueryBuilder()
            .withQuery(QueryBuilders.multiMatchQuery(keyword, "title", "content")
                .type(MatchQuery.Type.BEST_FIELDS)  // 取最高分
                .tieBreaker(0.3f))                   // 其他字段加权
            .withSort(SortBuilders.scoreSort().order(SortOrder.DESC))  // 按相关度排序
            .withPageable(PageRequest.of(page, size))
            .build();
        return esTemplate.search(query, Article.class);
    }
}

面试题

Q1:ES 为什么比 MySQL like 快?

ES 使用倒排索引:先对文档分词建词项→文档ID映射表,搜索时直接查词项对应的文档ID列表,O(1) 复杂度。MySQL like ‘%xxx%’ 是全表扫描 O(n),且不走索引。

Q2:ES 是如何保证集群高可用的?

机制 说明
分片 索引分成多个 Shard,分布在不同节点
副本 每个 Shard 有副本,主挂备上
选举 Master 节点宕机后自动选新 Master
脑裂 minimum_master_nodes 防脑裂(7.x 后自动)

Q3:ES 深度分页问题?

ES 默认只支持 from + size <= 10000 的分页。深分页时 ES 要在所有分片上取 from + size 条数据合并排序,内存和性能开销巨大。解决方案:search_after(基于上一页最后一条的排序值)或 scroll(游标方式)。


七、面试高频考点速查表

Spring Boot 高频

考点 一句话答案
自动配置原理 读 imports 文件 + @Conditional 条件装配
@Transactional 失效场景 非public/内部调用/异常被吞/非RuntimeException
@Autowire vs @Resource 先类型 vs 先名称
Bean 生命周期 实例化→属性赋值→初始化→使用→销毁
循环依赖怎么解决 三级缓存(singletonObjects/earlySingletonObjects/singletonFactories)
@Configuration vs @Component 全模式(CGLIB代理) vs 轻量模式
AOP 用什么代理 有接口 JDK,无接口 CGLIB;Boot 默认 CGLIB

Spring Cloud Alibaba 高频

考点 一句话答案
Nacos AP/CP 默认 AP(Distro),持久实例 CP(Raft)
Nacos 注册表存储 双层 ConcurrentHashMap
Sentinel 限流算法 滑动窗口 + 漏桶 + 令牌桶
Sentinel 熔断状态 Closed→Open→HalfOpen
Dubbo vs Feign TCP/二进制 vs HTTP/JSON
Gateway 核心 Route + Predicate + Filter
Seata 四模式 AT(默认)/TCC/SAGA/XA

MyBatis-Plus 高频

考点 一句话答案
N+1 问题 查1次主表+N次子表,用@BatchSize或JOIN解决
@BatchSize 原理 积累ID批量IN查询
逻辑删除 @TableLogic,DELETE改UPDATE
分页原理 PaginationInnerInterceptor 拦截加 LIMIT
一级缓存 SqlSession 级,Boot 中基本无效
二级缓存 namespace 级,生产不用(用Redis)

MQ 高频

考点 一句话答案
RocketMQ 事务消息 半消息→本地事务→提交/回滚+回查
消息幂等 Redis SETNX + DB 唯一索引
消息不丢失 同步发送+同步刷盘+手动ACK
消息顺序 同key到同queue,单线程消费
消息堆积 扩Consumer + 临时Topic转发
RocketMQ vs Kafka 事务消息/顺序/回溯 vs 高吞吐/日志

Redis 分布式锁高频

考点 一句话答案
SETNX 问题 死锁/误删/不可重入/无续期
Redisson 看门狗 TTL/3 间隔自动续期
可重入原理 Hash 结构存 threadId+count
Redis+Lua 预减库存 原子操作检查+扣减
RedLock N/2+1 实例加锁,有时钟争议

ElasticSearch 高频

考点 一句话答案
倒排索引 词项→文档ID列表,O(1)查找
分词器 ik_max_word(细)/ik_smart(粗)
深度分页 search_after/scroll 替代 from+size
ES vs MySQL 全文检索快 vs 事务/关联强

8 道综合场景题(结合你的简历)

题 1

问: 你的零碳平台用 Nacos + Sentinel,如果 Nacos 挂了,服务还能调用吗?

能。Nacos Client 会缓存服务列表到本地文件(~/nacos/naming/),Nacos Server 挂了后 Consumer 使用本地缓存的地址继续调用。只是无法注册新服务和获取最新列表。Sentinel 规则同理——Client 本地缓存规则,Server 挂了用最后一版规则。

题 2

问: @Transactional 标注的方法 A 内部调用方法 B(也有 @Transactional),B 的事务生效吗?

不生效。因为 this.B() 是目标对象直接调用,不经过代理。需要:①注入自己 self.B();②AopContext.currentProxy().B();③将 B 拆到另一个 Service。

题 3

问: 你简历写了 DCL 双重检查锁,为什么必须加 volatile?

// DCL 单例
public class Singleton {
    private static volatile Singleton instance;  // 必须 volatile

    public static Singleton getInstance() {
        if (instance == null) {                     // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) {             // 第二次检查
                    instance = new Singleton();      // 非原子操作!
                }
            }
        }
        return instance;
    }
}

new Singleton() 分三步:①分配内存 → ②初始化对象 → ③引用指向内存。如果不加 volatile,指令重排序可能变成 ①→③→②,其他线程在第一次检查时看到 instance 非 null(但还没初始化完),返回了一个半成品对象。

题 4

问: RocketMQ 事务消息的回查机制,如果 Producer 一直不回复怎么办?

Broker 默认回查 15 次(transactionCheckMax=15),每次间隔 60 秒。超过次数后 Broker 主动回滚该半消息(标记为 ROLLBACK),避免半消息一直堆积。

题 5

问: Sentinel 的匀速排队模式和直接拒绝模式分别适合什么场景?

  • 直接拒绝:突发流量保护场景,如秒杀,超出的请求直接返回 429
  • 匀速排队:削峰填谷场景,如消息消费,请求匀速通过,利用漏桶算法。适合处理能力稳定但请求有突刺的场景

题 6

问: 你用 Redis + Lua 预减库存,如果 Redis 和 MySQL 数据不一致怎么办?

预减库存只是“锁定”,不是真正扣减。流程是:①Redis Lua 预减 → ②发 MQ → ③Consumer 异步真正扣 MySQL 库存。如果 Consumer 失败,MQ 重试,最终一致。如果 Redis 挂了,降级到 DB 扣减(限流保护)。关键:Redis 是缓存的“可用额度”,MySQL 是“真实库存”,两者通过 MQ 最终一致。

题 7

问: Spring Boot 启动时自动配置了那么多 Bean,会不会很慢?

不会。自动配置类虽然多(200+),但每个都有 @Conditional 条件判断。只有类路径存在对应依赖时才真正创建 Bean。比如没引 Redis 依赖,RedisAutoConfiguration@ConditionalOnClass(RedisOperations.class) 不满足,直接跳过,不会实例化。

题 8

问: 你简历写了 MyBatis-Plus 的 @BatchSize,说说从 N+1 到批量查询的 SQL 变化?

N+1 模式:SELECT * FROM order WHERE ...(1 条)+ 循环 SELECT * FROM user WHERE id = ?(N 条)= N+1 条 SQL。 @BatchSize 模式:SELECT * FROM order WHERE ...(1 条)+ SELECT * FROM user WHERE id IN (?, ?, ..., ?)(N/size 条)= 1 + N/size 条 SQL。假设 N=1000、size=100,从 1001 条降到 11 条。


学习资源汇总

技术 官方文档 推荐视频
Spring Boot https://docs.spring.io/spring-boot/docs/current/reference/html/ 黑马 BV1Lq4y1s7HQ
Spring Framework https://docs.spring.io/spring-framework/reference/ 尚硅谷 BV1P4y1S7W9b
Spring Cloud Alibaba https://sca.aliyun.com/zh-cn/ 黑马 BV1Lb4y1A7Hq
Nacos https://nacos.io/zh-cn/docs/v2/quickstart/quick-start.html 官方文档足够
Sentinel https://sentinelguard.io/zh-CN/docs/introduction.html 官方文档足够
Dubbo https://dubbo.apache.org/zh/docs3-v2/java-sdk/quick-start/ 官方文档足够
MyBatis-Plus https://baomidou.com/ 尚硅谷 BV1cE411M7r8
RocketMQ https://rocketmq.io/zh/docs/ 黑马 BV1Lb4y1A7Hq
Redisson https://redisson.org/ 官方文档 + GitHub
ElasticSearch https://www.elastic.co/guide/en/elasticsearch/reference/current/ 黑马 BV1Lb4y1A7Hq

动手练习清单

□ 练习 1:手写一个 Spring Boot Starter(自动配置 + @Conditional)
□ 练习 2:写 @Transactional 各种失效场景的测试用例,验证确实不回滚
□ 练习 3:搭一个 Nacos + Sentinel + Gateway 的三服务微服务 Demo
□ 练习 4:用 MyBatis-Plus 写 N+1 问题 Demo,对比 @BatchSize 前后的 SQL 数量
□ 练习 5:写一个 RocketMQ 事务消息的完整 Demo(含回查)
□ 练习 6:用 Redisson 实现分布式锁,故意让业务 sleep 超过 TTL,观察 WatchDog 续期
□ 练习 7:用 Spring Data ES 建索引 + IK 分词 + 中文搜索