分类 Mysql 下的文章

唯一索引指的是有唯一性约束的索引,而普通索引则没有约束

两者在使用上的区别有:

  • 在查询场景下,未使用limit时,唯一索引匹配到之后立即返回,普通索引则需要

继续匹配下一条数据,直到不匹配才返回

  • 在更新场景下,如果数据不在buffer pool中,普通索引的更新可以先更新到change buffer中,多次更新可以在change buffer中合并,直到刷盘时才更新到磁盘,而唯一索引无法使用change buffer,因为需要从磁盘加载数据来判断是否符合唯一性

所以,如果是不存在buffer pool中的数据,需要更新的话,非唯一索引的效率更高,所以在写多读少的场景下非唯一索引的性能更高

mysql中有三种日志,分别是:

  1. redo log:InnoDB引擎的重做日志,为了事务的持久性
  2. undo log:InnoDB引擎的回滚日志,为了实现事务的原子性
  3. binlog:Mysql Sever的归档日志,用于数据备份和主从同步

下面具体说明各种日志的原理

Redo Log

mysql的数据都存在磁盘中,当操作数据的时候,都需要从磁盘获取,然后在内存里操作,为了提交效率,Mysql通过Buffer Pool来将索引、数据都缓存起来,而在更新的时候,也会先更新Buffer Pool再将更新过的内存数据刷到磁盘中,而为了避免在刷新到磁盘之前宕机之类的故障导致内存数据丢失,在更新内存的时候,也会采用WAL(Write Ahead Logging)技术将数据先写入到日志中,这个日志就是Redo Log。

什么是Redo Log?

redo log是物理日志,记录的是某个数据页做了什么修改,对某个表空间的某个数据页的某个偏移量做了某个更新,每当执行一个事务就会增加一条redo log。

Undo Log

mysql的事务的原子性,就是保证事务要么成功要么失败,失败是需要回滚的,而undo log就是记录了事务提交前的数据,事务回滚时可以利用undo log进行回滚,回滚的方式则不同的操作有不同的策略:

  • insert

    
    将主键值记录下来,如果要回滚则以主键为条件删除
    
  • delete

    
    将待删除数据记录下来,如果要回滚则将数据插入回去
    
  • update

    
    将待更新数据的旧值记录下来,如果要回滚则将数据更新为旧值
    
    

不同操作下 undo log的格式也不一样,但是每一条undo log都有一个roll_pointer指针和一个trx_id事务ID:

  • 通过trx_id可以看出数据是哪个事务变更的
  • 而roll_pointer则可以将undo log串联成一个链表,这个链表称为版本链

undo log还有一个作用:ReadView + undoLog实现MVCC

undo log也是需要记录redo log的,当修改undo页的时候,会先写入到redo log中,再真正修改Undo 页面

RedoLog和UndoLog的区别:

  1. Redo Log是记录操作完成后的数据,事务提交之后崩溃,需要redo log会做故障恢复
  2. Undo Log是记录操作开始前的数据,事务提交之前崩溃,需要通过undo log回滚数据

Redo Log也不是直接写入到磁盘中的,因为每产生一条redo log就写入一次会导致很高的磁盘IO,所以会先写入到redo log buffer中,redo log的刷盘时机:

  • Mysql正常关闭时
  • 事务提交时
  • redo log buffer写入量大于分配的内存空间的一半时
  • 每隔1秒,定时刷盘

redo log有两个文件,当一个log文件满了后会在另一个log文件上继续写入,写满后再切换回来,而且redo log是一个环形结构,写到末尾后又会从开头写。

故障恢复的时候,先执行redo log,将数据恢复到最新状态,同时也会恢复undo log,再将未提交的事务回滚掉。

BinLog

redo log和undo log都是InnoDB存储引擎产生的,而binlog则是server层产生的。

mysql完成一个更新操作后,server会生成一条binlog日志,当事务提交后,该事务产生的binlog会一起写入到binlog文件。

binlog会记录所有数据表结构的变更以及数据的修改,有3种格式:

  • STATEMENT

    
    每一条操作的sql都会记录到binlog中,主从复制中,slave拿到binlog后需要重放sql来同步数据,当sql中含有动态函数时,同步出来的数据可能会不一致
    
  • ROW

    
    记录了每一条数据变更后的样子,如果是修改表机构或者是批量修改的语句,则会产生大量的变更,导致binlog文件过大
    
  • MIXED

    
    根据情况动态选择使用STATEMENT还是ROW
    
    

BinLog的刷盘时机:

  • 事务提交时,会将写入binlog cache的日志持久化到磁盘
  • 当binlog cache写入量达到binlog_cache_size参数规定的大小后会进行刷盘

事务提交时,binlog cache日志持久化到磁盘的方式有3种,由sync_binlog参数控制:

  • sync_binlog=0(默认)

    
    每次提交事务时,只将binlog cache写入到文件缓存,由系统决定什么时候fsync到磁盘
    
  • sync_binlog=1

    
    每次提交时,都将binlog cache写入到文件缓存并执行fsync,将数据刷到磁盘
    
  • sync_binlog=N(N>1)

    
    每次提交时,只将binlog cache写入到文件缓存,积累到N个事务时,执行fsync,将数据刷到磁盘
    
    

RedoLog和BinLog的区别:

  • RedoLog是覆盖写,将文件写满后会从头进行覆盖,BinLog则是追加写,写满了就创建新的文件,不会覆盖之前的数据
  • RedoLog是物理日志,记录的是对数据页的修改,而BinLog则是有3种不同的格式
  • ReadLog用于故障恢复,BinLog用于备份恢复,主从同步

二阶段提交

从上面的介绍中,可以看出,redo log会影响主库数据,binlog会影响从库数据,如果两者不一致的话,会导致主从数据不一致。

为了避免这个问题,mysql采用了二阶段提交的方式:

  • Prepare(准备阶段):将日志写入到redo log,并设置事务为prepare状态
  • Commit(提交阶段):将日志写入binlog,再设置redolog中事务状态为commit

事务

事务的基本属性(ACID)

  1. 原子性(Atomicity):事务内的所有操作要么一起成功,要么一起失败,是一个不可分割的整体
  2. 一致性(Consistency):事务的执行不会破坏数据库的完整性约束,包括数据的完整性和业务逻辑的完整性
  3. 隔离性(Isolation):不同事务之间互不干扰,彼此隔离
  4. 持久性(Durability):事务执行的结果应该是被数据库保存的,不会回滚

事务的并发问题

  1. 脏读:事务A未提交的数据被另一个事务B读取,事务A被回滚后,事务B读取到的就是脏数据,称为脏读
  2. 不可重复读:事务A查询到数据后,事务B更新了数据并提交,事务A再查询数据时,结果就不一样了,就叫不可重复读
  3. 幻读:事务A将某个条件下数据更新时,事务B又插入了一条符合条件的数据,在事务A结束后,发现还有未更新的数据,跟发生幻觉一样,这就叫幻读

区分不可重复读和幻读
不可重复读侧重于修改,幻读侧重于新增或者删除
不可重复读侧重于事务内获取数据的变化,幻读侧重于事务执行后的变化
要解决不可重复读,锁住相应的行即可,要解决幻读则需要锁表

事务的隔离级别

  1. 读未提交:这种隔离级别下,事务之间没有隔离,会出现脏读、不可重复读、幻读
  2. 读已提交:这种隔离级别下,事务内部是隔离的,但是并行的事务会互相影响,会出现不可重复读、幻读
  3. 可重复读:这种隔离级别下,事务通过MVCC机制根据事务的版本区分是否可以获取数据,不会出现不可重复读,但是还会有幻读
  4. 串行化:事务按序执行,不互相影响,不会出现幻读

开启事务的两种方式

  • begin/end transaction

    执行begin transaction 语句之后,并不会立即开启事务,而是当有sql执行的时候才正式开启事务
  • start transaction with consistence snapshot

    执行语句之后,立即开启事务
    

如何解决事务并发的问题

  • 脏读:RC以及以上隔离级别,通过MVCC机制,读取当前sql可见的数据快照,即可避免脏读
  • 不可重复读:在RR及以上隔离界别,通过MVCC机制,读取当前事务可见数据快照,即可避免不可重复读
  • 幻读:RR级别的next-key lock可以避免幻读,串行化隔离级别下,所有事务无法并行,可以避免幻读

索引

索引是一种排好序的用于提高查询效率的数据结构。

Mysql的数据是存储在磁盘中的,而从磁盘查找数据最大的成本是磁盘的寻道操作,所以一般来说随机的磁盘IO的查询数据最大的性能瓶颈,由此,Mysql借助索引来实现高效查找。

为什么Mysql使用B+树作为索引结构?
Mysql中的索引是由B+树实现的,B+树实质上是一颗多路平衡查找树,类似的查找树有很多数据结构,比如平衡二叉树、红黑树,还有同样可以高效查找的hash表、跳表,之所以选择B+树而不是其他的数据结构,最重要的原因是B+树查询所需要的随机磁盘IO最少。

那为什么说B+树的查询效率高呢?可以通过3层的B+树可查找的数据量来体现:

假设主键是bigint类型(8个字节),每个索引项的指针是6个字节,索引上的每个节点都是一个页游16KB,也就是一个节点可以容纳16KB/(8+6)B=1142个索引项,如果B+树为3层,那有2层非叶子结点作为索引,最后1层叶子结点存放了数据,假设一条记录1KB,也就是一个节点可以存放16KB/1KB=16条数据,总共1142114216约为2kw,也就是3次随机磁盘IO下可检索的数据量为2kw,可以满足大多数场景了

那为什么也不选择B树呢?再对比B树和B+树:

  1. B树的非叶子结点和叶子结点都包含数据(索引+记录的磁盘地址),B+树只有叶子结点有数据,非叶子结点只有索引,所以相同数据量下,B树需要的节点更多,可能会有更大的树高
  2. B树中要做范围查询,需要做中序遍历,而在B+树中,叶子结点之间由双向指针形成了双向链表,做范围查询时,只需在叶子节点链表中遍历即可,涉及的磁盘IO更少
  3. B树的插入删除都可能需要重新做平衡,B+树因为有数据都在叶子结点上,而且所有的非叶子节点都在叶子结点上冗余了,所以插入删除操作大多数都可以直接在叶子节点上完成,不需要对B+树的结构做调整,只有在插入后节点饱存在节点分裂,相比起来插入删除效率更高

在Mysql中,不同的存储引擎的索引有所差别:

InnoDB

InnoDB的索引可分为主键索引和二级索引,其中主键索引是存放了数据的聚集索引(也叫聚簇索引),二级索引则是非聚集索引,依靠叶子结点上的主键值,去主键索引中进行回表查询来获取完整的数据。

当然,如果二级索引是复合索引,sql中查询的字段都在复合索引中可以找到,则不会再去回表查询,这也叫覆盖索引

MyISAM

MyISAM的索引和数据是分别存储的,所以没有聚集索引,所有的索引都只能从索引树中获取到指向磁盘的地址,再进行磁盘IO获取到数据

大多数时候,为了性能,我们都不会在在业务中使用串行化的事务隔离级别,所以必然存在事务并发的问题,对同一个数据的并发处理,很明显会发生竞态条件,产生不可预测的结果,对于需要确定性的程序来说是不可接受的,所以需要锁来保证并发操作下的数据正确性

按锁的定义可分为:

  • 共享锁(S Lock)
  • 排他锁(X Lock)

共享锁一般是读锁,当一个事务获取了共享锁之后,另一个事务也可以对相同的数据获取共享锁,但是如果此时另一个事务想要获取相同数据的排他锁则会被阻塞,必须等待共享锁的释放

排他锁一般是写锁,当一个事务获取了排他锁后,另一个事务无论获取排他锁还是共享锁都会阻塞,需要等待排他锁匙放

也就是共享锁共享锁兼容,排他锁与任何锁都不兼容

InnoDB实现了行级的排他锁共享锁,而为了支待在不同粒度上进行加锁操作,InnoDB允许行级锁与表级锁共存,实现了一种称为意向锁的额外的加锁方式,意向锁是表级的锁,用于表明是否有事物请求对数据加行级锁:

  • 意向共享锁(IS Lock):事务想要获得一张表中某几行的共享锁
  • 意向排他锁(IX Lock):事务想要获得一张表中某几行的排他锁

当事务对一行数据请求排他锁时,会同时对表请求意向排他锁,当另一个事务请求对全表进行修改,没有意向排他锁时,需要一行一行地扫描是否有行被锁定,而有意向排他锁时,则不需要判断,直接等待行锁匙放就行了。

InnoDB三种行锁算法:

  1. 行记录锁(Record Lock

    
    在行记录上加锁,防止事务间删除或者修改数据
    
  2. 间隙锁(Gap Lock

    
    锁住各个记录之间的间隙,不锁记录本身,为了防止幻读
    
  3. 临键锁(Next-Key Lock)

    
    锁住行记录本身与各个记录之间的间隙,为了防止幻读
    

注:可重复读隔离级别(RR)下,MVCC就可以解决快照读的幻读问题,为什么还要next-key 锁来防止并发呢?因为除了RR下的简单select语句是快照读,select … for update、select … lock in share mode都是当前读,还有inert、update、delete都是当前读,所以还需要next-key来防止幻读。

Next-Key Lock是Recod Lock 与 Gap Lock的组合,主要是为了防止幻读,是RR隔离级别下默认的行级锁算法:

  • 唯一索引的等值检索时

    • 如果查询记录存在,next-key lock会退化成record Lock
    • 如果查询记录不存在,next-key lock会退化成gap Lock
  • 唯一索引的范围检索时,next-key lock 退化为间隙锁和记录锁
  • 非唯一索引的等值检索时

    • 如果查询记录存在,除了加next-key lock外,还会加gap lock
    • 如果查询记录不存在,只会加next-key lock,然后退化成gap lock
  • 非唯一索引的范围检索时,next-key lock 不会退化为间隙锁和记录锁
  • 非索引检索则会加上全表next-key lock,也就是锁全表

InnoDB之所以可以实现行级锁,是因为有聚簇索引,基于聚簇索引可以实现在一棵索引树上产生竞态条件,从而实现行级锁

MyISAM中只支持表级锁,表级的共享锁、排他锁锁是Mysql的server层通过一种元数据锁的结构实现的。

InnoDB中依靠锁实现了一个事务的写操作与另一个事务的写操作的隔离性,以下是不同隔离级别下所使用的锁机制:

  • RC:insert、update、delete加Recode Lock
  • RR:iselect、nsert、update、delete都加Recod Lock
  • 串行化:读写都加表锁

MVCC

mvcc是多版本兵法控制,用来在数据库中控制事务并发读写的方法,mvcc只在隔离级别为RC、RR有效,是通过undo log中的版本链与ReadView一致性视图来实现的,mvcc就是在多个事物同时存在时,select语句找到具体版本链上的哪个版本,然后在找到的版本上返回所记录的数据。

原理

通过在每行记录上增加两个隐藏字段,分别保存了记录的创建时的版本号,和删除时的版本号,每开启一个新的事务版本号都会递增。

INSERT

插入的时候,将当前事务的ID作为创建的版本号

SELECT

根据两个条件查找记录:

  • 创建版本号小于当前事务ID
  • 删除版本号不存在或者大于当前事务ID

UPDATE

实际上是插入了一个新行,并且删除了原来记录行,插入的新行创建版本号为当前事务ID,删除的旧行的删除版本号为当前事务ID

DELETE

将当前事务ID作为行的删除版本号

上面的机制保证了,一个事务不会读取到另一个事务新产生的变更。

InnoDB的MVCC实现

InnoDB中会增加三个隐藏的列:

  1. DB_TRX_ID

    记录最近更新这条数据的事务ID
    
  2. DB_ROLL_PTR

    回滚指针,用于配合undo log找到该行的所有旧版本,形成一个版本链
    
  3. DB_ROW_ID

    隐藏的主键,表没有主键或者非NULL唯一列时会创建
    

ReadView是事务在快照读时创建的读视图,记录了事务快照图那一刻,系统中活跃的事务的ID,用于做可见性判断,即当前事务执行快照读的时候,创建一个ReadView,用于判断当前事务可以看到哪些版本的数据,如果当前活跃的事务不符合可见性,还会通过DB_ROLL_PTR回滚指针去undo log中取出历史版本的DB_TRX_ID来做比较,直到获取到满足条件的版本。

ReadView的结构:

  • trx_list

    
    当前活跃的事务列表
    
  • up_limit_id

    
    活跃事务列表中最小的事务ID
    
  • low_limit_id

    
    当前系统还没有分配的下一个事务ID
    
    

可见性判断:

  1. 首先比较DB_TRX_ID < up_limit_id则符合可见性,否则进入下一个判断
  2. 判断DB_TRX_ID ≥ low_limit_id,成立则说明这个版本是在当前ReadView之后才创建的,不可见,进入下一个判断
  3. 最后判断DB_TRX_ID是否在trx_list中,如果不在则说明该版本在创建ReadView之前就commit了,符合可见性,否则说明在创建ReadView时这个版本的事务还是活跃的,没有commit,对应的数据也不可见

不同的隔离级别下,创建ReadView的时机不同

  • RC下,每次快照读都会创建ReadView
  • RR下:只有第一次快照读会创建ReadView,后面

RC隔离级别下,每次快照读都会创建ReadView,也就导致在两次快照读之间,另一个事务的变更会被读取,所以存在 不可重复读

mvcc的优点,读操作不阻塞写操作,写操作也不阻塞读操作,实现了不加锁的并发读写

Mysql的count函数用于统计符合条件的记录数,常用的方式有:

1、count(*)
2、count(1)
3、count(id)
4、count(col)

首先需要明确一点:count函数对于返回的结果集,一行行地判断,不会统计null值。

初学者经常会纠结到底应该使用哪种方式做计数,实际上这四种计数方式是有很大差别的。
count()的原理:

    1. count(*)
      遍历整张表,不需要取出数据来计算,直接按行累计。
    1. count(1)
      遍历整张表,不需要取数,按行计数。
    2. count(id)
      遍历整张表,取出id,按行计数。
    3. count(col)
      遍历整张表,取出col,如果字段定义不为null,取出col之后,按行计数。如果字段定义可以为null,循环对col进行判断是否为null值,再计数。

    这四种计数方式遍历整张表的方式也有不同:

    1. count(*)

      会找任意较小的索引遍历,如果没有二级索引,就会直接遍历主键索引,因为主键索引包含了全表数据,所以在字段比较大的时候,可能会需要频繁去磁盘取数据,导致count(*) 效率低,耗时长,结局方案是给一个小字段加个二级索引,这样count(*) 的时候就会遍历这个二级索引,快速进行计数。
    2. count(1)

          使用索引遍历的选择和 count(*)一致。
    3. count(id)

          使用主键索引遍历
    4. count(col)

          如果col建立了二级索引,则会遍历二级索引,否则主键索引
      

    所以,性能上排序为:count(*) > count(1) > count(id) > count(col)。

    在不考虑是否对null计数得区别的前提下,性能优化的方向,除了使用count(*) 外,就是适当使用小的二级索引。

    mysql的count函数可以计算符合条件的记录条数,比如:

    select count(*) from users;

    执行结果:
    在这里插入图片描述

    上面的sql只是将查询到的记录总数输出,count函数本身还可以配合if函数实现更复杂的计数:

    select count(if(status = 1, 1, null)) from users

    注意,count会将所有非null值计数,所以if里面不符合条件应该返回null。

    如果需要按某个字段计算去重后的数量,则需使用 distinct 关键字:

    select count(distinct last_name) from users

    当需要对1:n的关联查询做统计时,以上简单的count使用方式就不足以实现需求了,比如有以下两个表:

    CREATE TABLE `articles` (
      `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
      `title` varchar(255) NOT NULL COMMENT '标题',
      PRIMARY KEY (`id`)
    );
    
    CREATE TABLE `posts` (
      `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
      `status` tinyint(4) NOT NULL ,
       `article_id` int(10) unsigned NOT NULL
      PRIMARY KEY (`id`)
    ) 

    要在一条sql中查询出所有有帖子(posts)的文章(articles)的数量has_post_cnt、无帖子的文章数量not_post_cnt
    首先必定要做连接来关联文章和帖子:

    select * from articles left join posts on posts.article_id = articles.id 

    一般的思路是连接后再按artiles.id 分组,再在外层对posts.id判断是否等于null:

    select count(if(p_id is not null, 1, null)) has_post_cnt, count(if(p_id is null, 1, null)) not_post_cnt from (
        select articles.id a_id, posts.id p_id from articles left join posts on posts.article_id = artiles.id  group by articles.id
    ) tmp 

    但这样的话子查询效率低,考虑不使用子查询的方式实现,首先就必须去掉分组,不然查询结果只能是按分组聚合的结果,出不了所需的计数,首先看这个sql:

    select count(if(posts.id is not null, 1, null)) has_post_cnt, 
        count(if(posts.id is null, 1, null)) not_post_cnt
        from articles left join posts on posts.article_id = artiles.id

    查询出来的not_post_cnt肯定是对的,因为一条没有帖子的文章和帖子表的连接也就是和null连接,肯定是1:1的,不会有重复记录。
    has_post_cnt由于没有对artiles.id做分组,所以是1:n的,这个数是文章的帖子记录数,考虑同一个文章的帖子记录的article_id字段是唯一的,所以使用distinct来做去重处理:

        select count(distinct if(posts.id is not null, posts.article_id, null)) has_post_cnt, 
        count(if(posts.id is null, 1, null)) not_post_cnt
        from articles left join posts on posts.article_id = artiles.id  group by articles.id

    这里的关键是count(distinct if(p_id is not null, posts.article_id, null)),先做了去重再做了计数,得到的结果即是有帖子记录的文章数。