京东618的硝烟虽已散去,可开发和备战618期间总结过的一些设计原则和遇到的一些坑还历历在目。伴随着网站业务发展,需求日趋复杂多样并随时变化;传统静态化方案会遇到业务瓶颈,不能满足瞬变的需求。因此,需要一种能高性能实时渲染的动态化模板技术来解决这些问题。
商品详情页是展示商品详细信息的一个页面,承载在网站的大部分流量和订单的入口。京东商城目前有通用版、全球购、闪购、易车、惠买车、服装、拼购、今日抄底等许多套详情页模板,通过一些特殊属性、商家类型和打标来区分,每套模板数据是一样的,核心逻辑基本一样,但是一些前端逻辑是有差别的。
它主要包括以下三部分:
商品详情页系统负责静的部分(整个页面)
商品详情页动态服务系统和商品详情页统一服务系统统一服务系统负责动的部分,比如实时库存。目前已经上线了几个核心服务,今晚计划切新库存服务的1/n流量。动态服务系统负责给内网其他系统提供一些数据服务(比如大客户系统需要商品数据),目前商品详情页系统已经稳定运行半年了,目前主要给列表页提供一些数据。
键值结构的异构数据集群
商品主数据因为是存储在DB中,对于一些聚合数据需要联合查询非常多,会导致查询性能差的问题,因此对于键值类型的查询,我们这套异构数据非常有用。我们这次架构的调整的主要目的是满足日趋复杂的业务需求,能及时开发业务方的需求。我们的系统主要处理键值数据的逻辑,关系查询我们有另一套异构系统。
下图是我们的模板页,核心数据都是一样的,只是展示方式和一些前端逻辑不太一样。
我们详情页的前端展示主要分为这么几个维度:
我们目前把数据按维度化存储,比如一些维度直接redis存,性能好。
京东商城还有一些特殊维度数据:比如套装、手机合约机等,这些数据是主商品数据外挂的,通过异步加载来实现的逻辑。还有一些与第三方合作的,如易车,很多数据都是无法异构的,都是直接异步加载的。目前有易车、途牛等一些公司有这种合作。
上图是我们的一个监控图。我们详情页流量特点是离散数据,热点少,各种爬虫、比价软件抓取;所以如果直接查库,防刷没做好,很容易被刷挂。
这是我们的一个架构历史。
这种方式经常受到依赖的服务不稳定而导致的性能抖动。基本发展初期都是这个样子的,扛不住加层缓存。因此我们设计了架构2.0。
主要思路:
主要缺点:
之前需求没那么多,因此页面变更不是很频繁,基本没什么问题。但是随着商品数量的增加这种架构的存储容量到达了瓶颈,而且按照商品维度生成整个页面会存在如分类维度变更就要全部刷一遍这个分类下所有信息的问题,因此我们又改造了一版按照尾号路由到多台机器。这种生成整个页面的方案会存在比如只有分类信息变了,也需要把这个分类下的商品重新刷一遍。
这种方式通过尾号路由的方式分散到多台机器扩容,然后生成HTML片段,按需静态化;当时我们做闪购的时候,需要加页头,都是通过js搞定的。但对于大的页面结构变更,需要全量生成。尤其像面包屑不一样的话会很麻烦,需要生成多个版本。
当时我记得印象最深的就是碎片文件太多,我们的inode不够了,经常要半夜去公司删文件。因为存在删除问题,每台服务器并不是全量,所以我们需要一个动态生成的服务,当静态化不存在的时候还原到动态服务;但这样双十一时压力非常大,我们依赖的系统随时都给我们降级。
我们的痛点:
其实最痛快的是业务来说我们要搞垂直,我们要模块化,我们要个性化;这些统统不好搞,因此我们就考虑做一版全动态的。其实思路和静态化差不多,数据静态化聚合、页面模板化。
我们要考虑和要解决的问题:
思路差不多:MQ得到变更通知,Worker刷元数据到JIMDB,前端展示系统取数据渲染模板。另外我们当时架构的目标是详情页上有的数据,我们都可以提供服务出去,主要提供单个商品的查询服务,所以我们把这个系统叫做动态服务系统。
因为我们这边主要是读服务,因此我们架构可能偏读为主的设计;目前我设计的几个系统都遵循这些原则去设计:
数据异构,是数据闭环的第一步,将各个依赖系统的数据拿过来,按照自己的要求存储起来;我们把很多数据划分为三个主要维度进行异构:商品信息、商品介绍和其他信息(分类、商家、店铺等)。
数据原子化处理,数据异构的数据是原子化数据,这样未来我们可以对这些数据再加工再处理而响应变化的需求。我们有了一份原子化异构数据虽然方便处理新需求,但恰恰因为第一份数据是原子化的,那么它会很分散,前端读取时mget的话性能不是很好,因此我们又做了数据聚合。
数据聚合,是将多个原子数据聚合为一个大JSON数据,这样前端展示只需要一次get,当然要考虑系统架构,比如我们使用的Redis改造,Redis又是单线程系统,我们需要部署更多的Redis来支持更高的并发,另外存储的值要尽可能的小。
我们目前的异构数据是键值结构的,用于按照商品维度查询,还有一套异构时关系结构的用于关系查询使用。
数据维度化对于数据应该按照维度和作用进行维度化,这样可以分离存储,进行更有效的存储和使用。我们数据的维度比较简单:
这是我们url的一些规则,methods指定聚合的服务。我们还对系统按照其作用做了拆分。
将系统拆分为多个子系统虽然增加了复杂性,但是可以得到更多的好处。比如,数据异构系统存储的数据是原子化数据,这样可以按照一些维度对外提供服务;而数据同步系统存储的是聚合数据,可以为前端展示提供高性能的读取。而前端展示系统分离为商品详情页和商品介绍,可以减少相互影响;目前商品介绍系统还提供其他的一些服务,比如全站异步页脚服务。我们后端还是一个任务系统。
异步化+并发化
多级缓存化因之前的消息粒度较粗,我们目前在按照一些维度拆分消息,因此读服务肯定需要大量缓存设计,所以我们是一个多级缓存的系统。
浏览器缓存,当页面之间来回跳转时走localcache,或者打开页面时拿着Last-Modified去CDN验证是否过期,减少来回传输的数据量;
CDN缓存,用户去离自己最近的CDN节点拿数据,而不是都回源到北京机房获取数据,提升访问性能;
服务端应用本地缓存,我们使用Nginx+Lua架构,使用HttpLuaModule模块的shareddict做本地缓存(reload不丢失)或内存级ProxyCache,从而减少带宽。
我们的应用就是通过Nginx+Lua写的,每次重启共享缓存不丢,这点我们受益颇多,重启没有抖动,另外我们还使用使用一致性哈希(如商品编号/分类)做负载均衡内部对URL重写提升命中率;我们对mget做了优化,如去商品其他维度数据,分类、面包屑、商家等差不多8个维度数据,如果每次mget获取性能差而且数据量很大,30KB以上;而这些数据缓存半小时也是没有问题的,因此我们设计为先读localcache,然后把不命中的再回源到remotecache获取,这个优化减少了一半以上的remotecache流量;这个优化减少了这个数据获取的一半流量;
服务端分布式缓存,我们使用内存+SSD+JIMDB持久化存储。
动态化我们整个页面是动态化渲染,输出的数据获取动态化,商品详情页:按维度获取数据,商品基本数据、其他数据(分类、商家信息等);而且可以根据数据属性,按需做逻辑,比如虚拟商品需要自己定制的详情页,那么我们就可以跳转走,比如全球购的需要走jd.hk域名,那么也是没有问题的;未来比如医药的也要走单独域名。
模板渲染实时化,支持随时变更模板需求;我们目前模板变更非常频繁,需求非常多,一个页面8个开发。
重启应用秒级化,使用Nginx+Lua架构,重启速度快,重启不丢共享字典缓存数据;其实我们有一些是Tomcat应用,我们也在考虑使用如Tomcat+LocalRedis或Tomcat+NginxLocalSharedDict做一些本地缓存,防止重启堆缓存失效的问题。
需求上线速度化,因为我们使用了Nginx+Lua架构,可以快速上线和重启应用,不会产生抖动;另外Lua本身是一种脚本语言,我们也在尝试把代码如何版本化存储,直接内部驱动Lua代码更新上线而不需要重启Nginx。
弹性化我们所有应用业务都接入了Docker容器,存储还是物理机;我们会制作一些基础镜像,把需要的软件打成镜像,这样不用每次去运维那安装部署软件了;未来可以支持自动扩容,比如按照CPU或带宽自动扩容机器,目前京东一些业务支持一分钟自动扩容,下个月会进行弹性调度尝试。
降级开关一个前端提供服务的系统必须考虑降级,推送服务器推送降级开关,开关集中化维护,然后通过推送机制推送到各个服务器;
可降级的多级读服务,前端数据集群—->数据异构集群—->动态服务(调用依赖系统);这样可以保证服务质量,假设前端数据集群坏了一个磁盘,还可以回源到数据异构集群获取数据;基本不怕磁盘坏或一些机器故障、或者机架故障。
开关前置化,如Nginx代替Tomcat,在Nginx上做开关,请求就到不了后端,减少后端压力;我们目前很多开关都是在Nginx上。
可降级的业务线程池隔离,从Servlet3开始支持异步模型,Tomcat7/Jetty8开始支持,相同的概念是Jetty6的Continuations。我们可以把处理过程分解为一个个的事件。
通过这种将请求划分为事件方式我们可以进行更多的控制。如,我们可以为不同的业务再建立不同的线程池进行控制:即我们只依赖tomcat线程池进行请求的解析,对于请求的处理我们交给我们自己的线程池去完成;这样tomcat线程池就不是我们的瓶颈,造成现在无法优化的状况。通过使用这种异步化事件模型,我们可以提高整体的吞吐量,不让慢速的A业务处理影响到其他业务处理。慢的还是慢,但是不影响其他的业务。我们通过这种机制还可以把tomcat线程池的监控拿出来,出问题时可以直接清空业务线程池,另外还可以自定义任务队列来支持一些特殊的业务。
多种压测方案我们在验证系统时需要进行压测。线下压测,Apacheab,ApacheJmeter,这种方式是固定url压测,一般通过访问日志收集一些url进行压测,可以简单压测单机峰值吞吐量,但是不能作为最终的压测结果,因为这种压测会存在热点问题;
线上压测,可以使用Tcpcopy直接把线上流量导入到压测服务器,这种方式可以压测出机器的性能,而且可以把流量放大,也可以使用Nginx+Lua协程机制把流量分发到多台压测服务器,或者直接在页面埋点,让用户压测,此种压测方式可以不给用户返回内容。服务刚开始的时候大量使用tcpcopy做验证,对于一些新服务,如果无法使用tcpcopy我们就在页面埋url让用户来压。
另外压测时,要考虑读、写、读或写同时压。只压某一种场景可能都会不真实。
使用SSD做KV存储时发现磁盘IO非常低。配置成RAID10的性能只有36MB/s;配置成RAID0的性能有130MB/s,系统中没有发现CPU,MEM,中断等瓶颈。一台服务器从RAID1改成RAID0后,性能只有~60MB/s。这说明我们用的SSD盘性能不稳定。
据以上现象,初步怀疑以下几点:SSD盘,线上系统用的三星840Pro是消费级硬盘;RAID卡设置,Writeback和Writethrough策略(后来测试验证,有影响,但不是关键);RAID卡类型,线上系统用的是LSI2008,比较陈旧。
在系统设计初期最头痛的就是存储选型,我们对于存储选型时尝试过LevelDB、RocksDB、BeansDB、LMDB、Riak等,最终根据我们的需求选择了LMDB。
机器:2台配置:32核CPU、32GB内存、SSD((512GB)三星840Pro—>(600GB)Intel3500/IntelS3610)数据:1.7亿数据(800多G数据)、大小5~30KB左右KV存储引擎:LevelDB、RocksDB、LMDB,每台启动2个实例压测工具:tcpcopy直接线上导流压测用例:随机写+随机读
Jimdb数据同步时要dump数据,SSD盘容量用了50%以上,dump到同一块磁盘容量不足。解决方案:
因为是基于Redis的,目前是先做数据RDBdump然后同步。后续计划改造为直接内存复制,之前存储架构是一主二从(主机房一主一从,备机房一从)切换到备机房时,只有一个主服务,读写压力大时有抖动,因此我们改造为之前架构图中的一主三从。
之前的架构是存储集群的分片逻辑分散到多个子系统的配置文件中,切换时需要操作很多系统。
解决方案:
我们都是在应用本地部署的Twemproxy,然后通过中间系统对外提供数据。
我们前端应用使用的是Nginx+Lua,起初不确定Lua做逻辑和渲染模板性能如何,就尽量减少for、if/else之类的逻辑;通过javaworker组装html片段存储到jimdb,html片段会存储诸多问题,假设未来变了也是需要全量刷出的,因此存储的内容最好就是元数据。
因此通过线上不断压测,最终jimdb只存储元数据,lua做逻辑和渲染;逻辑代码在3000行以上;模板代码1500行以上,其中大量for、if/else,目前渲染性可以接受。
商品详情页库存接口2014年被恶意刷,每分钟超过600w访问量,tomcat机器只能定时重启;因为是详情页展示的数据,缓存几秒钟是可以接受的,因此开启nginxproxycache来解决该问题,开启后降到正常水平;我们目前正在使用Nginx+Lua架构改造服务,数据过滤、URL重写等在Nginx层完成,通过URL重写+一致性哈希负载均衡,不怕随机URL,一些服务提升了10%+的缓存命中率。
还有我们会对这些前端的url进行重写,所以不管怎么加随机数,都不会影响我们服务端的命中率,我们服务端做了参数的重新拼装和验证。
开启NginxProxyCache后,性能下降,而且过一段内存使用率到达98%。
假设一个读服务是需要如下数据:
那么如果并发化获取那么需要:30ms;能提升一倍的性能。
假设数据E还依赖数据F(5ms),而数据F是在数据E服务中获取的,此时就可以考虑在此服务中在取数据A/B/D时预取数据F,那么整体性能就变为了:25ms。
Q1:对于依赖服务的波动,导致我们系统的不稳定,我们是怎么设计的?我们的数据源有三套:前端数据集群该数据每个机房有两套,目前两个机房。数据异构集群同上动态服务(调用依赖系统)。
Q2:静态化屏蔽通过js是怎么做的?
Q3:内网的服务通过什么方式提供?我偏好使用HTTP,目前也在学习HTTP2.0,有一些服务使用我们自己开发类似于DUBBO的服务化框架,之前的版本就是DUBBO改造的。
Q4:对于mq的处理如果出现异常是怎么发现和处理的?
Q5:对于模板这块,有做预编译处理?或者直接使用Lua写模板吗?
Q6:jimdb能否介绍下,特性是什么,为什么选用
Q7:咨询下对于价格这类敏感数据,前端有缓存么?还是都靠价格服务扛?
价格数据也是通过MQ得到变更存储到本地redis的,nginx+lua直接读本机redis,性能没的说。
Q8:库存和价格一样的模式处理吗?前端展示库存不是,我们做了几秒的服务端缓存。
Q9:github里有一个开源的基于lmdb的redis你们用的这个吗我们内部自己写的,有一版基于LevelDB的,我记得github上叫ardb。
Q10:看测试条件说测试的是大小5~30KB左右的数据,有没有测试过更大文件lmdb的表现?这个没有,我们的数据都是真实数据,最大的有50KB左右的,但是分布比较均匀;当时还考虑压缩,但是发现没什么性能问题,就没有压缩做存储了。
Q11:关于redis缓存,是每个子系统拥有自己的一套缓存;还是使用统一的缓存服务?是否有进行过对比测试?(看到又说使用单机缓存防止服务挂掉,影响整体服务)
Q12:“我们目前一些线上服务器使用的是LMDB,其他一些正在尝试公司自主研发的CycleDB引擎”。开始自主研发,这个是由于lmdb有坑还是处于别的考虑?