数据建模:第2部分 — 时序数据库建模方法
作者:Riccardo Tommasini / 开发者
2023年3月10日
导航到
本文最初发表在The New Stack上,并经授权在此重新发布。您可以在此处找到本系列的第1部分。
时变实体可能包含多个时变和静态属性,这使得映射它们成为一个特别的挑战。
时间在建模任务中臭名昭著。确实,时间维度加剧了建模任务的复杂性,使得简单的图表看起来相当复杂。当时间维度参与实体的识别时,它变得更加棘手。
右侧的图示展示了典型的数据库示例:员工是在特定时间观察到的传统实体,当属性更新时,我们会划掉实体的旧版本。
尽管如此,时间序列数据库(TSDBs)是分析性的,因此提供了对问题的更方便的视角。确实,时间序列比时间实体更简单,但有其自己的细微差别。
要了解时间序列描述的是哪种实体,让我们思考查询语言(如Flux)旨在满足哪些信息需求。通常,当我们查询时间序列数据库时,我们希望在一个时间范围内整体观察一个现象。尽管每个序列元素包含单独的时间戳,但我们关注的是整体情况——趋势。
我们试图表示的是哪种实体?
时变实体,其属性随时间变化。
答案直接来自时间数据库文献:时变实体是具有时变属性的实体——与属性相关联的值的变化是属性定义的组成部分。从形式上来说,时变属性是从时间域到与属性类型相关联的域的函数。
时变实体与传统的实体不同,后者建模的是瞬时视图。在实践中,时变实体描述了其在时间上所有时变属性的变化。上面的图也展示了员工实体的时变解释,我们可以观察到他的年龄和薪水随时间的演变。
为了更好地理解这个概念,让我们考虑以下场景:国家台球协会(NBA)希望跟踪年度锦标赛的台球比赛。传统的数据库设计会将球员和比赛作为锦标赛的实体。然而,NBA希望跟踪完整的比赛历史。直观地讲,至少有两个时变属性可以描述一场比赛:在任何给定时间点每个球员的得分总和。
由于静态实体通常转化为表(考虑上述建模技术),当涉及到时变实体时,我们可以考虑关联度测量⇒实体是有效的。由于时变实体可能包含多个时变和静态属性,我们需要了解如何将这些概念映射到TSBDs,尤其是InfluxDB。此外,实体的主键可能是复合的;在这种情况下,TSDB的对应物应该保留属性的身份识别性质。
下表展示了我们提出的简单映射。
概念性 | 逻辑性 | 物理性 |
---|---|---|
时变实体 | 测量 | 测量 |
时变属性名称 | 字段键 | 字段键 |
时变属性值 | 字段值 | 字段值 |
属性名称(主键的一部分) | 标签键 | 标签键(K约定) |
属性值(主键的一部分) | 标签值 | 标签值(K约定) |
属性名称(非主键的一部分) | 参考 | 字段键(常量约定或外部表) |
属性值(非主键的一部分) | 参考 | 字段键(常量约定或外部表) |
值得注意的是,InfluxDB 会对标签按键和值进行索引。因此,它们的基数可能会影响性能。我们可以将不参与主键但与多个查询相关的属性视为标签,以加快查询执行速度。
此外,由多个静态属性描述的时变实体提出了一个与关系数据库设计中的弱实体概念相关的建模挑战。
弱实体是没有主键以唯一标识它的实体。因此,它必须依赖于强实体集以实现其唯一识别。
每当可以区分实体的时变方面和静态方面时,我们可以考虑将它们分开,并将静态方面表示为要外包到外部数据库的时间变弱实体。实际上,InfluxDB 允许使用各种关系数据库进行时间序列丰富。这种方法通过保持序列简洁来避免冗余。
最后,尽管 测量 ⇐⇒ 实体
关联看起来很实用,但这种关联并不能完全反映 InfluxDB 抽象,正如我们将在下面看到的那样。
那么,关系呢?
区分的方面是时变属性是否描述了关系。
与传统数据库设计一样,时间序列数据库(TSDB)在建模关系时比实体更为复杂。直观地说,我们想要观察关系及其属性随时间的变化。然而,关系涉及多个参与者(通常是两个,但可能更多)。因此,我们必须区分这些情况。
- 静态实体之间的关系
- 时变实体与静态实体之间的关系
- 两个时变实体之间的关系
值得注意的是,这种描述与关系的基数——一对一、一对多和多对多正交——将变得更加清晰。然而,区分的方面是时变属性是否描述了关系。
让我们通过扩展我们的 NBA 示例来深入了解每个方面的细节。假设台球协会想扩展 IRA 分析。特别是,它想监控每个球在台上的相对位置以及在碰撞和击球时的位置。台球具有独特的纹理,允许使用指向每个台球的特殊摄像头进行识别,并在比赛时间校准到台上。从数据库建模的角度来看,我们考虑两个实体,球和台球桌以及多对多的“位置”关系,该关系跟踪球在每张桌子上的分配和时间位置。
球和台球桌是静态实体,可以使用唯一 ID 在实体集中进行识别。像往常一样,实体还有其他非标识属性,例如,球有颜色。球和台球桌之间的关系描述了球相对于桌中心的相对位置。
鉴于游戏动态的本质,时间标注描述了这种关系:我们处于第 1 种情况——连接两个静态实体的时变关系。特别是,这种关系有两个时变属性,x 和 y,它们是球随时间的相对坐标。
在我们的 TSDB 设计中,我们可以像处理时变实体一样表示这种关系。关系名称成为测量,而时间变属性的名字和值分别构成字段键和值。
鉴于多个不同的桌子上的比赛可以使用同一个球,以及台球桌可以举办许多比赛(每个比赛有几个球),这种关系具有多对多的基数。
在传统的数据库设计中,我们将多对多关系转化为所谓的“复合实体”,通过从参与关系的实体中借用元素来构建它们的主键。因此,复合键导致出现多个标签键和值。
这种方法也适用于第2种情况,即当关系连接一个时变实体和一个静态实体时。在这种情况下,我们有两种选择
- 如果关系具有时变属性,那么构建一个全新的系列。
- 相反,如果关系本身没有时变属性,我们通过添加代表静态侧键的额外标签和代表静态实体名称的额外标签来扩展表示时变侧的系列的标签集。
在推理基数时,也应用了类似的方法。在关系建模中,我们为一对一和多对一关系的一侧附加外键。另一方面,在TSBD设计中,我们的推理基于关系中是否存在时变属性。
在某些情况下,通过将静态实体之间的时变关系转换为与静态实体有关系的时变实体来扩展我们的概念建模是很方便的。例如,我们可以使用一个序列来表示这种关系随时间变化的状态,从而表示POSITION时变实体。
与之前一样,我们可以使用InfluxDB的标签来表示序列中的这些关系,如图中所示。在概念层面上,这个序列并不完全代表两个静态实体。例如,它省略了如球的颜色等属性。这个问题与我们之前讨论的时变实体的静态属性类似。
让我们继续我们的NBA场景,通过表示我们最后一个案例,即两个时变实体之间的关系。在这种情况下,我们建立在创建的扩展模式的基础上。现在,我们想表示球之间的碰撞。为此,我们考虑POSITION实体的自关系。
虽然在概念层面上,将碰撞表示为空间和时间中对象之间的关系很容易理解,但这种关系的表示却不太直观。事实上,COLLIDE和POSITION之间的区别在于时间的作用,在前者中时间有助于识别个别碰撞。继续使用台球示例,但这是一个大大简化的版本,不考虑球的大小,我们可以将碰撞定义为在给定的时间瞬间“t”两个球位于同一位置的发生——在t时刻,球1(x1,y1)和球2(x2,y2)满足x1==x2或y1==y2。这种比较意味着基于时间的基础连接,比较每个单独时间瞬间球的位置。这种操作不仅算法上昂贵,而且由于流的无限性而不切实际。事实上,即使考虑时间数据的顺序性质,比较范围仍然是无限的。
正如我们在“流数据处理简介”中所讨论的,解决无限性的常用方法是使用窗口操作符。因此,通过使用像Flux这样的连续查询语言,可以表示跨越时变实体的时变关系。
如下面的查询所示,简化台球示例的Flux查询仍然相当复杂。它需要对每个维度执行自连接以比较(x,y)。
然而,最重要的方面是在第7行使用窗口操作符,这减少了后续比较的基数。修改窗口的值(60秒是一回合的平均持续时间),我们将识别不同数量的碰撞,因为比较范围将改变。
另一种实现方式可以使用 aggregateWindow
对流元素进行下采样,但会以检测的精度为代价。在这种情况下,准确选择聚合函数至关重要。例如,“平均值”将结果近似得与“最后”不同。
在查询中,我们通过比较维度使用旋转来对齐连接条件。这种方法使我们能够避免对每个组合进行成对比较。另一方面,它需要过滤掉代表“自我碰撞”的条目,包括从球的角度来看的碰撞。
最后,我们将比较维度得到的序列进行逆旋转,并使用最近的实验性联合,然后将两个序列合并在一起。
总之,建模时变实体之间的时变关系是一个复杂任务,这取决于窗口大小参数。像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()