Administrator
发布于 2020-05-08 / 4390 阅读
27

Spring Cloud Netflix 停更之后,我们迁到了 Alibaba 全家桶

周会上定的事:6 周内把 Netflix 那一套换成 Alibaba

3 月底的架构周会,我把一张图投到了屏幕上:Spring Cloud Netflix 的组件里,Hystrix、Zuul 1.x、Ribbon、Archaius、Turbine 五个都挂着"maintenance mode"或者干脆不再更新。

会上有人问:"维护模式是什么意思?还在更新吗?"

意思是:还会修 bug,但不会有新特性。Netflix 自己内部已经在用别的东西了,Eureka 2.0 的开发 2018 年就停了。对我们来说,继续用下去的代价是:遇到问题只能自己改源码,社区的 issue 没人回。

那天定了迁移。到 5 月初全部完成,11 个服务,6 周。这篇记一下过程中的判断和踩到的坑。

迁移前的状态

Spring Boot 2.1.9.RELEASE
Spring Cloud Greenwich.SR2

注册中心    Eureka Server(2 节点)
配置中心    Spring Cloud Config + Git
熔断        Hystrix 1.5.18 + Hystrix Dashboard
网关        Zuul 1.3.1
负载均衡    Ribbon(Netflix)
链路追踪    Sleuth + Zipkin

这套东西跑了两年,没出过大问题。所以迁移会议上有质疑:既然能用,为什么要动?

我的理由有三条:

  • 网关是瓶颈。Zuul 1.x 用的是 Servlet 2.5 的阻塞模型,每个请求占一个线程。我们大促时网关单机 200 线程全满,QPS 卡在 1800 上不去。
  • 配置发布太麻烦。改个参数要提交 Git、等 Config Server 拉、再手动 POST /actuator/bus-refresh。两个月内因为漏刷导致的事故有 3 次。
  • Hystrix 的线程池开销。之前压测测出来每次调用 1.4 ms 的线程切换成本,见我在 Sentinel 那篇里写的。

第三条属于锦上添花,前两条是真痛点。

目标栈

Spring Boot 2.2.7.RELEASE
Spring Cloud Hoxton.SR4
Spring Cloud Alibaba 2.2.1.RELEASE

注册+配置   Nacos 1.2.0(替换 Eureka + Config)
熔断限流    Sentinel 1.7.2(替换 Hystrix)
网关        Spring Cloud Gateway 2.2.2(替换 Zuul)
负载均衡    Ribbon 保留(Spring Cloud LoadBalancer 当时还不成熟)
链路追踪    Sleuth + Zipkin 保留

版本对应关系必须查官方的 wiki,Hoxton.SR4 对应 Boot 2.2.x 和 Alibaba 2.2.1。我们第一次配错了,用了 Alibaba 2.1.1 配 Hoxton,启动时报:

java.lang.NoClassDefFoundError: org/springframework/cloud/client/discovery/ReactiveDiscoveryClient

版本不匹配是最浪费时间的错误类型,建议一开始就对着 Spring Cloud Alibaba 官方文档里的版本对应关系抄,别凭印象。

为什么 Ribbon 和 Zipkin 不动

Ribbon:Spring Cloud 官方出的替代品 spring-cloud-loadbalancer 在 Hoxton 时还比较简陋,不支持按区域路由、不支持自定义权重。评估之后决定 Ribbon 继续用——它虽然也在维护模式,但功能稳定、我们也没用它的高级特性。Hoxton 依然内置 Ribbon,兼容性没问题。

Zipkin:这一层没有 Alibaba 的替代品(SkyWalking 我们单独评估过,agent 方式对性能有影响,暂缓)。Sleuth 在 Hoxton 里工作正常,不动。

迁移分了四步

步骤内容耗时能否回滚
1升级 Boot 2.1 → 2.2.7,Cloud Greenwich → Hoxton.SR41.5 周能,发版回退
2Eureka → Nacos(注册),两套注册中心并行1 周能,配置切换
3Spring Cloud Config → Nacos Config1 周
4Zuul → Gateway,Hystrix → Sentinel2.5 周能,网关层切换

原则:每一步独立发布、独立可回滚,绝不攒一个大版本一起上。第 2 步之所以能并行,是因为 Eureka 和 Nacos 的客户端可以同时存在,只需要一个开关决定用哪个做服务发现。

第 2 步:两套注册中心怎么并行

依赖同时引入:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

两边的自动配置会打架,要排除掉:

spring:
  autoconfigure:
    exclude:
      - org.springframework.cloud.netflix.eureka.EurekaClientAutoConfiguration
      - com.alibaba.cloud.nacos.discovery.NacosDiscoveryAutoConfiguration

然后自己写配置类按开关装配。说实话这段挺脏的,我们只让它存活了两周,切换完成就删了。

更省事的做法是用 Nacos 的 NacosSync 工具做双向同步(阿里开源的一个同步组件),Eureka 和 Nacos 上的服务列表实时互抄,两边的应用各自只连自己那套,互不干扰。我们后来知道了这个工具,第 3、4 步就轻松多了。

第 4 步:Zuul 的过滤器要重写

这是工作量最大的一块。Zuul 的 Filter 和 Gateway 的 Filter 模型完全不同:

// Zuul 的写法
@Component
public class AuthFilter extends ZuulFilter {

    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return 0;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest req = ctx.getRequest();
        String token = req.getHeader("Authorization");
        if (!authService.check(token)) {
            ctx.setSendZuulResponse(false);
            ctx.setResponseStatusCode(401);
            ctx.setResponseBody("{\"code\":401}");
        }
        return null;
    }
}
// Gateway 的写法,响应式,没有 ThreadLocal
@Component
public class AuthFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (token == null) {
            return unauthorized(exchange);
        }
        // 注意这里要返回 Mono,鉴权本身如果是阻塞调用要包起来
        return authService.checkAsync(token)
            .flatMap(ok -> ok
                ? chain.filter(exchange)
                : unauthorized(exchange));
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
        return exchange.getResponse().writeWith(
            Mono.just(exchange.getResponse()
                .bufferFactory().wrap("{\"code\":401}".getBytes(StandardCharsets.UTF_8))));
    }

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

三个必须注意的点:

  • Gateway 是 WebFlux 的,整个链路没有 ThreadLocal。所有用 ThreadLocal 存用户信息的地方都要改成从 ServerWebExchange 的 attributes 里取,或者通过 Reactor 的 Context 传递。我们有 5 处 UserContext.get() 要改。
  • 不要在 Filter 里做阻塞调用。WebFlux 只有少数几个 event loop 线程,一个 JdbcTemplate 查询就能把整个网关卡住。我们的鉴权一开始是查数据库的,改成了查 Redis(Lettuce 是异步客户端),再后来改成校验 JWT(纯本地计算)。
  • 请求体只能读一次。Gateway 里 DataBuffer 读完就没了,要做签名校验的话得用 CacheRequestBodyFilter 把 body 缓存下来。这个 Zuul 也有类似问题,但表现形式不一样。

第 4 步:Hystrix 到 Sentinel 的注解替换

注解换了,语义也有差别:

// 之前
@HystrixCommand(fallbackMethod = "fallback",
    threadPoolKey = "inventoryPool",
    commandProperties = {
        @HystrixProperty(name = "coreSize", value = "20"),
        @HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "800")
    })
public Stock getStock(Long skuId) { ... }

// 之后
@SentinelResource(value = "getStock",
                  blockHandler = "blockHandler",
                  fallback = "fallback")
public Stock getStock(Long skuId) { ... }

差异要注意:

  • Hystrix 的 threadPoolKey 那一套线程池隔离配置在 Sentinel 里没有了,改成配并发线程数流控规则。
  • execution.isolation.thread.timeoutInMilliseconds 在 Sentinel 里也不存在。Sentinel 是信号量模式,无法打断正在执行的调用。超时必须配在 HTTP 客户端(Ribbon 的 ReadTimeout)上。
  • Hystrix Dashboard 换成 Sentinel Dashboard。后者能在线改规则,但规则默认存内存,我们接了 Nacos 持久化。

迁移中我们发现有 3 个 @HystrixCommand 实际依赖了线程池隔离来防止下游拖死业务线程。换成 Sentinel 之后这部分保护没了,补了并发数限流规则(配成 20,和原来的 coreSize 一样)。

踩到的坑

一、Feign 的 fallback 写法变了

// Hystrix 时代
@FeignClient(name = "inventory-service", fallback = InventoryFallback.class)

// Sentinel 时代要加配置项
feign:
  sentinel:
    enabled: true

@FeignClient(name = "inventory-service",
             fallbackFactory = InventoryFallbackFactory.class)

fallbackFactory 而不是 fallback,因为前者能拿到异常对象,日志更好排:

@Component
public class InventoryFallbackFactory implements FallbackFactory<InventoryClient> {

    @Override
    public InventoryClient create(Throwable cause) {
        return new InventoryClient() {
            @Override
            public Result<Stock> getStock(Long skuId) {
                log.error("库存服务调用失败, skuId={}", skuId, cause);
                return Result.fail("库存服务不可用");
            }
        };
    }
}

二、Zuul 的路径前缀规则不一样

Zuul 默认剥掉第一级路径前缀再转发,Gateway 默认保留整个路径。我们的老配置:

# Zuul:/api/order/list → order-service 的 /order/list
zuul:
  routes:
    order-service:
      path: /api/**
      serviceId: order-service
      stripPrefix: true
# Gateway 的等价配置
spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://order-service
          predicates:
            - Path=/api/**
          filters:
            - StripPrefix=1      # 不写这个,下游收到的是 /api/order/list

忘了 StripPrefix 会让所有下游接口 404,而且报错信息完全看不出是前缀的问题。我们花了一个下午。

三、Sleuth 的 traceId 在 Gateway 里断了

Gateway 是响应式的,Sleuth 的自动配置在 WebFlux 下需要额外的桥接。现象是网关日志里的 traceId-,下游服务有 traceId,串不起来。

解决方式是确保用了 spring-cloud-starter-sleuth 而不是 spring-cloud-sleuth-zipkin 的旧组合,并且网关的 Filter 里要用 exchange 传递而不是 ThreadLocal。Hoxton 版本里这两个都处理好了,但自定义 Filter 里如果手动开了线程池就会丢。

四、Nacos 的配置优先级高于本地

迁移后有个同事改本地 application.yml 调试,怎么都不生效。原因写在 Nacos 那篇里了,这里不重复。

6 周之后的数字

指标迁移前迁移后变化
网关单机 QPS18205400+197%
网关 P9996 ms23 ms-76%
网关线程数200(满)8 event loop-
配置发布耗时3 到 8 分钟2.3 秒数量级提升
服务调用额外开销1.4 ms(Hystrix)0.15 ms(Sentinel)-89%
组件数6(Eureka/Config/Hystrix/Zuul/Ribbon/Zipkin)4-2

网关的提升最明显,QPS 从 1820 到 5400,因为 WebFlux 是非阻塞的,不再受限于线程池大小。

留个问题

关于《Spring Cloud Netflix 停更之后,我们迁到了 Alibaba 全家桶》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考