Administrator
发布于 2020-03-29 / 4995 阅读
60

Sentinel 限流熔断实战:从 Hystrix 迁移的对比

压测时 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。

先搞清楚两个东西的设计差异

维度HystrixSentinel 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=500mstimeWindow=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 改,要么改造控制台做双向同步。我们选了前者,把控制台的写权限收了。

迁移前后的数据

指标HystrixSentinel 1.7.2
单次调用额外开销1.4 ms0.15 ms
单机最大 QPS(下单)30204680
P99(QPS 2000 时)86 ms41 ms
线程数(11 个 command)11 个池 × 20 = 2200(复用业务线程)
规则改完生效改代码重新发布Nacos 推送,1 到 2 秒

线程数那一项是意外收获,进程总线程数从 480 降到 210,上下文切换少了,GC 也更平稳。

就写到这。如果哪天你也被《Sentinel 限流熔断实战:从 Hystrix 迁移的对比》里同一个坑绊住,回来翻这篇,能省半小时。

参考