SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)

【十二章】数据类型

介绍

  • MySQL的数据分为以下几个大类:
  1. String Types 字符串类型
  2. Numeric Types 数字类型
  3. Date and Time Types 日期和时间类型
  4. Blog Types 存放二进制的数据类型
  5. Spatial Types 存放地理数据的类型

字符串类型

  • 最常用的两个字符串类型
  1. CHAR() 固定长度的字符串,如州(‘CA’, ‘NY’, ……)就是 CHAR(2)
  2. VARCHAR() 可变字符串

Mosh习惯用 VARCHAR(50) 来记录用户名和密码这样的短文本 以及 用 VARCHAR(255) 来记录像地址这样较长一些的文本,保持这样的习惯能够简化数据库设计,不必每次都想每一列该用多长的 VARCHAR
VARCHAR 最多能储存 64KB, 也就是最多约 65k 个字符(如果都是英文即每个字母只占一字节的话),超出部分会被截断
字符串类型也可以用来储存邮编,电话号码这样的特殊的数字型数据,因为它们不会用来做数学运算而且常常包含‘-’或括号等分隔符号

  • 储存较大文本的两个类型
  1. MEDIUMTEXT 最大储存16MB(约16百万的英文字符),适合储存JSON对象,CS视图字符串,中短长度的书籍
  2. LONGTEXT 最大储存4GB,适合储存书籍和以年记的日志
  • 还有两个用的少一些的
  1. TINYTEXT 最大储存 255 Bytes
  2. TEXT 最大储存 64KB,最大储存长度和 VARCHAR 一样,但最好用 VARCHAR,因为 VARCHAR 可以使用索引(之后会讲,索引可以提高查询速度)
  • 国际字符
    所有这些字符串类型都支持国际字符,其中:
    英文字符占1个字节
    欧洲和中东语言字符占2个字节
    像中日这样的亚洲语言的字符占3个字节
    所以,如果一列数据的类型为 CHAR(10),MySQL会预留30字节给那一列的值

整数类型

  • 我们用整数类型来保存没有小数的数字,MySQL里共有5种常用的整数类型,它们的区别在于占用的空间和能记录的数字范围
    SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第1张图片
  • 属性1. 不带符号 UNSIGNED
    这些整数可以选择不带符号,加上 UNSIGNED 则只储存非负数
    如最常用的 UNSIGNED TINYINT,占用空间和 TINYINT 一样也是1B,但表示的数字范围不是 [-128-127] 而是 [0-255],适合储存像年龄这样的数据,可以防止意外输入负数
  • 属性2. 填零 ZEROFILL
    整数类型的另一个属性是填零(Zerofill),主要用于当你需要给数字前面添加零让它们位数保持一致时
    我们用括号表示显示位数,如 INT(4) => 0001,注意这只影响MySQL如何显示数字而不影响如何保存数字
  • 如果试图存入超出范围的数字,MySQL会抛出异常 ‘The value is out of range’
  • 最佳实践 :
    总是使用能满足你需求的最小整数类型,如储存人的年龄用 UNSIGNED TINYINT 就足够了,至少可见的未来内没人能活过255岁
    数据需要在磁盘和内存间传输,虽然不同类型间只有几个字节的差异,但数据量大了以后对空间和查询效率的影响就很大了,所以在数据量较大时,有意识地分配每一字节,保持数据最小化是很有必要的。

定点数类型和浮点数类型

  • 这节主要讲储存小数的数据类型,有定点数和浮点数两种类型

  • Fixedpoint Types 定点数类型
    DECIMAL(p, s) 两个参数分别指定最大的有效数字位数和小数点后小数位数(小数位数固定)
    如:DECIMAL(9, 2) => 1234567.89 总共最多9位,小数点后两位,整数部分最多7位
    DECIMAL 还有几个别名:DEC / NUMERIC / FIXED,最好就使用 DECIMAL 以保持一致性,但其它几个也要眼熟,别人用了要认识

  • Floatingpoint Types 浮点数类型
    进行科学计算,要计算特别大或特别小的数时,就会用到浮点数类型,浮点数不是精确值而是近似值,这也正是它能表示更大范围数值的原因
    具体有两个类型:
    FLOAT 浮点数类型,占用4B
    DOUBLE 双精度浮点数,占用8B,显然能比前者储存更大范围的数值

  • 小结
    如果需要记录精确的数字,比如货币金额,就是用 DECIMAL 类型
    如果要进行科学计算,要处理很大或很小的数据,而且精确值不重要的话,就用 FLOAT 或 DOUBLE

布尔类型

  • 有时我们需要储存 是/否 型数据,如 “这个博客是否发布了?”,这里我们就要用到布林值,来表示真或假
  • MySQL里有个数据类型叫 BOOL / BOOLEAN
  • 案例
UPDATE posts 
SET is_published = TRUE / FALSESET is_published = 1 / 0
  • 布林值其实本质上就是 微整数 TINYINT 的另一种表现形式,TRUE / FALSE 实质上就是 1 / 0,但 Mosh 个人觉得写成 TRUE / FALSE 表意更清楚

枚举和集合类型

  • enumeration n. 枚举
  • 有时我们希望某个字段从固定的一系列值中取值,我们就可以用到 ENUM() 和 SET() 类型,前者是取一个值,后者是取多个值
  • ENUM()
    从固定一系列值中取一个值
  • 案例
    例如,我们希望 sql_store.products(产品表)里多一个size(尺码)字段,取值为 small/medium/large 中的一个,可以打开产品表的设计模式,添加size列,数据类型设置为 ENUM(‘small’,‘medium’,‘large’),然后apply
    则产品表会增加一个尺码列,可将其中的值设为small/medium/large(大小写无所谓),但若设为其他值会报错
  • SET()
    SET和ENUM类似,区别是,SET是从固定一系列值中取多个值而非一个值
  • 讲解 ENUM 和 SET 只是为了眼熟,最好不要用这两个数据类型,问题很多:
  1. 修改可选的值(如想增加一个’extra large’)会重建整个表,耗费资源
  2. 想查询可选值的列表或者想用可选值当作一个下拉列表都会比较麻烦
  3. 难以在其它表里复用,其它地方要用只有重建相同的列,之后想修改就要多处修改,又会很麻烦
  • 最佳实践

像这种某个字段是从固定的一系列值中取值的情况,不应该使用 ENUM 和 SET 而应该用这一系列的可选值另外建一个 “查询表” (lookup table)

例如,上面案例中,应该为尺码另外专门建一个 size表(可选尺码表)

又如,sql_invoicing 里为支付方式另外专门建了一个 payment_methods 可选支付方式表

这样就解决了上面的所有问题,既方便查询可选值的列表,也方便作为下拉选项,也方便复用和更改

  • 导航
    下一章设计数据库讲里讲 normalization(标准化/归一化)时会更详细地讲解这个问题

日期和时间类型

  • MySQL 有4种储存日期事件的类型:
  1. DATE 有日期没时间
  2. TIME 有时间没日期
  3. DATETIME 包含日期和时间
  4. TIMESTAMP 时间戳,常用来记录一行数据的的插入或最后更新时间
  • 最后两个的区别是:
    TIMESTAMP 占4B,最晚记录2038年,被称为“2038年问题”
    DATETIME 占8B,如果要储存超过2038年的日期时间,就要用 DATETIME
  • 另外,还有一个 YEAR 类型专门储存四位的年份

二进制大对象类型

  • 我们用 Blob 类型来储存大的二进制数据,包括PDF,图像,视频等等几乎所有的二进制的文件
  • 具体来说,MySQL里共有4种 Blob 类型,它们的区别在于可储存的最大文件大小:
    SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第2张图片
  • 注意 :

通常应该将二进制文件存放在数据库之外,关系型数据库是设计来专门处理结构化关系型数据而非二进制文件的
如果将文件储存在数据库内,会有如下问题:

  1. 数据库的大小将迅速增长
  2. 备份会很慢
  3. 性能问题,因为从数据库中读取图片永远比直接从文件系统读取慢
  4. 需要额外的读写图像的代码

所以,尽量别用数据库来存文件,除非这样做确实有必要而且上面这些问题已经被考虑到了

JSON类型

  • 背景:关于JSON

MySQL还可以储存 JSON 文件,JSON 是 JavaScript Object Notation(JavaScript 对象标记法)的简称
简单讲,JSON 是一种在互联网上储存和传播数据的简便格式(Lightweight format for storing and transferring data over the Internet)
JSON 在网络和移动应用中被大量使用,多数时候你的手机应用向后端传输数据都是使用 JSON 格式
语法结构:

{
    "key1": value1,
    "key2": value2,
    ……
}

SON 用大括号{}表示一个对象,里面有多对键值对
键 key 必须用引号包裹(而且似乎必须是双引号,不能用单引号)
值 value 可以是数值,布林值,数组,文本, 甚至另一个对象(形成嵌套 JSON 对象)

  • 案例 :
    用 sql_store 数据库,在 products 商品表里,在设计模式下新增一列 properties,设定为 JSON 类型,注意在Workbench里,要将 Eidt-Preferences-Modeling-MySQL-Default Target MySQL Version 设定为 8.0 以上,不然设定 JSON 类型会报错
    这里的 properties 记录每件产品附加的独特属性,注意这里每件产品的独特属性是不一样的,如衣服是颜色和尺码,而电视机是的重量和尺寸,把所有可能的属性都作为不同的列添加进表是很糟糕的设计,因为每个商品都只能用到所有这些属性的一部分(一个子集),相反,通过增加一列 JSON 类型的 properties 列,我们可以利用 JSON 里的键值对很方便的储存每个商品独特的属性
    现在我们已经有了一个 JSON 类型的列,接下来从 增 删 改 查 各角度来看看如何操作使用 JSON 类型的列,注意这里的 增删查改 主要针对的是 properties 列里的特定键值对,即如何 增删查改 某些特定的具体属性

给1号商品增加一系列属性,有两种方法
法1:

用单引号包裹(注意不能是双引号),里面用 JSON 的标准格式:

  1. 双引号包裹键 key(注意不能是单引号)
  2. 值 value 可以是数、数组、甚至另一个用 {} 包裹的JSON对象
  3. 键值对间用逗号隔开
USE sql_store;
UPDATE products
SET properties = '
{
    "dimensions": [1, 2, 3], 
    "weight": 10,
    "manufacturer": {"name": "sony"}
}
'
WHERE product_id = 1;

法2:

也可以用 MySQL 里的一些针对 JSON 的内置函数来创建商品属性:

UPDATE products
SET properties = JSON_OBJECT(
    'weight', 10,
    -- 注意用函数的话,键值对中间是逗号而非冒号
    'dimensions', JSON_ARRAY(1, 2, 3),
    'manufacturer', JSON_OBJECT('name', 'sony')
)
WHERE product_id = 1;

两个方法是等效的

现在来讲如何查询 JSON 对象里的特定键值对,这是将某一列设为 JSON 对象的优势所在,如果 properties 列是字符串类型如 VARCHAR 等,是很难获取特定的键值对的

有两种方法:

法1 :

使用 JSON_EXTRACT(JSON对象, ‘路径’) 函数,其中:

  1. 第1参数指明 JSON 对象
  2. 第2参数是用单引号包裹的路径,路径中 $ 表示当前对象,点操作符 . 表示对象的
SELECT product_id, JSON_EXTRACT(properties, '$.weight') AS weight
FROM products
WHERE product_id = 1;

法2

更简便的方法,使用列路径操作符 -> 和 ->>,后者可以去掉结果外层的引号

用法是:JSON对象 -> ‘路径’

SELECT properties -> '$.weight' AS weight
FROM products
WHERE product_id = 1;
-- 结果为:10

SELECT properties -> '$.dimensions' 
……
-- 结果为:[1, 2, 3]

SELECT properties -> '$.dimensions[0]' 
-- 用中括号索引切片,且序号从0开始,与Python同
……
-- 结果为:1

SELECT properties -> '$.manufacturer'
……
-- 结果为:{"name": "sony"}

SELECT properties -> '$.manufacturer.name'
……
-- 结果为:"sony"

SELECT properties ->> '$.manufacturer.name'
……
-- 结果为:sony

通过路径操作符来获取 JSON 对象的特定属性不仅可以用在 SELECT 选择语句中,也可以用在 WHERE 筛选语句中,如:
筛选出制造商名称为 sony 的产品:

SELECT 
    product_id, 
    properties ->> '$.manufacturer.name' AS manufacturer_name
FROM products
WHERE properties ->/->> '$.manufacturer.name' = 'sony'

结果为:
在这里插入图片描述
Mosh说最后这个查询的 WHERE 条件语句里用路径获取制作商名字时必须用双箭头 ->> 才能去掉结果的双引号,才能使得比较运算成立并最终查找出符合条件的1号产品,但实验发现用单箭头 -> 也可以,但另一方面在 SELECT 选择语句中用单双箭头确实会使得显示的结果带或不带双引号,所以综合来看,单双箭头应该是只影响路径结果 “sony” 是否【显示】外层的引号,但不会改变其实质,所以不会影响其比较运算结果,即单双箭头得出的sony都是 = ‘sony’ 的

如果我们是要重新设置整个 JSON 对象就用前面 增 里讲到的 JSON_OBJECT() 函数,但如果是想修改已有 JSON 对象里的一些属性,就要用 JSON_SET() 函数

USE sql_store;
UPDATE products
SET properties = JSON_SET(
    properties,
    '$.weight', 20,  -- 修改weight属性
    '$.age', 10  -- 增加age属性
)
WHERE product_id = 1;

注意 JSON_SET() 是选择已有的 JSON 对象并修改部分属性然后返回修改后新的 JSON 对象,所以其第1参数是要修改的 JSON 对象,并且可以用

SET porperties = JSON_SET(properties, ……)

的语法结构来实现对 properties 的修改

可以用 JSON_REMOVE() 函数实现对已有 JSON 对象特性属性的删除,原理和 JSON_SET() 一样

USE sql_store;
UPDATE products
SET properties = JSON_REMOVE(
    properties,
    '$.weight',
    '$.age'
)
WHERE product_id = 1;

【十三章】设计数据库

介绍

之前都是对已有数据库进行查询,这一章学习如何设计和创建数据库(以及表格)。

设计一个结构良好的数据库是需要耗费不少时间和心力的,但这是十分必要的,设计良好的数据库可以快速地查询到想要的数据并且有很好的扩展性(很容易满足新的业务需求),相反,一个设计糟糕的数据库可能需要大量维护且查询又慢又麻烦,Mosh之前的一家公司的数据库就做得很糟糕,有些储存程序有上千行代码而且有些查询执行时间长达数分钟,所以,拥有设计良好的数据库是非常重要的。

这一章将系统性地逐步讲解如何设计一个结构良好的数据库

数据建模

这一节讲数据建模,即为想要储存进数据库的数据建立模型的过程,其中包含4步:

  1. Understand the requirements 理解需求

第1步是理解和分析商业/业务需求,遗憾是很多程序员跳过了这一步就急着去设计数据库里的表和列了,实际上,这一步是最关键的一步,你对问题理解的越透彻,你才越容易找到最合适的解决方案,设计数据库也一样。所以,在动手创建表和列之前,要先完整了解你的业务需求,包括和产品经理、行业专家、从业人员甚至终端用户深入交流以及收集查阅与该问题领域相关的表、文件、应用程序、数据库,以及其他相关的任何信息或资料

  1. Build a conceptional model 概念建模

当收集并理解了所有相关信息后,下一步就是为业务创建一个概念性的模型。这一步包括找出/识别/确认(identify)业务中的 实体/事物/概念(entities/things/concepts)以及它们之间的关系。概念模型只是这些概念的一个图形化表达,用来与利益相关方交流和达成共识

  1. Build a logical model 逻辑建模

创建好概念模型后,转而创建数据模型(data model)或数据结构(data structure for storing data),即逻辑建模。这一步创建的是不依赖于具体数据库技术的抽象的数据模型,主要是确认所需要的表和列以及大体的数据类型

  1. Build a physical model 实体建模

实体建模指的是将逻辑模型在具体某种DBMS上加以实现的过程,相比于逻辑模型,实体模型会确定更多细节,包括各表主键的设定,各列在某一DBMS下特定的具体的数据类型,是否有默认值,是否可为空,还包括储存过程和触发器等对象的创建。总之,实体模型是在某一特定DBMS下对数据模型非常具体的实现

以上就是数据建模的流程

概念模型

  • 案例

想要建一个销售在线课程的网站,用户可以注册一项或多项课程,课程可以有诸如 “frontend(前端)” “backend(后端)” 这样的标签

对于一个线上课程网站来说,重要的概念/实体有哪些?很容易想到有学生(student)和课程(course)

我们需要一种将实体及其关系可视化的方法,一种是实体关系图(Entity Relationship, ER),一种是统一建模语言(Unified Modeling Language,UML),这里我们用实体关系图(ER),使用的工具是 http://draw.io

步骤如下:

  1. 建立学生实体并确定相关属性,如姓名、电子邮件、注册时间
  2. 建立课程实体并确定相关属性,如课程名、价格、老师、标签
  3. 建立两个实体间的关系,暂时先用多对多连线(概念模型里只是画好连线,逻辑建模时再考虑连线的类型),加上 enrolls 标签表示两者间的关系是“学生→注册 →课程”

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第3张图片

  • 注意
    建模是个迭代过程,不可能第一次就建立完美模型,需要在理解需求和模型设计之间不断反复,多次调整。比如这里的学生属性,可以先确定个大概,之后可以根据需要再进行增删修改
  • 小结
    概念模型主要是从很高的视角来总览业务需求,识别业务中的实体/事物/概念以及他们彼此间的关系,通常这些实体包括人、事件、地点等
    这一步暂不考虑数据类型和具体的DBMS这样的技术细节,只是从概念上总揽全局,目的是和业务人员交流,保持理解一致,避免鸡同鸭讲
  • 导航
    下一节我们将用这个概念模型来建立逻辑模型

逻辑模型

  • 案例

接前面线上课程网站的例子,对概念模型逻辑化的过程如下:

  1. 细化实体间关系:

考虑学生和课程的关系,首先这是一种多对多关系(通常意味着需要进一步细化),其次了解到业务上有如下需求:

-需要记录学生注册特定课程的日期
-课程价格是变化的,需要记录学生注册某门课程时的特定价格
这些属性相对于学生和课程而言都是一对多关系,不管放在学生还是课程身上都不合适,所以,应该为学生和课程之间的关系,即 注册课程的事件 本身另外设立一个实体 enrollmemt,上面的注册日期和注册价格都应该是这个 enrollment 注册事件 的属性

  1. 调整字段并大体确定字段的数据类型:

姓名(name)最好拆分为姓和名 (first_name 和 last_name),同理,地址应该拆分为省、市、街道等等小的部分,这样方便查询。注意课程里的 tags 标签字段不是一个好的设计,之后讲归一化时再来处理
这里的数据类型只需确定个大概即可,如:是 string,float 而非 VARCHAR, DECIMAL。等到下一步实体模型里再来确定某个DBMS下的具体数据类型

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第4张图片

  • 小结
    逻辑模型是在概念模型的基础上,在不依赖特定数据库系统的前提下确定数据结构,包括细化实体间的关系(常常要为关系创造新的实体)调整字段设置确定大体的数据类型。总之,逻辑模型会基本确立数据库中的表、列以及表间关系

实体模型

实体模型就是逻辑模型在具体DBMS的实现,这里我们用MySQL实现前面线上课程网站的逻辑模型

Workbench-file-new model 新建数据库模型,右键 edit 修改数据库名字为 school

上方用 add diagram 作 EER 图,这里 EER 表示 Enhanced Entity Relationship 增强型实体关系图。为三个实体创建三张表,设定表名、字段、具体的数据类型、是否可为空(即是否为必须字段?),是否有默认值(主键设定之后再讲)。有几个注意点:

-表名:
之前逻辑模型里表名用单数,但这里表名用复数。这只是一种惯例,单复数都行,关键是要保持一致。
如果团队有相关惯例就去遵守它,即便那不够理想,也别去破环惯例,否则沟通和维护成本会大大增加,你需要不断去想该用单数还是复数
-字段名:
以 enrollments 表为例,注册事件的属性应该是 date日期 和 price价格 而非 enrollment_date注册日期 和 enrollement_price注册价格,不要将表名前缀加上字段上造成不必要的麻烦,保持精简(keep things simple)
-数据类型:
数据类型要根据业务需要来,例如,和业务人员确认后发现课程价格最高是999美元,所以 price价格 就可以设定为 DECIMAL(5,2),之后如果需求变了了也可以随时更改,不要一上来就设定DECIMAL(9,2),浪费磁盘,注意尽可能节省空间(keep things small)
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第5张图片
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第6张图片

在这里插入图片描述
以上便是 “在线授课网站-实体模型

  • 小结
    实体模型是逻辑模型在特定DBMS上的实现,主要是一些技术上的细化,包括确定字段具体数据类型和性质(能否为空等),设置主键等
  • 导航
    接下来我们要给每一个表设置一个主键并定义表之间的关系

主键

  • 主键就是能唯一标识表中每条记录的字段
  • 设定 students 表的主键:
    不管是 first_name 还是 last_name 都不能唯一标识每条记录,它们两个合起来作为联合主键也不行,因为两个人全名相同也是可能的(都叫 Tom Smith)。Email 也不适合作主键,首先太长了,之后需要作为外键复制到其他表会很浪费资源,而且 Email 也可能改变。
    总之主键要短,可唯一标识记录,且永不改变。我们增加一个 student_id 作为主键,类型设为 INT(最大可表示2亿,一般足够了,但记得总是根据具体的需求决定),设为主键后自动变为不可为空,另外还要设定 AI(Auto Incremental)自动递增,这样会方便许多,不要担心主键唯一性的问题,最后我们把主键拖到表的第一列让表的结构看起来更清晰
    在这里插入图片描述
    SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第7张图片
  • 设定 courses 表的主键:
    增加一个 course_id 作为主键,其它和 student_id 一样
  • 导航
    下节课讲 enrollemnts 表的主键问题

外键

注意 enrollments 表的特殊性,它可以说是 students 和 courses 的衍生表,先要有学生和课程,才能有 学生注册课程 这一事件,后者表述的是前两者的关系,学生和课程是因,注册课程这一事件是果

MySQL里可以通过一对一或一对多两种连线表达这种先后关系/因果关系并自动建立外键,其中学生和课程被称作父表或主键表注册事件被称作子表或外键表外键是子表里对父表主键的引用

几个细节:

-连线时记不得先连主表还是子表可以看状态栏的提示
-MySQL自动添加的外键会带父表前缀,没必要,建议去掉

可以看到,相对于逻辑模型,实体模型有更多实现细节,包括设置字段具体类型和性质以及根据表间关系确定主键和外键

现在,根据表间关系给 enrollments 表添加了 student_id 和 course_id 两个外键,enrollments 的主键设置有两个选择:

  1. 将这两个外键作为联合主键
  2. 另外设置一个单独的主键 enrollment_id

两种选择各有优缺点,以联合主键为例:

-好处是可以避免重复的注册记录,即可以防止同一个学生重复注册同一门课程,因为主键(这里是联合主键)是唯一不可重复的,这可以防止一些不合理的数据输入
-坏处是如果 enrollments 未来有新的子表,就需要复制两个字段(而不是 enrollment_id 一个字段)作为外键,这也不一定是很大的麻烦,要根据数据量以及子表是否还有子表等情况来考
虑,在一定情况下可能会造成不必要冗余和麻烦

但目前来说,没有为 enrollments 建立子表的需求,永远不要为未来不知道会不会出现的需求进行设计开发,如果之后需要的话也可以通过脚本修改表结构,也不会很麻烦,所以目前的情况,用联合主键就好了。在 enrollments 表里把两个外键的黄钥匙都点亮,即成为联合主键
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第8张图片

  • 将子表中的两个复合主键从 外键(前面的红宝石)变为PK(主键,黄钥匙),然后上移

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第9张图片
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第10张图片
以上为 “父子表、主键与外键”

外键约束

有外键时,需要设置约束以防止数据损坏/污染(不一致)

在 enrollements 表设计模式里,打开 Foreign Keys 标签页,可以看到两个外键,以 fk_子表_父表 的方式命名,名称后可能有数字,是MySQL为了防止外键与其他外键重名自动添加的,这里没必要,可去掉。右边 Foreign Key Options 可分别选择当父表里的主键被修改或删除(Update / Delete)时,子表里的外键如何反应,有三种选项:

  1. CASCADE:
    瀑布/串联/级联,表示随着主键改变而改变,如主键某学生的 student_id 从1变成2,则该学生的所有注册课程记录的 student_id 也会全部变为2 (注意主键一般也最好是永远不要变的,这里讨论的是特殊情况)
  2. RESTRICT / NO ACTION:
    两者等效,作用都是禁止更改或删除主键。如:对于有过注册记录的课程,除非先删除该课程的注册购买记录,不然不能在 courses表 里删除该课程的信息
  3. SET NULL:
    就是当主键更改或删除时,使得相应的外键变为空,这样的子表记录就没有对应的主键和对应的父表记录了(no parent),被称为孤儿记录(orphan record),这是垃圾数据,让我们不知道是谁注册的课程或不知道注册的是什么课程,一般不用,只在极其特殊的情况可能有用。

经验法则

通常对于 UPDATE, 设置为 CASCADE 级联,随之改变

对于 DELETE,看情况而定,可能设置为 CASCADE 随之删除 也可能设置为 RESTRICT / NO ACTION 禁止删除。不要死板,永远按照业务/商业需求来选择,这也正是为什么之前强调“理解业务需求”是最重要的一步。比如我们课程注册记录里包含购买价格信息,则应该禁止删除,否则之后想统计某课或某时间段收入信息就会缺数据,相反如果只是个用户登录并设定一系列提醒的软件,可能允许用户注销并删除所有提醒就没什么大不了的,但万一我们需要这些提醒记录来进行统计,那又应该设置为禁止删除,总之一定要根据具体业务需求来(always check with the business)
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第11张图片

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第12张图片

数据库规范化/正规化/归一化

正式建立数据库前我们先要检查并确定现在的设计是最优化的(optimal),关键是没有任何冗余或重复。重复数据会占用更多空间并且使得增删查改的操作复杂化,比如,如果用户名在多处出现的话,一旦更改用户名就要到多处更改否则就会使得数据不一致,出现无效数据。

为了防止重复冗余,需要遵循数据库设计的7大规则或者说7大范式,每一条都是建立在你已经遵循了前一条的基础上。实际上,99%的数据库之需要遵循前三大范式就够了,其他几个并没有那么重要。接下来将依次讲解前三大范式并给出可操作的建议,让你能够在不死记硬背这些规则的情况下轻松设计出归一化的数据库

补充:维基百科——数据库规范化

数据库规范化,又称正规化、标准化,是数据库设计的一系列原理和技术,以减少数据库中数据冗余,增进数据的一致性。关系模型的发明者埃德加·科德最早提出这一概念,并于1970年代初定义了第一范式、第二范式和第三范式的概念,还与Raymond
F. Boyce于1974年共同定义了第三范式的改进范式——BC范式。
除外还包括针对多值依赖的第四范式,连接依赖的第五范式、DK范式和第六范式。
现在数据库设计最多满足3NF,普遍认为范式过高,虽然具有对数据关系更好的约束性,但也导致数据关系表增加而令数据库IO更易繁忙,原来交由数据库处理的关系约束现更多在数据库使用程序中完成。

第一范式

第一范式:

Each cell should have a single value and we cannot have repeated columns.
每个单元格都应该是单一值并且不能有重复的列

courses 里的 tags 标签列就不符合第一范式。tags 列用逗号隔开多个标签,不是单一值。若将 tags 分割成多列,每个标签一列呢?问题是我们不知道到底有多少标签,每次出现新标签就要改动表结构,这样的设计很糟糕。这也正是范式1要求没有重复列的原因

所以我们另外单独创建一个 tags 表,设置两个字段:

  1. tag_id TINYINT 如果标签是终端用户设定的,那数量就可能会迅速增长,但这里假定标签是管理员设定的,最多可能五六十个,那 TINYINT 足够了
  2. name VARCHAR(50)
  • 导航
    下节课我们将在 tags 与 courses 间建立多对多关系

链接表

原先,在课程表中,每个标签都出现好多次,这样前端会在很多不同地方重复出现,那么如果想为前端改名,就必须得更新很多条课程记录

尝试建立 courses 和 tags 之间的联系,发现两者是多对多关系(MySQL里只有一对一和一对多,没有多对多),这说明两者的关系需要进一步细化,我们添加一个 course_tags 表来专门描述两者间的关系,记录每一对课程和标签的组合,这个中间表或者说链表(link table)同时是 courses 和 tags 的子表,与这两个父表均为一对多的关系,建立两条一对多连线后 MySql 自动给 course_tags 表增加了两个外键 course_id 和 tag_id(注意去掉自动添加的表前缀),两者构成了 course_tags 表 的联合主键

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第13张图片

通过 course_tags 细化 courses 和 tags 的关系 与 之前通过 enrollments 表细化 students 和 courses 的关系一样,都是通过建立链表细化多对多关系,这是很常用的一种方法,有时链表只包含引用的两个外键,如 course_tags 表,有时链表还包含其它信息,如 enrollments 表还包含注册时间和注册费用

至此,删除掉 courses 里的 tags 列,我们的数据库就符合第一范式了,所有列都是单一值也没有诸如tag1,tag2这样的重复列,所有标签都保存在独立的 tags 表里拥有唯一记录。如果像之前那样标签以逗号分隔保存在 courses 表中,同一个标签如 “frontend” 会多次出现,如果要将这个标签改名为 “front-end” 就会多出很多不必要的锁定操作,修改标签却要锁定 courses 表里的记录,这本身就很不合理,tags 表才该是唯一储存标签的地方,而tags 里的标签条目才是修改标签时唯一应该被锁定的条目

第二范式

第二范式的人话解释:

Every table should describe one entity, and every column in that table should describe that entity.
每个表都应该是单一功能的/应该表示一个实体类型,这个表的所有字段都是用来描述这个实体的

以 courses 表为例,course_id、title、price 都完全是属于课程的属性,放在 courses 表里没问题,但注册时间 enrollment_date 放在 courses 表里就不合适,因为一个课程可以被多个学生注册所以有多个注册时间,同样的注册时间也不应该是 students 表的属性,因为一个学生可以注册多门课所以可以有多个注册时间,注册时间应该是属于“注册事件”的属性,所以应该另外建个 enrollments 表,放在该表里。

同理,对于订单表 orders 来说,order_id 和 date 应该是其中的属性,但 customer 就不是,虽然每个订单确实有对应的顾客,但顾客信息可能在不同订单里重复,这会占用多余的储存空间并使得修改变得困难,应该单独建一个顾客表来储存顾客信息,订单表里用顾客id而非顾客名来引用顾客表,当然,顾客id还是会重复,但4字节的数字比字符串占用的空间小多了,这已经是让重复最小化了

总之,第一范式是要求单一值和无重复列,这里第二范式是要求表中所有列都只能是完全描述该表所代表的实体的属性,不属于该实体的、在记录中可重复的属性(如订单表里的顾客信息),应该另外放在描述相应实体的表里(顾客表)

以我们这个模型为例,courses 里的 instructors 虽然是单一值符合第一范式却不符合第二范式,因为老师不是完全属于课程的属性,老师在不同课程中可能重复。所以,另外建立 instructrors 表作为父表,包含 instructor_id 和 name 字段,其中 instructor_id 为主键,一对多链接 courses 表后自动引进 courses 表作为外键,删除原先的 instructor 列。还有注意设置外键约束,UPDATE 设置为 CASCADE,DELETE 设置为 NO ACTION,也就是 instructor_id 会随着 instructors 表更改,但不允许在某教师有课程的情况下删除该教师的信息

至此,我们的数据库已符合第二范式。


补充:第二范式的维基百科

第二范式(2NF)是数据库正规化所使用的正规形式。规则是要求资料表里的所有资料都要和该资料表的键(主键与候选键)有完全依赖关系:每个非键属性必须独立于任意一个候选键的任意一部分属性。如果有哪些资料只和一个键的一部分有关的话,就得把它们独立出来变成另一个资料表。(查询表)

第三范式

第三范式的人话解释:

A column in a table should not be derived from other columns.
一个表中的字段不应该是由表中其他字段推导而来

例如,假设 invoices 发票表里现在有三个字段:发票额、支付额 和 余额,第三个可以由前两个相减得到所以不符合 3NF,每次前两者更新第三个就要随之更新,假设没有这样做,出现了 100,40,80 这样不一致的数据,就不知道到底该相信哪个了,余额到底是 80 还是 100-40=60?

同理,如果表里已经有 first_name 和 last_name 就不该有 full_name,因为第三者总是可以由前两者合并得到

不管是 余额balance 还是 全名fullname,都是一种冗余,应该删除

补充:第三范式的维基百科

第三范式(3NF)是数据库正规化所使用的正规形式,要求所有非主键属性都只和候选键有相关性,也就是说非主键属性之间应该是独立无关的。
如果再对第三范式做进一步加强就成了BC正规化,强调的重点在于“资料间的关系是奠基在主键上、以整个主键为考量、而且除了主键之外不考虑其他因素”。

  • 总结
    第三范式和前两范式一样,都是为了减少数据重复和冗余,增强数据的一致性和完整性(data integrity)
    感觉三大范式可以用三个关键词总结:单一值、单一功能、独立

我的实用建议

除非需要考试,不然没必要记忆和死板套用三大范式,实际工作中只需要专注于减少数据的重复性即可,比如发现一个 name 字段下出现的是一些重复的名字而不是重复的外键(如某种id),那就说明设计还不够归一化,具体违反哪条范式并不重要,关键是专注于避免重复性

  • 例子
    假设一个顾客表里每条都是一个顾客信息,有名字年龄生日性别还有收货地址,如果想让一个顾客可以有多条收货地址应该怎么办?
    如果仍然把收货地址放在这个顾客表,就要为了保存一个顾客的多条地址而将这个顾客的所有信息复制多条,这是一种没必要的重复和冗余
    我们先从概念和逻辑模型上思考,这里有两个关键实体,顾客 和 地址,它们是一对多关系,然后再细化为实体模型,应该建立两张表,顾客表保存顾客其他信息,地址表(实际上是顾客地址关系表)只保存顾客id和地址两个字段,这样就将重复性降到了最低
  • 小结
    总之,一定要先从概念和逻辑模型去考虑实体和关系,再逐步细化,过程中专注于避免数据的重复冗余以及保证数据的一致性和完整性,一定不要一上来就建表,这样几乎总是得到糟糕由混乱的数据库设计
  • 注意
    上面的例子以一个顾客有多个收货地址为前提,但如果一个顾客只有一个收货地址,那用一张表就足够了,用两张表是没必要的,所以关键是理解业务需求,总是按照业务需求来设计,这也引入了下一节内容:不要对全宇宙建模!

不要对什么都建模

设计数据库时总是考虑当前的业务需求,不要试图包罗万象,总有开发人员会考虑各种未来可能出现的需求,实际上大部分那些需求都从未发生,反而使得数据库增加了很多没必要的复杂性,增加了查询的难度并拖慢了执行效率

Mosh之前的公司曾有个人设计了一个过于一般化但也过于复杂难懂的数据库,企图满足所有未来可能的需求,但结果是没人能懂他的模型,而且执行增删改查异常麻烦且速度极低,最后成了一个没人敢碰的烂摊子

建立复杂模型不是本事,能够将复杂的模型不断简化让其尽可能地优美简单易懂又能满足目前的需求,这才是本事,如果还能有不错的拓展性以满足未来可能的新特性就更好了

总之,尽可能保持简洁,简洁才是终极哲学(Simplicity is the ultimate sophistication),无论你对未来的预测有多好,总会有意料之外的需求出现,总有一天你会写脚本改数据库甚至进行数据迁移,这是避免不了的,当前只需考虑如何最好地满足目前的需求就好了,不要企图对全宇宙建模

正向搭建数据库

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第14张图片
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第15张图片
有一步可以选择 除了创建数据库中的表 是否还要创建 储存程序、触发器、事务和用户对象,而且表格可以筛选到底要创建哪些表

最后一步会展示对应的SQL代码,里面有创建 school 数据库(schema?)以及各表的SQL代码,之后会详细讲。可以选择保存代码为文件(以保存到仓库中)或者复制到剪贴板然后到 workbench 查询窗口里以脚本方式运行,这里我们直接运行,返回 local instance 链接刷新界面就可以看到新的 school 数据库和里面的6张表了

使用数据库同步模型

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第16张图片
之后可能会修改数据库结构,比如更改某些表中字段的数据类型或增加字段之类,如果只是自己一个人用的一个本地数据库,可以直接打开对应表的设计模式并点击更改即可,但如果是在团队中工作通常不是这样。

在中大型团队中,我们通常有多个服务器来模拟各种环境,其中有:

  1. 生产环境(production environment):用户真正访问应用和数据库的地方
  2. 模拟环境(staging environment):与生产环境十分接近
  3. 测试环境(testing environment):存粹用来做测试的
  4. 开发环境(development environment)

所以不能是在设计模式中直接点击修改,相反,是在之前的实体模型(EER Diagram)中修改并使用菜单中的 Database → Synchronize Model,其中有一步可以选择链接,这里我们选择本地连接 local_instance,但如果是在团队中可能需要选择测试环境、模拟环境甚至开发环境的链接以对相应环境中的数据库执行更改,MySQL会自动检测到需要修改的是 school 数据库并提示要修改的表,例如我们想在 enrollments 中加上一个 coupons 折扣券 字段,会提示将影响的表除了 enrollments 还有 courses 等表,因为这些表与要修改的表是相互关联的,之后的 SQL 的语句会先暂时删除相关外键以消除这些联系,对目标表做出相应更改(增加 coupons 字段)后再重建这些联系,同样的,我们可以把这些代码保存起来成为文件并上传到仓库,就可以在不同环境执行相同修改以保持一致性
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第17张图片

反向搭建数据库

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第18张图片
如果要修改没有实体模型的数据库,第一次可以先逆向工程(Reverse Engineering)建立模型,之后每次就可以在该模型上修改了
例如,我们要修改 sql_store ,应如下操作:

  1. 关闭当前 school 数据库的 Model,不然之后的逆向工程结果会添加到当前模型上,最好是每个数据库都有一个单独的模型,除非数据库间相互关联否者不要在一个模型中处理多个数据库
  2. Database → Reverse Engineer,可以选择目标数据库,如上说所,除非数据库相互关联,否者最好一次只逆向工程一个数据库,让每个数据库都有一个单独的模型。
  3. 同样,可以筛选要哪些表

在反向搭建出的模型中,可以更好的看清和理解数据库的结构设计,可以修改表结构,还可以发现问题,如在 sql_store 数据库的模型中,可以发现有一个 order_items_notes 表并未与任何表相联,这样里面的 order_id 就可能输入无效值,相反如果是建立了链接的表,MySQL会自动验证数据的一致性/完整性/有效性(integrity),只允许子表中添加父表中存在的id值

第一次修改无模型的数据库可以使用MySQL自带的逆向工程,之后就可以用这个模型查看表结构、检查问题和进行修改

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第19张图片

项目:航班订票系统

解答:概念模型

主要建立实体、实体里的字段、实体间的关系,不用确定具体关系类型和字段类型等细节,主要用于和业务方交流

注意只根据机票信息决定需要的字段,满足当下需求就好,未来有新需求时再修改增加新的字段
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第20张图片

解答:逻辑模型

与概念模型相比,逻辑模型主要做了如下细化调整:

  1. 细化关系,尤其是多对多关系,通常要另外添加链表变成两个多对一关系,但是注意 =flights 和 airports 的关系很特殊,一个机场可对应多个航班但一个航班只能对应起飞和降落两个机场,是多对二的关系,或者说是两个多对一关系,如果还是通过一个链表来替换这个多对多关系,则不能防止一个航班出现多于两个机场,最好的办法是将 fights 中的机场区分为起飞机场id和降落机场id两个字段,分别建立外键引用airports(两个多对一连线?)
  2. 调整字段,name 这样的字段要拆分成 first_name 和 last_name 这样的更小组成成分,而重复性的字段常常要另外单独建表(查询表/资料表 lookup table)再以外键形式在原表中引用,但是 airports 里的 city 和 state 比较特殊,因为考虑到 city 和 state 与 airport经常一起查询,合并在一张表上能提高查询速度,而且机场数量并不多,重复性的问题并不严重,反而如果另外单独再建cities表和states表会使得数据过于碎片化,所以这里进行“反归一化”(denormalize),在 airports 表中保留 city 和 state 的原始字段,用一定的重复性来换取查询便利和效率
  3. 确认数据类型,注意有的所谓的 number 其实不需做计算且包含符号,所以应该用字符串类型

另外注意用词和表达要向业务方咨询,确保用词准确表达方式与业务规范相一致,这很重要

还有注意调整字段时,可以将 flights 里的 duration 和 distance 改为 duration_in_minutes和 distance_in_miles,这样更明确,看的人不用去猜单位是什么

实体模型就不展示了,从逻辑模型到实体模型只是具体DBMS技术上的调整和实现,没必要反复讲
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第21张图片

项目:视频租赁应用

解答:概念模型

这一步还是一样,为了建立概念模型,根据业务需求文档确定大概的实体、实体属性、实体间关系

注意这里将顾客和电影的多对多关系细化为一个rentals链表(link table),变成可操作的两个一对多关系,这个方法之前也反复用到,如将学生和课程之间的关系细化为 enrollments 表 以及 乘客和航班的关系 细化为 tickets 表

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第22张图片

解答:逻辑模型

如之前一样,在逻辑模型里,我们要确定数据储存方式,所以要进一步细化具体的实体间关系类型和字段的数据类型,也会为了减少数据重复性和提高数据一致性对表结构和数据库结构进行一些调整修改

关系类型具体化和字段数据类型确定

和之前差不多,只是要注意 coupon 和 rental 的关系比较特殊,是多对零或一(注意箭头的不同),因为一个 rental 可能有一个 coupon 也可能没有 coupon

字段调整

将名字拆分为姓和名,将租赁天数拆分为借电影日期和还电影日期(后两个才能提供足够信息计算各月收入等)

设计调整

之前的 users-permissions 用户-权限设计并不好,虽然业务文档确实提到了这两个实体,但仔细分析发现实际上用户只有两类 管理员和店员 而对应的权限唯一的区别也只是是否能修改电影列表,在这一业务情形下,没必要有一个完整的权限表将所有权限列出来,只需要有一个roles岗位表将用户分为两类即可,实际的权限可以通过if条件语句来根据用户是管理员还是店员来决定是否禁止其修改电影列表,这样的设计更精简,减少了每次增加一个用户就要挨个分配10个权限的重复性,也防止了给相同职位不同权限这样的错误的发生,增强了一致性

思想

没必要列出10个权限然后依次分配,以用户表-权限表的方式设计模型过于一般化,提供了业务并不需要的过高的控制等级,这种多余的复杂化和冗余会一直跟随系统一直造成不必要的麻烦。如果你有100元预算只想找个能歇脚能睡觉的地方,那一个500元的豪华宾馆多出的功能如高质量的网络漂亮的海景奢华的床铺等等对你来说都是没必要的,你不会多花400元买你不需要的功能,开发软件也一样, 所有功能和复杂性都是有成本的,都会有人买单,不要把公司的钱浪费在不需要的地方,要尽可能用最精简的方式满足当前的业务需求。

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第23张图片

创建和删除数据库

用 workbench 的向导来创建和修改数据库能够提高效率,但作为 DBA (Database Administrator 数据库管理员),你必须要能理解并审核相关代码,确保其不会对数据库有不利影响,而且也有能力手动写代码完成创建和修改数据库的操作,可以不依赖工具。

这节课讲创建和删除数据库:

CREATE DATABASE IF NOT EXISTS sql_store2;
DROP DATABASE IF EXISTS sql_store2

之后讲创建和修改表和表间关系等

创建表

  • 以在 sql_store2 中建表 customers 为例,注意创建表之前还是要先用 USE 选择数据库,不然不知道你是要在哪个数据库中创建表
USE sql_store2;

DROP TABLE IF EXISTS customers;
CREAT TABLE customers
-- 没有就创建,有的话就推倒重建CREATE TABLE IF NOT EXISTS customers
-- 没有就创建,有的话就不做改变

(
    -- 只挑选几个字段来建立
    customer_id INT PRIMARY KEY AUTO_INCREMENT,
    first_name VARCHAR(50) NOT NULL, 
    points INT NOT NULL DEFAULT 0,
    email VARCHAR(255) NOT NULL UNIQUE
    -- UNIQUE 确保 email 值唯一,即每个用户的 email 必须不一样
)
  • 左侧栏导航窗口选择某表中的列时,下面的 Object Info 可以查看列的数据类型
  • 小结:
    如上,创建对象(不管是数据库还是表)有两种方式,DROP …… IF EXIXTS ……; CREAT …… 和 CREAT …… IF NOT EXISTS ……,注意两种方式的区别在于,当原对象存在时,前者是推倒重建,后者是保持原状放弃创建
    括号中设置列的方式为 列名 数据类型 各种列性质,列间逗号分隔,常用的列性质有 PRIMARY KEY NOT NULL DEFAULT 0 UNIQUE

更改表

这节学习如何更改已存在的表,包括增删列和修改列类型和属性

USE sql_store2;
ALTER TABLE customers
    ADD [COLUMN] last_name VARCHAR(50) NOT NULL [AFTER first_name],
    ADD city VARCHAR(50) NOT NULL,
    MODIFY [COLUMN] first_name VARCHAR(60) DEFAULT '',
    DROP [COLUMN] points;

COLUMN 是可选的,有的人喜欢加上以增加可读性

AFTER first_name 是可选的,不加的话默认将新列添加到最后一列

MODIFY 修改已有列时其实感觉好像是是重置该列(= DROP + ADD),所以注意要列出该列全部类型和属性信息,如上例中将 first_name 修改为 VARCHAR(60) 类型并将默认值修改为空字符串’',但忘了加 NOT NULL,刷新后发现 first_name 不再有 NOT NULL 属性

列名最好不要有空格,但如果有的话可用反引号包裹,如 last name

  • 注意
    修改表永远不要直接在生产环境中进行,要首先在测试环境进行,确保没有错误和不良影响后再到生产环境进行修改

创建关系

  • 这节学习创建表间关系
  • 第26节在新的 store2 数据库中创建了 customers 表,这里我们接着创建 orders 表,并在表中添加 customer_id 外键来建立表间关系
CREATE DATABASE IF NOT EXISTS sql_store2;
USE sql_store2;
DROP TABLE IF EXISTS customers;
CREATE TABLE customers
(……);  
-- 在Workbench里可点击加减号来展开或收起代码块

DROP TABLE IF EXISTS orders;
CREATE TABLE orders
(
    order_id    INT PRIMARY KEY,
    customer_id INT NOT NULL,
    order_date  DATE NOT NULL,
    -- 在添加完所有列之后添加外键
    FOREIGN KEY fk_orders_customers (customer_id)
        REFERENCES customers (customer_id)
        ON UPDATE CASCADE
        -- 也有人主张用 NO ACTION / RESTRICT
        ON DELETE NO ACTION
        -- 禁止删除有订单的顾客
)
  • 外键名的命名习惯:
fk(foreign key 的缩写)_子表名_父表名
  • 设置外键的语法结构:
FOREIGN KEY 外键名 (外键字段)
        REFERENCES 父表 (主键字段)
        -- 设置外键约束:
        ON UPDATE CASCADE
        ON DELETE NO ACTION
  • 关于外键约束

ON DELETE 设置为 NO ACTION / RESTRICT 可以防止删除有的订单的顾客,这没什么问题;而对于 ON UPDATE,也有人主张同样应该设为 NO ACTION / RESTRICT,因为主键是永远不应该被更改的,理论上Mosh支持这个观点,但实际世界并不完美,由于意外或系统错误等原因,主键是有可能改变的,所以Mosh一般设置为CASCADE,让外键随着主键的更改而更改,但你要设置为 NO ACTION / RESTRICT 也同样有道理。另外,想查看外键约束的可选项以及想通过菜单选择来更改外键约束的话,可以打开某列的设计模式,在 Foreign Keys 标签页里进行选择

  • 表间依赖

还有注意一点,运行以上SQL文件从头创建 sql_store2数据库以及customers和orders两张表时,第一次运行没问题,但要再次运行的化会报以下错误:

/* Error Code: 1217. Cannot delete or update a parent row: 
a foreign key constraint fails*/

这是因为建立主外键关系后, customers 现在和 orders 是父子表,orders 表依赖于 customers 表,所以必须先删除 orders 表才能删除 customers 表,所以应该把 orders 表的 DROP 语句放到最前面:

CREATE DATABASE IF NOT EXISTS sql_store2;
USE sql_store2;
-- 删表时先删子表
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS customers;

-- 建表时先建父表(我觉得应该是)
CREATE TABLE customers
(……);

CREATE TABLE orders
(……);

这样运行再多次也没问题了,总是可以从头建立sql_store2数据库和customers、orders两张表(不过为什么不在最开头创建数据库的语句里用 DROP DATABASE IF EXISTS sql_store2; CREATE DATABASE sql_store2 这种直接将整个数据库推倒重建的方式呢?)

更改主键和外键约束

这一节学习如何在已经存在的表间创建和删除关系,还是用 ALTER TABLE 语句 + ADD、DROP 关键词,和27节修改表里一样,只不过这里增删的不是列而是外键:

USE sql_store2;
ALTER TABLE orders 
    DROP FOREIGN KEY fk_orders_customers,  -- orders_ibfk_1
    ADD FOREIGN KEY fk_orders_customers (customer_id)
        REFERENCES customers (customer_id)
        ON UPDATE CASCADE
        ON DELETE NO ACTION;

另外也可以通过类似的 ALTER TABLE 语句增删主键:

USE sql_store2;
ALTER TABLE orders
    ADD PRIMARY KEY (order_id,……,……),
    -- 可设置多个主键,在括号内用逗号隔开
    DROP PRIMARY KEY;
    -- 删除主键不用声明,会直接删除所有主键

另外,像增删主键这种既可以用菜单点击也可以用代码运行实现的操作(Workbench里这种操作相当多了),当忘记相关SQL代码写法时,可以通过菜单点击方式操作然后在 Review the SQL script 那一步看一看,就知道代码怎么写的了

字符集和排序规则

字符是以数字序列的形式储存于电脑中的,字符集是数字序列与字符相互转换的字典,不同的字符集支持不同的字符范围,有些支持拉美语言字符,有些也支持亚洲语言字符,有些支持全世界所有字符,查看MySQL支持的所有字符集:

SHOW CHARSET;

其中 armscii8 支持亚美尼亚语,big5 支持繁体中文,gb2312 和 gbk 支持简体中文,而 utf-8支持全世界的语言,utf-8 也是MySQL自版本5之后的默认字符集。

还可以看到字符集描述,默认排序规则,最大长度

排序规则(collation n. 校对,整理,排序规则)指的是某语言内字符的排序方式,utf-8 的默认排序规则是 utf8_general_ci,其中 ci 表示 case insensitive 大小写不敏感,即MySQL在排序时不会区分大小写,这在大部分时候都是适用的,比如用户输入名字的时候大小写不固定,我们希望只按照字符顺序而不管大小写来对名字进行排序。总之,99.9% 的情况下都不需要更改默认排序规则。

最大长度指的是对该字符集来说,给每个字符预留的最大字节数,如 latin1 是 1 字节,utf-8 就是 3 Byte,前面说过,在utf-8里,拉丁字符使用 1 字节,欧洲和中东字符使用 2 字节,亚洲语言的字符使用 3 字节,所以 utf-8 给每个字符预留 3 字节。

对于字符集来说,大部分时候用默认的 utf-8 就行了。但有时,我们可以通过更改字符集来减少空间占用,例如,我们某个特定的应用(对应的数据库)/特定表/特定列是只能输入英文字符的,那如果将该列的字符集从 utf-8 改为 latin1,占用空间就会缩小到原来的 1/3,以字段类型为 CHAR(10)(固定预留10个字符)且有 1 百万条记录为例,占用空间就会从约 30MB 减到 10MB。接下来讲如何用菜单和代码方式更改库/表/列的字符集。

  • 菜单方式更改字符集

右键 sql_store2 数据库,点击 Schema Inspector,可以查看整个数据库以及各表各列的字符集和排序规则,Schema Inspector 也能查看该数据库的主键外键、视图、触发器、储存程序、事务、函数等各方面情况

要修改库或者表和列的字符集,直接点开库或者表的设计模式(扳手按钮)在里面选择更改即可,一般我们会让表和列的字符集和整个库保持一致,毕竟一个应用要不然是国际化的要不然就不是。

  • 代码方式更改字符集

总的来说就是将设置字符集的语句 CHARACTER SET 字符集名 加在之前那些创建/更改数据库/表/列语句的合适位置即可

1.在创建或修改数据库时设置或修改数据库的字符集

CREATE/ALTER DATABASE db_name 
    CHARACTER SET latin1

2.在创建或修改表时设置或修改表的字符集

CREATE/ALTER TABLE table1
(……) 
CHARACTER SET latin1

3.在创建或修改表时设置或修改列的字符集
就是将 CHARACTER SET latin1 加在列设置语句的字段类型和字段性质之间

CREATE TABLE IF NOT EXISTS customers
(
    customer_id INT PRIMARY KEY AUTO_INCREMENT,
    first_name VARCHAR(50) CHARACTER SET latin1 NOT NULL, 
    points INT NOT NULL DEFAULT 0,
    email VARCHAR(255) NOT NULL UNIQUE
)USE sql_store2;
ALTER TABLE customers
    MODIFY first_name VARCHAR(50) CHARACTER SET latin1 NOT NULL,
    ADD    last_name  VARCHAR(50) CHARACTER SET latin1 NOT NULL AFTER first_name;

存储引擎

在MySQL中我们有若干种储存引擎,储存引擎决定了我们数据的储存方式以及可用的功能

展示可用的储存引擎:

SHOW ENGINES;

储存引擎有很多,我们真正需要知道只有两个:MyISAM 和 InnoDB

MyISAM 是曾经很流行的引擎,但自 MySQL5.5 之后,默认引擎就改为 InnoDB了,InnoDB支持更多的功能特性,包括事务、外键等等,所以最好使用 InnoDB

引擎是表层级的设置,每个表都可以设置不同的引擎(虽然这没必要)

外键是十分重要的,它可以增加引用一致性/完整性(referential integrity),如果我们有一个老数据库的引擎是MyISAM,我们想要给它设置外键,就必须要将其引擎升级为InnoDB,可以在表的设计模式里选择更改,也可以用修改表的代码:

ALTER TABLE customers
ENGINE = InnoDB;
  • 改变引擎是一个代价极高(expensive)的操作,它会重建整个表,在此期间无法方法访问数据。所以,除非有特殊的理由,不然不要在生产环境中改变储存引擎

【十四章】高效的索引

介绍

这一章我们来看提高性能的索引,索引对大型和高并发数据库非常有用,因为它可以显著提升查询的速度

这一章我们将学习关于索引的一切,它们是如何工作的,以及我们如何创造索引来提升查询速度,学习和理解这一章对于程序员和数据库管理员十分重要

  • 准备 :打开 load_1000_customers.sql 并运行,该文件会向 sql_store 库的 customers表插入上千条记录,这样我们就能看出索引对查询效率的影响

索引

  • 原理和作用

以寻找所在州(state)为 ‘CA’ 的顾客为例,如果没索引,MySQL 就必须扫描筛选所有记录。索引,就好比书籍最后的那些关键词索引一样,按字母排序,这样就能按字母迅速找到需要的关键词所在的页数,类似的,对 state 字段建立索引时,其实就是把 state 列单独拿出来分类排序并建立与原表顾客记录的对应关系,然后就可以通过该索引迅速找到所在州为 ‘CA’ 的顾客

另一方面,索引会比原表小得多,通常能够储存在内存中,而从内存读取数据总是比从硬盘读取快多了,这也会提升查询速度

如果数据量比较小,几百几千这种,没必要用索引,但如果是上百万的数据量,有无索引对查询效率的影响就很大了

  • 注意

但建立索引也是有代价的,首先索引会占用内存,其次它会降低写入速度,因为每次修改数据时都会自动重建索引。所以不要对整个表建立索引,而只是针对关键的查询建立索引。

  • 简化

严格来讲,应该用二叉树来描述索引,但只是为了学习如何操作索引的话没必要理解二叉树,所以这节课简化为用表格来展示索引以降低理解难度

创建索引

  • 案例

接着上面的例子,假设查询 ‘CA’ 的顾客,为了查看查询操作的详细信息,前面加上 EXPLAIN 关键字

注意这里只选择 customer_id 是有原因的,之后会解释

EXPLAIN SELECT customer_id 
FROM customers WHERE state = 'CA';

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第24张图片

得到很多信息,目前我们只关注 type 和 rows

type 是 ALL 而 rows 是 1010 行,说明在没有索引的情况下,MySQL扫描了所有的记录。可用下面的语句确认customers表总共就是1010条记录

SELECT COUNT(*) FROM customers;
-- 1010

现在创建索引,索引名习惯以idx或ix做前缀,后面的名字最好有意义,不要别取 idx_1、idx_2 这种没人知道是什么意思的名字

再次运行加上 EXPLAIN 的解释性查询语句

EXPLAIN SELECT customer_id FROM customers WHERE state = 'CA';

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第25张图片
这次显示 type 是 ref 而 rows 只有 112,扫描的行数显著减少,查询效率极大提升。

另外,注意 possible keys 和 key 代表了 MySQL 找到的执行此查询可用的索引(之后会看到,可能不止一个)以及最终实际选择的最优的索引

  • 练习

解释性查询积分过千的顾客id,建立索引后再来一次并对比两次结果

EXPLAIN SELECT customer_id 
FROM customers WHERE points > 1000;

CREATE INDEX idx_points ON customers (points);

EXPLAIN SELECT customer_id 
FROM customers WHERE points > 1000;

建立索引后的查询 type 为 range,表明我们查询的是一个取值范围的记录,扫描的行数 rows 从 1010 降为了 529,减了一半左右

  • 小结

解释性查询是在查询语句前加上 EXPLAIN
创建索引的语法:

CREATE INDEX 索引名(通常是 idx_列名) ON 表名 (列名);

查看索引

  • 实例1

查看 customers 表的索引:

SHOW INDEXES IN customers;
-- SHOW INDEXES IN 表名

在这里插入图片描述
可以看到有三个索引,第一个是 MySQL 为主键 customer_id 创建的索引 PRIMARY,被称作clustered index 聚合索引,每当我们为表创建主键时,MySQL 就会自动为其创建索引,这样就能快速通过主键(通常是某id)找到记录。后两个是我们之前手动为 state 和 points 字段建立的索引 idx_state 和 idx_points,它们是 secondary index 从属索引,MySQL 在创建从属索引时会自动为其添加主键列,如每个 idx_points 索引的记录有两个值:客户的积分points 和对应的客户编号 customer_id,这样就可以通过客户积分快速找到对应的客户记录

索引查询表中还列示了索引的一些性质,其中:
Non_unique 是否是非唯一的,即是否是可重复的、可相同的,一般主键索引是0,其它是1
Column_name 表明索引建立在什么字段上
Collation 是索引内数据的排序方式,其中A是升序,B是降序
Cardinality(基数)表明索引中独特值/不同值的数量,如 PRIMARY 的基数就是 1010,毕竟每条记录都都有独特的主键,而另两个索引的基数都要少一些,从之前 Non_unique 为 1 也可以看得出来 state 和 points 有重复值,这里的基数可以更明确看到 state 和 points 具体有多少种不同的值
Index_type 都是BTREE(二叉树),之前说过MySQL里大部分的索引都是以二叉树的形式储存的,但 Mosh 把它们表格化了以使其更清晰易懂

  • 注意

Cardinality 这里只是近似值而非精确值,要先用以下语句重建顾客表的统计数据:

ANALYZE TABLE customers;

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第26张图片
然后再用 SHOW INDEXES IN customers; 得到的才是精确的 Cardinality 基数

  • 实例2

查看orders表的索引

SHOW INDEXES IN orders;

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第27张图片
总共有四个: PRIMARY、fk_orders_customers_idx、fk_orders_shippers_idx、fk_orders_order_statuses_idx,第一个是建立在主键order_id上的聚合索引,后三个是建立在三个外键 customer_id、shipper_id、status 上的从属索引。

当我们建立表间链接时,MySQL会自动为外键添加索引,这样就能快速就行表连接(join tables)了

  • 另外

还可以通过菜单方式查看某表中的索引,在左侧导航栏里 customers 表的子文件里就有一个 indexes 文件夹,点击里面的索引可以看到该索引的若干属性,其中 visible(可见性) 表示其是否可用(enabeled)
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第28张图片

前缀索引

当索引的列是字符串时(包括 CHAR、VARCHAR、TEXT、BLOG),尤其是当字符串较长时,我们通常不会使用整个字符串而是只是用字符串的前面几个字符来建立索引,这被称作 Prefix Indexes 前缀索引,这样可以减少索引的大小使其更容易在内存中操作,毕竟在内存中操作数据比在硬盘中快很多

  • 案例

为 customers 表的 last_name 建立索引并且只使用其前20个字符:

CREATE INDEX idx_lastname ON customers (last_name(20));

这个字符数的设定对于 CHAR 和 VARCHAR 是可选的,但对于 TEXT 和 BLOG 是必须的

  • 最佳字符数

可最佳字符数如何确定呢?太多了会使得索引太大难以在内存中运行,太少又达不到筛选的效果,比如,只用第一个字符建立索引,那如果查找A开头的名字,索引可能会返回10万个结果,然后就必须对这10万个结果逐条筛选。

可以利用 COUNT、DISTINCT、LEFT 关键词和函数来测试不同数目的前缀字符得到的独特值个数,目标是用尽可能少的前缀字符得到尽可能多的独特值个数:

SELECT 
    COUNT(DISTINCT LEFT(last_name, 1)),
    COUNT(DISTINCT LEFT(last_name, 5)),
    COUNT(DISTINCT LEFT(last_name, 10))
FROM customers

结果是 ‘25’, ‘966’, ‘996’

可见从前1个到前5个字符,效果提升是很显著的,但从前5个到前10个字符,所用的字符数增加了一倍但识别效果只增加了一点点,再加上5个字符已经能识别出966个独特值,与1010的记录总数相去不远了,所以可以认为用前5个字符来创建前缀索引是最优的

全文索引

  • 案例

运行 create-db-blog.sql 得到 sql_blog 数据库,里面只包含一个 posts 表(文章表),每条记录就是一篇文章的编号 post_id、标题 title、内容 body 和 发布日期 data_published

假设我们创建了一个博客网站,里面有一些文章,并存放在上面这个 sql_blog 数据库里,如何让用户可以对博客文章进行搜索呢?

假设,用户想搜索包含 react 及 redux(两个有关前端的重要的 javascript 库)的文章,如果用 LIKE 操作符进行筛选:

USE sql_blog;
SELECT *
FROM posts
WHERE title LIKE '%react redux%'
    OR body LIKE '%react redux%';

有两个问题:

1.在没有索引的情况下,会对所有文本进行全面扫描,效率低下。如果用上节课讲的前缀索引也不行,因为前缀索引只包含标题或内容开头的若干字符,若搜索的内容不在开头,以依然需要全面扫描
2.这种搜索方式只会返回完全符合 ‘%react redux%’ 的结果,但我们一般搜索时,是希望得到包含这两个单词的任意一个或两个,任意顺序,中间有任意间隔的所有相关结果,即 google 式的模糊搜索

我们通过建立 Fulltext Index 全文索引 来实现这样的搜索

全文索引对相应字符串列的所有字符串建立索引,它就像一个字典,它会剔除掉in、the这样无意义的词汇并记录其他所有出现过的词汇以及每一个词汇出现过的一系列位置

建立全文索引:

CREATE FULLTEXT INDEX idx_title_body ON posts (title, body);

利用全文索引,结合 MATCH 和 AGAINST 进行 google 式的模糊搜索:

SELECT *
FROM posts
WHERE MATCH(title, body) AGAINST('react redux');

注意MATCH后的括号里必须包含全文索引 idx_title_body 建立时相关的所有列,不然会报错

还可以把 MATCH(title, body) AGAINST(‘react redux’) 包含在选择语句里, 这样还能看到各结果的 relevance score 相关性得分(一个 0 到 1 的浮点数),可以看出结果是按相关行降序排列的

SELECT *, MATCH(title, body) AGAINST('react redux')
FROM posts
WHERE MATCH(title, body) AGAINST('react redux');

全文检索有两个模式:自然语言模式和布林模式,自然语言模式是默认模式,也是上面用到的模式。布林模式可以更明确地选择包含或排除一些词汇(google也有类似功能),如:

1.尽量有 react,不要有 redux,必须有 form

……
WHERE MATCH(title, body) AGAINST('react -redux +form' IN BOOLEAN MODE);

2.布林模式也可以实现精确搜索,就是将需要精确搜索的内容再用双引号包起来

……
WHERE MATCH(title, body) AGAINST('"handling a form"' IN BOOLEAN MODE);
  • 小结 :全文索引十分强大,如果你要建一个搜索引擎可以使用它,特别是要搜索的是长文本时,如文章、博客、说明和描述,否则,如果搜索比较短的字符串,比如名字或地址,就使用前置字符串

组合索引

  • 查看 customers 表中的索引:
USE sql_store;
SHOW INDEXES IN customers;

目前有 PRIMARY、idx_state、idx_points 三个索引

之前只是对 state 或 points 单独进行筛选查询,现在我们要用 AND 同时对两个字段进行筛选查询,例如,查询所在州为 ‘CA’ 而且积分大于 1000 的顾客id:

EXPLAIN SELECT customer_id
FROM customers
WHERE state = 'CA' AND points > 1000;

在这里插入图片描述
会发现 MySQL 在 idx_state、idx_points 两个候选索引最终选择了 idx_state,总共扫描了 112 行记录

相对于无索引时要扫描所有的 1010 条记录,这要快很多,但问题是,idx_state 这种单字段的索引只做了一半的工作:它能帮助快速找到在 ‘CA’ 的顾客,但要寻找其中积分大于1000的人时,却不得不到磁盘里进行原表扫描(因为 idx_state 索引里并没有积分信息),如果加州有一百万人的话这就会变得很慢。

所以我们要建立 state 和 points 的组合索引:(两者的顺序其实很重要,下节课讲)

CREATE INDEX idx_state_points ON customers (state, points);

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第29张图片

再次运行之前的查询,发现在 idx_state、idx_points、idx_state_points 三个候选索引中 MySQL 发现组合索引 idx_state_points 对我们要做的查询而言是最优的因而选择了它,最终扫描的行数由 112 降到了 58,速度确实提高了

之后会看到组合索引也能提高排序的效率

我们可以用 DROP 关键字删除掉那两个单列的索引

DROP INDEX idx_state ON customers;
DROP INDEX idx_points ON customers;
  • 注意

新手爱犯的错误是给表里每一列都建立一个单独的索引,再加上 MySQL 会给每个索引自动加上主键,这些过多的索引会占用大量储存空间,而且数据每次数据更新都会重建索引,所以过多的索引也会拖慢更新速度

但实际中更多的是用到组合索引,所以不应该无脑地为每一列建立单独的索引而应该依据查询需求来建立合适的组合索引,一个组合索引最多可组合 16 列,但一般 4 到 6 列的组合索引是比较合适的,但别把这个数字当作金科玉律,总是根据实际的查询需求和数据量来考虑

组合索引的列顺序

  • 组合索引的原理

对于组合索引,一定要从原理上去理解,比如 idx_state_lastname, 它是先对 state 建立分类排序的索引,然后再在同一州(如 ‘CA’)内建立 lastname 的分类排序索引,所以这个索引对两类查询有效:

  1. 单独针对 state 的查询(快速找到州)

  2. 同时针对 state 和 lastname 的查询(快速找到州再在该州内快速找到该姓氏)

但 idx_state_lastname 对单独针对 lastname 的查询无效,因为它必须在每个州里去找该姓氏,相当于全面扫描了。所以如果单独查找某州的需求存在的话,就还需要另外为其单独建一个索引 idx_state

基于对以上原理的理解,我们在确定组合索引的列顺序时有两个指导原则:
1.将最常使用的列放在前面
在建立组合索引时应该将最常用的列放在最前面,这样的索引会对更多的查询有效

2.将基数(Cardinality)最大/独特性最高的列放在前面

因为基数越大/独特性越高,起到的筛选作用越明显,能够迅速缩小查询范围。比如如果首先以性别来筛选,那只能将选择范围缩小到一半左右,但如果先以所在州来筛选,以总共 20 个州且每个州人数相当为例,那就会迅速将选择范围缩小到 1/20

但最终仍然要根据实际的查询需求来决定,因为实际查询的筛选条件不一定能完全利用相应列的全部独特性,举例说明如下:

首先,为了比较的目的,针对 state 和 last_name 两列,同时建立两种顺序的索引 idx_state_lastname 和 idx_lastname_state

last_name 的独特性肯定是比 state 的独特性高的,可以用以下语句验证:

SELECT 
    COUNT(DISTINCT state),
    COUNT(DISTINCT last_name)
FROM customers;
-- 48, 996

所以如果查询语句的筛选条件为 WHERE state = ‘CA’ AND last_name = ‘Smith’,这种目标是特定州和特定姓氏的的查询能够充分利用各列独特性,肯定用 idx_lastname_state 先筛选姓氏能更快缩小范围提高效率

但如果进行姓氏的模糊查询,如,要查询 在加州 且 姓氏以A开头 的顾客,我们可以用 USE INDEX (索引名) 子句来强制选择使用的索引,对两种索引的查询结果进行对比:

EXPLAIN SELECT customer_id
FROM customers
USE INDEX (idx_state_lastname)
-- 注意括号
-- 注意位置:FROM之后WHERE之前
WHERE state = 'CA' AND last_name LIKE 'A%';
-- 7 rows

EXPLAIN SELECT customer_id
FROM customers
USE INDEX (idx_lastname_state)
WHERE state = 'CA' AND last_name LIKE 'A%';
-- 40 rows

会发现 idx_state_lastname 反而扫描的行数更少,效率更高,把查找的 state 换为 ‘NY’ 也是一样。这是因为 last_name 的筛选条件是 ‘LIKE’ 而不是 ‘=’,约束性更小(less restrictive),更开放(more open),并没有充分利用姓氏列的高独特性,对于这种针对姓氏的模糊查找,先筛选州反而能更快缩小范围提高效率,所以 idx_state_lastname 更有效

当然,如果对两列都进行模糊查询,如查询语句的筛选条件变为 WHERE state LIKE ‘A%’ AND last_name LIKE ‘A%’,可以想得到,验证也能证实,idx_lastname_state 会重新胜出

总之,不仅要考虑各列的独特性高低,也要考虑常用的查询是否能充分利用各列的独特性,两者结合来决定组合索引里的排序,不确定就测试对比验证,所以,第二条原则也许应该改为将常用查询实际利用到的独特性程度最高的列放在前面

以上面的例子来说,如果业务中常用查询是特定州和特定姓(很可能)或者模糊州和模糊姓(不太可能),就用 idx_lastname_state 而 舍弃 idx_state_lastname(不十分必要的索引不要保留,浪费空间和拖慢更新),相反,如果常用查询是特定州和模糊姓,就用 idx_state_lastname 而舍 idx_lastname_state

假设后一种情况成立,即只保留 idx_state_lastname,还要注意一点是,如前所述, idx_state_lastname 对 单独针对 last_name 的查询无效,如果有这样的查询需要就还要另外为该列建一个可用的索引 idx_state

  • 思想

总之,任何一个索引都只对一类查询有效而且对特定的查询内容最高效,我们要现实一些,要去最优化那些性能关键查询,而不是所有可能的查询(optimize performance critical queries, not all queries in the world)

能加速所有查询的索引是不存在的,随着数据库以及查询需求的增长和扩展,我们可能需要建立不同列的不同顺序的组合索引

索引无效时

有时你有一个可用的索引,但你的查询却未能充分利用它,这里我们看两种常见的情形:

  • 案例1

查找在加州积分大于1000的顾客id

注意之前查询的筛选条件都是与(AND),这里是或(OR)

USE sql_store;
EXPLAIN SELECT customer_id FROM customers
WHERE state = 'CA' OR points > 1000;

发现虽然显示 type 是 index,用的索引是 idx_state_points,但扫描的行数却是 1010 rows

因为这里是 或(OR) 查询,在找到加州的顾客后,仍然需要在每个州里去找积分大于 1000 的顾客,所以要扫描所有的 1010 条索引记录,即进行了 全索引扫描(full index scan)。当然全索引扫描比全表扫描要快一点,因为前者只有三列而后者有很多列,前者在内存里进行而后者在硬盘里进行,但 全索引扫描 依然说明索引未被有效利用,如果是百万条记录还是会很慢

我们需要以尽可能充分利用索引地方式来编写查询,或者说以最迎合索引的方式编写查询,就这个例子而言,可另建一个 idx_points 并将这个 OR 查询改写为两部分,分别用各自最合适的索引,再用 UNION 融合结果(注意 UNION 是自动去重的,所以起到了和 OR 相同的作用,如果要保留重复记录就要用 UNION ALL,这里显然不是)

CREATE INDEX idx_points ON customers (points);

EXPLAIN

        SELECT customer_id FROM customers
        WHERE state = 'CA'

    UNION

        SELECT customer_id FROM customers
        WHERE points > 1000;

结果显示,两部分查询中,MySQL 分别自动选用了对该查询最有效的索引 idx_state_points 和 idx_points,扫描的行数分别为 112 和 529,总共 641 行,相比于 1010 行有很大的提升

  • 案例2

查询目前积分增加 10 分后超过 2000 分的顾客id:

EXPLAIN SELECT customer_id FROM customers
WHERE points + 10 > 2010;
-- key: idx_points
-- rows: 1010

又变成了 1010 行全索引扫描,因为 column expression 列表达式(列运算) 不能最有效地使用索引,要重写运算表达式,独立/分离此列(isolate the column)

EXPLAIN SELECT customer_id FROM customers
WHERE points > 2000;
-- key: idx_points
-- rows: 4

直接从1010行降为4行,效率提升显著。所以想要 MySQL 有效利用索引,就总是在表达式中将列独立出来

使用索引排序

之前创建的索引杂七杂八的太多了,只保留 idx_lastname, idx_state_points 两个索引,把其他的 drop 了

USE sql_store;
SHOW INDEXES IN customers;
DROP INDEX idx_points ON customers;
DROP INDEX idx_state_lastname ON customers;
DROP INDEX idx_lastname_state ON customers;
SHOW INDEXES IN customers;

可以用 SHOW STATUS; 来查看Mysql服务器使用的众多变量,其中有个叫 ‘last_query_cost’ 是上次查询的消耗值,我们可以用 LIKE 关键字来筛选该变量,即: SHOW STATUS LIKE ‘last_query_cost’;

按 state 给 customer_id 排序(下节课讲为什么是 customer_id),再按 first_name 给 customer_id 排序,对比:

EXPLAIN SELECT customer_id 
FROM customers
ORDER BY state;
-- type: index, rows: 1010, Extra: Using index
SHOW STATUS LIKE 'last_query_cost';  
-- cost: 102.749

EXPLAIN SELECT customer_id 
FROM customers
ORDER BY first_name;
-- type: ALL, rows: 1010, Extra: Using filesort 
SHOW STATUS LIKE 'last_query_cost';  
-- cost: 1112.749

注意查看 Extra 信息,非索引列排序常常用的是 filesort 算法,从 cost 可以看到 filesort 消耗的资源几乎是用索引排序的 10 倍,这很好理解,因为索引就是对字段进行分类和排序,等于是已经提前排好序了

所以,不到万不得已不要给非索引数据排序,有可能的话尽量设计好索引用于查询和排序

但如之前所说,特定的索引只对特定的查询(WHERE 筛选条件)和排序(ORDER BY 排序条件)有效,这还是要从原理上理解:

以 idx_state_points 为例,它等于是先对 state 分类排序,再在同一个 state 内对 points 进行分类排序,再加上 customer_id 映射到相应的原表记录

所以,索引 idx_state_points 对于以下排序有效:

ORDER BY state
ORDER BY state, points
ORDER BY points WHERE state = 'CA'
/* 第3个是 “对加州范围内的顾客按积分排序”,
为何有效,从原理上也是很好理解的 */

相反,idx_state_points 对以下索引无效或只是部分有效,这些都是会部分或全部用到 filesort 算法的:

ORDER BY points
ORDER BY points, state
ORDER BY state, first_name, points

总的来说一个组合索引对于按它的组合列 “从头开始并按顺序” 的 WHERE 和 ORDER BY 子句最有效

对于 ORDER BY 子句还有一个问题是升降序,索引本身是升序的,但可以 Backward index scan 倒序索引扫描,所以它对所有同向的(同升序或同降序)的 ORDER BY 子句都有效,但对于升降序混合方向的 ORDER BY 语句则不够有效,还是以 idx_state_points 为例,对以下 ORDER BY 子句有效,即完全是 Using index 且 cost 在一两百左右:

ORDER BY state 
ORDER BY state DESC
ORDER BY state, points
ORDER BY state DESC, points DESC

但下面这两种就不能充分利用 idx_state_points,会部分使用 filesort 算法且 cost > 1000

ORDER BY state, points DESC
ORDER BY state DESC, points
  • 总结

特定索引只对特定查询和排序最有效,而且这些从索引的原理上都很好理解

建立什么索引取决于查询和排序需求,而查询和排序也要尽量去迎合索引以尽可能提高效率

覆盖索引

这节课讲为什么之前 SELECT 选择子句里只选 customer_id 这一个字段

  • 实例

以 state 排序查询 customers 表,每次 SELECT 不同的列并对比结果:

USE sql_store;

-- 1. 只选择 customer_id:
EXPLAIN SELECT customer_id FROM customers
ORDER BY state;
SHOW STATUS LIKE 'last_query_cost';

-- 2. 选择 customer_id 和 state:
EXPLAIN SELECT customer_id, state FROM customers
ORDER BY state;
SHOW STATUS LIKE 'last_query_cost';

-- 3. 选择所有字段:
EXPLAIN SELECT * FROM customers
ORDER BY state;
SHOW STATUS LIKE 'last_query_cost';

会验证发现前两次是完全 Using index 而且 cost 均只有两百左右,而第3种是 Using filesort 而且 cost 超过一千,这从 idx_state_points 的原理上也很好理解:

前面提到过,从属索引除了包含相关列还会自动包含主键列(通常是某种id列)来和原表中的记录建立对应关系,所以 组合索引 idx_state_points 中包含三列:state、points 以及 customer_id,所以如果 SELECT 子句里选择的列是这三列中的一列或几列的话,整个查询就可以在只使用索引不碰原表的情况下完成,这叫作覆盖索引(covering index),即索引满足了查询的所有需求所以全程不需要使用原表,这是最快的

  • 总结

设计索引时,先看 WHERE 子句,看看最常用的筛选字段是什么,把它们包含在索引中,这样就能迅速缩小查找范围,其次查看 ORDER BY 子句,看看能不能将这些列包含在索引中,最后,看看 SELECT 子句中的列,如果你连这些也包含了,就得到了覆盖索引,MySQL 就能只用索引就完成你的查询,实现最快的查询速度

维护索引

索引维护注意三点:

1.重复索引(duplicate index):

MySQL 不会阻止你建立重复的索引,所以记得在建立新索引前前检查一下已有索引。验证后发现,具体而言:

同名索引是不被允许的:

CREATE INDEX idx_state_points ON customers (state, points);
-- Error Code: 1061. Duplicate key name 'idx_state_points'

对相同列的相同顺序建立不同名的索引,5.7 版本暂时允许,但 8.0 版本不被允许:

CREATE INDEX idx_state_points2 ON customers (state, points);
/* warning(s): 1831 Duplicate index 'idx_state_points2' 
defined on the table 'sql_store.customers'. 
This is deprecated (不赞成;弃用;不宜用) 
and will be disallowed in a future release. */

2.冗余索引(redundant index):

比如,已有 idx_state_points,那 idx_state 就是冗余的了,因为所有 idx_state 能满足的筛选和排序需求 idx_state_points 都能满足

但当已有 idx_state_points 时,idx_points 和 idx_points_state 并不是冗余的,因为它们可以满足不同的筛选和排序需求

3.无用索引(unused index):

这个很好理解,就是那些常用查询、排序用不到的索引没必要建立,毕竟索引是会占用空间和拖慢数据更新速度的

所以一再强调 考虑实际需求 的重要性

  • 小结

要做好索引管理:

1.在新建索引时,总是先查看一下现有索引,避免重复、冗余、无用的索引,这是最基本的要求。
2.其次,索引本身要是要占用空间和拖慢更新速度的所以也是有代价的,而且不同索引对不同的筛选、排序、查询内容的有效性不同,因此,理想状态下,索引管理也应该是个根据业务查询需求需要不断去权衡成本效益,抓大放小,迭代优化的过程

性能最佳实践 (文档)

选择组合索引,而不是多个单列索引。
避免 SELECT *。大多数时候,选择所有列会忽略索引并返回您可能不需要的不必要的列。这会给数据库服务器带来额外负载。
避免使用前导通配符的LIKE 表达式(eg.“%name”) 。

【十五章】保护数据库

介绍

之前都是介绍本地数据库而你自己就是数据库的唯一用户,所以不必考虑安全问题。

但实际业务中数据库大多放在服务器里,你必须妥善处理好用户账户和权限的问题,合理决定谁拥有什么程度的权限以防止对数据的破坏和误用

这一章,我们学习如何增强数据库的安全性

创建一个用户

  • 实例

设置一个新用户,用户名为 john,可以选择用 @ 来限制他可以从哪些地方访问数据库

CREATE USER john  
-- 无限制,可从任何位置访问 

CREATE USER john@127.0.0.1;  
-- 限制ip地址,可以是特定电脑,也可以是特定网络服务器(web server)

CREATE USER john@localhost;  
-- 限制主机名,特定电脑

CREATE USER john@'codewithmosh.com';  
-- 限制域名(注意加引号),可以是该域名内的任意电脑,但子域名则不行 

CREATE USER john@'%.codewithmosh.com'; 
-- 加上了通配符,可以是该域名及其子域名下的任意电脑

可以用 IDENTIFIED BY 来设置密码

CREATE USER john IDENTIFIED BY '1234' 
-- 可从任何地方访问,但密码为 '1234'
-- 该密码只是为了简化,请总是用很长的强密码

查看用户

假设上节课我们最后用 CREATE USER john 创建了一个新账户 john,无限制,无密码

用两种方式可以查看MySQL服务器上的所有用户:

  • 法1

在一个自动创建的名为 mysql 的数据库(导航里似乎是隐藏了看不到)里,有个user表记录了帐户信息,查询即可:

SELECT * FROM mysql.user;

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第30张图片
可以看到罗列出的所有用户,除了 john 和 root 帐户,还有几个 MySQL 内部自动建立和使用的帐户(用户名均为 mysql.*)

Host 字段表示用户可以从哪里访问数据库,john 是一个通配符 %,表示他可以从任意位置访问数据库,其它几个用户都是 localhost,表示都只能从本电脑访问数据库,不能从远程链接访问

后面的一系列字段都是各种权限的配置,后面会细讲

  • 法2

也可以直接点击左侧导航栏的 Administration 标签页里的 Users and Privileges,同样可以查看服务器上的用户列表和信息
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第31张图片

删除用户

  • 案例

假设之前创建了 bob 的帐户,允许在 codewithmosh.com 域名内访问数据库,密码是 ‘1234’:

CREATE USER bob@codewithmosh.com IDENTIFIED BY '1234';

之后 bob 离开了组织,就应该删除它的账户,注意依然要在用户名后跟上 @主机名(host)

DROP bob@codewithmosh.com;
  • 最佳实践 :记得总是及时删除掉组织中那些不用的账户

修改密码

人们时常忘记自己的密码,作为管理员,你时常被要求修改别人的或自己的密码,这很简单,有两种方法:

  • 法1

用 SET 语句

SET PASSWORD FOR john = '1234';
-- 修改john的密码

SET PASSWORD = '1234';  
-- 修改当前登录账户的密码
  • 法2

用导航面板:还是在 Administration 标签页 Users and Privileges 里,点击用户 john,可修改其密码,最后记得点 Apply 应用。另外还可以点击 Expire Password 强制其密码过期,下次用户登录必须修改密码。

权限许可

创建用户后需要分配权限,最常见的是两种情形:

  • 常见情形1:对于网页或桌面应用程序的使用用户,给予其读写数据的权限,但禁止其增删表或修改表结构

例如,我们有个叫作 moon 的应用程序,我们给这个应用程序建个用户帐户名为 moon_app (app指明这代表的是整个应用程序而非一个人)

CREATE USER moon_app IDENTIFIED BY '1234';

给予其对 sql_store 数据库增删查改以及执行储存过程(EXECUTE)的权限,这是给终端用户常用的权限配置

GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE
-- GRANT子句表明授予哪些权限
ON sql_store.* 
-- ON子句表明可访问哪些数据库和表
-- ON sql_store.*代表可访问某数据库所有表,常见设置
-- 只允许访问特定表则是 ON sql_store.customers,不常见
TO moon_app;
-- 表明授权给哪个用户
-- 如果该用户有访问地址限制,也要加上,如:@ip地址/域名/主机名

这样就完成了权限配置

我们来测试一下,先用这个新账户 moon_app 建立一个新连接(点击 workbench 主页 MySQL connections 处的加号按钮):

将连接名(Connection Name)设置为:moon_app_connection; 主机名(Hostname)和端口(Post)是自动设置的,分别为:127.0.0.1 和 3306; 用户名(Username)和密码(Password)输入建立时的设置的用户名和密码:moon_app 和 1234

在新连接里测试,发现果然只能访问 sql_store 数据库而不能访问其他数据库(导航面板也只会显示 sql_store 数据库)
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第32张图片
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第33张图片
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第34张图片
然后点击 test connection
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第35张图片
然后 ok
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第36张图片
就可以用这个用户账户来连接了
SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第37张图片

USE sql_store;
SELECT * FROM customers;

USE sql_invoicing;
/* Error Code: 1044. Access denied for user 
'moon_app'@'%' to database 'sql_invoicing' */
  • 常见情形2. 对于管理员,给予其一个或多个数据库乃至整个服务器的管理权限,这不仅包括表中数据的读写,还包括增删表、修改表结构以及创建事务和触发器等

可以谷歌 MySQL privileges,第一个结果就是官方文档里罗列的所有可用的权限及含义,其中的 ALL 是最高权限,通常我们给予管理员 ALL 权限

GRANT ALL
ON sql_store.*
-- 如果是 *.*,则代表所有数据库的所有表或整个服务器
TO john;

查看权限

查看以给出的权限仍然有 SQL语句 和 导航菜单 两种方法:

  • 法1

查看 john 的权限

SHOW GRANTS FOR john;

去掉 FOR john,即查看当前登录帐户的权限

SHOW GRANTS;

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第38张图片
可以看到,当前root帐户拥有最高权限,除了 ALL 的所有权限外,还有另外一个叫 PROXY 的权限。感觉 root 帐户和 john 这样的 DBA 帐户的区别就跟群主和群管理员的区别一样

  • 法2

依然可以通过导航栏 Administration 标签页里的 Users and Privileges 来查看各用户的权限,其中 Administrative Roles 展示了该用户的角色Roles, 如 DBA,有很多可选项,感觉像是预设权限组合)和全局权限(Global Privileges), 而 Schema Privileges 则显示该用户在特定数据库的权限,因为 root 和 john 的权限是针对所有数据库的,所以没有特定数据库权限而 moon_app 就显示有针对 sql_store 数据库的权限,所有这些都是可以选择和更改的,记得最后要点Apply应用

SQL进阶教程 | 史上最易懂SQL教程 5小时零基础成长SQL大师(5)_第39张图片

撤销权限

有时你可能发现给某人的权限给错了,或者给某人的权限过多导致他滥用权限,这节课学习如何收回权限,很简单,和给予权限很类似

  • 案例

之前说过,应该只给予 moon_app 读写 sql_store 数据库的表内数据以及执行储存过程的权限,假设我们错误的给予了其创建视图的权限:

GRANT CREATE VIEW 
ON sql_store.*
TO moon_app;

要收回此权限,只用把语句中的 GRANT 变 REVOKE,把 TO 变 FROM 就行了,就这么简单:

REVOKE CREATE VIEW 
ON sql_store.*
FROM moon_app;

你可能感兴趣的