23.OpenFeign声明式调用与Sentinel流量保护

OpenFeign 与 Sentinel:服务调用、限流与熔断降级
consumer 已经能够通过服务名调用 provider 了。不过,远程接口一多,每次都要拼请求地址、处理参数和转换结果,调用代码就会变得重复。如果 provider 突然变慢,consumer 的请求还可能一直等待,影响自己的其他接口。
这篇文章顺着这两个问题展开:先用 OpenFeign 把远程调用写成接口,再用 Sentinel 保护接口和方法。流控、熔断、热点参数、来源授权和自定义返回,都用少量接口分别演示。
示例使用 Java 17 及以上版本,依赖组合为 Spring Boot 4.1.1、Spring Cloud 2025.1.2、Spring Cloud Alibaba 2025.1.0.0,对应 Sentinel 1.8.9。两个项目仍叫 provider 和 consumer,分别使用 8082、8282 端口,Nacos 服务地址为 127.0.0.1:8848。
依赖和适配器要与项目版本配套。本文使用 Spring MVC 和 jakarta.servlet,Sentinel 的适配器包名为 spring.webmvc_v6x。不要把旧教程中的 javax.servlet、Ribbon 或旧版熔断条件直接混进来。
代码统一使用 com.example.provider、com.example.consumer 包名。沿用已有项目时,请换成自己启动类能够扫描到的包,类上的注解和调用关系保持一致。
1. OpenFeign:把远程调用声明成接口
1.1 是什么,为什么要用
OpenFeign 是声明式 HTTP 客户端。“声明式”可以先理解为:在接口上说明请求发给哪个服务、访问哪个路径、使用什么请求方法,具体的 HTTP 请求由框架完成。
例如,consumer 要查询 provider 提供的信息。使用普通 HTTP 客户端时,需要写请求地址和发送代码;使用 OpenFeign 时,可以把这次调用声明为 ProviderClient.index(),业务代码只负责调用它。
看起来像调用本地方法,实际仍然经过网络。因此,连接失败、读取超时、远程 4xx/5xx 都需要考虑,不能把它当作没有成本的普通方法。
这里也要分清几个组件的职责:
| 组件 | 在这次调用中做什么 |
|---|---|
| Nacos Discovery | 查询 provider 的可用实例 |
| Spring Cloud LoadBalancer | 从实例中选择一个地址 |
| OpenFeign | 根据接口声明发送 HTTP 请求并接收响应 |
| Sentinel | 在配置规则后,对资源执行流量保护 |
早期 Spring Cloud 项目中,Feign 常与 Ribbon 一起使用;本文的 OpenFeign 使用 Spring Cloud LoadBalancer。OpenFeign 本身并不等于负载均衡器。
1.2 consumer 引入依赖
两个项目先保留已有的 Spring Boot 父工程,以及 Spring Cloud、Spring Cloud Alibaba 的 BOM。版本统一由它们管理:
xml
<properties>
<java.version>17</java.version>
<spring-cloud.version>2025.1.2</spring-cloud.version>
<spring-cloud-alibaba.version>2025.1.0.0</spring-cloud-alibaba.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
在 consumer 的 dependencies 中使用下面这些依赖。已经存在的依赖保留一份即可:
xml
<!-- 接收浏览器发来的 HTTP 请求。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- 从 Nacos 发现 provider。 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- 创建声明式 HTTP 客户端。 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- 使用服务名调用时,选择具体实例。 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
LoadBalancer 是 OpenFeign 的可选依赖,使用服务发现时应显式引入。只添加 OpenFeign,不代表服务名调用所需的组件就齐了。
1.3 provider 提供接口
provider 保留 Web MVC、Nacos Discovery 依赖以及自己的启动类。在启动类扫描范围内定义 Controller:
java
package com.example.provider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProviderController {
@Value("${server.port}")
private int port;
@GetMapping("/index")
public String index() {
// 返回端口,便于观察请求落到了哪个实例。
return "provider:" + port;
}
}
provider 的配置:
yaml
server:
port: 8082
spring:
application:
name: provider
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
先访问 http://localhost:8082/index,确认返回 provider:8082,再继续接入 consumer。这样可以先排除 provider 自身接口的问题。
1.4 consumer 声明 Feign 接口
下面的接口放在 consumer 项目中:
java
package com.example.consumer;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
@FeignClient(name = "provider")
public interface ProviderClient {
@GetMapping("/index")
String index();
}
@FeignClient(name = "provider") 指定服务名,要与 provider 注册到 Nacos 的名称一致。@GetMapping("/index") 指定远程请求方法和路径。
这个接口不需要手写实现类。OpenFeign 会创建代理对象,调用 index() 时,再按这些声明发送请求。接口上的映射是在描述远程接口,不会自动给 consumer 新增一个 /index 入口。
1.5 开启接口扫描,再注入使用
consumer 的启动类增加 @EnableFeignClients:
java
package com.example.consumer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@EnableFeignClients
public class ConsumerApplication {
public static void main(String[] args) {
SpringApplication.run(ConsumerApplication.class, args);
}
}
它默认扫描启动类所在包及其子包中的 Feign 接口。接口放在其他包时,可以用 @EnableFeignClients(basePackages = "对应包名") 明确指定。
有了代理对象,consumer 就可以通过构造器注入 ProviderClient:
java
package com.example.consumer;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ConsumerController {
private final ProviderClient providerClient;
public ConsumerController(ProviderClient providerClient) {
this.providerClient = providerClient;
}
@GetMapping("/remote")
public String remote() {
// 这里会发送 HTTP 请求,不是在 consumer 内执行 provider 的方法。
return providerClient.index();
}
}
consumer 的基本配置如下:
yaml
server:
port: 8282
spring:
application:
name: consumer
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
openfeign:
client:
config:
provider:
# 单位为毫秒,分别限制建立连接和等待响应的时间。
connectTimeout: 1000
readTimeout: 2000
启动 Nacos、provider、consumer 后,访问:
powershell
curl.exe http://localhost:8282/remote
只有一个 provider 实例时,结果为:
text
provider:8082
这次请求的过程是:浏览器访问 consumer,consumer 调用 Feign 代理,代理通过服务发现和 LoadBalancer 选出 provider,最后发送 GET /index。
再启动一个 8083 端口的 provider,就可以观察多实例调用。若要按 Nacos 控制台实例的原生权重分配请求,在 consumer 的 spring.cloud 下增加:
yaml
loadbalancer:
nacos:
enabled: true
然后进入 Nacos 控制台的服务列表,打开 provider 的实例详情,在实例编辑窗口修改“权重”。这里调整的是原生权重,与自定义 metadata.weight 不同。OpenFeign 沿用 LoadBalancer 的实例选择配置,不需要为它重新写一套权重算法。
1.6 传递参数时,要明确参数放在哪里
远程调用最终是 HTTP 请求,方法参数仍要对应到路径、查询字符串或 JSON 正文。例如,provider 有查询用户接口时,Feign 可以这样声明:
java
@GetMapping("/users/{id}")
UserVO findById(@PathVariable("id") Long id);
@GetMapping("/users")
List<UserVO> search(@RequestParam("name") String name);
@PostMapping("/users")
UserVO create(@RequestBody UserForm form);
这段是请求声明方式的补充,不替换上面的 /index 示例;使用时需要 provider 提供对应接口,并在 consumer 定义 UserVO、UserForm 等数据类型。
@PathVariable 放入路径,@RequestParam 放入查询字符串,@RequestBody 放入 JSON 正文。客户端声明和服务端约定要一致,Java 方法名则不要求相同。
2. Sentinel:防止局部故障拖垮整个调用链
2.1 先看服务雪崩怎样发生
假设订单服务要调用用户服务。用户服务突然变慢,订单请求就一直占着线程等待;等待的请求越来越多,订单服务的线程也被占满,连不依赖用户查询的接口都可能受影响。
一个局部问题沿着调用关系扩散,最后让多个服务不可用,这就是常说的服务雪崩。高并发会放大这个问题,服务超时、连接池耗尽等也可能成为起点。
通常需要配合几种手段:
- 超时:限制一次远程调用等待多久。前面的 Feign 超时配置就是在做这件事。
- 限流:限制进入某个资源的请求速率或并发数,避免请求量超过处理能力。
- 熔断:发现依赖持续变慢或报错后,暂时停止新的调用,留出恢复时间。
- 降级:无法正常完成时,给调用方一个业务上允许的替代结果或明确的失败提示。
熔断器可以类比家里的保险丝:问题出现后先切断一部分调用,保护其他部分。区别是软件熔断器可以在一段时间后尝试恢复。
Hystrix 是早期常见的实现,目前已停止活跃开发并进入维护状态;这里使用 Sentinel。Sentinel 提供规则判断和统计能力,但不会自动替业务决定“失败时该返回什么”。
2.2 什么是资源,什么是规则
资源是 Sentinel 需要统计和保护的对象,例如一个 HTTP 接口、一个方法,或者一次远程调用。
规则说明怎样保护这个资源,例如 /read 每秒允许多少请求、slowQuery 的慢调用比例达到多少时熔断。
可以把它理解为“先给需要保护的操作取名字,再给这个名字设置规则”。资源名必须与实际统计到的名称一致;给 /hot 配规则,不等于给名字为 hot 的方法资源配了同一条规则。
2.3 引入 Sentinel
在 consumer 中增加:
xml
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
这份 starter 在本文版本中会自动配置 Web MVC 拦截器和 @SentinelResource 切面。普通 HTTP 接口可以自动成为资源;需要给某个方法单独命名、读取方法参数或设置方法级处理逻辑时,再使用 @SentinelResource。
在已有 spring.cloud 下合并 Sentinel 配置,不要重复写第二个 spring::
yaml
sentinel:
transport:
dashboard: 127.0.0.1:8858
port: 8719
# 示例值:先按下面的步骤查出 Docker 能访问的宿主机 IPv4,再替换。
client-ip: 192.168.65.254
完整属性名以 spring.cloud.sentinel 开头。这里的 8858 是宿主机映射出来的控制台端口,8719 是 consumer 中用于接收控制台命令的端口,和业务端口 8282 不同。
client-ip 是应用通过心跳上报给控制台的回连地址。Sentinel 1.8.9 控制台要求这里是数值 IP,不能直接填写 host.docker.internal,否则注册接口会返回 invalid ip: host.docker.internal。上面的 192.168.65.254 只是本次环境的结果,先启动控制台,再查询自己环境中的地址。
2.4 在 Docker 中启动控制台
先从 Sentinel 官方发行页 下载 sentinel-dashboard-1.8.9.jar,放进一个单独的本地目录。
在这个目录打开 PowerShell,用 JRE 镜像运行官方控制台程序:
powershell
$dashboardJar = (Resolve-Path .\sentinel-dashboard-1.8.9.jar).Path
docker run -d --name study-sentinel-dashboard `
-p 127.0.0.1:8858:8080 `
--mount "type=bind,source=$dashboardJar,target=/app/sentinel-dashboard.jar,readonly" `
eclipse-temurin:17-jre `
java "-Dserver.port=8080" `
"-Dcsp.sentinel.dashboard.server=localhost:8080" `
"-Dproject.name=sentinel-dashboard" `
-jar /app/sentinel-dashboard.jar
浏览器打开 http://localhost:8858。官方控制台默认用户名和密码均为 sentinel;这里只将端口绑定到本机,供本地学习使用。
接下来,在 PowerShell 中查询容器访问 Windows 宿主机时使用的 IPv4:
powershell
docker exec study-sentinel-dashboard getent ahostsv4 host.docker.internal
例如本机得到:
text
192.168.65.254 STREAM host.docker.internal
192.168.65.254 DGRAM
192.168.65.254 RAW
取第一列的数值地址填入 client-ip。然后启动 consumer,检查控制台容器能否访问应用的命令端口;把下面地址替换为刚查到的结果:
powershell
docker exec study-sentinel-dashboard curl -sS --connect-timeout 3 http://192.168.65.254:8719/version
预期返回 1.8.9。8719 由应用中的 Sentinel 启动,默认延迟初始化;如果此时提示连接失败,先访问一次业务接口,等初始化完成后再检查。检查时确认应用确实启动、配置端口未被占用,必要时查看应用日志中的实际命令端口。若请求超时,还需检查 Windows 防火墙是否允许 Docker 到这个端口的连接。
访问 http://localhost:8282/remote 或后面新增的 /read,稍等心跳上报,控制台左侧就会出现 consumer。展开应用,进入“簇点链路”并刷新,能看到 /read 等已经访问过的资源,才说明控制台也能回连应用。
2.5 为什么控制台能看到应用,却可能保存不了规则
控制台和应用需要双向连通:
text
consumer → 宿主机 8858 → 控制台:发送心跳
控制台 → 宿主机数值 IP:8719 → consumer:查询资源、下发规则
浏览器 → consumer:8282:发起业务请求
localhost 是相对于当前进程所在环境的。控制台在容器中时,访问 localhost:8719 指向的是容器自己,所以 consumer 要上报一个容器能够访问的宿主机地址。Docker Desktop 提供的 host.docker.internal 可以帮助查出这个地址,但上报时需要使用解析得到的数值 IP。
若控制台也直接运行在宿主机,可把 client-ip 改为 127.0.0.1。若应用和控制台都运行在容器中,应用应上报控制台能访问的应用容器数值 IP;不要把容器名直接填进这个属性。Docker Desktop 或网络环境改变后,重新查询地址,不能假设示例 IP 永远不变。
控制台不转发业务流量,规则实际在应用内执行。学习阶段从控制台新增的规则通常只保存在应用和控制台的内存中,应用重启后不能认为它们仍然存在。生产使用需要另行配置持久化数据源和规则发布流程,单独引入 Nacos Discovery 并不会自动持久化 Sentinel 规则。
3. 流控规则:哪些请求可以进入
限流适合处理“请求来得太多”的问题。例如用户查询每次都正常,但短时间大量请求会占满数据库连接,就可以先限制查询入口。
在 consumer 中增加两个最简单的接口,分别代表查询和修改用户:
java
package com.example.consumer;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class FlowController {
@GetMapping("/read")
public String read() {
return "查询用户";
}
@GetMapping("/write")
public String write() {
return "修改用户";
}
}
/write 在这里仅返回文字,用 GET 方便观察规则,不会真的写数据库;实际修改数据的接口应按业务使用 POST、PUT 等方法。
启动后先分别访问一次,让控制台的“簇点链路”中出现 /read 和 /write。每做一种规则实验,先删除上一次的规则,否则两条规则一起生效,很难判断是哪一条拦住了请求。
3.1 直接限流:观察自己,限制自己
直接限流就是根据当前资源的访问量,决定是否继续放行。
在 consumer 的“簇点链路”中找到 /read,点击“流控”,填写:
| 配置项 | 示例值 |
|---|---|
| 资源名 | /read |
| 针对来源 | default,表示不限定来源 |
| 阈值类型 | QPS |
| 单机阈值 | 1 |
| 流控模式 | 直接 |
| 流控效果 | 快速失败 |
保存后,在 PowerShell 连续发送请求:
powershell
1..10 | ForEach-Object {
curl.exe -i -s http://localhost:8282/read
}
部分请求返回“查询用户”,超过当前统计窗口阈值的请求会被拦住。当前 Web MVC 适配器的默认返回是 HTTP 429,正文为 Blocked by Sentinel (flow limiting)。
QPS 表示每秒请求数。单机阈值 1 限制的是每个实例上的这个资源,不是整个集群合计只能访问一次。实际计数与统计窗口有关,不要把它理解成自然时钟每秒整点精确重置的业务配额。
另外一种阈值类型是并发线程数,关注的是当前同时在处理多少个资源调用。一个查询耗时很长时,即使 QPS 不高,也可能占用很多线程;这时限制并发数更贴近要保护的容量。
3.2 关联限流:观察 B,限制 A
假设查询和写入共用数据库,希望写入繁忙时先减少查询,让写入有更多处理余量。这时可以让 /read 关联 /write。
规则仍然添加在 /read 上,展开高级选项后设置:
- 阈值类型:QPS,单机阈值:1。
- 流控模式:关联。
- 关联资源:
/write。 - 流控效果:快速失败。
连续访问 /write,再立刻访问 /read:
powershell
1..10 | ForEach-Object {
curl.exe -s http://localhost:8282/write
}
curl.exe -i http://localhost:8282/read
当 /write 的流量达到规则阈值时,/read 会被限制;这条规则本身不会限制 /write。
这里的方向要记清楚:规则配在 A,关联资源填 B,观察 B 的流量,拦截 A 的请求。 两个资源不必相互调用,关联的原因是它们争用同一份业务容量。
3.3 链路限流:只限制从某个入口来的调用
关联限流关心两个资源的流量关系。再考虑另一种需求:两个入口都调用同一个用户查询方法,只想限制入口 A 对它的使用,入口 B 继续正常查询。
text
/entry-a → queryUser
/entry-b → queryUser
这时可以对 queryUser 设置链路限流,只统计并限制从 /entry-a 进入的这一条调用路径。
3.3.1 保留不同入口的上下文
本文 starter 已包含注解切面,不需要再重复引入一份 Sentinel 依赖,也不需要手动注册第二个 SentinelResourceAspect。
在已有 spring.cloud.sentinel 下增加:
yaml
web-context-unify: false
这个配置让不同 Web 入口保留各自的 Sentinel 上下文。默认统一上下文时,两条入口路径可能无法按预期分开,链路规则就不好匹配。
这也是链路示例需要的配置:starter 负责自动注册 Web MVC 拦截器和切面,配置项负责保留入口差异,不必再写一份手动拦截器配置与它叠加。
3.3.2 给共享方法定义资源名
先定义真正被两个入口调用的 Service:
java
package com.example.consumer;
import com.alibaba.csp.sentinel.annotation.SentinelResource;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import org.springframework.stereotype.Service;
@Service
public class UserQueryService {
@SentinelResource(value = "queryUser", blockHandler = "queryBlocked")
public String query() {
return "张三";
}
public String queryBlocked(BlockException exception) {
// 被 Sentinel 规则拦截时,不执行 query() 的业务代码。
return "用户查询过于频繁";
}
}
value = "queryUser" 给方法资源命名,blockHandler 指定被规则拦截后的处理方法。处理方法要与原方法返回类型一致,参数列表是在原参数后追加 BlockException;这里原方法没有参数,所以只接收这个异常。
注解通过 Spring AOP 代理生效。把 Service 交给 Spring 管理,并从其他 Bean 注入调用;同一个对象内部用 this.query() 调用,通常不会经过这层代理。
3.3.3 两个入口调用同一个 Service
java
package com.example.consumer;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class EntryController {
private final UserQueryService userQueryService;
public EntryController(UserQueryService userQueryService) {
this.userQueryService = userQueryService;
}
@GetMapping("/entry-a")
public String entryA() {
return userQueryService.query();
}
@GetMapping("/entry-b")
public String entryB() {
return userQueryService.query();
}
}
先分别访问两个入口,然后给资源 queryUser 添加规则:QPS 为 1,流控模式选“链路”,入口资源填 /entry-a,流控效果选“快速失败”。入口名称以控制台实际展示为准,本例未开启按 HTTP 方法区分资源的配置。
连续访问 /entry-a,超过阈值后返回“用户查询过于频繁”;连续访问 /entry-b,仍能返回“张三”。限制的是 A 入口下的 queryUser,不是给所有入口的查询方法设置总限额。
本例的 blockHandler 返回普通字符串,因此处理成功后 HTTP 状态仍是 200。它是在演示方法级替代返回,不能仅凭状态码判断是否触发了规则;若接口需要表达失败,应改用合适的响应对象和状态码。
4. 流控效果:超过阈值后怎样处理
前面介绍“根据谁的流量来判断”,这里再看“判断超过阈值后怎么做”。为了容易观察,下面都使用 /read 的直接 QPS 规则,一次只保留一种效果。
4.1 快速失败
快速失败就是不能通过时立即拒绝,不继续等待。
在 Sentinel 内部,这类拒绝通过 BlockException 的具体子类表示。Web 适配器可以把它转换为 HTTP 返回,注解资源也可以交给 blockHandler 处理,所以不一定会把 Java 异常栈直接展示给请求方。
用户频繁刷新列表、重复访问详情时,快速失败通常比让大量请求占着线程等待更合适。
4.2 Warm Up:给系统一个预热过程
新实例刚启动时,缓存可能还是空的,数据库连接和热点数据也没有充分准备。如果马上承接全部流量,反而容易被压垮。
Warm Up 会让允许通过的速率从较低水平逐步升高。给 /read 设置:
- 阈值类型:QPS。
- 单机阈值:30。
- 流控模式:直接。
- 流控效果:Warm Up。
- 预热时长:10 秒。
默认冷加载因子为 3,冷态起始允许速率大约为 30 / 3 = 10 QPS,在持续访问下逐步接近 30 QPS。它不是“前 10 秒固定只能 10 次,第 11 秒突然变成 30 次”。
观察时需要持续、足够的请求量,结合控制台的通过 QPS 和拒绝 QPS 看变化。手动刷新两三次,通常看不出预热曲线。这个过程也不会自动替你加载缓存,业务需要的初始化仍要自己完成。
4.3 排队等待:按间隔放行请求
有些业务更在意请求平稳进入。例如下游只适合每秒处理少量同步请求,短时突发时可以让请求稍等一会儿,而不是立即全部拒绝。
给 /read 设置:QPS 为 5,流控模式为直接,流控效果为“排队等待”,超时时间为 1000 毫秒。
每秒 5 次相当于按大约 200 毫秒的间隔放行。请求到达时,如果需要等待的时间还在允许范围内,就等待后进入;预计等待超过 1000 毫秒,就直接被拦截。
text
请求到达 → 计算需要等待多久
├─ 未超过等待上限 → 等待 → 执行业务
└─ 超过等待上限 → 拒绝
这一步发生在业务执行之前,不是业务调用失败后再重试。放行后如果数据库报错,这条排队规则不会自动帮它重新执行。
可以连续请求并观察耗时:
powershell
Measure-Command {
1..5 | ForEach-Object {
curl.exe -s http://localhost:8282/read
}
}
请求本身很快时,也会因为放行间隔产生等待。顺序请求能观察节奏;要观察等待超限的拒绝,需要让更多请求同时到达。
排队等待主要用于 QPS 规则,当前实现不适合很高的 QPS(如 1000 及以上)。同步等待仍会占用请求线程;大量任务需要可靠排队、持久化和重试时,应考虑消息队列,不能把这里的等待当作完整任务队列。
5. 熔断降级:依赖变慢或报错时先停一停
限流主要保护容量,熔断则关注调用是否持续不健康。即使请求量不大,一个依赖不断超时,也值得暂时停止调用。
Sentinel 1.8.x 使用慢调用比例、异常比例、异常数三种策略。它们都要区分两个时间:
- 统计时长:回看这一段时间内完成的调用,例如 5000 毫秒。
- 熔断时长:触发后暂时拒绝新调用多久,例如 8 秒。
这两个时间的用途不同,不需要简单地规定“熔断时间一定大于统计时间”。
5.1 熔断器怎样恢复
可以把熔断器理解为三种状态:
- 关闭:正常允许调用,同时统计结果。
- 打开:达到熔断条件,新的调用暂时被拒绝。
- 半开:熔断时间到后,放行一个探测请求,看依赖是否恢复。
探测符合当前策略的恢复条件,就关闭熔断器;仍然很慢或继续异常,就再次打开。熔断时间到了不等于把全部流量立即恢复。
规则判断一般在调用完成、统计到结果后发生。已经放行并正在执行的请求不会因为熔断器打开而被强制终止,慢调用阈值也不是 HTTP 读取超时设置。
5.2 先准备能模拟慢调用和异常的接口
下面的 Controller 放在 consumer 中。每个方法使用单独的资源名,便于分别设置规则:
java
package com.example.consumer;
import com.alibaba.csp.sentinel.annotation.SentinelResource;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class DegradeController {
@GetMapping("/slow")
@SentinelResource(value = "slowQuery", blockHandler = "slowBlocked")
public String slow(@RequestParam(defaultValue = "false") boolean slow)
throws InterruptedException {
if (slow) {
// 只用于模拟一次耗时 200 毫秒的查询。
Thread.sleep(200);
}
return "查询完成";
}
public String slowBlocked(boolean slow, BlockException exception) {
return "查询服务正在恢复,请稍后再试";
}
@GetMapping("/failure")
@SentinelResource(value = "errorQuery", blockHandler = "errorBlocked")
public String error(@RequestParam(defaultValue = "false") boolean fail) {
if (fail) {
// 让异常从方法中抛出,Sentinel 切面才能统计到这次失败。
throw new IllegalStateException("模拟数据库查询失败");
}
return "查询完成";
}
public String errorBlocked(boolean fail, BlockException exception) {
return "查询服务正在恢复,请稍后再试";
}
}
/slow?slow=true 模拟慢查询,/failure?fail=true 模拟查询失败。这里特意不用 /error,因为 Spring Boot 默认会把它用于错误处理,容易和业务演示混淆。
这些资源在 consumer 内是为了方便学习;实际保护远程依赖时,应把规则放在 consumer 的那次远程调用资源上,避免用整个接口的混合耗时掩盖真正出问题的依赖。
5.3 慢调用比例:慢请求占比过高时熔断
RT 是响应时间。慢调用比例策略先规定“超过多久算慢”,再判断“慢调用占比是否太高”。
访问一次 /slow,然后给资源 slowQuery 新增熔断规则:
| 配置项 | 示例值 |
|---|---|
| 熔断策略 | 慢调用比例 |
| 最大 RT | 100 毫秒 |
| 比例阈值 | 0.6 |
| 最小请求数 | 5 |
| 统计时长 | 5000 毫秒 |
| 熔断时长 | 8 秒 |
/slow?slow=true 耗时约 200 毫秒,超过 100 毫秒,所以被统计为慢调用。在同一统计窗口内,请求数达到最小值,并且慢调用比例超过 0.6 后,才会打开熔断器。
例如,一个窗口内完成 5 次调用,其中 4 次为慢调用,比例是 4 / 5 = 0.8,超过 0.6;如果只有 1 次慢调用,即使比例为 100%,也没有达到最小请求数,不能据此立即熔断。
先连续制造慢请求,再访问正常查询:
powershell
1..10 | ForEach-Object {
curl.exe -s "http://localhost:8282/slow?slow=true"
}
curl.exe -s http://localhost:8282/slow
前面已经放行的慢请求仍然返回“查询完成”;触发熔断后的请求返回“查询服务正在恢复,请稍后再试”,不会再执行 Thread.sleep(200)。如果请求恰好跨越统计窗口或访问速度很慢,触发位置可能不同,应结合控制台统计观察。
停止制造慢请求,等待 8 秒后访问不带参数的 /slow,让快速调用作为探测请求。如果探测满足恢复条件,后续查询恢复正常。
这里不采用“先有一个慢请求,再要求 1 秒内连续五个都慢”的旧判断方式。当前判断依据是配置的统计时长、最小请求数和慢调用比例。
5.4 异常比例:失败占比过高时熔断
异常比例关注“完成的调用中,有多少抛出了异常”。适合远程接口或数据库查询连续失败的场景。
删除上一条熔断规则,给 errorQuery 设置:异常比例 0.5、最小请求数 5、统计时长 5000 毫秒、熔断时长 8 秒。
例如,窗口内完成 5 次调用,其中 3 次异常,异常比例为 3 / 5 = 0.6,超过 0.5,满足最小请求数后会触发熔断。
可以先用全部失败的请求快速观察:
powershell
1..10 | ForEach-Object {
curl.exe -i -s "http://localhost:8282/failure?fail=true"
}
熔断前,方法确实执行并抛出业务异常,未统一处理时会返回 HTTP 500;触发熔断后,方法不再执行,转到 errorBlocked() 返回恢复提示。这两种结果代表不同阶段。
@SentinelResource 切面会自动记录从被保护方法中抛出的异常。若在方法内部把异常全部捕获后返回普通字符串,切面可能只看到一次正常返回,异常比例就无法按预期增加。手动使用 Sentinel API 定义资源时,则需要自己按规范记录异常并退出资源。
Sentinel 自己产生的 BlockException 不作为业务异常再次计入异常熔断,避免“被拦截”又不断增加业务失败统计。
5.5 异常数:失败次数超过上限时熔断
异常数不看占比,主要看统计时长内累计失败了多少次。例如某个第三方接口失败成本很高,希望连续一段时间内失败超过 2 次就先暂停调用。
继续使用 errorQuery,将策略改为“异常数”:异常数阈值 2、最小请求数 5、统计时长 5000 毫秒、熔断时长 8 秒。
在这个窗口内完成 5 次调用,且至少有 3 次异常,就超过异常数阈值并满足最小请求数。即使这时异常比例很低,异常数规则仍可能触发。
Sentinel 1.8.9 的异常数策略也检查最小请求数。统计时长可以按业务设置,例如 1000、5000 或 60000 毫秒,并不是固定统计一分钟。
因此,不应机械地要求熔断时长大于 60 秒。应该根据依赖恢复速度设置熔断时长,再用半开探测判断是否恢复;如果依赖仍然失败,再次熔断本来就是合理的保护行为。
6. 热点参数:给不同参数值分别限额
普通 QPS 规则统计整个资源。假设同一个商品查询接口,大部分流量都集中在一个热门商品上,单独限制这个商品,比把所有商品一起限制更细致。
热点参数规则会根据方法参数的值分别计数。下面继续使用 num1、num2,先看清规则怎样识别参数,再把这个思路用于商品编号或用户编号。
6.1 给方法资源传入参数
java
package com.example.consumer;
import com.alibaba.csp.sentinel.annotation.SentinelResource;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HotController {
@GetMapping("/hot")
@SentinelResource(value = "hot", blockHandler = "hotBlocked")
public String hot(
@RequestParam(value = "num1", required = false) Integer num1,
@RequestParam(value = "num2", required = false) Integer num2) {
return num1 + "-" + num2;
}
public String hotBlocked(
Integer num1, Integer num2, BlockException exception) {
// 只处理规则拦截,不执行原来的 hot 方法。
return "该参数访问过快,请稍后再试";
}
}
这里需要 @SentinelResource,因为参数规则要读取方法调用时传进来的参数。普通 URL 资源不会自动把查询字符串转换为这份方法参数列表。
访问 /hot?num1=1&num2=2,得到 1-2。参数下标从 0 开始:num1 是 0,num2 是 1,与 URL 中参数的书写先后顺序无关。
6.2 同一个值共享额度,不同值分别计算
在控制台“热点规则”中新增规则:资源名填 hot,参数索引填 0,单机阈值填 1,统计窗口时长填 1 秒,使用默认快速失败效果。
先连续访问同一个 num1:
powershell
1..5 | ForEach-Object {
curl.exe -s "http://localhost:8282/hot?num1=1&num2=10"
}
同一窗口内,num1=1 的请求超过额度后,就会返回“该参数访问过快,请稍后再试”。把 num2 改成 20,不会让 num1=1 获得一份新额度,因为规则索引选的是 0。
再访问 /hot?num1=2,它使用 num1=2 对应的额度,不会因为 num1=1 已经被限制就一定被拦住。
如果只访问 /hot?num2=10,num1 为 null,这条针对索引 0 的热点规则会跳过空值统计;它不是“整个接口都不受任何限制”,其他 URL 规则、方法规则仍然可能生效。
6.3 参数例外项:给指定值设置不同阈值
有时希望一个重要商品获得更高额度。展开热点规则的高级设置,增加参数例外项:
- 参数类型:
int,与示例Integer参数对应。 - 参数值:
100。 - 限流阈值:
3。
此时在同一个 1 秒窗口内,num1=100 按 3 次的额度判断,其他普通值仍按 1 次判断。
例外项是替换这个值的阈值,不是无条件放行,也不是在原额度上额外加 3 次。参数类型和实际类型不匹配时,例外项可能匹配不到,所以配置后要分别验证普通值和例外值。
7. 授权规则:根据调用来源放行或拒绝
热点规则看参数值,授权规则则看“请求来自哪个应用”。例如一个内部查询接口只允许来自 web、admin 两个调用来源,其他来源先拒绝。
Sentinel 支持白名单和黑名单:白名单只允许列出的来源,黑名单拒绝列出的来源。来源名称需要应用自己解析,Sentinel 不会自动知道是谁发起请求。
7.1 解析来源,并注册到 Spring
RequestOriginParser 是 Sentinel Web MVC 适配器提供的接口,负责从请求中提取来源字符串。为了容易演示,先读取 X-App 请求头:
java
package com.example.consumer;
import com.alibaba.csp.sentinel.adapter.spring.webmvc_v6x.callback.RequestOriginParser;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OriginConfig {
@Bean
public RequestOriginParser requestOriginParser() {
return request -> {
String origin = request.getHeader("X-App");
// 缺少来源时也给出明确名字,便于观察白名单的效果。
return origin == null || origin.isBlank() ? "unknown" : origin;
};
}
}
这里的 lambda 就是 RequestOriginParser 的实现,@Bean 把它交给 Spring。本文版本的 starter 会自动找到这个 Bean 并配置到 Web MVC 拦截器,不需要再手动调用旧 Servlet Filter 的配置 API。
它不是读取名为 origin 的任意查询参数,而是按我们写的逻辑读取请求头;控制台里的“流控应用”应填写解析后得到的来源名。
7.2 设置白名单并验证
删除前面 /read 上的流控规则,然后给资源 /read 添加授权规则:授权类型选白名单,流控应用填写 web,admin。
带允许来源的请求:
powershell
curl.exe -i -H "X-App: web" http://localhost:8282/read
返回“查询用户”。再用其他来源:
powershell
curl.exe -i -H "X-App: other" http://localhost:8282/read
请求被授权规则拒绝。不带 X-App 时,我们解析出的是 unknown,不在白名单中,也会被拒绝。
7.3 黑名单怎样变化
将同一条规则改为黑名单,流控应用填 other。此时 other 被拒绝,web、admin 以及未列出的来源可以通过,前提是没有其他规则阻止它们。
来源授权适合服务流量治理,但这个演示请求头可以由客户端自己填写,不能证明调用方身份。真实系统应由可信网关或认证机制确认来源,再向后传递受信任的信息;用户登录、角色和数据权限仍由相应的安全方案负责。
8. 自定义被拦截后的返回内容
到这里,请求已经可能因为限流、熔断或来源授权被拒绝。接下来要让调用方读到明确、统一的结果,而不是始终收到同一句默认提示。
先分清处理位置:Web URL 资源由 MVC 拦截器保护;@SentinelResource 定义的方法资源由注解切面保护。它们的处理入口不同。
8.1 Web 接口统一返回 JSON
BlockExceptionHandler 是当前 Sentinel Web MVC 适配器提供的回调接口。实现它,就能处理 Web 拦截器在进入 Controller 前产生的规则拒绝。
java
package com.example.consumer;
import com.alibaba.csp.sentinel.adapter.spring.webmvc_v6x.callback.BlockExceptionHandler;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.alibaba.csp.sentinel.slots.block.authority.AuthorityException;
import com.alibaba.csp.sentinel.slots.block.degrade.DegradeException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
@Component
public class SentinelBlockHandler implements BlockExceptionHandler {
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
String resourceName,
BlockException exception) throws Exception {
int status = 429;
String message = "访问过快,请稍后再试";
if (exception instanceof DegradeException) {
status = 503;
message = "服务正在恢复,请稍后再试";
} else if (exception instanceof AuthorityException) {
status = 403;
message = "当前调用来源不允许访问";
}
// 这里已经拒绝请求,直接写响应,不再执行 Controller。
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"code\":" + status + ",\"message\":\"" + message + "\"}");
}
}
@Component 已经完成注册,starter 会自动使用这个 Bean,不必再写一份重复的配置类。这里的提示文字固定,所以用简单字符串展示 JSON;如果返回内容含用户输入、异常信息等动态内容,应使用项目的 JSON 序列化工具,避免手工拼接造成转义错误。
给 /read 设置直接 QPS 规则后,超限结果变成:
text
HTTP 429
json
{
"code": 429,
"message": "访问过快,请稍后再试"
}
来源不被允许时返回 403;Web 资源触发熔断时返回 503。状态码是这里主动设置的,不是只在 JSON 中写一个 code 就能改变 HTTP 状态。
8.2 注解方法使用自己的 blockHandler
前面的 queryUser、slowQuery 和 hot 已经指定了各自的 blockHandler。它们由注解切面处理,不会自动改用上面的 MVC 回调。
例如,hotBlocked() 返回字符串,@RestController 会把它写入响应;MVC 的 SentinelBlockHandler 不会再把它包装为上面的 JSON。这也是为什么要先区分“URL 资源”和“方法资源”。
若要让方法资源也返回明确状态,可以把原方法和处理方法一起改成 ResponseEntity<String>。下面只展示 /hot 的返回方式变化,替换对应方法,不要再保留重复的同路径映射:
java
@GetMapping("/hot")
@SentinelResource(value = "hot", blockHandler = "hotBlocked")
public ResponseEntity<String> hot(
@RequestParam(value = "num1", required = false) Integer num1,
@RequestParam(value = "num2", required = false) Integer num2) {
return ResponseEntity.ok(num1 + "-" + num2);
}
public ResponseEntity<String> hotBlocked(
Integer num1, Integer num2, BlockException exception) {
// 返回类型和原方法一致,同时明确表达限流状态。
return ResponseEntity.status(429).body("该参数访问过快,请稍后再试");
}
这里额外导入 org.springframework.http.ResponseEntity。实际项目也可以将正文统一为业务响应对象。
8.3 限流异常与业务异常分开处理
常见的规则拒绝包括 FlowException(普通流控)、ParamFlowException(热点参数流控)、DegradeException(熔断)、AuthorityException(来源授权),都属于 BlockException。
数据库异常、参数校验错误和空指针异常不是同一类问题,不应全部写成“访问太快”。注解的 blockHandler 专门处理规则拒绝,业务异常可以交给项目的异常处理机制;如果使用注解的 fallback,也应明确哪些业务失败允许使用替代结果。
例如用户不存在应该返回“用户不存在”,支付失败不能返回“支付成功”。统一返回格式是为了方便调用方判断问题,不是把所有错误隐藏起来。
9. OpenFeign 整合 Sentinel
前面保护的是 consumer 自己的接口和方法。现在回到最初的远程调用:consumer 通过 OpenFeign 访问 provider,如何在 provider 不可用或调用被规则拦住时返回清楚的提示?
这里继续沿用 ProviderClient.index(),只修改 consumer。provider 的 /index 无需为了调用方降级而改写。
9.1 开启 Feign 的 Sentinel 集成
consumer 已有 OpenFeign、LoadBalancer、Nacos Discovery 和 Sentinel starter,在 YAML 顶层开启 Feign 集成。把前面的配置合在一起,consumer 最终可以使用下面这一份:
yaml
server:
port: 8282
spring:
application:
name: consumer
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
openfeign:
client:
config:
provider:
connectTimeout: 1000
readTimeout: 2000
sentinel:
web-context-unify: false
transport:
dashboard: 127.0.0.1:8858
port: 8719
# 换成第 2.4 节从控制台容器内查询到的宿主机数值 IPv4。
client-ip: 192.168.65.254
feign:
sentinel:
enabled: true
注意它是 feign.sentinel.enabled,不是放在 spring.cloud.openfeign 下。前面设置的连接超时、读取超时仍然保留。
本文选择 Alibaba 的 Sentinel Feign 集成,不需要再同时开启另一套 spring.cloud.openfeign.circuitbreaker.enabled。依赖和保护机制不同,不能只看到都叫“熔断”就把所有开关一起打开。
开启后,Feign 调用会成为 Sentinel 资源。对于当前接口,资源名为:
text
GET:http://provider/index
实际以控制台出现的资源名为准。它属于 consumer 中的一次出站调用,与 provider 内部的 /index Web 资源、consumer 的 /remote 入口是三个不同的资源。
9.2 实现 fallback
创建一个实现 ProviderClient 的类,描述远程调用失败后的替代行为:
java
package com.example.consumer;
import org.springframework.stereotype.Component;
@Component
public class ProviderFallback implements ProviderClient {
@Override
public String index() {
// 返回明确提示,不把调用失败伪装成真实的查询结果。
return "用户服务暂时不可用,请稍后再试";
}
}
再把 ProviderClient 的注解改为下面这样,方法保持不变:
java
@FeignClient(name = "provider", fallback = ProviderFallback.class)
public interface ProviderClient {
@GetMapping("/index")
String index();
}
fallback 指向的是一个 Spring Bean,所以实现类上的 @Component 不能漏。它实现同一个接口,因此调用方仍然通过 ProviderClient.index() 使用,Controller 的注入和调用代码不用变化。
9.3 看清正常调用与降级调用
先确保 provider 正常运行:
powershell
curl.exe http://localhost:8282/remote
结果为 provider:8082,说明正常调用仍然访问远程服务。
然后有两种简单观察方式,分别验证不同触发原因。
**验证规则拒绝:**在 consumer 的簇点链路中找到 GET:http://provider/index,添加直接 QPS 规则,阈值设为 1、效果为快速失败。连续请求 /remote,超限调用返回“用户服务暂时不可用,请稍后再试”,被拒绝的这部分调用不会再发送给 provider。
**验证远程故障:**删除上述流控规则,停止 provider,再访问 /remote。当服务发现没有可用实例,或者地址仍在缓存但连接失败、读取超时、远程调用抛出异常时,会进入 fallback。发现列表更新和客户端超时会影响等待时间,不保证停止实例后的第一毫秒就立即返回。
这两种情况都可能使用同一份 fallback,但原因不同:前者是规则主动阻止调用,后者是调用本身无法正常完成。
这个字符串示例的 fallback 会让 /remote 返回 HTTP 200 和提示正文,方便先看清调用切换。对外 API 应设计明确的失败状态或响应结构,让前端能够判断是否降级,不能把“有字符串返回”当作查询成功。
9.4 加了 fallback,不代表自动配置好了熔断
fallback 是“失败后做什么”,熔断规则是“何时暂时阻止后续调用”。只添加 fallback,没有配置熔断规则,不会自动建立一套业务合适的慢调用阈值。
需要保护 provider 时,在 consumer 的 GET:http://provider/index 上添加慢调用比例或异常比例等规则。例如设置最小请求数 5、统计时长 5000 毫秒、异常比例 0.5、熔断时长 8 秒。
这样,远程调用的失败会被 Sentinel 集成记录;达到规则条件后,新请求在 consumer 内就被熔断,再进入 fallback。控制台中可以分别看入口 /remote 和远程资源:fallback 正常返回时,入口可能看起来执行成功,但远程资源仍记录了失败或拒绝。
9.5 什么时候需要知道原始异常
简单提示使用 fallback 就能说明流程。如果还需要区分超时、无实例和规则拒绝,并记录失败原因,可以使用 FallbackFactory。
下面是替代方案,不与上面的 fallback 同时配置:
java
package com.example.consumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
@Component
public class ProviderFallbackFactory implements FallbackFactory<ProviderClient> {
private static final Logger log =
LoggerFactory.getLogger(ProviderFallbackFactory.class);
@Override
public ProviderClient create(Throwable cause) {
// 日志保留原因;返回给用户的内容不要直接暴露异常细节。
log.warn("调用 provider 失败", cause);
return () -> "用户服务暂时不可用,请稍后再试";
}
}
这里 ProviderClient 只有一个抽象方法,所以可以使用 lambda 返回实现对象。如果接口增加多个抽象方法,就要提供相应实现。
将接口注解改为:
java
@FeignClient(name = "provider", fallbackFactory = ProviderFallbackFactory.class)
调用方仍然不用修改。普通 fallback 适合固定的替代处理,factory 则多提供了原始失败原因。
查询类接口有时可以降级为缓存数据,并标明数据时间;创建订单、扣款等写操作则必须谨慎。读取超时并不证明远程端没有执行成功,不能在 fallback 中直接重做一次写入,也不能返回虚构的成功结果。是否重试需要结合幂等设计和业务状态确认。
10. 把配置与现象对应起来排查
10.1 Feign 接口无法注入或服务名调用失败
先检查启动类是否开启 @EnableFeignClients,接口是否在扫描范围内。再确认 LoadBalancer 依赖存在、@FeignClient 的服务名与 Nacos 中一致,以及两个服务使用相同的 namespace、group。
如果设置了 @FeignClient(url = "固定地址"),调用会使用指定地址,不能再用它验证 Nacos 多实例选择和控制台权重。本文没有设置 url。
10.2 控制台没有资源,或者新增规则不起作用
先请求一次目标接口,再检查应用心跳和控制台回连应用端口是否畅通。看到 consumer 名字只代表心跳可达,不代表 Docker 中的控制台一定能连到应用的 8719。
如果应用完全不出现在左侧,除了检查 dashboard 地址,还要核对 client-ip 是否填成了主机名。1.8.9 控制台会拒绝 host.docker.internal 这样的值;按第 2.4 节查询数值 IP,再通过容器中的 /version 请求验证回连。
随后核对资源名:/hot 是 URL 资源,hot 是注解资源,GET:http://provider/index 是 Feign 出站资源。热点参数规则应配给有方法参数的 hot;远程熔断则配给 consumer 的 Feign 资源。
还要检查是否有旧规则同时生效、注解方法是否经过 Spring 代理,以及是否误把单机阈值理解成集群总阈值。修改控制台规则后不需要为了每次实验重启应用,否则内存规则可能随重启丢失。
10.3 为什么异常发生了,却没有熔断
检查统计时长内是否达到最小请求数、失败比例或失败次数是否真的超过阈值。再确认异常是否从被保护方法抛出、有没有在内部被转换为普通返回。
对于慢调用,还要确认阈值单位是毫秒,资源覆盖的是哪段代码,以及请求是否刚好跨越统计窗口。用单次请求或低频刷新,很难稳定触发一个要求多次调用的规则。
10.4 为什么返回了提示,但状态码仍然是 200
普通字符串 blockHandler 和 Feign fallback 都只是替代返回,不会自动替外层 Controller 决定 HTTP 状态。要表达失败,需像 ResponseEntity.status(429) 那样主动设置,或使用项目统一的错误响应设计。
还要区分 MVC 全局回调与注解处理方法:前者不会自动接管已经被后者处理完的结果。

评论