前端视角理解数据和缓存
对数据系统的理解
数据系统设计是关于数据存储、共享、更新(以及传播更新)、缓存(以及缓存失效)的技术。大部分软件系统都可以从数据系统的角度去理解。
数据系统是如此的普遍,以至于开发者实际上每天都在设计数据系统,却常常没有意识到它们的普适性,将多个本质相同的问题当作了孤立的问题来理解。应用状态管理、配置管理、用户数据管理问题,本质上都属于数据系统的问题。
本篇文章站在前端的视角上,通过对数据系统的讨论,希望帮助开发者在开发的过程中有意识地识别、设计数据系统:
- 哪些是数据本源、哪些是缓存?
- 数据本源在哪些组件之间共享?(即作用域多大?)
- 缓存在哪些组件之间共享?(即作用域多大?)
- 缓存的生命周期是多长?
- 客户端中的哪些应用状态可以视为服务端数据库的缓存?
本文的大部分例子是前端应用,但是数据系统的规则适用于任何软件系统。
单一数据源与层级缓存
任何数据系统都需要遵循一个原则: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来得到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相关:
- https://twitter.com/kentcdodd...
- https://twitter.com/dan_abram...
以上是 前端视角理解数据和缓存 的全部内容, 来源链接: utcz.com/a/31893.html