外键是关系型数据库中非常便利的一种功能,它通过一个或多个列为两张表建立连接,从而允许跨表交叉引用相关数据。外键通过约束来保持数据的一致性,通过级联来同步数据在多表间的更新和删除。在关系数据库系统中,大多数表都遵循外键的概念。因此使用外键可以在一定程度上减轻业务代码中对数据一致性判断的工作量。 虽然外键的功能很便利,但有很多文章探讨过是否应该在MySQL等数据库中使用外键,因为外键在保证数据的一致性和引用合法性的同时,也增加了数据库需要承担的额外计算的开销。此外,在分布式场景中,没有办法在分区表上创建外键,也额外增加了业务从单机向分布式演进时的工作量。 PolarDB-X 作为分布式数据库,提供了外键这一功能,让你可以在分布式数据库中,通过外键对跨(库)表的数据建立连接,实现等同于单机数据库外键的数据一致性保证。同时,由于在分区表上检查和维护外键约束的实现比单机数据库更为复杂,不合理的外键使用可能会导致较大的性能开销,导致系统吞吐显著下降。 因此,外键功能会作为一项长期的实验性功能,建议你在对数据进行充分验证后谨慎使用。
如果你对外键的语法已经充分了解,那么可以选择略过这一节,或者可以简略看看,可能会有所收获。
创建外键的语法涉及到几类数据:
其中必须要指定的是外键引用的表和列,引用的表也被称为父表,而外键所在的表被称为子表。列表示这两张表中发生引用关系的数据所在的列。通常父表中被引用的列是它的主键,但也可以在建立索引的任意列上创建外键。
-- 创建外键
[CONSTRAINT [symbol]] FOREIGN KEY
[index_name] (col_name, ...)
REFERENCES tbl_name (col_name,...)
[ON DELETE reference_option]
[ON UPDATE reference_option]
reference_option:
RESTRICT | CASCADE | SET NULL | NO ACTION | SET DEFAULT
-- 删除外键
ALTER TABLE tbl_name DROP FOREIGN KEY CONSTRAINT_symbol;
当我们使用默认的外键类型 RESTRICT
时,在插入、更新或删除时会检查数据的一致性:
例如,数据库中包含 student(id, name, content) 和 class(id, student_name) 两张表,并且 student.name 和 class.student_name 之间建立了外键,在执行如下所示的操作时都会触发数据库对外键的检查:
MySQL中的外键约束支持三种匹配语法,分为 MATCH SIMPLE | MATCH FULL | MATCH PARTIAL
,其中默认的匹配方式是 MATCH SIMPLE
,MATCH PARTIAL
尚未实现。PolarDB-X支持默认的 MATCH SIMPLE
匹配方式,并暂时不支持修改匹配方式。 那么 MATCH SIMPLE 和 MATCH FULL 这两种匹配方式的区别是什么呢,区别是在对于 null 值一致性的处理。具体例子如下:
-- 插入一行数据 (1,1)
CREATE TABLE foo ( a int, b int,
PRIMARY KEY (a,b)
);
INSERT INTO foo (a,b) VALUES (1,1);
-- 创建两个不同匹配方式的外键
CREATE TABLE bar_simple ( a int, b int,
FOREIGN KEY (a,b) REFERENCES foo (a,b) ON MATCH SIMPLE
);
CREATE TABLE bar_full ( a int, b int,
FOREIGN KEY (a,b) REFERENCES foo (a,b) ON MATCH FULL
);
-- 符合约束
INSERT INTO bar_simple (a,b) VALUES (1,1);
INSERT INTO bar_full (a,b) VALUES (1,1);
-- 符合约束
INSERT INTO bar_simple (a,b) VALUES (1,NULL);
-- 不符合约束
INSERT INTO bar_full (a,b) VALUES (1,NULL);
-- 特殊case,符合约束
INSERT INTO bar_simple (a,b) VALUES (42,NULL);
级联操作的目的也是保持数据的一致性,以 CASCADE
为例:
例如,数据库中包含 student(id, name, content) 和 class(id, student_name) 两张表,并且 student.name 和 class.student_name 之间建立了外键,在执行如下所示的操作时会进行级联操作:
级联一种有五种引用模式,具体的参数和用法如下表所示:
参数 | 用法 |
ON DELETE NO ACTION / ON UPDATE NO ACTION | 默认参数; 在更新或删除父表字段时,如果字段有外键引用,则会语句会在执行更新或删除字段时失败。 |
ON DELETE RESTRICT / ON UPDATE RESTRICT | ON DELETE NO ACTION 和 ON UPDATE NO ACTION 的别名 |
ON DELETE CASCADE / ON UPDATE CASCADE | 在更新或删除父表字段时,如果字段有外键引用,则会进行级联更新或删除,即引用该列的所有行将被更新或删除。 |
ON DELETE SET NULL / ON UPDATE SET NULL | 在更新或删除父表字段时,如果字段有外键引用,则会被置为NULL,如果该列是NOT NULL,则会更新失败。 |
ON DELETE SET DEFAULT / ON UPDATE SET DEFAULT | 暂不支持 |
在某些场景下,你可以选择通过foreign_key_checks 这个参数暂时关闭外键功能,执行一些违反外键约束的操作后,再重新打开,比如先建立子表再建立父表、插入不符合外键约束条件的数据等。
在分布式数据库中,外键除了基础的约束和级联功能,还会涉及到一些分布式系统中才会涉及到的特殊场景。下面几个例子会说明在哪些场景下分布式数据库中外键的行为会与单机明显不同。
PolarDB-X分为计算层和存储层。非下推指外键的操作在计算层来处理;下推是指将外键的操作推送到存储层来处理,从而达到提前过滤数据、减少网络传输、并行计算等目的。
问题:PolarDB-X中,表的类型与分区方式是可以进行变化的,那么当表类型在分区表、单表、广播表之间变化时,外键的下推行为也可能同时受到影响。比如A,B表之前均为同一个分片上的单表,之间建立了外键F,是物理外键。当A表的表类型变更为分区表后,外键F必须变更为逻辑外键才能在分布式数据库中实现其功能。 方案:表类型的变更我们称为Repartition,那么需要在 Repartition 这个任务的流程中增加删除和添加外键的子任务。在Repartition中,首先在原表被删除变动之前(因为不允许删除带有外键的父表)加入删除所有关联外键的子任务,并更新所有子表状态,此外,还需要清理物理表中跟随外键建立的相关索引;然后在新表建好后加入创建所有关联外键的子任务,这样新建的外键就会按新的父子表分区状态来更新好下推或非下推行为。
问题:当根据业务需求对库表进行扩缩容、迁移、或进行物理/逻辑备份时,由于表的迁移是无序的,对于下推的物理外键,可能会形成先迁移子表,后迁移父表,或先迁移子表数据后迁移父表数据的情况,从而不满足外键约束,导致数据迁移失败。 方案:
对于这个方案,还需要考虑的一个问题是 foreign_key_checks 开关的影响:
问题:当外键涉及级联操作时,通常的想法是先去子表中查询是否存在需要级联的数据,如果存在,则构造相应的执行计划。由于需要先查询数据,再根据查询的数据决定是否进行外键的约束或级联,那么整个流程就会实现在执行器阶段,并通过查询出的数据构造物理计划,但在分布式数据库中会面临很多难以解决的问题,如:
所以我们采用的方法是复用优化器的能力。
方案:在优化器中,当某个表中包含外键并且是级联操作时,会根据外键相关的信息,递归地生成所有子表中所有级联操作的AST,并交给优化器生成逻辑执行计划。这些逻辑执行计划我们称之为外键的子计划,它们存储在原有的执行计划中,并通过 <库,表,外键索引> 唯一标识。当进入执行器阶段后,再构造 Select 物理计划并推到子表执行,如果子表中存在数据,则取出相应的外键子计划,和 Select 出的数据结合后执行。 需要注意的是,当某张表发生变化时,如被删除或修改,需要向上递归其所有的父表、父表的父表等,并更新它们存储在plan cache中的外键子计划。
外键的实现可以分为 DDL 和 DML 两大部分,DDL 中关注的是外键的创建、修改、删除以及各种限制条件,DML关注的外键的约束和级联。
命名
[CONSTRAINT [symbol]] FOREIGN KEY
[index_name] (col_name, ...)
REFERENCES tbl_name (col_name,...)
[ON DELETE reference_option]
[ON UPDATE reference_option]
PolarDB-X 中外键的命名遵循以下规则与Mysql保持一致:
限制
创建外键时需要满足以下条件:
元信息
外键的系统表有 foreign_key 和 foreign_key_cols。foreign_key 负责记录外键表、行为相关信息,foreign_key_cols 负责记录列相关信息。 其中 foreign_key 中的 PUSH_DOWN 表示外键是否下推,允许物理外键与逻辑外键同时存在,逻辑外键优先级更高:
create table if not exists `foreign_key` (
`ID` bigint unsigned not null auto_increment,
`SCHEMA_NAME` varchar(64) NOT NULL DEFAULT '',
`TABLE_NAME` varchar(64) NOT NULL DEFAULT '',
`INDEX_NAME` varchar(64) NOT NULL DEFAULT '',
`CONSTRAINT_NAME` varchar(64) NOT NULL DEFAULT '',
`REF_SCHEMA_NAME` varchar(64) NOT NULL DEFAULT '',
`REF_TABLE_NAME` varchar(64) NOT NULL DEFAULT '',
`REF_INDEX_NAME` varchar(64) NOT NULL DEFAULT '',
`N_COLS` int(11) unsigned NOT NULL DEFAULT '0',
`UPDATE_RULE` varchar(64) NOT NULL DEFAULT '',
`DELETE_RULE` varchar(64) NOT NULL DEFAULT '',
`PUSH_DOWN` int(11) unsigned NOT NULL DEFAULT '2',
primary key (`ID`),
unique key (`SCHEMA_NAME`, `TABLE_NAME`, `INDEX_NAME`)
) charset=utf8
create table if not exists `foreign_key_cols` (
`ID` bigint unsigned not null auto_increment,
`SCHEMA_NAME` varchar(64) NOT NULL DEFAULT '',
`TABLE_NAME` varchar(64) NOT NULL DEFAULT '',
`INDEX_NAME` varchar(64) NOT NULL DEFAULT '',
`FOR_COL_NAME`varchar(64) NOT NULL DEFAULT '',
`REF_COL_NAME` varchar(64) NOT NULL DEFAULT '',
`POS` int(11) unsigned NOT NULL DEFAULT '0',
primary key (`ID`),
key (`SCHEMA_NAME`, `TABLE_NAME`, `INDEX_NAME`)
) charset=utf8
对应到内存中外键的数据结构:
public class ForeignKeyData {
public String schema; // schema
public String tableName; // table name
public String constraint; // CONSTRAINT identifier
public String indexName; // FOREIGN KEY identifier
public List<String> columns; // child table columns
public String refSchema; // parent table schema
public String refTableName; // parent table name
public List<String> refColumns; // parent table columns
public ReferenceOptionType onDelete; // delete options
public ReferenceOptionType onUpdate; // update options
public Long pushDown = 2L; // pushdown
}
你可以使用 SHOW FULL CREATE TABLE 语句查看外键的定义和下推行为:
mysql> show full create table t2 \G
*************************** 1. row ***************************
Table: t2
Create Table: CREATE PARTITION TABLE `t2` (
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
`_drds_implicit_id_` bigint(20) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`_drds_implicit_id_`),
CONSTRAINT `t2_ibfk_1` FOREIGN KEY (`a`) REFERENCES `t1` (`a`) ON DELETE RESTRICT ON UPDATE RESTRICT /* TYPE LOGICAL */
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4
PARTITION BY KEY(`_drds_implicit_id_`)
PARTITIONS 3
/* tablegroup = `tg1121` */
也可以从以下系统表中获取外键有关信息:
事务
级联更新或删除时默认开启事务,如果发生违反约束,所有的级联操作都会被事务自动回滚。 在已经开启了事务的情况下,如果开启了 auto_savepoint, 只会回滚当前语句(包含后续的级联);如果没有开启,则报错 ERR_TRANS_CONTINUE_AFTER_WRITE_FAIL,需要回滚整个事务。
约束
与约束有关的 DML 分为 Insert,Insert Ignore,Upsert,Replace 几类:
算法
以 Insert 为例,流程图如下:
级联
情况分析
下面列举几个级联的情况,以 ON DELETE CASCADE 为例,便于理解后续对级联的设计。
CASCADE 与 RESTRICT
当 CASCADE 与 RESTRICT 同时存在于引用关系中时,会造成删除失败。 由于级联,删除表a的数据后应删除表b中的数据,但由于更深层的级联中存在RESTRICT,所以失败报错,因此在执行级联时需要有回滚的能力。
CREATE TABLE a (
id INT PRIMARY KEY
);
INSERT INTO a VALUES (1);
CREATE TABLE b (
id INT PRIMARY KEY,
a_id INT,
FOREIGN KEY fk(`a_id`) REFERENCES a(`id`) ON DELETE CASCADE
);
INSERT INTO b VALUES (1,1);
CREATE TABLE c (
b_id INT,
FOREIGN KEY fk(`b_id`) REFERENCES b(`id`) ON DELETE RESTRICT
);
INSERT INTO c VALUES (1);
DELETE FROM a WHERE id = 1;
ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`test`.`c`, CONSTRAINT `c_ibfk_1` FOREIGN KEY (`b_id`) REFERENCES `b` (`id`) ON DELETE RESTRICT)
自循环引用
表中的一列作为外键引用另一列,当删除表中任意一行时,将会删除表中的所有数据。 在这种自引用场景下,我们没有办法事先判断会进行多少次级联,只有当获取需要删除行中所对应的被引用列的数据后,才能根据数据时候在外键列中存在,来判断是否进行下次级联操作。 需要限制级联次数(Mysql中为15次),PolarDB-X 也限制为15次,超出报错。
CREATE TABLE a (
id INT PRIMARY KEY,
other_id INT,
FOREIGN KEY fk(`other_id`) REFERENCES a (`id`) ON DELETE CASCADE
);
INSERT INTO a VALUES (1, NULL), (2, 1), (3, 2), (4, 3);
UPDATE a SET other_id = 4 WHERE id = 1;
SELECT * FROM a;
+----+----------+
| id | other_id |
+----+----------+
| 1 | 4 |
| 2 | 1 |
| 3 | 2 |
| 4 | 3 |
+----+----------+
(4 rows)
DELETE FROM a WHERE id = 1;
SELECT * FROM a;
+----+----------+
| id | other_id |
+----+----------+
+----+----------+
(0 rows)
#进行了三次级联删除
多重级联
在多重级联中,表与表之间会存在复杂的引用关系,并且这些引用关系可能会导致竞争 例如在下面这个引用关系中,b和c引用自a,d引用自c,e引用自b和d,那在进行级联时,b和d会形成竞争关系。
a
/ \
b c
| |
| d
\ /
e
CREATE TABLE race_a (
id STRING PRIMARY KEY
);
CREATE TABLE race_b (
id STRING PRIMARY KEY,
a_id STRING,
FOREIGN KEY fk(`a_id`) REFERENCES race_a(`id`) ON DELETE CASCADE
);
CREATE TABLE race_c (
id STRING PRIMARY KEY,
a_id STRING,
FOREIGN KEY fk(`a_id`) REFERENCES race_a(`id`) ON DELETE CASCADE
);
CREATE TABLE race_d (
id STRING PRIMARY KEY,
c_id STRING,
FOREIGN KEY fk(`c_id`) REFERENCES race_c(`id`) ON DELETE CASCADE
);
CREATE TABLE race_e (
id STRING PRIMARY KEY,
b_id STRING,
d_id STRING,
FOREIGN KEY fk(`b_id`) REFERENCES race_b(`id`) ON DELETE CASCADE,
FOREIGN KEY fk(`d_id`) REFERENCES race_d(`id`) ON DELETE CASCADE
);
INSERT INTO race_a (id) VALUES ('a1');
INSERT INTO race_b (id, a_id) VALUES ('b1', 'a1');
INSERT INTO race_c (id, a_id) VALUES ('c1', 'a1');
INSERT INTO race_d (id, c_id) VALUES ('d1', 'c1');
INSERT INTO race_e (id, b_id, d_id) VALUES ('e1', 'b1', 'd1');
SELECT * FROM race_a;
+----+
| id |
+----+
| a1 |
+----+
(1 row)
SELECT * FROM race_b;
+----+------+
| id | a_id |
+----+------+
| b1 | a1 |
+----+------+
(1 row)
SELECT * FROM race_c;
+----+------+
| id | a_id |
+----+------+
| c1 | a1 |
+----+------+
(1 row)
SELECT * FROM race_d;
+----+------+
| id | c_id |
+----+------+
| d1 | c1 |
+----+------+
(1 row)
SELECT * FROM race_e;
+----+------+------+
| id | b_id | d_id |
+----+------+------+
| e1 | b1 | d1 |
+----+------+------+
(1 row)
DELETE FROM race_a WHERE id = 'a1';
SELECT * FROM race_a;
+----+
| id |
+----+
+----+
(0 rows)
SELECT * FROM race_e;
+----+------+------+
| id | b_id | d_id |
+----+------+------+
+----+------+------+
(0 rows)
设计思路
在级联中,表与表的关系可以抽象为一张有向图,其中表是图中的节点,而外键则是图中的边。确定在级联过程中哪些表和其中的数据会受到影响,所需要实现的就是一个图遍历算法。
单步描述
在级联的遍历过程中,由于级联涉及到的情况众多,每一步都需要根据其特定情况进行操作,所以需要对单步中遇到的情况做一个说明:
下面以一个一个映射关系为例进行具体介绍:
'NA 'DC 'DS 'UC 'US
\ \ | / /
\ \ | / /
\ \ | / /
\\|//
X
//|\\
/ / | \ \
/ / | \ \
/ / | \ \
NA DC DS UC US
上图中每个元素都代表一张表,下面的表引用自上面的表,并且表的名字代表了其与表X
的映射关系,比如X
引用自表'NA
,并且引用关系是NO ACTION
或 RESTRICT
,表DC
引用自表X
,并且引用关系是DELETE CASCADE
。 下面为元素的具体含义:
NA, 'NA
: NO ACTION
或 RESTRICT
DC, 'DC
: DELETE CASCADE
DS, 'DS
: DELETE SET NULL
或 DELETE SET DEFAULT
UC, 'UC
: UPDATE CASCADE
US, 'US
: UPDATE SET NULL
或 UPDATE SET DEFAULT
接下来为 INSERT, DELETE, UPDATE 具体分析:
INSERT
INSERT 时需要向前查找。 向表X中插入一行时,必须获取它引用的所有表。所以需要获取表'NA
, 'DC
, 'DS
,'UC
和'US
中的数据,并且不需要继续向前查找,因为 INSERT 不会产生级联。 对于那些引用表X
的表,INSERT与它们无关,不需要获取它们的相关数据。
DELETE
DELETE 时需要向后查找。 删除表X
中的一行时,不需要获取它引用的表。 对于引用表X
的表NA
, DC
, DS
,UC
和US
,需要进行以下操作:
NA
:获取数据,无其他操作。DC
:获取数据,继续进行 DELETE 操作。DS
:获取数据,继续进行 UPDATE 操作。UC
:获取数据,无其他操作。US
:获取数据,无其他操作。UPDATE
更新表X
的一行时,由于 UPDATE 是 DELETE + INSERT,所以与 INSERT 类似,也需要表X
引用的所有表'NA
, 'DC
, 'DS
,'UC
和'US
中的数据,并且不需要继续向前查找。 对于引用表X
的表NA
, DC
, DS
,UC
和US
,需要进行以下操作:
NA
:获取数据,无其他操作。DC
:获取数据,无其他操作。DS
:获取数据,无其他操作。UC
:获取数据,继续进行 UPDATE 操作。US
:获取数据,继续进行 UPDATE 操作。算法
正如遍历一张图一样,只要我们构建出图中顶点间的拓扑关系,就可以将级联关系遍历完成。
算法可以通过递归或队列迭代实现,过程入下:
Delete 级联的流程图:
Update 级联的流程图:
在本文中,我们介绍了 PolarDB-X 中外键的设计和实现,首先介绍了外键的具体功能,然后列举了在分布式数据库中外键需要额外考虑的一些场景,最后通过 DDL 和 DML 两方面介绍了 PolarDB-X 中外键的具体实现。外键作为一项功能较多、细节丰富的特性,本文仍有很许多尚未讨论的问题,如果你有任何问题或建议,欢迎与我们讨论。
作者:琦华
点击立即免费试用云产品 开启云上实践之旅!
本文为阿里云原创内容,未经允许不得转载。
|