为了应对用户规模的增加和交易活动的激增,淘宝购物车近年来进行了一系列技术升级,其中主要集中在扩容与性能优化上。本文将简要介绍这些改进措施,以及它们如何提升购物车的响应速度和用户体验,以实现“又快!又丝滑!”的购物体验。主要包括:
淘宝购物车扩容分析与解决方案
网络包大小与服务端并行化分析与方案
扩容的背景
首先讲一讲购物车的职责。购物车承载着购前环节中促成购买的职责:对于下单犹豫的用户,用营销等手段提升用户决策效率,对于转化确定性较高的用户,精准的推荐让他买的更多;在购中环节提供流畅的交易体验:优惠计算的准确性和过程的清晰度以及各种业务形态商品的凑单合并结算等。作为在购前链路中直面消费者的重要节点,购物车里的体验问题就显得十分重要且敏感。
分析往年的用户原声可以发现,用户对于扩容有强烈诉求。
So 购物车大刀阔斧般的升级可以说是刻不容缓!
针对全量用户做购物车加购商品上限的扩容,由200(现货120+预售80)扩充至380(现货300+预售80)。
扩容的挑战与解法
每每提到购物车的容量,在扩容功能上线前都会受到一个灵魂拷问为什么我的购物车上限只有120??? 我们内部在进行多轮讨论后,也意识到购物车的整体扩容在技术上和产品设计上都面临巨大的挑战。聚焦在技术上的挑战主要来源于对数据库的直接冲击与扩容带来的交易全链路计算量放大。
扩容首先带来的就是存储容量的上涨,按扩容到300的预期估算,存储量上涨就大约达到了2倍,在物理机资源有限的情况下存储量的上涨会同时到来BP缓存率的下降,这样就导致其他用户的数据只能去物理磁盘读取,磁盘IO会成为巨大的瓶颈,进而导致数据库各个操作的RT飙升。
扩容后由于 BP 命中率下降,冷数据访问导致大量读盘,数据库磁盘 IO 会成为巨大瓶颈。
购物车现有的分页设计着实提高了用户进入购物车的秒开体验。
但是遇到类似 跨店满减筛选、降价筛选、消费券筛选等强依赖实时计算结果的产品设计,需要针对用户加购品做全量计算并一次性下发给客户端。
大促跨店满减报名是类目+商家纬度,营销规则又包含了部分类目黑标的逻辑。
但是购物车跨店满减筛选是商品纬度的判断。所以这里有一个从类目+商家到商品纬度的打平计算的过程。
为了判断商品是否报名了跨店满减,需要调用 营销 进行实时计算。
用户纬度的所有加购品经过 营销 计算后,购物车才能筛选出哪些参与了跨店满减。
下发300+个品的数据包预计约为1M左右,在弱网环境下,按 100KB/s计算,需要的传输时间是10s 左右。
购物车已经采用了分页实时计算方案,提高用户秒开体验
由于筛选涉及全量下发,扩容会放大对交易链路的计算量且增加购物车本身的性能压力,从而导致用户体验的降低。
以双十一峰值的购物车监控为例,在峰值期间有明显的脉冲流量,且主要流量集中在查询与勾选这两种依赖读db的操作类型,而涉及写db的流量(加购,删除,更新等)明显偏少。
查询购物车的SQL逻辑基本属于简单语句,SQL这种的查询条件主要是用户id、加购状态等。并且购物车数据是按照用户纬度聚合,单次请求返回的数据体积不会太大。
所以我们总结出购物车的流量特点为:
典型的读多写少,
sql返回数据不会太大
有瞬时脉冲
对RT敏感
根据流量特点在调研多种数据库选型后,并结合营销团队在双十一通过 tairsql 解决买家资产查询的经验后,购物车本次选择云原生内存数据库 tairsql 作为读库提升查询性能。
引入tairsql后,峰值期间的读流量全部走tairsql,写流量仍然会对db与taisql执行双写,同时有数据同步任务(精卫任务)做同步兜底。
使用tairsql与db做读写分离
采用双写+精卫方案保证数据同步
购物车的首页筛选难点在于依赖全量商品实时计算的结果,购物车扩容后势必对筛选造成性能压力。那么是否存在一种方式,能在调用下游服务进行实时计算之前,根据某些规则结合商品属性(商品标签,营销属性等),进行一次过滤,从而减少实时计算商品数呢?
分析现有的筛选产品功能后,针对营销类筛选(跨店满减筛选、预热态筛选、消费券筛选),考虑前置生成商品染色表,记录加购品特征,在营销计算前,根据筛选规则过滤出可能参加指定活动的商品。
预计算链路图示:
预计算链路中通过监听类目信息,优惠规则结合加购底表数据,生成加购商品特征表。当用户发起筛选请求时,和正常的渲染请求一样还是会在实时加购数据中查询全量加购数据的基础信息,之后会结合特征表和筛选规则,筛选出符合规则的商品,这些通过筛选的商品再与后续下游服务交互做实时计算。
端到端耗时组成
近年来内网用户都在吐槽手淘购物车的性能体验不好,也很罕见的在用户舆情里也看到反馈 “购物车卡顿”。在如此激烈的竞争中,购物车不仅是用户购物体验的直接接口,更是电商运营效率的关键所在。一个优秀的购物车系统,应当能承受巨大的并发请求,同时保持数据的准确性与实时性,为用户提供流畅的购物体验。
从性能数据来看从一次用户在购物车内发起网络请求到客户端产生相应变化,耗时组成大致分为“网络传输”、“服务端耗时”、“客户端渲染”三大部分。从服务端的视角结合快速降低耗时的使命,我们针对除客户端逻辑外的优化思路主要有以下几块,具体的细节也会在后文详细展开
网络传输优化:
压缩数据:使用如Gzip这样的数据压缩技术或结合业务逻辑做裁剪减少传输数据的大小。
缓存策略:合理设置缓存策略,减少不必要的网络请求和包大小。
延迟加载:对于购物车非关键资源进行延迟加载,优先加载对用户体验影响最大的内容。
服务端耗时优化:
缓存机制:使用内存缓存如tair等,对频繁访问的数据进行缓存,减少网络传输压力。
并发处理:优化代码,使用异步处理和多线程技术,提高服务端的并发处理能力。
代码优化:精简代码逻辑,移除不必要的中间件和服务
网络包传输
上行包优化收益略大于下行包优化收益。
回到购物车的场景,上行包最直观的膨胀点就在于请接口求中params字段:
这一份长长的params里包含了,页面组件,页面状态等多个信息,其中页面组件是客户端渲染强依赖数据,而状态数据是服务端业务逻辑依赖数据。
状态数据同时存在于上下行协议中,且客户端不依赖其做逻辑处理,所以理论上这份数据可以不作为协议下发,并且在服务端的“历史长河”中,由于缺乏对该信息的管控,状态协议数据可以随意的追加,导致了这份大数据包逐渐成为上下行网络包中的一份包袱。
状态协议数据同时存在于上行与下行协议中,且客户端不依赖。
为了达到和客户端解耦的效果,最直观的方法就是将这份数据存到缓存中。然而在结合交易流量和现有计算资源做一个简单估算后,我们发现事情并没有那么简单。结合淘宝购物车的峰值流量来测算,整体产生的带宽量级会达到数十GB,在当前的规格资源下根本不够用。
前文也提到了我们的终极目标是把服务端依赖数据放到缓存中,完全与客户端解耦。经过结合业务逻辑的状态协议裁剪,包大小已经有所降低,带宽瓶颈与可用性问题确实得到了缓解。那么在将这份数据存到缓存之前,还有没有可以继续“压榨”的空间呢?答案是当然是有。
base64 和 压缩算法:
在原有的模式下,状态协议通过客户端和服务端直接传输来做保持,故需要通过使用base64编码确保数据能正确地存储和传输,而不是通过原始的二进制格式。这种方式会带来大约33%的数据膨胀,在确定了状态数据往缓存中存储的优化方向后,状态数据可以不再做base64编码,而转为直接存字节数组。
Base64编码原理:Base64编码将每组3个字节(共24位)的原始数据划分为4个单元,每个单元6位。由于每6位只能表示64种状态(2^6 = 64),Base64编码选择了一个64字符的集合来表示这些状态。这个字符集通常包括大写和小写英文字母(A-Z, a-z)、数字(0-9)、加号(+)和斜杠(/)。实际编码时,Base64处理器会查找这个字符集,将每个6位的值映射为相应的字符。
数据膨胀:考虑到大多数字符编码(如ASCII,UTF-8)中,一个字符通常占用8位(1字节)。在Base64编码中,每4个字符用来表示原始数据的3个字节。这意味着每个原始字节在编码后占用了8 * 4 / 3 = 10.666...位。换言之,原始数据的大小会增加大约1/3(33%)。
我们同时也测试了原始长度为36KB(未压缩状态数据)在各个压缩算法下的表现,虽然部分算法(例如Brotli)能实现更高的压缩比,但是综合解压和压缩时间来看,gzip仍然是最好的选型。
购物车状态缓存模式:
经过上述优化后,状态数据的大小在网络传输包能进一步得到缩减,从而减小带宽和存储上的压力。状态数据在客户端与服务端的交互方式也由原先网络传输的模式,改为客户端解耦的服务端缓存模式。客户端上行数据中不再持有状态数据,业务状态的计算完全交付服务端,若出现缓存超时或异常则通知客户端降级为网络传输(原有模式)。
经过上述的优化后,我们发现有一个遗留的“坑”点,为了能保证异常情况下用户操作能兜底上传一份状态数据,下行包中仍然保留了全量的状态协议。此时就需要我们在保证兜底能力的情况下,把下行包中的状态协议也进行剔除。
参考业界流式API模型,说法各不相同,主要是以下三种:
request streaming: 多个上行对应一个下行;
response streaming: 一个上行对应多个下行;
bidirectional streaming: 端云双向流式服务;
流式API是实现 “response streaming”,即一个上行多个下行,请求模型如下:
通过流式api的引入,下行包拆成了主包与副包,状态协议在副包中,其余数据保留在主包。客户端在收到主包后即可执行渲染逻辑,等效于状态数据在网络传输中的耗时,不再影响用户端到端体验耗时。
服务端接口
服务端耗时占比:
从用户的操作数据来看,大部分用户操作商品数量较少,这些情况下服务端的性能表现尚可,这意味着服务端耗时对购物车这部分用户的影响不大。但是随着商品数量的增加,服务端耗时在端到端耗时中占比不断攀升,这意味着服务端性能对购物车的深度用户影响较大。
商品数据较少时:服务端优化点在于下发数据量,在网络包优化中已经得到了解决
商品数据较大时:服务端优化点在于降低耗时
购物车的服务端逻辑一大特点是平均商品数较大,其中对于商品数的处理在历史上多使用简单的foreach直接遍历做计算。针对解决过多foreach逻辑最有效的方法自然是做并行化处理。购物车的流程基于业务特性将各个独立域活动进行串联,流程简化图如下:
结合业务回头来看整个流程,会发现全串行流程中低效的点:
部分节点之间并没有严格的顺序关系,例如查服务数据在注入商品标签后即可执行,且服务数据当前只在视图层构建有用。
互不依赖的下游服务例如库存查询、营销计算、查服务数据是低效的串行查询。
购物车平均商品数过大的特点,导致和商品数相关的节点内部做foreach循环非常低效,典型的有例如注入商品标签、视图层构建。
找到问题后,我们最终采用基于ForkJoinPool的方式实现购物车并行化改造:
ForkJoinPool主要针对那些可以拆分成多个子任务的大任务进行优化。它的核心技术原理是基于“工作窃取”算法,每个工作线程都有自己的任务队列,当线程完成自己队列里的任务后,它可以从其他线程的队列尾部窃取任务来继续工作,这样可以保持所有工作线程的高效运行,避免了线程闲置。ForkJoinPool 通过 ForkJoinTask 的子类如 RecursiveAction 和 RecursiveTask 来支持任务的分割(fork)和结果的合并(join)。
这种面向多子任务且支持并行化的方式,就非常适用于购物车场景中的多商品处理,例如:
并行处理多商品任务:购物车的一大特点就是平均商品数多,每个商品标签注入、单个视图分组构建等操作都可以作为一个子任务独立执行。
任务可拆分性:购物车中的每个商品处理逻辑都是独立的,这使得任务可以很方便地被拆分成小任务并行执行。
提高响应性能:使用 ForkJoinPool 可以在多核处理器上同时执行多个商品的处理任务,从而减少总体的处理时间,提高系统的响应速度。
动态任务调度:"工作窃取"算法能够在运行时动态地重新平衡任务负载,如果某些商品的处理耗时较长,ForkJoinPool 能够确保其他线程不会闲置,而是帮助处理剩余的工作。
结合ForkJoinPool的方式做并行优化后的购物车流程简化图如下:
注入商品标签和视图层构建,从商品维度拆分子任务,使用并行化。
将部分仅视图层且互不依赖的下游数据节点做聚合,内部做并行查询。
团队介绍
看到这里相信诸位能够感受到购物车技术体系的复杂度和深度,笔者所在的团队是淘天集团交易前链路技术团队(购物车&下单),在这里,你能够不断被各种商业模式烧脑,也能够不断被各种新兴技术锤炼,更能收获一群志同道合的战友。值此变革关键时期,也急需有能力和有梦想的你一起参与:
1. 负责淘宝购物车、下单等面向全民用户的C端产品演进和迭代,每一次需求每一行代码都能创造巨大的商业价值。
2. 支撑集团16N组织下形态各异电商的购物车、下单平台应用的维护(buy2 carts2),在这里见证如何针对形态各异的电商进行架构抽象出电商内核,并通过高度灵活的开放扩展机制解决业务的差异性。
3. 操刀完整的端到端协议的设计、演进和优化(奥创),见证移动时代在客户端不发版的情况下,如何既能高效满足产品需求迭代,又能获得native一样优异的消费者体验。
4. 全程保障每一次大促流量洪峰背后的业务安全和稳定性,全力促成持续的平台架构演进,确保用户每一次购物车浏览,每一次下单能够丝滑顺畅。
如果您有兴趣,可以点击下面的链接或者通过邮箱[email protected]与我们联系和交流,期待您的加入。
https://talent.taotian.com/off-campus/position-detail?lang=zh&positionId=1089401
¤ 拓展阅读 ¤
3DXR技术 | 终端技术 | 音视频技术
服务端技术 | 技术质量 | 数据算法