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

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

服务的部署:

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

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

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

我所经历的一次Dubbo服务雪崩,这是一个漫长的故事_第1张图片

从日记可以看出的问题:

一是远程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的大小而导致命令耗时长。

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

我所经历的一次Dubbo服务雪崩,这是一个漫长的故事_第2张图片

分别写入四个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长。

我所经历的一次Dubbo服务雪崩,这是一个漫长的故事_第3张图片

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

而Jedis的Read time out则是包括1、2、3、4步骤,从命令的发出到接收完成Redis服务端的响应结果,超时原因有两大原因:

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

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

所以将Redis从一主一从改为两主之后,导致Jedis的Read time out的原因一有所缓解,分摊了部分压力。但是原因2还是存在,耗时依然是问题。

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

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

我所经历的一次Dubbo服务雪崩,这是一个漫长的故事_第4张图片

问题最终还是要回到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 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。

我所经历的一次Dubbo服务雪崩,这是一个漫长的故事_第5张图片

这段代码不仅耗时,还很耗内存,没错,就是这个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,官方文档传送门:

https://github.com/alibaba/Sentinel/wiki/%E6%8E%A7%E5%88%B6%E5%8F%B0)

我所经历的一次Dubbo服务雪崩,这是一个漫长的故事_第6张图片

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

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

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