双十一大型电商统一服务架构实战

weedw 8年前
 

张开涛

2014年加入京东,主要负责商品详情页、详情页统一服务架构与开发工作,设计并开发了多个亿级访问量系统。工作之余喜欢写技术博客,有《跟我 学Spring》、《跟我学Spring MVC》、《跟我学Shiro》、《跟我学Nginx+Lua开发》等系列教程,博客访问量500万。开涛之前在高可用架构群分享《亿级商品详情页架构演 进技术解密》,受到高度关注,文章阅读数超过1.8万,关注本群公众号,回复29可查看。

京东商品详情页技术方案在之前《亿级商品详情页架构演进技术解密》这篇文章已经为大家揭秘了,接下来为大家揭秘下双十一抗下几十亿流量的商品详情页统一服务架构。

这次双十一整个商品详情页在流量增长数倍的情况下,没有出现不可用的情况,服务非常稳定。

统一服务提供了:促销和广告词合并服务、库存状态/配送至服务、延保服务、试用服务、推荐服务、图书相关服务、详情页优惠券服务、今日抄底服务等服务支持。

其实就是一个大的网关,提供各种服务。但区别于一般网关,它还提供业务逻辑的处理。这些服务中有我们自己做的服务实现,而有些是简单做下代理或者 接口做了合并输出到页面,我们聚合这些服务到一个系统的目的是打造服务闭环,优化现有服务。并为未来需求做准备,跟着自己的方向走,而不被别人乱了我们的 方向。这样一起尽在自己的掌控之中,想怎么玩就看心情了。

大家在页面中看到的c.3.cn/c0.3.cn/c1.3.cn/cd.jd.com请求都是统一服务的入口。我们分别为有状态服务和无状态服务提供了不同的域名,而且域名还做了分区。

为什么需要统一服务?

商品详情页虽然只有一个页面,但是依赖的服务众多,我们需要把控好入口,一统化管理。

这样的好处:

  • 统一管理和监控,出问题可以统一降级
  • 可以把一些相关接口合并输出,减少页面的异步加载请求
  • 一些前端逻辑后移到服务端,前端只做展示,不进行逻辑处理。

有了它,所有入口都在我们服务中,我们可以更好的监控和思考我们页面的服务,让我们能运筹于帷幄之中,决胜于千里之外。

在设计一个高度灵活的系统时,要想着当出现问题时怎么办:是否可降级、不可降级怎么处理、是否会发送滚雪球问题、如何快速响应异常。完成了系统核心逻辑只是保证服务能工作,服务如何更好更有效或者在异常情况下能正常工作也是我们要深入思考和解决的问题。

整体架构

整体流程

  1. 请求首先进入Nginx,Nginx调用Lua进行一些前置逻辑处理,如果前置逻辑不合法直接返回;然后查询本地缓存,如果命中直接返回数据。
  2. 如果本地缓存不命中数据,则查询分布式Redis集群,如果命中数据,则直接返回。
  3. 如果分布式Redis集群不命中,则会调用Tomcat进行回源处理;然后把结果异步写入Redis集群,并返回。

如上是整个逻辑流程,可以看到我们在Nginx这一层做了很多前置逻辑处理,以此来减少后端压力,另外我们Redis集群分机房部署,如下图所示:

即数据会写一个主集群,然后通过主从方式把数据复制到其他机房,而各个机房读自己的集群;此处没有在各个机房做一套独立的集群来保证机房之间没有交叉访问,这样做的目的是保证数据一致性。

在这套新架构中,我们可以看到Nginx+Lua已经是我们应用的一部分,我们在实际使用中,也是把它做为项目开发,做为应用进行部署。

我们主要遵循如下几个原则设计系统架构:

  • 两种读服务架构模式
  • 本地缓存
  • 多级缓存
  • 统一入口/服务闭环
  • 引入接入层
  • 前端业务逻辑后置
  • 前端接口服务端聚合
  • 服务隔离

两种读服务架构模式

  • 读取分布式Redis数据架构

可以看到Nginx应用和Redis单独部署,这种方式是一般应用的部署模式,也是我们统一服务的部署模式,此处会存在跨机器、跨交换机或跨机柜 读取Redis缓存的情况,但是不存在跨机房情况,因为使用容器化,不太好做不跨机柜的应用了。通过主从把数据复制到各个机房。如果对性能要求不是非常苛 刻,可以考虑这种架构,比较容易维护。

  • 读取本地Redis数据架构

可以看到Nginx应用和Redis集群部署在同一台机器,这样好处可以消除跨机器、跨交换机或跨机柜,甚至跨机房调用。如果本地Redis集群 不命中, 还是回源到Tomcat集群进行取数据。此种方式可能受限于TCP连接数,可以考虑使用unix domain socket套接字减少本机TCP连接数。如果单机内存成为瓶颈(比如单机内存最大256GB),就需要路由机制来进行Sharding,比如按照商品尾 号Sharding,Redis集群一般采用树状结构挂主从部署。

本地缓存

我们把Nginx作为应用部署,因此大量使用Nginx共享字典作为本地缓存,Nginx+Lua架构中,使用HttpLuaModule模块的 shared dict做本地缓存( reload不丢失)或内存级Proxy Cache,提升缓存带来的性能并减少带宽消耗。

之前的详情页架构也是采用这种缓存。另外使用一致性哈希(如商品编号/分类)做负载均衡内部对URL重写提升命中率。

在缓存数据时采用了维度化存储缓存数据,增量获取失效缓存数据(比如10个数据,3个没命中本地缓存,只需要取这3个即可)。维度如商家信息、店铺信息、商家评分、店铺头、品牌信息、分类信息等;比如本地缓存30分钟,调用量减少差不多3倍。

另外使用一致性哈希+本地缓存,如库存数据缓存5秒,平常命中率:本地缓存25%;分布式Redis28%;回源47%。一次普通秒杀活动命中 率:本地缓存 58%;分布式Redis 15%;回源27%。而某个服务使用一致哈希后命中率提升10%。对URL按照规则重写作为缓存KEY,去随机,即页面URL不管怎么变都不要让它成为缓 存不命中的因素。

多级缓存

对于读服务,在设计时会使用多级缓存来尽量减少后端服务压力,在统一服务系统中,设计了四级缓存,如下图所示:

  • 首先在接入层,会使用Nginx本地缓存,这种前端缓存主要目的是抗热点;根据场景来设置缓存时间。
  • 如果Nginx本地缓存不命中,接着会读取各个机房的分布式从Redis缓存集群,该缓存主要是保存大量离散数据,抗大规模离散请求,比如使用一致性哈希来构建Redis集群,即使其中的某台机器出问题,也不会出现雪崩的情况。
  • 如果从Redis集群不命中,Nginx会回源到Tomcat;Tomcat首先读取本地堆缓存,这个主要用来支持在一个请求中多次读取一个 数据或者该数据相关的数据。而其他情况命中率是非常低的,或者缓存一些规模比较小但用的非常频繁的数据,如分类,品牌数据;堆缓存时间设置为Redis缓 存时间的一半。
  • 如果Java堆缓存不命中,会读取主Redis集群,正常情况该缓存命中率非常低,不到5%。读取该缓存的目的是防止前端缓存失效之后的大量 请求的涌入,导致后端服务压力太大而雪崩。默认开启了该缓存,虽然增加了几毫秒的响应时间,但是加厚了防护盾,使服务更稳当可靠。此处可以做下改善,比如 设置一个阀值,超过这个阀值才读取主Redis集群,比如Guava就有RateLimiter API来实现。

统一入口/服务闭环

在《亿级商品详情页架构演进技术解密》中已经讲过了数据异构闭环的收益,在统一服务中也遵循这个设计原则,此处主要做了两件事情

  1. 数据异构,如判断库存状态依赖的套装、配件关系进行了异构,未来可以对商家运费等数据进行异构,减少接口依赖。
  2. 服务闭环,所有单品页上用到的核心接口都接入统一服务。

有些是查库/缓存然后做一些业务逻辑,有些是http接口调用然后进行简单的数据逻辑处理;还有一些就是做了下简单的代理,并监控接口服务质量。

引入Nginx接入层

在设计系统时需要把一些逻辑尽可能前置以此来减轻后端核心逻辑的压力。另外如服务升级/服务降级能非常方便的进行切换,在接入层做了如下事情:

  • 数据校验/过滤逻辑前置、缓存前置、业务逻辑前置
  • 降级开关前置
  • AB测试
  • 灰度发布/流量切换
  • 监控服务质量
  • 限流

服务有两种类型的接口:一种是用户无关的接口,另一种则是用户相关的接口。因此使用了两种类型的域名c.3.cn/c0.3.cn/c1.3.cn和cd.jd.com。

当请求cd.jd.com会带着用户cookie信息到服务端。在服务器上会进行请求头的处理,用户无关的所有数据通过参数传递,在接入层会丢弃所有的请求头(保留gzip相关的头)。

而用户相关的会从cookie中解出用户信息然后通过参数传递到后端;也就是后端应用从来就不关心请求头及Cookie信息,所有信息通过参数传递。

有些是查库/缓存然后做一些业务逻辑,有些是http接口调用然后进行简单的数据逻辑处理;还有一些就是做了下简单的代理,并监控接口服务质量。

请求进入接入层后,会对参数进行校验,如果参数校验不合法直接拒绝这次请求。对每个请求的参数进行了最严格的数据校验处理,保证数据的有效性。

如图所示,我们对关键参数进行了过滤,如果这些参数不合法就直接拒绝请求。另外还会对请求的参数进行过滤然后重新按照固定的模式重新拼装URL调度到后端应用。此时URL上的参数是固定的而且是有序的,可以按照URL进行缓存

缓存前置

很多缓存都前置到了接入层,来进行热点数据的削峰,而且配合一致性哈希可能提升缓存的命中率。在缓存时按照业务来设置缓存池,减少相互之间的影响和提升并发,使用Lua读取共享字典来实现本地缓存。

业务逻辑前置

在接入层直接实现了一些业务逻辑,原因是当在高峰时出问题,可以在这一层做一些逻辑升级。

后端是Java应用,当修复逻辑时需要上线,而一次上线可能花费数十秒时间启动应用。重启应用后Java应用JIT的问题会存在性能抖动的问题。 可能因为重启造成服务一直启动不起来的问题。而在Nginx中做这件事情,改完代码推送到服务器,重启只需要秒级,而且不存在抖动的问题。这些逻辑都是在 Lua中完成。

降级开关前置

降级开关分为这么几种:

  • 接入层开关和后端应用开关。在接入层设置开关的目的是防止降级后流量还无谓的打到后端应用。
  • 总开关是对整个服务降级,比如库存服务默认有货。
  • 原子开关是整个服务中的其中一个小服务降级,比如库存服务中需要调用商家运费服务,如果只是商家运费服务出问题了,此时可以只降级商家运费服务。

另外还可以根据服务重要程度来使用超时自动降级机制。使用init_by_lua_file初始化开关数据,共享字典存储开关数据。提供API进 行开关切换(switch_get(“stock.api.not.call”) ~= “1”)。可以实现:秒级切换开关、增量式切换开关(可以按照机器组开启,而不是所有都开启)、功能切换开关、细粒度服务降级开关、非核心服务可以超时自 动降级。

比如双十一期间有些服务出问题了,进行过大服务和小服务的降级操作,这些操作对用户来说都是无感知的。有一个后台可以直接控制开关,切换非常方便。

AB测试

对于服务升级,最重要的就是能做AB测试,然后根据AB测试的结果来看是否切新服务。这一块算是家常便饭。有了接入层非常容易进行这种AB测试;不管是上线还是切换都非常容易。

可以动态改Nginx配置、推送然后reload。也可以在Lua中根据请求的信息调用不同的服务或者upstream分组即可完成AB测试。因为有了Lua,可以实现复杂逻辑的编程,比如根据商品属性进行切换。

灰度发布/流量切换

对于一个灵活的系统来说,能随时进行灰度发布和流量切换是非常重要的一件事情。比如验证新服务器是否稳定,或者验证新的架构是否比老架构更优秀,有时候只有在线上跑着才能看出是否有问题。

在接入层也是通过配置或Lua代码来完成这些事情。灵活性非常好,可以设置多个upstream分组,然后根据需要切换分组即可。

监控服务质量

对于一个系统最重要的是要有双眼睛能盯着系统来尽可能早的发现问题。在接入层会对请求进行代理,记录status、request_time、 response_time来监控服务质量,比如根据调用量、状态码是否是200、响应时间来告警。 这些都是通过Nginx子请求记录的。

限流

系统中存在的主要限流逻辑是:

  • 对于大多数请求按照IP请求数限流,对于登陆用户按照用户限流
  • 对于读取缓存的请求不进行限流,只对打到后端系统的请求进行限流
  • 还可以限制用户访问频率,比如使用ngx_lua中的ngx.sleep对请求进行休眠处理,让刷接口的速度降下来或者种植cookie token之类的,必须按照流程访问。当然还可以对爬虫/刷数据的请求返回假数据来减少影响。

前端业务逻辑后置

前端JS应该尽可能少的业务逻辑和一些切换逻辑,因为前端JS一般推送到CDN。假设逻辑出问题了,需要更新代码上线,推送到CDN然后失效各个 边缘CDN节点或者通过版本号机制在服务端模板中修改版本号上线。这两种方式都存在效率问题,假设处理一个紧急故障用这种方式处理完了可能故障也恢复了。

因此我们的观点是前端JS只拿数据展示,所有或大部分逻辑交给后端去完成,即静态资源CSS/JS CDN,动态资源JSONP。前端JS瘦身,业务逻辑后置。这种方案可能在我们的场景下更适用。

在双十一期间某些服务出问题了,不能更新商品信息。此时秒杀商品如果不打标就不能购买。因此我们在服务端完成了这件事情,整个处理过程只需要几十 秒就能搞定,避免了商品不能被秒杀的问题。而如果在JS中完成需要耗费非常长的时间,因为JS在客户端还有缓存时间,而且一般缓存时间非常长。这也是详情 页动态化后能灵活的应对各种问题。

前端接口服务端聚合

商品详情页上依赖的服务众多,一个类似的服务需要请求多个不相关的服务接口,造成前端代码臃肿,判断逻辑众多。而我无法忍受这种现状,我想要的结果就是前端异步请求我的一个API,我把相关数据准备好发过去,前端直接拿到数据展示即可。

所有或大部分逻辑在服务端完成而不是在客户端完成。因此在接入层使用Lua协程机制并发调用多个相关服务然后最后把这些服务进行了合并。比如推荐服务:最佳组合、推荐配件、优惠套装。

通过

http://c.3.cn/recommend?methods=accessories,suit,combination&sku=1159330&cat=6728,6740,12408&lid=1&lim=6

进行请求获取聚合的数据。这样原来前端需要调用三次的接口只需要一次就能吐出所有数据。我们对这种请求进行了API封装,如下图所示 :

比如库存服务,判断商品是否有货需要判断:1、主商品库存状态、2、主商品对应的套装子商品库存状态、主商品附件库存状态及套装子商品附件库存状态。

套装商品是一个虚拟商品,是多个商品绑定在一起进行售卖的形式。如果这段逻辑放在前段完成,需要多次调用库存服务,然后进行组合判断,这样前端代 码会非常复杂,凡是涉及到调用库存的服务都要进行这种判断。因此我们把这些逻辑封装到服务端完成。另外对这些关系数据进行了异构存储,减少与其他系统进行 交互带来的未知性能开销。这样只需要读取自己的Redis就能拿到关系。

前端请求

http://c0.3.cn/stock?skuId=1856581&venderId=0&cat=9987,653,655&area=1_72_2840_0&buyNum=1&extraParam={%22originid%22:%221%22}&ch=1&callback=getStockCallback

然后服务端计算整个库存状态,而前端不需要做任何调整。

在服务端使用Lua协程并发的进行库存调用,如下图所示 :

另外接口也会吐出相关的附件/套装的库存状态,方便前台万一需要时使用。

另外比如今日抄底服务,调用接口太多,如库存、价格、促销等都需要调用,因此也使用这种机制把这几个服务在接入层合并为一个大服务,对外暴露。

http://c.3.cn/today?skuId=1264537&area=1_72_2840_0&promotionId=182369342&cat=737,752,760&callback=jQuery9364459&_=1444305642364

整个详情页的未来就是首屏尽量一个大服务提供所有数据,各子接口设置超时时间,在超时后,单独发送一次请求查询相关数据。

目前合并的主要有:促销和广告词合并、配送至相关服务合并。

服务隔离

服务隔离的目的是防止因为某些服务抖动而造成整个应用内的所有服务不可用。

可总结为:

  • 应用内线程池隔离
  • 部署/分组隔离
  • 拆应用隔离

应用内线程池隔离,采用了Servlet3异步化,并为不同的请求按照重要级别分配线程池,这些线程池是相互隔离的。也提供了监控接口以便发现问 题及时进行动态调整,该实践可以参考我博客的《商品详情页系统的Servlet3异步化实践》。目前Java系统也全面升级为 JDK8+Servlet3。

部署/分组隔离,意思是为不同的消费方提供不同的分组,不同的分组之间不相互影响,以免因为大家使用同一个分组导致有些人乱用导致整个分组服务不可用。

拆应用隔离,如果一个服务调用量巨大,那便可以把这个服务单独拆出去,做成一个应用,减少因其他服务上线或者重启导致影响本应用。

其他

对于http 304一些服务都设置了,如延保,设置了一个容忍时间,如果在这段时间内重复请求,会直接返回304,而不查缓存或回源处理。

超时时间/重试,对于一个系统来说,超时时间是必须设置的,主要有如TCP连接/读/写超时时间、连接池超时时间,还有相关的重试时机/次数。对 于一个网络服务,需要根据实际情况设置TCP连接/读/写超时时间,如果不设置很可能会在网络出问题时服务直接挂断,比如连接Redis都设置在 150ms以内。

还有如果使用Nginx,还需要考虑Nginx超时时间、upstream超时时间和业务超时时间。假设Nginx超时时间是5s、 upstream超时时间是6s,而业务的超时时间是8s,那么假设业务处理了7s,那么upstream超时时间到了需要进行下一个upstream的 重试,但是Nginx总的超时时间是5s,所以就无法重试了

连接池也需要设置获取连接的等待时间,如果不设置会有很多线程一直在等待,之前自己实现连接池时,会去发现是否网络错误,如果网络错误就没必要等待连接了,而立即失败即可。

还需要考虑服务失败重试时机,比如是立即重试,还是指数式重试;还有重试次数,是一次性重试三次还是指数式等待时间后进行一次次的重试。比如HttpClient(DefaultHttpRequestRetryHandler)默认会立即进行三次重试。

CDN缓存评价使用了版本化。对于版本化的意思是对于一些内容可以通过版本化机制设置更长的超时时间,而且固定URL顺序,这样相同的URL请求只要版本不变就会更有效的命中CDN数据。

版本可以通过消息推送机制通知相关系统。比如评价数据的版本化,可以每隔几分钟推送变更了版本的商品更新版本号,还要注意消息排重。另外为了发现 是哪台服务器出现了问题,都要在响应响应头记录服务器真实IP。这样出问题后,直接定位到哪台服务器出问题了。还有如对于整个系统无法保证整个系统不出现 脏数据,所以需要提供刷脏数据的接口,以便出现问题时进行数据的更新或删除。

Q&A

Q1 : 逻辑由前段向后端迁移后,后端压力对比上升了多少?还是降低了?

我个人从来不认为后端会有压力,我们的某服务响应时间在10-100ms之间,8CPU容器压测在10000~20000qps。另外后端是比较容易水平扩展的。

Q2 : 获取多维度数据时,部分维度需要回源,这时候逻辑上会阻塞。是如何做到大并发下的高吞吐?基于事件驱动网络IO?协程?

Nginx的非阻塞IO+Lua协程;另外我们使用Nginx共享字典,按照不同的维度开辟共享字典,对于大流量的可以做sharding,目前没有做sharding,只是按照维度分离共享字典,防止LRU和锁竞争。

Q3 : 多级缓存如何保证一致性?

1、缓存数据时间都非常短;2、有些可以持久化的数据是通过消息通知变更推送;3、不能保证100%没问题,也提供了清理接口进行清理。

Q4 : 接入层的本地缓存用啥存的也是redis么?

ngx_lua提供的共享字典,目前使用中没有遇到任何问题,reload不丢,减少了部署Redis的繁琐。也考虑过Java堆外缓存使用Local Redis或Local Nginx。

Q5 : “另外对这些关系数据进行了异构存储,减少与其他系统进行交互带来的未知性能开销 这样只需要读取自己的Redis就能拿到关系”这个异构存储是怎么维护和更新的?

数据都是KV存储,复杂的数据通过Redis Lua脚本减少交互。异构数据都是通过消息对接的。

Q6 : 请问商品数量这种变化相对较快的数据如何缓存的,如何防止超卖,特别对于热门商品?

超卖通过库存系统的服务进行控制,详情页是前端展示库存,主要是展示商品、价格、库存都是通过消息通知变更的。

Q7 :秒杀预热,开始,结束后:几个阶段的库存,价格相关信息如果管理?如何防止超卖和缺货?

秒杀是单独的服务实现,跟详情页是分开实现;另外据我知道的 1、秒杀是和主交易流程隔离的,防止相互影响;2、库存扣减通过Redis。

Q8 :聚合服务的超时时间如何设置?根据所有依赖服务的超时进行设置?

几个点:1、Nginx proxy timeout;2、客户端timeout,如HttpClient;3、每个服务都是单独设置超时时间。

Q9 :服务的超时时间统一管理么,某个业务超时时间变化可能影响很大,怎么控制?

根据业务去控制,不同的业务要求不一样;比如库存/配送至依赖的商家配送时效,正常100ms以内,如果超过这个时间,排除网络问题,相关系统肯定出问题了,可以进行优雅的降级。

Q10 :用户鉴权也是用ngx_lua共享字典做的么?用户信息这种敏感信息如果在接入暴露应该会产生安全隐患,但不在接入做放到后面核心域又会拖慢返回速度?

我们封装了一个API,专门在接入层对一些服务进行鉴权,不对用户信息的输出和暴露,这样能更好的控制而且可以尽早的发现非法请求;拖慢速度的情况,因为在接入层验证,这个就不是问题了。

Q11 :秒杀的商品库存 跟正常商品库存是分开的吗?

对,包括整个交易流程也是分离的,可以走下流程就看到了,而且必须按照正常用户步骤去访问才可以。