《SQL必知必会》学习随笔-进阶篇
前言
这是《SQL必知必会》学习随笔系列的进阶篇,承接基础篇的内容,主要聚焦于数据库性能优化相关的知识点。
进阶篇的核心内容包括:
- 数据库调优:从多个维度分析如何提升数据库性能
- 范式与反范式:理解数据库设计原则及其权衡
- 索引原理与应用:深入理解索引的底层实现和使用场景
- 锁机制:掌握不同粒度和类型的锁
- 性能优化实战:使用慢查询日志、EXPLAIN等工具定位和解决性能问题
如果你还没有阅读基础篇,建议先从基础篇开始,那里介绍了SQL的基本概念和MySQL的执行流程。
数据库调优
调优方向
- 用户的反馈
- 日志分析
- 服务器资源使用监控
- 数据库内部状况监控
调优维度
选择适合的DBMS
列式存储数据库可以大幅度降低系统的I/O,适合于分布式文件系统和OLAP,但不适用于数据需要频繁增删改的场景。
优化表设计
- 表结构要尽量遵循第三范式的原则(关于第三范式,我在后面章节会讲)。这样可以让数据结构更加清晰规范,减少冗余字段,同时也减少了在更新,插入和删除数据时等异常情况的发生。
- 如果分析查询应用比较多,尤其是需要进行多表联查的时候,可以采用反范式进行优化。反范式采用空间换时间的方式,通过增加冗余字段提高查询的效率。
- 表字段的数据类型选择,关系到了查询效率的高低以及存储空间的大小。一般来说,如果字段可以采用数值类型就不要采用字符类型;字符长度要尽可能设计得短一些。针对字符类型来说,当确定字符长度固定时,就可以采用CHAR类型;当长度不固定时,通常采用VARCHAR类型。
优化逻辑查询
SQL查询优化,可以分为逻辑查询优化和物理查询优化。逻辑查询优化就是通过改变SQL语句的内容让SQL执行效率更高效,采用的方式是对SQL语句进行等价变换,对查询进行重写。重写查询的数学基础就是关系代数。
SQL的查询重写包括了子查询优化、等价谓词重写、视图重写、条件简化、连接消除和嵌套连接消除等。
EXISTS子查询和IN子查询时,会根据小表驱动大表的原则选择适合的子查询。在WHERE子句中会尽量避免对字段进行函数运算,它们会让字段的索引失效。
假设我想对商品评论表中的评论内容进行检索,查询评论内容开头为abc的内容都有哪些,如果在WHERE子句中使用了函数,语句就会写成下面这样:
1 | SELECT comment_id, comment_text, comment_time FROM product_comment WHERE SUBSTRING(comment_text, 1,3)='abc' |
我们可以采用查询重写的方式进行等价替换:
1 | SELECT comment_id, comment_text, comment_time FROM product_comment WHERE comment_text LIKE 'abc%' |
在数据量大的情况下,第二条SQL语句的执行时间为前者的1/10。
范式
范式-3NF
这个算是数据库系统原理必学内容了,我简单说一下我的理解。
首先,从1NF -> 到 3NF 是逐级递进的, 2NF 包含了1NF的特性,而 3NF 又包含了 2NF 的特性.作为一种约束来说,1NF 对数据库的约束性最弱, 3NF 对数据库的约束最强。
在理解范式之前,需要先了解几个基本概念:
- 候选键(Candidate Key):能够唯一标识一条记录的最小属性集
- 主属性:包含在任一候选键中的属性
- 非主属性:不包含在任何候选键中的属性
- 函数依赖:如果通过属性A可以唯一确定属性B,则称B函数依赖于A,记作A→B
- 完全函数依赖:如果A→B,且A的任何真子集都不能决定B,则称B完全函数依赖于A
- 传递依赖:如果A→B,B→C,且B不依赖于A,则称C传递依赖于A
基于这些概念,三个范式可以这样理解:
1NF(第一范式):每个字段都是原子性的,不可再分。
2NF(第二范式):在1NF的基础上,非主属性完全函数依赖于候选键(消除了非主属性对候选键的部分依赖)。
3NF(第三范式):在2NF的基础上,非主属性不传递依赖于候选键(消除了非主属性对候选键的传递依赖)。
但是,3NF仍是有问题的。
3NF存在的问题:
即使满足了3NF,仍然可能存在主属性对候选键的部分依赖或传递依赖。例如,在某些情况下,一个主属性可能依赖于候选键的一部分,或者通过其他主属性传递依赖于候选键,这会导致数据冗余和更新异常。
为了解决这个问题,人们提出了一种新的范式:巴斯-科德范式,即BCNF。
BCNF(Boyce-Codd范式):在3NF的基础上消除了主属性对候选键的部分依赖或者传递依赖关系。简单来说,就是要求每个决定因素都必须是候选键。
反范式设计
范式为数据库提供了约束规则,这避免了许多问题,但这也对性能造成了一定影响。
反范式就是相对范式化而言的,允许少量的冗余,通过空间来换时间。
反范式的适用情况
当冗余信息有价值或者能大幅度提高查询效率的时候,如:
订单中的收货人信息,包括姓名、电话和地址等。每次发生的订单收货信息都属于历史快照,需要进行保存,但用户可以随时修改自己的信息,这时保存这些冗余信息是非常有必要的。
索引
简而言之,索引就是帮助数据库管理系统高效获取数据的数据结构。好比一本书的目录,它可以帮我们快速进行特定值的定位与查找,从而加快数据查询的效率。
不适用索引的场景
作为一种数据结构,索引本身也会占据存储空间.因此,在一些情况下,索引对搜素速率的优化比不上自己对性能的影响。这个时候我们就不能建立索引。
- 数据表中的数据行数比较少的情况下,比如不到1000行。
数据库本身查询速率就很高,千条数据不会对数据库产生压力,也就不需要用索引优化。
- 数据重复度大,比如高于10%的时候。
性别只分为男女。假设男女数量相同,如果想要在100万行数据中查找性别为男的数据,一旦创建了索引,你需要先访问50万次索引,然后再访问50万次数据表,
索引的分类
按属性的类型分类
聚集索引:属性为主键。
非聚集索引:属性不为主键。
聚集索引比使用非聚集索引的查询效率略高,通常使用聚集索引。
按属性的数量分类
单一索引:索引列为一列时为单一索引。
联合索引:多个列组合在一起创建的索引叫做联合索引。
联合索引的最左原则
在使用联合索引(复合索引)时,查询条件必须从索引的最左列开始,并且连续匹配,索引才能被有效利用。
当遇到范围查询(>、<、between、like)就会停止匹配。
根据这个特点,在设计索引时,应该注意:
- 区分度高的列放左边:优先将选择性(cardinality)高的列放在前面
- 等值查询在前,范围查询在后:
1 | -- 推荐顺序: (status, create_time) |
- 尽量使用覆盖索引:减少回表操作
- 避免索引列参与运算:保持索引列”纯净”
- 用 EXPLAIN 验证:
1 | EXPLAIN SELECT * FROM user WHERE a = 1 AND b = 2; |
索引的底层
不想看优化过程可以直接看B=树。
二叉树
二叉树是一种常见的树形存储结构,时间复杂度为O(log2n),它的特点是:
- 如果key大于根节点,则在右子树中进行查找;
- 如果key小于根节点,则在左子树中进行查找;
- 如果key等于根节点,也就是找到了这个节点,返回根节点即可。
但它有一个漏洞:
如果构造出来的树是链表型的,那么时间复杂度会退化为O(n).如果用二叉树作为索引的实现结构,会让树变得很高,增加硬盘的I/O次数,所以我们不使用这种结构。
B树(平衡的多路搜索树)
它的每一个节点最多可以包括M个子节点,M称为B树的阶。
- 根节点的儿子数的范围是[2,M]。
- 每个中间节点包含k-1个关键字和k个孩子,孩子的数量=关键字的数量+1,k的取值范围为[ceil(M/2), M]。
- 叶子节点包括k-1个关键字(叶子节点没有孩子),k的取值范围为[ceil(M/2), M]。
- 假设中间节点节点的关键字为:Key[1], Key[2], …, Key[k-1],且关键字按照升序排序,即Key[i]<Key[i+1]。此时k-1个关键字相当于划分了k个范围,也就是对应着k个指针,即为:P[1], P[2], …, P[k],其中P[1]指向关键字小于Key[1]的子树,P[i]指向关键字属于(Key[i-1], Key[i])的子树,P[k]指向关键字大于Key[k-1]的子树。
- 所有叶子节点位于同一层。
B+树
B+树基于B树做出了改进,主流的DBMS都支持B+树的索引方式,比如MySQL。B+树和B树的差异在于以下几点:
- 有 k 个孩子的节点就有k个关键字。也就是孩子数量=关键字数,而B树中,孩子数量=关键字数+1。
- 非叶子节点的关键字也会同时存在在子节点中,并且是在子节点中所有关键字的最大(或最小)。
- 非叶子节点仅用于索引,不保存数据记录,跟记录有关的信息都放在叶子节点中。而B树中,非叶子节点-既保存索引,也保存数据记录。
- 所有关键字都在叶子节点出现,叶子节点构成一个有序链表,而且叶子节点本身按照关键字的大小从小到大顺序链接。
B+树和B树有个根本的差异在于,B+树的中间节点并不直接存储数据。这样的好处是:
B+树查询效率更稳定。因为B+树每次只有访问到叶子节点才能找到对应的数据,而在B树中,非叶子节点也会存储数据,这样就会造成查询效率不稳定的情况,有时候访问到了非叶子节点就可以找到关键字,而有时需要访问到叶子节点才能找到关键字。
B+树的查询效率更高,这是因为通常B+树比B树更矮胖(阶数更大,深度更低),查询所需要的磁盘I/O也会更少。同样的磁盘页大小,B+树可以存储更多的节点关键字。
在查询范围上,B+树的效率也比B树高。这是因为所有关键字都出现在B+树的叶子节点中,并通过有序链表进行了链接。而在B树中则需要通过中序遍历才能完成查询范围的查找,效率要低很多。
哈希索引
用Hash进行检索效率非常高,逻辑为:
值key通过Hash映射找到桶bucket。桶(bucket)指的是一个能存储一条或多条记录的存储单位。一个桶的结构包含了一个内存指针数组,桶中的每行数据都会指向下一行,形成链表结构,当遇到Hash冲突时,会在桶中进行键值的查找。
Hash冲突
如果桶的空间小于输入的空间,不同的输入可能会映射到同一个桶中,这时就会产生Hash冲突,如果Hash冲突的量很大,就会影响读取的性能。
通常Hash值的字节数比较少,简单的4个字节就够了。在Hash值相同的情况下,就会进一步比较桶(Bucket)中的键值,从而找到最终的数据行。
Hash值的字节数多的话可以是16位、32位等,比如采用MD5函数就可以得到一个16位或者32位的数值,32位的MD5已经足够安全,重复率非常低。
哈希索引与B+树索引的区别
- Hash索引不能进行范围查询,而B+树可以。这是因为Hash索引指向的数据是无序的,而B+树的叶子节点是个有序的链表。
- Hash索引不支持联合索引的最左侧原则(即联合索引的部分索引无法使用),而B+树可以。对于联合索引来说,Hash索引在计算 Hash 值的时候是将索引键合并后再一起计算 Hash 值,所以不会针对每个索引单独计算Hash值。因此如果用到联合索引的一个或者几个索引时,联合索引无法被利用。
- Hash索引不支持ORDER BY排序,因为Hash索引指向的数据是无序的,因此无法起到排序优化的作用,而B+树索引数据是有序的,可以起到对该字段ORDER BY排序优化的作用。同理,我们也无法用Hash索引进行模糊查询,而B+树使用LIKE进行模糊查询的时候,LIKE后面前模糊查询(比如%开头)的话就可以起到优化作用。
索引的使用
什么时候创建索引
- 字段的数值有唯一性的限制,比如用户名
- 频繁作为WHERE查询条件的字段,尤其在数据表大的情况下
- 需要经常GROUP BY和ORDER BY的列
- UPDATE、DELETE的WHERE条件列,一般也需要创建索引
- DISTINCT字段需要创建索引
- 多表JOIN的连接字段需要创建索引
- 连接表的数量尽量不要超过3张,因为每增加一张表就相当于增加了一次嵌套的循环,数量级增长会非常快,严重影响查询的效率。
- 其次,对WHERE条件创建索引,因为WHERE才是对数据条件的过滤。如果在数据量非常大的情况下,没有WHERE条件过滤是非常可怕的。
- 最后,对用于连接的字段创建索引,并且该字段在多张表中的类型必须一致。比如user_id在product_comment表和user表中都为int(11)类型,而不能一个为int另一个为varchar类型。
索引失效
- 索引进行了表达式计算
需要把索引字段的取值都取出来,然后依次进行表达式的计算来进行条件判断,因此采用的就是全表扫描的方式;
- 对索引使用函数
- 在WHERE子句中,如果在OR前的条件列进行了索引,而在OR后的条件列没有进行索引
OR的含义就是两个只要满足一个即可,因此只有一个条件列进行了索引是没有意义的,只要有条件列没有进行索引,就会进行全表扫描;
- 使用LIKE进行模糊查询的时候,前面不能是%
- 索引列尽量设置为NOT NULL约束
判断索引列是否为NOT NULL,往往需要走全表扫描,因此我们最好在设计数据表的时候就将字段设置为NOT NULL约束。
锁
锁的分类
按照锁粒度进行划分
我们从锁定对象的粒度大小来对锁进行划分,分别为行锁、页锁和表锁。
- 行锁
按照行的粒度对数据进行锁定。锁定力度小,发生锁冲突概率低,可以实现的并发度高,但是对于锁的开销比较大,加锁会比较慢,容易出现死锁情况。
- 页锁
是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。
- 表锁
对数据表进行锁定,锁定粒度很大,同时发生锁冲突的概率也会较高,数据访问的并发度低。不过好处在于对锁的使用开销小,加锁会很快。
从数据库管理的角度对锁进行划分
我们还可以从数据库管理的角度对锁进行划分,分为共享锁和排它锁。
- 共享锁
也叫读锁或S锁,共享锁锁定的资源可以被其他用户读取,但不能修改。在进行SELECT的时候,会将对象进行共享锁锁定,当数据读取完毕之后,就会释放共享锁,这样就可以保证数据在读取时不被修改。
- 排它锁
也叫独占锁、写锁或X锁。排它锁锁定的数据只允许进行锁定操作的事务使用,其他事务无法对已锁定的数据进行查询或修改。
- 意向锁(Intent Lock)
简单来说就是给更大一级别的空间示意里面是否已经上过锁。举个例子,你可以给整个房子设置一个标识,告诉它里面有人,即使你只是获取了房子中某一个房间的锁。这样其他人如果想要获取整个房子的控制权,只需要看这个房子的标识即可,不需要再对房子中的每个房间进行查找。这样是不是很方便?
返回数据表的场景,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了,这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可。
如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁。同理,事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加意向排他锁。这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录,不能对整个表进行全表扫描。
从程序员的角度进行划分
如果从程序员的视角来看锁的话,可以将锁分成乐观锁和悲观锁。
- 乐观锁(Optimistic Locking)
认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制或者时间戳机制实现。
- 悲观锁(Pessimistic Locking)
也是一种思想,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。
避免死锁的发生
- 如果事务涉及多个表,操作比较复杂,那么可以尽量一次锁定所有的资源,而不是逐步来获取,这样可以减少死锁发生的概率;
- 如果事务需要更新数据表中的大部分数据,数据表又比较大,这时可以采用锁升级的方式,比如将行级锁升级为表级锁,从而减少死锁产生的概率;
- 不同事务并发读写多张数据表,可以约定访问表的顺序,采用相同的顺序降低死锁发生的概率。
优化步骤
慢查询定位执行慢的SQL
1 | mysql > show variables like '%slow_query_log'; |
开启慢查询日志
1 | mysql > set global slow_query_log='ON'; |
EXPLAIN查看执行计划
EXPLAIN可以帮助我们了解数据表的读取顺序、SELECT子句的类型、数据表的访问类型、可使用的索引、实际使用的索引、使用的索引长度、上一个表的连接匹配条件、被优化器查询的行的数量以及额外的信息(比如是否使用了外部排序,是否使用了临时表等)等。
EXPLAIN会返回一个表格:其中type是关键信息。
all是最坏的情况,因为采用了全表扫描的方式。index和all差不多,只不过index对索引表进行全扫描,这样做的好处是不再需要对数据进行排序,但是开销依然很大。如果我们在Extral列中看到Using index,说明采用了索引覆盖,也就是索引可以覆盖所需的SELECT字段,就不需要进行回表,这样就减少了数据查找的开销。
SHOW PROFILE查看SQL的具体执行成本
看下当前会话都有哪些profiles:
1 | mysql > show profiles; |
想要查看上一个查询的开销,可以使用:
1 | mysql > show profile; |
缓冲池和查询缓存的区别
对比
| 对比维度 | 缓冲池(Buffer Pool) | 查询缓存(Query Cache) |
|---|---|---|
| 适用场景 | 几乎所有场景,是数据库高性能的绝对核心。 | 极少更新、大量完全相同读取的静态表(如配置表、字典表)。 |
| 缓存内容 | 数据页和索引页(通常每页 16KB),即底层的物理/逻辑数据块。 | 完整的 SQL 查询语句及其对应的完整结果集。 |
| 工作原理 | InnoDB 在内存中开辟的一块连续空间。当你执行查询时,InnoDB 会先检查需要的数据页是否在 Buffer Pool 中。如果在(缓存命中),直接返回内存数据;如果不在(缓存未命中),则从磁盘读取该页到 Buffer Pool 中,然后再返回数据。 | 在 Server 层维护一个哈希表,Key 是 SQL 语句的哈希值,Value 是查询结果集。收到 SELECT 请求时,先计算哈希值去查表,命中则直接返回,跳过解析、优化、执行和存储引擎交互的所有步骤。 |
| 写入机制 | 当执行更新操作时,InnoDB 会先修改 Buffer Pool 中的数据页,并将其标记为”脏页(Dirty Page)”,随后由后台线程异步将这些脏页刷入磁盘(Flush)。这保证了写入的高性能。 | - |
查询缓存被淘汰
MySQL 官方在 8.0 版本中彻底移除了 Query Cache,主要原因包括:
- 扩展性差:查询缓存的全局锁机制在多核 CPU 和高并发环境下成为严重的性能瓶颈。
- 失效过于频繁:现代互联网应用读写频繁,查询缓存命中率极低,大部分时间都在做”缓存-失效”的无用功。
- 更优的替代方案:应用层缓存(如 Redis 或 Memcached)提供了更细粒度、更灵活、更高性能的缓存控制能力,完全取代了数据库层做结果集缓存的需求。
总结
进阶篇主要围绕数据库性能优化展开,涵盖了从宏观的调优方向到微观的索引实现细节。核心要点包括:
- 调优要多维度考虑:选择合适的DBMS、优化表设计、改进查询逻辑,而不是只盯着SQL语句本身
- 范式不是越高越好:需要在规范性和性能之间找到平衡,该反范式时就反范式
- 索引是把双刃剑:能加速查询但会增加写入成本和存储开销,要根据实际场景判断是否创建
- 理解底层原理:B+树、哈希索引、锁机制等原理能帮助我们更好地理解性能问题的根源
- 工具辅助诊断:慢查询日志、EXPLAIN、SHOW PROFILE等工具是定位性能瓶颈的利器
数据库优化是一个系统工程,需要结合业务特点、数据规模、硬件资源等多方面因素综合考虑。没有一招鲜吃遍天的银弹,只有不断实践、测试、验证,才能找到最适合自己项目的优化方案。
下一步可以继续深入学习事务、MVCC、分库分表等更高级的话题,也欢迎查看本系列的其他文章: