Tubes响应性数据系统的设计与原理

简介: 本文详细介绍了响应性数据系统在 Tubes 中的运用,以及响应性数据系统的三种不同设计与原理。Tubes是一套面向C端搭建场景,支持灵活扩展、极致性能和高稳定性的终端渲染解决方案,目前广泛运用在淘宝、天猫,包括:双11、618会场、淘宝新人版首页等业务场景。

介绍

响应性数据系统指的是程序在使用系统提供的数据的同时会自动订阅自己所使用的数据,被订阅的数据发生变化时使用了(订阅了)这个数据的程序会对数据的变化做出响应。


响应性是 Vue.js 最具特色的特性(之前叫响应式,Vue3给翻译成响应性,我认为响应性这个词整挺好),不过 Tubes 的响应性原理和 Vue.js 略有不同。


响应性数据系统在Tubes中发挥的作用

Tubes 为什么需要响应性数据系统,这来源于一个重要的业务问题:“如何解决会场分屏渲染问题”,站在技术视角要解决的问题是:“Tube多次执行的设计问题”。

正在读文章的你可能不了解Tubes体系,不清楚什么是Tube,没关系,你可以把问题想象成:如何解决 Express.js 的中间件多次执行、如何解决 Webpack 的Loader多次执行。


分屏渲染指的是在搭建体系中,产出的页面会先渲染首屏的模块,再渲染次屏模块。在Tubes体系中,从第一个Tube开始执行,执行到最后一个Tube则完成一次渲染(这与 Express.js 的中间件机制有点类似,一个请求进来从第一个 Middleware 开始执行,执行到最后一个Middleware结束),如下图所示:640.gif

从第一个Tube执行到最后一个Tube则完成一次模块的渲染。在会场分屏渲染的场景下,通常第一轮执行完毕表示完成了首屏模块的渲染。那么想要渲染次屏模块,只需要再执行一轮Tube就可以将次屏模块渲染出来。那么问题来了,Tube多次执行的机制如何设计比较优雅?

我们设计了两种方案:“方案一:基于循环系统实现”、“方案二:基于响应性数据系统实现”。下面我们对比下两个方案的区别:


 方案一:基于循环系统实现


基于循环系统,Tube可以一圈一圈执行。第一圈渲染时,Tube开发者可以在内部调用API通知 Tubes-Engine 本轮执行完后还需要再执行一轮。通过这样的机制,实现第一圈渲染首屏模块,第二圈渲染次屏模块。

这里的问题是由于并不是所有Tube都需要执行多次,因此 Tubes-Engine 需要为Tube提供一些属性和方法(例如:当前执行的圈数、once方法等)方便 Tube 根据 “圈数” 做一些逻辑、或者使用 once 声明自己只需要在第一轮执行一次,后面的其他轮次不参与执行。

其实只有和“首屏”、“次屏”渲染逻辑强相关的Tube需要多次执行(举个例子:env tube的逻辑是初始化一些环境信息,比如是否是Android或者iOS,PC还是Mobile,这个逻辑只需要在第一轮执行时执行一次就可以),在基于循环系统的实现下,不需要多次执行的Tube就需要开发者明确声明这个Tube只需要执行一次(就是说,如果开发者判断自己的Tube只需要执行一次,那么他需要为这个Tube做一些额外的处理)。


现在我们总结下这个方案的优缺点:

  1. 优点: Tubes-Engine内部实现简单(意味着不容易出错)
  2. 缺点:
  • Tube开发者需深入理解Tube运行机制(轮圈制)以及背后为什么设计成“轮圈制”
  • 每个Tube,都需要处理轮圈制相关的逻辑,例如:
  • 只需要执行一次的Tube:需要使用once声明自己只需要执行一次
  • 需要执行多次的Tube:需要根据圈数来判断本轮做什么事情,以及是否需要再来下一轮并调用API通知 Tubes-Engine 再执行一轮。


这个方案很直观,可以解决问题。但也可以看出,该方案对Tube开发者来说使用成本很高,无论Tube是否需要多次执行,开发者都需要深入理解背后的轮圈原理并对自己开发的Tube做相应的判断(是否需要多次执行)和处理。

 方案二:基于响应性数据系统实现


设计之初,Tube的本质就是一个具备 “幂等性” 的执行单元(函数),它的一面是输入,另一面是输出,前一个Tube的输出是下一个Tube的输入,多个Tube组合在一起完成页面渲染。

Tube是用于处理渲染的执行单元(函数),Tubes 的设计理念是利用若干简单的执行单元(Tube)让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。—— Tubes官网


具备 “幂等性” 意味着,输入相同的情况下,输出一定相同。也就是说输入不变的情况下,输出也没有变化,因此没有必要让其重新执行。只有输入变了,才意味着输出会变,这时候就需要重新执行Tube使其重新计算并输出新的结果。

方案的关键思路是“根据输入输出的变化来决定哪个Tube应该再次执行”,而实现这个能力的关键技术就是“响应性数据系统”

因此,基于响应性数据系统指的是:只有某个Tube所使用的数据发生“变化”时,才重新“按序”执行Tube。“按序”执行是指如果多个Tube都使用了同一个数据,且该数据发生了变化,那么这些 Tube 按照Tube的编排顺序执行。


例如:渲染了首屏后,“数据Tube”请求了次屏数据并将次屏数据也放入了Store中,那么这个放入数据的操作,就会触发一个数据的 update,那么此时使用了该数据的Tube会再次执行,且只有使用了该数据的Tube会再次执行。再次执行的Tube或许又会修改数据,依赖这个数据的后续Tube又会加入到“执行队列”中再次执行,以此实现一条 “基于依赖的执行链” 即:前一个 Tube 修改了数据并写入响应性数据系统,后面读取了这份数据的 Tube 会被再次执行并将重新计算后的新数据写入响应性数据系统,而这个写入行为又会再次触发后面的其他 Tube 执行,直到完成最终渲染。


现在我们总结下这个方案的优缺点:

  1. 优点:
  • 没有对Tube增加额外负担(无新增方法、无新增属性、无需Tube内部配合Tubes-Engine实现流程控制)
  • Tube对首屏次屏场景下的多次执行无感知
  • 只执行需要执行的Tube(而不是先执行,再由中间件内部判断是否跳过)
  1. 缺点: 内部实现复杂(意味着容易出错)


基于响应性数据系统,可以很智能的“挑选”出哪些Tube应该被执行,该方案对Tube开发者没有任何额外负担,而且它只执行需要执行的Tube,执行效率上也更好。最终,我们选择了使用响应性数据系统。

640.png

响应性数据系统的设计与原理


历史上,为了将性能优化到极致,Tubes 的响应性数据系统的实现原理共设计了三个版本,由于第二个版本是第一个版本的改良版,因此本节为您详细介绍第二个版本和第三个版本的实现原理。


 经典实现


当我们研究响应性数据系统的原理时,本质上我们在研究三件事:响应性数据收集依赖触发依赖

640 (1).png

  • 响应性数据


响应性数据最关键的两个核心是:监听器依赖存储


监听器


所谓的监听器指的是我们采用什么样的方式拦截用户对数据的操作,主流的实现方式有以下几种:

  1. Getter / Setter
  2. Proxy
  3. 自定义 Set/Get API


Tubes 采用第三种方式实现,原因是 Tube 开发者希望可以在原始数据中修改数据且不触发响应性,例如:

const data = this.store.get('data'); // 希望只在这里绑定依赖关系
const name = data.name; // 希望这个操作不触发Getter
data.name = name + '!'; // 希望这个操作不触发Setter
this.store.set('data', data); // 希望执行到这行代码时再统一触发响应性

Tube 开发者希望自由控制什么时候触发响应性,在这种场景下 Tubes 采用了自定义API的方式拦截用户对数据的操作。

依赖存储


当监听器拦截到用户对某个数据进行操作后,需要找到这个数据对应的“依赖”,如何找到数据对应的“依赖”主要看“依赖”如何存储,实现方式有很多种,传统的实现方式比如Vue2是直接将“依赖”存在数据身上,找到了数据也就找到了这个数据对应的“依赖”,不得不说这很方便,Tubes最初也是这样实现的,如下图所示:

640 (2).png

如上图所示,依赖都保存在 __dep__ 这个属性中,而这个属性则直接保存在数据中。


  • 收集依赖


关于依赖收集,本质上我们要解决的问题是“什么时候收集”、“收集谁”、“如何收集”、“收集到哪里”等问题。


什么时候收集


什么时候收集?答案是:“读取数据的时候收集依赖”。当监听器拦截到读取数据时,就可以开始进行依赖收集了。


收集谁


收集谁?回答这个问题似乎不那么简单,事实上这个问题没有标准答案。如果用发布&订阅者模式来理解,那么我们收集的是“订阅者”,当数据发生变化时我们要通知给订阅了这个数据的“订阅者”,但订阅者是谁?这取决于我们具体的需求,也就是我们希望使用响应性数据系统做什么事。


在 Tubes 中,我们希望响应性数据系统能帮助我们将需要再次执行的 Tube 挑选出来,那么我们的“订阅者”就是Tube,一旦数据发生变化,就能找到这个数据的依赖(也就是 Tube),随后就可以将这些 Tube 发送到调度系统中去执行。


在 Vue.js 中,希望响应性数据系统能帮助将需要重新渲染的组件挑选出来,那么这个时候订阅者就是组件,数据发生变化后,能找到数据的依赖(也就是组件),随后用新数据重新渲染组件。


实际上,Tubes 中的订阅者并不是 Tube,因为 Tube 需要支持并行执行,如果并行执行的 Tube 同时使用了某个数据,那么在收集他们的时候需要保持好并行Tube的数据结构,因此在 Tubes 内部,并行执行的 Tube 为一组,订阅者其实是这个小组。也就是先把 Tube 添加到这个小组,然后再把这个小组添加到依赖列表中。 假设并行执行的 Tube 只有其中一个使用了某个数据,那么这个数据的订阅者就是一个小组,然后这个小组里面只有一个 Tube,如果并行执行的 Tube 有多个使用了某个数据,那么这个小组里面就有多个 Tube。


如何收集


现在我们已经知道了“什么时候收集”、“收集谁”以及“收集到哪里”,下面详细介绍一下怎么收集,我们用一个例子来说明:


const data = ctx.store.get('a.d');

在 Tubes 中,由于同一时间只有一个 Tube 在执行,而且不存在嵌套关系,因此定位哪个 Tube 中执行了 ctx.store.get 并不困难,伪代码如下:

function get(keypath) {
  this.ctx.tb.store.effect = effect;
  this.ctx.tb.store.tube = tube;
  const res = this.ctx.tb.store.get(keypath);
  this.ctx.tb.store.tube = null;
  this.ctx.tb.store.effect = null;
  return res;
}

上面伪代码展示了当用户调用 this.store.get 时,如何定位当前正在执行的 Tube 和 Tube 所属的那个组(effect)。


收集到哪里


前面讲响应性数据的时候我们讲依赖是直接存在数据上的,因为这样方便修改数据的时候找到该数据对应的依赖,但只将依赖保存在被修改的数据身上是不够的。

试想一个场景,Tube A 使用了数据 a.d,这时候相当于订阅了 a.d 这个数据,现在另一个 Tube 修改了数据 a,或者数据 a.d.x 那么此时 Tube A要不要响应?答案是:要!

因此,我们得到一个结论:依赖不只是保存到目标数据身上,还要保存到目标数据的所有父级和所有子级中,如下图所示:

640 (4).png

上图红色圈表示需要被订阅的数据,绿色圈表示依赖保存的位置,右侧表示当 Tube 读取 a.d时,需要同时订阅 aa.da.d.ea.d.f

这里有一个细节,图中绿色圈比我们要订阅的数据高一个层级,这是因为数据的最底部会出现基本类型的数据,而基本类型的数据是无法挂载额外属性的,因此我们选择将每个数据的依赖都保存在父级节点中。


  • 读取依赖


触发依赖的时机是 “修改数据的时候” ,当监听器拦截到用户修改数据时,将对应数据的依赖找到就可以了。在 Tubes 中,依赖被找到后会发送给调度系统执行。


 基于哈希表的响应性数据系统


这种实现方式的原理和核心思想与传统方式没有太大区别,本质上我们仍在研究三件事:响应性数据、收集依赖、触发依赖。


顾名思义,这种实现方式最大的特点是使用哈希表来存储依赖,如下图所示:

640 (5).png

  • 为什么使用哈希表存储依赖


使用哈希表来存储依赖和将依赖直接保存在数据身上的主要区别在于操作依赖的 “速度”


前面我们讲收集依赖的时候除了要将依赖保存在当前读取的那个数据之外还需要将依赖保存在所有父级和所有子级中。将依赖保存在所有父级中并不怎么影响性能,但将依赖保存到所有子级,这需要遍历到每一个子级和子级的子级,如果数据非常大,那么这个遍历的过程将非常耗时,在低端机尤其明显。


使用哈希表来存储依赖,操作依赖的速度则不受数据大小的影响,存储依赖不再需要遍历数据的每个子级,无论是保存依赖还是读取依赖都不受数据量大小的影响,可以在一瞬间完成。


下面我们详细介绍使用哈希表如何存储和读取依赖。


  • 存储依赖


使用哈希表存储依赖非常简单,只需要使用 keypath 作为 Key,值是一个列表,将依赖追加到列表中即可。这里需要注意去重的逻辑,避免将同一个依赖重复添加到列表中。一图胜千言:

image.gif

  • 存储依赖


使用哈希表存储依赖非常简单,只需要使用 keypath 作为 Key,值是一个列表,将依赖追加到列表中即可。这里需要注意去重的逻辑,避免将同一个依赖重复添加到列表中。一图胜千言:

640 (7).png


也就是说,当我们修改了某个数据时,除了该数据外,该数据的所有父级以及所有子级都需要做出响应,现在这个逻辑仍然适用。因此,不难得出,当我们修改 a.d 时,我们需要在哈希表中将 aa.da.d.* 的依赖取出来,其中 a、为父级,a.d为数据本身,a.d.*为所有子级,如下图所示:

640 (3).gif值得注意的是,同一个KEY下面的依赖会去重, 但不同KEY之间有可能有重复的依赖,为了避免重复响应,取出来的数据需要做一个去重处理。


  • 性能提升


经过验证,相比于传统实现,使用哈希表来存储依赖在低端机的性能提升尤其明显。在维持写入速度不变的情况下,提升读取数据的速度 “1,349.8” 倍,在业务中的表现是会场的首屏速度提升452.6ms,以下是详细数据:


  1. 读取速度:提升了1,349.8倍,Android低端机由64.79ms(10次平均值)降低为0.048ms(10次平均值)。
  2. 写入速度:维持现有速度不变
  3. 时间复杂度:旧算法会随着数据量大小而“增加写入时长”,而新算法写入速度始终保持在 0ms ~ 0.2ms 区间
  1. 旧算法:读取数据为O(N)
  2. 新算法:读取数据为O(1)
  1. 对会场首屏速度的影响 (新旧算法渲染10次取平均数):提升452.6ms
  • 旧算法:2,924.7ms
  • 新算法:1,917.5ms
  1. 测试环境:低端机
  1. 设备:红米7
  2. 系统:Android 9
  3. 页面:活动中心页面

image.png


值得注意的是,同一个KEY下面的依赖会去重, 但不同KEY之间有可能有重复的依赖,为了避免重复响应,取出来的数据需要做一个去重处理。


  • 性能提升


经过验证,相比于传统实现,使用哈希表来存储依赖在低端机的性能提升尤其明显。在维持写入速度不变的情况下,提升读取数据的速度 “1,349.8” 倍,在业务中的表现是会场的首屏速度提升452.6ms,以下是详细数据:


  1. 读取速度:提升了1,349.8倍,Android低端机由64.79ms(10次平均值)降低为0.048ms(10次平均值)。
  2. 写入速度:维持现有速度不变
  3. 时间复杂度:旧算法会随着数据量大小而“增加写入时长”,而新算法写入速度始终保持在 0ms ~ 0.2ms 区间
  1. 旧算法:读取数据为O(N)
  2. 新算法:读取数据为O(1)
  1. 对会场首屏速度的影响 (新旧算法渲染10次取平均数):提升452.6ms
  • 旧算法:2,924.7ms
  • 新算法:1,917.5ms
  1. 测试环境:低端机
  1. 设备:红米7
  2. 系统:Android 9
  3. 页面:活动中心页面



相关文章
|
8月前
|
边缘计算 JSON 物联网
解锁业务灵活性:RuleGo规则引擎的高效解耦与实时响应秘籍
RuleGo是一个基于Go语言的轻量级、高性能规则引擎,旨在通过动态规则链和组件化设计,简化复杂系统的业务逻辑管理和实时响应。
解锁业务灵活性:RuleGo规则引擎的高效解耦与实时响应秘籍
|
8月前
|
存储 缓存 NoSQL
Redis缓存设计典型问题
缓存穿透 缓存穿透是指查询一个根本不存在的数据, 缓存层和存储层都不会命中, 通常出于容错的考虑, 如果从存储层查不到数据则不写入缓存层。缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。
|
4月前
|
XML Java 数据库
在微服务架构中,请求常跨越多个服务,涉及多组件交互,问题定位因此变得复杂
【9月更文挑战第8天】在微服务架构中,请求常跨越多个服务,涉及多组件交互,问题定位因此变得复杂。日志作为系统行为的第一手资料,传统记录方式因缺乏全局视角而难以满足跨服务追踪需求。本文通过一个电商系统的案例,介绍如何在Spring Boot应用中手动实现日志链路追踪,提升调试效率。我们生成并传递唯一追踪ID,确保日志记录包含该ID,即使日志分散也能串联。示例代码展示了使用过滤器设置追踪ID,并在日志记录及配置中自动包含该ID。这种方法不仅简化了问题定位,还具有良好的扩展性,适用于各种基于Spring Boot的微服务架构。
60 3
|
5月前
|
JSON Android开发 数据格式
Android项目架构设计问题之实现交互响应的结构化处理如何解决
Android项目架构设计问题之实现交互响应的结构化处理如何解决
22 0
|
6月前
|
消息中间件 负载均衡 中间件
中间件设计数据流
【7月更文挑战第8天】
58 3
|
5月前
|
缓存 安全 算法
在后端系统设计过程中,我们究竟设计的是什么?
在后端系统设计过程中,我们究竟设计的是什么?
59 0
|
7月前
|
Web App开发 JSON Java
JavaWeb基础第五章(请求,响应与分层解耦)
JavaWeb基础第五章(请求,响应与分层解耦)
|
8月前
|
消息中间件 算法 Java
C++实时通信优化技术探究
C++实时通信优化技术探究
81 3
|
网络协议
网络通信协议整体框架
网络通信协议整体框架
68 0
|
网络协议 安全 物联网
高效通信的新范式:探索异步请求在网络设计中的创新思路
本文将探讨一种新的网络设计范式,即使用异步请求来提高通信效率。传统的网络通信中,请求和响应是同步进行的,即发送请求后必须等待响应才能继续下一步操作,这样会导致通信效率低下。而通过引入异步请求的思想,可以在发送请求后不需要等待响应,而是可以立即进行其他操作,等到响应返回时再处理。这种设计思路可以大大提高通信效率,特别适用于网络延迟较高、带宽有限的情况。本文将详细阐述异步请求的原理和实现方式,并分析其在网络设计中的创新潜力。同时,还将探讨可能面临的挑战和解决方案,以及该创新思路对未来网络发展的影响。
134 0
高效通信的新范式:探索异步请求在网络设计中的创新思路