一个合理的数据模型是构建高性能、易维护应用程序的基础。在本章中,我们将回顾关系型数据库模式设计的基本原理,特别关注那些影响分布式数据库操作的设计要素,以及 CockroachDB 的一些高级特性,例如列族(Column Families)和 JSON 二进制(JSONB)支持。我们将涵盖如何创建表、索引以及其他用于支持 CockroachDB 应用的模式对象。
虽然 CockroachDB 提供了在线高效修改数据库模式的机制,但在生产环境中进行模式更改依然是一项高风险操作,通常需要协调应用代码和数据库配置的同步修改。如果操作不当,可能导致功能缺失、可用性降低,甚至性能下降。因此,尽管 CockroachDB 允许在生产环境中修改数据库模式,我们仍然强烈建议在应用设计阶段就把数据模式设计得尽可能完善。
关系型数据库设计是一个庞大的主题,已经有很多书籍专门探讨这一内容,并且至今仍存在不少争议。在这里,我们不会深入探讨高级设计原则,也不会参与各种设计范式"纯粹性"的争论。实际上,大多数数据库模型都在关系模型的理论纯粹性与数据库系统的现实限制之间寻求折中。因此,在本章中,我们将简要介绍关系模型的理论背景,并深入探讨如何在 CockroachDB 上进行实际可行的数据模型设计。
逻辑数据建模
应用的数据模型通常分两个阶段构建。第一阶段是逻辑数据建模,它主要关注对应用程序所需存储与处理的信息进行建模,确保所有必要数据都被正确、完整且明确地表示。接下来,逻辑模型会被映射为物理数据模型。物理数据模型描述了在数据库管理系统(DBMS)中实际创建的表、索引和视图等对象。
逻辑模型通常只关注满足应用的功能性需求,而物理模型则还需满足非功能性需求,尤其是性能方面的要求。
在实践中,这两个阶段往往并不严格区分,尤其是在敏捷开发或其他迭代式开发模式中。不过,不论是否显式区分,确定应用处理哪些数据,与这些数据应如何在具体数据库中表示,二者所需的分析思路是明显不同的。
我们在第 1 章中介绍过关系模型的一些核心概念。从理论上讲,在逻辑建模阶段,我们处理的是关系(Relations)、元组(Tuples)和属性(Attributes);而在物理设计阶段,我们处理的是表(Tables)、行(Rows)和列(Columns)。然而在学术圈之外,这种术语上的区分常被忽视,实际开发中,人们经常直接使用"表"和"列"的语言进行逻辑建模。
我的罪过(MEA CULPA)
关系型数据建模在过去四十年中产生了海量的研究成果与辩论。要在不简化或不失真的前提下对其做出合理阐述几乎是不可能的。
本书的重点是 CockroachDB,而非关系理论,因此我们尽量避免陷入关于"关系设计应该怎么做"的纷争。我们的目标,是提供足够的关系建模基础知识,使读者能够理解 CockroachDB 中有关物理设计的具体原则。
如果你希望进一步深入学习关系理论和设计,推荐阅读一些优秀的专著,比如由 C.J. Date 撰写、O'Reilly 出版的《Database Design and Relational Theory》。
规范化(Normalization)
所谓规范化的数据模型,是指其中不存在任何冗余数据,并且所有数据都能通过主键和外键被完整地标识。尽管从性能角度出发,规范化模型很少是最终形态,但在模式设计的初始阶段,规范化模型几乎总是最优选择,因为它能最大限度地减少冗余和歧义。
关系型理论定义了多个"规范化层级"。第三范式(Third Normal Form,3NF) 通常被认为是数据模型规范化程度的合理标准。
在第三范式中,一个元组(即一行)中的每个属性(即一列)只能依赖于该元组的完整主键,不能依赖于其他属性或键 。我们可以用一句口诀来记住这个原则:"依赖主键,依赖整个主键,只依赖主键。"
例如,参考图 5-1 所示的数据(原文中应该紧接着会给出一个示意图)。
即使我们为 studentname
(学生姓名)、testname
(测试名称)和 testdate
(测试日期)创建一个主键,距离第三范式仍然相去甚远。例如,像 studentdob
(学生出生日期)这样的属性,仅依赖于主键的一部分(studentname
);而那些重复出现的"answer"(答案)列,则依赖于一个非键属性(即对应的 question
,问题)。
图 5-2 展示了该数据的规范化版本。在这个版本中:学生参加测试,测试包含问题,学生对这些问题作答。每个实体中的所有属性现在都完全依赖于该关系的主键。
不要过度规范化(Don't Go Too Far)
你通常可以通过信息是否冗余 来判断一个数据模型是否已经很好地规范化。例如,在图 5-2 中,你会注意到学生姓名、测试名称、问题内容等,在多个实体中都没有重复。每个属性在整个模型中只出现一次。在一个良好规范化的模型中,唯一被重复的应该是外键引用。
话虽如此,过度规范化在实际项目中却往往是一种错误。在真实的数据库中,模型中每新增一个表,都会给程序代码带来额外复杂性,同时也会增加数据查询时的关联(JOIN)开销。
举个例子,我们偶尔会看到一些地址信息被"规范化"成图 5-3 中那样的结构。从理论角度看,这种建模方式并没有问题。比如,两个学生可能共用一个公寓,而城市、州、省、国家之间的关系也是客观存在的。甚至,我们还可以继续往模型中添加"大陆""太阳系""星系"等实体,这也不会违反第三范式的定义。
然而在实际应用中,这种模型意味着每次查询学生地址时都需要进行五表关联(five-way JOIN) 。由于这是一个非常常见的操作,因此在整个应用生命周期内,JOIN 操作的开销将会非常高。相比之下,将地址信息直接嵌入在学生表中(如图 5-2 所示)可能是更实际的做法。
纯粹主义者可能会坚持认为,这种"反规范化"的操作应当在物理建模阶段进行。确实,在生产系统中为城市或州单独建表可能是合理的------比如每个城市或州都可能有与业务相关的独特属性。但我们建议你在面对这类扩展关系时保持务实的态度。投入大量精力去建模那些最终在物理实现阶段注定会被合并的逻辑关系,是一种资源浪费。
主键选择(Primary Key Choices)
在 CockroachDB 中,主键的选择对性能至关重要,因为它将决定数据在系统节点之间的分布方式。我们会在"物理设计"部分深入探讨这个问题。
不过,即使从逻辑建模的角度出发,也有一些因素需要考虑。
第三范式要求每个关系必须有一个主键,但并不规定该主键是"自然主键"还是"人工主键"。**自然主键(Natural Key)是由实体中本身具有唯一性的属性构成的,例如身份证号、电子邮箱等;而人工主键(Artificial Key)**则是人为生成的、不依赖于实体本身属性的唯一标识,例如自增 ID 或 UUID。在数据库界,关于"自然主键 vs 人工主键"的讨论一直未曾停歇。
如果你没有显式指定主键,CockroachDB 会自动为你创建一个人工主键。
总体而言,我们倾向于认为:大多数基础实体应该使用人工主键 。从性能角度来看,人工主键通常表现更优;同时,当自然主键发生变化时,人工主键可以避免由此带来的各种更新开销。此外,在 CockroachDB 中,使用人工主键还能帮助我们确保主键在整个集群中更均匀地分布。
例如,假设我们打算将用户的电子邮箱作为主键。电子邮箱对每个用户而言是唯一的,因此从逻辑上说这是一个合理的主键。但问题在于,用户可能会更改自己的邮箱 。一旦更改,不仅用户表中的记录需要更新,还可能导致数据在集群中的位置发生迁移。更糟的是,所有引用该主键的外键也都必须更新并迁移,这会带来更大的复杂性和性能损耗。
特殊用途的数据模型(Special-Purpose Designs)
对于任意一组数据,往往存在不止一种"几乎正确"的关系模型。而在众多可能的模型中,有些模式特别适用于某些特定的工作负载。其中最常见的两类是:
数据仓库模型(Data Warehousing Designs)
这类模型(例如星型结构和雪花结构)通常包含一个大型的"事实表"(fact table),它通过外键连接多个"维度表"(dimension table)。但需要指出的是,CockroachDB 并非主要用于数据仓库场景,因此这类模型并不是 CockroachDB 部署中的典型用法。
时序数据模型(Time-Series Designs)
在这类模型中,**时间戳(数据产生时间)是每条数据的主键组成部分之一,数据的累积主要以持续插入(inserts)**的方式进行。我们将在下一节中简要讨论与时序数据相关的一些设计考量。
物理设计(Physical Design)
物理设计是指在逻辑模型的基础上进行调整,以提升性能、提高与目标数据库的兼容性,或增强系统的可维护性。
并非所有"关系型"数据库都以相同方式实现关系特性,很多数据库还扩展了关系模型,引入了实用的附加功能。因此,从逻辑模型到物理模型的映射,高度依赖于目标数据库的特性。
除此之外,工作负载的特点也会推动物理设计的调整。例如,如果某张表始终需要与另一张表进行 JOIN 操作,那么我们可能会将后者的一些列冗余复制到前者中,以减少 JOIN 操作的开销。
另一个关键的物理设计驱动力是数据库引擎的功能和性能特性。例如,在 CockroachDB 中,递增的主键会在部分节点上造成热点问题,应该避免使用;而在非分布式的 SQL 数据库(如 PostgreSQL)中,递增主键通常没有问题。
接下来的几节中,我们将讨论将逻辑模型转换为 CockroachDB 上的物理实现时,需要考虑的各种转化方式。
从实体到数据表(Entities to Tables)
逻辑设计的主要产出包括实体(Entities) 、属性(Attributes) 和 键(Keys) 。要将逻辑模型转化为物理模型,我们需要将"实体"转换为"表",将"属性"转换为"列"。
这种转换在很多情况下接近一对一映射 ,但你也需要注意,有时一个实体可能会被拆分成多个表,反之亦然。例如,我们可能会决定将图 5-3 所示的逻辑模型简化为一张表,将所有地址属性直接折叠进学生表中;或者我们也可以将 addresses
实体合并进 students
表中,同时将 states
和 countries
合并进 cities
表。
有些逻辑模型还可能包含子类型(Subtypes) ,即一个实体中包含多种"类型"的元组。例如,一个 person
实体可能会定义如图 5-4 所示的多种子类型。
人既可以是客户,也可以是员工(甚至两者兼而有之)。那么,在设计模型时,我们到底应该:
- 使用一张统一的
person
表? - 分别建一张
customer
表和一张employee
表? - 还是采用三张表的设计:一张
person
表存储客户和员工共有的属性,再加上一张customer
表和一张employee
表,分别存储各自独有的属性?
这个问题的答案取决于你的业务场景和性能需求 。上述每种方案在特定类型的查询中都有其性能优势,因此你需要根据应用中最关键的操作进行权衡。不过,就我们观察到的实际情况而言,最常见的做法是采用两张表(customers
和 employees
) 的模型。
从属性到列(Attributes to Columns)
在将属性映射为列的过程中,主要关注两件事:
- 选择最合适的数据类型;
- 正确设置该列是否允许为
NULL
。
在关系型数据库中,NULL 是一个重要的概念 ------它用于区分"已知的空值"与"未知或缺失的值"。SQL 的三值逻辑(TRUE / FALSE / NULL)正是以此为基础,尤其体现在 WHERE
条件判断中。
在某些数据库系统中,不建议在索引列中使用 NULL
值,推荐使用 NOT NULL
并设置 DEFAULT
值。这是因为在某些数据库(如 PostgreSQL)中,NULL
值不会被包含在索引中 。但在 CockroachDB 中,NULL
值是可以被索引的 ,你也可以通过索引在 WHERE
子句中高效地处理 IS NULL
的查询条件。
CockroachDB 的数据类型大多可以很好地映射到逻辑模型中的数据类型。以下是一些示例说明:
- 字符串类型:
TEXT
、CHAR
、VARCHAR
、CHARACTER VARYING
和STRING
在 CockroachDB 中是等价的。 - 整型:
所有整数类型(如INT
、INT2
、INT4
、INT8
、BIGINT
、SMALLINT
等)在 CockroachDB 中底层以相同方式存储 。
举例来说,BIGINT
和SMALLINT
在存储同一数值时,占用空间是一样的。这些类型的主要作用在于限定值的范围 。
INT
类型可以表示任何允许的整数值(即 64 位有符号整数)。 - 浮点数:
FLOAT
、FLOAT4
、FLOAT8
和REAL
类型都存储为 64 位有符号浮点数。 - 精确小数:
DECIMAL
类型用于存储精确的定点小数,在需要高精度的场景(如货币金额)时建议使用。 - 二进制数据:
BYTES
、BYTEA
和BLOB
可用于存储变长的二进制字符串 。这些数据与其他行数据一起存储,因此不适合存放超大对象(建议最大 1MB)。 - 时间类型:
TIME
存储 UTC 时间;TIMETZ
存储带有时区偏移的时间;
TIMESTAMP
和TIMESTAMPTZ
则包含了日期与时间信息。
在本章稍后的内容中,我们还会继续介绍其他 CockroachDB 特有的数据类型,如 数组(ARRAY) 和 JSON 等。
主键设计(Primary Key Design)
我们在前几章中已经提到,在 CockroachDB 中合理定义主键至关重要,现在是时候认真讨论这个关键话题了。
在 CockroachDB 中,表的主键用于决定该表数据的分片(range)如何在集群中分布 。如果主键是单调递增的,那么所有新写入的数据将始终被追加到同一个分片中(因此会落在同一个节点上)。这个节点很可能会变成性能热点,从而限制整个集群的插入吞吐能力。随着集群规模的扩大,这个问题会变得更加严重------即使增加节点,也未必能带来性能的提升。
这种现象在以时间戳作为主键前缀的时序数据库中也非常常见。所有"新"数据都会集中写入到单一节点,导致集群的可扩展性大打折扣。
举个例子,来看下面这个 ORDERS
表的实现方式:
sql
CREATE SEQUENCE order_seq;
CREATE TABLE orders (
salesorderid INT NOT NULL PRIMARY KEY DEFAULT nextval('order_seq'),
orderdate DATE NOT NULL DEFAULT now(),
duedate DATE NOT NULL,
shipdate DATE NULL,
customerid INT NOT NULL,
salespersonid INT NULL,
totaldue DECIMAL NULL
);
上面的 order_seq
是一个 递增的序列生成器 ,它生成的数字几乎都是连续且递增的。由于每一个 ORDERID
的值都比前一个大,新订单的数据会始终被插入到同一个分片中------该分片驻留在某个特定的节点上。结果是,这个节点要承担所有的插入请求负载 。随着插入数据增多,新的分片会被创建,并逐步迁移到其他节点上,但在任意时刻,始终只有一个节点在处理所有插入操作。
图 5-5 形象地说明了这个问题:由于 CockroachDB 中的分片是按照主键顺序组织的,顺序键(sequential keys)会导致所有访问流量集中在某个分片边界处,进而形成系统瓶颈。
在接下来的几节中,我们将探讨如何避免**主键"热点"反模式(hotspot antipattern)**的几种设计方法。
基于 UUID 的主键(UUID-based Primary Keys)
如果你的应用不需要主键值是连续递增的 ,那么推荐使用 UUID(通用唯一标识符) 作为主键。
UUID 是一种在所有系统之间都能保持唯一性的标识符。它通过结合主机特定信息、随机数和时间戳来生成一个在时间和空间上都独一无二的标识。
你可以使用 gen_random_uuid()
函数来生成 UUID,并将其设置为主键的默认值,例如:
sql
CREATE TABLE orders (
orderid uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
orderdate DATE NOT NULL DEFAULT now(),
duedate DATE NOT NULL,
shipdate DATE NULL,
customerid INT NOT NULL,
salespersonid INT NULL,
totaldue DECIMAL NULL
);
UUID 具备如下优点:
- 唯一性强
- 选择性高
- 能够在集群的所有节点之间实现均匀分布
因此,在 CockroachDB 中,UUID 是最推荐的主键机制。
关于 SERIAL 数据类型(THE SERIAL DATA TYPE)
在 PostgreSQL 中,SERIAL
数据类型通常用于创建自增主键。它是一种比显式定义 SEQUENCE
更简洁的替代方案(如我们前面示例中的做法)。
但在 CockroachDB 中,SERIAL
默认是通过 unique_rowid()
函数生成唯一 ID 的。这个函数会将 节点 ID 与 时间戳 组合成一个唯一数值。虽然这些值通常是递增的,但并不保证严格有序,因此:
- 可能会出现较大间隔(gap)
- 仍然有产生写入热点(hotspot)的风险
你可以通过设置会话变量 serial_normalization
,将 SERIAL
的行为调整为更接近 PostgreSQL 的方式。但即使如此,使用这种方法生成的序列也仍可能存在间隔,而且性能开销较大。
因此,CockroachDB 官方不推荐使用 SERIAL
类型,除非你确实需要与 PostgreSQL 兼容。
使用复合主键避免热点(Avoiding Hotspots with a Composite Key)
有时候,应用确实需要使用单调递增的键值(KV) 。在这种情况下,为了避免热点问题,可以使用复合主键 ,并将一个非单调递增的字段 放在主键的前缀位置。例如,下面这个实现中,orderid
是递增的,但主键以 customerid
作为前缀:
sql
CREATE TABLE orders (
orderid INT NOT NULL DEFAULT nextval('order_seq'),
orderdate DATE NOT NULL DEFAULT now(),
duedate DATE NOT NULL,
shipdate DATE NULL,
customerid INT NOT NULL,
salespersonid INT NULL,
totaldue DECIMAL NULL,
PRIMARY KEY (customerid, orderid)
);
这种设计会将同一个客户的订单放在相同的分片中,而来自不同客户的订单将分布到集群中的不同节点上。
这种方式在"聚簇"客户数据方面可能带来一些好处,但也有明显的缺点:在查询订单时,我们必须知道客户 ID 才能查找订单。我们大概都遇到过这样的场景------被客服要求提供"客户编号 + 订单编号",这确实让人抓狂。
当然,我们也可以为 orderid
创建一个二级索引,但这又会带来另一个热点问题,因为这个索引本身就是按顺序插入的。
我们真正需要的是一种方法,既能支持单调递增的主键,又不会带来可扩展性瓶颈。这时候,就该引出解决方案了。
哈希分片主键(Hash-Sharded Primary Keys)
**哈希分片索引(Hash-Sharded Index)**会在主键前缀中添加一个哈希值。哈希值是唯一且非连续的,因此:
如果一个表的主键使用了哈希分片,那么这些键值将在整个集群的分片中均匀分布 。从统计意义上讲,插入操作将在各个节点之间实现理想的负载均衡。
以下是一个使用哈希分片主键的建表示例:
sql
CREATE TABLE orders (
orderid INT NOT NULL DEFAULT nextval('order_seq'),
orderdate DATE NOT NULL DEFAULT now(),
duedate DATE NOT NULL,
shipdate DATE NULL,
customerid INT NOT NULL,
salespersonid INT NULL,
totaldue DECIMAL NULL,
PRIMARY KEY (orderid) USING HASH WITH BUCKET_COUNT = 6
);
哈希分片对应用是完全透明的:你不会看到任何哈希字段,针对主键的过滤操作也都可以照常执行。
不过,需要注意的是:使用哈希分片主键后,无法再通过主键范围查询或按主键排序输出结果 。
比如以下查询,在传统主键上可以通过范围扫描高效执行:
vbnet
SELECT * FROM orders
WHERE orderid > 0
ORDER BY orderid;
而在哈希分片索引中,将需要执行扫描 + 排序操作。不过优化器可能会优化成多个哈希桶的短扫描,而不是全表扫描。
其中,WITH BUCKET_COUNT
子句用于指定哈希桶(即分片)数量。一个合理的默认值是集群节点数的两倍。
关于顺序键的"缺口"问题(GAPS IN SEQUENTIAL KEYS)
虽然使用序列(SEQUENCE
)可以确保生成单调递增的键值,但无法保证中间不会出现缺口(gap) 。这是因为:
- 出于性能考虑,序列号的增长并不属于应用事务的一部分。
- 如果事务在获取了序列号后
ROLLBACK
回滚,那么该序列号就会"丢失"。 - 为了实现可扩展的分布式性能,通常会使用
CACHE
选项,使每个节点预取一段序列号区间,这将导致不同节点插入的键值顺序无保障。 - 此外,如果集群重启,已缓存的序列号可能会丢失。
如果某个应用确实需要完全无缺口的连续序列号 (例如"不允许订单编号有遗漏"),那么这个应用需要自己实现一套序列号生成逻辑。
但这种做法在性能与一致性之间的权衡非常复杂,我们将在第 6 章中进一步探讨这个问题。
主键属性的顺序(Ordering of Primary Key Attributes)
在多列主键(multicolumn primary key)中,各个属性的排列顺序对性能有显著影响 。你应当遵循本章稍后将介绍的复合索引设计原则。
通常来说:
- 某一列若经常独立于其他列被使用,则应将该列排在主键的前面;
- 同样,如果某些主键列经常出现在
ORDER BY
子句中,也应优先考虑它们在主键中的排列顺序。
正确的列顺序不仅影响查询效率,也影响数据的物理存储和分布方式。
主键性能总结(Summary of Primary Key Performance)
我们花了大量篇幅讨论 CockroachDB 中的主键机制,这是有充分理由的。主键设计对系统可扩展性影响极大 ,而在传统单体 SQL 数据库中表现良好的设计方式,到了 CockroachDB 里往往会适得其反。
图 5-6 展示了这种差异可能带来的严重后果。可以看到:
- 使用
SERIAL
或SEQUENCE
生成的递增主键会显著降低插入吞吐量; - 推荐使用 UUID 类型的主键;
- 如果业务必须使用递增主键,则应采用 哈希分片主键索引(hash-sharded primary key index) 。
图 5-6 中的数据来自一个 九节点的 CockroachDB Cloud 集群 。
结果表明,递增主键造成的性能惩罚与集群规模成正比 :节点越多,热点问题越严重。
因此,实际效果会根据你集群的规模有所不同。
在创建序列(sequence)时使用 CACHE
选项,可以显著提升序列的性能。这样可以避免每次获取"下一个"序列号时的阻塞等待。
然而,在像 CockroachDB 这样的分布式系统中,使用 CACHE
会削弱序列生成器的本质作用 。因为集群中的每个节点都有自己的缓存,最终将导致整个集群中生成的序列号是乱序的。
外键约束(Foreign Key Constraints)
外键约束有助于保障数据完整性,并能作为数据模型的"内嵌文档"被查询生成器和建模工具利用。
但在执行 DML 操作(尤其是插入)时,数据库必须通过查找被引用表的主键 来验证外键的有效性。这些查找操作会显著增加执行开销,从而降低整体吞吐性能。
对于包含外键约束的表来说,性能影响主要体现在插入操作上------因为外键很少会被更新,而且在删除操作时外键一般不需要验证。
而对于被引用的"父表" ,性能压力最明显的是在执行删除操作时,因为需要检查是否存在悬空引用(dangling references) 的子记录。
在外键约束定义中使用 ON DELETE CASCADE
子句时,删除父记录时会自动删除所有子记录 ;而 ON UPDATE CASCADE
在更新主键时也会产生类似效果(尽管更新主键在多数应用中比较少见)。
由于外键约束引入了额外开销,因此在生产环境中,将其移除是很常见的做法。在测试或开发环境中保留外键约束可以帮助及时发现数据异常,但在生产环境中,通常会为了性能而关闭它们。
反规范化(Denormalization)
在构建规范化数据模型的过程中,其核心目标之一就是消除数据表示中的冗余 。在一个良好规范化的模型中,每个数据元素在模型中只会出现一次,从而避免了数据库内部出现信息不一致的风险。
而反规范化 ,则是在物理模型中重新引入冗余、重复,或非规范化的数据结构 ------几乎总是为了提升性能而进行的设计。
反规范化是一种非常常见的实践,你完全不必因此而"感到有负罪感"。不过,也要牢记,反规范化可能带来一些副作用:
- 反规范化的数据可能导致数据不一致
这种不一致可能是暂时性的 (例如等待物化视图刷新),也可能是永久性的 (比如因为程序错误导致派生值未被更新)。因此,你需要确保具备健壮的机制来维护数据一致性。 - 反规范化本身也有性能开销
虽然反规范化的初衷是为了提高查询性能,但大多数情况下,它其实是以 DML(插入/更新/删除)性能为代价来换取查询性能的提升。你需要清楚地了解并接受这些权衡。
最理想的反规范化方式,是那些能够由数据库系统自动、透明地维护 的数据冗余结构。
例如,在某些数据库中,你可能会想对某张表进行纵向分区(vertical partitioning) ,将常用列与不常用列分开以优化访问效率。而在 CockroachDB 中,第 2 章介绍的 列族(column families) 就可以在不改动应用代码的前提下实现这一目标。我们将在后面的"纵向分区"小节中更详细地探讨列族的使用。
通过复制列来避免 JOIN(Replicating Columns to Avoid Joins)
JOIN 操作会显著增加数据查询的开销 。过度追求规范化常常会带来这样的后果:即使是最简单的 SELECT
查询,也可能需要跨多张表进行 JOIN。
举个例子,看看图 5-7 所示的部分模式结构(Partial Schema)。
(译者注:图中可能展示了某些典型的过度规范化场景,比如订单、客户、商品等表需要频繁 JOIN 的结构)
为了查询某个人的地址(假设这是我们经常进行的操作),我们需要进行一个五表连接(JOIN)操作:
css
SELECT p.firstname, p.lastname, a.addressline1, a.city, s2.name, c2.name
FROM person p
JOIN businessentityaddress b3 ON (b3.businessentityid = p.businessentityid)
JOIN address a ON (b3.addressid = a.addressid)
JOIN stateprovince s2 ON (s2.stateprovinceid = a.stateprovinceid)
JOIN countryregion c2 ON (c2.countryregioncode = s2.countryregioncode)
WHERE p.businessentityid = 1;
虽然这个 JOIN 是基于主键值(KV)的,因此性能相对还算可接受,但很明显,它需要进行五次查找操作,而如果所有这些字段都直接包含在 person
表中,只需一次查找。因此,解决方案很直接:将地址信息直接复制进 person
表中。
当一个人的地址发生变更时,可能需要执行两个 UPDATE
操作(分别更新 address
表和 person
表),但你不需要每次都进行五表 JOIN 来获取地址信息了。
就像很多设计决策一样,这并不是"全有或全无"的问题。在保留图 5-7 中"一人多地址"的设计的前提下,你也可以考虑将 state
和 country
表合并进 address
表,从而减少 JOIN 所涉及的表的数量。
汇总表(Summary Tables)
汇总表(summary table)通常用于存储那些实时计算开销较大的聚合信息。例如,在 MovR 应用中,我们可能需要一个仪表盘展示"按城市的收入趋势",其背后的查询可能如下:
vbnet
SELECT CAST(r.start_time AS date) AS ride_date, u.city, SUM(r.revenue)
FROM rides r
JOIN users u ON (u.id = r.rider_id)
GROUP BY ride_date, u.city;
由于历史日期的收入基本不会变动,每次刷新仪表盘都重新执行这个昂贵的查询是非常浪费的 。因此,我们可以从这个查询结果中创建一个汇总表,并定期(比如每小时一次)刷新数据。
你可以手动创建并维护这样的汇总表,但也可以使用专门用于此目的的 物化视图(Materialized View) 。例如:
vbnet
CREATE MATERIALIZED VIEW ride_revenue_by_date_city AS
SELECT CAST(r.start_time AS date) AS ride_date, u.city, SUM(r.revenue)
FROM rides r
JOIN users u ON (u.id = r.rider_id)
GROUP BY ride_date, u.city;
这个物化视图表相比原始表要小得多,而且我们可以手动控制何时刷新数据。比如通过以下命令:
ini
REFRESH MATERIALIZED VIEW ride_revenue_by_date_city;
纵向分区(Vertical Partitioning)
纵向分区 是指将一张表拆分成多张表,每张表包含不同的列集。这样做的目的是减少每次更新一行时所需处理的数据量,并降低在高并发更新时发生事务冲突的概率。
比如考虑一个物联网(IoT)应用,城市中的多个天气传感器每秒更新多次当前的温度和气压数据:
sql
CREATE TABLE cityWeather (
city_id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
city_name varchar NOT NULL,
currentTemp float NOT NULL,
currentAirPressure float NOT NULL
);
如果温度和气压数据来自不同的系统,同时写入同一行,就可能引发事务冲突。为避免这种冲突,我们可以将表拆为两张:
sql
CREATE TABLE cityTemp (
city_id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
city_name varchar NOT NULL,
currentTemp float NOT NULL
);
CREATE TABLE cityPressure (
city_id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
city_name varchar NOT NULL,
currentAirPressure float NOT NULL
);
不过,在 CockroachDB 中你不需要更改数据模型 ,可以通过 列族(Column Families) 来实现类似的效果。
正如第 2 章所介绍的,列族可以让一组列在存储层中被独立存储。你只需要为每组数据定义一个独立的列族,例如:
sql
CREATE TABLE cityWeather (
city_id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
city_name varchar NOT NULL,
currentTemp float NOT NULL,
currentAirPressure float NOT NULL,
FAMILY f1 (city_id, city_name),
FAMILY f2 (currentTemp),
FAMILY f3 (currentAirPressure)
);
这样做可以在不牺牲逻辑结构清晰度的前提下,获得类似纵向分区带来的并发性能提升。
水平分区(Horizontal Partitioning)
水平分区(通常简称为"分区")是指将一个表或索引拆分为多个逻辑片段(segments)。这种做法的好处包括:
- 查询时可以只读取包含相关数据的分区 ,从而减少逻辑读取的次数。
这种技术被称为 分区消除(partition elimination) ,尤其适用于以下场景:查询要读取的数据量太大,无法有效利用索引,但又不需要扫描整张表。 - 将表或索引拆分为多个分段后,可以显著提高并行处理能力,因为每个分区的操作可以并发执行。
CockroachDB 支持使用与 Oracle 等企业数据库类似的语法进行显式表分区(explicit table partitioning) 。
不过,由于 CockroachDB 的多区域部署能力(multiregion capabilities)非常强大,许多需要手动分区的场景在这里都被"自动化"处理了。例如,按行划分区域的表(regional by row tables)会被自动透明地分区,使来自特定地区的访问更高效。在其他数据库中,这种优化往往需要显式分区来实现。我们将在第 11 章详细介绍多区域部署拓扑结构。
重复组(Repeating Groups)
关系模型本身是不允许存在重复组(repeating groups)的,因为重复组中的属性无法仅依赖主键 来唯一标识。例如,在数组类型中,元素依赖于主键 加上数组下标 才能被唯一确定。
但在实际开发中,如果我们要查询一组同类型的小数据集合,频繁使用 JOIN 会显得异常繁琐。
例如,在本章开头的图 5-2 中,我们定义了一个 testAnswers
实体,其中每一行代表一次答题。如果一个测试有 100 道题,那就要读取 100 行记录,才能查看所有的答案。
这时,可以使用 数组类型(Array Type) 作为替代方案。CockroachDB 支持一维数组 ,数组中的元素类型必须一致。
例如,我们可以将某次测试的所有答案放入一个数组字段中:
sql
CREATE TABLE studentTest (
student_id uuid NOT NULL,
test_id uuid NOT NULL,
testDate date NOT NULL,
testAnswers varchar[] NOT NULL
);
然后我们可以用一条 UPDATE
语句一次性更新所有答案:
ini
UPDATE studentTest s
SET testAnswers = array['a', 'b', 'c', 'd']
WHERE student_id = '2fdaadf5-ff3e-45c4-bc92-cc0d566e1ad9'
AND test_id = 'dca69ac4-6c53-4efb-8c7e-bca9f412e2ee';
这样,我们只需要读取一行记录 就可以获取全部测试结果,大大减少了访问开销。
当然,数组类型也有一些缺点:
- 查询语法相对复杂
- 不太适合执行分析型查询,例如对数组内的元素求和或平均值,这些在 SQL 中并不直接受支持
不过,CockroachDB 支持倒排索引(Inverted Indexes) ,可以高效地对数组类型字段进行查询和检索。我们将在本章后续内容中详细介绍倒排索引的使用方法。
JSON 文档模型(JSON Document Models)
在过去十年里,对关系型数据库最大挑战的来源,莫过于 MongoDB、Couchbase 等所谓的"文档型数据库(document databases)"。这些数据库将所有数据以 JSON 文档 的形式进行存储。由于 JSON 本身是自描述的结构,因此数据库管理系统(DBMS)无需显式定义数据模式。你只需从数据库中取出 JSON,查看其结构即可了解数据内容。
抛开"抛弃关系模型、转投 JSON 文档"的"正统性之争"不谈,文档型数据库确实为开发者带来了不少便利:
- 面向对象编程(OOP) 中常见的复杂对象,往往具有嵌套结构和重复字段组,天然是"非规范化"的。若存入传统关系型数据库,通常需要先"拆解"这些对象。
正如有开发者戏称:"使用关系数据库就像有一个车库,但你必须把整辆车拆开,把零件分开存进抽屉里。"
而文档型数据库允许你"整车入库"。 - JSON 支持数据模型的动态演化 。
例如,通过 REST 接口接收来自 IoT 设备的响应数据,不需要提前定义字段结构,就能直接存入数据库。 - 现代 DevOps 流程强调持续集成(CI) ,需要代码与数据库能无缝协作。
传统 RDBMS 需要将代码提交与数据库结构变更(如 ALTER TABLE)同步进行,增加了部署难度。
文档型数据库通过灵活的数据结构绕过了这个障碍。
如果这些特性对你很有吸引力,那么你可能会希望将所有或部分数据存储为 CockroachDB 的 JSONB 类型。
JSON 对象是自描述的,可以包含嵌套的对象和数组。文档型数据库中,通常将子数据嵌入到父对象中以避免 JOIN 操作。
例如,下面是一个民宿房源的文档结构示例:
json
{
"_id": "10006546",
"listing_url": "https://www.airbnb.com/rooms/10006546",
"name": "Ribeira Charming Duplex",
"summary": "Fantastic duplex apartment with three bedrooms, located in the historic area of Porto, Ribeira (Cube)...",
"amenities": [
"TV", "Cable TV", "Wifi", "Kitchen", "Paid parking off premises", "Waterfront"
],
"images": {
"thumbnail_url": "",
"medium_url": "",
"picture_url": "https://a0.muscache.com/im/p/9b.jpg?aki_policy=large",
"xl_picture_url": ""
},
"host": {
"host_id": "51399391",
"host_url": "https://www.airbnb.com/users/show/51399391"
}
}
JSON 文档的反模式(JSON Document Antipatterns)
在 CockroachDB 中,不建议在 JSON 文档中实现一对多的关系 。
这是因为:JSON 数据会与行数据一起以内联方式存储在底层 KV 存储中 。CockroachDB 推荐将 JSON 文档的大小控制在 1MB 以下。
例如,在前一章介绍的视频流模型中,我们将用户观看过的所有影片嵌入到一个 JSON 数组中。考虑到如今的视频观看行为十分频繁,对于部分用户来说,这种做法很可能会导致 JSON 超过 1MB 的限制。
JSON 属性索引(Indexing JSON Attributes)
如前几章所述,你可以在 JSONB 列上创建倒排索引(inverted index) 。
倒排索引允许你在 JSON 对象中高效检索任意属性的值,而无需提前知道会有哪些属性。
不过,要注意:倒排索引会索引 JSON 中的每一个属性 ,因此可能会使索引项数量远超表中的行数,导致存储空间占用上升 ,且 索引维护成本增加。
另一种更轻量的做法是使用 计算列(computed columns) 从 JSONB 提取需要索引的字段,然后在这些列上建索引。
这种方式的前提是:你需要知道哪些字段可能会被查询,但你将获得更紧凑的索引结构。
假设我们决定将用户的详细信息以 JSONB 存储,JSON 文档如下:
css
{
"Address": "1913 Hanoi Way",
"City": "Sasebo",
"Country": "Japan",
"District": "Nagasaki",
"FirstName": "Mary",
"LastName": "Smith",
"Phone": "886780309",
"dob": "1982-02-20T13:00:00Z",
"likes": [
"Dinosaurs",
"Dogs",
"People"
]
}
我们知道我们会基于 LastName
和 FirstName
进行搜索,并希望能通过 cityId
与已有的 cities
表建立外键关系。那么可以定义如下数据表:
sql
CREATE TABLE people (
personId UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),
cityId UUID,
personData JSONB,
FirstName STRING AS (personData->>'FirstName') VIRTUAL,
LastName STRING AS (personData->>'LastName') VIRTUAL,
FOREIGN KEY (cityId) REFERENCES cities(cityid),
INDEX (LastName, FirstName)
);
这样的设计可以让我们:
- 高效地基于 LastName 和 FirstName 进行索引查询;
- 避免每次查询都需要使用复杂的 JSON 解析语法;
- 无需频繁使用 ALTER TABLE 就能为 JSON 增加新字段;
- 程序员可以直接将
personData
字段作为 JSON 加载进对象模型中使用。
使用 JSON 或数组避免 JOIN(Using JSON or Arrays to Avoid Joins)
我们之前已经提到:一对多关系不应使用 JSONB 列来建模,对于数组列也是同样的道理。我们应该避免在这些列中存储超过 KV 存储引擎一次操作可处理的数据量。
不过,在处理"一对少"关系(one-to-few relationships)时,使用 JSONB 或数组列则是非常有效的 。
举个例子,回顾我们在图 5-2 中设计的 "学生测试表" 模型:我们知道一次测试的题目数量通常最多也就几百道。在规范化的模型中,为了获取一次测试的所有答题结果,总是需要进行 JOIN 操作:
ini
SELECT s.student_id, s.test_id, question_no, questionanswer
FROM studentTest s
JOIN testAnswers t ON(t.student_id = s.student_id AND t.test_id = s.test_id)
WHERE s.student_id = ?
AND s.test_id = ?;
如果我们确定不会超过 CockroachDB 允许的 1MB 文档大小限制,那么就可以考虑将所有测试答案合并进一个 JSON 文档:
sql
CREATE TABLE studentTest (
student_id uuid NOT NULL,
test_id uuid NOT NULL,
testDate date NOT NULL,
answers JSONB
);
并以如下方式插入数据:
vbnet
INSERT INTO studentTest (student_id, test_id, testDate, answers)
VALUES (
'2fdaadf5-ff3e-45c4-bc92-cc0d566e1ad9',
'dca69ac4-6c53-4efb-8c7e-bca9f412e2ee',
now(),
'{
"answers": [
{"questionNumber": 1, "Answer": 5},
{"questionNumber": 2, "Answer": 25},
{"questionNumber": 3, "Answer": 58},
{"questionNumber": 4, "Answer": 3425},
{"questionNumber": 5, "Answer": 432},
{"questionNumber": 6, "Answer": 0},
{"questionNumber": 7, "Answer": 673}
]
}'
);
我们也可以在其他存在"多对少"关系(many-to-few)的场景下使用数组或 JSON 重复字段来避免 JOIN 操作。
例如,考虑图 5-8 所示的学生与课程的关系(students 与 classes),也是一个可以使用 JSON 或数组优化的场景。
每当我们想获取某个学生所选的课程列表时,都需要进行三表连接(three-way JOIN) :
sql
SELECT class_name FROM students
JOIN studentClasses USING(student_id)
JOIN classes USING(class_id)
WHERE student_id = '000390a6-4e1d-4bc1-aad7-66b645131d54';
这里的 studentClasses
表仅用于连接 students
和 classes
表,它本身并不包含任何独立的信息。
我们可以用数组类型来替代这种结构:将一个学生所选的所有课程 ID 作为外键,直接存储在数组列中:
sql
ALTER TABLE students ADD COLUMN classes UUID[];
UPDATE students s SET classes = (
SELECT array_agg(class_id)
FROM studentClasses sc
WHERE s.student_id = sc.student_id
);
上述示例中,array_agg
函数会将结果集中的所有 class_id
聚合为一个数组,我们把这些课程 ID 拷贝到每个学生的 classes
数组字段中。
此后,当我们需要查询某个学生的所有课程时,就可以使用 UNNEST
将数组"展开",然后与 classes
表进行连接:
vbnet
WITH students_classes AS (
SELECT student_id, UNNEST(classes) AS class_id
FROM students
)
SELECT class_name FROM classes
JOIN students_classes USING(class_id)
WHERE student_id = '000390a6-4e1d-4bc1-aad7-66b645131d54';
这种方式看起来可能稍显复杂,但在高性能场景下 ,将三表 JOIN 简化为二表 JOIN 是非常必要的,哪怕会稍微增加应用代码的复杂度。
当然,如果我们像之前处理测试答案那样,将所有课程信息直接嵌入到一个 JSONB 列中 ,就可以彻底避免 JOIN。
不过,使用数组列的方案不会复制 classes
表中的任何冗余信息 ,所以如果课程名称发生变更,只需要更新 classes
表一次即可。
要注意的是:通过这种方式嵌入外键,会丧失使用 FOREIGN KEY 约束的能力 ,也容易产生数据不一致的风险。
此外,在这种结构下,要查找属于某个课程的所有学生会变得更加困难,因为你需要对每个学生的课程数组进行解析才能判断是否包含该课程。
索引
索引是一个数据库对象,提供了一条快速访问表中特定数据的路径。
我们在第二章中讨论了索引的结构。你可能还记得,在CockroachDB中,索引和表共享相同的基础存储结构。基本表本质上是由主键索引的关系。二级索引也是关系,但由索引键索引,列值代表与该二级键关联的主键KVs。
索引的存在是为了优化性能和强制执行唯一性。一般来说,索引可以在不需要修改应用代码的情况下添加到系统中,因此与其他物理实现选项相比,它们相对容易修改。创建一个最优的索引集合是确保数据库性能最优的最重要因素之一。
索引选择性
列或列组的选择性是衡量这些列上索引有用性的常见标准。如果列或索引具有大量唯一值且重复值较少,则其选择性较高。例如,出生日期列的选择性很高,而性别列的选择性则很低。
选择性高的索引比选择性低的索引更有效,因为它们能更直接地指向特定值。CockroachDB优化器将确定其可用的各种索引的选择性,并通常会尽量使用选择性最高的索引。
索引盈亏平衡点
当你只想查找书中的某些内容时,你会查阅索引。当你想要吸收所有或大部分内容时,你会绕过索引,直接读正文。数据库索引也是如此------我们通常只在检索表中相对少量数据时才使用索引。
非覆盖索引------即包含过滤条件但不包含SELECT列表中所有列的索引------通常仅在检索表中少量数据时有效。超过这个范围时,从索引到基本表的来回跳转开销会比直接读取所有表中的行慢。
优化器将尝试确定访问的数据量,并选择合适的索引或表扫描。然而,你不想创建一个永远不会使用的索引,因此了解索引与表扫描之间的临界点非常重要。
然而,当我们使用STORING子句创建覆盖索引时,情况就非常不同;在这种情况下,即使访问了大量数据,索引也能优于表访问。确实,通过STORING子句向索引中添加列会增加索引维护的开销,但大多数情况下,查询性能的提升会大于写入开销的增加。
例如,假设我们有一组时间序列数据,其中每分钟记录一次测量(如温度),记录跨度为过去一年。应用程序常常需要确定某个近期时间段内的平均测量值。查询可能如下所示:
sql
SELECT AVG(measurement)
FROM timeseries_data
WHERE measurement_timestamp >
((date '20220101') - INTERVAL '$dayFilter days');
变量$dayFilter可以取低或高值。我们可以在表上创建一个非覆盖索引,如下所示:
scss
CREATE INDEX timeseries_timestamp_i1
ON timeseries_data(measurement_timestamp);
然而,只有在选择的天数非常少时(可能少于一周),该索引才有效。或者,我们可以创建一个包含测量列的覆盖索引:
scss
CREATE INDEX timeseries_covering
ON timeseries_data(measurement_timestamp) STORING (measurement);
这个索引可以有效地用于任何数据范围------从一天到整年的数据。
图5-9比较了索引扫描与表扫描的性能,依据是检索的数据天数。表扫描无论处理多少数据,都必须做相同的工作,而索引扫描的开销随着处理数据量的增大而增加。对于非覆盖索引,当检索的数据超过约一周的数据(约占总数据的2%)时,表扫描会更好。然而,覆盖索引即使在检索整个表数据时也能表现得很好。
从图5-9中可以得出几点结论:
- 当数据量达到总量的约10%到15%时,优化器会从索引扫描切换到表扫描。优化器是一款复杂的软件,但它不是魔法,它不能总是判断哪种访问路径更好。在某些情况下,创建一个非覆盖索引实际上会降低性能。
- 覆盖索引在性能上远远优于非覆盖索引,即使访问了整个表或大部分数据时也能有效使用。尽可能使用覆盖索引。
- 请记住,在CockroachDB中,索引和表具有相同的存储格式:覆盖索引不仅是一个快速访问机制,它也是表列子集的紧凑表示,比基本表扫描要快得多。
我们将在第8章回到索引性能和查询调优的内容。
索引开销
尽管索引可以显著提高读取性能,但它们会降低写操作的性能。通常,当插入或删除一行时,表中的所有索引都必须更新,当更新更改了索引中的任何列时,索引也必须修改。
因此,确保所有的索引都对查询性能有所贡献非常重要,因为否则这些索引将无谓地降低写入性能。特别是,当在频繁更新的列上创建索引时,应该特别小心。一行数据只能插入或删除一次,但可能会更新多次。因此,在更新频繁的列或插入/删除率非常高的表上创建索引会带来特别高的成本。图5-10展示了随着更多索引添加到表中,写入性能的开销情况。
请注意,通过删除主键索引无法提高性能。如果不存在显式的主键索引,CockroachDB将为你创建一个人工的主键。
复合索引
复合索引就是由多个列组成的索引。复合索引的优势在于,它通常比单列索引更具选择性。多个列的组合能够指向比单独的列索引更少的行。
例如,如果我们知道经常对firstname
和lastname
进行搜索,那么在这两列上创建一个索引是很有意义的:
arduino
CREATE INDEX flname_idx ON person (lastname, firstname);
这样的索引比仅在lastname
列上或分别在lastname
和firstname
列上创建的索引要更有效。稍后我们将在本章中提供一些关于复合索引的性能比较。
如果复合索引仅在WHERE子句中出现所有索引列时才能使用,那么你必须创建大量的复合索引------每个WHERE子句中列的不同组合都会对应一个索引。幸运的是,复合索引可以在只使用其初始或"领先"列时有效使用。领先列是索引定义中最早指定的列。
例如,我们刚才创建的(lastname, firstname)
索引可以优化以下查询:
ini
SELECT * FROM person WHERE lastname = 'Wood';
但不能优化以下查询:
ini
SELECT * FROM person WHERE firstname = 'John';
覆盖索引
覆盖索引是一种能够在不参考基本表的情况下满足查询的索引。例如,考虑以下查询:
ini
SELECT phonenumber
FROM people
WHERE lastname = 'Smith'
AND firstname = 'Samantha'
AND state = 'California';
在这种情况下,lastname
、firstname
、state
和phonenumber
的索引不仅可以找到请求的数据,还能返回phonenumber
。只需一次索引访问,不需要读取基本表。
在CockroachDB中,我们可以使用STORING
子句来存储我们可能在SELECT
子句中使用但不在WHERE
子句中使用的数据元素。对于前述查询,这个索引将是最优的:
scss
CREATE INDEX people_lastfirststatephone_ix ON people
(lastname, firstname, state)
STORING (phonenumber);
当然,我们也可以不使用STORING
子句,通过直接将列添加到索引中来创建一个覆盖索引。例如:
arduino
CREATE INDEX people_lastfirststatephone_ix ON people
(lastname, firstname, state, phonenumber);
这个索引可以用于满足包括电话号在WHERE
子句中的查询,因此通常会更优。然而,STORING
确实有一些优势。某些数据类型(如JSON和数组)不能被索引,但所有数据类型都可以存储。其他数据类型在被索引时占用的空间比存储时更多(对于排序字符串尤为严重)。如果你知道不需要按某个列进行过滤,存储它而不是索引它可能会更高效。
复合索引和覆盖索引性能
图5-11展示了复合索引和覆盖索引所带来的性能优势。图表显示了在不同索引场景下,满足以下查询所需的KV存储选项数量:
ini
SELECT phonenumber
FROM people
WHERE lastname = 'Smith'
AND firstname = 'Samantha'
AND state = 'California';
图5-11显示,如果没有索引,查询需要18,798次KV操作------我们必须读取表中的每一行。在lastname
或firstname
上创建的单列索引会稍微改善性能,而同时拥有lastname
和firstname
的索引比仅有其中任意一个索引要更好。
然而,直到我们使用复合索引时,才会看到真正高效的索引。如果我们在lastname
和firstname
上创建索引,只需6次KV读取;如果在lastname
、firstname
和state
上创建索引,仅需2次KV操作。如果我们将phonenumber
存储在索引中,那么只需要一次KV操作。
复合索引的指导原则
如前所述,索引带来的性能提升是有代价的------每个索引都会增加DML操作的开销,因此我们通常不能创建所有可能的索引。
最佳策略是创建覆盖尽可能广泛查询范围的复合索引。只要复合索引的任何领先列出现在WHERE子句中,该索引就可以被有效使用,因此复合索引中列的顺序非常重要。
以下指导原则可能有助于决定创建哪些索引:
- 为在WHERE子句中一起出现的列创建复合索引。
- 如果某些列有时会单独出现在WHERE子句中,请将它们放在索引的最前面。
- 列的选择性越高,它在索引前端的作用就越有用。
- 如果复合索引还支持那些未指定所有列的查询,那么这个复合索引就更有用。例如,
lastname, firstname
比firstname, lastname
更有用,因为仅查询lastname
的情况比仅查询firstname
的情况更常见。
索引和空值
在许多关系型数据库中,空值不会包含在索引中。因此,在这些系统中,通常建议在可能需要查找空值的情况下避免使用空值。然而,在CockroachDB中,空值包含在索引中,并且可以像普通索引一样通过索引进行查找。
倒排索引
我们在第二章和本章的"JSON文档模型"中讨论了倒排索引。倒排索引为数组中的所有元素以及JSONB列中的所有属性创建索引。尽管这些倒排索引非常有用且灵活,但从存储和维护的角度来看,它们的开销较大。我们建议,在可能的情况下,创建一个计算列来代替JSONB属性,并在该列上创建索引。
部分索引
部分索引可以仅在表的某些行上创建。通过在CREATE INDEX语句中添加WHERE子句,可以创建部分索引。
部分索引可以具有较低的维护开销,减少数据库中的存储需求,并且对于适当的查询更为高效。因此,它们是非常有用的索引类型。
部分索引的主要限制是,只有当CockroachDB可以确定部分索引包含满足查询所需的所有条目时,它才能被使用。实际上,这意味着部分索引通常用于优化包含与索引定义中相同WHERE子句过滤条件的查询。
排序优化索引
在某些情况下,索引可以用于优化ORDER BY操作。当CockroachDB需要返回按排序顺序的数据时,它必须检索所有需要排序的行,并在返回任何数据之前对这些行进行排序。然而,如果在ORDER BY列上存在索引,那么CockroachDB可以读取该索引,并直接按排序顺序从索引中检索行。
使用索引按排序顺序检索数据通常只有在优化一些少量"顶部"行时才有意义。如果你从索引中按排序顺序读取整个表,那么你将读取所有索引条目以及所有表条目,I/O操作的总数会过高。然而,如果你只获取第一"页"数据或"前10名",那么索引会更快,因为你完全不需要读取剩下的表行。
然而,如果索引包含了你在输出中需要的所有列,不管是因为它索引了所有这些列,还是使用STORING子句存储了其他列,那么你就能得到两全其美的结果------你可以高效地按排序顺序检索所有行。
图5-12展示了使用索引优化排序操作的效果:
vbnet
SELECT *
FROM orderdetails
ORDER BY modifieddate;
当查询中添加了LIMIT子句时,索引将执行时间从123毫秒减少到仅2毫秒------这是一个巨大的改进。然而,如果我们强制CockroachDB使用索引来检索所有行(这是默认情况下不会做的),那么执行时间将从296毫秒增加到4,000毫秒。
表达式索引
顾名思义,表达式索引是基于表达式创建的索引!也许你想在某列上创建索引,但在使用该列之前总会对其进行某种操作。在这种情况下,表达式索引是一个很好的选择。我们来看看一些可以使用表达式索引的例子。
在接下来的例子中,我们将创建一个简单的表并插入一些数据:
sql
CREATE TABLE customer (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"email" STRING NOT NULL,
"address" JSON NOT NULL
);
INSERT INTO customer ("email", "address") VALUES
('[email protected]', '{"zip": "10001"}'),
('[email protected]', '{"zip": "EC4M 9AF"}');
我们将对customer
表创建两个表达式索引。一个将索引客户邮箱地址的域部分(也许我们想要筛选来自特定公司的客户),另一个将索引他们地址JSON中的一个字段。
如果我们现在查询客户邮箱地址的域部分,CockroachDB将不得不执行全表扫描。它必须读取每个邮箱值,从邮箱地址中提取域,并与我们筛选的给定值进行比较:
sql
EXPLAIN SELECT * FROM customer
WHERE REGEXP_EXTRACT("email", '@(.+)$') = 'acme.com';
distribution: local
vectorized: true
• filter
│ filter: regexp_extract(email, '@(.+)$') = 'acme.com'
│
└── • scan
missing stats
table: customer@customer_pkey
spans: FULL SCAN
如以下示例所示,将表达式索引应用于该列可以让CockroachDB直接找到邮箱的域部分,不仅避免了对每一行执行正则提取操作,还节省了CockroachDB扫描列以查找值的时间(因为元范围将指向匹配行的确切位置)。索引的使用可以在EXPLAIN输出的最后一行中看到,如spans: [/'acme.com' - /'acme.com']
,这与我们筛选的值相匹配:
arduino
CREATE INDEX idx_customer_email_domain
ON customer ((REGEXP_EXTRACT("email", '@(.+)$')));
sql
SELECT * FROM customer
WHERE REGEXP_EXTRACT("email", '@(.+)$') = 'acme.com';
id | email | address
---------------------------------------+-----------------+-------------------
e13ab911-61ac-42af-be0c-1abff74d598e | [email protected] | {"zip": "10001"}
sql
EXPLAIN SELECT * FROM customer
WHERE REGEXP_EXTRACT("email", '@(.+)$') = 'acme.com';
distribution: local
vectorized: true
• index join
│ estimated row count: 1
│ table: customer@customer_pkey
│
└── • scan
estimated row count: 2 (99% of the table; stats collected 4 minutes ago)
table: customer@idx_customer_email_domain
spans: [/'acme.com' - /'acme.com']
表达式索引还可以对JSON文档中的字段进行创建。对于JSON文档,最灵活的索引是倒排索引,它会对文档中的每个字段创建索引。然而,如果你只关心文档中的一两个字段,那么倒排索引可能就有些过于复杂。让我们通过表达式索引来索引客户的邮政编码,以避免对整个地址进行索引:
arduino
CREATE INDEX idx_customer_address_zip
ON customer (("address"->>'zip'));
sql
SELECT * FROM customer
WHERE "address"->>'zip' = 'EC4M 9AF';
id | email | address
---------------------------------------+--------------------+--------------------
07844da4-a417-45be-8ea5-b2c1767d6ebc | [email protected] | {"zip": "EC4M 9AF"}
sql
EXPLAIN SELECT * FROM customer
WHERE "address"->>'zip' = 'EC4M 9AF';
info
---------------------------------------------------------------------------------
distribution: local
vectorized: true
• index join
│ estimated row count: 1
│ table: customer@customer_pkey
│
└── • scan
estimated row count: 2 (99% of the table; stats collected 5 minutes ago)
table: customer@idx_customer_address_zip
spans: [/'EC4M 9AF' - /'EC4M 9AF']
表达式索引避免了全表扫描,并允许CockroachDB精准定位包含我们请求的邮政编码的行的确切位置。
全文索引
CockroachDB通过TSVECTOR和TSQUERY数据类型的组合支持自然语言全文搜索。
为了准备进行全文搜索,首先需要对文本字符串进行规范化。使用to_tsvector
函数将字符串规范化为包含词形和位置的TSVECTOR,如下所示:
arduino
SELECT to_tsvector('the quick brown fox jumps over the lazy dog');
-- 返回:'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2
然后使用to_tsquery
、plainto_tsquery
或phraseto_tsquery
函数准备查询字符串:
arduino
SELECT to_tsquery('over & the & lazy & dog');
-- 返回:'lazi' & 'dog'
SELECT plainto_tsquery('over the lazy dog');
-- 返回:'lazi' & 'dog'
SELECT phraseto_tsquery('over the lazy dog');
-- 返回:'lazi' <-> 'dog'
注意"lazy"一词变成了"lazi"。这是由于"词干化",它将单词简化为其根或基本形式。还要注意,phraseto_tsquery
函数会生成一个TSQUERY,强制执行字符串中的顺序关系(即在这个例子中,"lazy"必须出现在"dog"之前)。
将TSVECTOR和TSQUERY与ts_rank
函数结合使用,产生一个表示匹配词形频率的数值,值越高表示匹配越好:
vbnet
SELECT ts_rank(
to_tsvector('the quick brown fox jumps over the lazy dog'),
plainto_tsquery('what did the quick brown fox jump over?')
);
-- 返回:0.46362182
SELECT ts_rank(
to_tsvector('the quick brown fox jumps over the lazy dog'),
plainto_tsquery('i like coffee')
);
-- 返回:1e-20 (或0.00000000000000000001)
@@
匹配运算符也可以用于匹配向量和查询。它根据是否找到匹配项返回布尔值true或false:
vbnet
SELECT to_tsvector('the quick brown fox jumps over the lazy dog') @@
plainto_tsquery('what did the quick brown fox jump over?');
-- 返回:t
SELECT to_tsvector('the quick brown fox jumps over the lazy dog') @@
plainto_tsquery('i like coffee');
-- 返回:f
基于可用的数据类型和函数,我们现在可以创建一个支持全文搜索的表:
sql
CREATE TABLE article (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title STRING NOT NULL,
title_vec TSVECTOR NOT NULL AS (to_tsvector('english', title)) STORED,
INVERTED INDEX (title_vec)
);
sql
INSERT INTO article (title) VALUES
('The rise and fall of North Korea - the sleeping giant of women''s football'),
('Northern lights illuminate skies as far south as Cornwall'),
('The ''superfood'' taking over fields in northern India');
该表包含一个"title"列和其TSVECTOR等效列,用于存储标题的词形:
css
SELECT title_vec FROM article;
bash
/* 返回:
'cornwal':9 'far':6 'illumin':3 'light':2 'northern':1 'sky':4 'south':7
'fall':4 'footbal':14 'giant':10 'korea':7 'north':6 'rise':2 'sleep':9 ...
'field':5 'india':8 'northern':7 'superfood':2 'take':3
*/
现在我们可以使用ts_rank
和@@
运算符组合来查询该表:
vbnet
SELECT title
FROM article, to_tsquery('northern') AS query
WHERE query @@ title_vec;
/* 返回:
Northern lights illuminate skies as far south as Cornwall
The 'superfood' taking over fields in northern India
*/
空间索引
空间索引是一种特殊类型的倒排索引,支持对GEOMETRY和GEOGRAPHY二维空间数据类型的操作。空间索引是一个复杂的主题,我们在这里仅介绍一些关键概念。有关更多详细信息,请参考CockroachDB文档集。
要创建空间索引,我们可以添加USING GIST(geom)
子句:
scss
CREATE INDEX geom_idx_1 ON some_spatial_table USING GIST(geom);
我们还可以使用各种空间索引调优参数进一步微调索引:
ini
CREATE INDEX geom_idx_1 ON geo_table1 USING GIST(geom)
WITH (s2_level_mod = 3);
CREATE INDEX geom_idx_2 ON geo_table2 USING GIST(geom)
WITH (geometry_min_x = 0, s2_max_level = 15);
CREATE INDEX geom_idx_3 ON geo_table3 USING GIST(geom)
WITH (s2_max_level = 10);
CREATE INDEX geom_idx_4 ON geo_table4 USING GIST(geom)
WITH (geometry_min_x = 0, s2_max_level = 15);
我们不推荐更改这些默认调优参数;默认值通常会提供最佳性能。
哈希分片索引
在本章早些时候,我们展示了在分布式数据库中,单调递增的主键可能会导致分布式数据库中的"热点"问题。我们建议使用哈希分片索引来避免这种单调递增主键的问题。
这些热点不仅仅出现在主键上。任何单调递增的索引列可能会导致所有新值集中在一个范围内,从而产生可扩展性和吞吐量问题。
如果你有索引列,其值持续递增(时间戳就是一个很好的例子),并且希望避免插入热点,那么你应该考虑对该索引进行哈希分片。其语法与我们在"哈希分片主键"一节中展示的主键示例相同。例如,要在modifieddate
列上创建一个哈希分片索引,我们可以执行以下操作:
ini
SET experimental_enable_hash_sharded_indexes=on;
CREATE INDEX orderdetails_hash_ix
ON orderdetails(modifieddate)
USING HASH WITH BUCKET_COUNT = 6;
请注意,虽然CockroachDB可能不会对哈希分片索引进行排序优化,但它仍然可能为"前10名"类型的查询提供足够好的性能。我们可以强制CockroachDB使用哈希分片索引来执行ORDER BY
,通过索引提示来实现(更多内容将在第8章中讲解):
sql
SELECT *
FROM orderdetails@orderdetails_hash_ix
ORDER BY modifieddate LIMIT 10;
CockroachDB将从每个"桶"中检索前10条记录,并在网关节点合并结果。这个结果可能仍然比全表扫描要好得多。
衡量索引效果
创建了索引之后,我们希望确保它被用于优化我们的查询,并准确发现我们所获得的效益。我们可以使用EXPLAIN
和EXPLAIN ANALYZE
命令来实现这一点。
EXPLAIN
揭示了CockroachDB优化器执行SQL语句的"计划"。我们将在第8章详细介绍EXPLAIN
,但现在我们先快速了解一下如何使用EXPLAIN
来揭示查询的性能特征。
EXPLAIN
展示了优化器为解决查询所制定的计划。例如,如果我们在people
表上创建了索引,并想查看查询是否会使用它,我们可以执行以下命令:
sql
EXPLAIN
SELECT phonenumber
FROM people
WHERE lastname = 'Smith'
AND firstname = 'Samantha'
AND state = 'California';
info
-----------------------------------------------------------------------------
distribution: local
vectorized: true
• filter
│ estimated row count: 63
│ filter: state = 'California'
│
└── • index join
│ estimated row count: 5
│ table: people@primary
│
└── • scan
estimated row count: 5 (0.02% of the table;)
table: people@people_lastfirst_ix
spans: [/'Smith'/'Samantha'---/'Smith'/'Samantha']
我们可以看到,people_lastfirst_ix
将被用来解决查询。
然而,在某些情况下,我们仍然无法确定索引是否提高了执行时间。如果我们使用EXPLAIN ANALYZE
,那么CockroachDB将执行该操作,并报告发生的I/O操作和其他操作的数量:
sql
EXPLAIN analyze
SELECT phonenumber
FROM people
WHERE lastname='Smith'
AND firstname='Samantha'
AND state='California';
info
----------------------------------------------------------------------------
planning time: 2ms
execution time: 4ms
distribution: local
vectorized: true
rows read from KV: 6 (598 B)
cumulative time spent in KV: 3ms
maximum memory usage: 30 KiB
network usage: 0 B (0 messages)
• filter
│ cluster nodes: n1
│ actual row count: 1
│ estimated row count: 63
│ filter: state = 'California'
│
└── • index join
│ cluster nodes: n1
│ actual row count: 3
│ KV rows read: 3
│ KV bytes read: 430 B
│ estimated row count: 5
│ table: people@primary
│
└── • scan
cluster nodes: n1
actual row count: 3
KV rows read: 3
KV bytes read: 168 B
estimated row count: 5 (0.02% of the table)
table: people@people_lastfirst_ix
spans: [/'Smith'/'Samantha'---/'Smith'/'Samantha']
EXPLAIN
还有一些额外的高级功能,我们将在第8章中学习。然而,你可以看到,使用EXPLAIN
来确定索引的使用情况和效果是非常简单的。
总结
在本章中,我们探讨了CockroachDB数据库架构设计的原则。一个健全的数据模型是一个高效且可维护的CockroachDB数据库的基础。
数据库建模通常分为两个阶段:逻辑建模和物理建模。逻辑建模的目的是确定应用功能所需的数据。在物理建模中,我们的目标是构建一个既能满足功能需求,又能满足性能和可用性要求的数据模型。物理模型几乎永远不应是逻辑模型的直接副本。
与传统的单体数据库相比,分布式SQL数据库如CockroachDB的数据库设计面临一些独特的挑战。特别是,主键应该构建为使得新行能够均匀地分布在集群中的各个节点上。UUID数据类型可以实现这一点,但如果需要递增主键,则建议使用哈希分片主键索引。
我们还探讨了CockroachDB数据库设计中的索引选择。我们的目标是创建尽可能少的复合索引,以支持常见的过滤条件。我们可能还需要创建一些索引来支持排序操作。
现在我们已经学会了如何创建数据模型,我们可以开始编写应用程序代码。在下一章中,我们将看到如何在应用程序开发框架中使用CockroachDB SQL。