May 29

高并发下的客户端业务开发讨论

Lrdcq , 2021/05/29 15:27 , 程序 , 閱讀(1195) , Via 本站原創
业务客户端开发,在某些业务领域总会遇到高并发问题。这里并不是指的日常访问流量高并发,比如本来应用访问流量大QPS高并不是问题。而是特指的用户业务流程高并发影响大:交易系统订单秒杀如何处理?营销推送同时触达怎么处理?紧急业务状态变更证明处理?当然这些问题在服务端高并发的解决方案中均有涉及,但是随着治理与优化的深入一定会到客户端这边来。同时客户端还有一个问题分发滞后性,假如1个月之后双11我们要提前进行并发优化储备,到双11应用覆盖面能有80%顶天了。

因此本文讨论的是,具有高并发业务特征的客户端,如何在业务开发设计阶段针对这一特点做好开发储备,防范于未然。

实际在业务中观测到的结果来看,电商业务秒杀类业务场景即可带来10倍数量级的突发流量,并且在全业务流程(查阅,加购,下单,售后)上放大。而一些特殊活动如业务冲刺(购物节),特殊操作如批量进行运营位配置导致的变更,都可能导致个别或者整体并发流量激增,业务流程压力增加。回到主题,客户端上对高并发的储备其实很简单,归结起来主要三点:压峰,打通信道,降级。下面围绕这三点展开讨论。

压峰

说到压峰,当然是减少服务请求压力。首要我们要整理清楚客户端到底会给服务端带来哪些压力,哪些压力真的会在高并发是成为问题(比如业务雪崩)。一般客户端到服务侧的链接有3类:

1. API请求,直接请求到业务服务上(java/php等)。绝大部分是关系到业务核心流程的,不过偶尔也会有一些无关紧要的Api,比如站内信,推荐列表等。如果未做微服务拆分即API会请求到枢纽服务上,Gateway爆炸会直接导致不可用,同样如果基础未服务承载不足,也会导致全体业务雪崩。另外也要考虑到均衡负载配置与限流不合理的情况,突发单机崩溃也会导致系统雪崩。因此API请求的压峰是最核心的点。

點擊在新視窗中瀏覽此圖片

2. 长链接,一些特殊的业务场景会从客户端到服务端保持特殊协议的长链接。有比如音频数据(比如会议通话应用)这样的特定协议,也有单纯的信息传输长链接如Android做推送使用的长链通道。长链服务是无法直接做均衡复杂的,因此一般还会配备一个赛马服务或类似于ping服务的东西来做ip选取服务(比较粗暴的方式是客户端硬编码一堆ip,每次启动跑马选取最合适的ip连接)。一般来说长链接不会真的用在关键服务上或者具有降级手段,另外跑马服务只要不挂,可以通过它去完成均衡或者拒接访问,因此长链接请求风险相对可控。

點擊在新視窗中瀏覽此圖片

3. CDN请求。原理上CDN拥有dns均衡负载与简易的扩容方案,应该能够承载超大量的并发请求,不会考虑成为大流量瓶颈。不过基于其大流量友好性,很多关键前端服务会部署在CDN上,包括:目前绝大部分静态web前端服务都会部署在cdn上;客户端配置文件大部分会配置在cdn上可能会涉及到关键业务。

基于以上信息,压峰的关键目标显然是API请求。如何在关键场景压峰,我们来review一下一次典型的雪崩的完整经过:

Case经典雪崩。雪崩的开端是预期外的秒杀业务瞬间流量,有两台机器由于fullgc问题瞬间down掉,2分钟后无法反馈心跳机制被下线,同时另外两台机器瞬间被打爆(同样无响应)。同时开始执行扩容与现有机器重启,重启速度较快但是单台机器重启完成后会被瞬间打爆,扩容的情况也类似。最后将所有流量阻止,完成机器重启并完成扩容,最后再打开流量,完成一次雪崩处理。这个过程中观测到引发雪崩的流量是日常流量的10倍左右,并且服务出现异常后还上升了大约60%,即最大访问量达到日常的16倍。

解析这个case,从前端角度有两部分,由于业务秒杀执行,带来了10倍的瞬发流量,这是用户真实操作的流量肯定是无法避免的,因此目标上是通过策略将该流量单位用户对服务的影响降到最低;而出问题后用户不断重试引起的流程可以视为异常流量,该流量应该是想办法尽可能的削掉。

1. 正常流程请求缩减

针对上面这个case,考虑到运营策略,我们考虑下用户正常访问流程,即用户根据事前得到的消息,或者推送通知,在假设下午3点,一起打开app进入首页并进入秒杀落地页。用户在该页面的习惯性行为是尝试刷新直到秒杀开放。刷新的方式包括下拉刷新与退出页面重新进入。到点后开始点击秒杀,由于确实可能会响应比较慢,铁定会连续点击或者继续尝试刷新,直到给出明确结果(秒杀成功进入提单页or已售空),然后进入支付流程。

可见,实际API服务带来压力的并不只是秒杀页本身,同时还包括首页(外侧页面)的API,具体描述请求时间轴的话,重点请求包括:
點擊在新視窗中瀏覽此圖片

- 下层页面第一次加载:这个加载应该逃不掉。但是一群人同时打开app产生的高并发场景同样也是问题,之前在另一个case中,运营同时给全国发送了看起来非常吸引人的推送,然后大量用户瞬间点击推送进入app涌入的流程也造成了高峰期首页访问约6倍的瞬时访问量,该访问最后的优化结论是:

a. 首页访问服务端应该尽可能的扛住。但是在扛不住时应快速拒绝服务避免雪崩。
b. 客户端首页建立完善的缓存机制,在请求失败时有缓存信息可以展示。
c. 关于非关键接口延后请求,延后请求无法解决服务端压力问题,客户端按正常启动时序即可。
d. 合并接口,简化接口数据内容与业务逻辑——前后端共同协作降低首页接口访问成本。包括按业务领域与首页展示优先级接口合并,静动数据分离(不要求100%实时性的数据redis化独立接口),降低服务端数据处理复杂度(可以接受一些渲染边界处理客户端实现)。

- 下层页面刷新:页面返回时刷新是移动端常见页面行为,但是来回刷新确实会产生大量请求。因此对该业务逻辑,进行限流过滤处理,视业务情况与重要性,比如通知类API最小间隔1分钟重复请求,订单类API最小间隔10s重复请求,来完成对用户来回刷的场景的处理。

- 核心页面访问:秒杀页的第一次访问和首页一致,是不可避免的,但是作为活动类,或者有明确时间点可预知访问情况的二三级页面,基础信息与资源预加载显然可行:

a. 页面资源与基础业务信息预加载:每次进入只请求关键信息(比如秒杀页面是秒杀状态,剩余库存等),其他信息(基础api)提前预载到磁盘上。
b. 关键信息接口通信双向化而不是轮询(见打通信道部分),避免页面自动轮询。

- 核心页面刷新:因此在核心页面反复刷新的场景,也只剩下核心API了,加上API服务侧的优化,可以把影响范围降低到最小。

针对秒杀雪崩的case,提炼出了以上通用性的方案,当然不限于此,正常的API访问涉及还有以下注意的:

- API设计避免嵌套循环,控制数据范围,如一个二级商品列表的推荐API数据,需要关注的点即:a. 列表数据返回展示数据与预载数据,再用户触发上下左右切换时继续双向补全。b. 商品卡片信息最小化,只包含卡片需要信息,甚至为了兼容不同设备导致的信息差距也直接切割开(web端与移动端按需求切分API)。c. API的数据查询避免跨领域的联合查询,当然前提是领域拆分合理。

- 除了API之外,客户端请求发起的方案也需要处理:a. UI上进行截流/限频限制,避免重复请求触发。b. 控制用户手动触发刷新的手段,避免用户使用手动刷新或者一定程度做假的用户刷新。c. 按业务重要程度铺开前端缓存使用。

- 另外,系统整体设计上也进行了一些迭代实践:a. 划分核心业务领域,对核心业务领域的API进行重点设计与资源投入,保障其实际服务可用性6个9以上。也对核心流程进行了白盒前端自动化测试,脚本模拟用户访问流程进行高强度访问来模拟压测。b. 客户端侧还是需要理解对核心流程压力分担的诉求,业务有边界职位没边界,客户端侧的分发实时性与数据实时性诉求不应通过服务侧解决,而是通过前端资源,cdn资源解决(因此大力推进纯cdn部署的动态化方案与快速上线方案)。

以上正常API访问的整体思路可以用以下这张图来表示:
點擊在新視窗中瀏覽此圖片

2. 异常流量削除

怎么定义异常流量,即系统预期之外的用户访问。更具体的描述,我们需要削除的是发生任何异常(比如服务端返回50x)之后,用户手动或者自动访问的多余流量。并且该能力应该是可以实时控制的。整体的思路沿着引导-约束-配置化的角度进行。

- 引导:阻止用户预期之外的访问,最简单的方式即是前端错误文案引导用户:“服务器异常,请稍后再试”并且在重试按钮上做一定阻塞。这里有一点是服务端日常也会有偶发的500错误,怎么判断服务器发生了整体雪崩呢?进行前后端两层约束:

a. API与服务端约定,如果是catchable服务异常,务必不能返回500,客户端视为“日常”的服务异常;而如果出现restful500或者httpcode500并且无法解析,视为发生可能雪崩。

b. 前端对服务端异常行为进行判断,基于日常50x应该是偶发case的考虑,同一个接口连续出现2次500,即判断该接口出现雪崩,前端网络库返回雪崩500异常而不是normal异常;同样如果不同接口连续出现3次500,视为系统雪崩,前端网络库会为所有后续500的请求返回雪崩500异常。前端页面根据异常进行阻塞性展示。

- 重试限制:一般在关键流程的异常界面处理上,偶尔会加上自动重试,基于的case是:系统初建时第一次雪崩恢复后,仍有大量用户反馈未恢复,客服引导后发现部分用户不会刷新(部分页面需要切换刷新),不会重启app重试。因此在关键页面流程上加入了自动重试:如果遇到了500,用户待在错误界面,会尝试静默重试接口,直到刷新出来。同时考虑到限流问题,对重试进行了限制:

a. 前台重试原则:只重试用户正在查看的接口数据,后台页面接口失败不进行重试,等到用户切换到相应页面才开始进行。

b. 重制间隔延长原则:无限重试是一个很危险的事情,考虑到如果是偶发应该能快速恢复,如果是雪崩,应该会在10分钟以上才能恢复,重试的间隔会以10s,10s,30s,60s,120s,180s,300s...这样延长,一开始会快速重试2次(即共计访问了3次),如果均失败,会开始拉长重试间隔,最后会重复到300s即5分钟间隔。当然用户重启App会破坏这个逻辑,但是实际观测在这个场景尝试重启app的用户不会太多,所以可控。

c. 重试范围最小化原则:原则上就算是核心流程,重试的接口应该也只有一个,通过重试成功带动其他同流程中失败的接口批量唤起,来解决雪崩恢复后的问题。当然如果是单点服务雪崩,也不存在恢复其他的接口的问题了。因此一般是以页面为单位:页面核心接口重试,重试成功后唤起页面其他接口刷新。

- 配置降级:真的遇到大范围事故的时候,客户端实际上有可能呈现出不可控状态——界面异常,到处弹错误文案,用户一头雾水。实际公司内出现雪崩的case,快则10分钟,慢则2个小时。在这个时间内如何挽救用户体验,并且顺便压峰——只有打通信道后进行配置化来模拟/拒绝访问,修改客户端行为。这一段在part3详细描述。

打通信道

客户端对服务器的访问根源是,需要不断查询服务数据以供更新,以尽量保证客户侧数据的实时性。虽然这是主流的B/S通信方式,但是说到高实时性的数据通信,自然还是长链接靠谱。因此打破API查询带来的访问瓶颈,必然需要打通信道,扩展更多的B/S通信方式。

实际业务客户端开发中,可以加以利用的通信方式还包括:长链接(MQTT,自建通道),静默推送(包括Voip)。

1. 长链接

想象这么一个case:我们有一个订单列表tab,是分页加载的,用户可以一直往下滑动加载很多页面。我希望切换tab的时候刷新订单列表更新订单状态,应该怎么做?如果对列表做整体刷新,分页是难以为继的用户体验大打折扣。但是客户端也没法知道到底改更新哪一个订单卡片。因此这里用API方案,存在大流量,大数量请求和损失用户体验的叠加糟糕情况。

这里就是长链接的用处来了。如果客户端启动后维持着一个长链接,服务端能将订单改变事件直接推送到用户手中,客户端随后对对应卡片执行刷新,即可最小成本的结局该问题。

当然如果以这个方式讨论,一个应用可以建设长链接进行双向通信的地方也太多了,因此一般的客户端会建设一个独立的与服务端通信的长链接。当然这个长链接是通用的,可以通过中转服务器双向访问到业务任意服务,任意服务也可以通过该服务找到特定用户。如果是一个Im应用,通过im通道就可以了,一般的应用的话则需要自己建设一个这样的中台。

阿里云就提供了这样一个中台——“阿里云API网关”(https://developer.aliyun.com/article/394520)。阿里也认识到“有一些场景是客户端向服务器端发送请求这种方式完成不了的,比如服务器向客户端推送应用内通知,用户之间的即时通信等功能。这种时候就需要建立一个通信通道,让服务器能够给指定的客户端发送下行通知请求。也就是客户端和服务器端之间具备双向通信的能力。具备双向通行能力的架构对于移动APP属于刚性需求。”因此有了这样的设计,这个设计也是业界较为通用的设计,基本设计是:

a. 客户端在启动时建立长链接,并且分发到用户唯一id,唯一id返回给客户端后,客户端也告诉业务服务;也可以是业务服务监听长链服务的MQ,筛选出自己需要的用户id。
b. 上行通信保持和http协议一致,对业务透明。(也就是长链接通道的上行访问用http形式即可,并且对服务侧透明)
c. 下行通过服务端直接调用(或者mq),向指定用户唯一id发送消息即可。

整个生命周期如图:
點擊在新視窗中瀏覽此圖片

使用长链,也可以使得上面解决API问题更加顺利,包括:a. 在预载解决方案中,主动推送预载数据。使得预载不依赖用户访问特定节点,打通预载的自由性与可控性。b. 配置化解决方案中,长链接主动更新配置文件是通过配置解决燃眉之急的关键方案,打通客户端实时性的必要通道,不可缺少。因此长链接通道依成为现代业务app反而不可或缺的可用性保障基础功能与重要补充手段了。

当然同时也需要尽量保证长链中台服务的可用性极高,同时整体流程也要http可降级。

2. 静默推送

自建长链确实很爽,但是有一个致命的问题是现在的操作系统如果客户端退出到后台,长链接可能被掐掉,特别是iOS设备,一般是不允许在后台维护长链接的。因此对于双向通行,重要的补充手段即使用操作系统提供的推送通道,进行静默推送。

这里说的推送并不是说传统的安卓开发时自建信道的那种推送,而是利用系统推送能力,主动唤起客户端的行为。如苹果APNS+PushService即可做到推送唤起客户端功能并在客户端未启动时执行预载等业务逻辑。安卓则需要针对小米,华为等厂商提供的API进行适配,去单独唤起pushservice进程。

当然,使用厂商的推送的触达率是远低于启动时的长链的,按之前的数据,安卓厂商的消息触达率在90%左右,APNS则在96%-97%,当然远比不上长链接的触达3个9左右。但是作为长链接的补充:在业务用户低峰时间也能进行数据分发更新,特别是触发大数据的下载储备,确实是一个有效的方案。比如提前为用户储备明天秒杀页的基本信息,有以下触发节点:
點擊在新視窗中瀏覽此圖片

降级

我们建设部署在CDN上的配置化能力,建设长链接能力,最终也到了可以实现客户端动态降级着一步了。最经典的降级case是来自前几年淘宝的双11降级方案,当流量到一定的阈值之后,非核心服务停机降低前端全部隐藏,相关服务扩容到核心服务中力保核心服务可用性。同样就算出了问题,也会有多种业务降级方案备份处理。简单说,考虑到应对突发流量的成本问题,降级会是常态方案。

当然对于服务异常等情况,降级方案更是必要的工具了。实际上的降级方案在客户端侧的体现在以下几部分:业务模块降级,请求降级,API请求配置化。

1. 业务模块降级

业务模块是可关闭,业务流程是可缩短的,说到底是客户端页面配置化的一环,考虑到网络因素降级需要快速发布部署的因素,整体技术方案显然是cdn配置文件+长链主动推送到在线用户(本段3条都同理)。实际可行的配置方案包括:

a. 关键业务开关:对核心可调整的流程设置专用的配置文件与分发渠道,当然应用中应该默认埋入正常流程的配置,一旦需要降级,通过cdn更新新的配置,严谨的描述,新的配置需要包括:新的配置map/有效期(核心流程需控制开始/结束时间)/优先级(可能会被复写)/校验key(配置文件安全风险应对)。

b. 【银弹】路由地址配置化:路由配置化可以完成任意页面跳转行为的转发与拦截,甚至可以一定程度做到业务流程控制(比如将app://page/a转发为app://action/alert?message=拦截提示&onclick= app://page/b来实现本来去a页面的流程在提示后转去b页面),对于业务降级的场景确实是银弹行为。只是配置的有效性与安全性依旧需要保证高质量。

c. 【银弹】服务端控制降级:cdn配置化方案的一大问题是,可能和实际业务服务的开关时效性不一致,毕竟无论如何cdn分发一定有延迟的。因此基于服务端也会进行降级的设定,也开辟的服务端异常返回控制单次降级行为的技术方案。比如进入a页面核心请求返回以下restful异常:
{
    "code": 500,
    "message": "[ExceptionAction]",
    "data": {
        "exceptionAction": {
            "type": "router",
            "data": "app://action/alert?message=拦截提示&onclick= app://action/close?redirect= app://page/b"
        }
    }
}

通用的ExceptionAction拦截器会在遇到该异常后执行exceptionAction指定的行为。这里是路由执行alert,然后关闭当前页再跳转到b页面。

2. 请求降级

上文的业务模块降级用在服务还在可预期状态下的请求,如果部分服务已经不在预期状态了,配置可能无法触达或者无法从cdn或者业务服务感知到就糟糕了,因此会存在本地的请求降级方案。

a. 域名降级:域名降级是救救CDN的核心方式。CDN确实不会挂,但是CDN域名被污染或者劫持确实是常有的事情,如果在突发流量时发生这种事情,就雪上加霜的。虽然有httpdns可用,CDN降级最有效的方式是域名降级,在本地储备多个CDN域名配对,并且在对应域名请求发生dns类网络异常后,进行域名替换。当然这个替换不限于cdn,所有http请求都应有域名降级方案。

b. 请求地址替换:对于API请求来说,除了域名,url地址也是控制到底转发到哪个服务的工具之一,毕竟很场景的通过path的头一二级,在nginx上控制对应API请求到底转发到哪个服务上。因此在配置方案上如果系统上存在多套系统可降级的方案,就可以储备请求地址替换方案,如http://host/v2/api/r/what替换为http://host/v1/api/r/what来进行部署服务的降级。

3. API请求配置化

以上还是讨论的大流量时有降级目标比如备份域名,旧服务或者乱七八糟的资源可用时,可行的降级方案,如果真的遇到没有降级目标可落地,服务已经处在爆炸边缘了,我们客户端侧仅有CDN资源可用,怎么办。因此需要一个可以配置化控制所有API请求的银弹配置化方案。实际上现在各大公司的网络库也确实有这么一层。

a. 这个银弹可以配置化的控制特定范围API的请求参数拦截,比如添加或者删除参数,控制缓存等行为。

b. 这个银弹可以配置化mock请求,即让请求直接不发出去,直接返回配置数据。在雪崩急救时有大用处比如让提示信息接口直接返回预设好的提示文案。更有甚者如果接口异常可能会导致客户端异常,直接mock屏蔽相关接口信息等待服务端修复。

c. 这个银弹可以控制请求行为,比如delay一个数字来让延长请求时间避免频繁请求,或者随机请求失败来整体限流,均能在雪崩中后期起到一定作用。

通过这些方式,我们几乎可以在服务端遇到大流量瓶颈的时候优先在客户端进行灵活操作,保证基础的用户体验。

————————

通过以上在基建的方案的提前准备,当然这些方案已经是在大量服务雪崩的尸体之上建设的了,应该确实,可以在未来的高并发特征应用建设中防范于未然。
关键词:客户端 , 高并发 , 大流量
logo