ScyllaDB实战——ScyllaDB的数据类型

359 阅读58分钟

本章内容:

  • ScyllaDB 用于存储字符串、数字、日期、ID 等的基本数据类型
  • ScyllaDB 用于聚合数据的集合类型
  • 何时使用这些数据类型,以适应你的设计需求

在上一章中,你学习了查询优先设计(Query-first design),并使用它将你的餐厅评论数据库扩展为完整餐厅评论应用程序的数据库架构的初步构建。从需求出发,你确定了应用程序需要运行的查询、这些查询涉及的表以及这些表的主键。本章的结尾留下了一个悬念:这些表中应该存储什么数据?与这些数据相关的数据类型是什么?在本章中,你将找到这些谜题的答案。

在本章中,你将关注查询优先设计的第五个问题(图 4.1):这些表中应该存储什么数据?为了回答这个问题,你需要了解 ScyllaDB 支持的数据类型,以便在你的架构中最佳地表示数据。

image.png

数据建模的最基本构建块是值和你赋予它们的类型。在 Scylla 中,每一列都有一个类型,且该列中的每个值都共享这个类型。Scylla 支持丰富的类型,不仅限于数字和文本。在本章中,你将探索如何使用这些工具为你的数据赋予意义。

4.1 为自己做准备

为了让你的学习更具实践性,你将为本章创建一个临时的 Keyspace,来尝试 Scylla 的数据类型。当你学习不同的数据类型时,你将在你的数据类型实验环境中看到它们的实际应用。通过设置新的 Keyspace 和表格,你可以继续确定餐厅应用所需的数据类型。不过,在此之前,你需要重新启动第二章中的数据库集群,并创建一个新的数据类型探索 Keyspace。

4.1.1 数据类型实验环境

为了开始创建你的实验环境,如果你的集群还没有运行,首先重新启动第二章中的 Docker ScyllaDB 集群。你可以使用 docker start 命令重新启动任何已停止的容器:

docker start scylla-1

scylla-2scylla-3 重复相同的过程,直到你的集群重新启动。注意,你也可以使用 docker-compose up 命令,通过代码仓库中附带的 compose.yml 文件来恢复你的集群。

为了实验数据类型,你将首先创建一个新的 types Keyspace 来存放一些测试表。你可以将复制因子设置为 1;这个 Keyspace 仅用于实验:

cqlsh> CREATE KEYSPACE types WITH replication =
  {'class': 'SimpleStrategy', 'replication_factor': 1};

如果你在 cqlsh 中指定使用新创建的 Keyspace,你就不需要在表名之前加上 Keyspace 名称。假设你也运行了以下命令:

cqlsh> USE types;

仔细观察,你会看到 cqlsh 提示符已经更新,显示你正在使用 types Keyspace,像这样:

cqlsh:types>

创建了 Keyspace 后,你可以创建一个包含 ScyllaDB 支持的多种数据类型的表(见列表 4.1)。这里没有包括所有类型,但你将在本章的后面部分学习到它们。你可以利用这个表格来探索和操作 ScyllaDB 提供的不同类型。暂时不必过于担心所有这些类型的含义,它们会很快讲解。如果你是那种喜欢在看电影前查找剧透的人,我已经在代码中注释了每个数据类型的解释。

列表 4.1 用于实验数据类型的表

cqlsh:types> CREATE TABLE basic(
    text TEXT,         #1

    ascii ASCII,       #2

    bigint BIGINT,     #3

    blob BLOB,         #4

    boolean BOOLEAN,   #5

    date DATE,         #6

    decimal DECIMAL,   #7

    double DOUBLE,     #8

    duration DURATION, #9

    float FLOAT,       #10

    inet INET,         #11

    int INT,           #12

    smallint SMALLINT, #13

    time TIME,         #14

    timestamp TIMESTAMP,#15

    timeuuid TIMEUUID, #16

    tinyint TINYINT,   #17

    uuid UUID,         #18

    varchar VARCHAR,   #19

    varint VARINT,     #20

    PRIMARY KEY (text)
);

注释:

  1. TEXT 是 UTF-8 编码的字符串。
  2. ASCII 是 ASCII 编码的字符串。
  3. BIGINT 是 64 位有符号整数。
  4. BLOB 存储任意字节。
  5. BOOLEAN 是传统的布尔值(真或假)。
  6. DATE 表示日期。
  7. DECIMAL 表示具有任意精度的浮点数,能够进行精确计算。
  8. DOUBLE 是双精度浮点数。
  9. DURATION 是时间长度。
  10. FLOAT 是单精度浮点数。
  11. INET 表示一个 IPv4 或 IPv6 格式的 IP 地址。
  12. INT 是 32 位有符号整数。
  13. SMALLINT 是 16 位有符号整数。
  14. TIME 表示一个不带日期的时间点。
  15. TIMESTAMP 结合日期和时间。
  16. TIMEUUID 是版本 1 的 UUID。
  17. TINYINT 是 8 位有符号整数。
  18. UUID 表示任何有效的 UUID,但通常是版本 4 UUID。
  19. VARCHAR 是 UTF-8 编码的字符串。
  20. VARINT 是可变精度的整数。

现在,你已经有了一个包含几乎所有 ScyllaDB 基本数据类型的实验环境。看到这些列后,你可能担心每次插入数据时都需要填充每个列。幸运的是,Scylla 中除了主键列之外,所有列都是可空的。你可以跳过设置值,Scylla 不会插入 null,而是完全跳过该列的写入。这对学习很有帮助!

通过这个数据类型实验环境,你可以实际尝试你所学到的内容,同时继续实施查询优先设计,并为餐厅评论应用确定你的列。让我们回顾一下并开始学习数据类型!

4.1.2 确定字段

在上一章中,你学习了如何应用查询优先设计(query-first design)来为餐厅评论应用开发数据库架构。在章节的最后,你已经根据需求设计了查询和表格,如图 4.2 所示。

image.png

你从以下需求开始:

  • 作者发布文章到网站。
  • 用户查看文章以阅读餐厅评论。
  • 文章包含标题、作者、评分、日期、图片画廊、评论文本和餐厅信息。
  • 评论的评分在 1 到 10 之间。
  • 主页显示按最新排序的文章摘要,展示标题、作者名、评分和一张图片。
  • 主页链接到各个文章。
  • 作者有专门的页面,展示其文章的摘要。
  • 作者有名字、简介和照片。
  • 用户可以查看按评分排序的文章摘要列表。

根据这些需求,你将应用程序需要执行的查询与这些查询将执行的表格进行了映射,如表 4.1 所示。

表 4.1 表与查询的映射

表格查询
articles创建文章、读取文章
authors创建作者、读取作者
article_summaries_by_author创建文章摘要、按作者读取文章摘要
article_summaries_by_date创建文章摘要、按日期读取文章摘要
article_summaries_by_score创建文章摘要、按评分读取文章摘要

在这一章中,你的重点是确定这些表中需要存储哪些数据。你不仅需要字段,还需要为每一列指定数据类型,以便适当地结构化数据。

再次查看需求,你可以通过列出所需字段来达到一半的目标。第三个需求——文章包含标题、作者、评分、日期、图片画廊、评论文本和餐厅——立刻非常有帮助,它列出了你需要存储的各种字段。其中一些字段看起来很简单,比如标题和评论文本,但如何在 ScyllaDB 列中表示图片画廊呢?不用担心,你将在本章后续学习到如何实现这一点。

按照作者、日期和评分排序的文章摘要表格,字段也相似。需求中提到,用户将查看展示标题、作者名、评分和一张图片的摘要。因此,可以推测,文章和摘要中字段使用的数据类型将非常相似,但具体的类型是什么呢?

作者也需要字段。需求中提到,作者有名字、简介和照片。此外,还有一些隐性字段,这些字段并未直接出现在需求中,但它们是实现需求所必需的,比如主键字段和分区键字段。你需要一个文章 ID、文章摘要 ID、作者 ID 以及文章摘要日期。综合起来,你需要表 4.2 所列的字段。

表 4.2 表与字段的映射

表格字段
articlesID、标题、作者 ID、评分、日期、图片画廊、评论文本、餐厅
authorsID、名字、简介、图片
article_summaries_by_author作者 ID、文章 ID、标题、作者名字、评分、图片
article_summaries_by_date日期、文章 ID、标题、作者名字、评分、图片
article_summaries_by_score评分、文章 ID、标题、作者名字、图片

有了这些字段,现在你只需要确定数据类型。虽然你可能能猜到其中一些——比如,评论文本不可能存储为整数——但接下来我们将使用这些字段来介绍 Scylla 的数据类型。首先来看 Scylla 如何处理文本和数字类型。

4.2 最常用的数据类型:文本和数字

文本和数字是许多应用程序中最常用的数据类型。然而,Scylla 和 CQL 对它们的表示方式有所不同,提供了不同的大小和变体来满足你的需求。

你可以将多个字段存储为文本或数字值,例如:文章标题、评论文本、餐厅、评论评分、作者名字、作者简介等。我们首先来看 Scylla 支持的文本类型。

4.2.1 文本类型

当你需要存储基于文本的字段——如作者名字、作者简介、文章文本、餐厅名称——时,ScyllaDB 提供了两种基于字符串的类型供数据建模者选择:

  • TEXT——UTF-8 编码的字符串(VARCHAR 也可以作为别名,因为这个名称在 SQL 数据库中常用于表示该数据类型)
  • ASCII——ASCII 编码的字符串

为了观察这些文本类型的实际应用,你可以将 TEXTASCII 插入到你的基础数据类型沙箱中。那么,为什么会有多个文本类型呢?如果它们都能处理有效的文本,为什么 Scylla 提供多个选项?ASCII 和 UTF-8 的区别在于,ASCII 字符仅限于 128 种选择,而 UTF-8 字符支持超过一百万种可能性。为了演示这两种类型,你可以分别插入每种类型的数据。

请注意,在以下示例中,每个字符串都需要用单引号括起来:

cqlsh:types> INSERT INTO basic(text, ascii)
  VALUES ('utf-8 encoded', 'ascii encoded');

如果你读取你插入的文本,你会看到插入的有效文本。每个字段包含你传入的值,这对于数据库来说是一个好事:

cqlsh:types> SELECT text, ascii FROM basic
  WHERE text = 'utf-8 encoded';

@ Row 1
-------+---------------
 text  | utf-8 encoded
 ascii | ascii encoded

(1 rows)

如果你尝试将一个非 ASCII 字符串(如“résumé”)插入到 ASCII 类型的字段中,你会期望看到某种错误,果然会出现错误:

cqlsh:types> INSERT INTO basic(text, ascii)
   VALUES ('résumé', 'résumé');

InvalidRequest: Error from server: code=2200 [Invalid query]
message="marshaling error: Value not compatible with type
org.apache.cassandra.db.marshal.AsciiType: 'résumé'"

数据类型不仅为你的模式提供结构,它们还提供验证。如果数据库能够防止你因类型错误而产生问题,这将增强数据的完整性。许多 Scylla 类型包含验证功能,防止你将无效值写入数据库。

一般来说,你应该避免使用 ASCII。很多软件bug都是由系统预期 ASCII 却得到 UTF-8 引起的。除非你有充分的理由使用 ASCII 类型,否则应该优先使用 TEXT 类型。因此,像文章评论、作者名称等基于文本的字段都应该使用 TEXT 类型。确定这一点之后,你可以查看哪些字段已经确定了数据类型,哪些仍需要指定类型,如表 4.3 所示。

表 4.3 你至今为字段选择的类型

字段类型
Article ID
Article titleTEXT
Article author ID
Article score
Article date
Article image gallery
Article review textTEXT
Article restaurantTEXT
Author ID
Author nameTEXT
Author bioTEXT
Author image

你已经为多个字段选择了合适的数据类型;你经常使用文本字段,而 TEXT 类型非常适合。接下来,我们来看看 ScyllaDB 如何存储数字类型,以及你可以选择的保存评论评分的方式。

4.2.2 数字类型

为了存储评论评分,你希望将其保存为数字。虽然直觉上我们可能认为数字除了它们的值之外没有其他区分特征,Scylla 提供了多种数字存储类型,具有不同的大小和精度。Scylla 有四种整数类型,按大小从小到大排列:TINYINT、SMALLINT、INT 和 BIGINT(见表 4.4)。这些都是有符号整数,意味着它们既可以表示正数也可以表示负数。

表 4.4 CQL 中的四种整数类型

类型大小最小值最大值
TINYINT8 位-128127
SMALLINT16 位-32,76832,767
INT32 位-2,147,483,6482,147,483,647
BIGINT64 位-9,223,372,036,854,775,8089,223,372,036,854,775,807

如果你将这些类型插入到数据库中,你会看到它们按预期工作:

cqlsh:types> INSERT INTO basic(text, tinyint, smallint, int, bigint)
  VALUES ('integers', 6, 10000, 50000, 3000000000);

当你读取这些数据时,你会得到你插入的数字:

cqlsh:types> SELECT text, tinyint, smallint, int, bigint
  FROM basic WHERE text = 'integers';

@ Row 1
----------+------------
 text     | integers
 tinyint  | 6
 smallint | 10000
 int      | 50000
 bigint   | 3000000000

(1 rows)

基于你对 ASCII 类型的经验,前表中的最小值和最大值可能会让你猜测 Scylla 会验证每个整数,确保它在支持的范围内。你是对的;如果你尝试将一个过大的值插入到 TINYINT 类型字段中,作为最小的整数类型,Scylla 会拒绝插入并抛出数据库错误:

cqlsh:types> INSERT INTO basic(text, tinyint)
  VALUES ('not so tiny int', 5000);

InvalidRequest: Error from server: code=2200 [Invalid query]
message="marshaling error: Value out of range for the type
org.apache.cassandra.db.marshal.ByteType: '5000'"

当数值超出范围时,Scylla 会阻止你插入它。你可以尝试对其他整数类型进行相同的操作,看看它们是如何处理的。

你的评论评分是一个介于 1 和 10 之间的数值,但 7.5 是否有效?并非每个数字都是整数,所以 Scylla 还支持浮动数字类型,通过其 FLOATDOUBLE 类型。FLOAT 是 32 位的单精度浮动点数,而 DOUBLE 是 64 位的双精度浮动点数(它们都采用标准的 IEEE 754 编码)。让我们看一下它们的实际应用,并将每种类型的数据插入数据库:

cqlsh:types> INSERT INTO basic(text, float, double)
  VALUES ('floats', 123.4, 567.8);

同样,当你读取浮动点数时,它们会是你插入的数字:

cqlsh:types> SELECT text, float, double FROM basic WHERE text = 'floats';

@ Row 1
--------+--------
 text   | floats
 float  | 123.4
 double | 567.8

(1 rows)

然而,仅仅因为数字有小数点,并不意味着它应该被存储为 FLOATDOUBLE 类型。这些数字有有限的大小,并且由于它们有限的精度,有时无法精确表示,导致舍入误差。你可能在其他编程语言中遇到过浮动点运算的不精确结果。为了解决这个问题,你有两个选择:

  • 你可以将数字表示为整数,之后在显示时转换为浮动点数(例如,显示给用户时)。存储货币金额的常见做法是将它们存储为基本单位(例如,美元的分),然后在显示时格式化值为字符串。
  • 你可以使用 CQL 的 DECIMAL 类型。DECIMAL 类型支持任意宽度,意味着它会根据需要增长,以精确表示其值。

当以小数点形式存储数据时,如果精度很重要——比如你要存储货币金额——你可以使用 DECIMAL 类型来确保准确性。

另一种准确性的形式是能够精确表示非常大的值。BIGINT 可以存储高达 9,223,372,036,854,775,807 的数值,但如果你需要超出这个范围怎么办?此时 DECIMALVARINT 就派上用场了;它们可以存储任意大的值。尽管这种情况比较少见,但拥有一个能满足非常非常大的数值需求的类型也是非常有用的。

选择数字类型时,重要的是选择最适合你应用场景的类型。如果你预计某个字段的值会超过 20 亿,就不要使用 INT 类型。与此同时,如果 TINYINT 可以满足某个字段的需求,你也应该避免使用 BIGINT。使用过大的数字类型会浪费数据库空间,而使用过小的数字类型会导致查询失败,当数字超过其数据类型的上限时就会出错。因此,在选择数据类型时,务必仔细考虑当前值的范围以及未来可能增长的范围。

尽管有这么多种数字类型,你只需要选择其中一个。哪个最适合用作评论评分?根据你的需求,评论评分是一个介于 1 和 10 之间的数字。考虑到之前的建议,你需要使用一个适合你需求的数据类型。不过,需求中有些不明确:7.5 是一个有效的值。

你可以支持 7.5,但你不太可能支持评分为 7.54:你的评分系统可能没有足够的精度来精确到小数点后两位。TINYINT 是一个很好的选择;如果你将评分存储为 1 到 100 之间的整数,你可以在应用程序中将其转换为 10 分制,这样就可以避免浮动点数的问题。做出这一决定后,你离确认所有数据类型的选择又近了一步(见表 4.5)。

表 4.5 为每个字段选择的数据类型,已选择 TINYINT 作为文章评分的类型

字段类型
Article ID
Article titleTEXT
Article author ID
Article scoreTINYINT
Article date
Article image gallery
Article review textTEXT
Article restaurantTEXT
Author ID
Author nameTEXT
Author bioTEXT
Author image

在查看剩余字段时,你会看到一些可能合适的类型,如数字或文本。如果你熟悉使用自增整数作为主键中的 ID,你可能会想到采用这种方式;或者,你可能会认为可以将日期存储为文本(例如:'2023-07-23')。等等!ScyllaDB 还提供了许多其他类型来满足你的需求,包括几种处理时间的方式。接下来,我们将探讨 Scylla 如何表示日期和时间,以便找出最适合存储文章日期的方式。

4.3 日期和时间

作为程序员,处理时间通常是一个棘手的任务——时区、闰秒以及时钟不同步等问题,使得时间的处理充满了坑洼,几乎每天的秒数都可能带来新的挑战。为了简化日期和时间的存储,ScyllaDB 提供了几种以时间为中心的数据类型(见表 4.6),帮助你更方便地存储文章的日期。

当你需要存储一个具体的时间点时,ScyllaDB 提供了四种类型:

  • TIMESTAMP:日期和基于 UTC 的时间
  • DATE:只有日期没有时间
  • TIME:只有时间没有日期
  • DURATION:一个没有关联日期的时间长度

表 4.6 时间类型

类型存储形式可接受的格式
TIMESTAMPBIGINT 表示自 Unix 纪元(1970-01-01 00:00:00+0000)以来的毫秒数1687654894759、'2023-06-24 21:02:00+0000' 等 ISO-8601 格式的时间戳
DATE32 位无符号整数,表示自 Unix 纪元(1970-01-01)以来的天数,其中纪元位于范围的中心(2^31)'2023-06-24'、641
TIME64 位有符号整数,表示自午夜以来的纳秒数'21:07:12.123'(可以精确到秒、毫秒、微秒或纳秒)
DURATION三个整数,表示月数、天数和纳秒数1y4mo3w5d3h6m19s400ms20us141ns 或任何单位组合

4.3.1 处理日期和时间

为了将非 DURATION 类型的时间插入到数据库中,你可以插入符合 ISO-8601 标准的字符串,例如 '2023-06-24' 用于 DATE,或 '21:07:12.123' 用于 TIME。你也可以插入它们的底层整数类型。时间戳通常以 Unix 纪元秒数或毫秒数的形式存储在许多系统中,表示从 Unix 纪元(1970-01-01 00:00:00+0000)以来经过的秒数或毫秒数。你可以插入这些类型,查看它们的实际表现:

cqlsh:types> INSERT INTO BASIC(text, timestamp, date, time)
  VALUES ('time', '2023-06-25 10:31:00+0000', '2023-06-25', '10:31:00');

注意:在处理时间戳时,最好通过四位数的时区规格(如 +0000、-0500 等)指定时区。如果没有设置时区,Scylla 会使用底层系统当前配置的时区。

当你查询这些时间数据时,你会看到 cqlsh 会将它们转换为最高精度,记录 TIMESTAMP 的微秒和 TIME 的纳秒:

cqlsh:types> SELECT text, timestamp, date, time FROM basic
  WHERE text = 'time';

输出:

@ Row 1
-----------+---------------------------------
 text      | time
 timestamp | 2023-06-25 10:31:00.000000+0000
 date      | 2023-06-25
 time      | 10:31:00.000000000

无论你如何插入时间数据,cqlsh 会始终呈现它们的最高精度。如果你以 Unix 纪元毫秒数的形式插入 TIMESTAMPcqlsh 会将其转换为更友好的字符串格式:

cqlsh:types> INSERT INTO basic(text, timestamp)
  VALUES('epoch milliseconds', 1687654894759);

查询结果:

cqlsh:types> SELECT text, timestamp FROM basic
  WHERE text = 'epoch milliseconds';

输出:

@ Row 1
-----------+---------------------------------
 text      | epoch milliseconds
 timestamp | 2023-06-25 01:01:34.759000+0000

这种转换是为什么你应该使用 ScyllaDB 的时间类型,而不是直接使用底层的整数类型或自定义实现。插入日期或时间时,ScyllaDB 会自动为你格式化这些数据,使其更容易阅读和理解,同时它也会验证时间格式,防止你插入格式不正确的值。

时间转换

Scylla 提供了几种函数,简化时间类型之间的转换。在本书中,你将看到这些函数的应用,实际上很快你就会用到其中之一:

  • toDate() :将 TIMESTAMPTIMEUUID 转换为 DATE
  • toTimestamp() :将 DATETIMEUUID 转换为 TIMESTAMP
  • toUnixTimestamp() :将 DATETIMESTAMPUUID 转换为表示 Unix 纪元时间的 BIGINT

4.3.2 持续时间

ScyllaDB 还支持存储 DURATION 类型:一种时间长度。你可以通过一种特殊的语法来存储时间长度(见表 4.7)。例如,5h6m 表示一个持续时间,包含五小时六分钟。

表 4.7 每种时间单位的缩写

单位缩写
y
mo
w
d
小时h
分钟m
s
毫秒ms
微秒us 或 µs
纳秒ns

让我们把一个持续时间存入数据库。重要的是要注意,尽管持续时间包含字母和数字,它并不是一个字符串类型。它必须作为常量存储,不带引号:

cqlsh:types> INSERT INTO basic(text, duration) VALUES('duration', 13d5h);

你存储了 13 天 5 小时。当你再次查询它时,会得到准确的值:

cqlsh:types> SELECT text, duration FROM basic WHERE text = 'duration';

输出:

@ Row 1
----------+----------
 text     | duration
 duration | 13d5h

注意:DURATION 类型不能用于主键,因为它们不能进行排序。30 天是否大于 1 个月?这取决于月份:1 月有 31 天,而 2 月有 28 天(有时还有 29 天!)。持续时间需要额外的日期上下文来确定排序顺序。

那么,存储一个持续时间在什么情况下有价值呢?如果你想存储一个时间长度,DURATION 类型可能是一个不错的选择。假设你决定在餐厅评论中记录收到食物的时间,以便了解快餐店是否真的“快”。你可以将 time_to_receive_food 这个值存储为 DURATION 类型。此类型让你能够使用明确的单位来指定持续时间:如果收餐时间为 5 分钟 4 秒,你可以存储为 5m4s。如果没有 DURATION 类型,你的选择将要么需要额外的工作来计算和转换(假设你将其存储为一个表示你等待食物秒数的整数),要么缺乏验证(如果你将该值存储为字符串)。

4.3.3 何时使用时间戳、日期和时间

考虑到这四种时间类型——TIMESTAMPDATETIMEDURATION——其中一种应该适合用来存储文章日期。在这些日期和时间类型中,并没有一个是你通常应该偏好的;每种类型都有其特定的使用场景,你应该根据实际需求选择合适的类型。TIMESTAMP 适用于需要存储特定日期和时间的场景,而 DATETIME 则分别是更为具体的日期和时间类型,不需要包含对方的内容。最后,DURATION 适用于需要存储特定时间长度的场景。考虑到这些情况,哪种类型最适合用来存储文章日期?

由于你只需要存储文章的日期,DATE 显然是最佳选择。你可能并不关心文章发布的具体时间,所以 TIMESTAMP 包含了不必要的信息。TIMEDURATION 都无法提供日期信息,因此可以立即排除。DATE 提供了你需要的信息,没有多余的内容;另外,其他类型都已经被排除。所以在令人意外的结局下,DATE 非常适合用来存储文章日期。现在,你距离完成数据类型选择清单(见表 4.8)又近了一步。

表 4.8 选择了 DATE 作为文章日期的字段类型

字段类型
文章 IDTEXT
文章标题TEXT
文章作者 IDTEXT
文章评分TINYINT
文章日期DATE
文章图片库TEXT
文章评论文本TEXT
文章餐厅TEXT
作者 IDTEXT
作者姓名TEXT
作者简介TEXT
作者图片TEXT

表中剩余的几个字段都涉及到 ID,因此接下来我们来看一下 ScyllaDB 为 ID 提供的支持。

4.4 ID

在数据建模中,一种常见的技术是使用唯一标识符(ID)来表示一个实体,以便可以唯一地识别它;这从名称上就能看出!ScyllaDB 最常用的 ID 方案是数据库原生支持的:全局唯一标识符(UUID)(RFC 9562)。UUID 可以提供排序和唯一性,但代价是掩盖其含义:它看起来是一个随机生成的十六进制字符序列。在这一节中,你不仅会了解 UUID,还会了解其他在 ScyllaDB 中存储 ID 的方法。首先,让我们来看一下 UUID 以及它为何在 Scylla 中如此有用。

4.4.1 UUID

UUID 是一个 36 个字符的十六进制标识符,包含 32 个十六进制字符和 4 个连字符。字符有特定的顺序:UUID 由一个 8 位的组、接着是三个 4 位的组,最后是一个 12 位的组组成。每组之间用连字符分隔。

注意:在内部,Scylla 将 UUID 存储为 128 位的二进制数据。

ScyllaDB 支持两种类型的 UUID:UUIDTIMEUUID。UUID 可以是任何类型的 UUID,但通常它对应的是版本 4,其中几乎整个 UUID 都是随机生成的。TIMEUUID 是版本 1 UUID 的一种实现,它在标识符中包含时间戳组件。TIMEUUID 可以按时间顺序排序,因此你可以为行提供一个无冲突的时间戳。

注意:在本书中,UUID 指的是 ScyllaDB 中的特定数据类型,而 UUID 也指在其他系统中实现的概念。如果你想了解更多关于 UUID 各个版本的差异(本书中我们将只使用版本 1 和版本 4),我发现这篇文章很有帮助:www.uuidtools.com/uuid-versio…

为什么无冲突的时间戳如此重要

如果一个值是无冲突的,那意味着它不会与其他值重复:它是唯一的。主键要求唯一性,如果一个 TIMEUUID 是无冲突的,它可以作为行的主键(并且可以作为分区键)。它包含时间戳这一点也至关重要。它允许你按 TIMEUUID 中的时间戳对数据进行排序,从而可以例如按 TIMEUUID 排序存储文章 ID,并仅通过按 ID 排序来获取最新的文章。

那么 UUID 的唯一性如何?

由于 UUID 是随机生成的,因此存在碰撞的可能性,但几乎可以忽略不计。UUID 包含 122 位的随机性——这非常多!你需要生成 2.7 亿亿个 ID 才能有 50% 的机会发生碰撞。你可能会对此感到担忧,但这种情况真的非常、非常、非常不可能发生。

Scylla 提供了一些帮助生成 UUID 和 TIMEUUID 的函数。对于 UUID,你可以调用 uuid() 函数,生成一个随机的 UUID。如果你调用 now(),Scylla 会给你生成一个基于硬件的 MAC 地址和当前时间的 TIMEUUID。让我们看一下它们的实际应用:

cqlsh:types> INSERT INTO basic(text, uuid, timeuuid) 
  VALUES ('uuids', uuid(), now());

当你加载这一行时,你会看到生成的 UUID。几乎可以肯定的是,所有阅读这本书并遵循此示例的人都会看到不同的 UUID:

cqlsh:types> SELECT text, uuid, timeuuid FROM basic WHERE text = 'uuids';

@ Row 1
----------+--------------------------------------
 text     | uuids
 uuid     | e2672c94-8699-4d78-bc08-fefd07519d90
 timeuuid | 01c23f50-3d66-11ee-8569-f853395f6f49

对于 TIMEUUID,Scylla 还支持通过其 toTimestamp 函数提取标识符中的时间,你可以看到它的实际效果:

cqlsh:types> SELECT text, toTimestamp(timeuuid) 
  FROM basic WHERE text = 'uuids';

与前面的十六进制字符串不同,Scylla 提取了时间并将其格式化为易于阅读的格式。注意列名;它明确告诉你这个值是由一个函数产生的:

@ Row 1
------------------------------+---------------------------------
 text                         | uuids
 system.totimestamp(timeuuid) | 2023-08-18 01:24:42.053000+0000

如果你曾经使用过关系型数据库,你可能会熟悉数据库为你生成整数型键的方式。这个方法类似,只不过它不是生成自增的整数键,而是生成一个随机且唯一的 TIMEUUID 或一个几乎唯一的 UUID

注意:想要了解 Scylla 为什么会在使用自增整数键时遇到性能问题,请关注本书后面关于 Scylla 计数器 类型的性能挑战部分。

让数据库生成 ID 的缺点是,你的应用程序无法在没有读取行的情况下知道生成的 ID。如果你将生成的 ID 作为主键使用,那么你将无法读取这一行,因为你不知道主键。让我们用伪代码来说明这个问题。

假设一个新作者正在发布他们的第一篇文章。作为应用程序开发者,假设你有三条可能相关的查询,你可以通过你所使用语言中的函数调用触发:

create_author(author...)    # 插入作者
get_author(id...)           # 根据 ID 获取作者
create_article(article, author_id...)  # 插入文章

当一个作者发布他们的第一篇文章时,你希望先生成作者信息,再将其 ID 添加到文章中。然而,在创建作者时,你并不知道作者的 ID。更糟糕的是,为了获取作者 ID,你需要加载作者——但你是通过 ID 加载作者的!

def add_article_for_new_author(author, article):
    create_author(author)                 # 1
    article.author_id = ?    # 2
    create_article(article)
# 1 创建作者时,数据库会生成一个 ID,但你无法看到它。
# 2 因为你不知道作者 ID,所以无法将其设置到文章中。

为了解决这个问题,应用程序通常选择在数据库之外生成 ID。通过在应用程序中而不是数据库内生成 ID,你不仅限于 UUID,还可以使用任何提供唯一性和排序功能的类型。这样做可以解决“在读取之前不知道 ID”的问题;因为你是在插入数据之前自己生成 ID,所以你早已知道该 ID。

UUID 并非 Scylla 专有。在各种编程语言的标准库中,或者作为额外的依赖项,都会有生成有效版本 1(TIMEUUID)或版本 4(UUID)的工具。

继续前面的示例,你可以通过在写入行之前生成 ID 来解决“未知 ID”问题。在每次插入之前,你生成一个 UUID,并将其附加到即将保存的对象上。你还需要修改查询,不再生成 ID,而是保存传递给查询的 ID 值:

python
复制代码
def add_article_for_new_author(author, article):
    author.id = generate_uuid()       # 1
    create_author(author)      # 2

    article.id = generate_uuid()       # 3
    article.author = author.id          # 4
    create_article(article)
  • #1 在将作者插入数据库之前,你生成一个 UUID,并将其分配给作者。
  • #2 插入作者时,数据库中使用的是你已经生成的 UUID,跳过了数据库内部生成 ID 的过程。
  • #3 因为你在客户端生成了作者的 ID,所以你可以将它设置到文章的作者字段中。

不仅仅是 UUID

在应用程序内部生成 ID 是一种常见的模式。只要你生成的是唯一的 ID(或者对于版本 4 UUID 来说,统计上非常有可能是唯一的),你就不局限于使用 UUID 作为 ID 类型。

一种替代的 ID 类型是 Twitter 的 Snowflake ID,它是一个 64 位的整数,行为类似于 TIMEUUID。每个 ID 都编码了一个时间戳、一个生成 ID 的工作者编号和一个序列号。将这些值组合起来可以得到一个唯一的 ID,并且可以按时间排序,其生成过程可以扩展,避免成为瓶颈。此外,Snowflake IDs 具有更高的可读性:因为它们是整数,可以方便地进行快速比较并且说“这个值比那个值旧”。缺点是复杂性:以这种方式生成 ID 并不是免费的,通常需要调用一个专门的系统来生成 ID。(想了解更多 Snowflake 的信息,请参见 Twitter 博客

我们将在本书的后续部分不再讨论 Snowflake,但重要的是要记住,ID 可以是任何提供唯一性和可排序性的数据类型。

了解了 Scylla 对 UUID 的支持之后,你现在可以确定哪种 ID 类型最适合你的应用程序。接下来,我们将看看需要唯一标识的字段,并选择合适的数据类型。

4.4.2 选择 ID 类型

在为你的餐厅评论应用程序选择存储的字段时,你识别出了三个 ID 字段:文章 ID、文章摘要 ID 和作者 ID。了解了 UUID 后,哪种 ID 类型适用于这些字段呢?

在选择 UUID 和 TIMEUUID 之间,你在选择一个主要是随机的类型,还是一个按时间排序的类型,其中随机性提供了唯一性。如果你回想一下需求,你希望按最最近的日期显示文章摘要。如果 ID 是按时间排序的,你可以利用它按最新的顺序对行进行排序。使用 TIMEUUID 可以实现这一点,而不是使用 UUID 的随机性所产生的混乱。

应该对所有 ID 字段都使用 TIMEUUID 吗?

我认为将时间编码到 ID 中是有价值的:它允许你定义数据之间的时间关系。随着应用程序的增长,能够说“这条数据在那条数据之前创建”是非常有用的。也有一些情况下 UUID 就足够了——当你不需要时间,只需要一个快速的随机标识符时。最终,TIMEUUID 只不过是一个带有额外时间编码的 UUID。你可以技术上将版本 1 的 UUID 存储为 UUID 类型;只是没有 TIMEUUID 的函数和验证。

使用 TIMEUUID 的缺点是,每秒生成的 ID 有上限:大约 1000 万个。如果你生成的 ID 数量超过了这个非常高的数字,可能需要考虑使用其他 ID 生成方案。

数据类型选择表

选择了 TIMEUUID 字段后,你几乎完成了你的数据类型检查清单(表 4.9)。

字段名称数据类型
文章 IDTIMEUUID
文章标题TEXT
文章作者 IDTIMEUUID
文章评分TINYINT
文章发布日期DATE
文章图片库
文章评论文本TEXT
文章餐厅TEXT
作者 IDTIMEUUID
作者姓名TEXT
作者简介TEXT
作者图片

在你的字段中最后一个是图像。使用类似前面提到的字段存储图像听起来非常复杂。图像有很多关联数据:文件、分辨率、标题等。幸运的是,ScyllaDB 提供了一些高级类型,可以简化数据的聚合。为了存储图像,你可以使用集合类型。

4.5 集合

Scylla 的集合类型与大多数编程语言中的集合非常相似;它提供了 LISTMAPSET 三种类型。

  • LIST 是一个有序的集合,包含特定类型的潜在非唯一值。
  • SET 与列表类似,但它保证唯一性,即集合中的每个元素都是唯一的。
  • MAP 是一组键值对的有序集合。每个键都是唯一的,并且该映射是根据键进行排序的。

你可以在图 4.3 中看到这些差异。

image.png

尽管集合可以存储多个项,但它们并不是表的替代品。集合最适合存储较小数量的数据。如果数据量很大,或者它将增长到一个未知的量,最好将其拆分成单独的表,并为其分配自己的分区键或聚类键,以便 Scylla 能够优化其性能。

与基本类型类似,你可以创建一个表来操作集合,如清单 4.2 所示。在声明集合类型时,需要在尖括号中指定集合中存储的类型,比如 <TEXT>。对于 LISTSET,它们只有一个类型,因此只需要列出一种类型。对于 MAP,由于它的键和值各有一个类型,类型之间用逗号分隔。我还将 SET 列命名为 set_text——set 是 CQL 中的保留字;你在 UPDATE 语句中已经用过它。

清单 4.2 操作集合的集合表

cqlsh:types> CREATE TABLE collections(
    text TEXT,
    list LIST<TEXT>,
    set_text SET<TEXT>,
    map MAP<TEXT, TEXT>,
    PRIMARY KEY (text)
);

创建好集合表后,你现在有了一个可以尝试集合类型的地方。记住你的需求:为文章存储图像库。

4.5.1 列表 (Lists)

CQL 中的列表(LIST)行为类似于编程语言中的列表或向量。你为列表的内容指定一个类型,可以在特定的索引位置追加、删除、访问和替换值。我们先通过常见的列表括号语法向表中插入一个初始列表:

cqlsh:types> INSERT INTO collections(text, list)
  VALUES ('list', ['a', 'b', 'c']);

访问你的列表将显示三个文本项:'a'、'b' 和 'c':

cqlsh:types> SELECT text, list FROM collections WHERE text = 'list';

@ Row 1
------+-----------------
 text | list
 list | ['a', 'b', 'c']

现在,数据库中已经有了这个列表,你可以开始探索 Scylla 的列表操作。你可以使用 + 操作符将两个列表连接起来,得到一个合并后的列表。下面的语句将 'd' 和 'e' 添加到已存储的列表中:

cqlsh:types> UPDATE collections
  SET list = list + ['d', 'e']
  WHERE text = 'list';

注意:如果你将更新查询写成 SET list = ['d', 'e'] + list,它会将 'd' 和 'e' 添加到列表的开头。

再次加载列表,你会看到 'd' 和 'e' 已经被添加到列表的末尾:

cqlsh:types> SELECT text, list FROM collections WHERE text = 'list';

@ Row 1
------+---------------------------
 text | list
 list | ['a', 'b', 'c', 'd', 'e']

你成功地通过更新当前列表的内容并追加了另一个列表来修改了它。这里有一个陷阱:这个操作是非幂等的,也就是说,重复执行它不会得到相同的结果。如果你连续执行五次这个追加操作,你会添加五次 'd' 和 'e',这可能并不理想。

如果你决定不再需要列表中的 'd' 和 'e',可以使用 - 操作符将它们从列表中移除。如果某项不在列表中,Scylla 不会报错,所以你也可以尝试删除 'f':

cqlsh:types> UPDATE collections
  SET list = list - ['d', 'e', 'f']
  WHERE text = 'list';

如果你再次运行 SELECT 查询,会发现不再需要的 'd' 和 'e' 已经被删除:

cqlsh:types> SELECT text, list FROM collections WHERE text = 'list';

@ Row 1
------+-----------------
 text | list
 list | ['a', 'b', 'c']

你还可以直接在列表的特定索引位置替换值。如果索引超出范围,会报错;但如果你想用 'z' 替换列表中的第一个项,可以执行以下语句:

cqlsh:types> UPDATE collections SET list[0] = 'z' WHERE text = 'list';

再次加载列表,你会得到更新后的列表:'z'、'b' 和 'c':

cqlsh:types> SELECT text, list FROM collections WHERE text = 'list';

@ Row 1
------+-----------------
 text | list
 list | ['z', 'b', 'c']

如果你决定 'z' 不是列表开头的正确值,你也可以通过索引删除列表中的值。去掉 'z' 后,列表将只剩下 'b' 和 'c':

cqlsh:types> DELETE list[0] FROM collections WHERE text = 'list';

再次加载列表后,结果如下:

cqlsh:types> SELECT text, list FROM collections WHERE text = 'list';

@ Row 1
------+------------
 text | list
 list | ['b', 'c']

你还可以跳过对列表中单个部分的访问,直接替换整个列表:

cqlsh:types> UPDATE collections
  SET list = ['a', 'b', 'c']
  WHERE text = 'list';

再次加载列表,你会得到原始列表:'a'、'b' 和 'c':

cqlsh:types> SELECT text, list FROM collections WHERE text = 'list';

@ Row 1
------+-----------------
 text | list
 list | ['a', 'b', 'c']

替换整个列表比变更单个元素更安全。列表操作的非幂等特性使其使用起来具有一定风险。如果一个查询在客户端超时,但数据库中仍然完成,且你正在追加数据或通过索引删除列表中的数据,重复执行该查询可能是不安全的。此外,一些列表操作——比如在特定索引处更新值——可能会导致在更新之前进行读取,从而拖慢操作。因此,推荐尽可能使用集合而不是列表。让我们进一步看一下为什么集合更优。

4.5.2 集合 (Sets)

集合与映射(Map)非常相似,主要有两个区别:集合中的每个值都是唯一的,并且集合中的值不能通过直接访问来获取。这些特性避免了列表中可能存在的危险访问模式——因为你不能添加重复的值,所以添加操作是幂等的;同样,由于无法通过位置删除元素,删除操作也变得是幂等的。

集合使用大括号表示。我们先来插入一个简单的集合。注意,列名是 set_text,因为 set 是 CQL 中的保留字:

cqlsh:types> INSERT INTO collections(text, set_text)
  VALUES ('set', {'a', 'b', 'c'});

访问集合时,像访问列表一样,返回的三个文本项是:'a'、'b' 和 'c':

cqlsh:types> SELECT text, set_text FROM collections WHERE text = 'set';

@ Row 1
----------+-----------------
 text     | set
 set_text | {'a', 'b', 'c'}

如果你尝试重新插入一个包含重复值的集合会发生什么呢?

cqlsh:types> INSERT INTO collections(text, set_text)
  VALUES ('set', {'a', 'b', 'c', 'c'});

当你再次查询集合时,你会发现重复的 'c' 值被自动去除了,而没有产生错误:

cqlsh:types> SELECT text, set_text FROM collections WHERE text = 'set';

@ Row 1
----------+-----------------
 text     | set
 set_text | {'a', 'b', 'c'}

通过在不向客户端报告错误的情况下去除重复项,Scylla 展示了为什么集合是更优选择。由于这些操作是幂等的,你可以反复执行它们,每次都得到相同的结果。不必担心操作顺序或重复数据的去除,使得你的应用程序更加简单和健壮。

和列表一样,你也可以使用 + 操作符将两个集合连接起来:

cqlsh:types> UPDATE collections
  SET set_text = set_text + {'d', 'e'}
  WHERE text = 'set';

当你再次查询集合时,'d' 和 'e' 已经被添加:

cqlsh:types> SELECT text, set_text FROM collections WHERE text = 'set';

@ Row 1
----------+---------------------------
 text     | set
 set_text | {'a', 'b', 'c', 'd', 'e'}

如果你连续执行五次这个更新操作,你每次得到的集合都是相同的。幂等性非常酷!

集合也支持通过 - 操作符移除数据。和列表一样,如果值不存在,Scylla 不会删除它——因为它根本不在集合中:

cqlsh:types> UPDATE collections
  SET set_text = set_text - {'d', 'e', 'f'}
  WHERE text = 'set';

更新后,集合恢复为 'a'、'b' 和 'c':

cqlsh:types> SELECT text, set_text FROM collections WHERE text = 'set';

@ Row 1
----------+-----------------
 text     | set
 set_text | {'a', 'b', 'c'}

由于集合具有更简洁的接口、唯一性保证以及没有潜在的操作错误,因此你应该在可能的情况下优先使用集合而不是列表。回想一下你的应用程序,你希望存储一组图片。虽然如何存储图片仍不清楚,但画廊是一个包含一个或多个项目的集合——这听起来非常像可以通过集合或列表来表示的东西。由于你应该优先使用集合,所以你的图片画廊可以用集合来表示。然而,集合中的数据类型还需要确定。

另一种集合类型,映射(Map),在集合的基础上扩展了其 API。映射是由键值对组成的集合——让我们看看如何使用映射。

4.5.3 映射(Maps)

在 CQL 中,映射与其他编程语言中的映射或字典非常相似。你可以将其视为一组键:映射中的每个键都是唯一的,每个键都指向一个值。所有键共享一个共同的数据类型,值也如此。要创建一个映射,你可以将键值对放入大括号中,每对之间用逗号分隔,键和值用冒号分隔:

cqlsh:types> INSERT INTO collections(text, map)
  VALUES('map', {'k1': 'v1'});

加载这行数据后,你会看到新创建的映射:

cqlsh:types> SELECT text, map FROM collections WHERE text = 'map';

@ Row 1
------+--------------
 text | map
 map  | {'k1': 'v1'}

(1 rows)

像列表和集合一样,你可以使用 + 运算符向映射中添加条目:

cqlsh:types> UPDATE collections
  SET map = map + {'k2': 'v2', 'k3': 'v3'}
  WHERE text = 'map';

此时,映射中有了三个条目:k1、k2 和 k3:

cqlsh:types> SELECT text, map FROM collections WHERE text = 'map';

@ Row 1
------+--------------------------------------
 text | map
 map  | {'k1': 'v1', 'k2': 'v2', 'k3': 'v3'}

(1 rows)

你还可以使用 - 删除映射中的条目。你不需要提供完整的条目,仅使用键即可。当以这种方式从映射中删除时,你实际上是在删除一组键:

cqlsh:types> UPDATE collections SET map = map - {'k3'} WHERE text = 'map';

如果再次检索映射,你会发现它不再包含 k3 的条目:

cqlsh:types> SELECT text, map FROM collections WHERE text = 'map';

@ Row 1
------+--------------------------
 text | map
 map  | {'k1': 'v1', 'k2': 'v2'}

(1 rows)

你还可以直接在映射条目上执行操作,方法是使用键:

cqlsh:types> UPDATE collections
  SET map['k2'] = 'mountain'
  WHERE text = 'map';

现在,k2 的值变成了 mountain:

cqlsh:types> SELECT text, map FROM collections WHERE text = 'map';

@ Row 1
------+--------------------------------
 text | map
 map  | {'k1': 'v1', 'k2': 'mountain'}

(1 rows)

就像你可以使用键更新值一样,也可以通过键删除条目:

cqlsh:types> DELETE map['k2'] FROM collections WHERE text = 'map';

重新加载映射后,你会看到它恢复为原始映射,仅包含 k1 的条目:

cqlsh:types> SELECT text, map FROM collections WHERE text = 'map';

@ Row 1
------+--------------
 text | map
 map  | {'k1': 'v1'}

(1 rows)

映射在其他集合的接口基础上构建,所有三种集合都支持以类似的方式添加、更新和删除数据。此时,你可能会认为映射非常适合存储图像,因为它支持插入任意的键和值,这样你就可以在其中存储文件名或分辨率。然而,有一个复杂问题:映射中的所有值必须具有相同的数据类型。如果文件名是 TEXT 类型,你就无法将分辨率存储为 INT 类型在映射中。幸运的是,你还有其他选择。你应该了解另一种集合类型:用户定义类型(User-Defined Types)。

4.5.4 用户定义类型(User-defined types)

用户定义类型(UDT)允许用户定义一个自定义类型,行为与 Scylla 中的其他类型类似;它可以用于表中、集合中,甚至作为主键。UDT 与映射(map)非常相似——它们使用类似的语法,将键映射到值。然而,UDT 可以包含不同类型的字段,而映射的值必须具有相同的数据类型。UDT 的价值在于,当你希望将一些相关数据一起存储为一个共同的类型时,使用 UDT 比将它们拆分成多个列并分别更新更为方便。

要使用 UDT,你首先需要创建它,就像创建键空间或表一样。假设我们有一个人的联系方式:你可以通过电子邮件、电话、社交网络私信,甚至通过信鸽与他们联系。我们可以使用 CREATE TYPE 语句创建一个表示这些联系方式的 UDT,如下所示。它与 CREATE TABLE 很相似——你为类型指定一个名称,并指定字段名和类型。

示例 4.3 创建一个 UDT 来表示数据库中的复杂类型

cqlsh:types> CREATE TYPE contact_info(
    email TEXT,
    phone TEXT,
    social_handle TEXT,
    wake_time TIME,
    sleep_time TIME
);

要查看你创建的类型,可以运行 DESCRIBE TYPE 语句。提供类型的名称 contact_info,你将看到刚刚创建的 UDT:

cqlsh:types> DESCRIBE TYPE contact_info;

CREATE TYPE types.contact_info (
    email TEXT,
    phone TEXT,
    social_handle TEXT,
    wake_time TIME,
    sleep_time TIME
);

要使用这个 UDT,它必须是表的一部分,因此你需要创建一个表。与之前的操作类似,下面的 CREATE TABLE 语句为你提供了一个表来尝试使用这个新创建的 UDT:

示例 4.4 在新表中使用 UDT 作为字段类型

cqlsh:types> CREATE TABLE udt(
    text text,
    contact_info contact_info,
    PRIMARY KEY (text)
);

插入 UDT 的方式与使用映射字面量非常相似,唯一的区别是你不再需要引用键。你可以将 UDT 插入到表中。假设我们的测试用户非常普通,并且睡得很好:

cqlsh:types> INSERT INTO udt(
  text,
  contact_info
) VALUES(
  'user 1',
  {
    email: 'user1@test.com',
    phone: '1-555-555-5555',
    social_handle: '@user1',
    wake_time: '08:00:00',
    sleep_time: '10:00:00'
  }
);

如果你加载该用户的联系方式,你将看到新插入的 UDT:

cqlsh:types> SELECT * FROM udt WHERE text = 'user 1';

@ Row 1
--------------+----------------------------------------------------------
 text         | user 1
 contact_info | {email: 'user1@test.com', phone: '1-555-555-5555',
                ↪ social_handle: '@user1',↪
                ↪ wake_time: 08:00:00.000000000,↪
                ↪ sleep_time: 10:00:00.000000000}

(1 rows)

插入 UDT 时,和在表中插入列数据一样,你不需要设置每个字段。如果你省略了一些字段会发生什么呢?假设用户 2 没有社交账号或固定的起床和睡觉时间:

cqlsh:types> INSERT INTO udt(text, contact_info)
  VALUES('user 2', {email: 'user2@test.com', phone: '1-555-555-5552'});

就像在表中一样,Scylla 会默认未设置的值为 null。UDT 中没有字段是必需的;如果你想强制执行这一点,你需要在应用程序中进行控制:

cqlsh:types> SELECT * FROM udt WHERE text = 'user 2';

@ Row 1
--------------+-----------------------------------------------------------
 text         | user 2
 contact_info | {email: 'user2@test.com', phone: '1-555-555-5552',↪
                 ↪ social_handle: null, wake_time: null,↪
                 ↪ sleep_time: null}

(1 rows)

UDT 是存储相关数据的有用工具,类似于地址。例如,街道上的房号变化时,地址也会变化。你可以在任何可以使用类型的地方使用 UDT——包括在集合中,甚至可以将 UDT 嵌套在其他 UDT 中。接下来,我们尝试在集合中使用 UDT。

你需要为这个测试创建另一个表:可以将其命名为 udt_set,并用它来将 UDT 存储在集合中。运行以下 CREATE 语句来查看一个惊喜。

示例 4.5 含有错误的表

cqlsh:types> CREATE TABLE udt_set (
    text text,
    udts set<contact_info>,
    PRIMARY KEY (text)
);

你遇到一个错误!

InvalidRequest: Error from server: code=2200 [Invalid query]
message="Non-frozen user types or collections are not allowed inside
collections: set<types.contact_info>"

什么?“不允许在集合中使用非冻结的用户定义类型或集合”?这是什么意思?什么是冻结类型,为什么 UDT 不是已经冻结的?这个错误并不意味着你只能在北极圈使用 ScyllaDB;它是集合类型的一个特性,它限制了某些用法,但以换取某些性能上的好处。

4.5.5 冻结集合

冻结集合只能一次性修改;逐个操作或对单个键的操作是被禁止的。通过冻结集合,整个集合会一起存储在磁盘上,分享各种元数据。通过将集合存储在一起,并且只允许通过完全替换来更新,它可以更加紧凑地存储,从而加速读取操作。

当集合类型被包裹在 FROZEN<…> 中时,该集合就是冻结的。你可以在下一个示例中看到一个冻结集合的例子。

示例 4.6 更新冻结集合的表(通过替换整个集合)

cqlsh:types> CREATE TABLE frozen_collections (
    text TEXT,
    list FROZEN<LIST<TEXT>>,
    set_text FROZEN<SET<TEXT>>,
    map FROZEN<MAP<TEXT, TEXT>>,
    PRIMARY KEY(text)
);

这个属性意味着你更新冻结集合的唯一方法是完全替换它。以下操作在冻结集合上是禁止的:

cqlsh: types> UPDATE frozen_collections SET list[0] = 'a' ...
cqlsh: types> UPDATE frozen_collections SET set_text = set_text + 'q' ...
cqlsh: types> UPDATE frozen_collections SET list = list - 'z' ...
cqlsh: types> UPDATE frozen_collections SET map['k2'] = 'new value'...

如果你在集合中使用集合,内层集合也必须是冻结的,这就解释了你之前在尝试将 contact_info UDT 用于集合时遇到的错误。Scylla 不允许你在集合中逐个更新 UDT 的字段,而是要求你完全替换整个 UDT。正如你将在后续章节中看到的,这种行为简化了值的磁盘存储,并减少了该值所需的磁盘空间。

现在,你已经了解了为什么在没有冻结 UDT 时尝试将其用于集合会遇到错误,你可以继续选择你的类型了。接下来,我们来看一下你可能希望如何为文章存储图像。

4.5.6 存储图像

在了解了集合后,你现在知道你有四种选择:

  1. 列表(List),支持将多个值存储在一起。
  2. 集合(Set),与列表类似,但它强制保证元素的唯一性。
  3. 映射(Map),支持存储具有相同类型的键和具有相同类型的值。
  4. 用户定义类型(UDT),允许你存储具有特定字段名的异构类型字段。

那么,哪种类型最适合存储图像呢?列表和集合被排除在外;你需要存储的是表示某个实体的数据,而不是一组值。你剩下的选择是映射或用户定义类型(UDT)。由于你需要存储描述特定实体的多个字段,而且每个字段可能具有不同的数据类型,因此UDT是最佳选择。

练习

你会在图像的用户定义类型(UDT)中存储哪些字段?这些字段需要什么数据类型?

虽然你可能会想直接在数据库中存储图像,但建议避免这样做:图像是大文件,当读取或传输时,存储成本可能会很高。通常,应用程序将图像存储在专门的文件存储服务中,如 Amazon S3 或 Google Cloud Storage,这些服务专为文件提供存储和访问而设计。在数据库中,你不存储图像本身,而是存储图像的元数据:例如文件名、位置、说明等。那么,在构建图像 UDT 时,你会存储哪些内容?

然而,你不能仅仅停留在存储单个图像的 UDT。你只需要存储文章摘要的图像,但文章本身包含一组图像。为了满足这个需求,你必须存储多张图像,这意味着你还需要一个能够存储多个值的数据类型。你的选择是列表或集合。考虑到除非需要重复项,否则你应该避免使用列表,集合是否适合你的需求?

答案是:集合完全符合!你不需要图像的重复副本,因此可以使用图像的集合。然而,正如你之前学到的,如果你在集合中使用用户定义类型(UDT),则该 UDT 需要被标记为冻结,这就意味着你将类型定义为 SET<FROZEN<image>>

决定了如何存储图像后,你现在已经知道了你的模式所需的所有数据类型,如下表所示:

表 4.10 你已经填写的应用所需的数据类型

字段数据类型
文章 IDTIMEUUID
文章标题TEXT
文章作者 IDTIMEUUID
文章评分TINYINT
文章日期DATE
文章图像画廊SET<FROZEN<image>>
文章评论文本TEXT
文章餐厅TEXT
作者 IDTIMEUUID
作者姓名TEXT
作者简介TEXT
作者图像image

现在,凭借这些数据类型,你已经准备好完成你的查询优先设计,并将需求转换为数据库中的表。但在继续之前,还有一些数据类型需要学习。虽然它们在此时不会立即使用,但它们在未来的工作中将非常有用。

4.6 其他几种数据类型

本节将快速介绍 ScyllaDB 中的其他数据类型。其中一些数据类型适用于广泛的用例,比如 BLOB,而其他一些则更加专注,例如 INET 类型,它表示 IP 地址。首先,我们来了解一下 BLOB 类型。

4.6.1 BLOB

假设你决定在应用程序中实现一个登录功能,用户可以登录并可能对文章进行评论。为了让用户能够通过用户名和密码登录,你需要某种方式来验证他们的凭证。你可能会考虑存储密码;但最佳实践是存储密码的哈希版本,并附加一个额外的秘密字符串(称为盐值),使用像 Argon2 或 scrypt 这样的加密哈希函数。这些哈希函数生成一系列字节,你可以使用 Scylla 的 BLOB 数据类型将其存储在数据库中。

警告:实现一个安全的应用程序登录流程超出了本书的范围,但请始终不要将密码以未加密的文本字段形式存储在数据库中。请参考 Open Web Application Security Project (OWASP) 的安全指南,帮助你构建一个安全的应用程序。

Scylla 支持将任意字节存储在其 BLOB 类型中。这个类型可以表示任何东西——序列化数据、任意的十六进制、编码值——只要它是字节形式,你就可以将其存储在这里。你可以使用 cqlsh 输入一个 BLOB 值,作为十六进制字面量存储:

cqlsh:types> INSERT INTO basic(text, blob) VALUES('blob', 0x123ABC456DEF);

读取你插入的行时,将看到基于十六进制的 BLOB:

cqlsh:types> SELECT text, blob FROM basic WHERE text = 'blob';

@ Row 1
------+----------------
 text | blob
 blob | 0x123abc456def

(1 rows)

Scylla 还支持几种叫做 typeAsBlob 的函数,用于将给定类型转换为 BLOB。例如,要将一个 INT 类型转换为 BLOB,你可以使用 intAsBlob()。尝试将该函数的结果插入到数据库中:

cqlsh: types> INSERT INTO basic(text, blob)
  VALUES ('as blob', intAsBlob(12));

当你读取它时,你会发现它与原始输入不同。你传入了 intAsBlob 函数的结果,即十六进制字面量 0xc。当你查询时,Scylla 会用前导零填充该值,给出你最初传入的整数的 BLOB 表示:

cqlsh:types> SELECT text, blob FROM basic WHERE text = 'as blob';

@ Row 1
------+------------
 text | as blob
 blob | 0x0000000c

(1 rows)

通过存储任意字节,BLOB 可以成为一个强大的工具。尽管某些类型是强类型的并且会强制执行约束,但字节的灵活性为你打开了许多可能性。你可以将应用程序中的对象序列化为字节并将其存储,或者你可以进一步压缩或加密一个较长的字符串。字节为你提供了你愿意处理的灵活性。然而,正如存储图像时所提到的,大型二进制数据不应存储在 Scylla 中;将大型文件存储在内容分发网络(CDN)或其他专门用于大对象存储的系统中。

接下来,我们将继续介绍一个更专注的数据类型——INET 类型。

4.6.2 IP 地址

Scylla 支持在 INET 类型中存储 IP 地址。像其他类型一样,Scylla 会验证 IP 地址是否是正确格式的 IPv4 或 IPv6 地址。要将其存储到数据库中,你只需将 IP 地址作为字符串输入:

cqlsh:types> INSERT INTO basic(text, inet)
 VALUES ('ip address', '127.0.0.1');

读取该值时,将返回你输入的地址:

cqlsh:types> SELECT text, inet FROM basic WHERE text = 'ip address';

@ Row 1
------+------------
 text | ip address
 inet | 127.0.0.1

(1 rows)

类型介绍的最后一站是一个需要稍微配置一下,但做得很好的类型:计数器(counter)。

4.6.3 计数器

在互联网早期,许多网站都有一个流行的功能——访问计数器。每当有人访问页面时,计数器就会递增。假设你想为你的应用程序添加这个功能,Scylla 中的 COUNTER 类型可以帮助你实现这一功能;它是一个 64 位的有符号整数,只支持两种操作:递增(increment)和递减(decrement)。这些操作有特殊的条件——你必须遵守 COUNTER 类型的规则。

  • 计数器不能是主键的一部分,并且它必须是表中唯一的非主键列。
  • 计数器更新不是幂等的,因此不能多次应用相同的递增操作而得到相同的结果。
  • 你不能通过 INSERT 来插入一个计数器;你必须使用赋值操作来更新它的值。

首先,你需要创建一个专门的计数器表:

列表 4.7 专用计数器表

cqlsh:types> CREATE TABLE counters(
    text TEXT,
    counter COUNTER,
    PRIMARY KEY (text)
);

如前所述,设置计数器时必须执行 UPDATE 语句,递增或递减其值。首先,递增计数器的值 1:

cqlsh:types> UPDATE counters
  SET counter = counter + 1
  WHERE text = 'counter';

在这里运行 UPDATE 而不是 INSERT 确实有些奇怪,但正如你在上一章学到的那样,UPDATE 会将数据插入行中,并更新已有的值。当你执行初始的更新操作时,Scylla 会看到没有值,于是假设值为 0,然后按指定的数量递增。递增 1 后,你预计计数器的值应该是 1:

cqlsh:types> SELECT * FROM counters WHERE text = 'counter';

@ Row 1
---------+---------
 text    | counter
 counter | 1

(1 rows)

如果将计数器递减 2,值会变成多少呢?

cqlsh:types> UPDATE counters
  SET counter = counter - 2
  WHERE text = 'counter';
cqlsh:types> SELECT * FROM counters WHERE text = 'counter';

@ Row 1
---------+---------
 text    | counter
 counter | -1

(1 rows)

如果你说是 -1,那你是对的!计数器的工作原理是:它会基于当前的值执行递增或递减操作,操作的结果依赖于之前的状态。这就是为什么计数器更新不是幂等的——你不能直接设置一个值;你只能基于之前的值进行更改。

CRDT:不是计算机显示器!

在底层实现中,计数器是作为一种冲突自由复制数据类型(CRDT,Conflict-Free Replicated Data Type)来实现的。出于显而易见的原因,通常会将其简称为 CRDT。虽然 CRDT 听起来像是一个老式的阴极射线管显示器(CRT),但它实际上是一种常用于分布式系统的数据类型,旨在提供可扩展的更新,而无需进行大量的协调。名称中的“冲突自由”部分暗示了其工作原理:更新可以随时发生,而无需协调,并且系统可以自动解决冲突。

计数器看似奇怪的限制——它必须是唯一的非主键列——与其作为 CRDT 的实现方式密切相关。虽然计数器看起来是一个数字,但它实际上是以多种数据类型存储的,以实现冲突自由的目标。Scylla 曾发布过一篇博文,详细介绍了计数器的实现细节;你可以在 www.scylladb.com/2017/04/04/… 阅读该博文。

因为计数器涉及到查看当前状态并进行修改——一种读-改-写模式——所以在使用时会增加性能开销。正如你将在下一章中看到的,Scylla 的设计目的是使写入操作尽可能快速,并将大量的性能消耗留给读取操作。由于计数器需要本地读取当前数据以更新计数器的状态,计数器更新的操作比标准的 ScyllaDB 写操作要复杂。它需要本地锁定计数器,查询计数器的当前状态并更新,然后才能将数据复制到其他节点。这些性能开销加剧了计数器的另一个挑战:它们无法保证准确的计数。在负载较大的系统中,更新可能会被遗漏或重试,导致计数器的值与预期的正确状态产生偏差。使用计数器时需要注意这些缺点。尽管存在这些缺陷,但有时这个模式正是你所需要的:一个依赖于值先前状态的递增计数器。

现在,你已经掌握了 ScyllaDB 中所有的数据类型。有些类型会立即派上用场,你将在接下来的章节中创建表并进一步了解 Scylla 中的读取和写入操作时使用到它们。其他的类型则可以暂时存放在你的脑海中,等到有需要时再拿出来解决问题。接下来,是时候完成查询优先设计,并将这些设计转化为实际的 Scylla 集群语句了。

总结

  • 查询优先设计继续通过将字段和类型分配给你的表来实现需求。
  • Scylla 的 TEXT 类型是存储字符串的最佳选择,因为它可以表示 UTF-8 编码中的任何字符。
  • Scylla 支持通过多种数值类型(如 TINYINT、BIGINT 和 DOUBLE)来存储整数和浮动点数。你应该根据你的使用场景选择适合的数值类型。
  • 当存储日期或时间时,如果需要日期和时间,使用 TIMESTAMP;如果只需要日期或时间,分别使用 DATE 或 TIME。DURATION 也适用于存储时间长度。
  • 当创建唯一标识符时,ScyllaDB 原生支持 UUID,但你可能需要在应用程序中生成它,以避免在创建时无法获取实际值。
  • TIMEUUID 按时间排序,允许进行无冲突的时间戳存储。
  • 基于 UUID 的类型是创建唯一标识符的强大选择,但你可能需要在应用程序中生成它,以避免在创建时无法获取实际值。
  • Scylla 的 SET 类型是存储多个值的最佳集合类型,因为它提供了幂等操作。
  • Scylla 也支持 LIST,但在使用时要谨慎,因为对列表的操作(如追加)不是幂等的,可能会导致列表中出现重复项。
  • MAP 用于将键与相同数据类型的值关联,提供每个键在映射中的唯一性。
  • 当需要表示复杂类型时,可以使用用户定义类型(UDT)而不是 MAP,以存储具有不同数据类型的值。
  • 通过将集合包裹在 FROZEN 类型中来冻结集合,可以提高性能,并且这是 UDT 或集合在集合中使用的要求。