照片由 Mathew Schwartz 在 Unsplash 提供
要旨这是我们关于Medium的特征存储中新的列表特征类型的系列文章的第二部分,它描述了我们如何使用ScyllaDB实现列表数据层。请参阅第一部分:奠定基础,以了解特征存储和列表特征类型的介绍。
我们将首先介绍ScyllaDB,这是一个设计用于高性能和可扩展性的数据库,并概述理解本文其余部分所需的ScyllaDB概念:集群、节点、表、行、列、分区、二级索引和时间戳到期。
然后我们将描述我们为列表定义的 ScyllaDB 数据模型,以支持即将从列表操作中产生的大量负载。此数据模型基于一个存储所有列表中所有项目的单一表。该表的主键设计使得大多数列表操作可以依赖于一个高效且单一的 ScyllaDB 查询。
最后,我们将逐一介绍每个已识别的列表操作,并详细说明实现这些操作的ScyllaDB查询/查询,并描述这些查询如何因我们的数据模型而变得高效。
让我们开始吧!
关于ScyllaDBScyllaDB 是一款专为需要高吞吐量、低延迟和高可扩展性的用例设计的数据库,这使得它非常适合用作 Medium 特性存储的数据层。在本节中,我们将介绍一些您需要了解的 ScyllaDB 概念,以便更好地理解本文的其余部分。请参阅以下文档以了解更多信息:
- ScyllaDB 用户指南 涵盖了广泛的主题,包括 数据建模,查询,功能 等。
- ScyllaDB 架构 深入探讨了 ScyllaDB 如何实现其性能目标。
一个 集群 是 ScyllaDB 数据库的最高层级结构。它是由一系列相互连接的 节点 组成的,每个节点都是一台物理或虚拟服务器,用于存储和管理集群数据的一部分,以提供高可用性、可扩展性、性能和容错性。
一个 表 是由行和列组成的一种二维数据结构。表面上,这与关系型 SQL 数据库(如 MySQL 或 PostgreSQL)中的表类似。然而,一个重要的区别是,使用 ScyllaDB 你需要预先了解访问模式,以便定义适合这些访问模式的数据模型,因为 ScyllaDB 不像关系型数据库那样支持即席查询。特别是,定义正确的主键(见下文)对于在表上执行高效查询至关重要。
一行代表表中的单条记录。一行是由一个或多个列组成的集合,用于存储与该记录相关的数据。例如,orders
表中的一行可能代表用户“Alice”在2024年09月03日下的订单,而另一行可能代表用户“Bob”在2024年08月30日下的订单。表中的每一行通过其主键(见下文)唯一标识,主键由一个或多个列组成。
一 列 在一行中存储一个数据项。表中的每一列都有一个关联的名称和数据类型(BOOLEAN、INT、TEXT等;完整列表在这里),这些是在创建表时指定的。给定列的数据类型对于该列有值的所有行都是一样的。例如,orders
表可能包含以下列:
user_id
列,类型为 TEXT。date
列,类型为 TIMESTAMP。value
列,类型为 FLOAT。
(该表可能还有一些其他列,例如所订购的产品等。这里为了便于阅读省略了这些列。)
“Alice”用户在2024年9月3日创建的一笔订单对应的行可能包含以下列值:user_id:"Alice"
, date:2024-09-03
, 和 value:43.21
。
以下模式说明了到目前为止我们所涵盖的内容:
ScyllaDB 的基本概念及其关系
‘orders’ 表的示例行和列
分区和主键/分区键/集群键一张表被划分为多个 分区 ,每个分区通过一致性哈希映射到集群中的特定节点。表中的每一行根据其分区键(见下文)存储在表的一个分区中。这使得 ScyllaDB 表能够以可扩展且高效的方式存储大量数据(想想数太字节)。定义 ScyllaDB 数据模型的一个目标是尽可能均匀地将数据分布在表的各个分区中,以便对表的请求能够在集群的节点之间均衡分布。
表的分区分布在集群的节点上
行的主键用于在其表内唯一地标识该行。它由两部分组成:
- 分区键用于确定行所属的分区。它是必需的,并由一个或多个列组成。给定分区中的所有行共享相同的分区键值。例如,
orders
表的分区键可能由user_id
列组成;这样,某个用户的所有订单都会存储在同一分区中,并且我们可以期望user_id
值足够多样,以使数据均匀分布在各个分区中。 - 聚簇键用于在其分区内的行进行排序。它是可选的,并由零个(当不存在时)、一个或多个列组成。例如,
orders
表的聚簇键可能由date
列组成,这样我们就可以高效地检索某个用户在两个日期之间的订单。
‘orders’表的一个示例分区
次级索引默认情况下,ScyllaDB 拒绝基于非主键列的查询,因为这些查询的性能不可预测(最坏的情况是它们会扫描整个表)。二级索引 是附加到表上的一个额外的数据结构,它允许在该表的非主键列上进行高效的查询。一旦创建了二级索引,ScyllaDB 会透明地在每次向表中插入、更新或删除包含索引列值的行时更新该索引。
ScyllaDB 支持两种类型的二级索引:
- 全局二级索引 (GSI) 独立于其基础表进行管理。其数据分布在可能与存储表数据的节点不同的节点上。GSI 用于独立于表的分区键查询数据。
- 局部二级索引 (LSI) 与表共享相同的分区键,因此索引数据存储在与表相同的节点上。对于依赖于表的分区键以及非主键列的查询,LSI 比 GSI 更高效,因为此类查询可以由单个节点执行。
例如,在 orders
表的 value
列上创建一个全局二级索引(GSI),可以高效地执行如“获取具有给定值的所有订单”的查询。而在表的分区键加上 value
列上创建一个局部二级索引(LSI),则适合执行如“获取给定用户具有给定值的所有订单”的查询。
在‘orders’表的‘value’列上创建的一个示例二级索引
TTL (时间至失效)在任何给定的行中,每个不属于表主键的列都可以有一个关联的 time-to-live (TTL) ,定义为列值失效前的秒数。列值的TTL可以在插入或更新值时设置。一旦列值的TTL达到零,ScyllaDB会自动删除该值。如果一行中所有非主键列的值都已通过其TTL被删除,则ScyllaDB也会自动删除该行。
TTL(时间戳到期)在处理表中的大量数据时特别有用:通过为每一行设置TTL,我们有助于减少表使用的存储大小(从而降低存储成本),否则随着时间的推移,那些我们并不关心的数据可能会导致存储无限制地增长。
ScyllaDB 列表的数据模型现在我们对 ScyllaDB 有了更深入的了解,让我们来探讨一下我们用于列表的数据模型。本节基于在第 1 部分:奠定基础中定义的概念;如果您需要复习这些概念,请参考该部分。
目标和要求这里的目的是定义一个ScyllaDB数据模型,用于存储列表项,并使其能够以高效且可扩展的方式实现列表操作。特别是,该数据模型必须能够支持来自获取列表项和添加列表项操作的大量负载;如我们在第一部分中所见,我们预计每秒将获取10万到100万项的列表项,以及每秒将存储1万到10万项的添加列表项操作。
我们的用例分析还确定了以下需求:数据模型必须允许同一个列表在这些项目的所有值都不同的情况下,包含具有相同时间戳的多个项目。例如,对于user
实体的story_presented
列表功能,该功能列出了向特定用户展示的故事的ID(例如,通过故事预览)。在Medium中,有许多用户界面在一次展示多个故事(例如主页),因此客户端可以报告具有相同时间戳的多个故事展示。
我们的 ScyllaDB 数据模型中的列表依赖于以下 **list_items**
表:
CREATE TABLE list_items (
feature_key TEXT,
entity_id TEXT,
item_key TEXT,
value BLOB,
PRIMARY KEY ((feature_key, entity_id), item_key)
)
WITH CLUSTERING ORDER BY (item_key DESC)
AND DEFAULT_TIME_TO_LIVE = $defaultTTL;
这张表存储了所有由功能存储管理的列表功能的所有列表项。它包含以下列:
feature_key
: 用于标识列表功能的键,由列表的实体类型、功能名称和功能版本拼接而成:"$entityType#$featureName|$featureVersion"
。entity_id
: 列表所属实体的ID。item_key
: 用于标识列表中项目的键。详情见下文。value
: 列表项值的字节表示。这些字节的含义对数据模型来说是不透明的。
表的分区键由feature_key
和entity_id
两列组成,这样给定列表的所有项都会存储在同一 ScyllaDB 分区中。item_key
列用作表的聚簇键,与CLUSTERING ORDER选项一起使用,可以高效地按逆时间顺序检索列表项。
‘list_items’ 表的结构
‘item_key’ 列item_key
列是 list_items
表中用于标识列表中某项的键。此键是由以下部分组成的拼接字符串:
- 关键的第一部分是物品时间戳的字符串表示,以十进制形式表示自Unix纪元以来的纳秒数。如果需要,该字符串会用零填充到19个字符。对于纳秒级别的Unix时间戳,19位数字可以覆盖到2286年11月11日,这应该足够满足我们的使用场景。
- 第二部分是一个字符的分隔符‘#’。
- 第三部分也是最后一部分是物品值的MD5哈希,并被编码为Base64。
例如,以下 item_key
值:
1724949845430000000#cUEirgoj2hOWvLLGgJ8hrQ==
标识一个列表项,其时间戳设置为UTC时间2024年8月29日16:44:05,并且其值的MD5哈希值在Base64编码后为 cUEirgoj2hOWvLLGgJ8hrQ==
。
item_key
值的格式设计为使得列表中的项目按照其时间戳的顺序进行排序(得益于 list_items
表的聚簇键)。获取列表项操作(见下文)依赖于此功能,以丢弃时间戳早于其 minTimestamp
参数的项目;这是通过添加 WHERE item_key >= $minTimestampStr
子句实现的,其中 $minTimestampStr
是上述定义的 minTimestamp
的字符串表示形式。
由于 item_key
列是表的主键的一部分,这种格式也允许多个项目具有相同的时间戳,只要这些项目的值都是唯一的——这是我们系统的一个关键要求。对于这些项目的 item_key
列的第一部分,所有值都相同(它们的时间戳);而最后一部分(它们值的 MD5 哈希)始终是唯一的。
在下面的伪代码示例中,我们将使用与 item_key
列相关的以下函数:
**buildItemKeyTimestamp**
接收一个项目的时间戳作为输入,并返回该项目item_key
值的字符串表示形式,如上定义。**buildItemKey**
接收一个项目作为输入,并返回该项目的item_key
值,同样如上定义。
我们的 ScyllaDB 数据模型还依赖于 list_items
表上的一个 **list_items_by_value**
本地二级索引 (LSI):
CREATE INDEX list_items_by_value
ON list_items((feature_key, entity_id), value);
此 value
列的索引用于实现移除具有特定值的列表项的操作(详见该操作的详细说明)。使用局部二级索引而非全局二级索引可以确保该索引的数据与基础表的数据存储在同一节点上,这预计会比依赖全局二级索引更快,如 ScyllaDB 文档 中所述。
每个 list_items
表中的行都关联有一个时间到期值(TTL),该值设置为关联项的过期日期与该行插入表中的时间之间的持续时间。项的过期日期等于项的时间戳加上其列表功能的TTL。ScyllaDB会自动删除TTL已过期的行,从而确保 list_items
表的大小不会无限制地增长。
如何通过 TTL 来帮助我们在列表功能的生命周期内和生命周期后控制存储使用情况
list_items
表还配置了一个默认的 TTL 值(在上面的 CREATE 语句中用 $defaultTTL
表示)。这个默认的 TTL 用作保护措施,防止我们在插入没有 TTL 的行时(例如在执行回填操作时)不小心这样做,这样这些行就不会永远存在于表中。
我们与 ScyllaDB 团队合作定义了此数据模型。他们的解决方案架构师审查了 list_items
表定义的第一个草稿,并就以下主题提供了有用的反馈:
- 使用短列名在 ScyllaDB 中没有优势。正如我们将在本系列的第 3 部分中看到的,这与 DynamoDB 不同。
- 使用单个
feature_key
列来连接实体类型、特征名称和特征版本,预计会比使用单独的entity_type
、feature_name
和feature_version
列稍微快一些。 - 只要我们通过 LSI 获取的表行数少于 60%,
list_items_by_value
局部二级索引 (LSI) 将比对整个表进行普通扫描更高效。如果我们超过这个 60% 的阈值,则扫描可能更合适。 - 我们的数据模型同时使用了通过时间到期 (TTL) 功能自动删除行和通过 DELETE 查询显式删除行。在某些情况下,这可能会导致性能不佳。ScyllaDB 提出与我们合作,在我们有足够的生产数据时调整表的压缩。
我们现在来回顾一下如何在我们刚刚定义的ScyllaDB数据模型基础上实现列表操作。更多关于这些操作及其预期调用率的细节,请参阅第一部分:奠定基础。
与元数据相关的操作创建列表功能和创建列表功能版本操作完全由特征存储的现有元数据层处理;它们与列表数据模型独立,因此我们在这里不会讨论它们。
删除列表功能和删除列表功能版本操作主要涉及特征存储的元数据层。理想情况下,这些操作会启动从list_items
表中删除相关项(即,对于给定特征名称及其可选版本的所有实体的所有列表中的所有项)。然而,这些项的删除在我们的数据模型中没有高效的实现,因此删除列表功能和删除列表功能版本的操作不会启动这些删除。这是一个被接受的限制:list_items
表中每个项关联的时间戳确保了被删除列表中的项将在某个时间点自动从表中清除。
此操作检索给定列表中的项目。它具有以下输入参数:
entityType
: 列表所属实体的类型。entityID
: 列表所属实体的标识符。featureName
: 列表功能的名称。featureVersion
: 列表功能的版本(可选,默认为""
)。minTimestamp
: 要检索的列表项的时间戳下限。limit
: 要检索的列表项的最大数量。
在输出时,此操作返回由 entityType
、entityID
、featureName
和 featureVersion
参数标识的列表中的项。项按照其时间戳的逆时间顺序返回,从时间戳最高的项开始。时间戳早于 minTimestamp
的项将被丢弃。操作最多返回 limit
个项。
ScyllaDB 查询获取列表项的操作如下(其中 $_param_
占位符会被替换为相应的 _param_
输入参数的值):
SELECT value, item_key
FROM list_items
WHERE feature_key = '$entityType#$featureName|$featureVersion'
AND entity_id = $entityID
AND item_key >= ${buildItemKeyTimestamp(minTimestamp)}
ORDER BY item_key DESC
LIMIT $limit;
list_items
表的分区键 (feature_key
+ entity_id
) 确保此查询仅命中单个 ScyllaDB 分区,从而实现最高效率。而表的聚簇键 (item_key
,其值以项目的 timestamp 开头) 则用于 (a) 根据项目的 timestamp 逆时间顺序对项目进行排序,利用了“CLUSTERING ORDER”表选项(参见上面的 list_items
表定义),以及 (b) 删除比 minTimestamp
更早的项目。
示例获取列表项操作
添加列表项此操作将一组项目添加到给定列表中。它具有以下输入参数:
entityType
: 列表所属实体的类型。entityID
: 列表所属实体的标识符。featureName
: 列表功能的名称。featureVersion
: 列表功能的版本(可选,默认为""
)。items
: 要添加的项目集。每个项目由其值和时间戳描述。
添加列表项操作不返回任何内容。
它实现如下:
BEGIN BATCH
-- 对于 $items 中的每个 $item:
INSERT INTO list_items(feature_key, entity_id, item_key, value)
VALUES (
'$entityType#$featureName|$featureVersion',
$entityID,
${buildItemKey(item)},
${item.Value},
)
USING TTL ${item.Timestamp + ttl - now};
APPLY BATCH;
每个项目都是通过 INSERT 语句添加到表中的。所有的 INSERT 语句通过一个单独的 BATCH 语句发送并执行。该批处理操作是作为 记录的(默认行为)来执行,以确保要么所有插入操作都完成,要么都不完成;这种原子行为是由所有插入操作都针对同一个分区(由 feature_key
和 entity_id
列定义)这一事实所实现的。
示例添加列表项操作
删除具有特定值的列表项此操作会删除列表中所有具有给定值的项。它具有以下输入参数:
entityType
: 列表所属实体的类型。entityID
: 列表所属实体的标识符。featureName
: 列表特征的名称。featureVersion
: 列表特征的版本(可选,默认为""
)。value
: 要移除的值。
删除具有特定值的列表项操作不返回任何内容。
与所有其他列表操作不同,这些操作都是通过向 ScyllaDB 发送单个请求来实现的,移除具有特定值的列表项操作需要依次执行两个步骤。第一步 运行以下 ScyllaDB 查询以获取要删除项的 item_key
值:
SELECT item_key
FROM list_items
WHERE feature_key = '$entityType#$featureName|$featureVersion'
AND entity_id = $entityID
AND value = $value;
这个第一个查询依赖于 list_items
表上的 list_items_by_value
局部二级索引来高效地识别具有指定值的项目。使用此索引预计会比扫描整个表更快,因为查询具有很高的选择性:具有指定值的列表项数量应该远低于总列表项数量。
一旦检索到 item_key
值,第二步 将运行一批 DELETE 语句来删除这些项:
-- 对于每一批 item_key 值:
DELETE FROM list_items
WHERE feature_key = '$entityType#$featureName|$featureVersion'
AND entity_id = $entityID
AND item_key IN ($itemKeyBatch);
每个批次最多包含10个 item_key
值。这种方法介于以下两种方法之间:(a)运行一个包含所有(未限制的)item_key
值的“WHERE … IN …”子句的单个 DELETE 语句,以及(b)为每个要删除的项目运行一个 DELETE 语句。随着要删除的项目数量的增加,(a)和(b)的性能预计会非线性下降。
示例:带有值的删除列表项操作
删除所有列表项此操作会删除列表中的所有项。它具有以下输入参数:
entityType
: 列表所属实体的类型。entityID
: 列表所属实体的标识符。featureName
: 列表功能的名称。featureVersion
: 列表功能的版本(可选,默认为""
)。
删除所有列表项操作不返回任何内容。
它通过依赖于 list_items
表的分区键(feature_key
+ entity_id
)使用单个 DELETE 语句实现:
DELETE FROM list_items
WHERE feature_key = '$entityType#$featureName|$featureVersion'
AND entity_id = $entityID;
由于所有项目都在同一个分区中,ScyllaDB 能够原子地执行删除操作:要么删除所有行(即整个分区),要么根本不删除。
示例删除所有列表项操作
总结和下一步步骤感谢您坚持到这一步!我们已经涵盖了相当多的内容,让我们来回顾一下。
ScyllaDB 是一种数据库,设计用于需要高吞吐量、低延迟和高可扩展性的用例。为了实现高效的查询,ScyllaDB 表必须定义一个适合表数据访问模式的主键。主键由分区键组成,该键定义了表中的行如何映射到分区,以及一个可选的排序键,该键定义了行在分区内的排序方式。依赖非主键列的数据访问模式可以通过使用二级索引来实现高效访问。ScyllaDB 表中的列值可以关联一个时间戳(TTL),该时间戳定义了一个持续时间,在此之后 ScyllaDB 将自动删除该值(以及它所属的行)。
我们为Medium的特性存储中定义的列表所使用的ScyllaDB 数据模型 依赖于一个名为**list_items**
的单个表,该表存储了特性存储管理的所有列表的所有项目。该表的每一行存储单个列表项的数据。表的分区键标识每个项目所属的列表;其值由列表的实体类型、特性名称、特性版本和实体ID组成。因此,给定列表的所有项目都存储在同一ScyllaDB分区中,这使得对该列表的查询执行更加高效。表的聚簇键设计目标为:(a) 使按反向时间顺序检索列表项更加高效,(b) 支持给定列表可以包含具有相同时间戳但所有值都不同的多个项的要求。**list_items_by_value**
二级索引使对表的value
列的查询更加高效,该列不是表主键的一部分。list_items
表的每一行都关联了一个TTL,以确保表的大小不会因为不再关心的数据而无限制地增长。
数据模型的一个接受的限制是,它没有提供一种高效的方式来删除与给定列表功能或列表功能版本相关联的所有项目(对于删除列表功能和删除列表功能版本的操作)。通过强制行TTL(time-to-live,时间戳),可以确保这些列表项将在某个时间点从表中自动删除。
列表操作的实现相对简单,这得益于我们定义的数据模型。除了“移除具有特定值的列表项”之外,所有操作都只需向ScyllaDB集群发送单个请求。“获取列表项”操作依赖于表的分区键来识别列表,并依赖于表的排序键来按逆时间顺序获取项,同时设置项时间戳的下限。“添加列表项”操作通过批处理语句原子地将请求的列表项插入表中。“移除具有特定值的列表项”是唯一不能用单个请求实现的操作;它首先通过依赖于list_items_by_value
二级索引来获取要删除项的排序键值,然后发送批处理删除请求来删除这些项。“移除所有列表项”操作请求删除具有给定分区键值的所有行,ScyllaDB能够原子地执行此操作。
系列的下一篇文章将描述我们如何使用DynamoDB实现列表功能的数据层。正如我们将看到的,ScyllaDB和DynamoDB之间有许多相似之处,但也有一些微妙的差异。敬请期待了解更多详情!
共同學習,寫下你的評論
評論加載中...
作者其他優質文章