前文所介绍的sql操作都是基于单表进行的,接下来我们来学习多表操作。
多表设计
在实际的项目开发中,会根据业务需求和业务模块之间的关系进行数据库表结构设计,由于业务之间相互关联,所以各个表结构之间也存在着各种联系,基本分为三种联系:一对多/多对一、一对一、多对多,我们依次来看:
一对多
"一对多"(One-to-Many)关系即一个表中的一行可以与另一个表中的多行相关联。这种关系通常通过外键来实现。
例如一个职位"老师"可以对应多个人,我们与之前的例子相结合,先新建一个部门表,与之前的表中的job相对应:
sql
create table tb_dept(
id int unsigned primary key auto_increment comment 'ID',
name varchar(10) not null unique comment '部门名',
creat_time datetime not null comment '创建时间',
updata_time datetime not null comment '修改时间'
)
insert into tb_dept(name, creat_time, updata_time)
values ('学生', now(), now()),
('老师', now(), now()),
('员工', now(), now());
因为部门表的一条数据可对应员工表的多条数据,因此称员工表为子表,部门表为父表。
此时数据虽已创建完毕,但两表之间还未创建联系,两表都可进行修改而不对另一个表产生影响。此时就要用到外键约束,添加外键约束有两种方式:
一、创建表时指定
sql
create table 表名(
-- 创建表的相关语句,
[constraint][外键名称] foreign key(外键字段名) references 主表(字段名)
);
-- 实际运用------------------------------------------------------------------------------------
CREATE TABLE tb_dept (
id INT PRIMARY KEY AUTO_INCREMENT,
-- 略
FOREIGN KEY (id) REFERENCES tb_emp(job) -- 定义外键约束
);
二、创建表后指定
sql
alter table 表名
add [constraint] [外键名称]
foreign key (外键字段名) references 主表(字段名);
-- 实际运用------------------------
alter table tb_dept
add constraint user_job
foreign key (id) references tb_emp (job);
但因为有图形化工具的存在,我们很少使用代码,而是右键表tb_emp---Modify Table(修改表)---点击foreign keys---新建---Name为外键名可随意填写---Target Table填写b_dept---点击Columens部分的"+"---Column Name填写job---Target Name填写id---确定即可成功创建。注意:两个相关联键的数据类型必须完全相同,且外键通常设置在子表中。
此时我们关闭表再打开,即可看到job列出现蓝色小钥匙,意为外键约束创建成功,此时如果想要删除父表某行已有关联的数据,系统就会报错。
使用这种方法定义的外键我们称之为物理外键,其有诸多缺点:
- 影响增删改查的效率(需检查外键关系)
- 仅用于单节点数据库,不适用于分布式,集群场景。
- 容易引发数据库的死锁问题,消耗性能
因此在实际开发中我们会使用逻辑外键来取代物理外键,即在业务逻辑层解决外键关联。
一对一
一对一指每个记录在一个表中唯一地对应另一个表中的一个记录。例如每个人都有着其对应的的身份信息。这种关系也通过外键来实现,因此我们也可以将其视为特殊的一对多。
实现方式就是在任意一方添加外键,关联另外一方的某字段,且两字段都为unique。
一对一常用于单表拆分,即将一张表的基础字段放在一张表中,详细字段放在另一个表中,以提高查询效率。实现方式即在任意一方添加外键,关联另外一方的某字段,且两字段都为unique。具体实现方法与一对多类似,在此不再赘述。
多对多
多对多指两个数据表之间可以相互关联多个实例的情况。例如,一个学生可以选择多门课程,而一门课程也可以由多个学生选修。
由于关系型数据库是基于表的,而表是二维的,直接表示多对多关系并不方便。因此,我们通常采用一个额外的表(称为"连接表"或"中间表")来存储这种关系。中间表通常包含两个外键,分别指向两个相关表的主键。
|----|------|---|--------|--------|---|----|------|
| 学生表 || | 中间表 || | 课程表 ||
| id | name | | stu_id | cou_id | | id | name |
| 1 | 张三 | | 1 | 1 | | 1 | 语 |
| 2 | 李四 | | 1 | 2 | | 2 | 数 |
| 3 | 王五 | | 2 | 3 | | 3 | 英 |
| 4 | 张伟 | | 3 | 1 | | 4 | 体 |
| | | | 3 | 4 | | | |
此时可看到id为1的学生张三学习了id为1和2的两门课程,同时id为1的课程也被id为1和3的两名学生所学习,创建表后选中三张表,右键---Diagiams---show...即可查看三者之间的关系:
我们以一个例子来练习所学内容:饭店内有多个菜品分类,每个分类都有不同的菜品及套餐,每个套餐又包含不同的菜品,试分析各表之间的关系,我们可以得出:
共有分类表、菜品表、套餐表三个基础表。
- 分类表和菜品表为一对多:每个分类都有不同的菜品。
- 分类表和套餐表为一对多:每个分类都有不同的套餐。
- 套餐表和菜品表为多对多:每个套餐包含多个菜品,每个菜品又会出现在多个不同的套餐中,因此需创建一中间表完善多对多的关系。
关系图:
再来分析各字段的名称和约束:
一、分类表
二、菜品表
sql
create table dish_name
(
id int unsigned auto_increment comment '主键ID' primary key,
name varchar(20) not null unique comment '菜品名称',
menu_id int unsigned not null comment '分类ID',
price decimal(8, 2) not null comment '价格',
dish_ifm varchar(200) comment '菜品描述,选填',
status tinyint unsigned not null default 1 comment '状态,1在售,2售空,默认售空',
creat_time datetime not null comment '创建时间',
update_time datetime not null comment '修改时间'
) comment '菜名表';
三、套餐表
sql
create table setmeal
(
id int unsigned auto_increment comment '主键ID' primary key,
name varchar(20) not null unique comment '套餐名称',
menu_id int unsigned not null comment '分类ID',
price decimal(8, 2) not null comment '价格',
dish_ifm varchar(200) comment '菜品描述,选填',
status tinyint unsigned not null default 1 comment '状态,1在售,2售空,默认售空',
creat_time datetime not null comment '创建时间',
update_time datetime not null comment '修改时间'
) comment '套餐表';
并添加中间表来完善套餐和菜品之间多对多的关系(注意需要记录该套餐内有几份菜品):
sql
create table setmeal_dish
(
id int unsigned auto_increment comment '主键ID' primary key,
menu_id int unsigned not null comment '套餐ID',
dish_id int unsigned not null comment '菜品ID',
copies tinyint unsigned not null comment '所关联的菜品份数'
) comment '套餐菜品关系表';
多表查询
多表查询就是从多个表中检索数据。在关系型数据库中,数据通常是分散存储在多个表中的,这些表通过某种关系(如外键)相互关联。基础的多表查询和单表查询的方法类似,只需在from后跟上多张表的名称,中间以","分隔即可,我们以之前所讲解的学生信息表和职务表来举例:
sql
-- 单表查询
select * from tb_emp;
-- 多表查询
select * from tb_emp,tb_dept;
但执行之后查询出来的数据量远远多于原本的数据数,因为系统将a集合和b集合中所有的数据都进行了一次匹配,查询出的数据相当于a数据数*b数据数。我们称之为笛卡尔积。
此时多出来的数据为我们不需要的数据,例如该数据:
我们只需要保留job=3且tb_dept.id=3的第一条数据即可,此时只需要在表名后添加where限制条件tb_emp.job=tb_dept.id即可:
sql
select * from tb_emp,tb_dept where tb_emp.job=tb_dept.id;
-- 或
select tb_emp.id, tb_emp.username, tb_emp.gender, job, tb_dept.name
from tb_emp,
tb_dept
where tb_emp.job = tb_dept.id
order by tb_emp.id;
在多表查询中,我们根据查询方式的不同,将其分为连接查询和子查询,子查询我们后文再介绍,我们先来看连接查询:
- 内连接:查询A、B交集部分的数据
- 外连接:分为左外连接和右外连接
- 左外连接:以左表为主,查询其全部数据及两表交集部分数据
- 右外连接:以右表为主,查询其全部数据及两表交集部分数据
连接查询-内连接
内连接有两种语法:
- 隐式内连接:select 字段列表 from 表1,表2 where 限制条件;
- 显式内连接:select 字段列表 from 表1 [inner] join 表2 on 连接条件;
隐式内连接即上文所介绍的方法,在此不再演示,但要注意:
- 如果两表中的某个字段名相同,则在查询时应注明"表名.字段名",另一表没有的字段则可以直接写"字段名"。
- 如果数据未满足where后的限制语句,则不会被查询到
显式内连接:
sql
select tb_emp.id, tb_emp.username, tb_emp.gender, job, tb_dept.name
from tb_emp
inner join tb_dept
on tb_emp.job = tb_dept.id
order by tb_emp.id;
此时我们可以发现指定数据时所使用的"表名.字段名"一旦多起来便很难分别,因此我们可以给数据表起别名,即在from 表名后添加 别名即可:
sql
-- 隐式内连接
select a.id, a.username, a.gender, job, b.name
from tb_emp a,
tb_dept b
where a.job = b.id;
-- 显式内连接
select a.id, a.username, a.gender, job, b.name
from tb_emp a
inner join tb_dept b
on a.job= b.id;
注意一旦起别名之后,原本的表名在该部分语句中便不可再使用。
连接查询-外连接
- 左外连接:select 字段名 from 表1 left [outer] join 表2 on 限制条件;
- 右外连接:select 字段名 from 表1 right [outer] join 表2 on 限制条件;
我们称表1为左表,表2为右表,左外连接会查询其全部数据及两表交集部分数据,右外连接则会查询其全部数据及两表交集部分数据。
sql
-- 左外连接
select a.id, a.username, a.gender, job, b.name
from tb_emp a
left outer join tb_dept b
on a.job= b.id;
-- 右外连接
select a.id, a.username, a.gender, job, b.name
from tb_emp a
right join tb_dept b
on a.job= b.id;
子查询
SQL语句中嵌套select语句,成为嵌套查询,又称子查询,格式:
select 字段名 from 表1 where 字段名 = (select 字段名 from 表2...);
其实和之前的查询区别不大,之前为where job = 2;,现在为where job = (select id from tb_dept where name='老师'),两者输出结果相同,因为select id from tb_dept where name='老师'的结果为2。
子查询外部的语句可以是insert、update、delete、select中的任意一个,但最常用的还是select。
根据查询结果的不同,子查询又分为四大类:
- 标量子查询:返回单个值
- 列子查询:返回一列数据,可以有多行
- 行子查询:返回一行数据,可以有多列
- 表子查询:返回多行多列的数据
一、标量子查询
其通常返回单个数字、字符串、日期等,常用操作符为:=、<>、>、>=、<、<=。
例:查询孙敏的入职时间:
sql
select entrydata from tb_emp where username='孙敏';
-- 对其进行运用
select * from tb_emp where entrydata < (select entrydata from tb_emp where username = '孙敏');
第一行代码输出结果为单行单列。
二、列子查询
其通常返回多条数据的同一个字段,常用操作符为:in、not in。
例:查询职位为老师和学生的所有人的姓名:
sql
select username from tb_emp where job in(1,2);
-- 对其运用:
select * from tb_emp where username in (select username from tb_emp where job in(1,2));
第一行代码输出结果为一列多行。
三、行子查询
其通常查询某条数据的信息,
例:查询孙敏的个人信息:
sql
select * from tb_emp where username='孙敏';
-- 对其运用
select *
from tb_emp
where gender = (select gender from tb_emp where username = '孙敏')
and job = (select job from tb_emp where username = '孙敏');
-- 比较冗余,优化后:
select *
from tb_emp
where (gender, job) = (select gender, job
from tb_emp
where username = '孙敏');
第一行代码输出结果为一行多列。
四、表子查询
返回多行多列,通常作为临时表来使用。
例:查询2014-05-25之后入职的员工:
sql
select * from tb_emp where entrydata > '2014-05-25';
-- 将其作为中间表运用:
select *
from (select *
from tb_emp
where entrydata > '2014-05-25') a
where job = 1;
第一行代码输出结果为多行多列。注意,如果使用表子查询的结果作为临时表,必须要为其赋予别名。
以多对多中的菜品表来些例题:
例题
1、查询每个分类下最贵的菜品,并展示出分类的名称和菜品的价格
sql
select setmeal.name as '分类', max(dish_name.price) as '价格'
from setmeal,
dish_name
where setmeal.menu_id = dish_name.menu_id
group by setmeal.name;
-- 简化为
select A.name as '分类', max(B.price) as '价格'
from setmeal A,
dish_name B
where A.menu_id = B.menu_id
group by A.name;
2、 查询各个分类下菜品在售,且该分类下菜品总数小于等于2的分类名称
sql
select B.name as '套餐名', count(*) as '数量'
from dish_name A,
setmeal B
where A.menu_id = B.menu_id
and A.status = 1
group by B.name
HAVING COUNT(*) <= 2;
3、查询家庭聚餐套餐中包含了哪些菜品(展示套餐名称、价格包含的菜品名称、价格、份数)
sql
select A.name, A.price, C.name, C.price, copies
from setmeal A,
setmeal_dish B,
dish_name C
where A.id = B.menu_id
and B.dish_id = C.menu_id
and A.name = '家庭聚餐套餐';
4、查询出低于菜品平均价格的菜品信息
sql
select *
from dish_name
where price < (select avg(price) from dish_name);
事务
以之前的员工表为例,如果职业'老师'需要从表中删除需要删除,此时需要删除部门表和员工表中所有关于老师的数据,但如果删除部门表时成功,员工表时意外出错,这就会导致数据的不一致性。为解决该问题可通过数据库中的事务操作。
事务指一组操作的集合,这些操作要么全都成功,要么全都失败。事务处理是确保数据库数据一致性和完整性的关键机制之一。
在之前的语句中,每个";"都意为着该语句的结束,即该事务已提交。而我们可以手动将其拆分为三个部分:
- 开始事务:start transaction;或者begin;
- 提交事务:commit;
- 回滚事务:rollback;
sql
start transaction;-- 或者begin;,意为事务开始
delete
from tb_emp
where job = 2;
delete
from tb_dept
where name = '老师';
commit;-- 意为提交操作
rollback;-- 意为回滚操作
此时语句就已被分为三个部分,我们可以每次只执行一部分,在未执行commit;语句之前,事务即使已被执行,但不会被提交(除该窗口外,其他窗口对数据进行操作,都是事务未开始的状态,但在此窗口对事务进行操作,事务处于已执行的状态), 如果想要撤回该事务,执行rollback;即可(前提是commit语句未执行)。
四大特性ACID
该特性为一道常见面试题
- 原子性(Atomicity):事务是一个不可分割的操作单元,事务中的所有操作要么全都执行,要么全都不执行。
一致性(Consistency):事务完成时,必须使所有数据保持一致状态。这意味着,事务在执行前后,数据库都必须满足所有的完整性约束(如外键约束、唯一性约束等)。
- 隔离性(Isolation):事务在执行过程中,对其他事务是隔离的,即其内部操作对其他并发事务是不可见的,直到该事务提交(commit)。这防止了事务之间的干扰和竞争条件。
- 持久性(Durability):一旦事务提交,它对数据库的影响就是永久性的,即使系统发生故障,也不会丢失已提交的事务。
索引
上文我们已介绍了数据库设计、数据库操作、接下来我们来看数据库优化。
如果数据库中有大量数据,那么使用之前查询的语句是非常耗时的,为提高操作效率,我们可为某字段添加索引。
索引(index)是帮助数据库高效获取数据的数据结构。
创建索引之前搜索数据,系统会从第一条数据开始与搜索值进行匹配,一直到最后一条语句,需要消耗大量时间。我们称之为全表扫描。创建索引之后,系统会维护该索引对应的数据结构以提高查询效率。
- 创建索引:create [unique] index 索引名 表名 (字段名, 字段名, ...);
- 查看索引:show index from 表名;
- 删除索引:drop index 索引名 on 表名;
sql
create index idx_user_age on chn_user (age);
show index from chn_user;
drop index idx_user_age on chn_user;
我们只为字段age创建了索引,但查询索引时却有三个:
这是因为主键字段在建表时就自动创建主键索引,同时该索引是效率最高的。添加唯一约束unique时,数据库也会添加唯一索引,同时注意:索引名通常为idx_表名_字段名。
- 优点:
- 通过数据查询的效率,降低数据库的IO成本。
- 通过索引列对数据进行排序,降低数据排序的成本,降低CPU消耗。
- 缺点:
- 索引会占用额外的存储空间。
- 提高查询效率的同时,也降低了增删改的效率。
索引的工作原理是通过预先构建一棵树形结构(通常是B树或B+树,也有使用哈希表的情况),将数据库表中的某一列或几列的值与对应记录的物理存储位置关联起来。这样,在进行查询时,数据库可以首先查找索引来定位到符合条件的记录所在的物理地址,从而避免全表扫描,大大提升查询效率。
B+树是一种n叉排序树,相当于二叉树plus,每个节点通常有多个key(图中的6,38,67)和指针(图中的p1,p2,p3),每个指针指向下一个磁盘块/页。它包含根节点、内部节点和叶子节点,即叶子节点和非叶子节点,其中非叶子节点只起到索引查找数据的作用,不存储数据,而叶子节点用来存储数据,所有在非叶子节点出现的key也都会出现在叶子节点之中
我们可以看到
特点:
- 每一个节点都可以存储多个key(有n个key就有n个指针)。
- 根节点和内部节点(非叶子节点)不保存数据,只用于索引,所有数据都保存在叶子节点中。
- 叶子节点之间又形成一个双向链表,便于数据的排序和区间范围查询。