时间衰减数据
本文档介绍了在数据库可扩展性工作组中引入的时间衰减模式。我们讨论时间衰减数据的特征,并为 GitLab 开发提出在此背景下应考虑的最佳实践。
某些数据集会受到强烈的时间衰减效应影响,其中最近的数据访问频率远高于旧数据。时间衰减的另一个方面是:随着时间的推移,某些类型的数据会变得不那么重要。这意味着我们也可以将旧数据移动到耐用性较低(可用性较低)的存储中,或者在极端情况下直接删除数据。
这些效应通常与产品或应用程序语义相关。它们在旧数据的访问程度以及旧数据对用户或应用程序的有用性或必要性方面可能有所不同。
让我们首先考虑那些数据没有内在时间偏差的实体。
用户或项目的记录可能同等重要且频繁访问,与创建时间无关。我们无法通过用户的 id 或 created_at 来预测相关记录的访问或更新频率。
另一方面,具有极端时间衰减效应的数据集的一个很好的例子是日志和时间序列数据,例如记录用户操作的事件。
大多数情况下,这类数据在几天或几周后可能就没有业务用途,即使从数据分析的角度来看也会迅速变得不那么重要。它们代表了一个快照,该快照与应用程序当前状态的相关性越来越小,直到某个点完全失去实际价值。
在两种极端情况之间,我们可以找到包含我们想要保留的有用信息的数据集,但旧记录在创建后的初始(短暂)时间段内很少被访问。
时间衰减数据的特征
我们对表现出以下特征的数据集感兴趣:
- 数据集大小:它们相当大。
- 访问方法:我们可以通过时间相关维度或具有时间衰减效果的分类维度来过滤访问数据集的绝大多数查询。
- 不可变性:时间衰减状态不会改变。
- 保留策略:无论我们是否希望保留旧数据,或者旧数据是否应保持可通过应用程序访问。
数据集大小
可能有各种大小的数据集表现出强烈的时间衰减效应,但在本蓝图的背景下,我们打算关注具有相当大数据集的实体。
较小的数据集对数据库相关资源使用没有显著贡献,也不会给查询带来严重的性能损失。
相反,超过 5000 万条记录或 100 GB 的大型数据集,在持续访问数据中真正的一小部分时,会带来显著的开销。在这些情况下,我们希望利用时间衰减效应的优势,减少活跃访问的数据集。
数据访问方法
时间衰减数据的第二个也是最重要的特征是,大多数情况下,我们能够隐式或显式地使用日期过滤器访问数据,根据时间相关维度限制我们的结果。
可能存在许多这样的维度,但我们只关注创建日期,因为它是最常用的,也是我们可以控制和优化的维度。它:
- 是不可变的。
- 在记录创建时设置
- 可以与物理上聚集记录相关联,而无需移动它们。
重要的是要补充说明,即使应用程序默认不是通过这种方式访问时间衰减数据,你也可以让绝大多数查询以显式方式过滤数据。从优化的角度来看,没有这种时间衰减相关访问方法的时间衰减数据是无用的,因为没有办法设定和遵循扩展模式。
我们并没有将定义限制为总是使用时间衰减相关访问方法访问的数据,因为可能存在一些异常操作。这些操作可能是必要的,如果其他访问方法可以扩展,我们可以接受它们不扩展。例如:管理员访问特定类型的所有过去事件,而所有其他操作只访问最多一个月的事件,限制在 6 个月前。
不可变性
时间衰减数据的第三个特征是它们的时间衰减状态不会改变。一旦被认为是"旧"的,它们就不能切换回"新"或再次变得相关。
这个定义听起来可能很琐碎,但我们必须能够对"旧"数据执行更昂贵的操作(例如,通过归档或将它们移动到成本较低的存储),而无需担心切换回相关状态以及重要应用程序操作性能不佳的后果。
作为时间衰减数据访问模式的反例,考虑一个应用程序视图,该视图按问题更新的时间显示问题。我们也从"更新"的角度关注最新数据,但该定义是易变的且不可操作的。
保留策略
最后,一个进一步区分时间衰减数据为具有略微不同方法的子类别的特征是我们是否希望保留旧数据(例如,保留策略)和/或旧数据是否可通过应用程序被用户访问。
(可选)时间衰减数据的扩展定义
顺便一提,如果我们将上述定义扩展到基于聚类属性限制访问数据集中明确定义的子集的访问模式,我们可以将时间衰减扩展模式用于许多其他类型的数据。
例如,考虑仅在标记为活动时才被访问的数据,如未标记为完成的待办事项、未合并的合并请求的流水线(或类似的非时间约束)等。在这种情况下,我们使用分类维度(即使用有限值集的维度)而不是时间维度来定义衰减。只要该子集与数据集的总体大小相比很小,我们可以使用相同的方法。
类似地,我们可以根据时间维度和额外的状态属性将数据定义为旧数据,例如 6 个月前失败的 CI 流水线。
时间衰减数据策略
分区表
这是从纯数据库角度解决时间衰减数据的可接受的最佳实践。你可以在分区表文档页面中找到有关 PostgreSQL 表分区化的更多信息。
按日期间隔(例如月、年)进行分区化,允许我们为每个日期间隔创建更小的表(分区),并且只对任何与应用程序相关的操作访问最新的分区。
我们必须根据感兴趣的日期间隔设置分区键,这可能取决于两个因素:
-
我们需要回溯多长时间访问数据? 如果我们总是访问一年前的数据,按周分区化就没有用,因为每次我们必须在 52 个不同的分区(表)上执行查询。作为示例,考虑任何 GitLab 用户个人资料上的活动信息流。
相反,如果我们只想访问最近 7 天创建的记录,按年分区化会在每个分区中包含太多不必要的记录,就像
web_hook_logs的情况一样。 -
创建的分区有多大? 分区化的主要目的是访问尽可能小的表。如果它们本身变得太大,查询性能就会下降。我们可能需要重新分区(拆分)它们为更小的分区。
完美的分区方案保持对数据集的几乎所有查询几乎总是在单个分区上,有些情况跨越两个分区,偶尔跨越多个分区是可以接受的平衡。我们还应该目标分区尽可能小,每个分区最大不超过 5-1000 万条记录和/或 10 GB。
分区化可以与其他策略结合使用,以修剪(删除)旧分区,将它们移动到数据库内部的更便宜存储,或将它们移出数据库(归档或使用其他类型的存储引擎)。
只要我们不希望保留旧记录并使用分区化,与从巨大表中删除数据(如下面的子部分所述)相比,修剪旧数据的成本几乎为零,实际上为零。我们只需要一个后台工作程序在分区内的所有数据超出保留策略期限时删除旧分区。
例如,如果我们只想保留不超过 6 个月大的记录,并且按月分区化,我们可以安全地始终保持最新的 7 个分区(当前月份和过去 6 个月)。这意味着我们可以在每个月初让工作程序删除第 8 个最旧的分区。
在 PostgreSQL 中,通过使用表空间,将分区移动到数据库内部的更便宜存储相对简单。可以为每个分区单独指定表空间和存储参数,因此在这种情况下,方法是:
- 在更便宜、较慢的磁盘上创建新的表空间。
- 在该新表空间上设置更高的存储参数,以便 PostgreSQL 优化器知道磁盘较慢。
- 使用后台工作程序自动将旧分区移动到慢速表空间。
最后,将分区移出数据库可以通过数据库归档或将分区手动导出到不同的存储引擎来实现(更多细节在专门的子部分中)。
修剪旧数据
如果我们不想以任何形式保留旧数据,我们可以实施修剪策略并删除旧数据。
这是一个易于实现的策略,使用修剪工作程序删除过去的数据。作为我们下面进一步分析的示例,我们修剪超过 90 天的旧 web_hook_logs。
与大型非分区表相比,这种解决方案的缺点是我们必须手动访问和删除所有不再被认为相关的记录。由于 PostgreSQL 中的多版本并发控制,这是一个非常昂贵的操作。如果插入率超过某个阈值,修剪工作程序将无法赶上新记录的创建,正如撰写本文时的 web_hook_logs 情况。
由于上述原因,我们的建议是我们应该将任何数据保留策略的实现基于分区化,除非有强烈的不这样做的原因。
将旧数据移出数据库
在大多数情况下,我们认为旧数据是有价值的,因此不想修剪它们。同时,如果它们不需要任何数据库相关操作(例如,直接访问或在连接和其他类型的查询中使用),我们可以将它们移出数据库。
这并不意味着用户不能通过应用程序直接访问它们;我们可以将数据移出数据库,并使用其他存储引擎或访问类型,类似于卸载元数据,但仅适用于旧数据的情况。
在最简单的用例中,我们可以为最新数据提供快速直接访问,同时允许用户下载包含旧数据的归档文件。这是在 audit_events 用例中评估的选项。根据国家和行业,审计事件可能有很长的保留期,而只有过去几个月的数据通过 GitLab 界面被主动访问。
其他用例可能包括将数据导出到数据仓库或其他类型的数据存储,因为它们可能更适合处理这类数据。一个例子是 JSON 日志,我们有时将其存储在表中:将此类数据加载到 BigQuery 或像 Redshift 这样的列式存储中可能更适合分析/查询数据。
我们可以考虑多种将数据移出数据库的策略:
- 将此类数据流式传输到日志中,然后将它们移动到辅助存储选项或直接加载到其他类型的数据存储(作为 CSV/JSON 数据)。
- 创建一个 ETL 流程,将数据导出为 CSV,上传到对象存储,从数据库中删除这些数据,然后将 CSV 加载到不同的数据存储中。
- 使用数据存储提供的 API 在后台加载数据。
对于大型数据集,这可能不是一个可行的解决方案;只要可以使用文件进行批量上传,它应该优于 API 调用。
用例
Web hook 日志
相关史诗:分区化:web_hook_logs 表
web_hook_logs 的重要特征如下:
-
数据集大小:这是一个非常大的表。在我们决定分区化它(
2021-03-01)时,它大约有 527M 条记录,总大小约为 1 TB- 表:
web_hook_logs - 行数:约 527M
- 总大小:1.02 TiB (10.46%)
- 表大小:713.02 GiB (13.37%)
- 索引大小:42.26 GiB (1.10%)
- TOAST 大小:279.01 GiB (38.56%)
- 表:
-
访问方法:我们最多请求过去 7 天的日志。
-
不可变性:它可以按
created_at分区化,这是一个不会改变的属性。 -
保留策略:为其设置了 90 天的保留策略。
此外,当时我们试图使用后台工作程序(PruneWebHookLogsWorker)修剪数据,该工作程序无法跟上插入速率。
因此,在 2021 年 3 月,自 2020 年 7 月以来的记录仍未被删除,并且该表每天增加超过 200 万条记录,而不是保持相对稳定的大小。
最后,到 2021 年 3 月,插入率增长到每月超过 170 GB 的数据,并且持续增长,因此修剪旧数据的唯一可行方法是通过分区化。
我们的方法是按月分区化该表,因为它与 90 天的保留策略一致。
所需的过程如下:
-
确定分区键
在这种情况下使用
created_at列很简单:当存在保留策略且没有冲突的访问模式时,这是一个自然的分区键。 -
在我们确定分区键后,我们可以创建分区并回填它们(从现有表复制数据)。我们不能只是分区化一个现有表;我们必须创建一个新的分区表。
因此,我们必须创建分区表和所有相关分区,开始复制所有内容,并添加同步触发器,以便任何新数据或对现有数据的更新/删除都可以镜像到新的分区表中。
该过程需要 15 天 7.6 小时才能完成。
-
在初始分区化开始后的一个里程碑中,清理后台迁移的后续工作,完成执行任何剩余作业,重试失败的作业等。
-
将任何剩余的外键和二级索引添加到分区表。这使其架构与原始非分区表保持一致,然后我们可以在下一个里程碑中交换它们。
我们没有在开始时添加它们,因为它们为每次插入增加了开销,并且会减慢表的初始回填速度(在这种情况下超过 5 亿条记录,这可能显著增加)。因此,我们创建了一个轻量级的、普通的表版本,复制所有数据,然后添加任何剩余的索引和外键。
-
将基表与分区副本交换:这是分区表开始被应用程序主动使用的时候。
删除原始表是一个破坏性操作,我们希望确保在过程中没有问题,因此我们保留旧的未分区表。我们还反向切换同步触发器,以便未分区表仍与分区表上发生的任何操作保持最新。这允许我们在必要时交换回表。
-
最后一步,交换后的一个里程碑:删除未分区表
-
在未分区表被删除后,我们可以添加一个工作程序来实现通过删除过去分区来修剪策略。
在这种情况下,工作程序确保始终只有 4 个分区处于活动状态(因为保留策略是 90 天),并删除任何超过四个月的分区。我们必须保留 4 个月的分区,而当前月份仍然活动,因为回溯 90 天会到达第四个最旧的分区。
审计事件
相关史诗:分区化:审计事件的分区化策略设计和实现
audit_events 表与上一小节中讨论的 web_hook_logs 表有许多共同特征,因此我们关注它们不同的点。
共识是分区化可以解决大多数性能问题。
与大多数其他大型表不同,它没有主要的冲突访问模式:我们可以将访问模式切换为按月分区化。对于其他表来说情况并非如此,例如,即使可以证明分区化方法(例如按命名空间),它们也有许多冲突的访问模式。
此外,audit_events 是一个写入密集型表,对其读取(查询)非常少,并且具有非常简单的架构,与数据库的其余部分不连接(没有传入或传出的 FK 约束),并且只有两个索引定义在其上。
后者在当时很重要,因为没有外键约束意味着我们可以在仍处于 PostgreSQL 11 时分区化它。这不再是问题,因为现在我们已经将 PostgreSQL 12 作为必需的默认值,如上面的 web_hook_logs 用例所示。
分区化 audit_events 所需的迁移和步骤与上一小节中描述的 web_hook_logs 类似。目前没有为 audit_events 定义保留策略,因此没有在其上实施修剪策略,但我们将来可能会实施归档解决方案。
audit_events 案例有趣的一点是,我们必须遵循以实现 UI/UX 更改的必要步骤,这些更改旨在鼓励对分区化数据的最佳查询。它可以作为应用程序级别更改的起点,以将所有访问模式与特定的时间衰减相关访问方法保持一致。
CI 表
CI 表用例的要求和分析:仍在进行中。我们计划在分析推进后添加更多详细信息。