This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,232 @@
<audio id="audio" title="33 | MySQL调优之SQL语句如何写出高性能SQL语句" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4e/85/4e7d99ff3eb1c0a91be47629437e2985.mp3"></audio>
你好,我是刘超。
从今天开始我将带你一起学习MySQL的性能调优。MySQL数据库是互联网公司使用最为频繁的数据库之一不仅仅因为它开源免费MySQL卓越的性能、稳定的服务以及活跃的社区都成就了它的核心竞争力。
我们知道应用服务与数据库的交互主要是通过SQL语句来实现的。在开发初期我们更加关注的是使用SQL实现业务功能然而系统上线后随着生产环境数据的快速增长之前写的很多SQL语句就开始暴露出性能问题。
在这个阶段中我们应该尽量避免一些慢SQL语句的实现。但话说回来SQL语句慢的原因千千万除了一些常规的慢SQL语句可以直接规避其它的一味去规避也不是办法我们还要学会如何去分析、定位到其根本原因并总结一些常用的SQL调优方法以备不时之需。
那么今天我们就重点看看慢SQL语句的几种常见诱因从这点出发找到最佳方法开启高性能SQL语句的大门。
## 慢SQL语句的几种常见诱因
### 1. 无索引、索引失效导致慢查询
如果在一张几千万数据的表中以一个没有索引的列作为查询条件大部分情况下查询会非常耗时这种查询毫无疑问是一个慢SQL查询。所以对于大数据量的查询我们需要建立适合的索引来优化查询。
虽然我们很多时候建立了索引但在一些特定的场景下索引还有可能会失效所以索引失效也是导致慢查询的主要原因之一。针对这点的调优我会在第34讲中详解。
### 2. 锁等待
我们常用的存储引擎有 InnoDB 和 MyISAM前者支持行锁和表锁后者只支持表锁。
如果数据库操作是基于表锁实现的,试想下,如果一张订单表在更新时,需要锁住整张表,那么其它大量数据库操作(包括查询)都将处于等待状态,这将严重影响到系统的并发性能。
这时InnoDB 存储引擎支持的行锁更适合高并发场景。但在使用 InnoDB 存储引擎时,我们要特别注意行锁升级为表锁的可能。在批量更新操作时,行锁就很可能会升级为表锁。
MySQL认为如果对一张表使用大量行锁会导致事务执行效率下降从而可能造成其它事务长时间锁等待和更多的锁冲突问题发生致使性能严重下降所以MySQL会将行锁升级为表锁。还有行锁是基于索引加的锁如果我们在更新操作时条件索引失效那么行锁也会升级为表锁。
因此基于表锁的数据库操作会导致SQL阻塞等待从而影响执行速度。在一些更新操作insert\update\delete大于或等于读操作的情况下MySQL不建议使用MyISAM存储引擎。
除了锁升级之外行锁相对表锁来说虽然粒度更细并发能力提升了但也带来了新的问题那就是死锁。因此在使用行锁时我们要注意避免死锁。关于死锁我还会在第35讲中详解。
### 3. 不恰当的SQL语句
使用不恰当的SQL语句也是慢SQL最常见的诱因之一。例如习惯使用&lt;SELECT *&gt;&lt;SELECT COUNT(*)&gt; SQL语句在大数据表中使用&lt;LIMIT M,N&gt;分页查询,以及对非索引字段进行排序等等。
## 优化SQL语句的步骤
通常我们在执行一条SQL语句时要想知道这个SQL先后查询了哪些表是否使用了索引这些数据从哪里获取到获取到数据遍历了多少行数据等等我们可以通过EXPLAIN命令来查看这些执行信息。这些执行信息被统称为执行计划。
### 1. 通过EXPLAIN分析SQL执行计划
假设现在我们使用EXPLAIN命令查看当前SQL是否使用了索引先通过SQL EXPLAIN导出相应的执行计划如下
<img src="https://static001.geekbang.org/resource/image/bd/f8/bd11fa15122956719289afea2464eff8.jpg" alt="">
下面对图示中的每一个字段进行一个说明,从中你也能收获到很多零散的知识点。
- id每个执行计划都有一个id如果是一个联合查询这里还将有多个id。
- select_type表示SELECT查询类型常见的有SIMPLE普通查询即没有联合查询、子查询、PRIMARY主查询、UNIONUNION中后面的查询、SUBQUERY子查询等。
- table当前执行计划查询的表如果给表起别名了则显示别名信息。
- partitions访问的分区表信息。
- type表示从表中查询到行所执行的方式查询方式是SQL优化中一个很重要的指标结果值从好到差依次是system &gt; const &gt; eq_ref &gt; ref &gt; range &gt; index &gt; ALL。
<img src="https://static001.geekbang.org/resource/image/8f/0b/8fc6cb3338945524fb09a092f396fa0b.jpg" alt="">
- system/const表中只有一行数据匹配此时根据索引查询一次就能找到对应的数据。
<img src="https://static001.geekbang.org/resource/image/b5/2b/b5ea0778ff22bdde10a57edfc353712b.jpg" alt="">
- eq_ref使用唯一索引扫描常见于多表连接中使用主键和唯一索引作为关联条件。
<img src="https://static001.geekbang.org/resource/image/d3/50/d390d8c7bb90bdbf26775265ad451c50.jpg" alt="">
- ref非唯一索引扫描还可见于唯一索引最左原则匹配扫描。
<img src="https://static001.geekbang.org/resource/image/40/a4/4020416795c991f68fb057b3e6b80ca4.jpg" alt="">
- range索引范围扫描比如&lt;&gt;between等操作。
<img src="https://static001.geekbang.org/resource/image/7f/c7/7f7a40f88150117f6fe0bb56f52da6c7.jpg" alt="">
- index索引全表扫描此时遍历整个索引树。
<img src="https://static001.geekbang.org/resource/image/d3/7b/d3d7221fec38845145ac0f365196427b.jpg" alt="">
- ALL表示全表扫描需要遍历全表来找到对应的行。
- possible_keys可能使用到的索引。
- key实际使用到的索引。
- key_len当前使用的索引的长度。
- ref关联id等信息。
- rows查找到记录所扫描的行数。
- filtered查找到所需记录占总扫描记录数的比例。
- Extra额外的信息。
### 2. 通过Show Profile分析SQL执行性能
上述通过 EXPLAIN 分析执行计划仅仅是停留在分析SQL的外部的执行情况如果我们想要深入到MySQL内核中从执行线程的状态和时间来分析的话这个时候我们就可以选择Profile。
Profile除了可以分析执行线程的状态和时间还支持进一步选择ALL、CPU、MEMORY、BLOCK IO、CONTEXT SWITCHES等类型来查询SQL语句在不同系统资源上所消耗的时间。以下是相关命令的注释
```
SHOW PROFILE [type [, type] ... ]
[FOR QUERY n]
[LIMIT row_count [OFFSET offset]]
type参数
| ALL显示所有开销信息
| BLOCK IO阻塞的输入输出次数
| CONTEXT SWITCHES上下文切换相关开销信息
| CPU显示CPU的相关开销信息
| IPC接收和发送消息的相关开销信息
| MEMORY :显示内存相关的开销,目前无用
| PAGE FAULTS :显示页面错误相关开销信息
| SOURCE :列出相应操作对应的函数名及其在源码中的调用位置(行数)
| SWAPS显示swap交换次数的相关开销信息
```
值得注意的是MySQL是在5.0.37版本之后才支持Show Profile功能的如果你不太确定的话可以通过select @@have_profiling查询是否支持该功能,如下图所示:
<img src="https://static001.geekbang.org/resource/image/76/45/76a42789a838dfd6b1735c41dd9f8c45.jpg" alt="">
最新的MySQL版本是默认开启Show Profile功能的但在之前的旧版本中是默认关闭该功能的你可以通过set语句在Session级别开启该功能
<img src="https://static001.geekbang.org/resource/image/84/91/840fbe1ecdf7526fdc818f4639e22091.jpg" alt="">
Show Profiles只显示最近发给服务器的SQL语句默认情况下是记录最近已执行的15条记录我们可以重新设置profiling_history_size增大该存储记录最大值为100。
<img src="https://static001.geekbang.org/resource/image/54/4f/5488fde01df647508d60b9a77cd1f14f.jpg" alt="">
获取到Query_ID之后我们再通过Show Profile for Query ID语句就能够查看到对应Query_ID的SQL语句在执行过程中线程的每个状态所消耗的时间了
<img src="https://static001.geekbang.org/resource/image/dc/23/dc7e4046ddd22438c21690e5bc38c123.jpg" alt="">
通过以上分析可知SELECT COUNT(*) FROM `order`; SQL语句在Sending data状态所消耗的时间最长这是因为在该状态下MySQL线程开始读取数据并返回到客户端此时有大量磁盘I/O操作。
## 常用的SQL优化
在使用一些常规的SQL时如果我们通过一些方法和技巧来优化这些SQL的实现在性能上就会比使用常规通用的实现方式更加优越甚至可以将SQL语句的性能提升到另一个数量级。
### 1. 优化分页查询
通常我们是使用&lt;LIMIT M,N&gt; +合适的order by来实现分页查询这种实现方式在没有任何索引条件支持的情况下需要做大量的文件排序操作file sort性能将会非常得糟糕。如果有对应的索引通常刚开始的分页查询效率会比较理想但越往后分页查询的性能就越差。
这是因为我们在使用LIMIT的时候偏移量M在分页越靠后的时候值就越大数据库检索的数据也就越多。例如 LIMIT 10000,10这样的查询数据库需要查询10010条记录最后返回10条记录。也就是说将会有10000条记录被查询出来没有被使用到。
我们模拟一张10万数量级的order表进行以下分页查询
```
select * from `demo`.`order` order by order_no limit 10000, 20;
```
通过EXPLAIN分析可知该查询使用到了索引扫描行数为10020行但所用查询时间为0.018s,相对来说时间偏长了。
<img src="https://static001.geekbang.org/resource/image/80/fe/80efe0ba8feb86baa20834fd48c302fe.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/58/1c/58e2377b2adcded4c454d410bbab7d1c.jpg" alt="">
- 利用子查询优化分页查询
以上分页查询的问题在于我们查询获取的10020行数据结果都返回给我们了我们能否先查询出所需要的20行数据中的最小ID值然后通过偏移量返回所需要的20行数据给我们呢我们可以通过索引覆盖扫描使用子查询的方式来实现分页查询
```
select * from `demo`.`order` where id&gt; (select id from `demo`.`order` order by order_no limit 10000, 1) limit 20;
```
通过EXPLAIN分析可知子查询遍历索引的范围跟上一个查询差不多而主查询扫描了更多的行数但执行时间却减少了只有0.004s。这就是因为返回行数只有20行了执行效率得到了明显的提升。
<img src="https://static001.geekbang.org/resource/image/10/2e/10e46817482166d205f319cd0512942e.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/49/bb/492ddbbe2ef47d63a6dc797fd44c16bb.jpg" alt="">
### 2. 优化SELECT COUNT(*)
COUNT()是一个聚合函数主要用来统计行数有时候也用来统计某一列的行数量不统计NULL值的行。我们平时最常用的就是COUNT(*)和COUNT(1)这两种方式了,其实两者没有明显的区别,在拥有主键的情况下,它们都是利用主键列实现了行数的统计。
但COUNT()函数在MyISAM和InnoDB存储引擎所执行的原理是不一样的通常在没有任何查询条件下的COUNT(*)MyISAM的查询速度要明显快于InnoDB。
这是因为MyISAM存储引擎记录的是整个表的行数在COUNT(*)查询操作时无需遍历表计算直接获取该值即可。而在InnoDB存储引擎中就需要扫描表来统计具体的行数。而当带上where条件语句之后MyISAM跟InnoDB就没有区别了它们都需要扫描表来进行行数的统计。
如果对一张大表经常做SELECT COUNT(*)操作这肯定是不明智的。那么我们该如何对大表的COUNT()进行优化呢?
- 使用近似值
有时候某些业务场景并不需要返回一个精确的COUNT值此时我们可以使用近似值来代替。我们可以使用EXPLAIN对表进行估算要知道执行EXPLAIN并不会真正去执行查询而是返回一个估算的近似值。
- 增加汇总统计
如果需要一个精确的COUNT值我们可以额外新增一个汇总统计表或者缓存字段来统计需要的COUNT值这种方式在新增和删除时有一定的成本但却可以大大提升COUNT()的性能。
### 3. 优化SELECT *
我曾经看过很多同事习惯在只查询一两个字段时都使用select * from table where xxx这样的SQL语句这种写法在特定的环境下会存在一定的性能损耗。
MySQL常用的存储引擎有MyISAM和InnoDB其中InnoDB在默认创建主键时会创建主键索引而主键索引属于聚簇索引即在存储数据时索引是基于B +树构成的,具体的行数据则存储在叶子节点。
而MyISAM默认创建的主键索引、二级索引以及InnoDB的二级索引都属于非聚簇索引即在存储数据时索引是基于B +树构成的,而叶子节点存储的是主键值。
假设我们的订单表是基于InnoDB存储引擎创建的且存在order_no、status两列组成的组合索引。此时我们需要根据订单号查询一张订单表的status如果我们使用select * from order where order_no='xxx来查询则先会查询组合索引通过组合索引获取到主键ID再通过主键ID去主键索引中获取对应行所有列的值。
如果我们使用select order_no, status from order where order_no='xxx来查询则只会查询组合索引通过组合索引获取到对应的order_no和status的值。如果你对这些索引还不够熟悉请重点关注之后的第34讲那一讲会详述数据库索引的相关内容。
## 总结
在开发中我们要尽量写出高性能的SQL语句但也无法避免一些慢SQL语句的出现或因为疏漏或因为实际生产环境与开发环境有所区别这些都是诱因。面对这种情况我们可以打开慢SQL配置项记录下都有哪些SQL超过了预期的最大执行时间。首先我们可以通过以下命令行查询是否开启了记录慢SQL的功能以及最大的执行时间是多少
```
Show variables like 'slow_query%';
Show variables like 'long_query_time';
```
如果没有开启,我们可以通过以下设置来开启:
```
set global slow_query_log='ON'; //开启慢SQL日志
set global slow_query_log_file='/var/lib/mysql/test-slow.log';//记录日志地址
set global long_query_time=1;//最大执行时间
```
除此之外很多数据库连接池中间件也有分析慢SQL的功能。总之我们要在编程中避免低性能的SQL操作出现除了要具备一些常用的SQL优化技巧之外还要充分利用一些SQL工具实现SQL性能分析与监控。
## 思考题
假设有一张订单表order主要包含了主键订单编码order_no、订单状态status、提交时间create_time等列并且创建了status列索引和create_time列索引。此时通过创建时间降序获取状态为1的订单编码以下是具体实现代码
```
select order_no from order where status =1 order by create_time desc
```
你知道其中的问题所在吗?我们又该如何优化?
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。

View File

@@ -0,0 +1,118 @@
<audio id="audio" title="34 | MySQL调优之事务高并发场景下的数据库事务调优" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/07/cd/07d186609c9f61cb4a4c3be85dff2bcd.mp3"></audio>
你好,我是刘超。
数据库事务是数据库系统执行过程中的一个逻辑处理单元保证一个数据库操作要么成功要么失败。谈到他就不得不提ACID属性了。数据库事务具有以下四个基本属性原子性Atomicity、一致性Consistent、隔离性Isolation以及持久性Durable。正是这些特性才保证了数据库事务的安全性。而在MySQL中鉴于MyISAM存储引擎不支持事务所以接下来的内容都是在InnoDB存储引擎的基础上进行讲解的。
我们知道在Java并发编程中可以多线程并发执行程序然而并发虽然提高了程序的执行效率却给程序带来了线程安全问题。事务跟多线程一样为了提高数据库处理事务的吞吐量数据库同样支持并发事务而在并发运行中同样也存在着安全性问题例如修改数据丢失读取数据不一致等。
在数据库事务中,事务的隔离是解决并发事务问题的关键, 今天我们就重点了解下事务隔离的实现原理,以及如何优化事务隔离带来的性能问题。
## 并发事务带来的问题
我们可以通过以下几个例子来了解下并发事务带来的几个问题:
1.数据丢失
<img src="https://static001.geekbang.org/resource/image/db/7d/db7d28a1f27d46cf534064ab4e74f47d.jpg" alt="">
2.脏读
<img src="https://static001.geekbang.org/resource/image/d7/4c/d717c7e782620d2e46beb070dbc8154c.jpg" alt="">
3.不可重复读
<img src="https://static001.geekbang.org/resource/image/61/9a/6173739ee9a5d7e26c8b00f2ed8d9e9a.jpg" alt="">
4.幻读
<img src="https://static001.geekbang.org/resource/image/28/b6/280826363e1d5a3e64529dfd3443e5b6.jpg" alt="">
## 事务隔离解决并发问题
以上 4 个并发事务带来的问题,其中,数据丢失可以基于数据库中的悲观锁来避免发生,即在查询时通过在事务中使用 select xx for update 语句来实现一个排他锁,保证在该事务结束之前其他事务无法更新该数据。
当然,我们也可以基于乐观锁来避免,即将某一字段作为版本号,如果更新时的版本号跟之前的版本一致,则更新,否则更新失败。剩下 3 个问题,其实是数据库读一致性造成的,需要数据库提供一定的事务隔离机制来解决。
我们通过加锁的方式可以实现不同的事务隔离机制。在了解事务隔离机制之前我们不妨先来了解下MySQL都有哪些锁机制。
InnoDB实现了两种类型的锁机制共享锁S和排他锁X。共享锁允许一个事务读数据不允许修改数据如果其他事务要再对该行加锁只能加共享锁排他锁是修改数据时加的锁可以读取和修改数据一旦一个事务对该行数据加锁其他事务将不能再对该数据加任务锁。
**熟悉了以上InnoDB行锁的实现原理我们就可以更清楚地理解下面的内容。**
在操作数据的事务中,不同的锁机制会产生以下几种不同的事务隔离级别,不同的隔离级别分别可以解决并发事务产生的几个问题,对应如下:
**未提交读Read Uncommitted**在事务A读取数据时事务B读取数据加了共享锁修改数据时加了排它锁。这种隔离级别会导致脏读、不可重复读以及幻读。
**已提交读Read Committed**在事务A读取数据时增加了共享锁一旦读取立即释放锁事务B读取修改数据时增加了行级排他锁直到事务结束才释放锁。也就是说事务A在读取数据时事务B只能读取数据不能修改。当事务A读取到数据后事务B才能修改。这种隔离级别可以避免脏读但依然存在不可重复读以及幻读的问题。
**可重复读Repeatable Read**在事务A读取数据时增加了共享锁事务结束才释放锁事务B读取修改数据时增加了行级排他锁直到事务结束才释放锁。也就是说事务A在没有结束事务时事务B只能读取数据不能修改。当事务A结束事务事务B才能修改。这种隔离级别可以避免脏读、不可重复读但依然存在幻读的问题。
**可序列化Serializable**在事务A读取数据时增加了共享锁事务结束才释放锁事务B读取修改数据时增加了表级排他锁直到事务结束才释放锁。可序列化解决了脏读、不可重复读、幻读等问题但隔离级别越来越高的同时并发性会越来越低。
InnoDB中的RC和RR隔离事务是基于多版本并发控制MVCC实现高性能事务。一旦数据被加上排他锁其他事务将无法加入共享锁且处于阻塞等待状态如果一张表有大量的请求这样的性能将是无法支持的。
MVCC对普通的 Select 不加锁如果读取的数据正在执行Delete或Update操作这时读取操作不会等待排它锁的释放而是直接利用MVCC读取该行的数据快照数据快照是指在该行的之前版本的数据而数据快照的版本是基于undo实现的undo是用来做事务回滚的记录了回滚的不同版本的行记录。MVCC避免了对数据重复加锁的过程大大提高了读操作的性能。
## 锁具体实现算法
我们知道InnoDB既实现了行锁也实现了表锁。行锁是通过索引实现的如果不通过索引条件检索数据那么InnoDB将对表中所有的记录进行加锁其实就是升级为表锁了。
行锁的具体实现算法有三种record lock、gap lock以及next-key lock。record lock是专门对索引项加锁gap lock是对索引项之间的间隙加锁next-key lock则是前面两种的组合对索引项以其之间的间隙加锁。
只在可重复读或以上隔离级别下的特定操作才会取得gap lock或next-key lock在Select 、Update和Delete时除了基于唯一索引的查询之外其他索引查询时都会获取gap lock或next-key lock即锁住其扫描的范围。
## 优化高并发事务
通过以上讲解,相信你对事务、锁以及隔离级别已经有了一个透彻的了解了。清楚了问题,我们就可以聊聊高并发场景下的事务到底该如何调优了。
### 1. 结合业务场景,使用低级别事务隔离
在高并发业务中,为了保证业务数据的一致性,操作数据库时往往会使用到不同级别的事务隔离。隔离级别越高,并发性能就越低。
那换到业务场景中,我们如何判断用哪种隔离级别更合适呢?我们可以通过两个简单的业务来说下其中的选择方法。
我们在修改用户最后登录时间的业务场景中,这里对查询用户的登录时间没有特别严格的准确性要求,而修改用户登录信息只有用户自己登录时才会修改,不存在一个事务提交的信息被覆盖的可能。所以我们允许该业务使用最低隔离级别。
而如果是账户中的余额或积分的消费就存在多个客户端同时消费一个账户的情况此时我们应该选择RR级别来保证一旦有一个客户端在对账户进行消费其他客户端就不可能对该账户同时进行消费了。
### 2. 避免行锁升级表锁
前面讲了在InnoDB中行锁是通过索引实现的如果不通过索引条件检索数据行锁将会升级到表锁。我们知道表锁是会严重影响到整张表的操作性能的所以我们应该避免他。
### 3. 控制事务的大小,减少锁定的资源量和锁定时间长度
你是否遇到过以下SQL异常呢在抢购系统的日志中在活动区间我们经常可以看到这种异常日志
```
MySQLQueryInterruptedException: Query execution was interrupted
```
由于在抢购提交订单中开启了事务,在高并发时对一条记录进行更新的情况下,由于更新记录所在的事务还可能存在其他操作,导致一个事务比较长,当有大量请求进入时,就可能导致一些请求同时进入到事务中。
又因为锁的竞争是不公平的,当多个事务同时对一条记录进行更新时,极端情况下,一个更新操作进去排队系统后,可能会一直拿不到锁,最后因超时被系统打断踢出。
在用户购买商品时,首先我们需要查询库存余额,再新建一个订单,并扣除相应的库存。这一系列操作是处于同一个事务的。
以上业务若是在两种不同的执行顺序下,其结果都是一样的,但在事务性能方面却不一样:
<img src="https://static001.geekbang.org/resource/image/0c/27/0c60d5685aa881cf66be43c6c4529927.jpg" alt="">
这是因为,虽然这些操作在同一个事务,但锁的申请在不同时间,只有当其他操作都执行完,才会释放所有锁。因为扣除库存是更新操作,属于行锁,这将会影响到其他操作该数据的事务,所以我们应该尽量避免长时间地持有该锁,尽快释放该锁。
又因为先新建订单和先扣除库存都不会影响业务所以我们可以将扣除库存操作放到最后也就是使用执行顺序1以此尽量减小锁的持有时间。
## 总结
其实MySQL的并发事务调优和Java的多线程编程调优非常类似都是可以通过减小锁粒度和减少锁的持有时间进行调优。在MySQL的并发事务调优中我们尽量在可以使用低事务隔离级别的业务场景中避免使用高事务隔离级别。
在功能业务开发时开发人员往往会为了追求开发速度习惯使用默认的参数设置来实现业务功能。例如在service方法中你可能习惯默认使用transaction很少再手动变更事务隔离级别。但要知道transaction默认是RR事务隔离级别在某些业务场景下可能并不合适。因此我们还是要结合具体的业务场景进行考虑。
## 思考题
以上我们主要了解了锁实现事务的隔离性你知道InnoDB是如何实现原子性、一致性和持久性的吗
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。

View File

@@ -0,0 +1,145 @@
<audio id="audio" title="35 | MySQL调优之索引索引的失效与优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7c/ba/7cdd92613410ae7756d12d84dddd16ba.mp3"></audio>
你好,我是刘超。
不知道你是否跟我有过同样的经历那就是作为一个开发工程师经常被DBA叫过去“批评”而最常见的就是申请创建新的索引或发现慢SQL日志了。
记得之前有一次迭代一个业务模块的开发涉及到了一个新的查询业务需要根据商品类型、订单状态筛选出需要的订单并以订单时间进行排序。由于sku的索引已经存在了我在完成业务开发之后提交了一个创建status的索引的需求理由是SQL查询需要使用到这两个索引
>
select * from order where status =1 and sku=10001 order by create_time asc
然而DBA很快就将这个需求驳回了并给出了重建一个sku、status以及create_time组合索引的建议查询顺序也改成了 sku=10001 and status=1。当时我是知道为什么要重建组合索引但却无法理解为什么要添加create_time这列进行组合。
从执行计划中我们可以发现使用到了索引那为什么DBA还要求将create_time这一列加入到组合索引中呢这个问题我们在[第33讲](https://time.geekbang.org/column/article/113440)中提到过,相信你也已经知道答案了。通过故事我们可以发现索引知识在平时开发时的重要性,然而它又很容易被我们忽略,所以今天我们就来详细聊一聊索引。
## MySQL索引存储结构
索引是优化数据库查询最重要的方式之一它是在MySQL的存储引擎层中实现的所以每一种存储引擎对应的索引不一定相同。我们可以通过下面这张表格看看不同的存储引擎分别支持哪种索引类型
<img src="https://static001.geekbang.org/resource/image/f6/1b/f6dc2c083b30377628b3699322f6611b.jpg" alt="">
B+Tree索引和Hash索引是我们比较常用的两个索引数据存储结构B+Tree索引是通过B+树实现的是有序排列存储所以在排序和范围查找方面都比较有优势。如果你对B+Tree索引不够了解可以通过该[链接](https://zhuanlan.zhihu.com/p/27700617)了解下它的数据结构原理。
Hash索引相对简单些只有Memory存储引擎支持Hash索引。Hash索引适合key-value键值对查询无论表数据多大查询数据的复杂度都是O(1)且直接通过Hash索引查询的性能比其它索引都要优越。
在创建表时无论使用InnoDB还是MyISAM存储引擎默认都会创建一个主键索引而创建的主键索引默认使用的是B+Tree索引。不过虽然这两个存储引擎都支持B+Tree索引但它们在具体的数据存储结构方面却有所不同。
InnoDB默认创建的主键索引是聚簇索引Clustered Index其它索引都属于辅助索引Secondary Index也被称为二级索引或非聚簇索引。接下来我们通过一个简单的例子说明下这两种索引在存储数据中的具体实现。
首先创建一张商品表,如下:
```
CREATE TABLE `merchandise` (
`id` int(11) NOT NULL,
`serial_no` varchar(20) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
`unit_price` decimal(10, 2) DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
```
然后新增了以下几行数据,如下:
<img src="https://static001.geekbang.org/resource/image/52/c8/524d7510e8c3ec264f274798d72b99c8.jpg" alt="">
如果我们使用的是MyISAM存储引擎由于MyISAM使用的是辅助索引索引中每一个叶子节点仅仅记录的是每行数据的物理地址即行指针如下图所示
<img src="https://static001.geekbang.org/resource/image/ea/43/eacb96b876a4db6f3f020bd1efd39243.jpg" alt="">
如果我们使用的是InnoDB存储引擎由于InnoDB使用的是聚簇索引聚簇索引中的叶子节点则记录了主键值、事务id、用于事务和MVCC的回流指针以及所有的剩余列如下图所示
<img src="https://static001.geekbang.org/resource/image/9a/42/9a98b5abb7ff82530b02c6aeb15b6242.jpg" alt="">
基于上面的图示如果我们需要根据商品编码查询商品我们就需要将商品编码serial_no列作为一个索引列。此时创建的索引是一个辅助索引与MyISAM存储引擎的主键索引的存储方式是一致的但叶子节点存储的就不是行指针了而是主键值并以此来作为指向行的指针。这样的好处就是当行发生移动或者数据分裂时不用再维护索引的变更。
如果我们使用主键索引查询商品则会按照B+树的索引找到对应的叶子节点,直接获取到行数据:
>
select * from merchandise where id=7
如果我们使用商品编码查询商品即使用辅助索引进行查询则会先检索辅助索引中的B+树的serial_no找到对应的叶子节点获取主键值然后再通过聚簇索引中的B+树检索到对应的叶子节点,然后获取整行数据。这个过程叫做回表。
在了解了索引的实现原理后,我们再来详细了解下平时建立和使用索引时,都有哪些调优方法呢?
## 1.覆盖索引优化查询
假设我们只需要查询商品的名称、价格信息,我们有什么方式来避免回表呢?我们可以建立一个组合索引,即商品编码、名称、价格作为一个组合索引。如果索引中存在这些数据,查询将不会再次检索主键索引,从而避免回表。
从辅助索引中查询得到记录而不需要通过聚簇索引查询获得MySQL中将其称为覆盖索引。使用覆盖索引的好处很明显我们不需要查询出包含整行记录的所有信息因此可以减少大量的I/O操作。
通常在InnoDB中除了查询部分字段可以使用覆盖索引来优化查询性能之外统计数量也会用到。例如在[第32讲](https://time.geekbang.org/column/article/113440)我们讲 SELECT COUNT(*)时如果不存在辅助索引此时会通过查询聚簇索引来统计行数如果此时正好存在一个辅助索引则会通过查询辅助索引来统计行数减少I/O操作。
通过EXPLAIN我们可以看到 InnoDB 存储引擎使用了idx_order索引列来统计行数如下图所示
<img src="https://static001.geekbang.org/resource/image/c6/6f/c689b2fd284c19fd2049b1f73091226f.jpg" alt="">
## 2.自增字段作主键优化查询
上面我们讲了 InnoDB 创建主键索引默认为聚簇索引数据被存放在了B+树的叶子节点上。也就是说,同一个叶子节点内的各个数据是按主键顺序存放的,因此,每当有一条新的数据插入时,数据库会根据主键将其插入到对应的叶子节点中。
如果我们使用自增主键,那么每次插入的新数据就会按顺序添加到当前索引节点的位置,不需要移动已有的数据,当页面写满,就会自动开辟一个新页面。因为不需要重新移动数据,因此这种插入数据的方法效率非常高。
如果我们使用非自增主键,由于每次插入主键的索引值都是随机的,因此每次插入新的数据时,就可能会插入到现有数据页中间的某个位置,这将不得不移动其它数据来满足新数据的插入,甚至需要从一个页面复制数据到另外一个页面,我们通常将这种情况称为页分裂。页分裂还有可能会造成大量的内存碎片,导致索引结构不紧凑,从而影响查询效率。
因此在使用InnoDB存储引擎时如果没有特别的业务需求建议使用自增字段作为主键。
## 3.前缀索引优化
前缀索引顾名思义就是使用某个字段中字符串的前几个字符建立索引,那我们为什么需要使用前缀来建立索引呢?
我们知道索引文件是存储在磁盘中的而磁盘中最小分配单元是页通常一个页的默认大小为16KB假设我们建立的索引的每个索引值大小为2KB则在一个页中我们能记录8个索引值假设我们有8000行记录则需要1000个页来存储索引。如果我们使用该索引查询数据可能需要遍历大量页这显然会降低查询效率。
减小索引字段大小,可以增加一个页中存储的索引项,有效提高索引的查询速度。在一些大字符串的字段作为索引时,使用前缀索引可以帮助我们减小索引项的大小。
不过前缀索引是有一定的局限性的例如order by就无法使用前缀索引无法把前缀索引用作覆盖索引。
## 4.防止索引失效
当我们习惯建立索引来实现查询SQL的性能优化后是不是就万事大吉了呢当然不是有时候我们看似使用到了索引但实际上并没有被优化器选择使用。
对于Hash索引实现的列如果使用到范围查询那么该索引将无法被优化器使用到。也就是说Memory引擎实现的Hash索引只有在“=”的查询条件下索引才会生效。我们将order表设置为Memory存储引擎分析查询条件为id&lt;10的SQL可以发现没有使用到索引。
<img src="https://static001.geekbang.org/resource/image/8e/d0/8eaac54829f3c99337e481d833a93dd0.jpg" alt="">
如果是以%开头的LIKE查询将无法利用节点查询数据
<img src="https://static001.geekbang.org/resource/image/0b/bb/0b20dbd8274be269779989b3df546dbb.jpg" alt="">
当我们在使用复合索引时需要使用索引中的最左边的列进行查询才能使用到复合索引。例如我们在order表中建立一个复合索引idx_user_order_status(`order_no`, `status`, `user_id`)如果我们使用order_no、order_no+status、order_no+status+user_id以及order_no+user_id组合查询则能利用到索引而如果我们用status、status+user_id查询将无法使用到索引这也是我们经常听过的最左匹配原则。
<img src="https://static001.geekbang.org/resource/image/4c/f0/4cebb097ea9a1d2db2c3cfc98bf611f0.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/18/65/18b7427c6d7e65821284fe90ec461565.jpg" alt="">
如果查询条件中使用or且or的前后条件中有一个列没有索引那么涉及的索引都不会被使用到。
<img src="https://static001.geekbang.org/resource/image/2b/35/2b8f194d32bf2387264d50258c1f2235.jpg" alt="">
所以你懂了吗作为一名开发人员如果没有熟悉MySQL特别是MySQL索引的基础知识很多时候都将被DBA批评到怀疑人生。
## 总结
在大多数情况下,我们习惯使用默认的 InnoDB 作为表存储引擎。在使用InnoDB作为存储引擎时创建的索引默认为B+树数据结构,如果是主键索引,则属于聚簇索引,非主键索引则属于辅助索引。基于主键查询可以直接获取到行信息,而基于辅助索引作为查询条件,则需要进行回表,然后再通过主键索引获取到数据。
如果只是查询一列或少部分列的信息我们可以基于覆盖索引来避免回表。覆盖索引只需要读取索引且由于索引是顺序存储对于范围或排序查询来说可以极大地极少磁盘I/O操作。
除了了解索引的具体实现和一些特性我们还需要注意索引失效的情况发生。如果觉得这些规则太多难以记住我们就要养成经常检查SQL执行计划的习惯。
## 思考题
假设我们有一个订单表order_detail其中有主键id、主订单order_id、商品sku等字段其中该表有主键索引、主订单id索引。
现在有一个查询订单详情的SQL如下查询订单号范围在5000~10000请问该查询选择的索引是什么有什么方式可以强制使用我们期望的索引呢
```
select * from order_detail where order_id between 5000 and 10000
```
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
<img src="https://static001.geekbang.org/resource/image/bb/67/bbe343640d6b708832c4133ec53ed967.jpg" alt="unpreview">

View File

@@ -0,0 +1,136 @@
<audio id="audio" title="36 | 记一次线上SQL死锁事故如何避免死锁" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1c/5b/1c5b75d5624f837b0d359360e5dfb95b.mp3"></audio>
你好,我是刘超。今天我们来聊聊死锁,开始之前,先分享个小故事,相信你可能遇到过,或能从中获得一点启发。
之前我参与过一个项目在项目初期我们是没有将读写表分离的而是基于一个主库完成读写操作。在业务量逐渐增大的时候我们偶尔会收到系统的异常报警信息DBA通知我们数据库出现了死锁异常。
按理说业务开始是比较简单的就是新增订单、修改订单、查询订单等操作那为什么会出现死锁呢经过日志分析我们发现是作为幂等性校验的一张表经常出现死锁异常。我们和DBA讨论之后初步怀疑是索引导致的死锁问题。后来我们在开发环境中模拟了相关操作果然重现了该死锁异常。
接下来我们就通过实战来重现下该业务死锁异常。首先,创建一张订单记录表,该表主要用于校验订单重复创建:
```
CREATE TABLE `order_record` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`order_no` int(11) DEFAULT NULL,
`status` int(4) DEFAULT NULL,
`create_date` datetime(0) DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_order_status`(`order_no`,`status`) USING BTREE
) ENGINE = InnoDB
```
为了能重现该问题我们先将事务设置为手动提交。这里要注意一下MySQL数据库和Oracle提交事务不太一样MySQL数据库默认情况下是自动提交事务我们可以通过以下命令行查看自动提交事务是否开启
```
mysql&gt; show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+
1 row in set (0.01 sec)
```
下面就操作吧先将MySQL数据库的事务提交设置为手动提交通过以下命令行可以关闭自动提交事务
```
mysql&gt; set autocommit = 0;
Query OK, 0 rows affected (0.00 sec)
```
订单在做幂等性校验时先是通过订单号检查订单是否存在如果不存在则新增订单记录。知道具体的逻辑之后我们再来模拟创建产生死锁的运行SQL语句。首先我们模拟新建两个订单并按照以下顺序执行幂等性校验SQL语句垂直方向代表执行的时间顺序
<img src="https://static001.geekbang.org/resource/image/49/a0/49198c13e2dfdff0a9492a1b58cd93a0.jpg" alt="">
此时我们会发现两个事务已经进入死锁状态。我们可以在information_schema数据库中查询到具体的死锁情况如下图所示
<img src="https://static001.geekbang.org/resource/image/7d/47/7d6e8c42d082ac5b75882e3d171a8047.jpg" alt="">
看到这你可能会想为什么SELECT要加for update排他锁而不是使用共享锁呢试想下如果是两个订单号一样的请求同时进来就有可能出现幻读。也就是说一开始事务A中的查询没有该订单号后来事务B新增了一个该订单号的记录此时事务A再新增一条该订单号记录就会创建重复的订单记录。面对这种情况我们可以使用锁间隙算法来防止幻读。
## 死锁是如何产生的?
上面我们说到了锁间隙,在[第33讲](https://time.geekbang.org/column/article/114194)中,我已经讲过了并发事务中的锁机制以及行锁的具体实现算法,不妨回顾一下。
行锁的具体实现算法有三种record lock、gap lock以及next-key lock。record lock是专门对索引项加锁gap lock是对索引项之间的间隙加锁next-key lock则是前面两种的组合对索引项以其之间的间隙加锁。
只在可重复读或以上隔离级别下的特定操作才会取得gap lock或next-key lock在Select、Update和Delete时除了基于唯一索引的查询之外其它索引查询时都会获取gap lock或next-key lock即锁住其扫描的范围。主键索引也属于唯一索引所以主键索引是不会使用gap lock或next-key lock。
在MySQL中gap lock默认是开启的即innodb_locks_unsafe_for_binlog参数值是disable的且MySQL中默认的是RR事务隔离级别。
当我们执行以下查询SQL时由于order_no列为非唯一索引此时又是RR事务隔离级别所以SELECT的加锁类型为gap lock这里的gap范围是(4,+∞)。
>
SELECT id FROM `demo`.`order_record` where `order_no` = 4 for update;
执行查询SQL语句获取的gap lock并不会导致阻塞而当我们执行以下插入SQL时会在插入间隙上再次获取插入意向锁。插入意向锁其实也是一种gap锁它与gap lock是冲突的所以当其它事务持有该间隙的gap lock时需要等待其它事务释放gap lock之后才能获取到插入意向锁。
以上事务A和事务B都持有间隙(4,+∞的gap锁而接下来的插入操作为了获取到插入意向锁都在等待对方事务的gap锁释放于是就造成了循环等待导致死锁。
>
INSERT INTO `demo`.`order_record`(`order_no`, `status`, `create_date`) VALUES (5, 1, 2019-07-13 10:57:03);
我们可以通过以下锁的兼容矩阵图,来查看锁的兼容性:
<img src="https://static001.geekbang.org/resource/image/58/e3/58b1567a4ff86460ececfd420eda80e3.jpg" alt="">
## 避免死锁的措施
知道了死锁问题源自哪儿,就可以找到合适的方法来避免它了。
避免死锁最直观的方法就是在两个事务相互等待时当一个事务的等待时间超过设置的某一阈值就对这个事务进行回滚另一个事务就可以继续执行了。这种方法简单有效在InnoDB中参数innodb_lock_wait_timeout是用来设置超时时间的。
另外我们还可以将order_no列设置为唯一索引列。虽然不能防止幻读但我们可以利用它的唯一性来保证订单记录不重复创建这种方式唯一的缺点就是当遇到重复创建订单时会抛出异常。
我们还可以使用其它的方式来代替数据库实现幂等性校验。例如使用Redis以及ZooKeeper来实现运行效率比数据库更佳。
## 其它常见的SQL死锁问题
这里再补充一些常见的SQL死锁问题以便你遇到时也能知道其原因从而顺利解决。
我们知道死锁的四个必要条件:互斥、占有且等待、不可强占用、循环等待。只要系统发生死锁,这些条件必然成立。所以在一些经常需要使用互斥共用一些资源,且有可能循环等待的业务场景中,要特别注意死锁问题。
接下来,我们再来了解一个出现死锁的场景。
我们讲过InnoDB存储引擎的主键索引为聚簇索引其它索引为辅助索引。如果我们之前使用辅助索引来更新数据库就需要修改为使用聚簇索引来更新数据库。如果两个更新事务使用了不同的辅助索引或一个使用了辅助索引一个使用了聚簇索引就都有可能导致锁资源的循环等待。由于本身两个事务是互斥也就构成了以上死锁的四个必要条件了。
我们还是以上面的这个订单记录表来重现下聚簇索引和辅助索引更新时,循环等待锁资源导致的死锁问题:
<img src="https://static001.geekbang.org/resource/image/b6/e7/b685033798d1027dac3f2f6cb1c2c6e7.jpg" alt="">
出现死锁的步骤:
<img src="https://static001.geekbang.org/resource/image/e0/b4/e018d73c4a00de2bc3dc6932e0fa75b4.jpg" alt="">
综上可知,在更新操作时,我们应该尽量使用主键来更新表字段,这样可以有效避免一些不必要的死锁发生。
## 总结
数据库发生死锁的概率并不是很大一旦遇到了就一定要彻查具体原因尽快找出解决方案老实说过程不简单。我们只有先对MySQL的InnoDB存储引擎有足够的了解才能剖析出造成死锁的具体原因。
例如,以上我例举的两种发生死锁的场景,一个考验的是我们对锁算法的了解,另外一个考验则是我们对聚簇索引和辅助索引的熟悉程度。
解决死锁的最佳方式当然就是预防死锁的发生了,我们平时编程中,可以通过以下一些常规手段来预防死锁的发生:
1.在编程中尽量按照固定的顺序来处理数据库记录,假设有两个更新操作,分别更新两条相同的记录,但更新顺序不一样,有可能导致死锁;
2.在允许幻读和不可重复读的情况下尽量使用RC事务隔离级别可以避免gap lock导致的死锁问题
3.更新表时,尽量使用主键更新;
4.避免长事务,尽量将长事务拆解,可以降低与其它事务发生冲突的概率;
5.设置锁等待超时参数我们可以通过innodb_lock_wait_timeout设置合理的等待超时阈值特别是在一些高并发的业务中我们可以尽量将该值设置得小一些避免大量事务等待占用系统资源造成严重的性能开销。
## 思考题
除了设置 innodb_lock_wait_timeout 参数来避免已经产生死锁的SQL长时间等待你还知道其它方法来解决类似问题吗
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。

View File

@@ -0,0 +1,109 @@
<audio id="audio" title="37 | 什么时候需要分表分库?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/f2/c1c21fa536d818c6450385b661d830f2.mp3"></audio>
你好,我是刘超。
在当今互联网时代,海量数据基本上是每一个成熟产品的共性,特别是在移动互联网产品中,几乎每天都在产生数据,例如,商城的订单表、支付系统的交易明细以及游戏中的战报等等。
对于一个日活用户在百万数量级的商城来说,每天产生的订单数量可能在百万级,特别在一些活动促销期间,甚至上千万。
假设我们基于单表来实现每天产生上百万的数据量不到一个月的时间就要承受上亿的数据这时单表的性能将会严重下降。因为MySQL在InnoDB存储引擎下创建的索引都是基于B+树实现的所以查询时的I/O次数很大程度取决于树的高度随着B+树的树高增高I/O次数增加查询性能也就越差。
当我们面对一张海量数据的表时通常有分区、NoSQL存储、分表分库等优化方案。
分区的底层虽然也是基于分表的原理实现的即有多个底层表实现但分区依然是在单库下进行的在一些需要提高并发的场景中的优化空间非常有限且一个表最多只能支持1024个分区。面对日益增长的海量数据优化存储能力有限。不过在一些非海量数据的大表中我们可以考虑使用分区来优化表性能。
>
分区表是由多个相关的底层表实现的,这些底层表也是由句柄对象表示,所以我们也可以直接访问各个分区,存储引擎管理分区的各个底层表和管理普通表一样(所有的底层表都必须使用相同的存储引擎),分区表的索引只是在各个底层表上各自加上一个相同的索引,从存储引擎的角度来看,底层表和一个普通表没有任何不同,存储引擎也无须知道这是一个普通表,还是一个分区表的一部分。
而NoSQL存储是基于键值对存储虽然查询性能非常高但在一些方面仍然存在短板。例如不是关系型数据库不支持事务以及稳定性方面相对RDBMS差一些。虽然有些NoSQL数据库也实现了事务宣传具有可靠的稳定性但目前NoSQL还是主要用作辅助存储。
## 什么时候要分表分库?
分析完了分区、NoSQL存储优化的应用接下来我们就看看这讲的重头戏——分表分库。
在我看来,能不分表分库就不要分表分库。在单表的情况下,当业务正常时,我们使用单表即可,而当业务出现了性能瓶颈时,我们首先考虑用分区的方式来优化,如果分区优化之后仍然存在后遗症,此时我们再来考虑分表分库。
我们知道如果在单表单库的情况下当数据库表的数据量逐渐累积到一定的数量时5000W行或100G以上操作数据库的性能会出现明显下降即使我们使用索引优化或读写库分离性能依然存在瓶颈。此时如果每日数据增长量非常大我们就应该考虑分表避免单表数据量过大造成数据库操作性能下降。
面对海量数据除了单表的性能比较差以外我们在单表单库的情况下数据库连接数、磁盘I/O以及网络吞吐等资源都是有限的并发能力也是有限的。所以在一些大数据量且高并发的业务场景中我们就需要考虑分表分库来提升数据库的并发处理能力从而提升应用的整体性能。
## 如何分表分库?
通常,分表分库分为垂直切分和水平切分两种。
垂直分库是指根据业务来分库,不同的业务使用不同的数据库。例如,订单和消费券在抢购业务中都存在着高并发,如果同时使用一个库,会占用一定的连接数,所以我们可以将数据库分为订单库和促销活动库。
而垂直分表则是指根据一张表中的字段,将一张表划分为两张表,其规则就是将一些不经常使用的字段拆分到另一张表中。例如,一张订单详情表有一百多个字段,显然这张表的字段太多了,一方面不方便我们开发维护,另一方面还可能引起跨页问题。这时我们就可以拆分该表字段,解决上述两个问题。
水平分表则是将表中的某一列作为切分的条件按照某种规则Range或Hash取模来切分为更小的表。
水平分表只是在一个库中如果存在连接数、I/O读写以及网络吞吐等瓶颈我们就需要考虑将水平切换的表分布到不同机器的库中这就是水平分库分表了。
结合以上垂直切分和水平切分,我们一般可以将数据库分为:单库单表-单库多表-多库多表。在平时的业务开发中,我们应该优先考虑单库单表;如果数据量比较大,且热点数据比较集中、历史数据很少访问,我们可以考虑表分区;如果访问热点数据分散,基本上所有的数据都会访问到,我们可以考虑单库多表;如果并发量比较高、海量数据以及每日新增数据量巨大,我们可以考虑多库多表。
这里还需要注意一点我刚刚强调过能不分表分库就不要分表分库。这是因为一旦分表我们可能会涉及到多表的分页查询、多表的JOIN查询从而增加业务的复杂度。而一旦分库了除了跨库分页查询、跨库JOIN查询还会存在跨库事务的问题。这些问题无疑会增加我们系统开发的复杂度。
## 分表分库之后面临的问题
然而,分表分库虽然存在着各种各样的问题,但在一些海量数据、高并发的业务中,分表分库仍是最常用的优化手段。所以,我们应该充分考虑分表分库操作后所面临的一些问题,接下我们就一起看看都有哪些应对之策。
为了更容易理解这些问题,我们将对一个订单表进行分库分表,通过详细的业务来分析这些问题。
假设我们有一张订单表以及一张订单详情表每天的数据增长量在60W单平时还会有一些促销类活动订单增长量在千万单。为了提高系统的并发能力我们考虑将订单表和订单详情表做分库分表。除了分表因为用户一般查询的是最近的订单信息所以热点数据比较集中我们还可以考虑用表分区来优化单表查询。
通常订单的分库分表要么基于订单号Hash取模实现要么根据用户 ID Hash 取模实现。订单号Hash取模的好处是数据能均匀分布到各个表中而缺陷则是一个用户查询所有订单时需要去多个表中查询。
由于订单表用户查询比较多此时我们应该考虑使用用户ID字段做Hash取模对订单表进行水平分表。如果需要考虑高并发时的订单处理能力我们可以考虑基于用户ID字段Hash取模实现分库分表。这也是大部分公司对订单表分库分表的处理方式。
### 1.分布式事务问题
在提交订单时,除了创建订单之外,我们还需要扣除相应的库存。而订单表和库存表由于垂直分库,位于不同的库中,这时我们需要通过分布式事务来保证提交订单时的事务完整性。
通常我们解决分布式事务有两种通用的方式两阶事务提交2PC以及补偿事务提交TCC。有关分布式事务的内容我将在第41讲中详细介绍。
通常有一些中间件已经帮我们封装好了这两种方式的实现例如Spring实现的JTA目前阿里开源的分布式事务中间件Fescar就很好地实现了与Dubbo的兼容。
### 2.跨节点JOIN查询问题
用户在查询订单时我们往往需要通过表连接获取到商品信息而商品信息表可能在另外一个库中这就涉及到了跨库JOIN查询。
通常我们会冗余表或冗余字段来优化跨库JOIN查询。对于一些基础表例如商品信息表我们可以在每一个订单分库中复制一张基础表避免跨库JOIN查询。而对于一两个字段的查询我们也可以将少量字段冗余在表中从而避免JOIN查询也就避免了跨库JOIN查询。
### 3.跨节点分页查询问题
我们知道当用户在订单列表中查询所有订单时可以通过用户ID的Hash值来快速查询到订单信息而运营人员在后台对订单表进行查询时则是通过订单付款时间来进行查询的这些数据都分布在不同的库以及表中此时就存在一个跨节点分页查询的问题了。
通常一些中间件是通过在每个表中先查询出一定的数据,然后在缓存中排序后,获取到对应的分页数据。这种方式在越往后面的查询,就越消耗性能。
通常我们建议使用两套数据来解决跨节点分页查询问题一套是基于分库分表的用户单条或多条查询数据一套则是基于Elasticsearch、Solr存储的订单数据主要用于运营人员根据其它字段进行分页查询。为了不影响提交订单的业务性能我们一般使用异步消息来实现Elasticsearch、Solr订单数据的新增和修改。
### 4.全局主键ID问题
在分库分表后主键将无法使用自增长来实现了在不同的表中我们需要统一全局主键ID。因此我们需要单独设计全局主键避免不同表和库中的主键重复问题。
使用UUID实现全局ID是最方便快捷的方式即随机生成一个32位16进制数字这种方式可以保证一个UUID的唯一性水平扩展能力以及性能都比较高。但使用UUID最大的缺陷就是它是一个比较长的字符串连续性差如果作为主键使用性能相对来说会比较差。
我们也可以基于Redis分布式锁实现一个递增的主键ID这种方式可以保证主键是一个整数且有一定的连续性但分布式锁存在一定的性能消耗。
我们还可以基于Twitter开源的分布式ID生产算法——snowflake解决全局主键ID问题snowflake是通过分别截取时间、机器标识、顺序计数的位数组成一个long类型的主键ID。这种算法可以满足每秒上万个全局ID生成不仅性能好而且低延时。
### 5.扩容问题
随着用户的订单量增加,根据用户 ID Hash 取模的分表中,数据量也在逐渐累积。此时,我们需要考虑动态增加表,一旦动态增加表了,就会涉及到数据迁移问题。
我们在最开始设计表数据量时尽量使用2的倍数来设置表数量。当我们需要扩容时也同样按照2的倍数来扩容这种方式可以减少数据的迁移量。
## 总结
在业务开发之前我们首先要根据自己的业务需求来设计表。考虑到一开始的业务发展比较平缓且开发周期比较短因此在开发时间比较紧的情况下我们尽量不要考虑分表分库。但是我们可以将分表分库的业务接口预留提前考虑后期分表分库的切分规则把该冗余的字段提前冗余出来避免后期分表分库的JOIN查询等。
当业务发展比较迅速的时候我们就要评估分表分库的必要性了。一旦需要分表分库就要结合业务提前规划切分规则尽量避免消耗性能的跨表跨库JOIN查询、分页查询以及跨库事务等操作。
## 思考题
你使用过哪些分库分表中间件呢?欢迎分享其中的实现原理以及优缺点。
期待在留言区看到你的分享。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。

View File

@@ -0,0 +1,122 @@
<audio id="audio" title="38 | 电商系统表设计优化案例分析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/05/64/0513c30c5e40307dabb5384a60f26064.mp3"></audio>
你好,我是刘超。今天我将带你一起了解下电商系统中的表设计优化。
如果在业务架构设计初期,表结构没有设计好,那么后期随着业务以及数据量的增多,系统就很容易出现瓶颈。如果表结构扩展性差,业务耦合度将会越来越高,系统的复杂度也将随之增加。这一讲我将以电商系统中的表结构设计为例,为你详讲解在设计表时,我们都需要考虑哪些因素,又是如何通过表设计来优化系统性能。
## 核心业务
要懂得一个电商系统的表结构设计,我们必须先得熟悉一个电商系统中都有哪些基本核心业务。这部分的内容,只要你有过网购经历,就很好理解。
一般电商系统分为平台型和自营型电商系统。平台型电商系统是指有第三方商家入驻的电商平台,第三方商家自己开设店铺来维护商品信息、库存信息、促销活动、客服售后等,典型的代表有淘宝、天猫等。而自营型电商系统则是指没有第三方商家入驻,而是公司自己运营的电商平台,常见的有京东自营、苹果商城等。
两种类型的电商系统比较明显的区别是卖家是C端还是B端很显然平台型电商系统的复杂度要远远高于自营型电商系统。为了更容易理解商城的业务我们将基于自营型电商系统来讨论表结构设计优化这里以苹果商城为例。
一个电商系统的核心业务肯定就是销售商品了,围绕销售商品,我们可以将核心业务分为以下几个主要模块:
### 1. 商品模块
商品模块主要包括商品分类以及商品信息管理,商品分类则是我们常见的大分类了,有人喜欢将分类细化为多个层级,例如,第一个大类是手机、电视、配件等,配件的第二个大类又分为耳机、充电宝等。为了降低用户学习系统操作的成本,我们应该尽量将层级减少。
当我们通过了分类查询之后就到了商品页面一个商品Item包含了若干商品SKU。商品Item是指一种商品例如IPhone9就是一个Item商品SKU则是指具体属性的商品例如金色128G内存的IPhone9。
### 2. 购物车模块
购物车主要是用于用户临时存放欲购买的商品,并可以在购物车中统一下单结算。购物车一般分为离线购物车和在线购物车。离线购物车则是用户选择放入到购物车的商品只保存在本地缓存中,在线购物车则是会同步这些商品信息到服务端。
目前大部分商城都是支持两种状态的购物车,当用户没有登录商城时,主要是离线购物车在记录用户的商品信息,当用户登录商城之后,用户放入到购物车中的商品都会同步到服务端,以后在手机和电脑等不同平台以及不同时间都能查看到自己放入购物车的商品。
### 3. 订单模块
订单是盘活整个商城的核心功能模块,如果没有订单的产出,平台将难以维持下去。订单模块管理着用户在平台的交易记录,是用户和商家交流购买商品状态的渠道,用户可以随时更改一个订单的状态,商家则必须按照业务流程及时更新订单,以告知用户已购买商品的具体状态。
通常一个订单分为以下几个状态:待付款、待发货、待收货、待评价、交易完成、用户取消、仅退款、退货退款状态。一个订单的流程见下图:
<img src="https://static001.geekbang.org/resource/image/11/3b/11c5a983340d380b07fc02d1bfcabc3b.jpg" alt="">
### 4. 库存模块
这里主要记录的是商品SKU的具体库存信息主要功能包括库存交易、库存管理。库存交易是指用户购买商品时实时消费库存库存管理主要包括运营人员对商品的生产或采购入库、调拨。
一般库存信息分为商品SKU、仓区、实时库存、锁定库存、待退货库存、活动库存。
现在大部分电商都实现了华南华北的库存分区所以可能存在同一个商品SKU在华北没有库存而在华南存在库存的情况所以我们需要有仓区这个字段用来区分不同地区仓库的同一个商品SKU。
实时库存则是指商品的实时库存,锁定库存则表示用户已经提交订单到实际扣除库存或订单失效的这段时间里锁定的库存,待退货库存、活动库存则分别表表示订单退款时的库存数量以及每次活动时的库存数量。
除了这些库存信息,我们还可以为商品设置库存状态,例如虚拟库存状态、实物库存状态。如果一个商品不需要设定库存,可以任由用户购买,我们则不需要在每次用户购买商品时都去查询库存、扣除库存,只需要设定商品的库存状态为虚拟库存即可。
### 5. 促销活动模块
促销活动模块是指消费券、红包以及满减等促销功能,这里主要包括了活动管理和交易管理。前者主要负责管理每次发放的消费券及红包有效期、金额、满足条件、数量等信息,后者则主要负责管理用户领取红包、消费券等信息。
## 业务难点
了解了以上那些主要模块的具体业务之后,我们就可以更深入地去评估从业务落地到系统实现,可能存在的难点以及性能瓶颈了。
### 1. 不同商品类别存在差异,如何设计商品表结构?
我们知道一个手机商品的详细信息跟一件衣服的详细信息差别很大手机的SKU包括了颜色、运行内存、存储内存等而一件衣服则包含了尺码、颜色。
如果我们需要将这些商品都存放在一张表中,要么就使用相同字段来存储不同的信息,要么就新增字段来维护各自的信息。前者会导致程序设计复杂化、表宽度大,从而减少磁盘单页存储行数,影响查询性能,且维护成本高;后者则会导致一张表中字段过多,如果有新的商品类型出现,又需要动态添加字段。
比较好的方式是通过一个公共表字段来存储一些具有共性的字段,创建单独的商品类型表,例如手机商品一个表、服饰商品一个表。但这种方式也有缺点,那就是可能会导致表非常多,查询商品信息的时候不够灵活,不好实现全文搜索。
这时候我们可以基于一个公共表来存储商品的公共信息同时结合搜索引擎将商品详细信息存储到键值对数据库例如ElasticSearch、Solr中。
### 2. 双十一购物车商品数量大增,购物车系统出现性能瓶颈怎么办?
在用户没有登录系统的情况下我们是通过cookie来保存购物车的商品信息而在用户登录系统之后购物车的信息会保存到数据库中。
在双十一期间,大部分用户都会提前将商品加入到购物车中,在加入商品到购物车的这段操作中,由于时间比较长,操作会比较分散,所以对数据库的写入并不会造成太大的压力。但在购买时,由于多数属于抢购商品,用户对购物车的访问则会比较集中了,如果都去数据库中读取,那么数据库的压力就可想而知了。
此时我们应该考虑冷热数据方案来存储购物车的商品信息用户一般都会首选最近放入购物车的商品这些商品信息则是热数据而较久之前放入购物车中的商品信息则是冷数据我们需要提前将热数据存放在Redis缓存中以便提高系统在活动期间的并发性能。例如可以将购物车中近一个月的商品信息都存放到Redis中且至少为一个分页的信息。
当在缓存中没有查找到购物车信息时,再去数据库中查询,这样就可以大大降低数据库的压力。
### 3. 订单表海量数据,如何设计订单表结构?
通常我们的订单表是系统数据累计最快的一张表,无论订单是否真正付款,只要订单提交了就会在订单表中创建订单。如果公司的业务发展非常迅速,那么订单表的分表分库就只是迟早的事儿了。
在没有分表之前订单的主键ID都是自增的并且关联了一些其它业务表。一旦要进行分表分库就会存在主键ID与业务耦合的情况而且分表后新自增ID与之前的ID也可能会发生冲突后期做表升级的时候我们将会面临巨大的工作量。如果我们确定后期做表升级建议提前使用snowflake来生成主键ID。
如果订单表要实现水平分表,那我们基于哪个字段来实现分表呢?
通常我们是通过计算用户ID字段的Hash值来实现订单的分表这种方式可以优化用户购买端对订单的操作性能。如果我们需要对订单表进行水平分库那就还是基于用户ID字段来实现。
在分表分库之后对于我们的后台订单管理系统来说查询订单就是一个挑战了。通常后台都是根据订单状态、创建订单时间进行查询的且需要支持分页查询以及部分字段的JOIN查询如果需要在分表分库的情况下进行这些操作无疑是一个巨大的挑战了。
对于JOIN查询我们一般可以通过冗余一些不常修改的配置表来实现。例如商品的基础信息我们录入之后很少修改可以在每个分库中冗余该表如果字段信息比较少我们可以直接在订单表中冗余这些字段。
而对于分页查询通常我们建议冗余订单信息到大数据中。后台管理系统通过大数据来查询订单信息用户在提交订单并且付款之后后台将会同步这条订单到大数据。用户在C端修改或运营人员在后台修改订单时会通过异步方式通知大数据更新该订单数据这种方式可以解决分表分库后带来的分页查询问题。
### 4. 抢购业务,如何解决库存表的性能瓶颈?
在平时购买商品时,我们一般是直接去数据库检查、锁定库存,但如果是在促销活动期间抢购商品,我们还是直接去数据库检查、更新库存的话,面对高并发,系统无疑会产生性能瓶颈。
一般我们会将促销活动的库存更新到缓存中通过缓存来查询商品的实时库存并且通过分布式锁来实现库存扣减、锁定库存。分布式锁的具体实现我会在第41讲中详讲。
### 5. 促销活动也存在抢购场景,如何设计表?
促销活动中的优惠券和红包交易,很多时候跟抢购活动有些类似。
在一些大型促销活动之前,我们一般都会定时发放各种商品的优惠券和红包,用户需要点击领取才能使用。所以在一定数量的优惠券和红包放出的同时,也会存在同一时间抢购这些优惠券和红包的情况,特别是一些热销商品。
我们可以参考库存的优化设计方式,使用缓存和分布式锁来查询、更新优惠券和红包的数量,通过缓存获取数量成功以后,再通过异步方式更新数据库中优惠券和红包的数量。
## 总结
这一讲,我们结合电商系统实战练习了如何进行表设计,可以总结为以下几个要点:
- 在字段比较复杂、易变动、不方便统一的情况下,建议使用键值对来代替关系数据库表存储;
- 在高并发情况下的查询操作,可以使用缓存代替数据库操作,提高并发性能;
- 数据量叠加比较快的表,需要考虑水平分表或分库,避免单表操作的性能瓶颈;
- 除此之外我们应该通过一些优化尽量避免比较复杂的JOIN查询操作例如冗余一些字段减少JOIN查询创建一些中间表减少JOIN查询。
## 思考题
你在设计表时,是否使用过外键来关联各个表呢?目前互联网公司一般建议逻辑上实现各个表之间的关联,而不建议使用外键来实现实际的表关联,你知道这为什么吗?
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。

View File

@@ -0,0 +1,174 @@
<audio id="audio" title="39 | 数据库参数设置优化,失之毫厘差之千里" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/1d/0f17a4b9a98f62297d3b1ee1910e631d.mp3"></audio>
你好,我是刘超。
MySQL是一个灵活性比较强的数据库系统提供了很多可配置参数便于我们根据应用和服务器硬件来做定制化数据库服务。如果现在让你回想你可能觉得在开发的过程中很少去调整MySQL的配置参数但我今天想说的是我们很有必要去深入了解它们。
我们知道数据库主要是用来存取数据的而存取数据涉及到了磁盘I/O的读写操作所以数据库系统主要的性能瓶颈就是I/O读写的瓶颈了。MySQL数据库为了减少磁盘I/O的读写操作应用了大量内存管理来优化数据库操作包括内存优化查询、排序以及写入操作。
也许你会想我们把内存设置得越大越好数据刷新到磁盘越快越好不就对了吗其实不然内存设置过大同样会带来新的问题。例如InnoDB中的数据和索引缓存如果设置过大就会引发SWAP页交换。还有数据写入到磁盘也不是越快越好我们期望的是在高并发时数据能均匀地写入到磁盘中从而避免I/O性能瓶颈。
>
SWAP页交换SWAP分区在系统的物理内存不够用的时候就会把物理内存中的一部分空间释放出来以供当前运行的程序使用。被释放的空间可能来自一些很长时间没有什么操作的程序这些被释放的空间的数据被临时保存到SWAP分区中等到那些程序要运行时再从SWAP分区中恢复保存的数据到内存中。
所以这些参数的设置跟我们的应用服务特性以及服务器硬件有很大的关系。MySQL是一个高定制化的数据库我们可以根据需求来调整参数定制性能最优的数据库。
不过想要了解这些参数的具体作用,我们先得了解数据库的结构以及不同存储引擎的工作原理。
## MySQL体系结构
我们一般可以将MySQL的结构分为四层最上层为客户端连接器主要包括了数据库连接、授权认证、安全管理等该层引用了线程池为接入的连接请求提高线程处理效率。
第二层是Server层主要实现SQL的一些基础功能包括SQL解析、优化、执行以及缓存等其中与我们这一讲主要相关的就是缓存。
第三层包括了各种存储引擎主要负责数据的存取这一层涉及到的Buffer缓存也和这一讲密切相关。
最下面一层是数据存储层,主要负责将数据存储在文件系统中,并完成与存储引擎的交互。
<img src="https://static001.geekbang.org/resource/image/52/a5/5297b9556d527dec788b5298d4810fa5.jpg" alt="">
接下来我们再来了解下当数据接收到一个SQL语句时是如何处理的。
### 1. 查询语句
一个应用服务需要通过第一层的连接和授权认证再将SQL请求发送至SQL接口。SQL接口接收到请求之后会先检查查询SQL是否命中Cache缓存中的数据如果命中则直接返回缓存中的结果否则需要进入解析器。
解析器主要对SQL进行语法以及词法分析之后便会进入到优化器中优化器会生成多种执行计划方案并选择最优方案执行。
确定了最优执行计划方案之后执行器会检查连接用户是否有该表的执行权限有则查看Buffer中是否存在该缓存存在则获取锁查询表数据否则重新打开表文件通过接口调用相应的存储引擎处理这时存储引擎就会进入到存储文件系统中获取相应的数据并返回结果集。
### 2. 更新语句
数据库更新SQL的执行流程其实跟查询SQL差不多只不过执行更新操作的时候多了记录日志的步骤。在执行更新操作时MySQL会将操作的日志记录到 binlog归档日志这个步骤所有的存储引擎都有。而InnoDB除了要记录 binlog 之外,还需要多记录一个 redo log重做日志
redo log 主要是为了解决 crash-safe 问题而引入的。我们知道当数据库在存储数据时发生异常重启我们需要保证存储的数据要么存储成功要么存储失败也就是不会出现数据丢失的情况这就是crash-safe了。
我们在执行更新操作时首先会查询相关的数据之后通过执行器执行更新操作并将执行结果写入到内存中同时记录更新操作到redo log的缓存中此时redo log中的记录状态为prepare并通知执行器更新完成随时可以提交事务。执行器收到通知后会执行binlog的写入操作此时的binlog是记录在缓存中的写入成功后会调用引擎的提交事务接口更新记录状态为commit。之后内存中的redo log以及binlog都会刷新到磁盘文件中。
## 内存调优
基于以上两个SQL执行过程我们可以发现在执行查询SQL语句时会涉及到两个缓存。第一个缓存是刚进来时的Query Cache它缓存的是SQL语句和对应的结果集。这里的缓存是以查询SQL的Hash值为key返回结果集为value的键值对判断一条SQL是否命中缓存是通过匹配查询SQL的Hash值来实现的。
很明显Query Cache可以优化查询SQL语句减少大量工作特别是减少了I/O读取操作。我们可以通过以下几个主要的设置参数来优化查询操作
<img src="https://static001.geekbang.org/resource/image/db/dd/db97c34b74f0903673badc256ba46cdd.jpg" alt="">
我们可以通过设置合适的 query_cache_min_res_unit 来减少碎片,这个参数最合适的大小和应用程序查询结果的平均大小直接相关,可以通过以下公式计算所得:
query_cache_size - Qcache_free_memory/ Qcache_queries_in_cache
Qcache_free_memory 和 Qcache_queries_in_cache 的值可以通过以下命令查询:
```
show status like 'Qcache%'
```
Query Cache虽然可以优化查询操作但也仅限于不常修改的数据如果一张表数据经常进行新增、更新和删除操作则会造成Query Cache的失效率非常高从而导致频繁地清除Cache中的数据给系统增加额外的性能开销。
这也会导致缓存命中率非常低,我们可以通过以上查询状态的命令查看 Qcache_hits该值表示缓存命中率。如果缓存命中率特别低的话我们还可以通过query_cache_size = 0或者query_cache_type来关闭查询缓存。
经过了Query Cache缓存之后还会使用到存储引擎中的Buffer缓存。不同的存储引擎使用的Buffer也是不一样的。这里我们主要讲解两种常用的存储引擎。
### 1. MyISAM存储引擎参数设置调优
MyISAM存储引擎使用key buffer缓存索引块MyISAM表的数据块则没有缓存它是直接存储在磁盘文件中的。
我们可以通过key_buffer_size设置key buffer缓存的大小而它的大小并不是越大越好。正如我前面所讲的key buffer缓存设置过大实际应用却不大的话就容易造成内存浪费而且系统也容易发生SWAP页交换一般我是建议将服务器内存中可用内存的1/4分配给key buffer。
如果要更准确地评估key buffer的设置是否合理我们还可以通过缓存使用率公式来计算
1-((key_blocks_unused*key_cache_block_size)/key_buffer_size)
>
<p>key_blocks_unused表示未使用的缓存簇blocks<br>
key_cache_block_size表示key_buffer_size被分割的区域大小key_blocks_unused*key_cache_block_size则表示剩余的可用缓存空间一般来说缓存使用率在80%作用比较合适)。</p>
### 2. InnoDB存储引擎参数设置调优
InnoDB Buffer Pool简称IBP是InnoDB存储引擎的一个缓冲池与MyISAM存储引擎使用key buffer缓存不同它不仅存储了表索引块还存储了表数据。查询数据时IBP允许快速返回频繁访问的数据而无需访问磁盘文件。InnoDB表空间缓存越多MySQL访问物理磁盘的频率就越低这表示查询响应时间更快系统的整体性能也有所提高。
我们一般可以通过多个设置参数来调整IBP优化InnoDB表性能。
- **innodb_buffer_pool_size**
IBP默认的内存大小是128M我们可以通过参数innodb_buffer_pool_size来设置IBP的大小IBP设置得越大InnoDB表性能就越好。但是将IBP大小设置得过大也不好可能会导致系统发生SWAP页交换。所以我们需要在IBP大小和其它系统服务所需内存大小之间取得平衡。MySQL推荐配置IBP的大小为服务器物理内存的80%。
我们也可以通过计算InnoDB缓冲池的命中率来调整IBP大小
(1-innodb_buffer_pool_reads/innodb_buffer_pool_read_request)*100
但如果我们将IBP的大小设置为物理内存的80%以后发现命中率还是很低此时我们就应该考虑扩充内存来增加IBP的大小。
- **innodb_buffer_pool_instances**
InnoDB中的IBP缓冲池被划分为了多个实例对于具有数千兆字节的缓冲池的系统来说将缓冲池划分为单独的实例可以减少不同线程读取和写入缓存页面时的争用从而提高系统的并发性。该参数项仅在将innodb_buffer_pool_size设置为1GB或更大时才会生效。
在windows 32位操作系统中如果innodb_buffer_pool_size的大小超过1.3GBinnodb_buffer_pool_instances默认大小就为innodb_buffer_pool_size/128MB否则默认为1。
而在其它操作系统中如果innodb_buffer_pool_size大小超过1GBinnodb_buffer_pool_instances值就默认为8否则默认为1。
为了获取最佳效率建议指定innodb_buffer_pool_instances的大小并保证每个缓冲池实例至少有1GB内存。通常建议innodb_buffer_pool_instances的大小不超过innodb_read_io_threads + innodb_write_io_threads之和建议实例和线程数量比例为1:1。
- **innodb_read_io_threads** / **innodb_write_io_threads**
在默认情况下MySQL后台线程包括了主线程、IO线程、锁线程以及监控线程等其中读写线程属于IO线程主要负责数据库的读取和写入操作这些线程分别读取和写入innodb_buffer_pool_instances创建的各个内存页面。MySQL支持配置多个读写线程即通过innodb_read_io_threads和innodb_write_io_threads设置读写线程数量。
读写线程数量值默认为4也就是总共有8个线程同时在后台运行。innodb_read_io_threads和innodb_write_io_threads设置的读写线程数量与innodb_buffer_pool_instances的大小有关两者的协同优化是提高系统性能的一个关键因素。
在一些内存以及CPU内核超大型的数据库服务器上我们可以在保证足够大的IBP内存的前提下通过以下公式协同增加缓存实例数量以及读写线程。
( innodb_read_io_threads + innodb_write_io_threads ) = innodb_buffe_pool_instances
如果我们仅仅是将读写线程根据缓存实例数量对半来分即读线程和写线程各为实例大小的一半肯定是不合理的。例如我们的应用服务读取数据库的数据多于写入数据库的数据那么增加写入线程反而没有优化效果。我们一般可以通过MySQL服务器保存的全局统计信息来确定系统的读取和写入比率。
我们可以通过以下查询来确定读写比率:
```
SHOW GLOBAL STATUS LIKE 'Com_select';//读取数量
SHOW GLOBAL STATUS WHERE Variable_name IN ('Com_insert', 'Com_update', 'Com_replace', 'Com_delete');//写入数量
```
如果读大于写,我们应该考虑将读线程的数量设置得大一些,写线程数量小一些;否则,反之。
- **innodb_log_file_size**
除了以上InnoDB缓存等因素之外InnoDB的日志缓存大小、日志文件大小以及日志文件持久化到磁盘的策略都影响着InnnoDB的性能。 InnoDB中有一个redo log文件InnoDB用它来存储服务器处理的每个写请求的重做活动。执行的每个写入查询都会在日志文件中获得重做条目以便在发生崩溃时可以恢复更改。
当日志文件大小已经超过我们参数设置的日志文件大小时InnoDB会自动切换到另外一个日志文件由于重做日志是一个循环使用的环在切换时就需要将新的日志文件脏页的缓存数据刷新到磁盘中触发检查点
理论上来说innodb_log_file_size设置得越大缓冲池中需要的检查点刷新活动就越少从而节省磁盘I/O。那是不是将这个日志文件设置得越大越好呢如果日志文件设置得太大恢复时间就会变长这样不便于DBA管理。在大多数情况下我们将日志文件大小设置为1GB就足够了。
- **innodb_log_buffer_size**
这个参数决定了InnoDB重做日志缓冲池的大小默认值为8MB。如果高并发中存在大量的事务该值设置得太小就会增加写入磁盘的I/O操作。我们可以通过增大该参数来减少写入磁盘操作从而提高并发时的事务性能。
- **innodb_flush_log_at_trx_commit**
这个参数可以控制重做日志从缓存写入文件刷新到磁盘中的策略默认值为1。
当设置该参数为0时InnoDB每秒种就会触发一次缓存日志写入到文件中并刷新到磁盘的操作这有可能在数据库崩溃后丢失1s的数据。
当设置该参数为 1 时,则表示每次事务的 redo log 都会直接持久化到磁盘中,这样可以保证 MySQL 异常重启之后数据不会丢失。
当设置该参数为 2 时,每次事务的 redo log 都会直接写入到文件中,再将文件刷新到磁盘。
在一些对数据安全性要求比较高的场景中显然该值需要设置为1而在一些可以容忍数据库崩溃时丢失1s数据的场景中我们可以将该值设置为0或2这样可以明显地减少日志同步到磁盘的I/O操作。
## 总结
MySQL数据库的参数设置非常多今天我们仅仅是了解了与内存优化相关的参数设置。除了这些参数设置我们还有一些常用的提高MySQL并发的相关参数设置总结如下
<img src="https://static001.geekbang.org/resource/image/be/4c/be83083a261bf1302aca81c122b0ac4c.jpg" alt="">
## 思考题
我们知道InnoDB的IBP的内存大小是有限的你知道InnoDB是如何将热点数据留在内存中淘汰非热点数据的吗
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。

View File

@@ -0,0 +1,109 @@
<audio id="audio" title="40 | 答疑课堂MySQL中InnoDB的知识点串讲" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/a9/f38e317137aa02fff1928c38978e3ea9.mp3"></audio>
你好,我是刘超。
模块六有关数据库调优的内容到本周也正式结束了今天我们一起串下MySQL中InnoDB的知识点。InnoDB存储引擎作为我们最常用到的存储引擎之一充分熟悉它的的实现和运行原理有助于我们更好地创建和维护数据库表。
## InnoDB体系架构
InnoDB主要包括了内存池、后台线程以及存储文件。内存池又是由多个内存块组成的主要包括缓存磁盘数据、redo log缓冲等后台线程则包括了Master Thread、IO Thread以及Purge Thread等由InnoDB存储引擎实现的表的存储结构文件一般包括表结构文件.frm、共享表空间文件ibdata1、独占表空间文件ibd以及日志文件redo文件等等。
<img src="https://static001.geekbang.org/resource/image/f2/92/f26b2fad64a9a527b5ac0e8c7f4be992.jpg" alt="">
### 1. 内存池
我们知道,如果客户端从数据库中读取数据是直接从磁盘读取的话,无疑会带来一定的性能瓶颈,缓冲池的作用就是提高整个数据库的读写性能。
客户端读取数据时如果数据存在于缓冲池中客户端就会直接读取缓冲池中的数据否则再去磁盘中读取对于数据库中的修改数据首先是修改在缓冲池中的数据然后再通过Master Thread线程刷新到磁盘上。
理论上来说,缓冲池的内存越大越好。我们在[第38讲](https://time.geekbang.org/column/article/120160)中详细讲过了缓冲池的大小配置方式以及调优。
缓冲池中不仅缓存索引页和数据页还包括了undo页插入缓存、自适应哈希索引以及InnoDB的锁信息等等。
InnoDB允许多个缓冲池实例从而减少数据库内部资源的竞争增强数据库的并发处理能力[第38讲](https://time.geekbang.org/column/article/120160)还讲到了缓冲池实例的配置以及调优。
InnoDB存储引擎会先将重做日志信息放入到缓冲区中然后再刷新到重做日志文件中。
### 2. 后台线程
Master Thread 主要负责将缓冲池中的数据异步刷新到磁盘中除此之外还包括插入缓存、undo页的回收等IO Thread是负责读写IO的线程而Purge Thread主要用于回收事务已经提交了的undo logPager Cleaner Thread是新引入的一个用于协助Master Thread刷新脏页到磁盘的线程它可以减轻Master Thread的工作压力减少阻塞。
### 3. 存储文件
在MySQL中建立一张表都会生成一个.frm文件该文件是用来保存每个表的元数据信息的主要包含表结构定义。
在InnoDB中存储数据都是按表空间进行存放的默认为共享表空间存储的文件即为共享表空间文件ibdata1。若设置了参数innodb_file_per_table为1则会将存储的数据、索引等信息单独存储在一个独占表空间因此也会产生一个独占表空间文件ibd。如果你对共享表空间和独占表空间的理解还不够透彻接下来我会详解。
而日志文件则主要是重做日志文件,主要记录事务产生的重做日志,保证事务的一致性。
## InnoDB逻辑存储结构
InnoDB逻辑存储结构分为表空间Tablespace、段(Segment)、区(Extent)、页Page)以及行(row)。
<img src="https://static001.geekbang.org/resource/image/88/76/88b4ae3373eb5428c238b70423a13e76.jpg" alt="">
### 1. 表空间Tablespace
InnoDB提供了两种表空间存储数据的方式一种是共享表空间一种是独占表空间。 InnoDB 默认会将其所有的表数据存储在一个共享表空间中即ibdata1。
我们可以通过设置innodb_file_per_table参数为11代表独占方式开启独占表空间模式。开启之后每个表都有自己独立的表空间物理文件所有的数据以及索引都会存储在该文件中这样方便备份以及恢复数据。
### 2. 段(Segment)
表空间是由各个段组成的段一般分为数据段、索引段和回滚段等。我们知道InnoDB默认是基于B +树实现的数据存储。
这里的索引段则是指的B +树的非叶子节点而数据段则是B +树的叶子节点。而回滚段则指的是回滚数据之前我们在讲事务隔离的时候就介绍到了MVCC利用了回滚段实现了多版本查询数据。
### 3. 区(Extent) / 页Page
区是表空间的单元结构每个区的大小为1MB。而页是组成区的最小单元页也是InnoDB存储引擎磁盘管理的最小单元每个页的大小默认为16KB。为了保证页的连续性InnoDB存储引擎每次从磁盘申请4-5个区。
### 4. 行Row
InnoDB存储引擎是面向行的row-oriented)也就是说数据是按行进行存放的每个页存放的行记录也是有硬性定义的最多允许存放16KB/2-200行即7992行记录。
## InnoDB事务之redo log工作原理
InnoDB是一个事务性的存储引擎而InnoDB的事务实现是基于事务日志redo log和undo log实现的。redo log是重做日志提供再写入操作实现事务的持久性undo log是回滚日志提供回滚操作保证事务的一致性。
redo log又包括了内存中的日志缓冲redo log buffer以及保存在磁盘的重做日志文件redo log file前者存储在内存中容易丢失后者持久化在磁盘中不会丢失。
InnoDB的更新操作采用的是Write Ahead Log策略即先写日志再写入磁盘。当一条记录更新时InnoDB会先把记录写入到redo log buffer中并更新内存数据。我们可以通过参数innodb_flush_log_at_trx_commit自定义commit时如何将redo log buffer中的日志刷新到redo log file中。
在这里我们需要注意的是InnoDB的redo log的大小是固定的分别有多个日志文件采用循环方式组成一个循环闭环当写到结尾时会回到开头循环写日志。我们可以通过参数innodb_log_files_in_group和innodb_log_file_size配置日志文件数量和每个日志文件的大小。
Buffer Pool中更新的数据未刷新到磁盘中该内存页我们称之为脏页。最终脏页的数据会刷新到磁盘中将磁盘中的数据覆盖这个过程与redo log不一定有关系。
只有当redo log日志满了的情况下才会主动触发脏页刷新到磁盘而脏页不仅只有redo log日志满了的情况才会刷新到磁盘以下几种情况同样会触发脏页的刷新
- 系统内存不足时,需要将一部分数据页淘汰掉,如果淘汰的是脏页,需要先将脏页同步到磁盘;
- MySQL认为空闲的时间这种情况没有性能问题
- MySQL正常关闭之前会把所有的脏页刷入到磁盘这种情况也没有性能问题。
在生产环境中如果我们开启了慢SQL监控你会发现偶尔会出现一些用时稍长的SQL。这是因为脏页在刷新到磁盘时可能会给数据库带来性能开销导致数据库操作抖动。
<img src="https://static001.geekbang.org/resource/image/2a/fd/2a32f1dc2dfbb1f9bc169ee55174d2fd.jpg" alt="">
## LRU淘汰策略
以上我们了解了InnoDB的更新和插入操作的具体实现原理接下来我们再来了解下读的实现和优化方式。
InnoDB存储引擎是基于集合索引实现的数据存储也就是除了索引列以及主键是存储在B +树之外其它列数据也存储在B + 树的叶子节点中。而这里的索引页和数据页都会缓存在缓冲池中在查询数据时只要在缓冲池中存在该数据InnoDB就不用每次都去磁盘中读取页从而提高数据库的查询性能。
虽然缓冲池是一个很大的内存区域但由于存放了各种类型的数据加上存储数据量之大缓冲池无法将所有的数据都存储在其中。因此缓冲池需要通过LRU算法将最近且经常查询的数据缓存在其中而不常查询的数据就淘汰出去。
InnoDB对LRU做了一些优化我们熟悉的LRU算法通常是将最近查询的数据放到LRU列表的首部而InnoDB则是将数据放在一个midpoint位置通常这个midpoint为列表长度的5/8。
这种策略主要是为了避免一些不常查询的操作突然将热点数据淘汰出去,而热点数据被再次查询时,需要再次从磁盘中获取,从而影响数据库的查询性能。
如果我们的热点数据比较多我们可以通过调整midpoint值来增加热点数据的存储量从而降低热点数据的淘汰率。
## 总结
以上InnoDB的实现和运行原理到这里就介绍完了。回顾模块六前三讲我主要介绍了数据库操作的性能优化包括SQL语句、事务以及索引的优化接下来我又讲到了数据库表优化包括表设计、分表分库的实现等等最后我还介绍了一些数据库参数的调优。
总的来讲作为开发工程师我们应该掌握数据库几个大的知识点然后再深入到数据库内部实现的细节这样才能避免经常写出一些具有性能问题的SQL培养调优数据库性能的能力。
这一讲的内容就到这里,相对基础,不熟悉的同学抓紧补补课,如有疑问,欢迎留言讨论。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。