周会上定的事: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.SR4 | 1.5 周 | 能,发版回退 |
| 2 | Eureka → Nacos(注册),两套注册中心并行 | 1 周 | 能,配置切换 |
| 3 | Spring Cloud Config → Nacos Config | 1 周 | 能 |
| 4 | Zuul → Gateway,Hystrix → Sentinel | 2.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 周之后的数字
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| 网关单机 QPS | 1820 | 5400 | +197% |
| 网关 P99 | 96 ms | 23 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 全家桶》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。