京东前端:三级列表页持续架构优化
1.京东三级列表页
三级列表页是什么
列表页是京东商城的三大核心系统之一。京东三级列表页是用户选取商品类型后,展示同类商品的页面,具体如图1所示。
图1
如何进入三级列表页
用户在首页左侧的导航树中(见图2)、全部商品分类列表页或者顶部面包屑导航中,选择到商品的最小分类级别后,就可以到达三级列表页。
图2
三级列表页的作用
该页面根据用户选择的商品类目进行检索,将结果以列表的形式展现在页面上。使用户快速地找到自己需要的产品,提高用户购买转化率。
三级列表页的业务特点
涉及到多维度多因子质量分综合排序,排序质量的效果直接关系到转化率,客单价,进而影响到GMV,实质上是数据挖掘和机器学习技术在海量数据上的应用。
在不同三级类目下,通过复杂不确定的查询条件、属性区域和列表查询结果的实时联动,以及跟区域相关的库存、京东配送、货到付款等复杂业务逻辑下,做到高并发实时计算。
2.优化原因
三级列表页的页面周围依赖的内部系统太多,要做到异步化展示,阻塞可降级。
在持续开发一个核心系统过程中,除了满足业务需求外,还应该考虑系统未来的架构,追求极致的系统的可用性、高性能和稳定性。这个过程是一个长期积累和重构的过程,京东三级列表页的优化工作,就是这个过程的一部分。
优化前的状况
优化前的三级列表页有以下特点:
· 基于搜索实现;
· 全量数据,搜索结果不理想;
· 接口响应时间长,影响了用户体验;
· 没法针对数据做二次优化;
· 转化率相对较低;
· 基于以上原因,需要对三级列表页做出改变,也就是对老版本进行重构。
重构版本目的
通过优化,希望达到以下目的:
· 非全量数据,线下异步根据数据模型进行进行筛选部分最优数据;
· 要求实时过滤计算,接口响应时间要快,保证用户体验;
· 数据进行优化,提高转换率,提搞GMV;
· 实现前端降级和异步模块出错上报。
3.优化原则
每个应用都要满足自己特定的需求,因为其商业条件、应用场景、用户期望,以及功能复杂性各不相同。尽管如此,如果应用必须对用户作出响应,那我们就必须从用户角度来考虑可感知的处理时间这个常量。事实上,虽然生活节奏越来越快(至少我们感觉如此),但人类的感知和反应时间则一直都没有变过。
图3表格展示了Web性能社区总结的经验法则:必须在250 ms内渲染页面,或者至少提供视觉反馈,才能保证用户不走开。如果想让人感觉很快,就必须在几百ms内响应用户操作。超过1s,用户的预期流程就会中断,心思就会向其他任务转移,而超过10s,除非你有反馈,否则用户基本上就会终止任务!
图3
此次的优化工作遵循以下四个原则:
· 首屏优先:精简和瘦身页面,首屏优先展示出来;
· 惰性交互:需用户交互的部分惰性加载;
· 惰性执行:能不执行的先别执行,惰性执行;
· 惰性滚屏:滚屏惰性加载。
· 遵循这四个原则,进行了优化工作。
4.主要优化工作
(1)首屏优先
为了保证首屏优先展示,HTML文档进行了适当精简。
目的:尽快渲染出页面并达到可交互的状态。
方法:
如果非必须,尽量只生成首屏需要的HTML数据。
优先获取资源、提前解析。如首屏需要的CSS和JS;如果不考虑维护成本,可以把首屏需要的CSS和JS放到文档中。
发现和优先安排关键网络资源,尽早分派请求并取得页面。
文档精简后,服务端生成程序耗时短,性能才会好。
如图4所示,列表页的头、面包屑、品牌区、属性筛选区、60个商品主图数据,这些是服务端模板渲染输出;而剩余部分是在前端JS惰性加载或生成。
图4 图5
(2)惰性交互
惰性交互,即对需用户交互的部分进行惰性加载。
对于三级列表页品牌区,服务端只渲染18个品牌,用户在点更多时,AJAX异步加载其他的。对于整个属性是筛选区服务端只渲染5行,其他行用户在点更多时,JS从文档嵌入资源中取到数据,并渲染成HTML。这样做可以保证服务端计算量少,提升服务端性能,减少数据传输。
如图5,点“更多”时才加载更多的品牌,因为有些三级类目有非常多品牌,如果不采用这种方式,整个页面渲染非常慢。因为需要SEO的原因,京东三级列表页不能使用BigPipe等技术来进行更优的处理。
(3)惰性执行
能不执行的先别执行,惰性执行。
图6是三级列表页最重要的商品区(商品主图+N个关联商品小图),每个商品的区域都是完全一样的;如果在服务端拼装整个商品区域的话,尤其涉及到小图部分,会有非常多的重复HTML元素。
我们把体验和减少页面内容进行了折中处理:服务端渲染输出商品主图部分;小图部分通过Json数据嵌入到页面,然后通过JS惰性执行渲染。这样可以很好地对页面进行瘦身。而且小图资源是页面嵌入的,非异步加载;没有网络请求。因此,用户基本感知不到异步带来的渲染闪动问题。
图6就是页面嵌入的小图Json数据。
图6
(4)惰性滚屏
三级列表页的60个商品区域的图片和页尾都是当用户向下滚动页面时,才去加载当前屏幕中的图片和模块。这样可以节省服务器带宽和压力,提升页面整体渲染时间。
5.细节优化工作
在实际优化过程中,还涉及到非常多的优化细节。
将一些JS/CSS资源直接嵌入页面。
把资源嵌入文档可以减少请求的次数。比如页面需要的JS、CSS数据。如图7所示。
图7
图7中的这些JS对象,是后端渲染输出的,因此不适合放入单独的JS文件,直接在页面中嵌入输出会更好些。slaveWareList是小图的列表对象。如果放在服务端模板渲染输出的话,首先需要进行一些循环拼装页面;另外会使页面体积变得非常大。
权衡之后决定放到前端JS渲染输出。这样也带来了一些好处:
· 减轻服务端压力,提升渲染模板性能和减少服务端执行时间;
· 服务端不用生成HTML,文档减少上百个div,减少页面大小和网络开销;
· 提前放到文档中,不用异步调用;
· 用户基本感知不到渲染过程。
对引入的资源排定优先次序
根据自己系统的业务,对每种资源定优先级:对必需的资源优先加载,而低优先级的请求保存在队列中延时加载或等待必需资源加载完再加载;如:搜索推荐热词、顶部三个热卖商品接口、60个主商品的图片、价格优先加载。而对于库存、促销信息、广告词、预售商品、店铺信息等,延后加载。对于点击流,广告统计数据则延时两秒再加载。
应用JS缓存来存储公有属性和商品信息属性
三级列表页中的每个商品都是一个对象,存放在一个Map中,通过AJAX接口异步填充和维护商品的属性。用于后续用户交互用。同时维护成本也会降低;即页面中用到的每个商品数据放入一个map中,如果没有则异步加载;如果有直接使用;即这些数据是公共数据(见图8)。
图8
AJAX接口最优调用
页面往往依赖很多的异步接口,因此要对异步接口进行压测,找出接口的最优调用方式。如京东三级列表页依赖价格、库存、广告词、店铺信息等异步调用接口。而页面有时候会出现多达300多个商品,如果用一个get请求把这些sku做参数,性能非常慢,那么就要采用分组分批调用。如页面商品在300个时,价格接口分六组,第一组30个,第二组30个,第三组60个,第四组60个,第五组100个,第六组100个。
DNS预解析
对可能的域名进行提前解析,避免将来HTTP请求时的DNS延迟。如对价格、库存、图片、单品页等服务预解析。
减少HTTP重定向
HTTP重定向极费时间,特别是不同域名之间的重定向,更加费时;这里面既有额外的DNS查询、TCP握手,还有其他延迟。最佳的重定向次数为零。比如三级列表页以前是http://list.jd.com/9987-653-655.html,而现在是http://list.jd.com/list.html?cat=9987,653,655;在过渡期间可以重定向,但是过渡完成后就没必要重定向了。
使用CDN(内容分发网络)
把数据放到离用户地理位置更近的地方,可以显著减少每次TCP连接的网络延迟,增大吞吐量。比如京东三级列表页、商品详情页、公共JS、CSS。
传输压缩过的内容(Gzip压缩)
传输前应该压缩应用资源,把要传输的字节减至最少:确保对每种要传输的资源采用最好的压缩手段。所有文本资源都应该使用Gzip压缩,然后再在客户端与服务端间传输。一般来说,Gzip可以减少60%~80%的文件大小,也是一个相对简单(只要在服务器上配置一个选项),但优化效果较好的举措。(对于压缩级别,经过不同服务器多次压测,建议Nginx设置为1-4)
去掉不必要的资源
任何请求都不如没有请求快,把一些非必须的或者可异步的,或者可延迟的尽量延迟请求。
在客户端缓存资源
应该缓存应用资源,从而避免每次请求都发送相同的内容。对静态资源CSS/JS或变化不频繁的HTML块,可以放到前端localstorage。因为每次都传输一些不变的静态文件或者HTML,实在是太浪费了。
无状态域名
Cookie在很多应用中都是常见的性能瓶颈,很多开发者都会忽略它给每次请求增加的额外负担;减少请求的HTTP首部数据(比如HTTP cookie),节省的时间相当于几次往返的延迟时间。如列表页依赖的价格、库存接口,采用3.cn无状态域名,从而减少主域下cookie传输。
并行处理请求和响应
请求和响应的排队都会导致延迟,无论是客户端还是服务器端。这一点经常被忽视,但却会无谓地导致很长延迟。
域名分区
当页面中非常多请求都是一个域名下资源时,由于浏览器同时只能打开6个连接池,而且每个链接池是对不同域名起作用,所以很多请求一个域名会出现排队现象。如果把这些请求域名分区,让请求并行,从而加快资源下载。如:页面需要下载上百张图片,对图片进行域名分区调用。京东大部分页面都对图片进行了域名分区调用:
拼合和连接
合并链接:把多个JavaScript或CSS文件组合为一个文件。
拼合:把多张图片组合为一个更大的复合的图片(CSS Sprites)。
服务端写相关信息到header
把服务器IP后两位写到header,如果有问题,方便定位哪台服务器。ups:后端路由的所有服务器都取到。把缓存命中信息或异常走兜底了,把后端运行状态写到header。Head-status:命中、未命中、异常等状态(见图9)。
图9
6.降级方案和异步模块出错上报功能的实现
降级方案
主动降级
页面依赖很多AJAX异步接口服务,难免保证这些服务从不出错。所以在调用这些接口服务时都提前判断该接口开关是否开启,如果开关关闭则不调用该接口服务。页面不展示相关模块。保证在一个接口服务出问题时,我们可以快速降级。
被动降级
当某个异步接口服务返回非200状态码、请求超时、数据格式不正确等异常,就会被动隐藏或不展示相应模块。最上面三个热卖商品依赖的广告服务出问题时,会把每个三级分类对应的三个兜底商品展示出来,防止开天窗。对于其他模块因为是商品的属性,暂时做隐藏处理。
上报模块错误
当页面被动降级了,js就会上报该模块,后台程序记录并报警。同时也会上报js运行中出错的信息。记录什么浏览器,哪个版本,什么错误。我们会对这些问题验证和修改。保证每个用户都能访问。
Web性能监控
为什么要做Web性能监控,因为页面可能放在CDN,前端JS执行很多业务逻辑不知道运行情况,整个链路网络偶尔不稳定、页面依赖的模块和第三方异步服务多人工难以实时监控等,这些情况请求还没有到后端就可能出问题,所以后端监控无能为力。
前端监控分两个方向:用WebKit内核模拟浏览器,定时抓取设定的页面;前端JS植入监控。
用WebKit内核模拟浏览器,定时抓取设定的页面
该Web监控项目采用一个中心服务,多个终端服务来完成大量页面抓取和校验。
部署到全国各个机房,实时监控页面是否打开正常(请求超时、返回非200)、页面HTML关键元素是否丢失,页面是否出现乱码等。
每个终端定时向中心服务请求需要处理的页面URL和该页面需要验证的规则。如果验证不通过,则记录下来并报警。同时会保存现场(HTML文档、页面截图)。
该项目在这次618起到很重要的作用,页面出现任何问题,都会提前检测出来。
前端JS植入监控
该JS统计页面白屏时间、首屏加载时间、每个AJAX异步方法调用耗时和请求状态码。
同时也会上报异步模块降级了,JS运行中错误信息等。
埋点统计
京东列表页的埋点主要是来统计用户点击当前页面位置记数,帮助广告系统、业务、产品经理后续的工作。
埋点数据上报,就是通过onclick发送AJAX请求到后端服务。
其中对于点击后刷新当前页面的情况,需要在新页面记录上次点击的位置。因为在当前页面点击后上报AJAX方法还没执行就关闭当前窗口加载点击后的URL了。
下图是点击流插件的统计,数据敏感不做展示,大家只看功能(见图10)。
图10
7.总结
用时
此次重构的时间段为:2014年12月到2015年4月。
效果
京东三级列表页从优化到上线,已经经历了两个618和一个双11的考验,每天有上亿的访问量,页面打开时间在20~80毫秒(在某些地区或低带宽下会大于100ms)。
后端方法调用tp99的性能数据如图11所示。
图11
心得
列表页从开始200+ms到现在100ms内,QPS单台机器几百到现在的近万,页面从1MB到现在200KB内,包扩后台系统的拆分,逻辑算法后移、后台实时计算等优化。是需要有匠人的精神精雕细琢。
列表页每周都会根据业务方和产品经理的需求在开发功能。对于每个功能点都要深入思考,列出多种方案,最终选择一个简单、易维护、不影响系统性能、不降低用户体验的方案。这个过程要不断思考、或请教有这方面经验的人、包括参考外部公司的方案。有趣的是可能晚上突发奇想就有更好的方案。
中间也遇到无数的坑。对于每次遇到各种问题,必须想方案避免再次出现。同时要分析Nginx日志,分析每个请求,进而对爬虫、恶意参数访问、恶意请求做相应处理。这些都是前端服务必做的。当然后端服务也是非常重要的,后续会有列表页量身打造的缓存(加速、抗大流量、多样化兜底基础数据)、服务端架构、自动降级、架构高可用等方案。
作者简介
王向维,京东商城三级列表页架构师。工作期间,完成了京东三级列表页由Node.js版本到Nginx+Lua版本的变迁,并针对三级列表页前端即服务器端做了大量的优化工作。