京东秒杀是京东最大的营销渠道。近年来随着业务的快速发展,渠道产品数量和用户流量都呈现快速增长的趋势。图片来自宝途网同时,业务方未来计划将渠道产品数量增加5到10倍,对产品池扩容需求强烈,对我们现有体系提出挑战建筑学。为应对商品数量激增带来的风险,秒杀后台团队在年初针对秒杀商品池的扩容技术建立了专项优化项目,并完成了十项结构升级。-6.18前按计划扩容百万级商品池。本文主要介绍秒杀商品池扩容项目的优化经验。京东秒杀渠道业务主要包括两部分:一是核心渠道服务,即直接向终端用户提供渠道服务。另一部分是维护秒杀商品池的数据,为店铺详情、购物车等多个终端提供秒杀商品阅读服务,展示“京东秒杀”的促销氛围标签,我们称之为秒杀商品标识服务。图1:京东秒杀渠道业务秒杀系统是一个高并发、高流量的系统,采用缓存技术提升系统性能。在渠道核心服务的历史业务迭代过程中,采用了将商品池数据完全缓存在内存中的缓存方案。这是因为在渠道业务中,有全量产品多维度分拣的需求。同时,渠道开发初期产品数量不多,全缓存方式内存压力不大,开发成本低。由于秒杀产品存在时促销和库存有??限的特点,对数据更新的实时性要求比较高。我们通过ZK通知实现产品数据更新。原系统架构如图2所示:图2:京东原系统架构图。秒杀CMS系统将商品数据推送到JIMDB(京东内部分布式缓存和高速key-value存储服务,类似Redis),同时通过ZooKeeper发送通知。秒杀SOA系统监听通知,从JIMDB获取最新数据,更新本地缓存,提供渠道核心服务和商品标记服务。问题分析在之前的大促中,当商品池数量激增时,观察到系统的堆内存消耗过快。同时,MinorGC垃圾回收的效果有限。MinorGC回收后,堆内存的低点持续上升,并且堆内存有持续增长的趋势,并且会规律性的飙升。FullGC更频繁,对CPU利用率影响更大,接口性能毛刺严重。图3:系统异常监控通过JVM堆内存变化图可以看出堆空间增长很快,MinorGC无法回收新增加的堆空间。堆空间呈规律性增长,而且会周期性增长,推测与定时任务有关。FullGC后内存回收率高,排除内存泄漏。FullGC对CPU利用率有很大影响。频繁的GC严重影响系统的稳定性和界面的性能。分析堆对象的增长。使用jmap-histo命令打印FullGC发生前后JVM堆中的对象,如图4和图5所示:图4:FullGC发生前的堆内存对象图5:堆内存对象FullGC发生后FullGC前后堆中对象分布分析,以类别秒杀为例,FullGC后堆中商品对象不到100万个,占内存125M是与秒杀类目实际有效商品数量差不多,String对象一共约385M。在FullGC发生之前,堆中类别秒杀产品数量达到近500万个,占用内存达到700M。另外,String对象占用了1.2G的内存。结合系统架构分析,可以确定产品在overlay更新过程中,老对象没有被回收,继续进入老年代。老年代内存占用越来越高,最终导致堆内存不足,FullGC。堆对象中的String对象也是这种更新方法的副产品。这是因为产品数据是以String形式存储在JIMDB中的,更新时会从JIMDB中拉取并在本地反序列化得到对象列表。从图6所示的问题代码可以看出生成大String对象的原因:图6:问题代码针对上述全量更新场景,旧对象和临时生成的String对象满足垃圾回收条件.为什么不在MinorGC阶段呢?回收?我们知道,在大多数情况下,对象分配在新生代的伊甸园区,对象进入老年代有以下几种情况:①大对象直接进入老年代:大对象是Java对象,需要大量连续的内存空间。比如长字符串和数组。大对象会导致在剩余内存空间足够的情况下提前触发垃圾回收,以获得足够的连续空间进行放置。同时,大对象的频繁拷贝也会影响性能。虚拟机提供了一个-XX:PretenureSizeThreshold参数,让大于阈值的对象直接在老年代分配。为了避免临时String对象直接进入老年代的情况,我们明确关闭了这个功能。②长期存活的对象会进入老年代:虚拟机为每个对象定义一个对象年龄计数器。如果对象是在Eden中创建的,在第一次MinorGC后存活下来,并且可以被Suivivor容纳,则会被Move到Survivor空间,并将对象age设置为1。每进行一次MinorGC,age就会增加1年。当达到阈值时(可通过参数-XX:MaxTenuringThreshold设置,CMS垃圾收集器默认值为6),老年代将被提升。在上面的分析中,临时String对象是活不过6次MinorGC的。③动态对象年龄判定:为了更好的适应不同的程序内存情况,虚拟机并不严格要求对象年龄达到MaxTenuringThreshold才能晋升到老年代。如果Survivor空间中所有小于或等于某个年龄的对象的大小之和大于Survivor空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代。通过上面的分析,我们发现临时String对象最有可能触发动态对象年龄判定机制,进入老年代。打印虚拟机的GC信息,加上-XX:+PrintTenuringDistribution参数打印GC发生时新生代的对象年龄信息,得到图7所示的GC日志信息:图7:GC日志来自GC日志,可以看到Survivor空间大小为358M,Survivor区目标利用率默认为50%,DesiredSurvivor大小为179M,age<=2的对象总大小为269M。因此,虽然设置的提升阈值为6,但虚拟机动态计算出的提升阈值为2,最终导致年龄大于等于2的对象进入老年代。我们尝试通过优化JVM参数来解决问题,但效果并不理想。曾经做过一些尝试:增加新生代的空间来减少进入老年代的对象,但结果适得其反,STW更频繁,CPU利用率波动更大。切换到G1垃圾收集器,效果不明显,CPU利用率波动较大。显式设置晋升到老年代的阈值(MaxTenuringThreshold),试图延缓对象进入老年代的速度,没有效果。上述问题的分析结论给我们的启示是:如果新生代中存在频繁死活的大对象,会触发虚拟机的动态对象年龄判定机制,降低对象进入老年代的门槛,并导致堆内存增长过快。优化方案①双缓冲区定时hash更新通过以上分析可以发现,为了防止堆内存增长过快,需要控制商品数据更新的粒度和频率。原有的商品更新方案是根据活动的维度覆盖更新商品数据,每个商品的状态变化都会触发更新操作。我们希望可以将数据更新控制在较小的范围内,同时可以控制数据更新的频率,最后设计了一种双缓冲的定时哈希更新方案,如图8所示。图8:双缓存定时哈希更新示意图该方案的实现是将活动下的商品在SKU维度上哈希到不同的桶中,以桶为粒度进行更新操作。同时,为了控制数据更新的频率,我们在SOA端设计了双缓存定时切入的方法。CMS产品数据更新时,会映射到需要更新的bucket,实时通知SOA端;SOA端收到ZK通知后,会在读缓存中标记需要更新的bucket,但不会实时更新数据。到了定时,读写缓存会自动切换。此时会读取读缓存中标记的待更新bucket,并从JIMDB中获取bucket对应的商品列表,完成数据的细粒度分段更新。该方案的哈希份额数和时序可以根据具体业务情况进行调整,在性能和实时性之间取得了平衡,上线后取得了很好的优化效果。②引入本地LRU缓存和双buffer缓存定期hash更新的方案虽然提升了系统性能,但仍然无法支持千万级商品的扩容。为了彻底摆脱机器内存对产品池容量的限制,我们推出了秒杀架构的全面升级。核心思想是引入一个局部LRU缓存组件来剔除冷数据,从而将内存中的缓存产品总数控制在一个安全的范围内。系统拆分:原有系统存在的问题是渠道核心服务和商品标记服务共享同一个基础数据,存在系统耦合问题。从商品池来看,渠道核心服务商品池是秒杀商品池的一个子集。从业务角度看,渠道核心服务业务逻辑复杂,调用链路长,响应时间长。产品标记服务逻辑简单,调用链路短,响应时间短。拆分渠道核心服务和商品标记服务,独立部署,实现资源隔离,可以根据业务特点进行针对性优化。渠道核心服务可以减少内存中的商品缓存数量,商品标记服务可以升级商品缓存方案,也可以避免架构升级过程中对渠道核心服务的影响。图9:系统拆分缓存方案优化:渠道核心业务历史逻辑复杂,直接面向终端用户,升级难度大。一期扩容项目主要优化点是拆分出渠道核心服务产品池,去除非渠道展示产品,减少产品缓存。第一阶段优化主要针对秒杀标记服务的缓存方案进行升级。原有系统架构中,闪购商品池全缓存在内存中,当商品数量激增时会导致JVM堆内存资源不足,商品池容量有限,无法扩展水平地。商品在活动维度存储和更新,会导致大key的问题。覆盖更新时,会在内存中产生临时的大对象,不利于JVM垃圾回收的性能。图10:缓存方案升级对于未打包的产品标记服务,缓存方案优化的总体思路是实现冷热数据的拆分。升级后的产品标记服务不再使用本地全量缓存,而是使用JIMDB全量缓存+本地LRU缓存组件。对缓存组件的要求是在缓存数据达到预设产品数上限时实现冷数据的清除,同时具备较高的缓存命中率和读写性能。在对比了常用的缓存框架Caffeine和GuavaCache之后,最终采用了Caffeine缓存。优点是:更好的性能。Caffeine的读写性能明显优于Guava。这是因为Guava中的读写操作混合了过期时间处理。一个put操作可能会触发一个淘汰操作,因此它的读写性能会受到一定的影响。但是,Caffeine对这些事件的操作是异步的。将事件提交到队列,通过默认的ForkJoinPool.commonPool()或者自己配置的线程池进行入队操作,然后进行异步淘汰和过期操作。命中率高,内存占用低。Guava采用的是分段LRU算法,而Caffeine采用的是结合了LRU和LFU优点的算法:W-TinyLFU,可以用更少的资源记录访问频率,可以解决稀疏突发访问元素的问题。升级后的架构图如图11所示:图11:升级后的架构图渠道核心服务和商品标记服务独立部署,资源隔离。在输入和更新产品时,LightningDealCMS将SKU维度写入JIMDB以形成完整的LightningDeals产品池。商品标记服务使用Caffeine缓存,写入写入过期时间设置30s,最大缓存200w条商品数据,实现热数据缓存,淘汰过期数据和冷数据。③在非秒杀SKU查询处理中引入Bloomfilter,为了避免缓存穿透的问题(即高频查询单个无效商品,如果没有本地缓存??,每次请求都会访问JIMDB),我们为非秒杀对于商品的查询结果,在本地缓存中存储一??个空值标识,防止无效的SKU请求每次访问JIMDB。店铺详情、购物车等渠道产品池的数量比秒杀产品池高出几个数量级。秒杀查询服务请求的SKU中存在大量非秒杀产品,会降低本地缓存的命中率,带来缓存雪崩的风险。为了拦截大量非秒杀SKU的请求,我们引入了过滤机制。在本地过滤器的选择上,我们尝试使用所有有效产品SkuId组成的Set集合来生成本地过滤器。上线后,我们观察到本地过滤器数据更新的性能会出现波动。分析发现这种方法空间复杂度高,内存占用也比较高。将过滤器优化为布隆过滤器后,内存占用减少,性能进一步提升。优化效果完成架构升级后,通过单机压力测试、灰度验证、灰度上线、满载测试等流程,对新系统的性能和结果准确性进行了严格验证。618推广前,新系统全面上线顺利。从近几年推广期间的系统性能来看,优化效果显着,如图12和图13所示,主要体现在以下几个方面。图12:大促与业务扶持绩效对比:秒杀产品池数量持续增长。由于架构调整,所有产品都缓存在JIMDB中。新系统支持横向扩展,未来可支持更高订单的产品,满足业务的长远规划。性能优化:大促期间标记服务接口tp999持续下降,618大促接口性能提升90%。同时,从接口性能对比上,解决了接口性能毛刺问题。稳定性提升:GC频率持续降低,系统稳定性提升。图13:接口性能监控对比总结。秒杀商品池扩容优化项目通过优化商品更新方式、系统拆分、优化缓存方案,实现系统架构升级,提升渠道商品容量和性能。设立目标。作者:洪超编辑:陶佳龙来源:转载自公众号京东零售科技
