天天低头写代码,可你知道什么是代码级性能优化吗?(上)

程超 2016-06-16 10:46:23
写在最前面
 

 

今天我将和大家一起探讨我负责的某项目的性能变迁之路。

 

我们以前看到的很多架构变迁或者演进方面的文章大多都是针对架构方面的介绍,很少有针对代码级别的性能优化介绍,这就好比盖楼一样,楼房的基础架子搭得很好,但是盖房的工人不够专业,有很多需要注意的地方忽略了,那么在往里面填砖加瓦的时候出了问题,后果就是房子经常漏雨,墙上有裂缝等各种问题出现,虽然不至于楼房塌陷,但楼房也已经变成了危楼。

 

那么今天我们就将针对一些代码细节方面的东西进行介绍,欢迎大家吐槽以及提建议。

 

一、服务器环境
 

 

服务器配置:4核CPU 8G内存 共4台
MQ:RabbitMQ
数据库:DB2
SOA框架:公司内部封装的Dubbo
缓存框架:Redis,Memcached
统一配置管理系统:公司内部开发的系统

 

二、问题描述
 

 

  1. 单台40TPS,加到4台服务器能到60TPS,扩展性几乎没有。

  2. 在实际生产环境中,经常出现数据库死锁导致整个服务中断不可用。

  3. 数据库事务乱用,导致事务占用时间太长。

  4. 在实际生产环境中,服务器经常出现内存溢出和CPU时间被占满。

  5. 程序开发的过程中,考虑不全面,容错很差,经常因为一个小bug而导致服务不可用。

  6. 程序中没有打印关键日志,或者打印了日志,信息却是无用信息没有任何参考价值。

  7. 配置信息和变动不大的信息依然会从数据库中频繁读取,导致数据库IO很大。

  8. 项目拆分不彻底,一个tomcat中会布署多个项目WAR包。

  9. 因为基础平台的bug,或者功能缺陷导致程序可用性降低。

  10. 程序接口中没有限流策略,导致很多vip商户直接拿我们的生产环境进行压测,直接影响真正的服务可用性。

  11. 没有故障降级策略,项目出了问题后解决的时间较长,或者直接粗暴的回滚项目,但是不一定能解决问题。

  12. 没有合适的监控系统,不能准实时或者提前发现项目瓶颈。

 

三、优化解决方案
 

 

1、数据库死锁优化解决


我们从第二条开始分析,先看一个基本例子展示数据库死锁的发生:

 


 

注:在上述事例中,会话B会抛出死锁异常,死锁的原因就是A和B二个会话互相等待。

 

分析:出现这种问题就是我们在项目中混杂了大量的事务+for update语句,针对数据库锁来说有下面三种基本锁:

 


当for update语句和gap lock和next-key lock锁相混合使用,又没有注意用法的时候,就非常容易出现死锁的情况。

 

那我们用大量的锁的目的是什么,经过业务分析发现,其实就是为了防重,同一时刻有可能会有多笔支付单发到相应系统中,而防重措施是通过在某条记录上加锁的方式来进行。

 

针对以上问题完全没有必要使用悲观锁的方式来进行防重,不仅对数据库本身造成极大的压力,同时也会把对于项目扩展性来说也是很大的扩展瓶颈,我们采用了三种方法来解决以上问题:

 

  • 使用Redis来做分布式锁,Redis采用多个来进行分片,其中一个Redis挂了也没关系,重新争抢就可以了。

  • 使用主键防重方法,在方法的入口处使用防重表,能够拦截所有重复的订单,当重复插入时数据库会报一个重复错,程序直接返回。

  • 使用版本号的机制来防重。


以上三种方式都必须要有过期时间,当锁定某一资源超时的时候,能够释放资源让竞争重新开始。

 

2、数据库事务占用时间过长


伪代码示例:

 

 

项目中类似这样的程序有很多,经常把类似httpClient,或者有可能会造成长时间超时的操作混在事务代码中,不仅会造成事务执行时间超长,而且也会严重降低并发能力。

 

那么我们在用事务的时候,遵循的原则是快进快出,事务代码要尽量小。针对以上伪代码,我们要用httpClient这一行拆分出来,避免同事务性的代码混在一起,这不是一个好习惯。

 

3、CPU时间被占满分析


下面以我之前分析的一个案例作为问题的起始点,首先看下面的图:

 


项目在压测的过程中,cpu一直居高不下,那么通过分析得出如下分析:

 

  • 数据库连接池影响

 

我们针对线上的环境进行模拟,尽量真实的在测试环境中再现,采用数据库连接池为咱们默认的C3P0。

 

那么当压测到二万批,100个用户同时访问的时候,并发量突然降为零!报错如下:


 

那么针对以上错误跟踪C3P0源码,以及在网上搜索资料:
http://blog.sina.com.cn/s/blog_53923f940100g6as.html


发现C3P0在大并发下表现的性能不佳。

 

  • 线程池使用不当引起

 

 

以上代码的场景是每一次并发请求过来,都会创建一个线程,将DUMP日志导出进行分析发现,项目中启动了一万多个线程,而且每个线程都极为忙碌,彻底将资源耗尽。

 

那么问题到底在哪里呢???就在这一行!

 

 

在并发的情况下,无限制的申请线程资源造成性能严重下降,在图表中显抛物线形状的元凶就是它!!!那么采用这种方式最大可以产生多少个线程呢??答案是:Integer的最大值!看如下源码:

 


那么尝试修改成如下代码:

 

 

修改完成以后,并发量重新上升到100以上TPS,但是当并发量非常大的时候,项目GC(垃圾回收能力下降),分析原因还是因为Executors.newFixedThreadPool(50)这一行,虽然解决了产生无限线程的问题,但是当并发量非常大的时候,采用newFixedThreadPool这种方式,会造成大量对象堆积到队列中无法及时消费,看源码如下:

 

 

可以看到采用的是无界队列,也就是说队列是可以无限的存放可执行的线程,造成大量对象无法释放和回收。

 

  • 最终线程池技术方案
     

方案一:

 


注:因为服务器的CPU只有4核,有的服务器甚至只有2核,所以在应用程序中大量使用线程的话,反而会造成性能影响,针对这样的问题,我们将所有异步任务全部拆出应用项目,以任务的方式发送到专门的任务处理器处理,处理完成回调应用程序器。后端定时任务会定时扫描任务表,定时将超时未处理的异步任务再次发送到任务处理器进行处理。

 

方案二:


使用AKKA技术框架,下面是我以前写的一个简单的压测情况:
http://www.jianshu.com/p/6d62256e3327

 

4、日志打印问题


先看下面这段日志打印程序:

 

 

像这样的代码是严格不符合规范的,虽然每个公司都有自己的打印要求。

 

首先日志的打印必须是以logger.error或者logger.warn的方式打印出来。

 

日志打印格式:[系统来源] 错误描述 [关键信息],日志信息要能打印出能看懂的信息,有前因和后果。甚至有些方法的入参和出参也要考虑打印出来。

 

在输入错误信息的时候,Exception不要以e.getMessage的方式打印出来。

 

合理的日志格式是:

 

 

我们在程序中大量的打印日志,虽然能够打印很多有用信息帮助我们排查问题,但是更多是日志量太多不仅影响磁盘IO,更多会造成线程阻塞对程序的性能造成较大影响。


在使用Log4j1.2.14版本的时候,使用如下格式:

 

 

那么在压测的时候会出现下面大量的线程阻塞,如下图:

 

 

再看压测图如下:

 

 

 

原因可以根据log4j源码分析如下:

 


注:Log4j源码里用了synchronized锁,然后又通过打印堆栈来获取行号,在高并发下可能就会出现上面的情况。

 

于是修改log4j配置文件为:

 

 

上面问题解决,线程阻塞的情况很少出现,极大的提高了程序的并发能力,如下图所示:

 

 

5、缓存优化方案

 

我们在用缓存的时候,不管是Redis或者Memcached,基本上会通用遇到以下三个问题:

  • 缓存穿透

  • 缓存并发

  • 缓存失效
     

(1)
 
缓存穿透

 

 

 

 

注:上面三个图会有什么问题呢?

 

我们在项目中使用缓存通常都是先检查缓存中是否存在,如果存在直接返回缓存内容,如果不存在就直接查询数据库然后再缓存查询结果返回。这个时候如果我们查询的某一个数据在缓存中一直不存在,就会造成每一次请求都查询DB,这样缓存就失去了意义,在流量大时,可能DB就挂掉了。

 

那这种问题有什么好办法解决呢?

 

要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。
有一个比较巧妙的作法是,可以将这个不存在的key预先设定一个值。

 

比如,"key" , “&&”。


在返回这个&&值的时候,我们的应用就可以认为这是不存在的key,那我们的应用就可以决定是否继续等待继续访问,还是放弃掉这次操作。如果继续等待访问,过一个时间轮询点后,再次请求这个key,如果取到的值不再是&&,则可以认为这时候key有值了,从而避免了透传到数据库,从而把大量的类似请求挡在了缓存之中。

 

(2)
 
缓存并发

 

有时候如果网站并发访问高,一个缓存如果失效,可能出现多个进程同时查询DB,同时设置缓存的情况,如果并发确实很大,这也可能造成DB压力过大,还有缓存频繁更新的问题。

 

我现在的想法是对缓存查询加锁,如果KEY不存在,就加锁,然后查DB入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入DB查询。

 

这种情况和刚才说的预先设定值问题有些类似,只不过利用锁的方式,会造成部分请求等待。

 

(3)
 
缓存失效

 

引起这个问题的主要原因还是高并发的时候,平时我们设定一个缓存的过期时间时,可能有一些会设置1分钟啊,5分钟这些,并发很高时可能会出在某一个时间同时生成了很多的缓存,并且过期时间都一样,这个时候就可能引发一当过期时间到后,这些缓存同时失效,请求全部转发到DB,DB可能会压力过重。

 

那如何解决这些问题呢?


其中的一个简单方案就时讲缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

 

我们讨论的第二个问题时针对同一个缓存,第三个问题时针对很多缓存。

 

总结来看:

  1. 缓存穿透:查询一个必然不存在的数据。比如文章表,查询一个不存在的id,每次都会访问DB,如果有人恶意破坏,很可能直接对DB造成影响。

  2. 缓存失效:如果缓存集中在一段时间内失效,DB的压力凸显。这个没有完美解决办法,但可以分析用户行为,尽量让失效时间点均匀分布。

 

当发生大量的缓存穿透,例如对某个失效的缓存的大并发访问就造成了缓存雪崩。

 

6、程序容错优化方案
 

在这一块我要先举一个程序的例子说明一下什么才是容错,先看程序:

 

 

注:那么如果service层的方法调用dao层的方法,一旦数据插入失败,那么这种异常处理的方式是容错吗?


把异常给吃掉了,在service层调用的时候,虽然没有打印报错信息,但是这能是容错吗?


所谓容错是指在故障存在的情况下计算机系统不失效,仍然能够正常工作的特性。

 

我们拿使用缓存来作为一个案例讲解,先看一个图:

 


这是一个最简单的图,应用服务定期从redis中获取配置信息,可能会有朋友认为这样已经很稳定了,但是如果Redis出现问题呢?可能会有朋友说,Redis会是集群,分片或者主从,确保不会出现问题。

 

其实我是这样认为的,虽然应用服务程序尽量的保持轻量级是不错的,但是不能因此而把希望全部寄托在中间组件上面,换句话说,如果此时的Redis是单点,那么后果会是什么样的,那么随着大量的并发请求到来的时候,程序中会报大量的错误,同时正常的流程也不能进行下去了业务也可能由此而中断。

 

那么在此种场景下我的解决方案是,要把缓存的使用分级别,有的缓存同步要求时效性非常高,比如支付限额配置,在后台修改完成以后前台立刻就能够获得感知,并且能够成功切换,这种情况只能实时的从Redis中获取最新数据,但是每次获取完最新的数据后都可以同步更新本地缓存,当单点的Redis挂掉后,应用程序至少还能从本地读取信息而不至于服务瞬间挂掉。有的缓存对时效性要求不高,允许有一定延迟,那么在这种情况下我采用的方案是,利用本地缓存和远程缓存相结合的方式,如下图所示:
 

方案一:

 


这种方式通过应用服务器的Ehcache定时轮询Redis缓存服务器更同步更新本地缓存,缺点是因为每台服务器定时Ehcache的时间不一样,那么不同服务器刷新最新缓存的时间也不一样,会产生数据不一致问题,对一致性要求不高可以使用。

 

方案二:

 


通过引入了MQ队列,使每台应用服务器的Ehcache同步侦听MQ消息,这样在一定程度上可以达到准同步更新数据,通过MQ推送或者拉取的方式,但是因为不同服务器之间的网络速度的原因,所以也不能完全达到强一致性。基于此原理使用Zookeeper等分布式协调通知组件也是如此。

 

7、部分项目拆分不彻底

 

拆分前

 


注:一个Tomcat中布署多个应用war包,彼此之间互相牵制在并发量非常大的情况下性能降低非常明显。

 

拆分后


注:拆分前的这种情况其实还是挺普遍,之前我一直认为项目中不会存在这种情况但是事实上还是存在了。解决的方法很简单,每一个应用war只布在一个tomcat中,这样应用程序之间就不会存在资源和连接数的竞争情况,性能和并发能力提交较为明显。

 

8、因基础平台组件功能不完善导致性能下降


先看一段代码:

 

 

注:首先我们先不说这段代码的格式如何如何,先看功能实现,使用Future来做超时控制,这是为何呢?原因其实是在我们调用的Dubbo接口上面,因为是Dubbo已经经过二次封装,结果把自带的timeout给淹沫了,程序员只能通过这种方式来控制超时,可以看到这种用法非常差劲,对程序性能造成一定的影响。

 

9、如何快速定位程序性能瓶颈

 

我相信在定位程序性能问题的时候,大家有很多种办法,比如用jdk自带的命令,如Jcmd,Jstack,jmap,jhat,jstat,iostat,vmstat等等命令,还可以用VisualVM,MAT,JRockit等可视化工具,我今天想说的是利用一个最简单的命令就能够定位到哪段程序可能存在性能问题,请看下面介绍:

 

一般我们会通过top命令查看各个进程的cpu和内存占用情况,获得到了我们的进程id,然后我们将会通过pstack命令查看里边的各个线程id以及对应的线程现在正在做什么事情,分析多组数据就可以获得哪些线程里有慢操作影响了服务器的性能,从而得到解决方案。示例如下:

 

 

由此可以判断出来在LWP 30222这个线程产生了性能问题,执行时间长达31.4毫秒的时间,再观察无非就是下面的几个语句出现的问题,只需要简单排查就知道了问题瓶颈。

 

 

10、关于索引的优化

 

  • 组合索引的原则是偏左原则,所以在使用的时候需要多加注意

 

  • 索引的数量不需要过多的添加,在添加的时候要考虑聚集索引和辅助索引,这二者的性能是有区别的

 

  • 索引不会包含有NULL值的列
    只要列中包含有NULL值都将不会被包含在索引中,复合索引中只要有一列含有NULL值,那么这一列对于此复合索引就是无效的。所以我们在数据库设计时不要让字段的默认值为NULL。

 

  • MySQL索引排序
    MySQL查询只使用一个索引,因此如果where子句中已经使用了索引的话,那么order by中的列是不会使用索引的。因此数据库默认排序可以符合要求的情况下不要使用排序操作;尽量不要包含多个列的排序,如果需要最好给这些列创建复合索引。

 

  • 使用索引的注意事项
    以下操作符可以应用索引:
    大于等于
    Between
    IN
    LIKE 不以%开头

     

    以下操作符不能应用索引:

    NOT IN

    LIKE %_开头

 

  • 索引技巧
    同样是1234567890,数值类型存储远比字符串节约存储空间。
    节约存储就是节约IO,减少IO就是提升性能
    通常对数字的索引和检索要比对字符串的索引和检索效率更高。

 

11、使用Redis需要注意的一些点

 

  • 在增加key的时候尽量设置过期时间,不然Redis Server的内存使用会达到系统物理内存的最大值,导致Redis使用VM降低系统性能

 

  • Redis Key设计时应该尽可能短,Value尽量不要使用复杂对象。

 

  • 将对象转换成JSON对象(利用现成的JSON库)后存入Redis。

 

  • 将对象转换成Google开源二进制协议对象(Google Protobuf,和JSON数据格式类似,但是因为是二进制表现,所以性能效率以及空间占用都比JSON要小;缺点是Protobuf的学习曲线比JSON大得多)

 

  • Redis使用完以后一定要释放连接,如下图示例:

 


不管是返回到连接池中还是直接释放掉,总之就是要将连接还回去。

 

在接下来的第二篇文章中我们将介绍系统的降级、限流,还有监控的一些方案。敬请期待!

 

 
本文经作者同意,授权发布。

 

作者介绍  程超

  • 易宝支付架构师,10年JAVA工作经验,擅长分布式和大数据技术领域,目前主要从事金融支付类方向。

 

 
 
近期热文(点击标题可阅读全文)

近期活动:

DAMS第二届中国数据资产管理峰会

峰会官网:www.dams.org.cn

最新评论
访客 2024年04月08日

如果字段的最大可能长度超过255字节,那么长度值可能…

访客 2024年03月04日

只能说作者太用心了,优秀

访客 2024年02月23日

感谢详解

访客 2024年02月20日

一般干个7-8年(即30岁左右),能做到年入40w-50w;有…

访客 2023年08月20日

230721

活动预告