第05期:外键到底能不能用?

这篇主要说明表属性 - 外键。

外键的设计初衷是为了在数据库端保证对逻辑上相关联的表数据在操作上的一致性与完整性。

外键在大部分企业写的开发规范里会**直接规避掉!**外键有优缺点,也并不是说每种场景都不适用,完全没有必要一刀切。外键到底能不能用?下面会针对不同的场景来告诉你答案。

一、外键的优缺点

优点:

  • 精简关联数据,减少数据冗余

    避免后期对大量冗余处理的额外运维操作。

  • 降低应用代码复杂性,减少了额外的异常处理

    相关数据管理全由数据库端处理。

  • 增加文档的可读性

    特别是在表设计开始,绘制 ER 图的时候,逻辑简单明了,可读性非常强。

缺点:

  • 性能压力

    外键一般会存在级联功能,级联更新,级联删除等等。在海量数据场景,造成很大的性能压力。比如插入一条新记录,如果插入记录的表有 10 个外键,那势必要对关联的 10 张表逐一检查插入的记录是否合理,延误了正常插入的记录时间。并且父表的更新会连带子表加上相关的锁。

  • 其他功能的灵活性不佳

    比如,表结构的更新等。

二、外键的使用

外键参照动作列表:

  • CASCADE:级联,子表跟随父表更新外键值

  • SET NULL:子表更随主表更新外键值为 NULL

  • RESTRICT/ NO ACTION:默认,限制父表改动外键值

  • SET DEFAULT:目前产生的效果和 RESTRICT 相同。

那先来简单看看 MySQL 里外键的用法。MySQL 外键仅有 InnoDB 和 NDB 两种引擎支持,这里只关注 InnoDB。

本次示例 MySQL 的版本为最新版 8.0.19

示例

下面 f1 是父表,f2、f3、f6 分别代表不同类型的外键表,也就是子表。

-- 引用基础表,也就是父表
mysql-(ytt_fk/3305)->create table f1(id int primary key, 
	r1 int, r2 int, r3 int,key idx_r1(r1),key idx_u1 (r2,r3));
Query OK, 0 rows affected (0.02 sec)

--   随着参照表级联更新外键表,也就是父表更新的话,会级联更新子表的外键
mysql-(ytt_fk/3305)->create table f2(id int primary key, 
	f1_r1 int, mark int, constraint f1_fk_r1 foreign key (f1_r1) references f1(r1) on update cascade);
Query OK, 0 rows affected (0.02 sec)


--  随着参照表更新外键值为 NULL,也就是父表更新的话,会级联更新子表的外键为 NULL
mysql-(ytt_fk/3305)->create table f3 (id int primary key, 
	f1_id int, foreign key (f1_id) references f1(id) on update set null);
Query OK, 0 rows affected (0.02 sec)

--  多个键值外键。子表的可以引用父表非主键的其他键
mysql-(ytt_fk/3305)->create table f6 ( id int auto_increment primary key, 
	f1_r2 int, f1_r3 int, foreign key (f1_r2,f1_r3) references f1(r2,r3));
Query OK, 0 rows affected (0.02 sec)
场景一:强烈要求数据一致性,程序弱化,数据库端强化,表结构改动小,并发不高的场景。

用一条记录验证表 f2 和 f6。从功能性角度来看,外键的优势很明显,在数据库端完全满足了数据完整性校验。

mysql-(ytt_fk/3305)->insert into f1 values (1,10,100,1000);
Query OK, 1 row affected (0.00 sec)

mysql-(ytt_fk/3305)->insert into f2 values (1,1);
Query OK, 1 row affected (0.01 sec)

mysql-(ytt_fk/3305)->insert into f6 values (1,100,1000);
Query OK, 1 row affected (0.00 sec)

-- 更新引用表 f1
mysql-(ytt_fk/3305)->update f1 set id = 2 where id =1;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1  Changed: 1  Warnings: 0

-- f2 也成功级联更新
mysql-(ytt_fk/3305)->select * from f2;
+----+-------+
| id | f1_id |
+----+-------+
|  1 |     2 |
+----+-------+
1 row in set (0.00 sec)

-- 引用表 r2 字段不允许更新,因为表 f6 有针对字段 r2 的外键约束。
mysql-(ytt_fk/3305)->update f1 set r2 = 11 ;
ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`ytt_fk`.`f6`, CONSTRAINT `f6_ibfk_1` FOREIGN KEY (`f1_r2`, `f1_r3`) REFERENCES `f1` (`r2`, `r3`))
场景二:频繁的数据装载,但是也严格要求数据库端保证数据一致性。

这里只验证表 f6,同时克隆一张新表 f6_no_fk ,除了没有外键,表结构和 f6 一样。导入 400W 条样例数据。

-- 导入 f6,有外键,时间 32 秒多。
mysql-(ytt_fk/3305)->load data infile '/var/lib/mysql-files/f1_sub.dat' into table f6;
Query OK, 4000000 rows affected (32.57 sec)
Records: 4000000  Deleted: 0  Skipped: 0  Warnings: 0

-- 导入 f6_no_fk,没有外键,时间 25 秒多。
mysql-(ytt_fk/3305)->load data infile '/var/lib/mysql-files/f1_sub.dat' into table f6_no_fk;
Query OK, 4000000 rows affected (25.95 sec)
Records: 4000000  Deleted: 0  Skipped: 0  Warnings: 0

从上面看到,单独的测试导入 400W 条记录,带有外键的表比非外键的表时间上没有优势。那针对上面的场景优化下,关闭外键检查参数,导入完成后,再开启。

mysql-(ytt_fk/3305)->truncate f6;
Query OK, 0 rows affected (0.04 sec)

-- 关闭外键检查。
mysql-(ytt_fk/3305)->set foreign_key_checks=0;
Query OK, 0 rows affected (0.00 sec)

-- 重新导入,时间28秒多。
mysql-(ytt_fk/3305)->load data infile '/var/lib/mysql-files/f1_sub.dat' into table f6;
Query OK, 4000000 rows affected (28.42 sec)
Records: 4000000  Deleted: 0  Skipped: 0  Warnings: 0

-- 开启外键检查。
mysql-(ytt_fk/3305)->set foreign_key_checks=1;
Query OK, 0 rows affected (0.00 sec)

从以上结果看出,关闭外键检查后,导入时间和没有外键的表 f6_no_fk 差不多。

场景三:并发少,事物块简单。

接下来再看下简单的事物块提交方式,我简单写了一个每 500 条记录提交一次的存储过程。

DELIMITER $$
CREATE DEFINER=`ytt`@`127.0.0.1` PROCEDURE `sp_generate_data`(IN `tb_name` VARCHAR(64), IN `f_number` INT)
begin
declare i int default 0;
set @@autocommit=0;
while i < f_number DO

  set @stmt = concat("insert into ",tb_name,"(f1_r2,f1_r3) values (ceil(rand()*10),ceil(rand()*10))");
  prepare s1 from @stmt;
  execute s1;
  set i = i + 1;
  if mod(i,500)=0 THEN
    commit;
  end if;
end while;
drop prepare s1;
commit;
set @@autocommit=1;
end$$
DELIMITER ;

接下来插入 100W 条记录,

-- 外键表写入总时间为 1 分 14 秒
mysql> call sp_generate_data('f6',1000000);
Query OK, 0 rows affected (1 min 14.14 sec)

-- 非外键表写入时间为 1 分 8 秒
mysql> call sp_generate_data('f6_no_fk',1000000);
Query OK, 0 rows affected (1 min 8.45 sec)

-- 关闭外键检查
mysql> set foreign_key_checks=0;
Query OK, 0 rows affected (0.00 sec)

-- 时间为 1 分 4 秒
mysql> call sp_generate_data('f6',1000000);
Query OK, 0 rows affected (1 min 4.28 sec)

mysql> set foreign_key_checks=1;
Query OK, 0 rows affected (0.00 sec)

从测试的结果来看,有外键和没有外键的检索时间在这样的场景下也相差无几。

场景四:主表的外键引用字段类型要扩充,原来的数据溢出,没法保存更大的值。

比如此时字段 r2 定义的数据类型不合适了,需要更改为大点的,比如以下,直接修改会报错,

mysql-(ytt_fk/3305)->alter table f1 change r2 r2 bigint;
ERROR 3780 (HY000): Referencing column 'f1_r2' and referenced column 'r2' in foreign key constraint 'f6_ibfk_1' are incompatible.

mysql-(ytt_fk/3305)->alter table f6 change f1_r2 f1_r2 bigint;
ERROR 3780 (HY000): Referencing column 'f1_r2' and referenced column 'r2' in foreign key constraint 'f6_ibfk_1' are incompatible.

那怎么改呢?需要先把外键删掉,修改完了类型,再加上约束。这种场景就不太适合用外键。

mysql-(ytt_fk/3305)->alter table f6 drop constraint f6_ibfk_1;
Query OK, 0 rows affected (0.00 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql-(ytt_fk/3305)->alter table f6 change f1_r2 f1_r2 bigint;
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql-(ytt_fk/3305)->alter table f1 change r2 r2 bigint;
Query OK, 100000 rows affected (0.73 sec)
Records: 100000  Duplicates: 0  Warnings: 0

mysql-(ytt_fk/3305)->alter table f6 add foreign key (f1_r2,f1_r3) references f1(r2,r3);
Query OK, 0 rows affected (0.03 sec)
Records: 0  Duplicates: 0  Warnings: 0
场景五:子表有触发器需求来更新必要的字段。

那关于这点就是,子表的触发器不会随着父表的更新级联应用,也就是此时触发器失效。举个例子,往 f2 上添加一个 before update 触发器。

-- 前置更新触发器
CREATE TRIGGER `tr_af_update` BEFORE UPDATE ON `f2`
 FOR EACH ROW set new.mark = new.f1_r1;

mysql-(ytt_fk/3305)->insert into f2 values (1,10,5);
Query OK, 1 row affected (0.00 sec)

mysql-(ytt_fk/3305)->select * from f2;
+----+-------+------+
| id | f1_r1 | mark |
+----+-------+------+
|  1 |    10 |    5 |
+----+-------+------+
1 row in set (0.00 sec)

-- 更新父表,

mysql-(ytt_fk/3305)->update f1 set r1 = 2 where r1 = 10;
Query OK, 5133 rows affected (0.15 sec)
Rows matched: 5133  Changed: 5133  Warnings: 0

-- 子表 f2对应的级联做了更改,但是触发器动作没执行。
mysql-(ytt_fk/3305)->select * from f2;
+----+-------+------+
| id | f1_r1 | mark |
+----+-------+------+
|  1 |     2 |    5 |
+----+-------+------+
1 row in set (0.00 sec)

-- 正常的操作应该这样
mysql-(ytt_fk/3305)->update f2 set id = 2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

-- mark字段对应的克隆成了f1_r1字段的值。
mysql-(ytt_fk/3305)->select * from f2;
+----+-------+------+
| id | f1_r1 | mark |
+----+-------+------+
|  2 |     2 |    2 |
+----+-------+------+
1 row in set (0.00 sec)
场景六:父表为分区表,有外键的需求。

那针对分区表,暂时不支持子表以分区表为父表的外键。

mysql-(ytt_fk/3305)->create table f1_partition like f1;
Query OK, 0 rows affected (0.02 sec)

mysql-(ytt_fk/3305)->alter table f1_partition  partition by key() partitions 4;
Query OK, 0 rows affected (0.10 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql-(ytt_fk/3305)->create table f7 ( id int primary key, 
	f1_partition_id int, foreign key (f1_partition_id) references f1_partition(id));
ERROR 1506 (HY000): Foreign keys are not yet supported in conjunction with partitioning
场景七:日常并发很高的场景,应该尽量减少相关事务锁的范围和量级。

那举个简单例子,看看有外键情况下,父表更新,子表级联加锁的情形。

-- SESSION 1
mysql-(ytt_fk/3305)->begin;
Query OK, 0 rows affected (0.00 sec)

mysql-(ytt_fk/3305)->update f1 set r2 = 101 where r2 = 100;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0


mysql-(ytt_fk/3305)->select sys.ps_thread_id(connection_id()) as cid;
+------+
| cid  |
+------+
|   47 |
+------+
1 row in set (0.00 sec)

总共有 11 个锁,也就简单的执行了下 Update,而且更新的只是一行。

-- SESSION 2 
mysql-((none)/3305)->select count(*) from performance_schema.data_locks where thread_id = 47;
+----------+
| count(*) |
+----------+
|       11 |
+----------+
1 row in set (0.00 sec)

查看锁的细化,父有 f1 有 5 个锁,子表 f6 有 6 个锁。

这都是 MySQL 为了保证数据一致性强制加的,这点在 TPS 要求比较高的场景肯定不合适

mysql-((none)/3305)->select object_name,lock_type,lock_mode,lock_status,lock_data from performance_schema.data_locks where thread_id = 47 order by object_name;
+-------------+-----------+---------------+-------------+------------------------+
| object_name | lock_type | lock_mode     | lock_status | lock_data              |
+-------------+-----------+---------------+-------------+------------------------+
| f1          | TABLE     | IX            | GRANTED     | NULL                   |
| f1          | RECORD    | X             | GRANTED     | supremum pseudo-record |
| f1          | RECORD    | X             | GRANTED     | 100, 100, 1            |
| f1          | RECORD    | X,REC_NOT_GAP | GRANTED     | 1                      |
| f1          | RECORD    | X,GAP         | GRANTED     | 101, 100, 1            |
| f6          | TABLE     | IS            | GRANTED     | NULL                   |
| f6          | RECORD    | S,REC_NOT_GAP | GRANTED     | 100, 100, 12           |
| f6          | TABLE     | IX            | GRANTED     | NULL                   |
| f6          | RECORD    | X,REC_NOT_GAP | GRANTED     | 12                     |
| f6          | RECORD    | X,REC_NOT_GAP | GRANTED     | 101, 100, 12           |
| f6          | RECORD    | S,GAP         | GRANTED     | 101, 100, 12           |
+-------------+-----------+---------------+-------------+------------------------+
11 rows in set (0.00 sec)

三、外键的限制:

  1. 仅有 InnoDB 和 NDB 引擎支持。
  2. 不支持虚拟列。
  3. 不支持临时表。
  4. 外键列以及引用列数据类型、字符集、校对规则都得一致。
  5. 外键列以及引用列都必须建立索引。
  6. 外键引用多个列的,列顺序必须一致。
  7. 大对象字段不能作为引用列。
  8. constraint 命名必须在单个 database 里唯一。
  9. 外键级联更新操作不会触发子表上的触发器。
  10. 不支持分区表。

总结

本文主要从几个例子来演示了外键是否应该使用以及在哪些场景下使用,让大家了解外键的详细需求。

从上面我描述的几个场景来说,场景 1,2,3 很适合用外键;场景 4,5,6,7 就不太适合用外键;可以把外键功能放在数据库之外实现。

根据如下表生成10条数据 CREATE TABLE `z_inventory` ( `inventory_id` bigint NOT NULL AUTO_INCREMENT COMMENT '库存ID(主)', `product_id` bigint NOT NULL COMMENT '商品ID(关联product表)', `warehouse_id` bigint NOT NULL COMMENT '仓库ID(关联仓库表)', `production_date` date NOT NULL COMMENT '生产日', `shelf_days` int NOT NULL COMMENT '保质天数', `expire_date` date GENERATED ALWAYS AS ((`production_date` + interval `shelf_days` day)) STORED COMMENT '过(生产日 + 保质天数)', `stock_quantity` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '库存数量', `locked_quantity` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '锁定数量', `available_quantity` decimal(12,2) GENERATED ALWAYS AS ((`stock_quantity` - `locked_quantity`)) VIRTUAL COMMENT '可用数量(计算字段)', `unit_cost` decimal(12,2) NOT NULL COMMENT '批次成本单价', `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-正常,1-已过,2-已出库', `created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`inventory_id`), KEY `idx_product_warehouse` (`product_id`,`warehouse_id`), KEY `idx_expire_status` (`expire_date`,`status`), KEY `idx_production_date` (`production_date`), KEY `idx_available_quantity` (`available_quantity`) ) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='库存表';
03-28
<think>嗯,用户让我根据提供的z_inventory表结构生成10条数据。首先,我需要仔细看一下这个表的结构,确保生成的数据符合各个字段的要求。首先,主是inventory_id,而且是自增的,所以插入数据的时候不需要指定这个字段,数据库会自动处理。接下来是product_id和warehouse_id,这两个都是,所以需要确保它们的值是存在的,但因为是示例数据,我可以假设一些合理的id,比如product_id从1001到1010,warehouse_id可能用1到3这样的数值。 生产日production_date是一个日类型的字段,需要生成合理的日,比如过去的一年内的日。保质天数shelf_days应该是一个正整数,比如30到365天之间。过expire_date是生成字段,根据生产日加上保质天数,所以不需要手动填充,但需要确保生成的production_date和shelf_days组合后,expire_date是正确的。 stock_quantity是库存数量,decimal类型,两位小数,所以可以是比如100.00到500.00之间的数值。locked_quantity是锁定数量,同样两位小数,可能比stock_quantity小或者等于。available_quantity是计算字段,自动生成,所以不需要手动输入。unit_cost是单位成本,比如10.50到50.00这样的数值。status字段有0、1、2三个状态,需要根据过和当前日来判断,但如果是示例数据,可能需要随机分配或者根据expire_date是否早于当前日来设置。不过,可能用户只需要静态数据,所以可以随机选0或1,2可能表示已出库,但需要考虑stock_quantity是否为0。 created_time和updated_time都是自动生成的,所以插入数据的时候可以忽略,但需要确保生产日不晚于创建时间。不过,用户可能需要显式设置created_time,但根据表结构,默认是CURRENT_TIMESTAMP,所以插入时可以不指定。 现在,开始生成具体的数据。比如第一条数据,product_id是1001,warehouse_id是1,生产日是2023-03-15,保质180天,过自动计算为2023-09-11。库存数量是200.00,锁定50.00,可用数量自动计算为150.00。单位成本25.50,状态0。其他字段由数据库处理。 需要注意每个字段的数据类型和约束,比如production_date不能是未来的日,除非有特殊情况。status的状态值是否正确,比如已过的status应该是1,这时候expire_date应该早于当前日。比如假设当前日是2023-12-01,那么生产日加上保质天数是否已经过。比如第三条数据,生产日是2022-06-01,保质180天,过是2022-11-28,这时候在2023年已经过,所以status应该是1。 另,已出库的状态2可能需要stock_quantity为0,或者有其他逻辑,但示例数据可能不需要严格符合,只是示例。比如最后一条数据status是2,可能stock_quantity是0.00,但用户可能允许其他情况。 生成数据时还要注意inventory_id从12开始,因为表结构里AUTO_INCREMENT=12,所以第一条数据的id应该是12,后续递增到21。product_id和warehouse_id的组合是否需要唯一?可能不需要,因为同一个商品可能在不同仓库有多个库存。 需要检查生成的expire_date是否正确,比如生产日加上shelf_days的天数是否正确。例如,生产日2023-01-10,保质90天,过应该是2023-04-10。可以用日计算工具验证。 还有unit_cost是否合理,不能为负数,库存数量和锁定数量也不能为负数。locked_quantity必须小于等于stock_quantity,否则可用数量会是负数,但数据库可能允许,不过业务上不合理,所以需要确保locked_quantity不超过stock_quantity。 总结步骤: 1. 确定各字段的取值范围和约束。 2. 生成product_id和warehouse_id的组合。 3. 生成合理的production_date和shelf_days,并计算对应的expire_date。 4. 设置stock_quantity和locked_quantity,确保locked_quantity ≤ stock_quantity。 5. 根据expire_date和当前日设置status。 6. 分配unit_cost的值。 7. 确保created_time和updated_time由数据库处理,无需插入。 8. 检查自增主从12开始,生成10条数据,id从12到21。 现在按照这些步骤生成10条示例数据,并确保每个字段都符合要求,特别是生成字段和约束条件。</think>根据表结构要求,我为您生成10条符合规范的示例数据,并附上说明: ```sql INSERT INTO `z_inventory` ( `product_id`, `warehouse_id`, `production_date`, `shelf_days`, `stock_quantity`, `locked_quantity`, `unit_cost`, `status` ) VALUES -- 数据1:正常状态,高可用库存 (1001, 1, '2023-03-15', 180, 200.00, 50.00, 25.50, 0), -- 数据2:近将过 (1002, 2, '2023-06-01', 90, 150.00, 30.00, 18.75, 0), -- 数据3:已过 (1003, 3, '2022-06-01', 180, 80.00, 0.00, 32.00, 1), -- 数据4:零库存锁定 (1004, 1, '2023-05-20', 365, 300.00, 0.00, 22.80, 0), -- 数据5:完全锁定 (1005, 2, '2023-07-01', 60, 100.00, 100.00, 45.90, 0), -- 数据6:已出库 (1006, 3, '2023-04-10', 270, 0.00, 0.00, 19.99, 2), -- 数据7:小数库存 (1007, 1, '2023-08-15', 30, 75.50, 25.25, 12.30, 0), -- 数据8:长保质 (1008, 2, '2023-01-10', 720, 500.00, 150.00, 28.40, 0), -- 数据9:不同仓库同商品 (1009, 1, '2023-09-01', 90, 180.00, 45.00, 36.70, 0), -- 数据10:最低成本 (1010, 3, '2023-07-20', 120, 220.00, 80.00, 9.99, 0); ``` **关字段说明:** 1. `expire_date`和`available_quantity`字段由数据库自动计算生成 2. `status`字段逻辑: - 0:正常(expire_date > 当前日) - 1:已过(expire_date <= 当前日) - 2:已出库(stock_quantity = 0) 3. `created_time`和`updated_time`自动维护 4. 主`inventory_id`从12开始自增(AUTO_INCREMENT=12) **数据验证提示:** 1. 所有`locked_quantity` ≤ `stock_quantity` 2. `production_date` + `shelf_days` = `expire_date` 3. 数值字段均保留两位小数 4. 索引字段(product_id、warehouse_id等)包含常用查询组合 **使用建议:** 1. 查询可用库存:`SELECT * FROM z_inventory WHERE available_quantity > 0 AND status = 0` 2. 过预警:`SELECT * FROM z_inventory WHERE expire_date BETWEEN CURDATE() AND CURDATE() + INTERVAL 7 DAY` 3. 成本分析:`SELECT product_id, AVG(unit_cost) FROM z_inventory GROUP BY product_id` 需要调整具体数值或补充其他业务场景的测试数据,可以随时告知。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值