9

对数据系统的理解

数据系统设计是关于数据存储、共享、更新(以及传播更新)、缓存(以及缓存失效)的技术。大部分软件系统都可以从数据系统的角度去理解。

数据系统是如此的普遍,以至于开发者实际上每天都在设计数据系统,却常常没有意识到它们的普适性,将多个本质相同的问题当作了孤立的问题来理解。应用状态管理、配置管理、用户数据管理问题,本质上都属于数据系统的问题。

本篇文章站在前端的视角上,通过对数据系统的讨论,希望帮助开发者在开发的过程中有意识地识别、设计数据系统

  • 哪些是数据本源、哪些是缓存
  • 数据本源在哪些组件之间共享?(即作用域多大?)
  • 缓存在哪些组件之间共享?(即作用域多大?)
  • 缓存的生命周期是多长?
  • 客户端中的哪些应用状态可以视为服务端数据库的缓存

本文的大部分例子是前端应用,但是数据系统的规则适用于任何软件系统。

如果你希望从服务端、分布式系统的视角来理解数据系统,我了解到ddia是一本很优秀的书籍,提供了更完整、专业的讨论。

单一数据源与层级缓存

任何数据系统都需要遵循一个原则:single source of truth,即单一数据本源每个数据应该只有一个【数据本源】,其他的数据获取方式都只是缓存。

如果你是一名前端开发者,那么你在学习前端状态管理(比如redux)的时候,应该已经听说过这个原则,但是你可能会忽略这个原则的普适性:这个原则并不仅仅适用于前端应用的状态管理,它适用于任何软件系统。状态管理问题并不是特定于前端领域的问题,而是任何软件系统设计的普遍问题。

认识层级缓存

数据系统的设计,很大程度上是【层级缓存系统】的设计。

从计算机底层的视角来看,缓存层级是这样的:

计算机底层的缓存金字塔

完整版耗时表。通过这些时间,可以大致估算出一个数据系统的性能。

缓存层级的特点:

  • 处于越低的层级,越接近于【数据本源】
  • 处于越低的层级,存储容量越大,数据越完整
  • 处于越低的层级,访问起来越慢
  • 当最底层的数据本源发生更新的时候,上层的数据缓存应该及时失效,并且针对旧数据的操作不应该直接应用于新数据上

站在实际软件系统的视角,道理也是一样的,只不过应用在了更加宏观的层面:

  • 一般不需要考虑计算机底层的缓存
  • 加入应用运行时缓存,比如前端应用状态(本质上还是在内存中)
  • 对于 服务器/客户端 系统,客户端中的大部分应用状态可以视为服务端数据库的缓存

缓存落后问题

任何涉及到缓存的地方,就免不了缓存落后的问题。当最底层的数据本源发生更新的时候,下游的数据缓存应该及时失效,并且针对旧数据的操作不应该直接应用于新数据上。一份数据源,可能被外部应用更新。如果缓存无法在第一时间知道【数据本源】的更新,那么它就会落后于实际数据,产生不一致。

不同的数据系统对于缓存不一致的容忍程度不同,缓存失效的策略也不同。

比如DNS系统,只需要保证用户最终能够读取到最新的IP地址(最终一致性)。修改DNS记录后不会在全球所有DNS服务节点生效,需要等待DNS服务器缓存过期后向源服务器请求新记录才能实现更新。

从web前端应用的视角来说,很多前端应用状态可以视为服务端数据源的缓存。一般来说前端应用能够在”自己主动提交更新的时候“更新前端状态。但是如果是一些外部事件造成服务端数据源的改变,大部分前端应用无法立刻知晓更新。大部分前端应用选择容忍这种缓存落后,仅在组件挂载时请求数据、更新状态,因为跨客户端/服务器做缓存失效的代价太大了。
缓存落后造成的典型问题有:”前端请求删除某资源时,服务端发现资源已经不存在,因此请求失败“。

作用域与生命周期

【数据本源】、缓存都需要考虑作用域与生命周期。

作用域就是对数据共享范围的考量;生命周期是对创建、销毁时机的考量。两者往往有很大的相关性。

常见的【数据】作用域划分方式:

  • 跨应用实例级别:多个标签页(应用实例)共享一个【数据】
  • 单应用实例级别:每个标签页(应用实例)内部有一个全局【数据】(也叫应用全局数据)
  • 应用局部级别:应用局部管理自己的【数据】,一个页面中可能包含多个独立的【数据】。比如:

    • 组件实例级别:每个组件实例拥有一份自己的【数据】。类似于对象属性。
    • 组件类级别:每一个组件类共享一份自己的【数据】。类似于类的静态属性。
    • 组件树级别:一颗组件树共享一份自己的【数据】。
    • 手动控制:你也可以在组件之间手动传递【数据】对象,更精确地控制【数据】的可见范围。当没有组件持有【数据】对象的时候,它就会被垃圾回收。
这里的【数据】可以指代【数据本源】,也可以指代【缓存】。

常见的生命周期划分方式:

  • 持久化,【数据】只能被应用主动删除。一般与“跨应用实例级别”的作用域配合。
  • 与页面生命周期同步,页面销毁时这个【数据】也销毁。一般与“单应用实例级别”的作用域配合。
  • 与组件生命周期同步,应用程序框架根据当前应用状态和输入,来创建、销毁组件,【数据】也随之创建和泯灭。一般与“组件实例级别”或“组件树级别”的作用域配合。
  • 与代码模块的生命周期同步。这种【数据】一般声明在代码模块顶部,或者作为类的静态成员。比如:

    const sharedCache = new Map();
    export const Component = class Component {
    // ...
    getData(key) {
      return sharedCache.get(key);
    }
    }
  • 手动创建、清除。比如第一次执行某种行为的时候创建【数据】,在应用路由到某个功能之外的时候清除。一般与“手动控制”的作用域配合。

识别常见的数据系统

在识别、设计数据系统的时候,对于每一个逻辑上的数据定义,应该先有一个明确的【数据本源】,然后衍生出多级缓存。下面列举一些常见的数据系统类型。

持久化存储作为【数据本源】

常见的持久化存储是文件系统、数据库。

举个例子,我们可以用数据库来存储用户的账号、姓名、邮箱等用户数据,将它作为【数据本源】。

这些地方可能包含用户数据的【缓存副本】:

- 数据库本身的缓存系统,由数据库内部实现
- 服务端应用内一般会使用**请求级别**的缓存:每次请求读取一次数据源,存到缓存(即变量)中,用来做计算。缓存的作用域和生命周期都是本次请求
- 客户端应用向服务端请求某个用户的数据以后,将结果保存在客户端应用状态中。**客户端中的很多应用状态,本质上都是服务端数据源的缓存**。当数据需要更新时,必须提交给服务端的【数据本源】。

持久化存储在软件系统在软件关闭时也能够保持数据,一般只能由应用主动删除。

计算公式作为【数据本源】

有一类数据,是可以基于其他数据来计算出来的,它的本源并不存在于硬盘或内存中。这种数据又称为衍生数据。对于这种衍生数据来说,如果在每次需要使用的时候都计算一次,一来可能造成性能问题,二来可能导致前后不一致,因此往往需要将计算结果缓存起来,并且要明确定义缓存的生命周期(比如软件重启、页面刷新时重新计算)。

举个例子,用户年龄是一种数据,但是并没有哪个会数据库会存储“用户现在多少岁”这个数据,它的【数据本源】是一个计算公式:当前时间-出生时间。前端应用一般在需要展示年龄的时候就计算一次,存到应用状态(本质上是内存中的缓存),然后在当前页面一直使用这个结果。

这个缓存的生命周期与页面生命周期一致,页面关闭时缓存也随之销毁。作一个极端的假设,这个页面打开使用超过了一年,那么就会出现缓存过时的问题(岁数应该增长了一岁),因此需要引入缓存失效的手段。最原始的缓存失效手段是,重启应用(即刷新页面),下次启动的时候重新计算最新的年龄。

这个缓存的作用域仅限于这个页面,如果有多个标签页同时打开了这个前端应用,那么每个页面都有一份自己的缓存,相互隔离,避免读取到同一个数据的两个缓存。

应用状态作为【数据本源】

对于前端应用来说,浏览器url是一种前端应用状态(只不过它由浏览器来管理,并提供操控API给前端应用代码)。前端应用根据不同的url状态来展示不同的功能,服务端不关心每个客户的url状态,因此url是前端应用的一种【数据本源】。前端应用一般会订阅url的更新,响应url的变化展示不同的页面组件。

在这里,前端url数据并没有明显的缓存的存在。理论上你可以每次需要使用这个数据的时候都访问数据本源window.location.href。有时候在路由框架中存在一份url缓存副本,只不过因为它订阅了url的更新,所以一般不会出现缓存落后的问题。

比如,前端应用可以识别这个模式的url来得到region参数:www.my-app.com/${region}/items。如果用户访问了url:www.my-app.com/cn-hangzhou/items,那么就相当于启动应用,并把region数据初始化为cn-hangzhou。url就是region数据的【数据本源】。如果用户在应用中通过操作按钮切换了region,前端应用逻辑就使用浏览器API来更新url(数据本源),然后,前端应用感知到url的更新,进而更新自己的行为。

这个例子也可以看出,需要更新数据的时候,应该更新【数据本源】,而不应该直接更新缓存。

由前端管理的【数据本源】还包括:页面的滚动状态、输入框的focus的状态等UI状态,无需提交给服务端。

由于这种数据本源就在本进程中,访问速度很快,因此一般不需要考虑缓存。主要需要考虑的是它的初始化方式作用域

常见的初始化方式

  • 可以直接初始化为一个默认值。
  • 可以读取应用启动参数来初始化数据本源。比如上面的例子,用户点击怎样的url来打开页面,决定了应用的初始region。对于命令行应用则可以读取命令行参数。
  • 可以在应用启动时读取外部状态来初始化数据本源。初始化以后就无需再考虑外部状态。当数据需要更新时,直接更新应用中的【数据本源】,这是它与”外部持久化存储作为数据本源“的根本区别

作用域已在前面的段落讨论。

相关阅读

下一篇文章:数据系统杂谈:React,数据一致性,计算与通信的本质

前端React相关:

ddia相关章节:


csRyan
1.1k 声望198 粉丝

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart doesn't find a perfect rhyme with the head, then your passion means nothing.