起因
在线上的spring clould集群中,有一个业务服务只有一台机器部署,原因就在于它的业务特殊性(小声bb,前人滴锅),无法进行多节点的负载均衡,它主要是用来做指令下发,与第三方服务器交互。由于它在线上的cpu负荷太高,导致服务器经常很卡,且高峰期客户端调用老超时。随机统计近一天20小时的调用量平均在每秒6次的样子,看起来不高,但由于执行比较耗时,且调用它大多是并发查询,所以cpu会突增。。
调用流程
先来一张流程图说明一下实现一次完整调用的流程:
- 客户端首先发起open请求,它的作用是打开一个连接,这个连接是服务端与第三服务之间的一个socket连接,并且存储在服务端的一个自定义连接池中,返回给客户端的就是一个session。
- 客户端发起execue请求,并携带上一步的session,服务端通过此session获取到连接,执行相关的指令操作,并返回结果。
- 客户端发起close请求,并携带session,服务端就会关闭与第三方的socket连接。
业务瓶颈
由于请求量比较大,且通过socket请求返回内容,由于网络等各种因素,整个调用流程耗时比较长,最多的有超过1分钟,业务量大的时候,服务器负荷经常在700/800,有时候还会上千,服务器cpu是32核的,所以必须要优化。
方案一
我起初思考的是不让客户端持有session了,客户端直接执行execute这一步即可,open和close全由服务端控制,所有指令都执行完毕后再返回结果。其实这一种方案是最好的,但是改造量比较大,涉及地方太多,客户端、服务端都要修改,为了减少改动,就被否决了。另外我们这个服务的执行和第三方交互耗时太长,涉及多指令可能会导致各种超时,还需要全局调整超时,修改点太多,比较尴尬。
方案二
在不改动客户端基础上,只改服务端代码,就可以实现多节点部署的方案,我设想的是能否把存储连接的连接池改为像redis这种的公共存储呢。于是我开始想把我的连接对象进行序列化存储,后来实践发现这无法实现,网上有人这样解释道:socket 是不可序列化的,因为它是表示网络双方连接的一种抽象对象,连接双方的状态是与时间高度相关,而序列化则是一种试图固化对象状态的操作,所以二者是矛盾的,是毫无意义的。所以此种方案也被否决掉了。
方案三
基于方案2,做了一个变形,既然无法序列化socket,那我标识服务器呢。我们知道在spring clould中,服务之间的调用是可以通过feign来实现的,而feign可以指定具体的调用地址。
@FeignClient(name = "cmd-service", url="${service.url}")
于是我可以像下面这样操作:
1.在服务中新建2个配置文件,第一个配置项为第二台机器的ip,供feign
调用,第二个即为自身服务器标识,存入redis,session为key,标识为value。
#同一session请求发送到同一服务实例,129服务器读取该配置
service:
url: 127.0.0.1:16003
#标识session属于哪一服务实例
flag: server2
代码中通过这样判断:
//不控制好会造成死循环
if(StringUtils.isBlank(request.getSession()) || redisUtil.get(request.getSession()) ==null) {
throw new NoFoundElementException("Session is not allowed null");
}
if( redisUtil.get(request.getSession()).equals(handlerConfig.getServerFlag())){
//业务逻辑
}else {
response = feignClient.execute(request);
}
这样就可以实现双节点的负载均衡,缺点就是这是属于定制化的改动,不过可以添加服务分担压力也是可以的,特别需要注意的是判断条件的控制
,否则就会造成死循环了,redis采用超时存储,过期自动删除。
思考
代码试运行了几天,发现连接池中有些session并没有及时清除,猜测可能是由于客户端没有调用close导致,具体还得再观察。不知道还有其他更好的方案吗?