注明:极客时间《MySQL实战45讲》个人学习总结
目录
第十一讲:怎么给字符串加索引?
第十二讲:为什么我的mysql会抖一下 基本概念 sql什么时候会变慢? 尽量避免的情况? innodb刷新脏页的控制策略 总结 问题
第十三讲:为什么表数据删除了一半,表文件不变? innodb_file_per_table 数据删除流程 如何解决增删改的空洞问题? inplace和Online optimize table、analyze table 和 alter table 问题
第十四讲:count这么慢,我该怎么办? 为什么count在innodb会这么慢? 那么如何减少查询工作量? 用缓存系统保存计数 在数据库保存计数 不同的count用法 总结 问题
第十五讲:日志和索引的相关问题 第一个问题两段提交不同的瞬间,mysql异常重启怎么保证数据完整性? 1.mysql怎么知道binlog是完整的? 2.redo log和bin log如何关联? 3.处于 prepare 阶段的 redo log 加上完整 binlog,重启就能恢 复,MySQL 为什么要这么设计? 4.如果这样的话,为什么还要两阶段提交呢?干脆先 redo log 写 完,再写 binlog。崩溃恢复的时候,必须得两个日志都完整才可以。是不是 一样的逻辑? 5.不引入两个日志,也就没有两阶段提交的必要了。只用 binlog 来 支持崩溃恢复,又能支持归档,不就可以了? 6.那能不能反过来,只用 redo log,不要 binlog? 7.redo log 一般设置多大? 8.正常运行中的实例,数据写入后的最终落盘,是从 redo log 更新 过来的还是从 buffer pool 更新过来的呢? 9.redo log buffer 是什么?是先修改内存,还是先写 redo log 文件? 10.解决业务上面的问题
问题
第十六讲:orderby是怎么工作的 全字段排序 rowid 排序 全字段排序 VS rowid 排序 总结 问题
第十七讲:如何正确地显示随机消息?
第十八讲:为什么这些SQL语句逻辑相同,性能却差异巨大? 案例一:条件字段函数操作 案例二:隐式类型转换 案例三:隐式字符编码转换
第十九讲:为什么我只查一行的语句,也执行这么慢?
第二十讲:幻读是什么,幻读有什么问题?
第十一讲:怎么给字符串加索引?
前缀
第一种可以直接全部查询alter table SUser add index index1(email);但是这种比较精准,可以直接查询一次。但是占用内存很大。 第二种是可以查询一部分alter table SUser add index index2(email(6))。这种虽然可以节省内存,但是如果多个前缀相同,那么就会导致最后还是会对比很多次(扫描行增多)。但是定义好长度那么就能节省很多内存。 但是前缀索引无法使用覆盖索引,因为每次都不能够确定是不是已经获取完整的数据信息。
如果遇到区分度不好的字段怎么办?
比如身份证
倒序存储,能够把相同的部分放到后面。不同的特别部分放到前面,就能够增加前缀的区分度。
select * from user where id_card=reverse(身份证)
hash, 创建一个字段int,然后每次插入数据身份证进行hash函数对应到这个字段,但是可能会发生hash冲突,所以查询的时候增加身份证验证。
select * from user where id_card=hash(身份证) and id_card=
他们的区别
倒序占用更多空间,hash占用更少 hash的函数复杂度比reverse更高,CPU使用成本更高 但是hash的稳定性更高 但是他们都不支持范围扫描。原因就是因为已经倒序,排序的位置不同也就是乱序。
问题
”学号 @gmail.com"这个字段如何设置索引?
第十二讲:为什么我的mysql会抖一下
基本概念
sql什么时候会变慢?
就是在把内存的数据页更新到磁盘的数据页的时候 第一种情况就是redo log写满的时候,这个时候checkpoint如果需要往前面走,那么就需要刷脏页。你会问为啥不送到日志文件,因为日志文件已经满了,这个时候就只能刷新脏页,覆盖前面的redo日志 第二种就是内存不够的时候,需要淘汰一些数据页的时候可能是脏页,那么就要进行更新。那么为什么不直接把内存页弄走,下次加载的时候再让redo日志来恢复脏页?这是为了保证数据页只有两种状态,第一种是在内存直接返回记录,第二种不在内存那么肯定就已经刷新到磁盘了。如果还要第三种那么就需要考虑维护的成本. 第三种就是mysql找机会把内存的脏页写回去 第四种mysql关闭的时候需要把脏页写回去
尽量避免的情况?
redo log写满的时候,那么这个时候更新都是被阻塞的,防止更新丢失。如果脏页积累太多导致redo log不能够覆盖前面的数据页那么就会需要频繁chepoint移动,也就是需要刷新脏页导致查询很慢。一边刷新脏页+查询肯定是非常慢的。刷新脏页占用IO资源 内存不够用的时候,脏页的写入。
如果脏页太多导致查询慢,因为脏页需要刷新,干净页并不需要 日志写满,更新阻塞,导致写的性能是0
innodb刷新脏页的控制策略
告知innodb主机的IO能力,innodb才能知道全力刷新脏页的速度,innodb_io_capacity
如果刷新脏页太慢,就会导致内存脏页很多。所以要考虑生产脏页的速度,innodb_max_dirty_pages_pct 脏页的比例上限,默认75 第二就是要参考redo log的写速度
整个过程其实就是计算出脏页的比例和当前日志序号(写到哪)和checkpoint(checkpoint可以回溯到第二章的位置,就是擦除的redo log的位置)的差值N来计算出R,也就是刷新脏页的速度。刷脏页就是把内存的数据写入磁盘
尽量不要让脏页的比例高达75%
Innodb_buffer_pool_pages_dirty/Innodb_buffer_pool_pages_total 脏页比例计算
innodb_flush_neighbors如果是1,那么如果邻居也是脏数据也能够被刷新,但是对于当前的SSD的IOPS已经不再是瓶颈所以可以设置为0,增快响应的速度
总结
先写日志再写硬盘WAL,写日志过程先在内存,就会造成脏页的问题。而这一章就是讲脏页是如何进行处理的,时机应该是什么 刷新脏数据的时候,并不是redo log来把新数据写入,而是对应的内存页更新写入。redo log能够判断这个页是否能更新而不需要去花太大力气去维护脏数据。 而且这个地方需要分清楚的就是内存、日志和磁盘。更新的是内存的脏页
问题
一个内存配置为 128GB、innodb_io_capacity 设置为 20000 的大规格实例,正常会建议 你将 redo log 设置成 4 个 1GB 的文件。 但如果你在配置的时候不慎将 redo log 设置成了 1 个 100M 的文件,会发生什么情况 呢?又为什么会出现这样的情况呢?
导致系统错误认为系统刷新脏页速度很快,那么就会等到脏页比较多的时候才会全力刷新脏页,问题是redo日志容量太小容易写满,导致写多的每时每刻都在刷新脏页,查询速度非常慢。 而且更新的时候需要阻塞其它业务
第十三讲:为什么表数据删除了一半,表文件不变?
innodb_file_per_table
表数据可以存放到共享表空间或者是单独的文件 现在基本存储到.ibd后缀的文件上面 innodb_file_per_table参数off是共享表空间存储,no是单独的文件 no是推荐做法,能够单独管理文件
那么为什么表数据删除一半标文件没有被删除?
数据删除流程
下面的把R4删除了实际上只会标记记录为可以复用,但是没有删除文件里面的数据。 R4这个位置插入的数据只能是300到600之间,但是如果删除一个数据页的记录,那么整个数据页就会被标记为可以复用,就可以插入其他数据了。这种也被叫做空洞 记录复用和数据页复用是不一样,记录复用是只能在某个范围,但是数据页复用是都可以插入。
插入数据到满的pageA里面会造页分裂,这个时候pageA就多出了一个空间但是没有被使用
如何解决增删改的空洞问题?
可以通过重新建表转移数据来减少这些空洞,但是在转移过程中表不能被更新
5.6之后的机制Online DDL
建立临时表,扫描A主键的数据页
生成一个临时文件,并且生成row log记录更新的A操作(也就是记录在这段期间对A的操作)
把log应用到临时文件,得到逻辑数据和A相同的数据文件(然后应用到tmp_file中)
最后就是临时表替换表A
在online ddl的时候,获取到的是MDL读锁,原因就是如果获取的是写锁,那么就不会允许其它线程来给A表写数据,但是读锁可以,那么为什么不把锁去掉?防止其他线程修改A表的结构。
inplace和Online
inplace是sever层的一个说法,实际上DDL操作已经在引擎层做完了,数据也传输完了,相当于server层并没有做数据转移,这就是原地等待,inplace
optimize table、analyze table 和 alter table
optimize table重建+analyze analyze table只是重新统计数据 alter table修改表的结构
问题
1… 一个表 t 文件大小为 1TB; 2. 对这个表执行 alter table t engine=InnoDB; 3. 发现执行完成后,空间不仅没变小,还稍微大了一点儿,比如变成了 1.01TB。 你觉得可能是什么原因呢 ?
第十四讲:count这么慢,我该怎么办?
为什么count在innodb会这么慢?
原因就是innodb需要每行每行地查,原因就是,不是所有数据对于当前事务都是可见的。由于MVCC的原因,一个数据多个版本 对于MyISAM来说总行数直接存在表中 innodb需要一行一行来数,那么为什么需要一行一行数?因为mvcc导致每个事务的视图是不一样的。需要判断记录是不是可读的
那么如何减少查询工作量?
通过比较小普通索引,普通索引下面的就是主键索引,但是对于需要计算数据行来说基本是没问题的。减少扫描的数据量,因为主键索引下面就是数据扫描的数据量更大 show table status->TABLE ROWS显示这个表有多少行,但是这个是通过采样估算得来的,也就是mysql可能会预估错误。
用缓存系统保存计数
在数据库保存计数
解决崩溃丢失问题,因为redo log。 但是仍然会出现上面的问题 但是这个地方可以使用事务和生成readview解决问题。
不同的count用法
server层要什么给什么
innodb只给必要的值
其它count(?)并没有被优化
count(id)
遍历表并且需要取出id返回给server,并且直接累加id的行数,还需要拷贝字段值给server
直接遍历表,返回给server并且不会判空。所以这个速度更快,不需要分析和取出行数据
如果not null,跟id一样 如果null,每次取出字段还要进行判空操作。
事务优化+不用一行一行取出计算。
count(字段)<count(主键id)<count(1)=count(*)
总结
放在redis上计算无法处理多个会话一起工作导致数据不一致问题。但是mysql的一致性视图+事务可以解决这方面的问题
问题
从并发角度来看对于mysql计行数是应该先更新计数还是应该先插入记录?
应该先插入再计数,原因是插入和更新计数都是在同一个事务,如果现在有新的事务需要读取记录数。这里更新记录实际上就是mysql的一个表,如果先更新导致后面的事务需要被阻塞,等待插入完成事务提交才能读取到。但是如果先插入,那么就不会阻塞后面的事务先读取这个count。因为事务有自己的一个一致性视图,只要对应的事务读取到正确的版本的行数就可以了。如果需要插入,两个事务之间是独立不会影响所以没关系。而且另一个事务只是查询而不是更新。所以另一个事务并不会被阻塞。除非另一个事务也需要查询这个时候就要阻塞了。
第十五讲:日志和索引的相关问题
第一个问题两段提交不同的瞬间,mysql异常重启怎么保证数据完整性?
图中的commit不是commit语句的那个,而是commit步骤,也就是commit语句最后的一部分,commit之后那么事务才算是提交完成。 如果在时刻A出现重启。写入redo日志的prepare之后,binlog之前出现问题,所以这里重启之后事务会进行回滚。 时刻B的redo日志写完,binlog也写了,在时刻B的时候重启会怎么处理?
对于崩溃恢复的规则
redo日志完整而且是commit那么直接提交 如果只有prepare,那么需要判断binlog是否完整
1.mysql怎么知道binlog是完整的?
statement格式的binlog最后有一个commit row格式的binlog最后有一个XID event MySQL 5.6.2之后还有binlog-checksum验证内容的正确性 所以binlog就是通过最后是否有这些标志判断是否完整。
2.redo log和bin log如何关联?
他们都有一个XID,崩溃的时候就会扫描redo log 如果发现redo日志存在prepare和commit那么就提交 否则那么就需要验证对应的相同XID的binlog是否完整。
3.处于 prepare 阶段的 redo log 加上完整 binlog,重启就能恢 复,MySQL 为什么要这么设计?
能够让从库和主库保持数据的一致性,这个时候binlog已经写入从库已经可以直接使用了,所以主库只需要提交一下就可以了。
4.如果这样的话,为什么还要两阶段提交呢?干脆先 redo log 写 完,再写 binlog。崩溃恢复的时候,必须得两个日志都完整才可以。是不是 一样的逻辑?
必要性其实就是因为redo日志提交之后但是binlog写入失败,导致的问题就是innodb不允许redo提交的日志之后回滚,那么binlog这时候的数据和当前数据不一致。所以两阶段提交是为了持久化日志的时候的数据一致性。 意思就是分布式事务上面的原理,实际上每个数据库需要都确认好写好redo log和binlog的时候那么这个时候再次去提交,防止redo log提交之后,binlog写失败。
5.不引入两个日志,也就没有两阶段提交的必要了。只用 binlog 来 支持崩溃恢复,又能支持归档,不就可以了?
对于mysql的MyISAM来说binlog是不支持直接崩溃恢复的。而是后来引入的innodb存储引擎的redo log才能够支持崩溃恢复 binlog只是记录了逻辑语句但是没有记录数据页级别的更新操作。所以在binlog1提交之后是不能够再使用的了。但是binlog2如果期中重启就可以回滚到binlog2之前的状态。binlog无法处理数据页的回滚和恢复。 意思其实就是如果在这个时候,binlog1和binlog2仍然处于内存中,这个时候数据页修改但是没有刷新到磁盘,但是binlog1认为已经提交了,那么就不能使用了,但是binlog2没有提交所以可以恢复binlog2的相关内容。但是对于redo log来说就算事务说提交了,但是只要没有刷新到脏页,那么这个redo log就是不能被覆盖的。这个就是基于他们两个日志不同的性质决定是否能够有这个崩溃恢复的能力。
6.那能不能反过来,只用 redo log,不要 binlog?
redo log无法保存历史记录而是每次只要刷新脏页那么redo log就会被覆盖,所以起不了归档的作用
7.redo log 一般设置多大?
8.正常运行中的实例,数据写入后的最终落盘,是从 redo log 更新 过来的还是从 buffer pool 更新过来的呢?
redo日志没有数据页完整记录不可能是redo log更新的 脏页的刷新通常是把内存的脏页进行写盘 redo log更大的作用是更新会内存的数据页状态。让重启后干净数据页变成修改后还没刷新到磁盘的脏页。
9.redo log buffer 是什么?是先修改内存,还是先写 redo log 文件?
先保存日志到buffer,但是这个时候事务还没有提交,如果提交那么就直接送到redo log文件上。
10.解决业务上面的问题
CREATE TABLE ` like ` (
` id` int ( 11 ) NOT NULL AUTO_INCREMENT ,
` user_id` int ( 11 ) NOT NULL ,
` liker_id` int ( 11 ) NOT NULL ,
PRIMARY KEY ( ` id` ) ,
UNIQUE KEY ` uk_user_id_liker_id` ( ` user_id` , ` liker_id` )
) ENGINE = InnoDB ;
CREATE TABLE ` friend` (
id` int ( 11 ) NOT NULL AUTO_INCREMENT ,
` friend_1_id` int ( 11 ) NOT NULL ,
` firned_2_id` int ( 11 ) NOT NULL ,
UNIQUE KEY ` uk_friend` ( ` friend_1_id` , ` firned_2_id` )
PRIMARY KEY ( ` id` )
) ENGINE = InnoDB ;
现在两个表,一个friend一个like。如果A要关注B,那么就先查一下B是否有关注A,如果有那么就加入到friend。如果没有那么就只加入单向关注like。 但是现在A和B同时关注对方,那么出现的问题就是大家都只发现对方没有关注自己,所以没有加入到friend
解决办法就是在关注like表上面加入一个relationship,如果是1那么就userid 关注likeid,如果是2那么就是likeid关注userid,3就是互相关注。问题是怎么处理?实际上每次在查询对方是不是关注自己之前先插入自己要去关注对方的记录也就是插入一个relationship的记录,并且按照A和B的id大小,从小到大放到useid和likeid。userid一定要小于likeid。保证了次序,两个事务如果同时进行关注,那么肯定就先插入一条关注记录,并且查询这个关注记录,这个时候由于插入记录之后,另一个事务是无法插入同一条记录的,因为这里的userid和likeid是唯一索引。所以相当于通过唯一索引给记录加上了锁。那么就能够保证第一个事务单向关注之后,第二项事务能够发现已经插入了记录而且发现对象关注自己,所以可以插入到朋友的行列。而这里能够读取的原因就是因为这里使用的是锁的机制。
mysql> begin ;
insert into ` like ` ( user_id, liker_id, relation_ship) values ( A, B, 1 ) on duplicate key up
select relation_ship from ` like ` where user_id= A and liker_id= B;
insert ignore into friend( friend_1_id, friend_2_id) values ( A, B) ;
commit ;
问题
CREATE TABLE ` t` (
` id` int ( 11 ) NOT NULL primary key auto_increment ,
` a` int ( 11 ) DEFAULT NULL
) ENGINE = InnoDB ;
insert into t values ( 1 , 2 ) ;
update t set a= 2 where id= 1 ;
这个时候发现一个问题就是影响了0行这是为什么?
mysql取出数据之后发现a本来就是2不更新 mysql调用存储引擎修改接口,引擎发现a是2不进行更新 innodb执行加锁更新操作
实际上这里不是第一种也不是第二种。第一种可以通过锁来进行判断。如果发现这里有锁,说明不是mysql直接读取数据,行锁只能是innodb来做。
他仍然是会加锁和更新。因为它并不能直接判断数据就是不变的。由于mvcc版本链,其它事务可能会改变这个a的值,但是对于当前事务来说这个值是不可见的,但是只有做update的时候才能够看到版本链上面有这个数据所以不需要进行改变。当前读不能够直接判断,而是要在做update的时候看到版本链才能判断。因为在存储引擎部分,只能看到id=1和要写a=3的信息,但是无法看到a具体的值。
对于下面这种相当于就是验证了在update之前的条件判断看到的版本仍然只是当前事务能够看到的版本。而不是版本链上面的新的版本。只有在update过程才能使用到版本链的最新版本,并且告诉引擎更新了,但是数值并没有发生改变,影响的行数是0。也就是做了加锁和更新,只是没有影响而已。下面的这个验证就是先是让存储引擎去判断一下a是否等于3,就是能不能在存储引擎直接赋值,很明显最后没有成功,说明仍然还是要调用接口来实现查询这个行数据然后进行更新。
第十六讲:orderby是怎么工作的
CREATE TABLE ` t4` (
` id` int ( 11 ) NOT NULL ,
` city` varchar ( 16 ) NOT NULL ,
` name` varchar ( 16 ) NOT NULL ,
` age` int ( 11 ) NOT NULL ,
` addr` varchar ( 128 ) DEFAULT NULL ,
PRIMARY KEY ( ` id` ) ,
KEY ` city` ( ` city` )
) ENGINE = InnoDB ;
select city, name, age from t where city= '杭州' order by name limit 1000 ;
全字段排序
select city, name, age from t where city= '杭州' order by name limit 1000 ;
上面这个查询语句使用到了文件排序,因为name没有加上索引。
语句执行流程
初始化sort_buffer放入name、city、age三个字段 找到city是杭州的字段的主键,也就是查找二级索引 根据id查询出一行数据的三个字段存入sort_buffer 然后取出city是杭州的下一个id 重复获取 最后sort_buffer里面的数据根据name进行排序 返回前1000行
如果sort_buffer太小那么就需要使用到磁盘的空间进行存储之后等待内存排序完再取出来进行排序,sort_buffer的大小取决于sort_buffer_size 那么为什么外部文件是多个?因为mysql把需要排序的数据分成多份,每一份单独排序之后放到当前的临时文件然后再通过归并排序来把文件进行合并成一个大的有序文件 如果sort_buffer足够大那么直接在内存进行排序,否则越小那么临时文件就越多
SET optimizer_trace= 'enabled=on' ;
select VARIABLE_VALUE into @a from performance_schema. session_status where variable_name = 'Innodb_rows_read' ;
select city, name, age from t where city= '杭州' order by name limit 1000 ;
SELECT * FROM ` information_schema` . ` OPTIMIZER_TRACE` G
select VARIABLE_VALUE into @b from performance_schema. session_status where variable_name = 'Innodb_rows_read' ;
select @b - @a ;
number_of_tmp_files的意思就是有多少个临时文件,这些临时文件最后的处理就是通过归并排序合并成大文件然后返回给客户端。 examined_rows:参与排序的行数。 sort_mode:packed_additional_fields 排序使用了紧凑处理,按照实际空间进行分配
总结:全字段排序实际上就是先创建一个临时表在sort_buffer,然后根据索引去查询对应的id,去到聚簇索引获取对应的字段放入到临时表,最后根据排序列进行排序,如果发现内存不够就放到外部文件进行一个归并排序。
rowid 排序
上面这种全字段排序相当于就是对着原表把数据读取到sort_buffer然后进行排序,如果读取字段太多,也就是一行占用大量数据,那么内存只能读取少量的行数据,需要使用很多外部文件,就会导致速度很慢
如果字段太多怎么处理?
SET max_length_for_sort_data = 16 ;
上面这个是设置如果字段长度大于这个那么就要换一种算法处理
新算法只会把要排序的name和id放到sort_buffer。(这里name+age+city已经大于16了,所以使用新算法)
流程
初始化sort_buffer并且存放name和id 找到city=杭州的所有行数据的id 去到主键索引去找到这些name和id存入sort_buffer 重复取 然后按照name进行排序 取出前1000行结果,并且通过id获取name,age,city三个字段返回给客户端 总结来说就是先减少sort_buffer的字段量,只是用排序列+id,减少外部排序的一个消耗,然后排序完之后再通过id获取所有的数据返回
这种就是row_id排序
也就是这次多访问了一次主键索引,第一次是获取name和id进行排序,第二次才是获取完整的数据,这里第二次获取的数据是直接返回到客户端不会占用内存的。 在这个地方读取的行数是5000,因为多了1000是根据排序好的id去到主键索引进行读取,之前只需要读取4000行,因为通过外部文件排序返回到客户端。不需要多次读取。
而且sort_mode也改成sort_key+rowid说明索引列+id参与了排序
总结:rowid排序的原理其实就是现在sort_buffer创建排序列+id的表,然后通过查询获取对应数据的id,然后访问聚簇索引获取name和id字段放到临时表,然后进行排序,最后再通过id去获取全字段并且返回客户端。返回并不需要占用内存。但是rowid可以解决的问题就是全字段排序的全字段占用的每一行的内存非常多,导致每次加载进内存的行数据实际上很少,那么就只能通过创建更多的临时文件,进行一个归并排序,导致IO成本和CPU成本都是非常高的。但是rowid排序可以通过减少字段量,然后增加每次加载进内存的排序数量,减少IO成本,但是最后需要扫描多一次表。所以仍然需要考虑到两种排序的不同成本比较。
全字段排序 VS rowid 排序
内存大的时候仍然使用全字段排序,减少访问主键索引的次数,减少磁盘的访问。 rowid排序回表经常需要磁盘读。因为现在是name排序而不是主键id 无论是全字段排序(内存足够的时候使用)还是rowid(内存不足的时候使用)其实都是对磁盘IO成本比较高,所以尽量还是需要避免。 之所以要做排序就是因为原来的数据对于排序列来说是无序的。
alter table t add index city_user( city, name) ;
但是这样就不同了。增加了city和name1的一个排序。所以并不需要我们自发进行临时排序。 查询流程
找到city=杭州的所有id 根据id取出所有需要的行数据name,city、age 取出1000条。 由于现在已经通过索引排好序根本就不需要使用file sort 也不需要读取4000行数据。原因是不需要临时排序,只需要直接取出前1000条就可以了。
alter table t add index city_user_age( city, name, age) ;
还能通过覆盖索引继续优化,直接不需要回表。这里的Extra就是using index使用到覆盖索引。 对于order by的原理实际上就是两种using filesort策略来进行排序,但是在有索引的情况下可以大大简化整个过程,不需要送到sort_buffer进行一个排序,也不需要借助外部文件,索引的好处就是减少了IO和CPU的成本让语句运行更快,继续优化的覆盖索引能够让随机磁盘IO减少,在查询数据量大的时候有着非常好的性能。
总结
从一开始的全字段排序到rowid排序。全字段排序的问题就是占用内存大,但是内存足够的情况下还是全字段更方便,因为全字段不需要再次访问主键索引。 但是对于rowid来说内存不足的时候,全字段排序就需要使用很多的临时文件进行归并排序合并成大文件返回到客户端。这个时候IO成本比起rowid排序的回表的随机IO更高。 为了减少排序可以通过索引,为了减少回表可以通过覆盖索引。但是仍然需要考虑各种成本。
问题
如果有索引city_name(city, name),下面语句是否需要排序,如果数据库要求不排序而且显示101页。“limit 10000,100”,如何进行实现?
select * from t where city in ( '杭州' , " 苏州 " ) order by name limit 10100 ;
对于这个语句仍然需要排序,因为city是一个范围查询。所以后面的索引失效了。必须要是city是等值的时候索引才会生效。 解决办法是创建两条语句 select * from t where city=“杭州” order by name limit 10100;和select * from t where city=“苏州” order by name limit 10100; 然后把这两个结果集的10001到10100之间的结果集进行一个归并排序。对于分页来说,相当于是前面这10100条都是要被扫描的,如果不把两个语句拆分的问题就是没有办法使用到索引,如果数据很大的,那么就要进行rowid或者是全字段排序,可能造成的IO成本和CPU成本比多扫描10100行的成本更高。
无条件查询的排序
select * from t order by time
通常都是要使用全表扫描+其中一种文件排序,原因就是就算建立了索引进行排序,最后回表的随机IO成本比起file sort可能更高。 除非是limit的数量比较少。
第十七讲:如何正确地显示随机消息?
内存临时表
CREATE TABLE ` words` (
` id` int ( 11 ) NOT NULL AUTO_INCREMENT ,
` word` varchar ( 64 ) DEFAULT NULL ,
PRIMARY KEY ( ` id` )
) ENGINE = InnoDB ;
select word from words order by rand( ) limit 3 ;
上面这个随机获取3条记录,需要使用到内存临时表和排序。
对于innodb表如果内存足够优先选择字段排序,但是内存表,说明了它并不会进行磁盘IO操作了,而是直接从这个表中获取数据,也就是回表在内存进行,所以rowid排序的优势很大。
执行过程
创建临时表,使用的是memory存储引擎,只有两个字段double R和一个varchar(64) W字段 从words按照主键大小取出数据,并且rand()产生的小数放到R,对于word值存入W字段。 在临时表通过word进行排序。 初始化sort_buffer,一个double和一个整型 从临时表取出R值和位置信息,存入sort_buffer,所以需要对临时表全表扫描。包括之前对words全表扫描现在是已经进行了两次了。扫描了20000行 sort_buffer按照R值进行排序。 设置对应的慢日志信息
set global slow_query_log= on ;
set global slow_query_log_file = '日志文件路径'
set global log_queries_not_using_indexes= on ;
最后取出前三个结果的位置信息。返回到客户端,扫描行数是20003
SET timestamp = 1636443493 ;
select word from words order by rand( ) limit 3 ;
mysql表是怎么定位一行数据的?memory存储引擎不是索引组织表,所以rowid对于这个存储引擎来说就是一个数组下标
所以在这个地方pos就是一个rowid(系统分配),通过排序R小数之后能够快速定位到内存临时表的数据。
总结
对于这个地方rand选择3个随机行,建立了一个临时表,并且通过rowid方式进行了一次排序。排序列+定位的rowid。 其实就是原表扫描,通过函数rand()生成小数R和要取的word字段生成一个临时表,然后再次全表扫描临时表送到sort_buffer的R和位置信息去,sort_buffer进行排序,最后获取前3条数据并且通过位置信息直接定位。因为memory存储引擎使用的是数组,所以对于rowid来说就是数组下标。 那么为什么会优先使用rowid排序?因为这里并不是使用原表而是使用临时表,临时表存储在内存,那么回表根本不会考虑有没有回表IO成本。但是对于全字段排序来说可能会占用大量内存导致的文件排序,所以同样是内存操作,就使用rowid节约内存而且还能够减少IO成本来进行排序。
磁盘临时表
tmp_table_size如果临时表大于这个就需要使用到磁盘临时表 通过internal_tmp_disk_storage_engine来控制 现在设置tmp_table_size=1024,sort_buffer_size=32768,max_length_for_sort_data=16
set tmp_table_size= 1024 ;
set sort_buffer_size= 32768 ;
set max_length_for_sort_data= 16 ;
SET optimizer_trace= 'enabled=on' ;
select word from words order by rand( ) limit 3 ;
SELECT * FROM ` information_schema` . ` OPTIMIZER_TRACE` G
很明显这里word已经比这个max_length_for_sort_data更大,所以使用的是rowid排序法。 问题是R已经8字节,rowid6个字节,10000行也就是140000字节了,已经超出sort_buffer的容量32768,为什么没有使用磁盘临时空间?因为这里使用了新的算法就是优先队列算法而没有使用归并算法。
其实这里的意思也是很简单,如果对于sort_buffer来说,现在先从临时表上面去获取前3条数据,然后通过最大堆的计算方式来从临时表获取最小的3个行数据,那么这个时候的排序就不需要完全使用到所有的sort_buffer空间了。因为如果使用归并排序,实际上是把10000行数据进行排序但是使用的就只有3条,非常耗费空间和性能,而且IO成本非常高。对于上面的图来说,新进来的和最大的那个比较,如果新进来的更小那么就去掉原来的那个,并且重新整理最大堆。 然后这个最大堆就可以直接去临时表上面获取数据。
select city, name, age from t where city= '杭州' order by name limit 1000 ;
对于这里的1000行就没有使用优先队列算法了,因为(name和rowid)*1000的存储空间完全大于sort_buffer_size所以只能够使用归并排序算法了。
随机排序方法
方法1
取出表的主键id最小N和最大的值M 并且随机生成一个数X=(M-N)*rand()+N 取不小于X的第一个ID行 这个方法只是扫描了三行。效率很高,问题是对于一些空洞非常多的数据表来说这个就是bug。不是真正的随机。比如1,2,40001,40002这个时候很明显就是一直选后面的。
select max ( id) , min ( id) into @M , @N from t ;
set @X = floor( ( @M - @N + 1 ) * rand( ) + @N ) ;
select * from t where id >= @X limit 1 ;
方法2
取出表的行数C Y = floor(C * rand())获取这个值 limit Y,1取出一行。 这种其实就是丢弃前Y个。获取下一个记录limit Y,1需要扫描Y+1行还有就是一开始的扫描C,所以代价比上面的更高C+Y+1上面只是3行扫描。
select count ( * ) into @C from t;
set @Y = floor( @C * rand( ) ) ;
set @sql = concat( "select * from t limit " , @Y , ",1" ) ;
prepare stmt from @sql ;
execute stmt;
DEALLOCATE prepare stmt;
总结
直接使用rand()取出行数需要扫描
全表扫描放到临时表(还可能是磁盘临时表),然后进行排序 接着就是获取R(也就是rand()生成的排序列)+rowid,送到sort_buffer进行优先队列排序或者是归并排序。优先队列不需要使用额外的临时文件,但是归并排序需要使用到临时文件。使用的是rowid排序。实际上还是需要再次全表扫描临时表,然后排序。最后取出前3行。代价是20003非常高。 随机的方法
第一种就是去最大和最小id,计算出一个随机值(前提是空洞要少) 第二种是通过扫描行数+随机的Y然后获取Y+1的行数据。相对代价比较高。但是还是会比rand()好。因为不需要排序,也不需要创建临时表。 第二种方法之所以更好原因就是不需要创建临时表,而且不需要排序。所以对应的代价还是要更小一些。即使y不好运也是扫描了20000行,但是由于减少了大量排序的创建表,节约了空间和时间
问题
对于下面的思路还有没有什么可以优化的地方。现在扫描的个数是C+(Y1+1)+(Y2+1)+(Y3+1)
mysql> select count ( * ) into @C from t;
set @Y1 = floor( @C * rand( ) ) ;
set @Y2 = floor( @C * rand( ) ) ;
set @Y3 = floor( @C * rand( ) ) ;
select * from t limit @Y1 ,1 ;
select * from t limit @Y2 ,1 ;
select * from t limit @Y3 ,1
可以通过下面的语句
select * from t limit N, M- N+ 1 ;
把y1和y2,y3进行一个排序,先获取最小那条的id,然后后面的语句就是select * from t id>取出第一条的id limit y2-y1或者是y3-y2 limit1。好处是直接定位了id位置,不需要直接扫描y2行或者是y3行。所以总扫描数是@y1+C+@y3-@y2+@y2-@y1=@y3+C=M+C。
第十八讲:为什么这些SQL语句逻辑相同,性能却差异巨大?
案例一:条件字段函数操作
CREATE TABLE ` tradelog` (
` id` int ( 11 ) NOT NULL ,
` tradeid` varchar ( 32 ) DEFAULT NULL ,
` operator` int ( 11 ) DEFAULT NULL ,
` t_modified` datetime DEFAULT NULL ,
PRIMARY KEY ( ` id` ) ,
KEY ` tradeid` ( ` tradeid` ) ,
KEY ` t_modified` ( ` t_modified` )
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
select count ( * ) from tradelog where month ( t_modified) = 7 ;
对于上面的查询对t_modified使用了函数,导致索引无法辨认出来的7。真正的索引排序是按照原来的值进行排序的,所以一旦经过了函数计算,那么就会导致出来的值是索引无法比较和辨认的。 但是对于这个地方仍然使用了t_modified,原因是t_modified内存占用更小,所以不需要解析所有字段我,从而没有使用聚簇索引。
如果是使用下面的语句,那么就可以使用到索引的快速定位。
select count ( * ) from tradelog where
- > ( t_modified >= '2016-7-1' and t_modified< '2016-8-1' ) or
- > ( t_modified >= '2017-7-1' and t_modified< '2017-8-1' ) or
- > ( t_modified >= '2018-7-1' and t_modified< '2018-8-1' ) ;
案例二:隐式类型转换
select * from tradelog where tradeid= 110717 ;
这条语句tradeid有索引但是最后还是全表扫描,原因就是tradeid是varchar类型,但是这里给定的数据是一个int类型导致的数据类型转换,如果数据类型转换相当于就是做了一个函数。
数据类型转换的规则
对于字符串和数字的比较,通常是字符串转换成数字,也就是说这里的tradeid转换成了数字,所以数据类型和索引不相同无法进行比较。
为什么有数据类型转换就要全表扫描
看到下面这条sql就知道他对索引列进行了函数计算,所以最后索引无法辨认,那么就只能走全表扫描了。
select * from tradelog where CAST( tradid AS signed int ) = 110717 ;
案例三:隐式字符编码转换
CREATE TABLE ` trade_detail` (
` id` int ( 11 ) NOT NULL ,
` tradeid` varchar ( 32 ) DEFAULT NULL ,
` trade_step` int ( 11 ) DEFAULT NULL ,
` step_info` varchar ( 32 ) DEFAULT NULL ,
PRIMARY KEY ( ` id` ) ,
KEY ` tradeid` ( ` tradeid` )
) ENGINE = InnoDB DEFAULT CHARSET = utf8;
select d. * from tradelog l, trade_detail d where d. tradeid= l. tradeid and l. id= 2 ;
插入11条数据到trade_detail,而且有4条是属于id=2的时候tradeid的数据。 但是你会发现连接查询的时候表2并没有使用到任何索引,是一个全表扫描,这是为什么? 原因就是trade_detail是一个utf-8的表,但是对于tradelog就是utf8mb4,那么utf8mb4是utf8的一个超集,因为utf8mb4存储更多的字符,那么就要先把utf8字符集按照数据长度进行拓展,相当于对utf8的表的字段进行了一次函数的转换 所以最后你会发现d.tradeid实际上是进行了一次函数转换的 那么要怎么优化?
优化的关键是utf8需要转换到utf8mb4但是对于utf8mb4并不需要转换成utf8,所以可以改一下对比顺序,实际上就是改一下驱动表,如果是trade_detail作为驱动表,那么实际上是先在detail表上面查询一条记录,然后根据这条记录的取出来的d.tradeid作为常数条件给l.tradeid=d.tradeid,那么这个时候只需要转换一下d.trade.id就行了,但是索引列l.tradeid没有函数转换所以是可以使用的
第十九讲:为什么我只查一行的语句,也执行这么慢?
CREATE TABLE ` t` (
` id` int ( 11 ) NOT NULL ,
` c` int ( 11 ) DEFAULT NULL ,
PRIMARY KEY ( ` id` )
) ENGINE = InnoDB ;
delimiter ; ;
create procedure idata( )
begin
declare i int ;
set i= 1 ;
while ( i<= 100000 ) do
insert into t values ( i, i)
set i= i+ 1 ;
end while ;
end ; ;
delimiter ;
call idata( ) ;
第一类:查询长时间不返回
可能是表被锁住了。 使用show processlist查看一下
select * from t where id= 1 ;
等 MDL 锁
这种就是一个session在修改表那么就会加上MDL锁,如果修改的是表的结构。另一个session在查询表就会被阻塞。
等 flush
这种实际上就是在刷新表的时候,也就是刷新脏页的时候刚好在查询导致的慢。
等行锁
如果一个事务在执行update,刚好另一个事务要查询数据并且需要加上s锁,那么就会导致读的那个事务被阻塞。
select * from t where id= 1 lock in share mode ;
第二类:查询慢
select * from t where c= 50000 limit 1 ;
这条语句c没有加上索引,所以只能够进行一个全表扫描,扫描50000行。所以非常慢
还有一种场景
下面这两个语句设置一个场景让第一个尽可能慢。怎么设置? 首先要知道这两个语句的性质,第一条语句是一致性非锁定读,第二个是一致性锁定读。说明,第一个语句需要根据undo log链去找到自己的那个版本,但是对于第二条直接能够读取当前版本。 知道性质之后,假设sessionA开启一个事务并且要查询select * from t where id=1,问题是在查询之前sessionB执行100w条update 这个id=1的事务。导致undo log链非常长,那么sessionA就要找100w次才能够找到这个版本。
select * from t where id= 1
select * from t where id= 1 lock in share mode
总结:对于查询一行语句慢的几个原因,第一种是被锁住了,类似MDL和行锁,第二种就是flush表,还有一种就是undo log链太长的问题。
问题
下面语句如何加锁,加锁什么时候释放。
begin ;
select * from t where c= 5 for update ;
commit ;
第二十讲:幻读是什么,幻读有什么问题?
上期问题的语句
begin ;
select * from t where c= 5 for update ;
commit ;
这里最简单的解释就是,加上写锁,两阶段加锁协议,所以会在事务结束的时候释放锁。
幻读是什么?
幻读指的是在两次查询同一个范围,但是多出了不同的记录。 就像是下面这个。
可重复读的级别下由于mvcc是不会出现幻读的,但是这里使用了当前读,所以会造成幻读现象。 幻读指的是新插入的数据,而不是修改之后出现的数据。id=0这个就不是幻读。
幻读有什么问题?
这里的sessionA很明显就是没有完全锁住这个d=5的行列,造成的问题就是当前读的时候,其它事务是可以通过修改不同的行的d来得到更多d=5的时候的记录。 这种就破坏了加锁的声明和数据一致性问题。
下面的图作为新的一个例子,展示如果不正确加锁,会导致什么问题。
update t set d= 5 where id= 0 ;
update t set c= 5 where id= 0 ;
insert into t values ( 1 , 1 , 5 ) ;
update t set c= 5 where id= 1 ;
update t set d= 100 where d= 5 ;
上面就是整理出来的所有语句,导致的最后的问题就是sessionA原本只想修改一个记录,但是由于B和C修改并且增加了d=5的记录导致最后这些d=5的记录全部修改成为d=100的记录。最后结果就是(0,5,100)、(1,5,100) 和 (5,5,100)。 这个问题怎么造成?
原因就是在修改d=5这条记录的时候只给一条记录加上了锁。没有给其它修改成d=5的记录加上锁。
现在假设对d=5的记录加锁,并且对所有要修改为d=5的记录都加上锁那么这个时候sessionB的语句序列就会被阻塞。 可以看到sessionB的序列放到了后面,但是sessionC的插入仍然没有解决问题,解决了sessionB的更新记录问题,但是sessionC产生的幻读没有被解决。原因是什么,就是因为id=1这条记录没有插入,所以不能给他加上锁,导致随意的插入。
insert into t values ( 1 , 1 , 5 ) ;
update t set c= 5 where id= 1 ;
update t set d= 100 where d= 5 ;
update t set d= 5 where id= 0 ;
update t set c= 5 where id= 0 ;
如何解决幻读?
幻读产生的原因就是没有办法锁定新插入的记录,也就是没有锁定间隙。那么间隙是什么?间隙就是比如插入记录1,5,那么他们的间隙就是2,3,4。如果再插入7,那么还有一个间隙就是6。也就是说间隙就是插入id之间的空隙,这些空隙没有被锁住就会能够随意插入数据导致最后的产生的幻读问题。
所以解决幻读的关键就是next-key lock。锁定间隙不允许插入数据。比如上面的d=5的查询就会把间隙加上这个next-key lock。那么最左边和最右边怎么处理?由于每个页都有一个最小值的记录和一个最大值的记录,所以只要在最大值和最后一个记录加上一个next-key lock还有就是在最小值和第一个记录之间加上间隙锁,那么最后就能够防止幻读的产生。 这里加上的间隙锁只是对应与当前需要作为条件列的一个间隙。比如c=7,那么要锁定它的话要么就是5-7锁定这个范围,那么就没办法插入c=7这个值的记录。间隙锁之间并不会冲突,因为他们目标基本一样,但是会和不符合规则的插入操作出现冲突。
但是间隙锁仍然有问题
它会导致并发问题,比如下面的这个逻辑,A加上间隙锁(5-10)这个时候id=9并不存在,B也给(5-10)加上间隙锁,那么导致两方都没办法进行插入,导致的死锁问题。
逻辑备份为什么要在可重复读?
为了拿到当前视图,不被其他事务改变导致最后出现数据不一致的问题。而且在mvcc的支持下,是可以做到不使用全局锁的。
思考题
为什么下面的B和C都会阻塞?
什么场景一定要使用可重复读的级别?设置不同级别你会考虑什么?