本文字数:6047字
预计阅读时间:40分钟
01
故障出现
事情起源于一次故障,2023年12月14日14点26分,大量Dubbo服务报出异常,无法链接zookeeper集群:
Session 0x0 for server dubboZk.xxx.com/10.x.x.x:2181, Closing socket connection. Attempting reconnect except it is a SessionExpiredException.
登录Zookeeper节点发现,集群整体处于不可用状态,且抛出如下异常:
2023-12-14 14:26:15,255 [myid:9] - WARN [NIOWorkerThread-2:NIOServerCnxn@373] - Close of session 0x0
java.io.IOException: ZooKeeperServer not running
at org.apache.zookeeper.server.NIOServerCnxn.readLength(NIOServerCnxn.java:544)
at org.apache.zookeeper.server.NIOServerCnxn.doIO(NIOServerCnxn.java:332)
at org.apache.zookeeper.server.NIOServerCnxnFactory$IOWorkRequest.doWork(NIOServerCnxnFactory.java:522)
at org.apache.zookeeper.server.WorkerService$ScheduledWorkRequest.run(WorkerService.java:154)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
随即将Zookeeper节点一一重启,结果重启失败。
经过查看重启日志,发现Zookeeper在重启过程中,恢复数据阶段报出如下错误:
Caused by: java.io.IOException: Unreasonable length = 10053968
at org.apache.jute.BinaryInputArchive.checkLength(BinaryInputArchive.java:166)
at org.apache.jute.BinaryInputArchive.readBuffer(BinaryInputArchive.java:127)
at org.apache.zookeeper.server.persistence.Util.readTxnBytes(Util.java:159)
at org.apache.zookeeper.server.persistence.FileTxnLog$FileTxnIterator.next(FileTxnLog.java:768)
at org.apache.zookeeper.server.persistence.FileTxnSnapLog.fastForwardFromEdits(FileTxnSnapLog.java:352)
at org.apache.zookeeper.server.persistence.FileTxnSnapLog.lambda$restore$0(FileTxnSnapLog.java:258)
at org.apache.zookeeper.server.persistence.FileTxnSnapLog.restore(FileTxnSnapLog.java:303)
at org.apache.zookeeper.server.ZKDatabase.loadDataBase(ZKDatabase.java:285)
at org.apache.zookeeper.server.quorum.QuorumPeer.loadDataBase(QuorumPeer.java:1094)
根据上述日志,既然重启恢复数据失败,那么将follower节点的快照日志和事务日志删除,这样重启时就不需要恢复数据了,启动后数据可以重新从leader同步。
然而,事与愿违,删除数据后,重启集群,集群短暂恢复了一会,但很快又进入故障状态,查看日志发现了与之前类似的错误:
java.io.IOException: Unreasonable length = 10053968
at org.apache.jute.BinaryInputArchive.checkLength(BinaryInputArchive.java:166)
at org.apache.jute.BinaryInputArchive.readBuffer(BinaryInputArchive.java:127)
at org.apache.zookeeper.server.quorum.QuorumPacket.deserialize(QuorumPacket.java:85)
at org.apache.jute.BinaryInputArchive.readRecord(BinaryInputArchive.java:108)
at org.apache.zookeeper.server.quorum.Learner.readPacket(Learner.java:152)
at org.apache.zookeeper.server.quorum.Follower.followLeader(Follower.java:85)
at org.apache.zookeeper.server.quorum.QuorumPeer.run(QuorumPeer.java:740)
反复出现的Unreasonable length = 10053968
引起了大家的警觉,该异常对应的源码如下:
org.apache.jute.BinaryInputArchive.java
private void checkLength(int len) throws IOException {
if (len < 0 || len > maxBufferSize + extraMaxBufferSize) {
throw new IOException("Unreasonable length = " + len);
}
}
上述代码中的maxBufferSize + extraMaxBufferSize默认大小为2M,而异常中的Unreasonable length = 10053968
已经达到了9.58M,远超2M,故抛出异常。
经过搜索,发现该值由系统变量jute.maxbuffer配置,它指定了znode中可以存储的数据的最大大小。
随即将该值调整至20M后,重启所有节点,集群恢复。
那么,这个9.58M大小的数据到底是什么?
02
故障定位
由于该问题发生时,异常信息只打印了长度,没有相关内容,所以无法定位原因,故按如下步骤来诊断原因:
首先,将Zookeeper的事务日志和快照日志下载到本地。
其次,获取Zookeeper源码,修改异常堆栈中的org.apache.zookeeper.server.persistence.FileTxnLog.next()方法代码,在Zookeeper启动恢复数据的过程中,将超长的内容打印到日志中。
最后,发现了这个超大的数据,在/dubbo-test/com.sohu.xxxService/consumers/下,注册了4096个node,node的path类似如下:
consumer%253A%252F%252F10.x.x.x%252Fcom.sohu.xxxService%253Fapplication%253Dspaces-videos-read-service-test...timestamp%253D1702534957419
此node是Dubbo消费者启动后注册到Zookeeper一个临时node,当Dubbo消费者关闭后,临时node会被Zookeeper自动删除,但是正常情况下不会注册这么多的node。
这些node的path几乎都一样,只是最后的timestamp不同,一个node的path近2450个字节,4096个差不多9.58M,那么这个node是谁注册进去的呢?
由于此node的path中携带了ip,经过询问相关业务人员,原来当时业务方在进行压测,而压测代码由于书写不规范导致创建了大量消费者实例,从而注册到Zookeeper大量的临时node,代码类似如下:
public void test() {
for (long i = 0; i < Long.MAX_VALUE; i++) {
VideoInfoReadService service = getDubboService(VideoInfoReadService.class);
... ...
}
}
public T getDubboService(Class clazz) {
ReferenceConfig reference = new ReferenceConfig<>();
reference.setRegistry(DubboRegistry);
reference.setInterface(clazz);
reference.setCheck(false);
reference.setApplication(DubboApplication);
reference.setTimeout(timeout);
return reference.get();
}
问题找到了,但是Zookeeper默认已经通过jute.maxbuffer限制了node数据大小为2M,为啥还能产生这么大的数据呢?
对于本次故障来说,并不是一个node中的data超过了2M,而是node下的子node合起来超过了2M,而Zookeeper并没有限制一个node下有多少个子node。
可见Zookeeper可能并不认为一个node下有大量的子node会有什么问题。
那么真实情况确实如此吗?故障到底是如何触发的呢?
03
故障重现
既然知道一个node下存在大量临时子node会触发这种故障,那么就模拟这种情况。
搭建一个测试的Zookeeper集群,通过如下简单的模拟代码,直接重现了故障:
zooKeeper.create("/test", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
// 构建临时节点路径,大小100k
byte[] bytes = new byte[1024 * 100];
Arrays.fill(bytes, (byte) 'a');
String str = new String(bytes);
// 这里需要创建21个临时节点,总大小大于2M
for (int i = 0; i < 21; i++) {
zooKeeper.create("/test/" + str + "-" + i, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
}
上面的代码会在/test节点下创建21个临时节点,每个临时节点的path长度达100K,那么/test节点下的所有临时节点路径长度总和就超过了2M,执行完上述代码,整个Zookeeper集群直接故障了。
经过分析Zookeeper的源码,结合故障时的堆栈,找到了本次故障的本质原因:
当Zookeeper客户端关闭时,leader会清除session,此时会删除本次session中创建的临时节点,对应Zookeeper的PrepRequestProcessor
的如下代码(超大的数据包就在此产生):
protected void pRequest2Txn(...) {
......
case OpCode.closeSession:
long startTime = Time.currentElapsedTime();
synchronized (zks.outstandingChanges) {
Set es = zks.getZKDatabase().getEphemerals(request.sessionId);
...
if (ZooKeeperServer.isCloseSessionTxnEnabled()) {
// 这里会将一个session创建的所有子节点放到一个request里了,超大的数据包在这产生
request.setTxn(new CloseSessionTxn(new ArrayList(es)));
}
......
}
接着,leader发起提议,注意此时提议的Request对象中包含了超大数据包,对应ProposalRequestProcessor
的如下代码:
public void processRequest(Request request) {
zks.getLeader().propose(request);
}
在提议逻辑中,会遍历所有的follower,将提议包发送给follower,参见如下leader代码:
void sendPacket(QuorumPacket qp) {
synchronized (forwardingFollowers) {
for (LearnerHandler f : forwardingFollowers) {
f.queuePacket(qp);
}
}
}
所有的follower接到这个超大的数据包后,由于长度检测抛出异常,导致无法处理。
由于Zookeeper是CP的系统,它要求强一致性,如果一致性无法达到,则进入不可用状态,导致了本文开头的故障。
04
故障影响及思考
本次故障导致的影响:
依赖Zookeeper做定时调度的应用无法执行任务;
部分Dubbo服务调用失败。
其实Dubbo本地缓存了路由信息,按理说Zookeeper短暂不可用时,只会影响新启动的Dubbo服务,而运行中的Dubbo服务应该不会受影响。但是由于某些业务使用Dubbo版本过低,存在某些Bug,在Zookeeper集群不可用时,会导致服务注册和发现出现异常,从而导致Dubbo服务调用失败。
本次故障带来的思考:
1. Zookeeper相关
a. 本次故障通过调大jute.maxbuffer
临时解决了,但是Zookeeper官方文档中,并不建议将该属性调整过大,原因如下:
过大的znode会导致不必要的延迟,从而降低吞出量;
过大的znode会使leader和follower之间的同步时间不可预测,甚至超时,导致仲裁不稳定。
由此可见,调大该值为系统的稳定性埋下了隐患。
另外,如果真有在node下存储大量子node的需求,Zookeeper该如何支持?
b. 故障发生时,大量的异常Unreasonable length = 10053968
毫无意义,无法定位问题节点,最起码需要在检测出数据包过大,抛出异常前,打印部分数据包内容,以便能够定位。
c. Zookeeper不同于Web服务,不支持打印访问日志,如果能加上访问日志,记录每次请求的ip、node、进出包的大小等等关键信息,那么对于定位此种问题就不会手忙脚乱了。
2. Dubbo相关
a. Dubbo诞生之初就采用Zookeeper作为服务注册和发现中心,在单体应用演变到SOA(面向服务的架构)的年代,可能没有其他更好的选择。但是在今天看来,作为CP架构的Zookeeper是否适合作为服务注册和发现中心?
对于互联网服务来说,其实更在意的是服务是否是高可用的,比如在某一时刻,服务调用者并不关心注册和发现中心获取到了2个服务提供者,还是3个服务提供者,因为它更关心的是能否调用成功,甚至在网络状况不好时,照样能从注册和发现中心获取到服务提供者。
在微服务盛行的今天,显然AP架构更适合作为服务注册和发现中心。
b. 由于团队内部已经采用了SpringCloud作为微服务治理平台,并在此基础上做了大量的基础工作,比如日志,指标,监控,追踪等等。而对于Dubbo服务,由于其采用私有协议,这些基础的工作需要单独定制开发。
c. 业务端采用的Dubbo版本长久不升级,存在某些bug和漏洞,安全隐患较大。如果升级则需要考虑兼容性问题以及潜在的风险。
综上:
Zookeeper为了实现强一致性,牺牲了可用性,不适合作为服务注册和发现中心;
由于团队内部主流项目都接入了SpringCloud微服务平台,即使是Dubbo项目,也集成了SpringBoot进行部署,所以,在权衡开发运维成本,技术积累,付出和收益等后,确定Dubbo服务逐渐迁移到SpringCloud服务才是最终方向。
在确定了方向后,如何才能保障用最小的成本和最稳定的方案将Dubbo服务迁移到SpringCloud服务呢?
05
Dubbo服务Web化调研
1. 首先需要制定一个明确的目标,以便能够进行方案评估。
在将Dubbo服务迁移到SpringCloud服务的过程中,需要保障如下几点:
对业务端代码无侵入,业务端尽量不用修改现有代码;
能够和Dubbo框架共存,方便逐步的、稳妥的迁移;
能够兼容现存的接口,避免修改接口带来不稳定性;
不能依赖Dubbo,方便后续移除Dubbo。
2. 为了保证目标的实现,首先,先看一下Dubbo的架构:
从上图可以看出,Dubbo架构核心有三大组件,分别是Provider(服务提供者),Consumer(服务消费者)和Registry(Zookeeper)。
如果业务端想提供服务,只要提供Provider的接口和实现,再进行简单的配置即可。
如果业务端想调用服务,只需要依赖服务方提供的接口包,进行简单配置即可。
3. 这里,为了后续更方便的阐述,这里举一个简单的例子:
a. 首先,假设服务方提供了一个视频服务:
public interface VideoService {
Video getVideo(long id);
}
服务方需要将该接口打包成videoService.jar
,提供给调用方。
b. 其次,服务方需要实现上述接口,假设实现如下:
@Service
public class UgcVideoService implements VideoService {
private VideoDao videoDao;
@Override
public Video getVideo(long id) {
return videoDao.queryById(id);
}
}
接着按照Dubbo规范进行配置并部署即可。
c. 最后,调用方依赖上videoService.jar
,按照如下方式调用即可:
@Reference
private VideoService videoService;
public Video getVideo(long id) {
return videoService.getVideo(id);
}
实际中,Dubbo框架会自动生成代理,完成服务调用:
好了,到这里,我们可以清晰的看出业务端使用Dubbo框架开发的整体流程,这里简单描述一下涉及到业务代码的部分:
Provider提供一个接口,并打成jar包;
Provider实现该接口,完成业务逻辑,并按照Dubbo规范配置和部署服务;
Consumer依赖Provider提供的jar包,像直接调用本地方法一样调用Provider的Dubbo服务。
上述业务代码如果改造成SpringCloud的服务提供和调用方式,需要做哪些工作?
4. 众所周知,SpringCloud的服务交互采用HTTP协议,其实将上述接口转换为HTTP协议很简单,因为Spring MVC已经提供了一套标准的规范,只需要参照规范实现即可。
例如上面服务方的业务里的接口VideoService
,可以通过增加几个简单的注解,即可暴露HTTP协议端点,类似如下:
@RestController
@RequestMapping("/video")
public class VideoController {
@Autowired
private VideoService videoService;
@RequestMapping("/getVideo")
public Video getVideo(long id) {
return videoService.getVideo(id);
}
}
此时,服务方便可完成HTTP协议端点的暴露:/video/getVideo?id=xx
。
上面的修改方式非常的简单清晰,因为只是新增了标准的Spring MVC的注解,不会影响原有的Dubbo项目。
但是上面的修改方式违反了之前规定的目标:
对业务端代码无侵入;
能够兼容现存的接口。
首先,这里仅仅是一个业务接口,如果涉及到几十个,甚至几百个,手工修改工作量极大。
其次,调用方是通过本地方法调用的方式videoService.getVideo(id)
使用的,为了保障兼容现存接口,依然需要支持这种方式的调用,而改成HTTP协议后,显然这种方式行不通了。
鉴于上述修改方案简单清晰,可调试,并且支持团队内的指标、追踪等,又是Spring标准的Web暴露方式,那么能否为业务端自动生成相关的代码呢?
06
Dubbo服务自动Web化方案
经过调研和实践,自动Web化是可行的,由于Dubbo服务的特点是Provider和Consumer都依赖一个共同的接口,那么可以根据接口做些文章。
1. 首先,根据接口定义出HTTP协议端点的规则。
这里还拿上面示例代码作说明,例如下面的接口:
public interface VideoService {
Video getVideo(long id);
}
可以定义出HTTP协议的端点:/类名/方法名?参数名=参数,也就是/VideoService/getVideo?id=123。
2. 其次,根据HTTP协议端点规则,通过反射方式,将服务方接口代码自动生成提供方的Controller代码,类似如下:
@RestController
@RequestMapping("/VideoService")
public class VideoController {
@Autowired
private VideoService videoService;
@RequestMapping("/getVideo")
public Video getVideo(long id) {
return videoService.getVideo(id);
}
}
3. 最后,根据HTTP协议端点规则,通过反射方式,将调用方的代码自动生成Consumer端的代理代码,此代理代码会生成到业务方接口代码所在的jar包中,这样便可以在Consumer项目中使用,类似如下:
@AutoWebClientProxy
public class VideoServiceProxy implements VideoService {
private AutoWebClient autoWebClient;
@Override
public Video getVideo(long id) {
AutoWebRequest autoWebRequest = new AutoWebRequest();
autoWebRequest.setUri("/VideoService/getVideo/long");
autoWebRequest.setReturnType(Video.class);
autoWebRequest.addRequestBody("id", String.valueOf(id));
return autoWebClient.invoke(autoWebRequest);
}
}
上述代码中的autoWebClient.invoke()
会通过HTTP协议调用Controller,底层可以是任意的实现,比如HttpClient,OkHttp等。
由于入参和返回值跟Provider接口一致,Consumer端调用时就可以做到无缝替换了。
如上所示,仅仅通过业务接口的定义,便可以生成一套支持HTTP协议的SpringCloud的标准的Web代码,那么该在什么时机进行代码生成呢?
4. 为了方便业务端调试&追踪代码,计划生成静态代码,即class文件。由于团队内部的项目都使用Maven管理,在Maven编译期间,通过自定义的Maven的插件,扫描业务的接口,按照事先制定的统一的HTTP协议端点规则,自动生成Controller和Consumer代理代码即可,生成的class文件可以自动打包部署,免于纳入到源码管理中,这样就可以不对业务端代码造成侵入了,如下所示:
另外,关于代码生成,采用了javapoet,使生成代码更易实现。
最终,该方案满足了之前的所有目标,下图中的AutoWebService即为自动Web化方案的整体逻辑架构:
如上图所示,上半部分的Dubbo项目整体运行于SpringCloud平台中,同时,下半部分为自动生成的Controller和Consumer代理,它们通过Eureka做服务的注册和发现,与Dubbo项目共存,互不影响。
07
Dubbo服务自动Web化实践的典型问题
1 性能相关
从Dubbo切换到SpringCloud,相信大家都有一个疑问,就是Dubbo服务之间调用时,底层通信采用Netty,而Netty以高性能著称。而SpringCloud服务之间采用Http协议通信,那么切换后服务性能是否会有影响呢?
为了满足业务端对性能的需求,进行了性能比对压测,相关压测环境和参数如下:
a. 整体测试流程如下图:
如上图所示,压测工具采用Apache Bench - ab,调用流程如下:
ab -> Dubbo Consumer -> Dubbo Provider
ab -> AutoWebProxy -> AutoWebService
b. 物理部署配置如下:
c. 应用版本及逻辑部署:
SpringCloud-2021.0.8,SpringBoot-2.6.15,Dubbo-2.6.13
Dubbo Provider和AutoWebService的Controller共存
Dubbo Consumer和AutoWebService的代理共存
Dubbo的Consumer调用Provider
AutoWebService的代理调用Controller
压测工具采用Apache Bench
d. 部分参数说明:
Dubbo采用默认配置,底层通信为Netty,即一个链接处理所有请求。
由于AutoWebService依赖SpringCloud,默认内置容器为Tomcat,由于Tomcat为每个请求采用一个线程,故将最大线程数调整至400,并开启长连配置:
server:
tomcat:
threads:
max: 400
max-keep-alive-requests: -1
AutoWebService的客户端代理的Http实现类采用OkHttp,链接池最小空闲数配置为100(官方建议根据qps或调用服务的线程数设置)。
e. 所有测试用例经过预热,跑3次后取最高值,测试期间物理资源cpu、内存、网络等指标正常,最终得到如下结果:
收发为字符串压测数据(纵坐标为每秒的吞吐量,横坐标为并发量和收发字节大小的组合坐标):
根据上图压测数据,可以看出,在收发为字符串时,大小为10字节,AutoWebService的性能低于Dubbo。
其余字节时AutoWebService的性能与Dubbo旗鼓相当,吞吐可达每秒1万~1.6万。
收发为对象的压测数据(纵坐标为每秒的吞吐量,横坐标为并发量和收发字节大小的组合坐标):
根据上图压测数据,可以看出,在收发为对象时,在较小的对象时,与字符串测试结果相当。
但是当收发对象较大时,AutoWebService吞吐高于Dubbo,尤其当收发对象大于1K时,Dubbo性能急剧下降,这与Dubbo官方说的Dubbo设计的目的是为了满足高并发小数据量调用,在大数据量下性能表现并不好
不谋而合了。
f. 压测总结:由于日常开发时基本收发都用对象的方式,AutoWebService的性能会略高于Dubbo,尤其涉及到大量对象传输时。
以上压测结果仅代表特定环境下的两个框架的对比情况,由于版本,环境,配置,压测参数等等不同,压测结果会不尽相同。
2 多对象入参问题
某些接口存在入参为多个对象的方法,例如如下代码:
public interface VideoService {
List
而在自动转换为Controller时,需要为对象参数添加@RequestBody,但是该注解在一个方法中只支持一个,如果添加多个就会报错。
【解决方案】:客户端HTTP调用时,采用Post请求,将多个对象分别序列化为类似k1=v1,k2=v2的形式,其中值为json,当做字符串传递。
在Controller端,检测到参数为对象时,分别解析类似参数k1,k2,将对应的值反序列化为对象即可。
3 返回值问题
返回值为泛型类型
【解决方案】:反射时检测并支持泛型类型。
业务代码抛出自定义异常
【解决方案】:捕获自定义异常,将异常序列化,传输至Consumer端,再抛出。
4 异步调用问题
例如如下代码:
Video video = videoService.getVideo(123);
此代码为同步调用,即调用方法返回即可获取到Video对象。
业务端需要支持异步调用,但是不能修改业务的接口签名,接口代码类似如下:
public interface VideoService {
Video getVideo(long id);
}
而众所周知的是,异步调用后,结果会异步返回,一般是通过回调通知或Future获取结果,不修改上述接口签名定义的前提下,该如何做?
【解决方案】:采用动态代理机制,在发生videoService.getVideo(123)
调用时进行拦截,拦截后底层进行异步调用,并将Future对象返回给业务端。
这里有一点需要说明一下,即动态代理并不能修改方法的返回值类型,即Video getVideo(long id)
方法返回为Video,代理后的方法依然需要返回为Video,但是这里需要将Future返回,所以采用了暂存到ThreadLocal的方法,简化代码如下:
/**
* 代理调用
*/
public class AsyncInvocationHandler implements InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 异步调用
CompletableFuture future = (CompletableFuture) client.invoke(request);
// 调用的Future放入ThreadLocal
FUTURE_THREADLOCAL.set(future);
// 返回值处理
}
}
/**
* 支持同步转异步调用
*/
public interface AutoWebSupplier {
T get() throws Exception;
static CompletableFuture supplyAsync(AutoWebSupplier supplier) {
try {
supplier.get();
return FUTURE_THREADLOCAL.get();
} catch (Exception e) {
throw new AutoWebException("unexpected", e);
} finally {
FUTURE_THREADLOCAL.remove();
}
}
}
// 业务端异步调用
CompletableFuture
如上代码所示,在不修改业务接口定义的情况下,实现了异步调用的支持。
相关的问题还有很多,这里不再一一列举,由于自动化生成的代码的特性,不可能一开始就100%满足所有业务端需求,需要根据业务端的实际情况进行不断地完善。
08
总结
1. 为了强化Zookeeper运维,已经对如下方面进行了开发和优化:
支持输出请求日志,并支持输出指令和请求包大小;
传输包过大时打印部分信息,便于故障定位;
支持动态封禁客户端IP等等。
2. 由于Dubbo依赖CP架构的Zookeeper作为服务注册和发现组件,无法保障高可用,并且某些低版本的Dubbo存在的BUG,会导致故障,综合了成本和效益后,提供了一套自动Web化的方案,目前各个项目已经陆续接入AutoWebService,反应良好。