前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >我所经历的一次Dubbo服务雪崩,这是一个漫长的故事

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

作者头像
猿天地
发布2019-11-12 10:09:44
7270
发布2019-11-12 10:09:44
举报
文章被收录于专栏:猿天地猿天地

转自:Java艺术

这周本来是要写一篇Dubbo源码分析的,被突发事件耽搁了,下周有时间再补上。

这周,笔者经历了一次服务雪崩。服务雪崩,听到这个词就能想到问题的严重性。是的,整个项目,整条业务线都挂了,从该业务线延伸出来的下游业务线也跟着凉了。笔者是连续三天两夜的忙着处理问题,加起来睡眠时间不足5小时,今天才得以睡个好觉。但事故之后还有很多问题等着去处理。

其实这一天的到来我是有意料到的,但我以为会是数据量上升导致,实际却是并发量先上升,而严重程度超出我的预料。问题出现那天,我们还在进行每周的技术分享会,结果运营推开会议室的大门传来噩耗,顿时技术分享会变成了问题排查讨论会,场面像极了一起加班解决Bug。

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

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

服务的部署:

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

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

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

从日记可以看出的问题:

一是远程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则是包括1、2、3、4步骤,从命令的发出到接收完成Redis服务端的响应结果,超时原因有两大原因:

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

2、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)。

代码语言:javascript
复制

/**
 * @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,官方文档传送门:

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

(Sentinel控制台)

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

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

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

对于本次服务雪崩带来的影响,我只能深感抱歉!说实话,我已经厌倦了这种卖力不讨好的事情。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-11-11,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 猿天地 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
云数据库 Redis
腾讯云数据库 Redis(TencentDB for Redis)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档