数据建模:第二部分 — 时间序列数据库方法
作者:Riccardo Tommasini / 开发者
2023 年 3 月 10 日
导航至
本文最初发表于 The New Stack,并经许可在此转载。您可以在此处找到本系列的第一部分。
随时间变化的实体可能包含多个随时间变化和静态的属性,这使得映射它们成为一项特殊的挑战。
时间在建模任务中是出了名的。事实上,时间方面加剧了建模任务的复杂性,使得简单的图表看起来非常复杂。当时间维度参与识别实体时,时间维度变得特别棘手。
右图可视化了典型的数据库示例:员工是在给定时间我们观察到的传统实体,当属性更新时,我们划掉该实体的先前版本。
然而,时间序列数据库 (TSDB) 是分析型的,因此为问题提供了更方便的视角。实际上,时间序列比时间实体更简单,但也有其自身的细微之处。
为了理解时间序列描述的是哪种实体,让我们思考一下像 Flux 这样的查询语言旨在满足什么样的信息需求。通常,在查询时间序列数据库时,我们希望在一个时间范围内整体观察一种现象。尽管每个序列元素都包含单独的时间戳,但我们关注的是大局——趋势。
我们试图表示的是哪种实体?
随时间变化的实体,即属性随时间变化的实体。
答案直接来自关于时间数据库的文献:随时间变化的实体是以随时间变化的属性为特征的实体——与属性类型关联的值的可变性是属性定义不可或缺的一部分。形式上讲,随时间变化的属性是从时间域到与属性类型关联的域的函数。
随时间变化的实体与传统实体不同,后者对瞬时视图进行建模。在实践中,随时间变化的实体描述了其所有随时间变化的属性随时间的变化。上图还显示了员工实体的时间变化解释,我们可以在其中观察到他的年龄和薪水随时间的变化。
为了更好地理解这个概念,让我们考虑以下场景:国家台球协会 (NBA) 想要跟踪年度锦标赛的撞球比赛。传统数据库设计会将球员和比赛表示为锦标赛的实体。但是,NBA 想要跟踪完整的比赛历史记录。直观地说,至少有两个随时间变化的属性表征一场比赛:任何给定时间每位球员的总得分。
由于静态实体通常转换为表(考虑到上面提到的建模技术),因此当涉及到随时间变化的实体时,我们可以认为关联 Measurement ⇐⇒ Entity 是有效的。由于随时间变化的实体可能包含多个随时间变化和静态的属性,我们需要了解如何将这些概念映射到 TSBD,特别是 InfluxDB 中。此外,实体的主键可能是复合的;在这种情况下,TSDB 对应物应保留属性的识别性质。
下表显示了我们提出的朴素映射。
概念 | 逻辑 | 物理 |
---|---|---|
随时间变化的实体 | Measurement(测量) | Measurement(测量) |
随时间变化的属性名称 | Field key(字段键) | Field key(字段键) |
随时间变化的属性值 | Field value(字段值) | Field value(字段值) |
属性名称(主键的一部分) | Tag key(标签键) | 带有 K 约定的标签键 |
属性值(主键的一部分) | Tag value(标签值) | 带有 K 约定的标签值 |
属性名称(不是主键的一部分) | 被引用 | 带有常量约定或外部表的字段键 |
属性值(不是主键的一部分) | 被引用 | 带有常量约定或外部表的字段键 |
值得注意的是,InfluxDB 按键和值索引标签。因此,它们的基数可能会影响性能。我们可以将不参与主键但与多个查询相关的属性视为标签,以加快查询执行速度。
此外,由多个静态属性描述的随时间变化的实体提出了一个有趣的建模挑战,该挑战与关系数据库设计中弱实体的概念有关。
弱实体是没有唯一标识它的主键的实体。因此,它必须依赖于强实体集才能进行唯一标识。
只要有可能区分实体的随时间变化方面和静态方面,我们就可以考虑将它们分开,并将静态方面表示为要外包到外部数据库的随时间变化的弱实体。实际上,InfluxDB 允许使用各种关系数据库来丰富时间序列。这种方法通过保持系列的简洁性来帮助避免冗余。
最后,尽管 Measurement ⇐⇒ Entity
关联看起来很实用,但这种关联并未提供关于 InfluxDB 抽象的完整视角,正如我们将在下文中看到的那样。
关系呢?
区分方面是随时间变化的属性是否表征关系。
与传统数据库设计一样,TSDB 关系在建模方面比实体略微复杂。直观地说,我们想要观察关系及其属性随时间的变化。但是,关系涉及多个参与方(通常是两个,但也可能是多个)。因此,我们必须区分情况。
- 静态实体之间的关系
- 随时间变化的实体与静态实体之间的关系
- 两个随时间变化的实体之间的关系
值得注意的是,这种表征与关系基数是正交的——一对一、一对多和多对多,这将变得更加清晰。但是,区分方面是随时间变化的属性是否表征关系。
让我们通过扩展我们的 NBA 示例来深入研究每个细节。假设台球协会想要扩展 IRA 分析。特别是,它想要监控桌面上每个球的相对位置以及碰撞和击球。台球具有独特的纹理,可以使用指向每个台球桌的特殊相机进行识别,并在比赛时校准到球桌。从数据库建模的角度来看,我们考虑两个实体,球和台球桌,以及多对多关系“位置”,该关系跟踪球在每个球桌上的分配和随时间的位置。
球和台球桌是静态实体,可以在实体集中使用唯一 ID 进行识别。与往常一样,实体具有额外的非识别属性,例如,球具有颜色。球和台球桌之间的关系描述了球相对于球桌中心的位置。
鉴于游戏的动态性质,时间注释表征了这种关系:我们属于情况 1——连接两个静态实体的随时间变化的关系。特别是,该关系具有两个随时间变化的属性 x 和 y,它们是球随时间的相对坐标。
在我们的 TSDB 设计中,我们可以像随时间变化的实体的情况一样表示关系。关系名称变为 measurement(测量),而随时间变化的属性的名称和值分别构成字段键和值。
鉴于不同球桌上的多场比赛可以使用相同的球,并且一个台球桌可以举办多场比赛(每场比赛都有多个球),因此该关系具有多对多基数。
在传统数据库设计中,我们将多对多关系转换为所谓的“复合实体”,这些实体通过从参与关系的实体借用元素来构建其主键。因此,复合键会产生多个标签键和值。
这种方法扩展到情况 2,即关系连接随时间变化的实体和静态实体。在这种情况下,我们有两种选择
- 如果关系具有随时间变化的属性,则构建一个全新的系列。
- 相反,如果关系本身没有随时间变化的属性,我们将对随时间变化的一侧进行建模的系列的标签集扩展为表示静态侧键的其他标签以及表示静态实体名称的其他标签。
当推理基数时,可以使用类似的方法。在关系建模中,我们将外键附加到一对一和多对一关系的任一侧。另一方面,在 TSBD 设计中,我们的推理基于关系中是否存在随时间变化的属性。
在某些情况下,通过将静态实体之间随时间变化的关系转换为与静态实体具有关系的随时间变化的实体来扩展我们的概念建模是方便的。例如,我们可以使用一个系列来表示 POSITION 随时间变化的实体,该系列捕获了这种关系随时间的状态。
与之前一样,我们可以使用 InfluxDB 的标签来表示系列中的这些关系,如右图所示。在概念层面上,该系列并未完全表示这两个静态实体。例如,它省略了诸如球颜色之类的属性。这个问题类似于我们讨论的关于随时间变化的实体的静态属性的问题。
让我们继续我们的 NBA 场景,表示我们的最后一个案例,即两个随时间变化的实体之间的关系。在这种情况下,我们建立在我们创建的扩展模式之上。现在,我们想要表示球之间的碰撞。为此,我们考虑 POSITION 实体之间的自关系。
虽然在概念层面上,很容易理解将碰撞表示为空间和时间中对象之间的关系,但这种关系的表示不太直观。实际上,COLLIDE 和 POSITION 之间的区别在于时间的作用,时间在前者中帮助识别单个碰撞。继续台球示例,但在一个大大简化的版本中,不考虑球的体积,我们可以将碰撞定义为两个球在给定时间点“t”出现在同一位置——在 t 时,ball1(x1,y1) 和 ball2(x2,y2) 使得 x1==x2 或 y1==y2。这种比较意味着基于时间的连接,比较每个时间点的球的位置。这种操作不仅在算法上成本高昂,而且由于流的无限性质而不可行。实际上,即使考虑到时间数据的顺序性质,比较范围仍然是无限的。
正如我们在“流数据简介”中所讨论的那样,处理无限性的常用方法是使用窗口运算符。因此,通过使用像 Flux 这样的连续查询语言,可以表示跨随时间变化的实体的随时间变化的关系。
如下面的查询所示,简化的台球示例的 Flux 查询仍然相当复杂。它需要对每个维度进行自连接以进行比较 (x,y)。
但是,最重要的方面是第 7 行中窗口运算符的使用,这降低了稍后比较的基数。修改窗口的值(60 秒是平均回合持续时间),我们将识别出不同数量的碰撞,因为比较范围将发生变化。
另一种实现可以使用 aggregateWindow
来对流元素进行降采样,但这会以检测精度为代价。在这种情况下,准确选择聚合函数至关重要。例如,“mean”将以不同于“last”的方式近似结果。
在查询中,我们使用透视来对齐连接条件,方法是比较维度。这种方法允许我们避免对每个组合进行成对比较。另一方面,它需要过滤掉那些表示“自碰撞”的条目,包括来自两个球的角度的碰撞。
最后,我们取消透视维度比较产生的系列,并使用最新的实验性 union,我们将两个系列合并在一起。
总之,对随时间变化的实体之间随时间变化的关系进行建模是一项高级任务,它取决于窗口大小参数。像 Flux 这样的连续查询语言使这成为可能,但该任务仍然相当复杂。最后,下表总结了我们对数据建模的分析。
摘要表
实体 | 实体 | 关系 (的属性) |
|
---|---|---|---|
静态 | 静态 | 静态 | 超出范围 |
静态 | 静态 | 随时间变化的 | 参见随时间变化的实体表 1,加上实体测量名称的标签 |
静态 | 随时间变化的 | 随时间变化的 | 参见随时间变化的实体表 1,加上实体测量名称的标签 |
静态 | 随时间变化的 | 静态 | 为右侧实体的键属性和左侧实体的名称向系列添加标签 |
随时间变化的 | 随时间变化的 | 随时间变化的 | 需要窗口化 |
import "influxdata/influxdb"
all = from(bucket: "training")
|> range(start: v.timeRangeStart, stop: v.timeRangeStop)
|> filter(fn: (r) => r["_measurement"] == "position")
|> group(columns: ["ball", "_field"], mode: "by")
|> window(every: 60s)
|> pivot(rowKey: ["_start", "_time", "_stop"],
columnKey: ["_field"], valueColumn: "_value")
//ball cannot be key otherwise i have only self collisions
all1 = all |> group(columns: ["_measurement"], mode: "by")
all2 = all |> group(columns: ["_measurement"], mode: "by")
//ball cannot be key otherwise i have only self collisions
collisionx = join(tables: {b1: all1, b2: all2}, on: ["_time", "x"])
|> filter(fn: (r) => r.ball_b1 != r.ball_b2)
|> map(fn: (r) => ({r with _measurement: "collision"}))
|> keep(columns: ["_measurement","_field","_value","_time","ball_b1", "ball_b2","x"])
|> group(columns: ["_measurement", "ball_b1", "ball_b2"])
|> experimental.unpivot()
collisiony = join(tables: {b1: all1, b2: all2}, on: ["_time", "y"])
|> filter(fn: (r) => r.ball_b1 != r.ball_b2)
|> map(fn: (r) => ({r with _measurement: "collision"}))
|> keep(columns: ["_measurement", "_field", "_value", "_time", "ball_b1", "ball_b2", "y"])
|> group(columns: ["_measurement", "ball_b1", "ball_b2"])
|> experimental.unpivot()
//remerge the two series and write
union(tables: [collisionx, collisiony]) |> influxdb.wideTo()