压测时 Hystrix 的线程切换吃掉 1.4 毫秒,我们换成了 Sentinel
3 月做秒杀压测,QPS 压到 3000 的时候就上不去了。抓了一次火焰图,发现热点不在业务代码,在 HystrixThreadPool 的线程调度上。
我们订单服务上挂了 11 个 @HystrixCommand,每个都跑在独立的线程池里。线程池隔离的好处是某个下游拖死只影响自己那个池子,但代价是每次调用都要做一次线程切换加 SynchronousQueue 的交接。实测单次调用的额外开销在 1.1 到 1.8 毫秒,平均 1.4 毫秒。
再加上 Hystrix 2018 年 11 月就宣布停止开发了(Netflix 官方说只做安全修复,不再加新功能),我们决定迁到 Sentinel。用的是 Sentinel 1.7.2 加 Spring Cloud Alibaba 2.2.0.RELEASE。
先搞清楚两个东西的设计差异
| 维度 | Hystrix | Sentinel 1.7.2 |
|---|---|---|
| 隔离方式 | 线程池隔离(默认)/ 信号量 | 信号量(并发线程数),无线程池 |
| 单机 QPS 限流 | 没有,要自己写 | 内建,支持 QPS 和并发线程数 |
| 熔断条件 | 错误率(默认 50%,10 秒窗口 20 个请求) | RT 超标 / 异常比例 / 异常数 |
| 熔断后恢复 | 半开,放行一个请求试探 | 熔断时长到了直接关闭进入半开,试探失败再次熔断 |
| 热点参数 | 不支持 | 支持,按参数值单独限流 |
| 系统自适应 | 不支持 | 支持,按 Load / CPU / 总 QPS 兜底 |
| 控制台 | 只有 Dashboard 看板,改不了规则 | 能在页面上改规则,但要配持久化否则重启丢失 |
| 单次调用开销 | 约 1.4 ms(线程池模式) | 约 0.15 ms |
最核心的一点:Hystrix 解决的是"容错熔断",Sentinel 解决的是"流量治理"。前者是怕被下游拖死,后者是怕被上游打死。这两个问题不一样,Sentinel 把限流放在了第一位。
迁移:注解换成 @SentinelResource
// 之前
@HystrixCommand(fallbackMethod = "queryFallback",
commandProperties = {
@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "800")
})
public List<Order> queryByUser(Long userId) { ... }
// 之后
@SentinelResource(value = "queryByUser",
blockHandler = "queryBlockHandler", // 限流/熔断时走这里
fallback = "queryFallback") // 业务异常走这里
public List<Order> queryByUser(Long userId) { ... }
public List<Order> queryBlockHandler(Long userId, BlockException ex) {
log.warn("queryByUser 被限流, userId={}", userId);
return Collections.emptyList();
}
public List<Order> queryFallback(Long userId, Throwable t) {
return Collections.emptyList();
}
两个回调要分清:blockHandler 只在被 Sentinel 规则拦住时触发(限流、熔断、热点),fallback 是业务代码抛异常时的兜底。参数列表必须和原方法一致,最后多一个 BlockException / Throwable。
迁移踩的第一个坑:方法必须是 public,而且不能是内部调用。@SentinelResource 依赖 AOP 代理,同一个类里 this.xxx() 调用不走代理,规则完全不生效。我们有 3 个地方是这么调的,全改成注入自己或者拆到另一个类。
流控规则:QPS 还是并发数
Sentinel 的流控可以选两种统计维度:
- QPS:每秒请求数。适合保护"处理能力有明确上限"的资源。
- 并发线程数:正在处理的数量。适合保护"每个请求耗时长、占用资源多"的操作,比如批量导出。
我们的导出接口用并发数,因为一次导出要 8 到 40 秒,用 QPS 限流根本卡不住(10 QPS 也能堆出 300 个并发在跑)。
流控效果三种,我逐个试过:
// 代码方式配规则,生产上我们从 Nacos 拉
private void initFlowRule() {
FlowRule rule = new FlowRule("exportOrder");
rule.setGrade(RuleConstant.FLOW_GRADE_THREAD); // 并发线程数
rule.setCount(8); // 最多 8 个并发
rule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT); // 快速失败
FlowRuleManager.loadRules(Collections.singletonList(rule));
}
- 快速失败(默认):超了直接抛
FlowException,走 blockHandler。最常用。 - Warm Up:冷启动,让阈值在一小段时间内从 1/3 慢慢升到设定值。我们秒杀用了它,
warmUpPeriodSec = 10。因为秒杀开始的瞬间系统还处于"冷"状态(JIT 没编译、缓存没预热),一上来就放满 QPS 会直接雪崩。 - 排队等待:超了不拒绝,让请求排队,超时才丢弃。匀速器模式,用的是漏桶算法。适合"不希望丢请求但可以等"的场景,我们给消息处理用了。
还有一个流控模式容易和流控效果搞混:
| 模式 | 含义 | 我们的用法 |
|---|---|---|
| 直接 | 资源自身达到阈值就限流 | 绝大多数情况 |
| 关联 | 关联资源达到阈值时限流自己 | 下单接口压力大了,限流查询接口,把资源让给下单 |
| 链路 | 只统计从某个入口进来的调用 | 同一方法被两个入口调用,只限制其中一个 |
关联模式那个我们真的用了。/api/order/create 的 QPS 超过 800 时,自动限流 /api/order/list,把线程和数据库连接让给下单。效果不错,双十二那次下单成功率从 91% 提到 99.4%。
熔断降级:三种策略怎么选
1.7.2 里熔断有三种:
- RT(平均响应时间):连续 5 个请求的平均 RT 超过阈值,且 1 秒内的请求数 ≥ 5,就熔断。我们设
count=500ms、timeWindow=10s。 - 异常比例:QPS ≥ 5 且每秒异常占比超过阈值。这个最常用,我们设 40%。
- 异常数:最近 1 分钟异常数超过阈值。适合 QPS 低但要求稳的接口。
DegradeRule rule = new DegradeRule("queryByUser")
.setGrade(RuleConstant.DEGRADE_GRADE_EXCEPTION_RATIO)
.setCount(0.4) // 异常比例 40%
.setTimeWindow(10) // 熔断 10 秒
.setMinRequestAmount(5); // 1 秒内至少 5 个请求才统计
minRequestAmount 这个参数别忽略。不设的话流量低谷期一两个请求失败就触发熔断了,我们吃过这个亏——凌晨两点一个接口被熔断了 6 次,因为那时候总共就 3 个请求,失败 2 个就是 66%。
热点参数限流,这个 Hystrix 完全没有
秒杀场景下,我们希望"某个爆款商品的请求单独限流",而不是一刀切限制整个下单接口。Sentinel 的 ParamFlowRule 正好干这个:
ParamFlowRule rule = new ParamFlowRule("seckill")
.setParamIdx(0) // 第 0 个参数,也就是 skuId
.setCount(100); // 单个参数值每秒最多 100
// 给特定爆款开小灶
ParamFlowItem item = new ParamFlowItem()
.setObject("1000477") // 这个 skuId
.setCount(5) // 每秒只放 5 个
.setClassType(String.class.getName());
rule.setParamFlowItemList(Collections.singletonList(item));
ParamFlowRuleManager.loadRules(Collections.singletonList(rule));
效果是:普通商品每个 skuId 每秒 100 个,SPU 1000477 这个爆款每秒只有 5 个。这样不会因为一个爆款把整个秒杀接口打挂。
需要注意参数类型。基本类型和 String 支持得最好,自定义对象要实现 ParamFlowArgument 接口自己提供取值逻辑。
规则持久化,不配的话重启就没了
默认情况规则只存在内存里,应用重启全丢。我们接了 Nacos 数据源:
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-nacos</artifactId>
</dependency>
spring:
cloud:
sentinel:
transport:
dashboard: 10.0.0.31:8858
port: 8719
datasource:
flow:
nacos:
server-addr: 10.0.0.21:8848
dataId: order-service-flow-rules
groupId: SENTINEL_GROUP
rule-type: flow
degrade:
nacos:
server-addr: 10.0.0.21:8848
dataId: order-service-degrade-rules
groupId: SENTINEL_GROUP
rule-type: degrade
Nacos 里存的是 JSON 数组:
[
{
"resource": "queryByUser",
"limitApp": "default",
"grade": 1,
"count": 200,
"strategy": 0,
"controlBehavior": 0
}
]
有个坑:在 Sentinel 控制台上改的规则,不会推回 Nacos。控制台是推给应用的内存,应用重启又被 Nacos 覆盖了。所以要么统一在 Nacos 改,要么改造控制台做双向同步。我们选了前者,把控制台的写权限收了。
迁移前后的数据
| 指标 | Hystrix | Sentinel 1.7.2 |
|---|---|---|
| 单次调用额外开销 | 1.4 ms | 0.15 ms |
| 单机最大 QPS(下单) | 3020 | 4680 |
| P99(QPS 2000 时) | 86 ms | 41 ms |
| 线程数(11 个 command) | 11 个池 × 20 = 220 | 0(复用业务线程) |
| 规则改完生效 | 改代码重新发布 | Nacos 推送,1 到 2 秒 |
线程数那一项是意外收获,进程总线程数从 480 降到 210,上下文切换少了,GC 也更平稳。
就写到这。如果哪天你也被《Sentinel 限流熔断实战:从 Hystrix 迁移的对比》里同一个坑绊住,回来翻这篇,能省半小时。