全球领先的redis客户端:SFedis

时间:2022-09-07 20:59:44

零、背景

  这个客户端起源于我们一个系统的生产问题。

一、问题的发生

  在我们的生产环境上发生了两次redis服务端连接数达到上限(我们配置的单节点连接数上限为8000)导致无法创建连接的情况。由于这个系统生产环境的redis集群的tps达到百万级,所以发生了这个情况的后果是非常严重的,有的业务会发生缓存穿透的情况,有的业务会直接报错。

二、问题分析

  在生产环境上每个redis节点的tps上限在50000左右,我们监控redis的slowlog的阀值设置为0.1ms,也就是说如果服务端慢到10000tps时就会触发报警,但在问题发生当时并没有报警。实际上这是我们的一个失误:如果redis一个服务节点是独享一个cpu核的,那么按照redis的机制是可以推测出slowlog是不可能会有“慢”的结果的。那么如果慢一定不是在redis本身的处理上,有可能是塞在epoll上或者网络上。但我们并没有发现有任何地方有异常(包括网络)。

  我们并没有查到故障发生在哪里,但故障的确就发生了,这是很离奇的。

  最后我们只能进行了推测:正常情况下整个集群的速度是非常快的。监控设置的0.1ms的阀值虽然看起来是非常快(万分之一秒),但和正常情况下的平均响应时长来说还是慢了5倍的差距。也就是说,我们检测每一个地方都没有看到问题,可能只是因为检测的标准以及检测工具的能力(精确度)的问题。比如说:平时单节点平均处理能力在0.02ms每个命令,但当慢(无论慢在哪里)到0.05ms的时候我们是没能监控出来的,而实际上这个时候问题已经发生了。假设网络因未知原因卡了一秒钟,那么就会有几十万到一百万个请求塞在网络上,客户端因请求还没有返回,新的请求就会向连接池申请新的连接,如果服务端没有保留足够的buffer来处理瞬间多出来的请求,那么很有可能在这个时候发生一个雪崩效应——连接数瞬间达到上限。

三、临时解决

  当时在故障处理时,我们采取了比较粗爆但有效的办法:减少客户端的数量。我们停掉了相关服务的一半节点,使所有运行节点的线程池即使全部打满也不会达到redis服务端的上限,这样当业务消费一段时间后,请求降下来了,再启动被关掉的服务。

  当天晚上我们对redis集群进行了扩容,保留了更大的buffer,使应对异常冲击的能力提高一些。

  这些只是临时的解决方案,治标不治本的。所以还是需要更进一步研究更好的解决方案。

  这里需要说明一点:为什么服务可以停掉一半?如果服务停掉了一半,前端的请求会不会把服务的cpu打满,导致服务挂掉呢?

  这里是因为:

  1.服务端对所有的rest/http接口以及rpc接口都做了隔离限流,每任何一个接口超过一定的并发之后,后面的请求就会马上报错,保证服务的安全。

  2.用户端是移动App,在移动端我们对所有重要业务做了统一的重试机制,如果没有传上来的,可以在一定时间之后再次重试。

  所以这里服务端减少服务能力的情况下,并不会导致严重的业务问题,但是会使业务数据上传变慢一些。

四、原理分析

  当时我们的客户端用的是jedis,连接的管理用的是jedis自带的。

  因为redis服务端的每个节点的数据是不同的,所以在长时间的调用下,每个客户端一定会访问到每个服务端节点。这样的话,服务端每个节点的连接数就并不取决于服务端集群的大小,而取决于客户端集群的大小。

  如下图所示:如果客户端有2个,每个客户端的连接池上限是40个连接,那么无论服务端是多少个节点,每个节点的连接数量的上限应该是40*2=80个。

  全球领先的redis客户端:SFedis

  那么问题来了:服务端的每个节点处理能力是有限的,连接数过多是没有意义的,如果每个服务端的连接上限是10000个,每个客户端的连接池上限是100个,那么在理论上要保证连接安全,客户端的节点数上限是10000/100=100个。但如果我们需要更大的业务处理能力(业务应用集群的节点数需要超过100个)的情况下,怎么办呢?

五、一个想法

  从理论上说,1个连接是可以达到一个网卡的带宽极限的,那么是否有可能做到每个redis客户端只有一个连接,却可以达到原来n多个连接一样的性能(甚至更好)呢?

六、研究业界现有方案

  带着问题,我们用了两个月时间来研究测试各种业界公认的成熟方案(除了当时正在用的jedis客户端之外,还研究了twemproxy、Codis、redis 4.0 (cluster)、redisson),发现这些方案并没有让我们满意。下面说一下我们为什么不选择这些方案:

  twemproxy:代理并不能完全解决连接数的问题,它只能让连接数少一些,而且代理大约有20%的性能损耗。

  Codis:1.代理和twemproxy的差不多,也不能完全解决连接数的问题;2.Codis新版本没有节点失效的检测的能力;3.整个方案的部署比较麻烦。4.在增加节点时,集群会自动迁移数据(当然,这个不能说是缺点,但如果整个集群的内存达到几个T的情况下,内存的数据迁移会有什么后果不好预料(迁移数据导致网络塞住怎么办?迁移数据时服务会中段多长时间?))。

  Redis cluster:1.必须做主备,当主备都挂了的情况下,不能自动摘除节点;2.在增加节点时,集群会自动迁移数据——这一点和Codis一样——我们宁可缓存穿透,也不希望他迁移数据(如果实现了一致性hash,那么会穿透的数据还是很少的——比如:如果我们服务已经有了100个节点,再加一个节点最多只会导致1%的数据失效);

  redisson:这个客户端用了nio机制,在异步操作的情况下的确会大大减少连接数,并且异步的性能非常好(极端的情况下,有可能是jedis的十倍)。但在同步的情况下就没那么乐观,还是需要多个连接才能勉强追得上jedis的速度。如果我们改用redisson的异步形式,则需要改业务代码,这是很难接受的——不过这里我认为是redisson的开发者们对代码的优化没有做到极致,因为在基础原理上nio可以达到的程度绝对可以比现在的redisson更好。

  另外,如果采用短连接的形式的话,对性能的影响比较大,所以我们也不想牺牲长连接的优势。

  既然找不到已经实现好的成熟方案,那么我们是否可以自己实现一个呢?

七、自己开发

  目标很清晰:一个“新的jedis”,但每个客户端在连接每个服务节点时只连一个连接,最重要的是性能绝不可以比jedis差。

  虽然目标很清晰,并且在基础原理上是可以达到的,但具体的技术细节确并不容易。目标是我定的,但我给不出在技术细节上的实现方案,后来我们部门内的一个码神想到了一个很好的实现方案。

  具体原理是这样的:

  1.redis的通信协议是tcp,这就提供了异步请求的基础——如果是同步的网络请求,客户端就需要等待服务端的响应,那么在等待这段时间里,带宽是空着的,这样要打满带宽就必然需要多个连接,所以,如果我们需要用一个连接打满带宽就必然需要用异步。

  2.redis的命令协议上是没有在发送与接收之间建立对应关系的(没有msg_id之类的属性),这如果不停的发送与接收命令,应该如何告诉业务哪个接收到的数据属于应用事例的哪个线程呢?这里我们找到了一个很巧妙的对应关系:顺序。redis服务端是单线程的,那么服务端先接收到的命令必然先返回,同时,tcp协议又是保证顺序的,这就决定了我们可以用“顺序”做为“发”与“收”之间的对应协议。

  3.为了不修改业务,我们必须用“新的原理”来实现“老的接口”,老接口都是同步操作的,那么这里的阻塞动作就一定要在客户端框架中来实现了。这里就要用到Future了。

  全球领先的redis客户端:SFedis

  最终的实现结果是:我们自己实现的新redis客户端框架SFedis访问每个服务节点只用1个连接,却比业界广泛使用的Jedis用多个连接还要快一点。

  我们现在还没有实现异步接口,如果我们真的实现了异步接口,那么估计比redisson还要快。

  另:在十一月的新书《决战618》我看到书中有写到京东也有用nio实现自己的redis客户端来解决连接数的问题,不过书中只有一句话讲这个,完全没有任何细节。

八、结果展示

  我们有两个服务共用一个redis集群,下图是其中一个服务上线后的连接数监控图:

  全球领先的redis客户端:SFedis

  这里可以看到:一个服务上线之后的几天比上线前的几天,redis连接数直接腰斩了。

  下图是另一个服务也上线之后的连接数监控图:

  全球领先的redis客户端:SFedis

  可以看到在第二个服务上线之后,连接数已经完全不再波动了(这里千万别误会:后面三天的线是平的,不代表没有服务。服务是正常运行的,而且运行得很健康),这里连接数停留在应用实例的个数上(58个)。

  这里声明一下:这个系统是有做灰度的,在生产上有多个环境在跑不同的版本,上面的两个截图是一个小环境上线前后的监控情况,所以节点数比较少,只有58个。而且这个小环境在性能上留的buffer是比较充足的,所以平时的redis连接数也不高。在大的生产环境上这个图会显得更猛一些。

九、开源计划

  目前这个客户端还没有开源,但开源已在计划之中。后续开源之后会公布出来。

十、人员招募

  我们团队正在招人,岗位有:Android开发、Java后台开发、架构师、测试。欢迎大家推荐或自荐!

  简历请发我邮箱:zhouyou@sf-express.com