我所经历的一次Dubbo服务雪崩,这是一个漫长的故事

在一个处理用户点击广告的高并发服务上找到了问题。看到服务打印的日记后我彻底蒙了,全是jedis读超时,Read time out!一直用的是亚马逊的Redis服务,很难想象Jedis会读超时。git

看了服务的负载均衡统计,发现并发增加了一倍,从每分钟3到4万的请求数,增加到8.6万,很显然,是并发翻倍致使的服务雪崩。github

服务的部署:redis

处理广告点击的服务:2台2核8g的实例,每台部署一个节点(服务)。下文统称服务A设计模式

规则匹配服务(Rpc远程调用服务提供者):2个节点,2台2核4g实例。下文统称服务B数组

还有其它的服务提供者,但不是影响本次服务雪崩的凶手,这里就不列举了。缓存

从日记能够看出的问题:bash

一是远程rpc调用大量超时,我配置的dubbo参数是,每一个接口的超时时间都是3秒。服务提供者接口的实现都是缓存级别的操做,3秒的超时理论上除了网络问题,调用不该该会超过这个值。在服务消费端,我配置每一个接口与服务端保持10个长链接,避免共享一个长链接致使应用层数据包排队发送和处理接收。网络

二是刚说的Jedis读操做超时,Jedis我配置每一个服务节点200个最小链接数的链接池,这是根据netty工做线程数配置的,即读写操做就算200个线程并发执行,也能为每一个线程分配一个链接。这是我设置Jedis链接池链接数的依据。数据结构

三是文件句柄数达到上线。SocketChannel套接字会占用一个文件句柄,有多少个客户端链接就占用多少个文件句柄。我在服务的启动脚本上为每一个进程配置102400的最大文件打开数,理论上目前不会达到这个值。服务A底层用的是基于Netty实现的http服务引擎,没有限制最大链接数。架构

因此,解决服务雪崩问题就是要围绕这三个问题出发。

第一次是怀疑redis服务扛不住这么大的并发请求。估算广告的一次点击须要执行20次get操做从redis获取数据,那么每分钟8w并发,就须要执行160w次get请求,而redis除了本文提到的服务A和服务B用到外,还有其它两个并发量高的服务在用,保守估计,redis每分钟须要承受300w的读写请求。转为每秒就是5w的请求,与理论值redis每秒能够处理超过 10万次读写操做已通过半。

因为历史缘由,redis使用的仍是2.x版本的,用的一主一从,jedis配置链接池是读写分离的链接池,也就是写请求打到主节点,读请求打到从节点,每秒接近5w读请求只有一个redis从节点处理,很是的吃力。因此咱们将redis升级到4.x版本,并由主从集群改成分布式集群,两主无从。别问两主无从是怎么作到的,我也不懂,只有亚马逊清楚。

Redis升级后,理论上,两个主节点,分槽位后请求会平摊到两个节点上,性能会好不少。但好景不长,服务从新上线一个小时不到,并发又突增到了六七万每分钟,此次是大量的RPC远程调用超时,已经没有jedis的读超时Read time out了,相比以前好了点,至少不用再给Redis加节点。

此次的事故是并发量超过临界值,超过redis的实际最大qps(跟存储的数据结构和数量有关),虽然升级后没有Read time out! 但Jedis的Get读操做仍是很耗时,这才是罪魁祸首。Redis的命令耗时与Jedis的读操做Read time out不一样。

redis执行一条命令的过程是:

  1. 接收客户端请求

  2. 进入队列等待执行

  3. 执行命令

  4. 响应结果给客户端

因为redis执行命令是单线程的,因此命令到达服务端后不是当即执行,而是进入队列等待。redis慢查询日记记录slowlog get的是执行命令的耗时,对应步骤3,执行命令耗时是根据key去找到数据所在的内存地址这段时间的耗时,因此这对于key-value字符串类型的命令而言,并不会由于value的大小而致使命令耗时长。

为验证这个观点,我进行了简单的测试。

分别写入四个key,每一个key对应的value长度都不等,一个比一个长。再来看下两组查询日记。先经过CONFIG SET slowlog-log-slower-than 0命令,让每条命令都记录耗时。

key_4的value长度比key_3的长两倍,但get耗时比key_3少,而key_1的value长度比key_2短,但耗时比key_2长。

第二组数据也是同样的,跟value的值大小无关。因此能够排除项目中因value长度过长致使的slowlog记录到慢查询问题。慢操做应该是set、hset、hmset、hget、hgetall等命令耗时比较长致使。

而Jedis的Read time out则是包括一、二、三、4步骤,从命令的发出到接收完成Redis服务端的响应结果,超时缘由有两大缘由:

  • redis的并发量增长,致使命令等待队列过长,等待时间长

  • get请求读取的数据量大,数据传输时间长

因此将Redis从一主一从改成两主以后,致使Jedis的Read time out的缘由一有所缓解,分摊了部分压力。可是缘由2仍是存在,耗时依然是问题。

Jedis的get耗时长致使服务B接口执行耗时超过设置的3s。因为dubbo消费端超时放弃请求,可是请求已经发出,就算消费端取消,提供者没法感知服务端超时放弃了,仍是要执行完一次调用的业务逻辑,就像说出去的话收不回来同样。

因为dubbo有重试机制,默认会重试两次,因此并发8w对于服务b而言,就变成了并发24w。最后致使业务线程池一直被占用状态,RPC远程调用又多出了一个异常,就是远程服务线程池已满,直接响应失败。

问题最终仍是要回到Redis上,就是key对应的value太大,传输耗时,最终业务代码拿到value后将value分割成数组,判断请求参数是否在数组中,很是耗时,就会致使服务B接口耗时超过3s,从而拖垮整个服务。

模拟服务B接口作的事情,业务代码(1)。

/**
 * @author wujiuye
 * @version 1.0 on 2019/10/20 {描述:}
 */
public class Match {

    static class Task implements Runnable {
        private String value;

        public Task(String value) {
            this.value = value;
        }

        @Override
        public void run() {
            for (; ; ) {
                // 模拟jedis get耗时
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // =====> 实际业务代码
                long start = System.currentTimeMillis();
                List<String> ids = Arrays.stream(value.split(",")).collect(Collectors.toList());
                boolean exist = ids.contains("4029000");
                // ====> 输出结果,耗时171ms .
                System.out.println("exist:" + exist + ",time:" + (System.currentTimeMillis() - start));
            }
        }
    }

    ;

    public static void main(String[] args) {
        // ====> 模拟业务场景,从缓存中获取到的字符串
        StringBuilder value = new StringBuilder();
        for (int i = 4000000; i <= 4029000; i++) {
            value.append(String.valueOf(i)).append(",");
        }
        String strValue = value.toString();
        System.out.println(strValue.length());
        for (int i = 0; i < 200; i++) {
            new Thread(new Task(strValue)).start();
        }
    }
}
复制代码

这段代码很简单,就是模拟高并发,把200个业务线程所有耗尽的场景下,一个简单的判断元素是否存在的业务逻辑执行须要多长时间。把这段代码跑一遍,你会发现不少执行耗时超过1500ms,再加上Jedis读取到数据的耗时,直接致使接口执行耗时超过3000ms。

这段代码不只耗时,还很耗内存,没错,就是这个Bug了。改进就是将id拼接成字符串的存储方式改成hash存储,直接hget方式判断一个元素是否存在,不须要将这么大的数据读取到本地,即避免了网络传输消耗,也优化了接口的执行速度。

因为并发量的增加,致使redis读并发上升,Jedis的get耗时长,加上业务代码的缺陷,致使服务B接口耗时长,从而致使服务A远程RPC调用超时,致使dubbo超时重试,致使服务B并发乘3,再致使服务B业务线程池全是工做状态以及Redis并发又增长,致使服务A调用异常。正是这种连环效应致使服务雪崩。

最后优化分三步

一是优化数据的redis缓存的结构,刚也提到,由大量id拼接成字符串的key-value改为hash结构缓存,请求判断某个id是否在缓存中用hget,除了能下降redis的大value传输耗时,也能将判断一个元素是否存在的时间复杂度从O(n)变为O(1),接口耗时下降,消除RPC远程调用超时。

二是业务逻辑优化,下降Redis并发。将服务B由一个服务拆分红两个服务。这里就很少说了。

三是Dubbo调优,将Dubbo的重试次数改成0,失败直接放弃当前的广告点击请求。为避免突发性的并发量上升,致使服务雪崩,为服务提供者加入熔断器,估算服务所能承受的最大QPS,当服务达到临界值时,放弃处理远程RPC调用。

(我用的是Sentinel,官方文档传送门:

github.com/alibaba/Sen…

因此,缓存并非简单的Get,Set就好了,Redis提供这么多的数据结构的支持要用好,结合业务逻辑优化缓存结构。避免高并发接口读取的缓存value过长,致使数据传输耗时。同时,Redis的特性也要清楚,分布式集群相比单一主从集群的优势。检讨img。

通过两次的项目重构,项目已是分布式微服务架构,同时业务的合理划分让各个服务之间完美解耦,每一个服务内部的实现合理利用设计模式,完成业务的高内聚低耦合,这是一次很是大的改进,但仍是有还多历史遗留的问题不能很好的解决。同时,分布式也带来了不少问题,总之,有利必有弊。

有时候就须要这样,被项目推着往前走。在未发生该事故以前,我花一个月时间也没想出困扰个人两大难题,是此次的事故,让我从一个短暂的夜晚找出答案,一个通宵让我想通不少问题。

相关文章
相关标签/搜索