Administrator
发布于 2019-11-15 / 3064 阅读
38

Dubbo + Zookeeper 第一个分布式服务踩坑记

第一次把单体拆出 Dubbo 服务,踩了五个坑

11 月,组里决定把用户中心从订单系统里拆出来,做成独立的 Dubbo 服务。这是我第一次真正搭分布式服务,之前只在本地 demo 里跑过。

技术选型是 Dubbo 2.7.3 + Zookeeper 3.4.14 + Spring Boot 2.1.7。搭起来跑了三天,踩的坑比预想的多,记下来。

环境先跑通

Zookeeper 用 Docker 起,单机够用:

docker run -d \
  --name zk \
  -p 2181:2181 \
  -v /data/zk/data:/data \
  -v /data/zk/datalog:/datalog \
  zookeeper:3.4.14

验证一下:

$ docker exec -it zk zkCli.sh -server 127.0.0.1:2181
[zk: 127.0.0.1:2181(CONNECTED) 0] ls /
[zookeeper]

服务提供者起来之后,Zookeeper 上的目录结构是这样的:

[zk: localhost:2181(CONNECTED) 3] ls /dubbo
[com.xxx.user.api.UserService, com.xxx.user.api.UserQueryService]

[zk: localhost:2181(CONNECTED) 4] ls /dubbo/com.xxx.user.api.UserService
[configurators, consumers, providers, routers]

[zk: localhost:2181(CONNECTED) 5] ls /dubbo/com.xxx.user.api.UserService/providers
[dubbo%3A%2F%2F10.20.33.15%3A20880%2Fcom.xxx.user.api.UserService%3Fanyhost%3Dtrue%26application%3Duser-service%26dubbo%3D2.0.2%26generic%3Dfalse%26interface%3Dcom.xxx.user.api.UserService%26methods%3DgetUser%2CupdateUser%26pid%3D18304%26release%3D2.7.3%26side%3Dprovider%26timestamp%3D1573812947123]

那串 URL 编码解开就是 dubbo://10.20.33.15:20880/com.xxx.user.api.UserService?...服务注册的本质就是在 ZK 上建一个临时节点,节点名是提供者的完整地址。因为是临时节点(EPHEMERAL),提供者进程挂掉、和 ZK 的 session 断开后,节点自动消失,消费者通过 watch 机制收到通知。

看 session 超时:

[zk: localhost:2181(CONNECTED) 6] get /dubbo/com.xxx.user.api.UserService/providers
...
ephemeralOwner = 0x16e4c8a2b3f0001

Dubbo 默认 session 超时 60 秒,可通过 registry.session 配置。我们生产设的是 60 秒,意味着提供者宕机后,消费者最长 60 秒才会感知。这个数字要记住,它对故障恢复时间有直接影响。

坑一:导错了 @Service

启动报错,服务没注册上去:

Caused by: java.lang.IllegalStateException: No such any registry to reference
    at org.apache.dubbo.config.utils.ConfigValidationUtils.validateReferenceConfig(...)

查了半天,是我 import 错了注解。Dubbo 2.7 的包名从 com.alibaba.dubbo 改成了 org.apache.dubbo(Dubbo 捐给 Apache 之后统一改的),而我照着老教程写的是 alibaba 的:

// 错:老版本包名(Dubbo 2.6 及以前)
import com.alibaba.dubbo.config.annotation.Service;

// 对:Dubbo 2.7 用这个
import org.apache.dubbo.config.annotation.Service;

更坑的是它编译不报错,只是不注册。而且如果项目里两个版本的 jar 都在(依赖没排干净),行为会非常诡异。

正确的写法:

// 提供者
@Service(version = "1.0.0", timeout = 3000, retries = 0)
public class UserServiceImpl implements UserService {
    @Override
    public UserDTO getUser(Long userId) {
        return userMapper.selectById(userId);
    }
}

// 消费者
@Component
public class OrderAssembler {
    @Reference(version = "1.0.0", timeout = 3000, check = false)
    private UserService userService;
}

@Service 是 Dubbo 的,@Component 是 Spring 的,两者别混用。@Service 用在实现类上,表示"这个实现要作为 Dubbo 服务暴露出去";@Reference 用在消费方的字段上,表示"注入一个远程服务代理"。

坑二:DTO 忘了实现 Serializable

第一次调用就炸了:

org.apache.dubbo.rpc.RpcException: Failed to invoke the method getUser in the service
com.xxx.user.api.UserService. Tried 3 times of the providers [10.20.33.15:20880] (1/1) from the
registry 10.20.31.20:2181 on the consumer 10.20.33.18 using the dubbo version 2.7.3.
Last error is: Failed to invoke remote method: getUser, provider: dubbo://10.20.33.15:20880/...,
cause: java.io.NotSerializableException: com.xxx.user.dto.UserDTO

原因很直白:UserDTO 没实现 Serializable。而且注意日志里的 "Tried 3 times"——默认重试 2 次(加第一次共 3 次),序列化这种必然失败的错误也被重试了 3 遍。

修起来简单:

public class UserDTO implements Serializable {
    // 一定要显式声明,不写的话类结构一变(比如加个字段),
    // 反序列化就会因为 serialVersionUID 不匹配而失败
    private static final long serialVersionUID = 1L;

    private Long userId;
    private String nickName;
    // ...
}

顺便说一句, dubbo 的 Hessian2 序列化其实不强制要求实现 Serializable(它按字段读写),但 Dubbo 框架在调用前会做检查。所以加上就对了,成本为零。

坑三:重试把订单创建了两遍

这个问题比前面两个严重。上线第二天,运营发现有 14 笔重复订单。

追下来是这样:网络抖动导致调用超时,Dubbo 自动重试,而我们的下单接口不是幂等的,重试就创建了第二笔。

我之前的配置是:

dubbo:
  consumer:
    timeout: 3000
    retries: 2        # 默认就是 2

Dubbo 的 retries 默认值确实是 2(第一次 + 重试 2 次,共 3 次调用)。文档上写了"对幂等操作建议设置重试次数,非幂等操作建议设置为 0",我看了但没往心里去。

正确的做法:按方法区分配置

dubbo:
  consumer:
    timeout: 3000
    retries: 1
  provider:
    timeout: 3000
    retries: 0

# 针对具体方法覆盖全局配置
# 查询类:可以重试
# 写操作:retries = 0,绝不重试
@Component
public class OrderFacade {

    // 写操作:不重试
    @Reference(version = "1.0.0", timeout = 5000, retries = 0)
    private OrderService orderService;

    // 读操作:允许重试
    @Reference(version = "1.0.0", timeout = 1000, retries = 2)
    private UserQueryService userQueryService;
}

我们的规矩定成了:所有写操作 retries = 0,读操作可以设 1 到 2。配 Dubbo 的时候,判断标准就是"这个调用重试一次会不会有副作用"。

坑四:超时配置的优先级搞反了

我一开始在提供者上配了 timeout = 1000,消费者上没配,以为会生效。实际调用时发现超时是 1000 毫秒,看起来生效了。但后来我在消费者上配了 3000,提供者还是 1000,实际生效的是 1000——消费者的配置被忽略了。

查了文档才知道,Dubbo 的配置优先级是这样的:

  1. 方法级优先于接口级,接口级优先于全局配置
  2. 同级别下,消费方优先于提供方

但有个例外:如果消费方没配置,就用提供方的。 provider 的 timeout 是"我的方法最多要跑这么久",相当于一个建议值;consumer 的 timeout 是"我最多等这么久",是硬约束。所以推荐的做法是:

  • 提供方配 timeout:因为我最了解自己的方法要跑多久
  • 消费方配 retries 和 loadbalance:因为重试和负载均衡是消费方的策略
  • 消费方如果想覆盖,就显式配置(但要记得它会盖掉提供方的建议)

我们最后统一成:所有服务提供方在 @Service 上声明 timeout,消费方只在有特殊要求时才覆盖。

坑五:序列化协议没选,默认用的 Hessian2

Dubbo 2.7 默认的序列化协议是 Hessian2,能跑,但性能不是最好的。我把几种都测了一遍。测试对象是一个包含 20 个字段的 OrderDTO,序列化 + 反序列化 10 万次:

协议配置方式序列化耗时反序列化耗时字节数
Hessian2(默认)protocol.serialization=hessian21,842 ms2,310 ms486
Kryoserialization=kryo642 ms781 ms318
FSTserialization=fst589 ms803 ms341
Java 原生serialization=java3,120 ms5,240 ms892

Kryo 比默认的 Hessian2 快 2.9 倍,字节数还少 35%。代价是:Kryo 需要预注册类才能获得最佳性能,而且它对类的结构变化敏感。

dubbo:
  protocol:
    name: dubbo
    port: 20880
    serialization: kryo

加依赖:

<dependency>
    <groupId>de.javakaffee</groupId>
    <artifactId>kryo-serializers</artifactId>
    <version>0.42</version>
</dependency>

类注册(可选,注册后性能更好、包更小):

// 在 resources/META-INF/dubbo/internal/ 下配置,或者代码里注册
public class SerializationOptimizerImpl implements SerializationOptimizer {
    @Override
    public Collection<Class> getSerializableClasses() {
        List<Class> classes = new ArrayList<>();
        classes.add(UserDTO.class);
        classes.add(OrderDTO.class);
        return classes;
    }
}
dubbo:
  protocol:
    serialization: kryo
    optimizer: com.xxx.SerializationOptimizerImpl

我们最终选了 Kryo,但只在内部服务之间用。对外网关还是 Hessian2,因为兼容性更好,调试时抓包也方便(Hessian2 的包可读性稍强)。

其他几个配置

check = false。消费者启动时默认会检查依赖的服务是否可用,不可用就启动失败。开发时很烦(要起好几个服务),设成 false:

dubbo:
  consumer:
    check: false

但生产环境建议保持 true。启动失败总比启动后调不通强,而且能避免"服务起来了但依赖没起来"这种半死状态被负载均衡打进来。

启动时不要暴露服务。Spring Boot 应用启动完成后,可能还有一些初始化(比如预热缓存)没做完,这时候就注册上去会被立刻打流量:

dubbo:
  provider:
    delay: 5000        # 延迟 5 秒再暴露服务

线程模型。Dubbo 默认的 dispatcherall,所有消息都派发到业务线程池。IO 密集的服务可以用 message(只有请求响应走线程池):

dubbo:
  protocol:
    dispatcher: message
    threads: 200
    threadpool: fixed

我们没改这个,因为默认的 fixed 池 200 线程够用。改之前最好先压测,我们试过改成 cached,结果高峰期线程数涨到 800,上下文切换把 CPU 吃满了。

一个诡异的问题

服务上线一周后,有天下午消费者突然全部报 No provider available,但提供者进程都活着。

查下来是 Zookeeper 的 session 超时:那天宿主机网络抖动,ZK 客户端和Ubuntu 服务器的连接断了超过 60 秒(session timeout),ZK 把临时节点删了,消费者认为服务下线。网络恢复后,Dubbo 的 Curator 客户端会自动重连并重新注册,但重连有几秒的窗口。

日志里能看到:

2019-11-22 14:31:07 WARN  [Curator-Framework-0] o.a.c.f.state.ConnectionStateManager -
  Session expired event received
2019-11-22 14:31:07 INFO  [Curator-Framework-0] o.a.d.r.z.ZookeeperRegistry -
  [DUBBO] Re-subscribe, urls: [...], dubbo version: 2.7.3, current host: 10.20.33.15
2019-11-22 14:31:08 INFO  [Curator-Framework-0] o.a.d.r.z.ZookeeperRegistry -
  [DUBBO] Re-register, urls: [...]

从 session expired 到重新注册完成,用了 1.7 秒。这 1.7 秒里所有调用失败。

缓解办法:dubbo.registry.timeout 调大到 10 秒,session 保持 60 秒不要动(ZK 服务端有 min/max session timeout 限制,客户端设太大会被服务端截断)。另外消费方要有熔断降级,别让一个服务抖动拖垮整个链路。我们后来在消费端加了 Hystrix(当时还没换 Sentinel)。

就写到这。如果哪天你也被《Dubbo + Zookeeper 第一个分布式服务踩坑记》里同一个坑绊住,回来翻这篇,能省半小时。

参考