第一次把单体拆出 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 的配置优先级是这样的:
- 方法级优先于接口级,接口级优先于全局配置
- 同级别下,消费方优先于提供方
但有个例外:如果消费方没配置,就用提供方的。 provider 的 timeout 是"我的方法最多要跑这么久",相当于一个建议值;consumer 的 timeout 是"我最多等这么久",是硬约束。所以推荐的做法是:
- 提供方配 timeout:因为我最了解自己的方法要跑多久
- 消费方配 retries 和 loadbalance:因为重试和负载均衡是消费方的策略
- 消费方如果想覆盖,就显式配置(但要记得它会盖掉提供方的建议)
我们最后统一成:所有服务提供方在 @Service 上声明 timeout,消费方只在有特殊要求时才覆盖。
坑五:序列化协议没选,默认用的 Hessian2
Dubbo 2.7 默认的序列化协议是 Hessian2,能跑,但性能不是最好的。我把几种都测了一遍。测试对象是一个包含 20 个字段的 OrderDTO,序列化 + 反序列化 10 万次:
| 协议 | 配置方式 | 序列化耗时 | 反序列化耗时 | 字节数 |
|---|---|---|---|---|
| Hessian2(默认) | protocol.serialization=hessian2 | 1,842 ms | 2,310 ms | 486 |
| Kryo | serialization=kryo | 642 ms | 781 ms | 318 |
| FST | serialization=fst | 589 ms | 803 ms | 341 |
| Java 原生 | serialization=java | 3,120 ms | 5,240 ms | 892 |
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 默认的 dispatcher 是 all,所有消息都派发到业务线程池。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 第一个分布式服务踩坑记》里同一个坑绊住,回来翻这篇,能省半小时。