电商邮件服务平台性能优化谈

从今年一月份开始,团队陆续完成了邮件服务的架构升级。新平台上线运行的过程当中也发生了一系列的性能问题,即便不少看起来微不足道的点也会让整个系统运行得不是那么平稳,今天就将这段时间的问题以及解决方案统一整理下,但愿能起到抛砖的做用,让读者在遇到相似问题的时候能多一个解决方案。sql

新平台上线后初版架构以下:数据库

电商邮件服务平台性能优化谈

这版架构上线后,咱们遇到的第一个问题:数据库读写压力过大后影响总体服务稳定。缓存

表现为:安全

一、数据库主库压力高,同时伴有大量的读,写操做。性能优化

二、远程服务接口性能不稳定,业务繁忙时数据库的插入操做延迟升高,接口响应变慢,接口监控频繁报警,影响业务方。服务器

通过分析后,咱们作了以下优化:多线程

一、数据库作读写分离,将Checker的扫表操做放到从库上去(主从库中的同步延迟不影响咱们发送,此次扫描不到的下次扫表到便可,由于每条邮件任务上有版本号控制,因此也不担忧会扫描到“旧记录”的问题)。架构

二、将Push到Redis的操做变成批量+异步的方式,减小接口同步执行逻辑中的操做,主库只作最简单的单条数据的Insert和Update,提升数据库的吞吐量,尽可能避免由于大量的读库请求引发数据库的性能波动。并发

这么作还有一个缘由是通过测试,对于Redis的lpush命令来讲每次Push1K大小的元素和每次Push20K的元素耗时没有明显增长。异步

所以,咱们使用了EventDrieven模型将Push操做改为了定时+批量+异步的方式往Redis Push邮件任务,这版优化上线后数据库主库CPU利用率基本在5%如下。

总结:此次优化的经验能够总结为:用异步缩短住业务流程 +用批量提升执行效率+数据库读写分离分散读写压力。

优化后的架构图:

电商邮件服务平台性能优化谈

优化上线后,咱们又遇到了第二个问题:JVM假死。

表现为:

一、单位时间内JVM Full GC次数明显升高,GC后内存居高不下,每次GC能回收的内存很是有限。

二、接口性能降低,处理延迟升高到几十秒。

三、应用基本不处理业务。

四、JVM进程还在,能响应jmap,jstack等命令。

五、jstack命令看到绝大多数线程处于block状态。

电商邮件服务平台性能优化谈

电商邮件服务平台性能优化谈

堆信息大体以下(注意红色标注的点):

如上两图,能够看到RecommendGoodsService 类占用了60%以上的内存空间,持有了34W个 “邮件任务对象”,很是可疑。

分析后发现制造平台在生成“邮件任务对象”后使用了异步队列的方式处理对象中的推荐商品业务,由于某个低级的BUG致使处理队列的线程数只有5个,远低于预期数量, 所以队列长度剧增致使的堆内存不够用,触发JVM的频繁GC,致使整个JVM大量时间停留在”stop the world ” 状态,JVM响应变得很是慢,最终表现为JVM假死,接口处理延迟剧增。

总结:

一、咱们要尽可能让代码对GC友好,绝大部分时候让GC线程“短,平,快”的运行并减小Full GC的触发机率。

二、咱们线上的容器都是多实例部署的,部署前一般也会考虑吞吐量问题,因此JVM直接挂掉一两台并不可怕,对于业务的影响也有限,但JVM的假死则是很是影响系统稳定性的,与其奈活,不如快死!

相信不少团队在使用线程池异步处理的时候都是使用的无界队列存放Runnable任务的,此时必定要很是当心,无界意味着一旦生产线程快于消费线程,队列将快速变长,这会带来两个很是很差的问题:

一、从线程池到无界队列到无界队列中的元素全是强引用,GC没法释放。

二、队列中的元素由于等不到消费线程处理,会在Young GC几回后被移到年老代,年老代的回收则是靠Full GC才能回收,回收成本很是高。

通过一段时间的运行,咱们将JVM内存从2G调到了3G,因而咱们又遇到了第三个问题:内存变大的烦恼。

JVM内存调大后,咱们的JVM的GC次数减小了很是多,运行一段时间后加上了不少新功能,为了提升处理效率和减小业务之间的耦合,咱们作了不少异步化的处理。更多的异步化意味着更多的线程和队列,如上述经验,不少元素被移到了年老代去,内存越用越小,若是正好在业务量不是特别大时,整个堆会呈现一个“稳步上升”的态势,下一步就是内存阀值的持续报警了。

电商邮件服务平台性能优化谈

因此,无界队列的使用是须要很是当心的。

咱们把邮件服务分为生产邮件和促销邮件两部分,代码90%是复用的,但独立部署,独立的数据库,促销邮件上线后,咱们又遇到了老问题:数据库主库压力再次CPU100%

在通过生产邮件3个月的运行及优化后,咱们对代码作了少量的改动用于支持促销邮件的发送,促销的业务能够归纳为:瞬间大量数据写入,Checker每次须要扫描上百万的数据,整个系统须要在大量待发送数据中维持一个较稳定的发送速率。上线后,数据库又再次报出异常:

一、主库的写有大量的死锁异常(原来的生产邮件就有,不过再促销邮件的业务形态中影响更明显)。

二、从库有大量的全表扫描,读压力很是高。

死锁的问题,缘由是这样的:

电商邮件服务平台性能优化谈

条件1:若是有Transaction1须要对ABC记录加锁,已经对A,B记录加了X锁,此刻在尝试对C记录枷锁。

条件2:若是此前Transaction2已经对C记录加了独占锁,此刻须要对B记录加X锁。

就会产生dead lock。实际状况是:若是两条update语句同一时刻既须要扫描ABC又须要扫描DCB,那么死锁就出现了。

尽管Mysql作了优化,好比增长超时时间:innodb_lock_wait_timeout,超时后会自动释放,释放的结果是Transaction1和Transaction2所有Rollback(死锁问题并无解决,若是不幸,下次执行还会重现)。再若是每一个Transaction都是update数万,数十万的记录(咱们的业务就是),那事务的回滚代价就很是高了。

解决办法不少,好比先select出来再作逐条作update,或者update加上一个limit限制每次的更新次数,同时避免两个Transaction并发执行等等。咱们选择了第一种,由于咱们的业务对于时间上要求并不高,能够“慢慢作”。

全表扫表的问题发生在Checker上,咱们封装了不少操做邮件任务的逻辑在不一样的Checker中,好比:过时Checker,重置Checker,Redis Push Checker等等。他们负责将邮件任务更新为业务须要的各类状态,大部分时候他们是并行执行的,会产生不少select请求。严重时,读库压力基本维持在95%上长达数小时。

全表扫描99%的缘由是由于select没有使用索引,因此每每开发同窗的第一反应是加索引,而后让数据库“死扛”读压力 ,但索引是有成本的,占用硬盘空间不说,insert/delete操做都须要维护索引,其实咱们还有另外好几种方案能够选择,好比:是否是须要这么频繁的执行select? 是否是每次都要select这么多数据?是否是须要同一时间并发执行?

咱们的解决办法是:合理利用索引+下降扫描频率+扫描适量记录。

首先,将Checker里的SQL统一化,每一个Checker产生的SQL只有条件不一样,使用的字段基本同样,这样能够很好的使用索引。

其次,咱们发现发送端的消费能力是整个邮件发送流程的制约点,消费能力决定了某个时间内须要多少邮件发送任务,Checker扫描的量只要刚刚够发送端满负荷发送就能够了,

所以,Checker再也不每一个几分钟扫表一次,只在队列长度低于某个下限值时才扫描,

而且一次扫描到队列的上限值,多一个都不扫。

通过以上优化后,促销的库也没有再报警了。

直到两周之前,咱们又遇到了一个新问题:发送节点CPU100%.

这个问题的表象为:CPU正常执行业务时保持在80%以上,高峰时超过95%数小时。监控图标以下:

电商邮件服务平台性能优化谈

在说这个问题前,先看下发送节点的线程模型:

电商邮件服务平台性能优化谈

Redis中根据目标邮箱的域名有一到多个Redis队列,每一个发送节点有一个跟目标邮箱相对应的FetchThread用于从Redis Pull邮件发送任务到发送节点本地,而后经过一个BlockingQueue将任务传递给DeliveryThread,DeliveryThread链接具体邮件服务商的服务器发送邮件。考虑到每次链接邮件服务商的服务器是一个相对耗时的过程,所以同一个域名的DeliveryThread有多个,是多线程并发执行的。

既然表象是CPU100%,根据这个线程模型,第一步怀疑是否是线程数太多,同一时间并发致使的。查看配置后发现线程数只有几百个,同时一时间执行的只有十多个,是相对合理的,不该该是引发CPU100%的根因。

可是在检查代码时发现有这么一个业务场景:

一、因为JIMDB的封装,发送平台采用的是轮询的方式从Redis队列中Pull邮件发送任务,Redis队列为空时FetchThread会sleep一段时间,而后再检查。

二、从业务上说网易+腾讯的邮件占到了整个邮件总量的70%以上,对非前者的FetchThread来讲,Pull不到概率很是高。

那就意味着发送节点上的不少FetchThread执行的是没必要要的唤醒->检查->sleep的流程,白白的浪费CPU资源。

因而咱们利用事件驱动的思想将模型稍稍改变一下:

电商邮件服务平台性能优化谈

每次FetchThread对应的Redis队列为空时,将该线程阻塞到Checker上,由Checker统一对多个Redis队列的Pull条件作判断,符合Pull条件后再唤醒FetchThread。

Pull条件为:

1.FetchThread的本地队列长度小于初始长度的一半。

2.Redis队列不为空。

同时知足以上两个条件,视为能够唤醒对应的FetchThread。

以上的改造本质上仍是在下降线程上下文切换的次数,将简单工做归一化,并将多路并发改成阻塞+事件驱动和下降拉取频率,进一步减小线程占用CPU时间片的机会。

上线后,发送节点的CPU占用率有了20%左右的降低,可是并无直接将CPU的利用率优化为很是理想的状况(20%如下),咱们怀疑并无找到真正的缘由。

电商邮件服务平台性能优化谈

因而咱们接着对邮件发送流程作了进一步的梳理,发现了一个很是奇怪的地方,代码以下:

电商邮件服务平台性能优化谈

咱们在发送节点上使用了Handlebars作邮件内容的渲染,在初始化时使用了Concurrent相关的Map作模板的缓存,可是每次渲染前却要从新new一个HandlebarUtil,那每一个HandlebarUtil岂不是用的都是不一样的TemplateCache对象?既然如此,为何要用Concurrent(意味着线程安全)的Map?

进一步阅读源码后发现不管是Velocity仍是Handlebars在渲染先都须要对模板作语法解析,构建抽象语法树(AST),直至生成Template对象。构建的整个过程是相对消耗计算资源的,所以猜测Velocity或者Handlebars会对Template作缓存,只对同一个模板解析一次。

为了验证猜测,能够把渲染的过程单独运行下:

电商邮件服务平台性能优化谈

能够看到Handlebars的确能够对Template作了缓存,而且每次渲染前会优先去缓存中查找Template。而除了一样执行5次,耗时开销特别大之外,CPU的开销也一样特别大,上图为使用了缓存CPU利用率,下图为没有使用到缓存的CPU利用率:

电商邮件服务平台性能优化谈

找到了缘由,修改就比较简单了保证handlebars对象是单例的,可以尽可能使用缓存便可。

上线后结果以下:

电商邮件服务平台性能优化谈

至此,整个性能优化工做已经基本完成了,从每一个案例的优化方案来看,有如下几点经验想和你们分享:

一、性能优化首先应该定位到真正缘由,从缘由下手去想方案。

二、方案应该贴合业务自己,从客观规律、业务规则的角度去分析问题每每更容易找到突破点。

三、一个细小的问题在业务量巨大的时候甚至可能压垮服务的根因,开发过程当中要注意每一个细节点的处理。

四、平时多积累相关工具的使用经验,遇到问题时能结合多个工具定位问题。

相关文章
相关标签/搜索