记录一次高并发业务场景的性能优化

功能优化

直播账号创建优化

场景:每个企业云用户在直播时需要登录第三方直播账号,如果没有,则创建一个直播账号

问题:创建直播账号的服务是第三方提供的,不支持高并发

优化:在用户登录的时候调用直播服务,初始化直播账号,将创建直播账号的并发压力分散到用户登录期间,这个优化经历了几个版本,最终我们使用方案3实现。

方案1:在登录的回调函数中同步调用直播服务创建账号。这个方案存在的问题是,假如创建直播账号的服务响应缓慢、异常将影响用户登录。

方案2:在登录的回调函数中异步调用直播服务创建账号。这个方案解决了方案1的问题,但代码、服务依然耦合在一起。登录本不应该依赖直播服务,如果以后需要在登录功能上做另外一些额外的预处理和通知,将会让登录的功能变得复杂且职责不清。

方案3:在登录的回调函数中发送一个主题消息,并投递到MQ,之所以选择主题消息而不是点对点消息,是考虑到未来可能会在登录的时候做其他的一些业务操作。直播业务通过订阅登录消息,收到消息后进行账号初始化、缓存,使登录服务与创建直播账号服务完全解耦,并且有良好的扩展性。

 

通过消息队列削峰异步处理

场景:生成试卷、保存草稿、提交试卷

问题:这些场景有着较高的并发,这些业务操作本身也比较复杂,原先的同步调用在高并发的场景下会导致服务处理不过来,提示业务操作失败

优化:将同步操作改为消息+轮询的方式。

1、 用户点击考试的的时候发送出卷的消息,并在redis中写入出卷中的状态并返回客户端

2、 后端根据业务需要配置处理线程数(生成试卷这里配置了100个线程),执行业务操作,在出卷完成后将试卷放到redis中,并擦除redis出卷状态。

3、 客户端首先延迟1s再发轮询请求(调用出卷请求后马上开始轮询,这时大部分情况下后端是还没出好试卷,所以这个请求意义不大),然后再间隔每两秒轮询,处理完成,再获取试卷数据。

 

通过缓存提升服务响应时间、降低DB读压力

场景:获取期次、章节、课时单元、试卷、考试、题目信息等

问题:之前的这些操作基本都是直接查数据库,在高并发的情况会给数据造成较大的压力,增加业务操作的响应时间

优化:对于这些与用户无直接关联,写少读多的数据,使用缓存减轻数据库的压力

1、 缓存框架可以覆盖到的地方(Service的接口)通过注解的方式添加缓存,减少缓存代码的侵入性

2、 由于修改数据的地方比较多、业务也较复杂,避免对代码改动的范围过大影响其他业务线,所以并没有在原接口上面直接加注解缓存,而是增加一个xxxWithCache的接口,在这个接口上配置缓存注解,在需要缓存的地方调用withCache接口。因为在写时没有及时的擦掉缓存,会导致了部分数据最多有90秒的延迟。

3、 其他不能使用缓存框架地方通过编码实现缓存

 

读写分离,降低主库压力

场景:导出报表等功能

优化:通过从读库获取数据

 

代码调用剥离

场景:期次指派全部3万名员工、提交试卷计算每个学生的期次总成绩

问题:整个指派过程需要40分钟

优化:用户指派之后还需要干这么几个事情

1、发送指派通知

2、更新指派用户数

3、初始化签到数据

         所以整个指派的时长是成倍增加的,之前的实现是通过同步观察者模式实现,后面改为消息队列实现,并且在索引优化后整个指派过程只有3分钟。

 

延迟合并请求

场景:用户提交试卷更新答卷人数、用户指派更新指派汇总数等

问题:在高并发下的对同一查询条件的count,同一记录的update,会造成资源剧烈争抢,还可能造成死锁

优化:通过延迟合并处理,降低并发争抢资源和无用的计算。目前设置的是2分钟,用户提交试卷后并不会马上进行答卷人数的统计更新,而是延迟2分钟进行更新,这期间如果有其他用户提交试卷,更新请求就会被忽略。未来可以通过缓存增量更新+定时重算的方式实现更好的用户体验。

 

云信聊天室优化

场景:直播场景下的讨论区聊天

问题:并发聊天的时候整个直播页面就崩溃了。

优化:在服务端和前端都做了并发限制

服务端:云信针对整个聊天室做并发控制,在并发超过上限后会丢弃部分普通用户的信息

客户端:通过队列上限500+定时任务的方式,来避免服务端推送的消息量过快时,客户端频繁刷新页面导致崩溃。

 

前端随机打散请求

场景:获取直播状态、定时保存草稿

问题:客户端发起接口请求的间隔步长相同,在比较极端的情况下可能造成服务端并发过大(如果3w人在10s内一起进入考试,那么每隔5分钟就会有一轮3000的并发保存草稿)

优化:定时保存草稿,第一次保存草稿的时间是300秒内的随机数,第二次开始为间隔300秒(这样哪怕3w人一起进入考试,保存草稿的并发依然被分散了)。

 

批处理、合并SQL

场景:获取课时资源

问题:通过遍历每个章节来获取章节下的课时,这样如果有10个章节,需要执行10次获取课时的接口

优化:通过合并SQL,将期次下的所有课时一次取出,然后在内存中根据课时对应的章节ID进行分组组合,从而减少了SQL语句的数量。

 

 

压力测试过程中发现的一些问题

性能测试主要是通过jvisualvm监控服务器CPU的压力,通过profile快照分析各种线程和各个接口CPU耗时,以及通过哨兵平台的服务调用汇总信息等作为参考依据。

 

调整log级别,取消无用log

log4j 1.x版本,大量输出日志很耗CPU资源

a、将sql日志的日志级别从info改为error

b、控制日志的输出长度

          c后续可以考虑将log4j改为log4j2(https://my.oschina.net/OutOfMemory/blog/789267)

 

调整框架并发数

提高并发框架的中每个接口的并发数到300,提高dubbo服务提供方的总体线程数到300,提高消费方每服务每方法最大并发数到100.

 

JSON转换消耗大量CPU

通过profile快照发现json转换上面耗费大量cpu,主要体现在有些地方用的是gson转换,有些地方对象转换的用法是将dto先转为json字符串,再将json转换成vo。通过将gson换成fastjson性能得到了提升,而对象转换尽量用beancopier(http://www.cnblogs.com/kaka/archive/2013/03/06/2945514.html)。

 

AOP切面尽量不要做太多事情

AOP存在性能问题将影响所有被拦截的接口

            a、BeanPlacementInterceptor

            每个请求都需要通过远程服务获取站点信息,目前provider中的服务实现已经改为从缓存中取站点,但依然存在2次的远程调用。这是一个极高并发的接口,在AOP里调用服务还会受到并发数的限制,建议后续改成直接从缓存中读取,这样可以省去一次远程调用;如果改成本地缓存,则完全去除了远程调用。不建议在AOP中调用远程服务。

            b、WebMethodsCalledLogAspectj

          这个拦截器用于记录每个请求的响应时间,在finally方法中会用gson序列化返回值,对于试卷这么大的对象转换时耗时的。

 

 

踩过的一些坑

请求链路上的某个服务并发没有提高,导致目标服务TPS上不去

dubbo提供者原先总体并发数是100,每个服务的消费方的并发数是20。由于很多服务都依赖于base-conf,provider-server,导致目标服务的代码怎么优化TPS都提不上去,体现在profile快照上的现象是不管怎么提高压力机并发数,快照中的耗时线程数就是20个。修改请求链路中涉及的服务提供者并发数后,TPS提升。

 

CPU满载导致中间件服务表现异常,影响优化方向判断

通过jvisualvm工具分析出从redis中获取试卷快照,每次占用CPU200毫秒甚至更多,继而怀疑redis对大数据的缓存操作能力,最后改为memcached,没有任何提升,实际上是因为CPU跑满,导致其他的服务异常,事实上,每次从缓存取100k快照的时间不过10毫秒左右。

 

压力机能力不够导致TPS上不去

在测试获取快照接口的过程中,把一些DB操作的代码注释掉,几乎是全内存操作,TPS依然保持在50左右,最后把压力机改为6台,TPS马上提升了好几倍。

 

交换机能力影响TPS

第三方直播的几个接口在并发测试的过程中,1台服务器和5台集群服务的吞吐能力几乎一模一样,最后发现交换机是100M的。

 

后续待探讨的一些优化思考

预处理

       以生成试卷为例,是否可以在开卷前的某段时间内将试卷生成好,等用户进入考试时直接将试卷发给用户

 

功能设计上多考虑一些抽象

现在的做法是每个用户生成了一张不同的卷子,因为是用户的维度,数据量是比较大的,这种情况用缓存其实是存在一定风险的,一场3w用户的考卷就用掉3、4G。如果我们针对每次考试,生成几张不同试卷,每个用户进来后从这个试卷池挑选一张试卷,缓存使用就会小很多。生成试卷和获取试卷信息的操作就简单。获取答卷信息的操作就变为通过试卷快照id从缓存中获取试卷,然后读取用户填写的答案等答卷相关信息即可。涉及的DB操作也会变得很轻量。

 

数据库设计的时候对一些大字段定义做分离

分离易变和不变的信息,尽量不要将易变和不变的信息糅合在一个字段里。

以生成试卷快照为例:目前的快照包含了试卷本身+用户填写的答案。保存草稿的时候实际上是读取试卷快照,然后将用户填写的答案填充到快照中,然后再更新整个大快照。试卷再生成后就不在变化,而答案在多次保存草稿和提交时都会发生变化,如果我们在设计上将试卷和用户答案分离,那么保存草稿的动作就非常简单,就是更新答案字段,对于客观题来讲答案就是一个很小的字段。

 

类似的业务场景尽量使用框架或公共代码的方式解决

a、比如使用缓存框架来处理缓存,未来要将缓存中间件替换,业务代码无须改动;如果在现有的分布式缓存基础上再加一级本地缓存,业务代码基本也是不需要调整的。

b、比如目前所有导出excel功能用的是相同的导出模板框架,未来我们要在现有的进度上面增加预计完成时间,只要在模板框架底层做这个改动就可以,业务层面的代码无须改动。


并发处理

目前代码的整个处理流程都是串行的,但对于同一个服务内部调用的其他服务可能没有依赖关系,对于一些性能要求比较高的场景可以考虑并行处理,当然这样会使变成模型变得复杂一些。

举个例子:导出excel的过程分为2个部分,1、从数据库获取数据;2、往excel文件写入数据,目前是串行的做法,如果改成并行的做法就是,获取数据的线程在读取一页数据后,交给excel写入线程后马上开始读取第二页的数据。再比如指派名单列表,需要显示指派用户的最新状态(调用员工服务),还需要显示指派用户的问卷完成情况(调用问卷服务),实际上员工服务和问卷服务是可以并行的(这个dubbo也是支持的,不过不推荐生产环境用,是通过NIO实现,不需要客户端启动多个线程)。

本文来自网易实践者社区,经作者陈志良授权发布。