[京东技术]声明:本文转载自微信公众号“开涛的博客”,转载务必声明。
作者:刘锟洋,独立博主,系统架构师。就职于京东成都研究院,做过A/B Test,精准营销平台,会员营销平台。公司内部开源小组发起人,内部开源过多个开源项目,对性能全链路优化和分布式服平台有浓厚兴趣。笔者博客:http://www.liuinsect.com/。
EDM简介
EDM是Email Direct Marketing的缩写,即邮件营销。是利用电子邮件(Email)与受众客户进行商业交流的一种直销方式,邮件营销的对于企业的价值主要体现在三个方面:开拓新客户、维护老客户,以及品牌建设。
在互联网领域,大部分企业都有类似业务。总体来说,有欧美背景的公司往往比较重视EDM,所以EDM的效果也做得很不错,而国内就相对做的少一点。
我们的EDM平台应该是其中规模相对大一点的,每日发送量千万级别,峰值数据2T(每日),如何能在峰值到来时保证系统的稳定,以及从大量的邮件发送任务中筛选出高优先级发送任务并及时发送是构建这个平台的难点之一。
老平台日益突出的问题
原有的老平台已经是5年前的产物了,受限于当时研发人员对EDM的研发经验和当时的技术栈,老平台的几个问题在日后的发展中越来越突出,慢慢的让新架构变成了一件不可不做的事情。
老平台存在的问题如下:
-
技术栈、实现思路混乱:
-
受限于业务,老平台分成了生产邮件和促销邮件两个子平台。对于邮件任务的发送,老促销邮件平台引入了Thrift框架,而老生产邮件平台则通过抢占式更新数据库的方式实现。
-
渲染邮件模板时,老生产使用的是velocity,而老促销已经改为由上层(邮件模板装修系统)用handlebars提前渲染好后直接发送。
-
-
SQL Server的改造需求:
- 老平台的数据库使用的是SQL Server,从降低公司成本的角度出发,有尽快迁移到MySQL的需求。
-
历史需求包袱很重:
- 很多业务已经下线,但是代码还留在线上(包括各种关联系统)。
新平台方案
在这样的情况下,我们决定抛弃老的平台,构建新一代的EDM平台。那其中第一步就是确认业务架构。经过多次梳理和方案推导后,整个EDM平台的业务架构如下:
-
新平台将由生产平台、发送平台和统一管理平台组成:生产平台负责生产邮件发送任务,发送平台则部署在主流运营商的网络上,冗余部署一定量的发送节点,保证发送成功率。因为邮箱服务提供商通常会对邮件的发送方按IP做流控限制,并且对各种网络运营商投递的邮件的接受程度也不一样。同时,为了保证在主流的网络运营商渠道上都有发送节点,避免因某个网段或某个运营商网络故障引起邮件发送成功率的波动。所以将发送和生产的逻辑区分开,有利于应用区分部署和扩容,平台间的职责也更加清晰。
-
生产平台和发送平台间使用Redis队列传递邮件任务。设计期间考虑过MQ,kafka类组件,没有使用的原因是以上组件对于传递邮件的任务都“过重”,引入新的组件意味着新的风险,新组件的稳定性也会影响平台的稳定性。同时Redis对队列有原生的支持,作为当前最常使用的组件,其简单易用的特点正好符合新架构对这个组件的要求。再加上公司已经对Redis实现了集群及自动伸缩方案,可用率大大提高,学习零成本,都是Redis的优势,也是这次架构升级选用Redis的理由。
-
建立统一管理平台,实现对生成和发送情况的统一调度。要求是实现对整个生产和发送平台的调度管理,包含指定高优节点,屏蔽成功率较低节点,降级开关等一系列措施。
总结
-
生产平台对接用户组,订单组,会员等系统的MQ消息或远程调用生成邮件发送任务,并按类型划分1-10个优先级,数字越大,优先级越高;
-
发送平台聚焦在发送业务的处理上,包括优先级队列、发送降频、邮件模板渲染等事务;
-
管理平台主要负责配置数据的维护、降级开关的推送和必要时人工对生产、发送平台的调度;
-
Redis按优先级和目标邮箱两个维度拆分成多个Redis队列,负责将邮件任务传递到发送平台。
注:老平台也是生产和发送分成两个平台,只不过职责没有划分得非常清晰,比如各自的平台上都集成了管理功能,发送节点只能调节自己的发送策略,不利于发送策略的批量调整。
方案细节
Redis优先级队列
首先,业务上决定了邮件任务的紧急程度是不一样的,例如用户对账户密码找回的邮件就比优惠券到账的及时性敏感度更高,显然密码找回的邮件需要以最快速度投递到用户邮箱中。这和某些在极端场景才出现的“优先级”不一样,是一直持续存在的高优任务,最简单的办法就是区别对待,按优先级设置队列,从生产平台开始到Redis队列再到发送平台都一直是一个或多个特殊的队列,方便系统对这些高优发送任务做处理。
高优先级队列长度的报警阀值比较小,一旦积压研发同学会第一时间收到报警,必要时可以人工接入。而发送平台总是最先拉取高优先级发送任务,保证其第一时间被处理。值得注意的是,从客观规律上看高优的邮件往往量是比较小,这使得发送平台总是优先处理高优的邮件并不会让优先级低的发送任务没有机会被拉取。
当业务量较大,发送较为频繁时触发邮件服务商的流控,或网络不稳定,出口IP异常时都会引起部分发送平台的邮件投递成功率的下降,这时需要让成功率将低的节点暂时甚至永久的不再向邮件服务商投递邮件,解决方案之一是在队列的拆分维度除了优先级以外再增加一个目标邮箱,一旦出现上述问题后,可以直接让发送节点不再拉取该邮箱的所有队列来实现故障隔离。
用优先级和目标邮箱拆分Redis队列还有一个好处是,如果使用的是分布式的Redis,队列的元素总是在一个分片中的,如果队列过少,会导致有可能大量元素都集中在同一个分片中形成热点分片。将Redis队列拆分后可以让分个分片的读写相对更均衡,分片的利用率更高。实际上,Redis队列还设置了一个最大长度,防止队列无限制的增长。
投递降频
投递邮件时如果投递被拒绝邮件服务提供商一般都会返回一个错误码,发送平台上有一个错误映射表,发送错误后将错误码和错误映射表比较,如果触发了流控则降低邮件发送任务的拉取频率,直到投递成功率恢复后再逐步提升发送能力。
Checker
Checker是生产平台扫描邮件发送任务的定时任务的总称,按职责不同,Checker被具体分为UnsuccessChecker、InQueueChecker、ExpireChecker等等。职责是将各个状态的邮件发送任务更新为下一个流程需要的状态,比如将入库成功的状态更新为Redis队列中状态,Redis队列中状态更新为发送平台发送中状态,发送错误的任务状态更新为重新投递状态等等。
重新投递是非常重要的一个功能,因为某个出口IP因为各种原因可能会常常被邮件服务商拒收,重试相当于有较大几率更换出口IP再做发送尝试,有利于投递成功率的提高。
邮件发送任务常常被各种Checker更新,为了保证数据的一致性和状态按正确流程流转,邮件发送任务被加上了版本号,每次更新后自增,更新时使用乐观锁更新。
性能优化
初期平台上线时的第一版架构如下:
上线后出现了一系列的性能问题,总结起来主要为两类:
- 写库CPU 100%,影响远程调用接口的性能,引发上游团队关注;
- 代码编写不当,引发JVM假死和CPU 100%。
写库CPU 100%
1. 数据库同一时间读写请求太多和索引利用率不高导致的
因此第一步想将数据库的读写压力分开,对数据库做了读写分离,所有的读请求全部调整到了从库上去,以此降低主库压力。这里有一个前提是经过评估,从库读取脏数据并不会对业务产生困扰,因为邮件发送任务本身有版本号,即使数据库主从同步有延迟引起从库读到“脏数据”使用乐观锁更新时也会失败,不会引起业务错误。
第二步将查询条件归一再后建立索引,索引不是越多越好,归一时可以现将查询任务列出来,观察哪些查询条件是相似的,有没有特殊的业务导致了不一样的查询条件,这些业务有没有办法从其他角度去支持,逐步归纳后再建索引。上述步骤多次使用后,通常情况必须要建立的索引就不会太多了。目前邮件发送任务表(分表后单表千万级)只有一个主键索引和一个组合索引(三个字段),所有查询条件全部先利用索引查询数据。
调整完索引后发现Checker的扫描还是过于频繁,读库CPU利用率还是不够理想(过高),梳理业务后发现整个平台的发送能力取决于邮件服务商的接受量和一定程度的发送平台量(出口IP量)。两者不变的情况下,整个发送平台的发送量不会提升,Redis队列的吞吐能力也不变,Checker大部分时候运行的结果只是Push了个别元素,甚至没有Push。Checker完全可以改成Redis队列小于一定阀值,例如最大长度的1/2再做一次扫描,一次扫描尽量将队列填满。调整Checker策略和索引后数据库的QPS大约降了2/3,load也稳定在5以下。
2. Checker引起数据库死锁
例如有Transaction1需要对ABC记录加锁,已经对A,B记录加了X锁,此刻正尝试对C记录加锁。同时此前Transaction2已经对C记录加了独占锁,此刻需要对B记录加X锁,就会产生数据库的死锁。
尽管MySQL做了优化,比如增加超时时间:innodb_lock_wait_timeout,超时后会自动释放,释放的结果是Transaction1和Transaction2全部Rollback(死锁问题并没有解决,如果不幸,下次执行还会重现)。
如果每个Transaction都是Update数万、数十万的记录(我们的业务就是),那事务的回滚代价就非常高,还会引起数据库的性能波动。
解决办法很多,比如先查询出数据后再做逐条做写操作,或者写操作加上一个limit限制每次的更新次数,同时避免两个Transaction并发执行等等。
最终在调整了Checker的运行周期后选择了逐条更新的方案,因为业务对于时间上要求并不高,更新不及时并不会引起业务上的错误。
经过以上优化后,压测表明整个平台3倍峰值流量下,数据库CPU利用率10%以下,load5以下,95%的远程调用只有一次数据库的insert操作,远程接口TP999在20ms内。
JVM假死和CPU 100%
出于减少远程接口同步逻辑的需要,研发同学将大部分操作改为异步方式,比如邮件的推荐商品服务。
因为邮件发送任务在生产平台到发送平台间流转需要一定的时间,将推荐商品服务异步化后,生产邮件发送任务的同步逻辑会减少,远程接口调用或MQ消息消费线程可以更早返回,对推荐接口的性能波动容忍度也会变高,只需要保证在发送平台渲染邮件模板前能够拿到推荐商品的数据即可。
异步改造时,研发同学使用了线程池的无界队列,并因为一个低级BUG导致上线后无界队列的消费线程只有5个,生产和消费的速率严重不匹配,导致了短时间内JVM内存占用过高,JVM频繁GC,JVM频繁处于“stop the world”阶段,呈现出“假死”状态,最终再次影响到远程接口的调用和MQ消息的消费。
这次的经验说明,实例宕机或许并不是最难处理的,更难处理的是实例处于可以提供服务,但是没有服务能力的状态。
1. 使用有界队列
使用有界队列,防止超长队列的产生。设置队列的拒绝策略,队列无空闲位置时,放弃入队操作。此时会导致部分邮件缺少推荐商品模块,可视作推荐商品模块的处理能力达到上限后的一种降级方式。
2. Redis元素不能太大
Redis队列中元素的大小大于10K时,入队和出队的效率会严重下降,出于这个原因,Redis队列中只存放有邮件发送任务的原始数据。
3. 缓存模板引擎解析结果
渲染工作是在发送节点上完成的。发送高峰期时,发送平台的CPU利用率整体在80%甚至90%以上,发送能力无法再提升。经过一系列排查后发现CPU利用率较高的源头来自于Handlebars模板渲染模块。
抽样查看部分线上机器的线程占用率时发现渲染线程大部分时间一直在做邮件模板的语法解析,参考相应文档后发现语法解析是模板渲染中最耗时的流程,为了提高效率无论是Velocity还是Handlbars都会对模板语法解析的结果做缓存,下次渲染时直接使用解析结果渲染。
缓存是基于VelocityEngine或Handlebar实例的,如果JVM中存在多个VelocityEngine或Handlebar实例,缓存就无法有效利用,结果是每次渲染模板时都要做语法解析,如果并发解析的线程达到数十、数百个的话,就会引起实例的CPU 100%。
因此:
- 保证全局只有一个Handlebar实例,方便共享缓存结果;
- 容器启动时,渲染线程依次启动并等待一段时间后再启动下一个渲染线程,避免并发启动多个线程时出现并发解析模板的情况。
除了以上问题的解决,上线后研发团队还做了几次全流程的优化,优化包括黑名单、退订数据缓存化,Redis队列Push方式异步化、批量化,发送平台的拉取合并、邮件模板本地化等。
优化后平台的应用架构如下:
容灾方案
容灾方案中,优先考虑的就是多网络运营商覆盖的问题,防止某一网络运营商网络故障影响邮件发送的能力。目前的方案是单一机房配置单一网络运营商的出口IP及反解析域名;每个机房部署的生产平台、Redis队列和发送平台彼此之间相互独立运行,但底层使用同一个数据库,生产平台提供的远程接口为同一个别名服务,MQ消息也是消费的同一个Topic下的内容(比如两个机房,每个机房1/2的消费),多个Redis之间存在少量数据的同步,比如去重数据。整体架构如下:
我们将紧急情况分为内部接口、服务故障和外部服务故障。(内部接口故障因深入到业务细节,暂时略过,只举一个例子)
针对内部故障,比如:
- Redis集群故障。如果是单分片故障,Redis集群提供了主从分片,可以通过切换分片的方式解决。如果是集群整体故障,可以启用备用Redis集群,在这里不存在集群数据为空的问题,因为生产平台有Checker存在,如果切换集群,Checker可以感知到Redis队列数据量不够,会重新将待发送的邮件任务Push到Redis队列中。
针对外部故障,比如:
-
机房出口网络故障。可以停止故障机房的发送平台,因数据库共享,数据入库后对端机房的Checker会将数据重新Push到对端机房的Redis队列中,从对端机房发送邮件任务。这里还有一种方案是修改故障机房的Redis集群配置,故障机房的生产平台生成邮件发送任务后直接将数据Push到对端机房的Redis集群中,省略Checker扫描的这一步,会大大减少数据库的读压力。
-
邮件服务提供商对部分出口IP降频。发送节点上内置了降频处理措施,可以解决该问题。
-
邮件服务商屏蔽部分出口IP。通过自研的配置推送与服务监控框架,可用管理平台将被屏蔽的IP地址推送到发送平台上,发送平台通过比对如果发现自身已被屏蔽,将不再从Redis队列中Pull相应的邮件发送任务。
总结
经过半年多的架构升级与持续优化,我有几点经验可以和大家共享的:
-
深入识别清楚业务特性对架构至关重要。EDM平台上层对接了精准营销平台的促销邮件和生产邮件业务。除开业务上少量的差别外,促销邮件每日发送量较大,但对邮件的及时性要求不高,生产邮件每日发送量相对较小,但对邮件的及时性要求很高。原有的架构是把两者当成了不同的事情在处理,所以有两套不同的架构方式。但是总体来看,生产邮件大促期间发送量也很高,促销邮件也有高优先级任务需要优先发送,对优先级也有要求。无论是生产还是促销,本质上还是个邮件发送平台,新的架构可以同时按照有对高优邮件的优先支持和发送量剧增时平台吞吐量的稳定两个原则去设计。
-
采用简单有效的原则去设计架构。将邮件发送任务从生产平台传递到发送平台有很多种方式,甚至很多种框架可以选择,比如kafka、MQ、Thrift等,最终选择Redis主要原因还是Redis简单,lpush和rpop两个命令需要研发同学学习就可以完成该模板功能的开发。至于顺序消费、事务性消费业务上都没有要求,该模块的职责仅仅是能够将邮件发送任务传递到发送平台即可,同时兼容区分优先级的方法,有一定的分区容错能力即可。而Redis正好满足了这两个最强烈的要求。
-
坚定的围绕业务特性做设计。邮件发送的业务特性是:数据总是单向流动的。数据总是从生产平台到发送平台,重新投递时仍然是从生产平台传递到发送平台。因此checker的功能放在生产端以复用整个传递的逻辑,流控的策略可以加在生产端往Redis Push时和发送平台从Redis Pull时,容灾切换时考虑好了生产平台怎么切换流量,发送平台的切换方案也就随之确定等等,围绕业务特性,尊重客观规律可以节省很多不必要的考虑。
如何用一套通用的解决方案解决两个业务的不同需求是建设该平台的难点,需要在两种业务形态间找到共性并满足各自业务对及时性,发送量方面的要求。在做方案时需要更多的关注到架构本身对性能、容灾、业务和研发同学的友好性,架构越容易让人接受,更简单的解决现有问题,才有可能在以后的发展中不断往好的方向进化,容纳更多复杂的业务需求,支持业务的长久发展。
细节追踪
1、优化上线后,出现了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次数减少了非常多,运行一段时间后加上了很多新功能,为了提高处理效率和减少业务之间的耦合,我们做了很多异步化的处理。更多的异步化意味着更多的线程和队列,如上述经验,很多元素被移到了年老代去,内存越用越小,如果正好在业务量不是特别大时,整个堆会呈现一个“稳步上升”的态势,下一步就是内存阀值的持续报警了。
所以,无界队列的使用是需要非常小心的。
直到两周以前,我们又遇到了一个新问题:发送节点CPU 100%.
这个问题的表象为:CPU正常执行业务时保持在80%以上,高峰时超过95%数小时。监控图标如下:
在说这个问题前,先看下发送节点的线程模型:
Redis中根据目标邮箱的域名有一到多个Redis队列,每个发送节点有一个跟目标邮箱相对应的FetchThread用于从Redis Pull邮件发送任务到发送节点本地,然后通过一个BlockingQueue将任务传递给DeliveryThread,DeliveryThread连接具体邮件服务商的服务器发送邮件。考虑到每次连接邮件服务商的服务器是一个相对耗时的过程,因此同一个域名的DeliveryThread有多个,是多线程并发执行的。
既然表象是CPU 100%,根据这个线程模型,第一步怀疑是不是线程数太多,同一时间并发导致的。查看配置后发现线程数只有几百个,同时一时间执行的只有十多个,是相对合理的,不应该是引起CPU 100%的根因。
但是在检查代码时发现有这么一个业务场景:
-
由于JIMDB的封装,发送平台采用的是轮询的方式从Redis队列中Pull邮件发送任务,Redis队列为空时FetchThread会sleep一段时间,然后再检查;
-
从业务上说网易+腾讯的邮件占到了整个邮件总量的70%以上,对非前者的FetchThread来说,Pull不到几率非常高。
那就意味着发送节点上的很多FetchThread执行的是不必要的唤醒–>检查–>sleep的流程,白白的浪费CPU资源。
于是我们利用事件驱动的思想将模型稍稍改变一下:
每次FetchThread对应的Redis队列为空时,将该线程阻塞到Checker上,由Checker统一对多个Redis队列的Pull条件做判断,符合Pull条件后再唤醒FetchThread。
Pull条件为:
- FetchThread的本地队列长度小于初始长度的一半;
- Redis队列不为空。
同时满足以上两个条件,视为可以唤醒对应的FetchThread。
以上的改造本质上还是在降低线程上下文切换的次数,将简单工作归一化,并将多路并发改为阻塞+事件驱动和降低拉取频率,进一步减少线程占用CPU的时间片的机会。
上线后,发送节点的CPU占用率有了20%左右的下降,但是并没有直接将CPU的利用率优化为非常理想的情况(20%以下),我们怀疑并没有找到真正的原因。
于是我们接着对邮件发送流程做了进一步的梳理,发现了一个非常奇怪的地方,代码如下:
我们在发送节点上使用了Handlebars做邮件内容的渲染,在初始化时使用了Concurrent相关的Map做模板的缓存,但是每次渲染前却要重新new一个HandlebarUtil,那每个HandlebarUtil岂不是用的都是不同的TemplateCache对象?既然如此,为什么要用ConcurrentMap(意味着线程安全)?
进一步阅读源码后发现无论是Velocity还是Handlebars在渲染先都需要对模板做语法解析,构建抽象语法树(AST),直至生成Template对象。构建的整个过程是相对消耗计算资源的,因此猜想Velocity或者Handlebars会对Template做缓存,只对同一个模板解析一次。
为了验证猜想,可以把渲染的过程单独运行下:
可以看到Handlebars的确可以对Template做了缓存,并且每次渲染前会优先去缓存中查找Template。而除了同样执行5次,耗时开销特别大以外,CPU的开销也同样特别大,上图为使用了缓存CPU利用率,下图为没有使用到缓存的CPU利用率:
找到了原因,修改就比较简单了保证handlebars对象是单例的,能够尽量使用缓存即可。
上线后结果如下:
至此,整个性能优化工作已经基本完成了,从每个案例的优化方案来看,有以下几点经验想和大家分享:
- 性能优化首先应该定位到真正原因,从原因下手去想方案;
- 方案应该贴合业务本身,从客观规律、业务规则的角度去分析问题往往更容易找到突破点;
- 一个细小的问题在业务量巨大的时候甚至可能压垮服务的根因,开发过程中要注意每个细节点的处理;
- 平时多积累相关工具的使用经验,遇到问题时能结合多个工具定位问题。