第二阶段:开发框架(吃饭的家伙)
第二阶段:开发框架(吃饭的家伙)
定位: 你简历每个项目都在用 Spring Boot + Spring Cloud Alibaba + MyBatis-Plus,面试官会从这里开始问。这是你的“基本功”区域,必须烂熟于心。
本文档覆盖: Spring Boot 核心 → Spring Cloud Alibaba 全家桶 → MyBatis-Plus → RocketMQ → Redisson → ElasticSearch
学习策略: 先理解原理(为什么这么设计),再记面试题(面试怎么问),最后动手验证(写代码跑一遍)
目录
- 一、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 面向切面编程
小白讲解
想象你开了一家连锁餐厅,每家分店都要做这些事:
- 开门前检查卫生
- 营业中卖饭
- 关门后盘点库存
如果每家分店都自己写“检查卫生”和“盘点库存”的代码,就重复了。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,而是自己实现动态代理?
三个原因:
- 易用性:AspectJ 需要单独的 ajc 编译器和构建配置,Spring AOP 只需加依赖和注解,零侵入
- 够用原则:企业开发 80% 的 AOP 需求(事务、日志、权限、缓存)都是方法级拦截,Spring AOP 足够
- 容器集成: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 表达式去匹配包名和方法名,只需要在目标方法上加一个自定义注解,切面就自动生效。这意味着:
- 精确控制哪些方法需要增强(只有加了注解的才增强)
- 不依赖包结构(方法在哪个包无所谓)
- 业务代码只需加一行注解,零侵入
- 注解可以携带参数(如操作描述、限流 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 事务默认只在抛出
RuntimeException或Error时回滚。如果业务代码抛出的是受检异常(如IOException、SQLException),默认不会回滚。通过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 下是怎么解决幻读的?
两种机制配合:
- 快照读(普通 SELECT):通过 MVCC(多版本并发控制),事务第一次查询时生成 Read View,后续查询复用同一 Read View,始终读取事务开始时的快照数据,不受其他事务插入的影响。
- 当前读(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,干两件事:
- 服务注册发现(黄页电话簿):每个微服务启动时到 Nacos 登记“我叫 order-service,住在 192.168.1.10:8080”,消费方需要调用时查黄页获取地址
- 动态配置管理(小区公告栏):配置统一放 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?
三个核心原因:
- 降低资源消耗: HTTP 短连接频繁创建/销毁,大量心跳请求导致 CPU 和文件描述符消耗高;gRPC 基于长连接,连接复用,资源消耗低
- 提升实时性: 1.x 长轮询有 30 秒延迟,2.x gRPC 流式推送毫秒级到达
- 统一协议: 2.x 统一用 gRPC 做注册发现和配置推送,简化了实现
Q8:Nacos 怎么做配置灰度发布?
我们生产环境用的是 Group 隔离 + 配置开关 的组合方式。
假设有 10 台机器(192.168.1.101 ~ 110),要把超时时间从 3000ms 改成 5000ms:
- 在 Nacos 的 prod 命名空间下创建
GRAY_GROUP,复制DEFAULT_GROUP的配置并修改 timeout=5000- 灰度机器(101、102)的
application.yml配置group: GRAY_GROUP,其余 8 台仍读DEFAULT_GROUP- 配置里同时埋一个开关
feature.new-timeout.enabled,GRAY 组设 true,DEFAULT 组设 false- 代码用
@RefreshScope + @Value监听开关,true 走新逻辑,false 走旧逻辑- 观察 30 分钟没问题 → 把 GRAY_GROUP 配置同步到 DEFAULT_GROUP → 全部生效
- 出问题?改回
enabled=false秒级回滚,不用重新部署如果只是临时验证一个小改动,也可以用 Nacos 控制台自带的 Beta 发布——直接指定灰度 IP 列表(如 192.168.1.101,192.168.1.102),不用改代码也不用改 Group。
Q9:@RefreshScope 的原理是什么?配置变更后 Bean 是怎么更新的?
@RefreshScope会使 Bean 被代理,代理对象持有一个RefreshScopedProxyBean。当 Nacos 配置变更时:
- Spring Cloud 收到
RefreshEvent事件ContextRefresher调用Environment.getPropertySources()更新配置源- 广播
EnvironmentChangeEvent,所有@ConfigurationProperties的 Bean 自动重新绑定@RefreshScope的 Bean 被标记为 stale(过期)- 下次访问该 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 规则拦截(流控超限、熔断打开、系统限流),参数为BlockExceptionfallback:处理业务代码抛出的异常(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 默认将规则存储在内存中,应用重启后规则丢失。生产环境通过
ReadableDataSource和WritableDataSource将规则持久化到 Nacos:
- Dashboard 修改规则 → 调用 Nacos DataSource 写入 Nacos 配置中心
- Nacos 配置变更 → 推送到所有应用实例的
ReadableDataSource- 应用监听到配置变更 → 重新加载规则到内存
- 应用重启 → 从 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 的区别?
- 按需加载: Java SPI 一次性加载所有实现类,即使不用也会加载(浪费资源);Dubbo SPI 按需加载,用到哪个加载哪个
- IOC 支持: Dubbo SPI 支持依赖注入,加载实现类时自动注入其依赖的其他扩展点
- AOP 包装: Dubbo SPI 支持 Wrapper 类自动包装,类似 Spring AOP,可以在扩展点前后加逻辑
- 自适应扩展:
@Adaptive注解让接口在运行时根据 URL 参数动态选择实现类- 配置格式: Java SPI 是纯类名,Dubbo SPI 是
key=类名,可以按 key 获取
Q4:Dubbo 的 Failover 容错策略为什么不适合非幂等操作?
Failover 在调用失败后会自动切换到其他 Provider 重试。如果操作是非幂等的(如新增记录),第一次调用可能已经成功写入数据库,只是网络超时导致 Consumer 认为失败,重试就会写入重复数据。
非幂等操作应使用 Failfast 策略——只调用一次,失败直接抛异常,由业务层决定是否手动重试。
Q5:Dubbo 3.0 的 Triple 协议解决了什么问题?
- 跨语言互通: Dubbo 协议是私有二进制协议,其他语言无法直接调用;Triple 基于 gRPC,Go/Python/Node 等语言可以直接通过 gRPC 调用
- 网关穿透: Dubbo 协议走 TCP,无法穿透标准 HTTP 网关;Triple 走 HTTP/2,可以穿透 Nginx/K8s Ingress
- 流式调用: Dubbo 协议只支持请求-响应模式;Triple 支持 Server Stream / Client Stream / 双向流,适合实时推送、大文件传输等场景
- 生态对接: 可以直接和 gRPC 生态(Envoy、Istio)集成
Q6:Dubbo 服务调用超时了怎么排查?
- 确认超时位置: 看 Consumer 端日志,是 timeout 还是其他异常
- 看 Provider 端日志: 方法是否真的执行慢,还是根本没收到请求
- 网络排查:
telnet Provider_IP Provider_Port检查网络连通性- 线程池满排查: Provider 端 Dubbo 线程池可能满了(日志会有
Thread pool is EXHAUSTED),查看dubbo.protocol.threads配置- GC 排查: Provider 或 Consumer 可能 Full GC 导致 STW,看 GC 日志
- 合理设置超时: Consumer timeout 应大于 Provider timeout + 网络延迟
Q7:Dubbo 怎么做服务降级?
三种方式:
mock属性:@DubboReference(mock = "return null")——Provider 不可用时返回 nullmock类: 实现UserServiceMock类,自定义降级逻辑(返回缓存数据/默认值)- 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) │
└──────────────┘
处理流程说明:
- 请求到达 Gateway,
RoutePredicate匹配路由规则 - 匹配成功后,请求依次经过
GatewayFilter(前置)→GlobalFilter→GatewayFilter(后置) GlobalFilter和GatewayFilter合并后按@Order排序执行- 最后由
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 中,改了需要重启。生产环境需要动态路由:
- 配合 Nacos: 路由配置存在 Nacos,监听配置变更自动刷新路由表
- 数据库 + 内存: 路由规则存数据库,启动时加载到内存,修改后通过接口触发刷新
- 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 怎么解决跨域问题?
两种方式:
- 全局配置(推荐): 在
spring.cloud.gateway.globalcors中配置,对所有路由生效- ** 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 支持集群部署:
- 多 TC 节点: 多个 Seata Server 注册到 Nacos,微服务通过 Nacos 发现 TC 集群
- 共享存储: 所有 TC 节点共享同一个数据库(存储事务日志),保证状态一致
- 负载均衡: 微服务端通过
seata.service.vgroup-mapping将事务组映射到 TC 集群,TC 间通过 Redis/DB 共享数据- 故障转移: 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 分词 + 中文搜索