日邮件发送量千万级,谈京东的EDM平台优化之路

jjxg6932 8年前
   <p>EDM 是 Email Direct Marketing 的缩写,即邮件营销。它是利用电子邮件(Email)与受众客户进行商业交流的一种直销方式,邮件营销的对于企业的价值主要体现在三个方面:开拓新客户、维护老客户,以及品牌建设。</p>    <p>京东的EDM平台每日发送量千万级别,峰值数据2T(每日),如何能在峰值到来时保证系统的稳定,以及从大量的邮件发送任务中筛选出高优先级发送任务并及时发送是构建这个平台的难点之一。</p>    <h2><strong>老平台日益突出的问题</strong></h2>    <p>原有的老平台已经是5年前的产物了,受限于当时研发人员对EDM的研发经验和当时的技术栈,老平台的几个问题在日后的发展中越来越突出,慢慢的让新架构变成了一件不可不做的事情。</p>    <p>老平台存在的问题如下:</p>    <p><strong>1、技术栈、实现思路混乱</strong></p>    <p>受限于业务,老平台分成了生产邮件和促销邮件两个子平台。对于邮件任务的发送,老促销邮件平台引入了Thrift框架,而老生产邮件平台则通过抢占式更新数据库的方式实现。</p>    <p>渲染邮件模板时,老生产使用的是velocity,而老促销已经改为由上层(邮件模板装修系统)用handlebars提前渲染好后直接发送。</p>    <p><strong>2、SQL Server的改造需求。</strong></p>    <p>老平台的数据库使用的是SQL Server,从降低公司成本的角度出发,有尽快迁移到MySQL的需求。</p>    <p><strong>3、历史需求包袱很重。</strong></p>    <p>很多业务已经下线,但是代码还留在线上(包括各种关联系统)。</p>    <h2><strong>新平台:业务架构的确定</strong></h2>    <p>在这样的情况下,我们决定抛弃老的平台,构建新一代的EDM平台。那其中第一步就是确认业务架构。</p>    <p>新平台将由生产平台、发送平台和统一管理平台组成。生产平台负责生产邮件发送任务,发送平台则部署在主流运营商的网络上,冗余部署一定量的发送节点,保证发送成功率。因为邮箱服务提供商通常会对邮件的发送方按IP做流控限制,并且对各种网络运营商投递的邮件的接受程度也不一样。</p>    <p>同时,为了保证在主流的网络运营商渠道上都有发送节点,避免因某个网段或某个运营商网络故障引起邮件发送成功率的波动。所以将发送和生产的逻辑区分开,有利于应用区分部署和扩容,平台间的职责也更加清晰。</p>    <p>老平台也是生产和发送分成两个平台,只不过职责没有划分得非常清晰,比如各自的平台上都集成了管理功能,发送节点只能调节自己的发送策略,不利于发送策略的批量调整。</p>    <p>生产平台和发送平台间使用Redis队列传递邮件任务。设计期间考虑过MQ,Kafka类组件,没有使用的原因是以上组件对于传递邮件的任务都“过重”,引入新的组件意味着新的风险,新组件的稳定性也会影响平台的稳定性。同时Redis对队列有原生的支持,作为当前最常使用的组件,起简单易用的特点正好符合新架构对这个组件的要求。再加上公司已经对Redis实现了集群及自动伸缩方案,可用率大大提高,学习零成本,都是Redis的优势,也是这次架构升级选用Redis的理由。</p>    <p>建立统一管理平台,实现对生成和发送情况的统一调度。要求是实现对整个生产和发送平台的调度管理,包含指定高优节点,屏蔽成功率较低节点,降级开关等一系列措施。</p>    <p>经过多次梳理和方案推导后,整个EDM平台的业务架构如下:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/275639d40c38f14089d4a01481b04b38.jpg"></p>    <p>生产平台对接用户组,订单组,会员等系统的MQ消息或远程调用生成邮件发送任务,并按类型划分1-10个优先级,数字越大,优先级越高。</p>    <p>Redis按优先级和目标邮箱两个维度拆分成多个Redis队列,负责将邮件任务传递到发送平台。</p>    <p>发送平台聚焦在发送业务的处理上,包括优先级队列、发送降频、邮件模板渲染等事务。</p>    <p>管理平台主要负责配置数据的维护、降级开关的推送和必要时人工对生产、发送平台的调度。</p>    <h2><strong>方案细节</strong></h2>    <p><strong>1、Redis优先级队列</strong></p>    <p>首先,业务上决定了邮件任务的紧急程度是不一样的,例如用户对账户密码找回的邮件就比优惠券到账的及时性敏感度更高,显然密码找回的邮件需要以最快速度投递到用户邮箱中。这和某些在极端场景才出现的“优先级”不一样,是一直持续存在的高优任务,最简单的办法就是区别对待,按优先级设置队列,从生产平台开始到Redis队列再到发送平台都一直是一个或多个特殊的队列,方便系统对这些高优发送任务做处理。</p>    <p>同样,高优先级队列长度的报警阀值比较小,一旦积压研发同学会第一时间收到报警,必要时可以人工接入。而发送平台总是最先拉取高优先级发送任务,保证其第一时间被处理。值得注意的是,从客观规律上看高优的邮件往往量是比较小,这使得发送平台总是优先处理高优的邮件并不会让优先级低的发送任务没有机会被拉取。</p>    <p>当业务量较大,发送较为频繁时触发邮件服务商的流控,或网络不稳定,出口IP异常时都会引起部分发送平台的邮件投递成功率的下降,这时需要让成功率将低的节点暂时甚至永久的不再向邮件服务商投递邮件,解决方案之一是在队列的拆分维度除了优先级以外再增加一个目标邮箱,一旦出现上述问题后,可以直接让发送节点不再拉取该邮箱的所有队列来实现故障隔离。</p>    <p>同时,用优先级和目标邮箱拆分Redis队列还有一个好处是,如果使用的是分布式的Redis,队列的元素总是在一个分片中的,如果队列过少,会导致有可能大量元素都集中在同一个分片中形成热点分片。将Redis队列拆分后可以让分个分片的读写相对更均衡,分片的利用率更高。实际上,Redis队列还设置了一个最大长度,防止队列无限制的增长。</p>    <p><strong>2、投递降频</strong></p>    <p>投递邮件时如果投递被拒绝邮件服务提供商一般都会返回一个错误码,发送平台上有一个错误映射表,发送错误后将错误码和错误映射表比较,如果触发了流控则降低邮件发送任务的拉取频率,直到投递成功率恢复后再逐步提升发送能力。</p>    <p><strong>3、Checker</strong></p>    <p>Checker是生产平台扫描邮件发送任务的定时任务的总称,按职责不同,Checker被具体分为UnsuccessChcker、InQueueChecker、ExpireChecker等等。职责是将各个状态的邮件发送任务更新为下一个流程需要的状态,比如将入库成功的状态更新为Redis队列中,Redis队列中更新为发送平台发送中,发送错误的任务更新为重新投递等等。</p>    <p>重新投递是非常重要的一个功能,因为某个出口IP因为各种原因可能会常常被邮件服务商拒收,重试相当于有较大几率更换出口IP再做发送尝试,有利于投递成功率的提高。因为邮件发送任务常常被各种Checker更新,为了保证数据的一致性和状态按正确流程流转,邮件发送任务被加上了版本号,每次更新后自增,更新时使用乐观锁更新。</p>    <h2><strong>性能优化</strong></h2>    <p>初期平台上线时的第一版架构如下:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/3655a999d9aad01331d53559147c474b.jpg"></p>    <p>上线后出现了一系列的性能问题,总结起来主要为两类。</p>    <p><strong>第一类问题:写库CPU 100%,影响远程调用接口的性能,引发上游团队关注。</strong></p>    <p>引发写库CPU 100%的原因最主要的还是数据库同一时间读写请求太多和索引利用率不高导致的。因此第一步想将数据库的读写压力分开,对数据库做了读写分离,所有的读请求全部调整到了从库上去,以此降低主库压力。这里有一个前提是经过评估,从库读取脏数据并不会对业务产生困扰,因为邮件发送任务本身有版本号,即使数据库主从同步有延迟引起从库读到“脏数据”使用乐观锁更新时也会失败,不会引起业务错误。</p>    <p>第二步将查询条件归一再后建立索引,索引不是越多越好,归一时可以现将查询任务列出来,观察哪些查询条件是相似的,有没有特殊的业务导致了不一样的查询条件,这些业务有没有办法从其他角度去支持,逐步归纳后再建索引。上述步骤多次使用后,通常情况必须要建立的索引就不会太多了。目前邮件发送任务表(分表后单表千万级)只有一个主键索引和一个组合索引(三个字段),所有查询条件全部先利用索引查询数据。</p>    <p>调整完索引后发现Checker的扫描还是过于频繁,读库CPU利用率还是不够理想(过高),梳理业务后发现整个平台的发送能力取决于邮件服务商的接受量和一定程度的发送平台量(出口IP量)。两者不变的情况下,整个发送平台的发送量不会提升,Redis队列的吞吐能力也不变,Checker大部分时候运行的结果只是Push了个别元素,甚至没有Push。Checker完全可以改成Redis队列小于一定阀值,例如最大长度的1/2再做一次扫描,一次扫描尽量将队列填满。调整Checker策略和索引后数据库的QPS大约降了2/3,load也稳定在5以下。</p>    <p>还有一个让数据库CPU较高的原因是Checker引起数据库死锁。</p>    <p>例如有Transaction1需要对ABC记录加锁,已经对A,B记录加了X锁,此刻正尝试对C记录加锁。同时此前Transaction2已经对C记录加了独占锁,此刻需要对B记录加X锁,就会产生数据库的死锁。</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/893af185fa1f3d2becfc90041bc00492.jpg"></p>    <p>尽管MySQL做了优化,比如增加超时时间:innodb_lock_wait_timeout,超时后会自动释放,释放的结果是Transaction1和Transaction2全部Rollback(死锁问题并没有解决,如果不幸,下次执行还会重现)。</p>    <p>如果每个Transaction都是Update数万,数十万的记录(我们的业务就是),那事务的回滚代价就非常高,还会引起数据库的性能波动。</p>    <p>解决办法很多,比如先查询出数据后再做逐条做写操作,或者写操作加上一个limit限制每次的更新次数,同时避免两个Transaction并发执行等等。最终在调整了Checker的运行周期后选择了逐条更新的方案,因为业务对于时间上要求并不高,更新不及时并不会引起业务上的错误。</p>    <p>经过以上优化后,压测表明整个平台3倍峰值流量下,数据库CPU利用率10%以下,load5以下,95%的远程调用只有一次数据库的Insert操作,远程接口TP999在20ms内。</p>    <p><strong>第二类问题是因为代码编写不当,引发JVM假死和CPU 100%。</strong></p>    <p>出于减少远程接口同步逻辑的需要,研发同学将大部分操作改为异步方式,比如邮件的推荐商品服务。因为邮件发送任务在生产平台到发送平台间流转需要一定的时间,将推荐商品服务异步化后,生产邮件发送任务的同步逻辑会减少,远程接口调用或MQ消息消费线程可以更早返回,对推荐接口的性能波动容忍度也会变高,只需要保证在发送平台渲染邮件模板前能够拿到推荐商品的数据即可。</p>    <p>异步改造时,研发同学使用了线程池的无界队列,并因为一个低级BUG导致上线后无界队列的消费线程只有5个,生产和消费的速率严重不匹配,导致了短时间内JVM内存占用过高,JVM频繁GC,JVM频繁处于“stop the world”阶段,呈现出“假死”状态,最终再次影响到远程接口的调用和MQ消息的消费。这次的经验说明,实例宕机或许并不是最难处理的,更难处理的是实例处于可以提供服务,但是没有服务能力的状态。</p>    <p>我们的解决方案是使用有界队列,防止超长队列的产生。设置队列的拒绝策略,队列无空闲位置时,放弃入队操作。此时会导致部分邮件缺少推荐商品模块,可视作推荐商品模块的处理能力达到上限后的一种降级方式。</p>    <p>Redis队列中元素的大小大于10K时,入队和出队的效率会严重下降,出于这这个原因,Redis队列中只存放有邮件发送任务的原始数据,渲染工作是在发送节点上完成的。发送高峰期时,发送平台的CPU利用率整体在80%甚至90%以上,发送能力无法再提升。经过一系列排查后发现CPU利用率较高的源头来自于Handlebars模板渲染模块。</p>    <p>抽样查看部分线上机器的线程占用率时发现渲染线程大部分时间一直在做邮件模板的语法解析,参考相应文档后发现语法解析是模板渲染中最耗时的流程,为了提高效率无论是Velocity还是Handlbars都会对模板语法解析的结果做缓存,下次渲染时直接使用解析结果渲染。但缓存是基于VelocityEngine或Handlebar实例的,如果JVM中存在多个VelocityEngine或Handlebar实例,缓存就无法有效利用,结果是每次渲染模板时都要做语法解析,如果并发解析的线程达到数十、数百个的话,就会引起实例的CPU 100%。</p>    <p>解决方案:</p>    <ul>     <li> <p>保证全局只有一个Handlebar实例,方便共享缓存结果。</p> </li>     <li> <p>容器启动时,渲染线程依次启动并等待一段时间在启动下一个渲染线程,避免并发启动多个线程时再次出现并发解析模板的情况。</p> </li>    </ul>    <p>除去以上问题的解决,上线后研发团队还做了几次全流程的优化,优化包括黑名单、退订数据缓存化,Redis队列Push方式异步化、批量化,发送平台的拉取合并、邮件模板本地化等。</p>    <p>优化后平台的应用架构如下:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/4d742fa8e4cb7d54d3fac6bdb999355d.jpg"></p>    <h2><strong>容灾方案</strong></h2>    <p>容灾方案中,优先考虑的就是多网络运营商覆盖的问题,防止某一网络运营商网络故障影响邮件发送的能力。目前的方案是单一机房配置单一网络运营商的出口IP及反解析域名,每个机房部署相应的生产平台,Redis队列和发送平台,彼此之间相互独立运行,底层使用同一个数据库,生产平台提供的远程接口为同一个别名服务,MQ消息也是消费的同一个Topic下的内容,多个Redis之间存在少量数据的同步,比如去重数据。整体架构如下:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/e66d9dde3052a15f5e6d796a7e22d996.jpg"></p>    <p>我们将紧急情况分为内部接口、服务故障和外部服务故障。针对内部故障,比如:</p>    <ul>     <li> <p>Redis集群故障。如果是单分片故障,Redis集群提供了主从分片,可以通过切换分片的方式解决。如果是集群整体故障,可以启用备用Redis集群,在这里不存在集群数据为空的问题,因为生产平台有Checker存在,如果切换集群,Checker可以感知到Redis队列数据量不够,会重新将待发送的邮件任务Push到Redis队列中。</p> </li>    </ul>    <p>针对外部故障,比如:</p>    <ul>     <li> <p>机房出口网络故障。可以停止故障机房的发送平台,因数据库共享,数据入库后对端机房的Checker会将数据重新Push到对端机房的Redis队列中,从对端机房发送邮件任务。这里还有一种方案是修改故障机房的Redis集群配置,故障机房的生产平台生成邮件发送任务后直接将数据Push到对端机房的Redis集群中,省略Checker扫描的这一步,会大大减少数据库的读压力。</p> </li>     <li> <p>邮件服务提供商对部分出口IP降频。发送节点上内置了降频处理措施,可以解决该问题。</p> </li>     <li> <p>邮件服务商屏蔽部分出口IP。通过自研的配置推送与服务监控框架,可用管理平台将被屏蔽的IP地址推送到发送平台上,发送平台通过比对如果发现自身已被屏蔽,将不再从Redis队列中Pull相应的邮件发送任务。</p> </li>    </ul>    <h2><strong>总结</strong></h2>    <p>EDM平台上层对接了精准营销平台和生产邮件业务,如何用一套通用的解决方案解决两个业务的不同需求是建设该平台的难点,需要在两种业务形态间找到共性并满足各自业务对及时性,发送量方面的要求。尤其是生产邮件业务,对发送的及时性,稳定性的要求都较高,非常容易引起上层业务团队的关注。在做方案时需要更多的关注到架构本身对性能、容灾、业务和研发同学的友好性,架构越容易让人接受,更简单的解决现有问题,才有可能在以后的发展中不断往好的方向进化,容纳更多复杂的业务需求,支持业务的长久发展。</p>    <p> </p>    <p> </p>    <p>来自:http://www.infoq.com/cn/articles/the-optimization-road-of-jingdong-edm-platform</p>    <p> </p>