文章目录
- 前言
- 1. Java基础
- 2. 集合框架
- 3. 多线程和并发编程
- 4. JVM
- 5. MySQL
- 6. 设计模式
- 7. 编程思维
- 8. 计算机网络
- 9. 操作系统
- 10. 工程经验
- 9. 操作系统
- 10. 工程经验
- 11. 面经
前言
面试准备的时候收到了很多前辈文章的帮助,现在暑期实习春招快要结束了,希望总结一下我的知识和经验,也能帮助到其他人。
面试知识千千万,而且在不断地变化,不可能由一篇文章解决所有问题,但是我们又希望在复习的时候能有个条理和指引,能够跳过那些早就已经烂熟于心的知识,快速复习到那些薄弱环节。所以这篇文章的主要价值在于目录,复习的时候快速浏览目录就能知道自己哪个方面不会,快速查漏补缺,节约时间。
不仅仅是对于找工作的时候,虽然看上去背八股文
是完全为了应试,但是实际上我认为掌握知识最重要的目的是为了在真正遇到问题的时候能有一个方向,不至于连怎么查,是哪方面的问题都不知道,只要知道问题大概是什么样的问题,在现在丰富的网络资源下其实就没有解决不了的问题了。
这篇文章记录的是我在面试中遇到的面试真题以及在各处看到的知识点
文章中来源于其他文章的信息会注明原文链接,未注明来源或未贴上链接的知识点来自于JavaGuide或我自己的总结分析
请大家支持原创作者
全面的知识点总结见JavaGuide
1. Java基础
1.1 JDK、JRE和JVM的差别
1.1.1 JDK
JDK是 Java Development Kit,它是功能齐全的 Java SDK。它拥有JRE所拥有的一切,还有编译器( Javac)和工具(如 javadoc和jdb)。它能够创建和编译程序。
1.1.2 JRE
JRE是Java运行时环境。它是运行已编译Java程序所需的所有内容的集合,包括Java 虚拟机(JM),Java类库,java命令和其他的一些基础构件。但是,它不能用于创建新程序。
1.1.3 JVM
Java虚拟机(JVM)是运行Java字节码的虚拟机。JVM有针对不同系统的特定实现( Windows, Linux, macos),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的JVM实现是Java语言“一次编译,随处可以运行”的关键所在。
1.2 自增自减运算符 i++和++i
符号在前就先加/减,符号在后就后加/减”。
1.3 泛型
泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。泛型的本质是参数化类型、也就是说所操作的数据类型被指定为一个参数。
Java泛型类型擦除以及擦除带来的问题
1.4 Java的编译与解释和JIT
我们需要格外注意的是 .class->机器码 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言。
HotSpot 采用了惰性评估(Lazy Evaluation)的做法,根据二八定律,消耗大部分系统资源的只有那一小部分的代码(热点代码),而这也就是 JIT 所需要编译的部分。JVM 会根据代码每次被执行的情况收集信息并相应地做出一些优化,因此执行的次数越多,它的速度就越快。JDK 9 引入了一种新的编译模式 AOT(Ahead of Time Compilation),它是直接将字节码编译成机器码,这样就避免了 JIT 预热等各方面的开销。JDK 支持分层编译和 AOT 协作使用。但是 ,AOT 编译器的编译质量是肯定比不上 JIT 编译器的。
1.5 枚举Enum
1.5.1 Enum基本概念
enum关键字在 java5 中引入,表示一种特殊类型的类,其总是继承java.lang.Enum类
当定义一个枚举类型时,每一个枚举类型成员都可以看作是 Enum 类的实例,这些枚举成员默认都被 final、public, static 修饰,当使用枚举类型成员时,直接使用枚举名称调用成员即可。
方法名称 | 描述 |
---|---|
values() | 以数组返回枚举类型的所有成员 |
valueOf() | 将普通串转换为枚举实例 |
compareTo() | 比较两个枚举成员在定义时的顺序 |
ordinal() | 获取枚举成员的索引位置 |
Enum可以看作一个类,它的成员都可以看成类的实例。当然,这个构建实例的过程不是我们做的,一个enum的构造方法限制是private的,也就是不允许我们调用。
因此,Enum可以拥有类方法和实例方法。我们把Type看做一个类,那么enum中静态的域和方法,都可以视作类方法。和我们调用普通的静态方法一样,调用实例方法同理,只要用Enum的成员调用即可。
最后,对于某个实例而言,还可以单独实现自己的实例方法。
enum Type{
A{
public String getType() {
return "I will not tell you";
}
},B,C,D;
static int value;
public static int getValue() {
return value;
}
String type;
public String getType() {
return type;
}
}
这里,A实例后面的{…}就是属于A的实例方法,可以通过覆盖原本的方法,实现属于自己的定制。
除此之外,我们还可以添加抽象方法在enum中,强制ABCD都实现各自的处理
1.5.2 Enum的优势
以这种方式定义的常量使代码更具可读性,允许进行编译时检查,预先记录可接受值的列表,并避免由于传入无效值而引起的意外行为。相当于增强了可配置型,避免数字的硬编码。
1.5.3 Enum实现一些设计模式
1.5.3.1 枚举实现单例模式
class Resource{
}
public enum SomeThing {
INSTANCE;
private Resource instance;
SomeThing() {
instance = new Resource();
}
public Resource getInstance() {
return instance;
}
}
上面的类Resource是我们要应用单例模式的资源,具体可以表现为网络连接,数据库连接,线程池等等。
获取资源的方式很简单,只要 SomeThing.INSTANCE.getInstance() 即可获得所要实例。下面我们来看看单例是如何被保证的:
首先,在枚举中我们明确了构造方法限制为私有,在我们访问枚举实例时会执行构造方法,同时每个枚举实例都是static final类型的,也就表明只能被实例化一次。在调用构造方法时,我们的单例被实例化。
也就是说,因为enum中的实例被保证只会被实例化一次,所以我们的INSTANCE也被保证实例化一次。
可以看到,枚举实现单例还是比较简单的,除此之外我们再来看一下Enum这个类的声明:
public abstract class Enum<E extends Enum<E>>
implements Comparable<E>, Serializable
可以看到,枚举也提供了序列化机制。某些情况,比如我们要通过网络传输一个数据库连接的句柄,会提供很多帮助。
《Effective Java》指明:
单元素的枚举类型已经成为实现Singleton的最佳方法。
以上内容参见文章:利用枚举实现单例模式
1.6 内部类
以下内容参见文章:详谈Java内部类
1.6.1使用内部类的优势
1.6.1.1 用内部类间接实现多继承
使用内部类最吸引人的原因是:每个内部类都能独立地继承一个(接口的)实现,所以无论外围类是否已经继承了某个(接口的)实现,对于内部类都没有影响。
——《Think in java》
在实际问题中我们会遇到一些接口无法解决或难以解决的问题,此时我们可以使用内部类继承某个具体的或抽象的类,间接解决类无法多继承引起的一系列问题。
注:内部类可以嵌套内部类,但是这极大的破坏了代码的结构,不推荐使用。
/**
1. Outer类继承了ClassA,实现了IFunctionA
*/
public class Outer extends ClassA implements IFunctionA{
/**
* Inner类继承了ClassB,实现了IFunctionB
*/
public class Inner extends ClassB implements IfunctionB{
//
}
}
1.6.1.2内部类的其他优点
除了上面的优点之外还有如下四点:
1、内部类可以用多个实例,每个实例都有自己的状态信息,并且与其他外围对象的信息相互独立。
2、内部类并没有令人迷惑的“is-a”关系,他就是一个独立的实体。
3、内部类提供了更好的封装,除了该外围类,其他类都不能访问。
4、创建内部类对象的时刻并不依赖于外围类对象的创建。
具体来说,内部类信息(属性、方法)可以和外部类重名;内部类是具有类的基本特征的独立实体;可以利用访问修饰符隐藏内部类的实施细节,提供了更好的封装;静态内部类使用时可直接使用,不需先创造外部类。
1.6.2 如何使用内部类
1.6.2.1 成员内部类
/**
* 外部类、成员内部类的定义
*/
public class Outer {
private int outerVariable = 1;
private int commonVariable = 2;
private static int outerStaticVariable = 3;
//省略getter/setter
/**
* 成员方法
*/
public void outerMethod() {
System.out.println("我是外部类的outerMethod方法");
}
/**
* 静态方法
*/
public static void outerStaticMethod() {
System.out.println("我是外部类的outerStaticMethod静态方法");
}
/**
* 内部类
*/
public class Inner {
private int commonVariable = 20;
/**
* 构造方法
*/
public Inner() {
}
/**
* 成员方法,访问外部类信息(属性、方法)
*/
public void innerShow() {
//当和外部类冲突时,直接引用属性名,是内部类的成员属性
System.out.println("内部的commonVariable:" + commonVariable);
//内部类访问外部属性
System.out.println("outerVariable:" + outerVariable);
//当和外部类属性名重叠时,可通过外部类名.this.属性名
System.out.println("外部的commonVariable:" + Outer.this.commonVariable);
System.out.println("outerStaticVariable:" + outerStaticVariable);
//访问外部类的方法
outerMethod();
outerStaticMethod();
}
}
/**
* 外部类访问内部类信息
*/
public void outerShow() {
Inner inner = new Inner();
inner.innerShow();
}
}
/*
1. 其他类使用成员内部类
*/
public class Other {
public static void main(String[] args) {
//外部类对象
Outer outer = new Outer();
//创造内部类对象
Outer.Inner inner = outer.new Inner();
inner.innerShow();
/*
* 可在Outer中定义get方法,获得Inner对象,那么使用时,只需outer.getInnerInstance()即可。
* public Inner getInnerInstance(Inner类的构造方法参数){
* return new Inner(参数);
* }
*/
}
}
小结:成员内部类当成Outer的成员信息存在
1. 可以是任何的访问修饰符。
2. 内部类的内部不能有静态信息。
3. 内部类也是类,该继承继承,该重写重写,该重载重载,this和super随便用。
4. 外部类如何访问内部类信息,必须new之后用实例访问。
5. 内部类可以直接使用外部类的任何信息,如果属性或者方法发生冲突,调用外部类.this.属性或者方法。
1.6.2.2 静态内部类
/**
* 外部类、内部类定义
*/
public class Outer {
private int outerVariable = 1;
/**
* 外部类定义的属性(重名)
*/
private int commonVariable = 2;
private static int outerStaticVariable = 3;
static {
System.out.println("Outer的静态块被执行了……");
}
/**
* 成员方法
*/
public void outerMothod() {
System.out.println("我是外部类的outerMethod方法");
}
/*
* 静态方法
*/
public static void outerStaticMethod() {
System.out.println("我是外部类的outerStaticMethod静态方法");
}
/**
* 静态内部类
*/
public static class Inner {
/**
* 成员信息
*/
private int innerVariable = 10;
private int commonVariable = 20;
static {
System.out.println("Outer.Inner的静态块执行了……");
}
private static int innerStaticVariable = 30;
/**
* 成员方法
*/
public void innerShow() {
System.out.println("innerVariable:" + innerVariable);
System.out.println("内部的commonVariable:" + commonVariable);
System.out.println("outerStaticVariable:"+outerStaticVariable);
outerStaticMethod();
}
/**
* 静态方法
*/
public static void innerStaticShow() {
//被调用时会先加载Outer类
outerStaticMethod();
System.out.println("outerStaticVariable"+outerStaticVariable);
}
}
/**
* 外部类的内部如何和内部类打交道
*/
public static void callInner() {
System.out.println(Inner.innerStaticVariable);
Inner.innerStaticShow();
}
}
其他类使用静态内部类
public class Other {
public static void main(String[] args) {
//访问静态内部类的静态方法,Inner类被加载,此时外部类未被加载,独立存在,不依赖于外围类。
Outer.Inner.innerStaticShow();
//访问静态内部类的成员方法
Outer.Inner oi = new Outer.Inner();
oi.innerShow();
}
}
运行结果:(注意加载顺序)
内部类加载的时候不需要先加载外部类,从他们声称class文件的方式就可以看出内部类已经是一个独立的类了,Outer.Inner不代表要先访问Outer,只是Inner叫这个名字。
小结:和成员内部类对比理解(区别异同)
-
内部可以包含任意的信息。
-
静态内部类的方法只能访问外部类的static关联的信息。
-
利用 外部类.内部类 引用=new 外部类.内部类(); 然后利用引用.成员信息(属性、方法)调用。
-
访问内部类的静态信息,直接外部类.内部类.静态信息就可以了。
-
静态内部类可以独立存在,不依赖于其他外围类。
-
静态内部类与非静态内部类之间存在一个最大的区别,我们知道非静态内部类在编译完成之后会隐含地保存着一个引用,该引用是指向创建它的外围类,但是静态内部类却没有。没有这个引用就意味着:
- 它的创建是不需要依赖外围类的创建。
- 它不能使用任何外围类的非static成员变量和方法。
1.6.2.3 局部内部类
实际上只是在方法内部定义的内部类
- 类前不能有访问修饰符。
- 仅在此方法内使用。
- 无法创造静态信息。
- 可以直接访问方法内的局部变量和参数(有限制,下面详谈),但是不能更改。
- 可以随意的访问外部类的任何信息。
局部内部类访问局部变量的限制
变量’xxx’从内部类中访问,需要final或有效的final
能访问的局部变量限制如下:
- 直接被final修饰的变量。
- 已被赋值且始终未改变的变量(有且仅有赋值一次),引用指向不能改变。JDK8以前(不包括8)只能访问被final修饰的变量。
1.6.2.4 匿名内部类
/*** 外部内、内部类*/public class Outer { public static IAnimal getInnerInstance(String speak){ //下面即时创建匿名内部类,实际上是一个未命名的抽象类或接口的实现类 return new IAnimal(){ @Override public void speak(){ System.out.println(speak); }}; //注意上一行的分号必须有 } public static void main(String[] args){ //调用的speak()是重写后的speak方法。 Outer.getInnerInstance("小狗汪汪汪!").speak(); }}
小结
- 匿名内部类是没有访问修饰符的。
- 使用匿名内部类时,这个new之后的类或接口首先是要存在的,其次我们要重写new后的类或接口的某个或某些方法。
- 匿名内部类访问方法参数时也有和局部内部类同样的限制。
- 匿名内部类没有构造方法。
- 匿名内部类必须继承类或实现接口
1.7 代理
参考文章:代理模式详解
代理模式是一种比较好理解的设计模式。简单来说就是 我们使用代理对象来代替对真实对象(real object)的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。
代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。
1. 静态代理
实际应用场景非常非常少,日常开发几乎看不到使用静态代理的场景。
从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。
静态代理的代表用法是AspectJ,他需要特定编译器,利用编译期修改字节码的方法在编译期创建代理对象
2. 动态代理
从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
1. JDK动态代理
2. CGLib动态代理
2. 动态代理
1.8 类加载
2. 集合框架
3. 多线程和并发编程
4. JVM
5. MySQL
5.1和5.2参考了一文搞懂MySQL索引所有知识点(建议收藏)
5.1 MySQL数据结构
5.1.1 B+树与其他数据结构的对比
1. 二叉搜索树
特点:每个节点最多两个分叉,做左子树小,右子树大。
缺点:依赖于根结点的选取和数据的特点,有可能退化为链表
2. 平衡二叉树
一种通过旋转来保证左右子树层级最多相差一的数据结构,查询的时间复杂度是O(logn)。
缺点:
- 时间复杂度依赖于树高,每个节点的读取都要对应一次磁盘IO,而磁盘IO每次都会读取一页的内容,导致IO利用率很低。
- 不支持快速范围查询
3. B树
MySQL的数据是存储在磁盘文件中的,查询处理数据时,需要先把磁盘中的数据加载到内存中,磁盘IO 操作非常耗时,所以我们优化的重点就是尽量减少磁盘 IO 操作。访问二叉树的每个节点就会发生一次IO,如果想要减少磁盘IO操作,就需要尽量降低树的高度。那如何降低树的高度呢?
假如key为bigint=8字节,每个节点有两个指针,每个指针为4个字节,一个节点占用的空间16个字节(8+4*2=16)。
因为在MySQL的InnoDB存储引擎一次IO会读取的一页(默认一页16K)的数据量,而二叉树一次IO有效数据量只有16字节,空间利用率极低。为了最大化利用一次IO空间,一个简单的想法是在每个节点存储多个元素,在每个节点尽可能多的存储数据。每个节点可以存储1000个索引(16k/16=1000),这样就将二叉树改造成了多叉树,通过增加树的叉树,将树从高瘦变为矮胖。构建1百万条数据,树的高度只需要2层就可以(1000*1000=1百万),也就是说只需要2次磁盘IO就可以查询到数据。磁盘IO次数变少了,查询数据的效率也就提高了。
这种数据结构我们称为B树,B树是一种多叉平衡查找树。
缺点:
- 不支持快速范围查询
- 因为非叶子结点中还会存储data行记录,data占用的空间导致每页中存储的索引量减少,树就会相应地变高,磁盘IO次树变大。
4. B+树
B树的缺点在于对于范围查找的支持很差,因为数据不仅仅存储在叶子结点,而是路径上每一个可能节点,所以为了支持范围查找,我们使用B+树
B+树和B树最主要的区别在于非叶子节点是否存储数据的问题
- B树:非叶子节点和叶子节点都会存储数据。
- B+树:只有叶子节点才会存储数据,非叶子节点至存储键值。叶子节点之间使用双向指针连接,最底层的叶子节点形成了一个双向有序链表。
4.1 实际使用中B+树的树高
首先,MySQL中默认一页的大小和操作系统中的不同,是统一用16k的(不同于一般操作系统到的4k)
MySQL中InnoDB中对B+树实际上是按照主键索引的。所以此树中每个节点存储的数据:
• 非叶子节点:主键+指针
• 叶子节点:数据
假设一行数据大小为1K,那么一页存16条数据,即一个叶子节点能存16条数据;再看非叶子节点,假设主键ID为bigint类型,那么长度为8B,指针大小在InnoDB源码中为6B,一共就是14B,那么一页里就可以存储16K/14=1170个(主键+指针),那么一颗高度为2的B+树能存储的数据为:117016=18720条,一颗高度为3的B+树能存储的数据为:11701170*16=21902400(千万级条)。所以在InnoDB中B+树高度一般为1-3层,它就能满足千万级的数据存储。在查找数据时一次页的查找代表一次IO,所以通过主键索引查询通常只需要1-3次IO操作即可查找到数据。所以也就回答了我们的问题,1页=16k这么设置是比较合适的,是适用大多数的企业的,当然这个值是可以修改的,所以也能根据业务的时间情况进行调整。
5.1.2 InnoDB和MyISAM的数据结构
1. MyISAM
MyISAM的表分为三个文件:表结构文件、数据文件和索引文件
在索引文件中,根据索引查找时叶子结点存储的是句柄,是数据文件中的指针,真正的数据存储在数据文件中,每个索引都是一样的
缺点:根据主键查询时仍然要通过句柄再找一次,增加了一次IO
2. InnoDB
InnoDB的表分为两个文件,分别是表结构文件和数据文件,索引也存在数据文件中
每个InnoDB表都有一个聚簇索引 ,聚簇索引使用B+树构建,叶子节点存储的数据是整行记录。一般情况下,聚簇索引等同于主键索引,当一个表没有创建主键索引时,InnoDB会自动创建一个ROWID字段来构建聚簇索引。
除了聚簇索引外的索引都称为辅助索引,辅助索引和主键索引组织树的方式是相同的,但是叶子结点存放的是主键的值,要得到数据还要再根据主键进行查找
5.2 MySQL索引
5.2.1 单列索引
1. 主键索引
根据主键搜索,是MySQL索引中最快的,主键是MySQL数据组织最关键的,即便建表中没有定义主键,MySQL也会定义默认隐藏主键,根据B+数据结构的特点,主键最好定易为已于比较,且单增的数据
主键索引也叫聚簇索引,其他所有索引都叫辅助索引或非聚簇索引
2. 普通索引
MySQL中基本索引类型,没有什么限制,允许在定义索引的列中插入重复值和空值。
3. 唯一索引
索引列中的值必须是唯一的,但是允许为空值。
4. 全文索引
只能在文本类型CHAR,VARCHAR,TEXT类型字段上创建全文索引。字段长度比较大时,如果创建普通索引,在进行like模糊查询时效率比较低,这时可以创建全文索引。 MyISAM和InnoDB中都可以使用全文索引。
5. 前缀索引
在文本类型如CHAR,VARCHAR,TEXT类列上创建索引时,可以指定索引列的长度,但是数值类型不能指定。
6. 空间索引
7. 覆盖索引
覆盖索引并不是说是索引结构,覆盖索引是一种很常用的优化手段。因为在使用辅助索引的时候,我们只可以拿到主键值,相当于获取数据还需要再根据主键查询主键索引再获取到数据。覆盖索引意味着我们查询到组合索引的叶子节点就可以直接返回了,而不需要回表。这种情况就是覆盖索引。
参考文章:mysql-覆盖索引
主键这个数据本身就是聚簇索引的一部分,而覆盖索引这里同理,也就是覆盖索引本身包含了要查找的所有数据,所以可见,覆盖索引首先必须存储索引的列,哈希索引、空间索引、全文索引都不存储索引列的值,不能做覆盖索引,所以MySQL只能用B-Tree做覆盖索引。
当发起一个被索引覆盖的查询(也叫作索引覆盖查询)时,在EXPLAIN的Extra列可以看到“Using index”的信息
从执行结果上看,这个SQL语句只通过索引,就取到了所需要的数据,这个过程就叫做索引覆盖。
请务必结合下面的优化场景再深入理解覆盖索引
覆盖索引的几种优化场景
1.无WHERE条件的查询优化:
执行计划中,type 为ALL,表示进行了全表扫描
如何改进?优化措施很简单,就是对这个查询列建立索引。如下:
ALERT TABLE t1 ADD KEY(staff_id);
再看一下执行计划
explain select sql_no_cache count(staff_id) from t1*************************** 1. row *************************** id: 1 select_type: SIMPLE table: t1 type: indexpossible_keys: NULL key: staff_id key_len: 1 ref: NULL rows: 1023849 Extra: Using index1 row in set (0.00 sec)
其中possible_key: NULL,说明没有WHERE条件时查询优化器无法通过索引检索数据,也就是不会回表,这里使用了索引的优点,即从索引中获取数据,减少了读取的数据块的数量,避免回表,这就是覆盖索引的思想。 无where条件的查询,可以通过索引来实现索引覆盖查询,但前提条件是,查询返回的字段数足够少,毕竟,建立key length过长的索引,始终不是一件好事情。
- 查询消耗
从时间上看,小了0.13 sec
2. 二次索引优化
如下这个查询场景
select sql_no_cache rental_date from t1 where inventory_id<80000;……| 2005-08-23 15:08:00 || 2005-08-23 15:09:17 || 2005-08-23 15:10:42 || 2005-08-23 15:15:02 || 2005-08-23 15:15:19 || 2005-08-23 15:16:32 |+---------------------+79999 rows in set (0.13 sec)
explain select sql_no_cache rental_date from t1 where inventory_id<80000*************************** 1. row *************************** id: 1 select_type: SIMPLE table: t1 type: rangepossible_keys: inventory_id key: inventory_id key_len: 3 ref: NULL rows: 153734 Extra: Using index condition1 row in set (0.00 sec)
其中Extra : Using index condition 表示使用的索引方式为二级检索,也就是通过索引查询获得主键再回表查询。可想而知,回表是其瓶颈。
尝试针对这个SQL建立联合索引,如下:
alter table t1 add key(inventory_id,rental_date);
explain select sql_no_cache rental_date from t1 where inventory_id<80000*************************** 1. row *************************** id: 1 select_type: SIMPLE table: t1 type: rangepossible_keys: inventory_id,inventory_id_2 key: inventory_id_2 key_len: 3 ref: NULL rows: 162884 Extra: Using index1 row in set (0.00 sec)
Extra : Using index 表示一级检索,没有回表查询的过程,实现了索引覆盖
3. 分页查询优化
如下这个查询场景
select tid,return_date from t1 order by inventory_id limit 50000,10;+-------+---------------------+| tid | return_date |+-------+---------------------+| 50001 | 2005-06-17 23:04:36 || 50002 | 2005-06-23 03:16:12 || 50003 | 2005-06-20 22:41:03 || 50004 | 2005-06-23 04:39:28 || 50005 | 2005-06-24 04:41:20 || 50006 | 2005-06-22 22:54:10 || 50007 | 2005-06-18 07:21:51 || 50008 | 2005-06-25 21:51:16 || 50009 | 2005-06-21 03:44:32 || 50010 | 2005-06-19 00:00:34 |+-------+---------------------+10 rows in set (0.75 sec)
explain select tid,return_date from t1 order by inventory_id limit 50000,10*************************** 1. row *************************** id: 1 select_type: SIMPLE table: t1 type: ALLpossible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 1023675 1 row in set (0.00 sec)
看出是全表扫描。加上而外的排序,性能消耗很大,执行计划很糟糕
如何通过覆盖索引优化呢?
我们创建一个索引,包含排序列以及返回列,由于tid是主键字段,因此,下面的复合索引就包含了tid的字段值(因为所有辅助索引叶子结点都是主键嘛)
alter table t1 add index liu(inventory_id,return_date);
效果:
select tid,return_date from t1 order by inventory_id limit 50000,10;+-------+---------------------+| tid | return_date |+-------+---------------------+| 50001 | 2005-06-17 23:04:36 || 50002 | 2005-06-23 03:16:12 || 50003 | 2005-06-20 22:41:03 || 50004 | 2005-06-23 04:39:28 || 50005 | 2005-06-24 04:41:20 || 50006 | 2005-06-22 22:54:10 || 50007 | 2005-06-18 07:21:51 || 50008 | 2005-06-25 21:51:16 || 50009 | 2005-06-21 03:44:32 || 50010 | 2005-06-19 00:00:34 |+-------+---------------------+10 rows in set (0.03 sec)
可以发现,添加复合索引后,速度提升0.7s,直接提升了25倍
我们看一下改进后的执行计划
explain select tid,return_date from t1 order by inventory_id limit 50000,10 \G*************************** 1. row *************************** id: 1 select_type: SIMPLE table: t1 type: indexpossible_keys: NULL key: liu key_len: 9 ref: NULL rows: 50010 Extra: Using index1 row in set (0.00 sec)
可以见得,使用了复合索引,不需要回表
对比一下如下的改写SQL,思想是通过索引消除排序
select a.tid,a.return_date from t1 a inner join (select tid from t1 order by inventory_id limit 800000,10) b on a.tid=b.tid;
并在此基础上,我们为inventory_id列创建索引,并删除之前的覆盖索引
alter table t1 add index idx_inid(inventory_id);drop index liu;
select a.tid,a.return_date from t1 a inner join (select tid from t1 order by inventory_id limit 800000,10) b on a.tid=b.tid;+--------+---------------------+| tid | return_date |+--------+---------------------+| 800001 | 2005-08-24 13:09:34 || 800002 | 2005-08-27 11:41:03 || 800003 | 2005-08-22 18:10:22 || 800004 | 2005-08-22 16:47:23 || 800005 | 2005-08-26 20:32:02 || 800006 | 2005-08-21 14:55:42 || 800007 | 2005-08-28 14:45:55 || 800008 | 2005-08-29 12:37:32 || 800009 | 2005-08-24 10:38:06 || 800010 | 2005-08-23 12:10:57 |+--------+---------------------+
这种优化手段较前者时间多消耗了大约140ms。这种优化手段虽然使用索引消除了排序,但是还是要通过主键值回表查询。因此,在select返回列较少或列宽较小的时候,我们可以通过建立复合索引的方式优化分页查询,效果更佳,因为它不需要回表!
4、建了索引但是查询不走索引
CREATE TABLE `t_order` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,`order_code` char(12) NOT NULL,`order_amount` decimal(12,2) NOT NULL,PRIMARY KEY (`id`),UNIQUE KEY `uni_order_code` (`order_code`) USING BTREE )ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
select order_code,order_amount from t_order order by order_code limit 1000;
发现虽然在order_code上建了索引,但是看查询计划却不走索引,为什么呢?因为数据行读取order_amount,所以是随机IO。那怎么办?重新建索引,使用覆盖索引。
ALTER TABLE `t_order` ADD INDEX `idx_ordercode_orderamount` USING BTREE (`order_code` ASC, `order_amount` ASC);
再看执行计划,就走到索引了
覆盖索引总结
覆盖索引是一种非常强大的工具,能大大提高查询性能,只需要读取索引而不需要读取数据,有以下优点:
-
索引项通常比记录要小,所以MySQL访问更少的数据。
-
索引都按值得大小存储,相对于随机访问记录,需要更少的I/O。
-
数据引擎能更好的缓存索引,比如MyISAM只缓存索引。
-
覆盖索引对InnoDB尤其有用,因为InnoDB使用聚集索引组织数据,如果二级索引包含查询所需的数据,就不再需要在聚集索引中查找了。
限制:
-
覆盖索引也并不适用于任意的索引类型,索引必须存储列的值。
-
Hash和full-text索引不存储值,因此MySQL只能使用B_Tree。
-
不同的存储引擎实现覆盖索引都是不同的,并不是所有的存储引擎都支持覆盖索引。
-
如果要使用覆盖索引,一定要注意SELECT列表值取出需要的列,不可以SELECT * ,因为如果将所有字段一起做索引会导致索引文件过大,查询性能下降。
5.2.2 组合索引
组合索引的使用,需要遵循最左前缀匹配原则(最左匹配原则)。一般情况下在条件允许的情况下使用组合索引替代多个单列索引使用。
联合索引的创建原则,在创建联合索引的时候因该把频繁使用的列、区分度高的列放在前面,频繁使用代表索引利用率高,区分度高代表筛选粒度大,这些都是在索引创建的需要考虑到的优化场景,也可以在常需要作为查询返回的字段上增加到联合索引中,如果在联合索引上增加一个字段而使用到了覆盖索引,那我建议这种情况下使用联合索引。
联合索引的使用
- 考虑当前是否已经存在多个可以合并的单列索引,如果有,那么将当前多个单列索引创建为一个联合索引。
- 当前索引存在频繁使用作为返回字段的列,这个时候就可以考虑当前列是否可以加入到当前已经存在索引上,使其查询语句可以使用到覆盖索引。
最左匹配原则(重要)
最左前缀匹配原则和联合索引的索引存储结构和检索方式是有关系的。
在组合索引树中,最底层的叶子节点按照第一列a列从左到右递增排列,但是b列和c列是无序的,b列只有在a列值相等的情况下小范围内递增有序,而c列只能在a,b两列相等的情况下小范围内递增有序。
就像上面的查询,B+树会先比较a列来确定下一步应该搜索的方向,往左还是往右。如果a列相同再比较b列。但是如果查询条件没有a列,B+树就不知道第一步应该从哪个节点查起。
可以说创建的idx_abc(a,b,c)索引,相当于创建了(a)、(a,b)(a,b,c)三个索引。、
组合索引的最左前缀匹配原则:使用组合索引查询时,MySQL会一直向右匹配直至遇到范围查询(>、<、between、like)就停止匹配。
5.2.3 建立索引的几个原则
参考文章:【57期】面试官问,MySQL建索引需要遵循哪些原则呢? (qq.com)
1. 选择区分度高的索引,最好是唯一性索引
唯一性索引的值是唯一的,可以更快速的通过该索引来确定某条记录。例如,学生表中学号是具有唯一性的字段。为该字段建立唯一性索引可以很快的确定某个学生的信息。如果使用姓名的话,可能存在同名现象,从而降低查询速度。
区分度的公式是count(distinct col)/count(*),表示字段不重复的比例,比例越大我们扫描的记录数越少,唯一键的区分度是1,而一些状态、性别字段可能在大数据面前区分度就 是0,那可能有人会问,这个比例有什么经验值吗?使用场景不同,这个值也很难确定,一般需要join的字段我们都要求是0.1以上,即平均1条扫描10条记录
2.为经常需要排序、分组和联合操作的字段建立索引
经常需要ORDER BY、GROUP BY、DISTINCT和UNION等操作的字段,排序操作会浪费很多时间。如果为其建立索引,可以有效地避免排序操作。
3.为常作为查询条件的字段建立索引
如果某个字段经常用来做查询条件,那么该字段的查询速度会影响整个表的查询速度。因此,为这样的字段建立索引,可以提高整个表的查询速度。
4.限制索引的数目
索引的数目不是越多越好。每个索引都需要占用磁盘空间,索引越多,需要的磁盘空间就越大。修改表时,对索引的重构和更新很麻烦。越多的索引,会使更新表变得很浪费时间。
5.尽量使用数据量少的索引
如果索引的值很长,那么查询的速度会受到影响。例如,对一个CHAR(100)类型的字段进行全文检索需要的时间肯定要比对CHAR(10)类型的字段需要的时间要多。
6.尽量使用前缀来索引
如果索引字段的值很长,最好使用值的前缀来索引。例如,TEXT和BLOG类型的字段,进行全文检索会很浪费时间。如果只检索字段的前面的若干个字符,这样可以提高检索速度。
7. 尽量使用不常更新的字段做索引
因为常更新的字段每次更新时都要修改索引结构,所以尽量选择不常更新的字段节省消耗。
8.删除不再使用或者很少使用的索引
表中的数据被大量更新,或者数据的使用方式被改变后,原有的一些索引可能不再需要。数据库管理员应当定期找出这些索引,将它们删除,从而减少索引对更新操作的影响。
9. 最左前缀匹配原则(重要)。
mysql会一直向右匹配直到遇到范围查询(>、<、between、like)就停止匹配,比如a =”1” and b=”2” and c> 3 and d = 4
如果建立(a,b,c,d)顺序的索引,d是用不到索引的,如果建立(a,b,d,c)的索引则都可以用到,a,b,d的顺序可以任意调整。因为mysql的查询优化器会帮你优化成索引可以识别的形式
10. 索引列不能参与计算、函数、类型转换等操作。
比如from_unixtime(create_time) = ’2014-05-29’
就不能使用到索引,因为当索引参与计算时,其实际上就没办法保持原来的顺序了,此索引无法命中。但是语句可以写成create_time = unix_timestamp(’2014-05-29’);
来规避此问题
11.尽量的扩展索引,不要新建索引。
比如表中已经有a的索引,现在要加(a,b)的索引,那么只需要修改原来的索引即可
12.group by ,order by的字段都适合建立索引
group by,order by如果没有索引,则会使用file sort建立临时表再排序的做法,非常耗时,所以宜在这些字段上建立索引
13.同时存在where和order by/group by的情况如何建联合索引
SELECT * FROM [table] WHERE [columnX] = [value] and [columnY] = [value] ORDER x,y LIMIT 0,10;
建立索引(columnX,columnY,x,y)实现order by的优化,比建立(x,y,columnX,columnY)索引效果要好得多。注意columnX、Y在前和where先于order by执行对表数据进行筛选有关
5.2.4 索引未命中的情况
1. 在索引列上做计算、函数、(自动or手动)类型转换等任何操作
会导致索引失效而转向全表扫描
2. 联合索引中最左匹配原则对于组合和范围查询
3. 使用不等于(!=或者<>)
4. 使用is null,is not null,not in, not exits
5. like以通配符开头时无法命中
like以通配符开头(’%abc…’)mysql索引失效会变成全表扫描的操作。
6. 索引为字符串字段,且条件中数据没有用引号引用起来
7. 如果条件中有or,则除非条件的每个列都加上索引否则不生效
如果OR条件有没有索引的列,可以使用union拼接多个查询语句
EXPLAIN SELECT * FROM users WHERE name = '123' OR name = 'admin';
EXPLAIN SELECT * FROM users WHERE name = '123' UNION SELECT * FROM users WHERE name = 'admin';
可以看到 OR连接的各条件字段要能用到索引的情况下,将 OR 改为 UNION 改变sql执行计划,从而达到提升SQL性能。
8. 联合索引
对于复合索引:**MySQL索引中的字段,一个查询可以只使用索引中的一部分,但只能是最左侧部分。**例如索引是key index (a,b,c)。可以支持a | a,b| a,b,c 3种组合进行查找,但不支持 b,c进行查找 。当最左侧字段是常量引用时,索引就十分有效。
利用索引中的附加列,可以缩小搜索的范围,但使用一个具有两列的索引不同于使用两个单独的索引。
*复合索引的结构与电话簿类似,人名由姓和名构成,电话簿首先按姓氏对进行排序,然后按名字对有相同姓氏的人进行排序。**如果您知道姓,电话簿将非常有用;*如果您知道姓和名,电话簿则更为有用,但如果您只知道名不姓,电话簿将没有用处。
所以说**创建复合索引时,应该仔细考虑列的顺序****。****对索引中的所有列执行搜索或仅对前几列执行搜索时,复合索引非常有用;仅对后面的任意列执行搜索时,复合索引则没有用处。
5.3 MySQL锁
参考文章:MySQL锁详解
根据锁的类型可以分为共享锁和排他锁,也叫做读锁和写锁。
根据加锁的范围,MySQL里面的锁大致可以分成全局锁、表级锁和行锁三类
行锁又可以分为乐观锁和悲观锁,悲观锁可以通过for update实现,乐观锁则通过版本号实现。
MyISAM 操作数据都是使用的表锁,你更新一条记录就要锁整个表,导致性能较低,并发不高。当然同时它也不会存在死锁问题。
而 InnoDB 与 MyISAM 的最大不同有两点:一是 InnoDB 支持事务;二是 InnoDB 采用了行级锁。也就是你需要修改哪行,就可以只锁定哪行。
1 全局锁
全局锁就是对整个数据库实例加锁。MySQL提供了一个加全局读锁的方法,命令是Flush tables with read lock
。当需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句
全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都select出来存成文本
但是让整个库都只读,可能出现以下问题:
如果在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆
如果在从库上备份,那么在备份期间从库不能执行主库同步过来的bin_log,会导致主从延迟
官方自带的逻辑备份工具是mysqldump。当mysqldump使用参数–single-transaction的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于MVCC的支持,这个过程中数据是可以正常更新的。single-transaction只适用于所有的表使用事务引擎的库
那么,既然要全库只读,为什么不使用set global readonly=true
的方式?
- 在有些系统中,
readonly
的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此修改global变量的方式影响面更大 - 在异常处理机制上有差异。如果执行
Flush tables with read lock
命令之后由于客户端发生异常断开,那么MySQL会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为readonly
之后,如果客户端发生异常,则数据库会一直保持readonly
状态,这样会导致整个库长时间处于不可写状态,风险较高
2 表级锁
MySQL里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)
表锁
表锁的语法是lock tables … read/write。可以用unlock tables主动释放锁,也可以在客户端断开的时候自动释放。lock tables语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象
如果在某个线程A中执行lock tables t1 read,t2 wirte;
这个语句,则其他线程写t1、读写t2的语句都会被阻塞。同时,线程A在执行unlock tables之前,也只能执行读t1、读写t2的操作。连写t1都不允许
即读锁让所有人只能读,写锁只能自己读写。
MDL(默认锁,重点)
另一类表级的锁是MDL。MDL不需要显式使用,在访问一个表的时候会被自动加上。MDL的作用是,保证读写的正确性。如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做了变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定不行。
在MySQL5.5版本引入了MDL,当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加MDL写锁
读锁之间不互斥,因此可以有多个线程同时对一张表增删改查
读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行
MDL是写优先,即一个写锁在阻塞时,后来的读锁也会被阻塞
事务中的MDL锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。
给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。在对大表操作的时候,需要特别小心,以免对线上服务造成影响
- 如何安全地给小表加字段?
首先要解决长事务,事务不提交,就会一直占着DML锁。在MySQL的information_schema库的InnoDB_trx表中,可以查到当前执行的事务。如果要做DDL变更的表刚好有长事务在执行,要考虑先暂停DDL,或者kill掉这个长事务 - 如果要变更的表是一个热点表,虽然数据量不大,但是上面的请求很频繁,而又不得不加个字段,该怎么做?
在alter table语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到MDL写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。之后再通过重试命令重复这个过程。
3. 行锁
MySQL的行锁是在引擎层由各个引擎自己实现的。但不是所有的引擎都支持行锁,比如MyISAM引擎就不支持行锁
行锁就是针对数据表中行记录的锁。比如事务,而这时候事务B也要更新同一行,则必须等事务A的操作完成后才能进行更新。
在 Mysql 中,行级锁并不是直接锁记录,而是锁索引。索引分为主键索引和非主键索引两种,如果一条sql 语句操作了主键索引,Mysql 就会锁定这条主键索引;如果一条语句操作了非主键索引,MySQL会先锁定该非主键索引,再锁定相关的主键索引。
InnoDB 行锁是通过给索引项加锁实现的,如果没有索引,InnoDB 会通过隐藏的聚簇索引来对记录加锁。也就是说:如果不通过索引条件检索数据,那么InnoDB将对表中所有数据加锁,行锁实际上退化为表锁。因为没有了索引,找到某一条记录就得扫描全表,要扫描全表,就得锁定表。
参考文章:【215期】MySQL中事务和锁的重点和难点,一次性讲清楚! (qq.com)
我们可以通过MVCC
(后面会再介绍)可以解决脏读
、不可重复读
、幻读
这些读一致性问题,但实际上这只是解决了普通select
语句的数据读取问题。事务利用MVCC
进行的读取操作称之为快照读
,所有普通的SELECT
语句在READ COMMITTED
、REPEATABLE READ
隔离级别下都算是快照读
。除了快照读
之外,还有一种是锁定读
,即在读取的时候给记录加锁,在锁定读
的情况下依然要解决脏读
、不可重复读
、幻读
的问题。由于都是在记录上加锁,这些锁都属于行级锁
。
InnoDB
的行锁,是通过锁住索引来实现的,如果加锁查询的时候没有使用过索引,会将整个聚簇索引都锁住,相当于锁表了。根据锁定范围的不同,行锁可以使用记录锁(Record Locks)
、间隙锁(Gap Locks)
和临键锁(Next-Key Locks)
的方式实现。假设现在有一张表t
,主键是id
。我们插入了4行数据,主键值分别是 1、4、7、10。接下来我们就以聚簇索引为例,具体介绍三种形式的行锁。
- 记录锁(Record Locks) 所谓记录,就是指聚簇索引中真实存放的数据,比如上面的1、4、7、10都是记录。
显然,记录锁就是直接锁定某行记录。当我们使用唯一性的索引(包括唯一索引和聚簇索引)进行等值查询且精准匹配到一条记录时,此时就会直接将这条记录锁定。例如
select * from t where id =4 for update;
就会将id=4
的记录锁定。 - 间隙锁(Gap Locks) 间隙指的是两个记录之间逻辑上尚未填入数据的部分,比如上述的(1,4)、(4,7)等。
同理,间隙锁就是锁定某些间隙区间的。当我们使用用等值查询或者范围查询,并且没有命中任何一个
record
,此时就会将对应的间隙区间锁定。例如select * from t where id =3 for update;
或者select * from t where id > 1 and id < 4 for update;
就会将(1,4)区间锁定。 - 临键锁(Next-Key Locks) 临键指的是间隙加上它右边的记录组成的左开右闭区间。比如上述的(1,4]、(4,7]等。
临键锁就是记录锁(Record Locks)和间隙锁(Gap Locks)的结合,即除了锁住记录本身,还要再锁住索引之间的间隙。当我们使用范围查询,并且命中了部分
record
记录,此时锁住的就是临键区间。注意,临键锁锁住的区间会包含最后一个record的右边的临键区间。例如select * from t where id > 5 and id <= 7 for update;
会锁住(4,7]、(7,+∞)。mysql默认行锁类型就是临键锁(Next-Key Locks)
。当使用唯一性索引,等值查询匹配到一条记录的时候,临键锁(Next-Key Locks)会退化成记录锁;没有匹配到任何记录的时候,退化成间隙锁。
间隙锁(Gap Locks)
和临键锁(Next-Key Locks)
都是用来解决幻读问题的,在已提交读(READ COMMITTED)
隔离级别下,间隙锁(Gap Locks)
和临键锁(Next-Key Locks)
都会失效!
两段锁协议
在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议
事务A持有的两个记录的行锁都是在commit的时候才释放的,事务B的update语句会被阻塞,直到事务A执行commit之后,事务B才能继续执行。
如果事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放
假设要实现一个电影票在线交易业务,顾客A要在影院B购买电影票。业务需要涉及到以下操作:
1.从顾客A账户余额中扣除电影票价
2.给影院B的账户余额增加这张电影票价
3.记录一条交易日志
为了保证交易的原子性,要把这三个操作放在一个事务中。如何安排这三个语句在事务中的顺序呢?
如果同时有另外一个顾客C要在影院B买票,那么这两个事务冲突的部分就是语句2了。因为它们要更新同一个影院账户的余额,需要修改同一行数据。根据两阶段锁协议,所有的操作需要的行锁都是在事务提交的时候才释放的。所以,如果把语句2安排在最后,比如按照3、1、2这样的顺序,那么影院账户余额这一行的锁时间就最少。这就最大程度地减少了事务之间的锁等待,提升了并发度
那么这种方式会不会产生死锁呢?
答案是会的,所以我们要注意死锁的问题。
4. 间隙锁
间隙锁是可重复读级别下才有的锁,MVCC实现快照读和next-key lock实现当前读。来解决幻读的问题
且对于修改插入删除,仍然必须加锁完成,无论是乐观还是悲观锁
建表和初始化语句如下:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
这个表除了主键id外,还有一个索引c
为了解决幻读问题,InnoDB引入了间隙锁,锁的就是两个值之间的空隙
当执行select * from t where d=5 for update
的时候,就不止是给数据库中已有的6个记录加上了行锁,还同时加了7个间隙锁。这样就确保了无法再插入新的记录
如前文所说,行锁分成读锁和写锁
但是对于间隙锁来说:跟间隙锁存在冲突关系的是往这个间隙中插入一个记录这个操作。间隙锁之间不存在冲突关系
这里sessionB并不会被堵住。因为表t里面并没有c=7会这个记录,因此sessionA加的是间隙锁(5,10)。而sessionB也是在这个间隙加的间隙锁。它们用共同的目标,保护这个间隙,不允许插入值。但它们之间是不冲突的。
间隙锁和行锁合称next-key lock,每个next-key lock是前开后闭区间。表t初始化以后,如果用select * from t for update要把整个表所有记录锁起来,就形成了7个next-key lock,分别是(-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。因为+∞是开区间,在实现上,InnoDB给每个索引加了一个不存在的最大值supremum,这样才符合都是前开后闭区间
间隙锁和next-key lock的引入,细化了锁的粒度,对于幻读的解决很重要,但同时也带来了一些困扰,正如同行锁一般,间隙锁也会带来死锁的问题
5. next-key lock
间隙锁和行锁合称next-key lock
表t的建表语句和初始化语句如下:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
1、next-key lock加锁规则
原则1:加锁的基本单位是next-key lock,next-key lock是前开后闭区间
原则2:查找过程中访问到的对象才会加锁
优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为行锁
优化2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁
一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止
这个规则只限于MySQL5.x系列<=5.7.24,8.0系列<=8.0.13
案例一:等值查询间隙锁
-
由于表t中没有id=7的记录,根据原则1,加锁单位是next-key lock,sessionA加锁范围就是(5,10]
-
根据优化2,这是一个等值查询(id=7),而id=10不满足查询条件,next-key lock退化成间隙锁,因此最终加锁的范围是(5,10)
所以,sessionB要往这个间隙里面插入id=8的记录会被锁住,但是sessionC修改id=10这行是可以的
案例二:非唯一索引等值锁
-
根据原则1,加锁单位是next-key lock,因此会给(0,5]加上next-key lock
-
c是普通索引,因此访问c=5这一条记录是不能马上停下来的,需要向右遍历,查到c=10才放弃。根据原则2,访问到的都要加锁,因此要给(5,10]加next-key lock
-
根据优化2,等值判断,向右遍历,最后一个值不满足c=5这个等值条件,因此退化成间隙锁(5,10)
-
根据原则2,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有任何锁,这就是为什么sessionB的update语句可以执行完成
锁是加在索引上的,在这个例子中,lock in share mode只锁覆盖索引,但是如果是for update,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁,这样的话sessionB的update语句会被阻塞住。如果你要用 lock in share mode 来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段
案例三:主键索引范围锁
-
开始执行的时候,要找到第一个id=10的行,因此本该是next-key lock(5,10]。根据优化1,主键id上的等值条件,退化成行锁,只加了id=10这一行的行锁
-
范围查询就往后继续找,找到id=15这一行停下来,因此需要加next-key lock(10,15]
所以,sessionA这时候锁的范围就是主键索引上,行锁id=10和next-key lock(10,15]
案例四:非唯一索引范围锁
这次sessionA用字段c来判断,加锁规则跟案例三唯一的不同是:在第一次用c=10定位记录的时候,索引c上加上(5,10]这个next-key lock后,由于索引c是非唯一索引,没有优化规则,因此最终sessionA加的锁是索引c上的(5,10]和(10,15]这两个next-key lock
案例五:唯一索引范围锁bug
sessionA是一个范围查询,按照原则1的话,应该是索引id上只加(10,15]这个next-key lock,并且因为id是唯一键,所以循环判断到id=15这一行就应该停止了
但是实现上,InnoDB会扫描到第一个不满足条件的行为止,也就是id=20。而且由于这是个范围扫描,因此索引id上的(15,20]这个next-key lock也会被锁上
所以,sessionB要更新id=20这一行是会被锁住的。同样地,sessionC要插入id=16的一行,也会被锁住
案例六:非唯一索引上存在等值的例子
insert into t values(30,10,30);
新插入的这一行c=10,现在表里有两个c=10的行。虽然有两个c=10,但是它们的主键值id是不同的,因此这两个c=10的记录之间也是有间隙的
sessionA在遍历的时候,先访问第一个c=10的记录。根据原则1,这里加的是(c=5,id=5)到(c=10,id=10)这个next-key lock。然后sessionA向右查找,直到碰到(c=15,id=15)这一行,循环才结束。根据优化2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成(c=10,id=10)到(c=15,id=15)的间隙锁
也就是说,这个delete语句在索引c上的加锁范围,就是下图中蓝色区域覆盖的部分,这个蓝色区域左右两边都是虚线,表示开区间
8、案例七:limit语句加锁
加了limit 2的限制,因此在遍历到(c=10,id=30)这一行之后,满足条件的语句已经有两条,循环就结束了。因此,索引c上的加锁范围就变成了从(c=5,id=5)到(c=10,id=30)这个前开后闭区间,如下图所示:
再删除数据的时候尽量加limit,这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围
案例八:一个死锁的例子
-
sessionA启动事务后执行查询语句加lock in share mode,在索引c上加了next-key lock(5,10]和间隙锁(10,15)
-
sessionB的update语句也要在索引c上加next-key lock(5,10],进入锁等待
-
然后sessionA要再插入(8,8,8)这一行,被sessionB的间隙锁锁住。由于出现了死锁,InnoDB让sessionB回滚
sessionB的加next-key lock(5,10]操作,实际上分成了两步,先是加(5,10)间隙锁,加锁成功;然后加c=10的行锁,这时候才被锁住的
共享锁
共享锁和排它锁是根据锁的性质分类的,上面的表锁、行锁是根据锁的范围分类的,他们是有交叉的
共享锁:对某一资源加共享锁,自身可以读该资源,其他人也可以读该资源(也可以再继续加共享锁,即 共享锁可多个共存),但无法修改。要想修改就必须等所有共享锁都释放完之后。语法为:
select * from table lock in share mode
共享锁一般用作读锁,但是查询默认不加锁
排他锁
首先说明:数据库的增删改操作默认都会加排他锁,而查询不会加任何锁。
排他锁:对某一资源加排他锁,自身可以进行增删改查,其他人无法进行任何操作。语法为:
select * from table for update
用动态的观点看待加锁
表t的建表语句和初始化语句如下:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
1. 不等号条件里的等值查询
begin;
select * from t where id>9 and id<12 order by id desc for update;
利用上面的加锁规则,这个语句的加锁范围是主键索引上的(0,5]、(5,10]和(10,15)。加锁单位是next-key lock,这里用到了优化2,即索引上的等值查询,向右遍历的时候id=15不满足条件,所以next-key lock退化为了间隙锁(10,15)
-
首先这个查询语句的语义是order by id desc,要拿到满足条件的所有行,优化器必须先找到第一个id<12的值
-
这个过程是通过索引树的搜索过程得到的,在引擎内部,其实是要找到id=12的这个值,只是最终没找到,但找到了(10,15)这个间隙
-
然后根据order by id desc,再向左遍历,在遍历过程中,就不是等值查询了,会扫描到id=5这一行,所以会加一个next-key lock (0,5]
在执行过程中,通过树搜索的方式定位记录的时候,用的是等值查询的方法
2. 等值查询的过程
begin;
select id from t where c in(5,20,10) lock in share mode;
这条in语句使用了索引c并且rows=3,说明这三个值都是通过B+树搜索定位的
在查找c=5的时候,先锁住了(0,5]。但是因为c不是唯一索引,为了确认还有没有别的记录c=5,就要向右遍历,找到c=10确认没有了,这个过程满足优化2,所以加了间隙锁(5,10)。执行c=10会这个逻辑的时候,加锁的范围是(5,10]和(10,15),执行c=20这个逻辑的时候,加锁的范围是(15,20]和(20,25)
这条语句在索引c上加的三个记录锁的顺序是:先加c=5的记录锁,再加c=10的记录锁,最后加c=20的记录锁
select id from t where c in(5,20,10) order by c desc for update;
由于语句里面是order by c desc,这三个记录锁的加锁顺序是先锁c=20,然后c=10,最后是c=5。这两条语句要加锁相同的资源,但是加锁顺序相反。当这两条语句并发执行的时候,就可能出现死锁
insert语句的锁为什么这么多?
1、insert … select语句
表t和t2的表结构、初始化数据语句如下:
CREATE TABLE `t` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(null, 1,1);
insert into t values(null, 2,2);
insert into t values(null, 3,3);
insert into t values(null, 4,4);
create table t2 like t;
在可重复读隔离级别下,bin_log_format=statement
时执行下面这个语句时,需要对表t的所有行和间隙加锁
insert into t2(c,d) select c,d from t;
2、insert循环写入
要往表t2中插入一行数据,这一行的c值是表t中c值的最大值加1,SQL语句如下:
insert into t2(c,d) (select c+1, d from t force index(c) order by c desc limit 1);
这个语句的加锁范围,就是表t索引c上的(3,4]和(4,supermum]这两个next-key lock,以及主键索引上id=4这一行
执行流程是从表t中按照索引c倒序吗,扫描第一行,拿到结果写入到表t2中,因此整条语句的扫描行数是1
但如果要把这一行的数据插入到表t中的话:
insert into t(c,d) (select c+1, d from t force index(c) order by c desc limit 1);
explain结果中的Extra字段中Using temporary
字段,表示这个语句用到了临时表
执行流程如下:
1.创建临时表,表里有两个字段c和d
2.按照索引c扫描表t,依次取c=4、3、2、1,然后回表,读到c和d的值写入临时表
3.由于语义里面有limit 1,所以只取了临时表的第一行,再插入到表t中
这个语句会导致在表t上做全表扫描,并且会给索引c上的所有间隙都加上共享的next-key lock。所以,这个语句执行期间,其他事务不能在这个表上插入数据
需要临时表是因为这类一边遍历数据,一边更新数据的情况,如果读出来的数据直接写回原表,就可能在遍历过程中,读到刚刚插入的记录,新插入的记录如果参与计算逻辑,就跟语义不符
3、insert唯一键冲突
sessionA执行的insert语句,发生唯一键冲突的时候,并不只是简单地报错返回,还在冲突的索引上加了锁,sessionA持有索引c上的(5,10]共享next-key lock(读锁)
在sessionA执行rollback语句回滚的时候,sessionC几乎同时发现死锁并返回
1.在T1时刻,启动sessionA,并执行insert语句,此时在索引c的c=5上加了记录锁。这个索引是唯一索引,因此退化为记录锁
2.在T2时刻,sessionA回滚。这时候,sessionB和sessionC都试图继续执行插入操作,都要加上写锁。两个session都要等待对方的行锁,所以就出现了死锁
4、insert into … on duplicate key update
上面这个例子是主键冲突后直接报错,如果改写成
insert into t values(11,10,10) on duplicate key update d=100;
就会给索引c上(5,10]加一个排他的next-key lock(写锁)
insert into … on duplicate key update的语义逻辑是,插入一行数据,如果碰到唯一键约束,就继续执行后面的更新语句。如果有多个列违反了唯一性索引,就会按照索引的顺序,修改跟第一个索引冲突的行
表t里面已经有了(1,1,1)和(2,2,2)这两行,执行这个语句效果如下:
主键id是先判断的,MySQL认为这个语句跟id=2这一行冲突,所以修改的是id=2的行
4. 数据库的死锁
1. 行锁导致的死锁
事务A在等待事务B释放id=2的行锁,而事务B在等待事务A释放id=1的行锁。事务A和事务B在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有两种策略:
- 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数
InnoDB_lock_wait_timeout
来设置 - 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数
InnoDB_deadlock_detect
设置为on
,表示开启这个逻辑
在InnoDB中,InnoDB_lock_wait_timeout
的默认值是50s,意味着如果采用第一个策略,当出现死锁以后,第一个被锁住的线程要过50s才会超时退出,然后其他线程才有可能继续执行。对于在线服务来说,这个等待时间往往是无法接受的
正常情况下还是要采用主动死锁检查策略,而且InnoDB_deadlock_detect
的默认值本身就是on。主动死锁监测在发生死锁的时候,是能够快速发现并进行处理的,但是它有额外负担的。每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁
如果有所有事务都要更新同一行的场景,每个新来的被堵住的线程都要判断会不会由于自己的加入导致死锁,这是一个时间复杂度是O(n)的操作
怎么解决由这种热点行更新导致的性能问题?
- 如果确保这个业务一定不会出现死锁,可以临时把死锁检测关掉
- 控制并发度
- 将一行改成逻辑上的多行来减少锁冲突。以影院账户为例,可以考虑放在多条记录上,比如10个记录,影院的账户总额等于这10个记录的值的总和。这样每次要给影院账户加金额的时候,随机选其中一条记录来加。这样每次冲突概率变成员原来的1/10,可以减少锁等待个数,也就减少了死锁检测的CPU消耗
2. 间隙锁造成的死锁
1.sessionA执行select … for update语句,由于id=9这一行并不存在,因此会加上间隙锁(5,10)
2.sessionB执行select … for update语句,同样会加上间隙锁(5,10),间隙锁之间不会冲突
3.sessionB试图插入一行(9,9,9),被sessionA的间隙锁挡住了,只好进入等待
4.sessionA试图插入一行(9,9,9),被sessionB的间隙锁挡住了
两个session进入互相等待状态,形成了死锁
间隙锁的引入可能会导致同样的语句锁住更大的范围,这其实是影响并发度的
在读提交隔离级别(READ_COMMITTED)下,不存在间隙锁
5.4 事务
事务状态参考文章:【215期】MySQL中事务和锁的重点和难点,一次性讲清楚! (qq.com)
根据事务所处的不同阶段,事务大致可以分为以下5个状态:
- 活动的(active) 当事务对应的数据库操作正在执行过程中,则该事务处于
活动
状态。 - 部分提交的(partially committed) 当事务中的最后一个操作执行完成,但还未将变更刷新到磁盘时,则该事务处于
部分提交
状态。 - 失败的(failed) 当事务处于
活动
或者部分提交
状态时,由于某些错误导致事务无法继续执行,则事务处于失败
状态。 - 中止的(aborted) 当事务处于
失败
状态,且回滚操作执行完毕,数据恢复到事务执行之前的状态时,则该事务处于中止
状态。 - 提交的(committed) 当事务处于
部分提交
状态,并且将修改过的数据都同步到磁盘之后,此时该事务处于提交
状态。
1. ACID
Atomicity:原子性,事务作为逻辑整体要么全部完成要么全部失败
Consistency:一致性,指的是数据库总是从一个一致性的状态转换到另外一个一致性的状态。比如A转账给B100块钱,假设中间sql执行过程中系统崩溃A也不会损失100块,因为事务没有提交,修改也就不会保存到数据库。
Isolation:隔离性,并发访问的事务部相互干扰
Durability:持久性,事务提交对数据库的改变是永久性的。
ACID靠什么保证
A 原子性由undo log日志保证,它记录了需要回滚的日志信息,事务回滚时撤销已经执行成功的sql
C 一致性一般由代码层面来保证
I 隔离性由MVCC来保证
D 持久性由内存+redo log来保证,mysql修改数据同时在内存和redo log记录这次操作,事务提交的时候通过redo log刷盘,宕机的时候可以从redo log恢复
2. MySQL的3种并发问题
- 脏读:读了未提交的数据
- 不可重复读:同一行数据在事务中多次读取前后不一致
- 幻读:同一范围在事务中前后读取的行数不一致,插入和删除影响了其他事务。
注意:其实还有一种问题:更新丢失,影响数据库的一致性,但是更新丢失是极其严重的问题,所以mysql会在更新时加锁来解决更新丢失的问题,不要和幻读弄混了
3. 隔离级别
READ_UMCOMITTED 读未提交
允许读取尚未提交的变更
什么都不防
REAAD_COMMITTED 读已提交
只允许读取已提交的数据
防脏读
REPEATABLE_READ 可重复读(默认隔离级别)
对同一字段多读取结果一致,除非是自己更改的
防脏读、不可重复读
Serializable 串行化
所有事务必须依次执行
没有并发问题
各个隔离级别下可能出现的读一致性
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
未提交读(READ UNCOMMITTED) | 可能 | 可能 | 可能 |
已提交读(READ COMMITTED) | 不可能 | 可能 | 可能 |
可重复读(REPEATABLE READ) | 不可能 | 不可能 | 可能(对InnoDB不可能) |
串行化(SERIALIZABLE) | 不可能 | 不可能 | 不可能 |
5.5 并发控制和日志
MySQL中有六种日志文件,分别是:重做日志
(redo log)、回滚日志
(undo log)、二进制日志
(bin_log)、错误日志
(error log)、慢查询日志
(slow query log)、一般查询日志
(general log),中继日志
(relay log)。
其中重做日志和回滚日志与事务操作息息相关,二进制日志也与事务操作有一定的关系,这三种日志,对理解MySQL中的事务操作有着重要的意义。
以下重点介绍一下redo log、undo log、bin_log。
假设数据库在操作时,按如下约定记录日志:
- 事务开始时,记录START T
- 事务修改时,记录(T,x,v),说明事务T操作对象x,x的值为v
- 事务结束时,记录COMMIT T
必须了解的MySQL三大日志:bin_log、redo log和undo log
1. undo日志
undo log可以把没有COMMIT的事务回滚到事务开始前的状态。原理是保存该数据操作之前的版本。
使用undo log时,要求:
- 记录修改日志时,(T,x,v)中v为x修改前的值,这样才能借助这条日志来回滚;
- 事务提交后,必须在事务的所有修改(包括记录的修改日志)都持久化后才能写COMMIT T日志;这样才能保证,宕机恢复时,已经COMMIT的事务的所有修改都已经持久化,不需要回滚。
使用undo log时事务执行顺序
- 记录START T
- 记录需要修改的记录的旧值(要求日志持久化)
- 根据事务的需要更新数据库(要求日志持久化)
- 记录COMMIT T
使用undo log进行宕机回滚
- 扫描日志,找出所有已经START,还没有COMMIT的事务。
- 针对所有未COMMIT的日志,根据undo log来进行回滚。
如果数据库访问很多,日志量也会很大,宕机恢复时,回滚的工作量也就很大(因为不知道是不是有的事务持续时间很长,所以必须扫描整个log),为了加快回滚,可以通过checkpoint机制来加速回滚
- 在日志中记录checkpoint_start (T1,T2…Tn) (Tx代表做checkpoint时,正在进行还未COMMIT的事务)
- 等待所有正在进行的事务(T1~Tn)COMMIT
- 在日志中记录checkpoint_end
checkpoint机制实际上使得我们只需要扫描最近的一个checkpoint_start之后的log,大大减少了恢复的时间消耗
借助checkpoint来进行回滚
从后往前,扫描undo log
- 如果先遇到checkpoint_start, 则将checkpoint_start及其之后的所有未提交的事务进行检查,需要回滚的回滚;
- 如果先遇到checkpoint_end, 则将前一个checkpoint_start之后所有未提交的事务进行检查、回滚;(在checkpoint的过程中,可能有很多新的事务START或者COMMIT)。
使用undo log,在写COMMIT日志时,要求redo log以及事务的所有修改都必须已经持久化,这种做法通常很影响性能。
要注意,update是要加锁的,否则就算有undo_log也无法回滚的,参考后面 5.7 如何解决幻读问题 的解释
2. redo日志
redo log是指在回放日志的时候把已经COMMIT的事务重做一遍,对于没有commit的事务按照abort(终止,尤指异常终止)处理,不进行任何操作。
注意,是已经commit但是没有来得及持久化(也就是从内存写入磁盘)的事务需要重做,没有commit的需要看有没有写入bin_log,在下面两阶段提交的部分会再论述这个问题
作用
- 确保事务的持久性。
- 防止在发生故障的时间点,尚有脏页未写入磁盘,在重启mysql服务的时候,根据redo log进行重做,从而达到事务的持久性这一特性。
mysql是如何保证一致性的呢?最简单的做法是在每次事务提交的时候,将该事务涉及修改的数据页全部刷新到磁盘中。但是这么做会有严重的性能问题,主要体现在两个方面:
- 因为InnoDB是以页为单位进行磁盘交互的,而一个事务很可能只修改一个数据页里面的几个字节,这个时候将完整的数据页刷到磁盘的话,太浪费资源了!
- 一个事务可能涉及修改多个数据页,并且这些数据页在物理上并不连续,使用随机IO写入性能太差!
所以我们需要redo_log将随机读写简化为顺序读写,将小数据读写转变为连续数据大读写,增加IO效率
过程
使用redo log时,要求:
- 记录redo log时,(T,x,v)中的v必须是x修改后的值,否则不能通过redo log来恢复已经COMMIT的事务。
- 写COMMIT T日志之前,事务的修改不能进行持久化,否则恢复时,对于未COMMIT的操作,可能有数据已经修改,但重放redo log不会对该事务做任何处理,从而不能保证事务的原子性。
使用redo log时事务执行顺序
- 记录START T
- 记录事务需要修改记录的新值(要求持久化,指对于日志的记录要先持久化)
- 记录COMMIT T(要求持久化)
- 事务相关的修改写入数据库
使用redo log重做事务
- 扫描日志,找到所有已经COMMIT的事务;
- 对于已经COMMIT的事务,根据redo log重做事务;
在日志中使用checkpoint
- 在日志中记录checkpoint_start (T1,T2…Tn) (Tx代表做checkpoint时,正在进行还未COMMIT的日志)
- 将所有已提交的事务的更改进行持久化; 3. 在日志中记录checkpoint_end
根据checkpoint来加速恢复
从后往前,扫描redo log
- 如果先遇到checkpoint_start, 则把T1~Tn以及checkpoint_start之后的所有已经COMMIT的事务进行重做;
- 如果先遇到checkpoint_end, 则T1~Tn以及前一个checkpoint_start之后所有已经COMMIT的事务进行重做;
与undo log类似,在使用时对持久化以及事务操作顺序的要求都比较高,可以将两者结合起来使用,在恢复时,对于已经COMMIT的事务使用redo log进行重做,对于没有COMMIT的事务,使用undo log进行回滚。redo/undo log结合起来使用时,要求同时记录操作修改前和修改后的值,如(T,x,v,w),v为x修改前的值,w为x修改后的值,具体操作顺序为:
- 记录START T
- 记录修改日志(T,x,v,w)(要求持久化,其中v用于undo,w用于redo)
- 更新数据库
- 记录 COMMIT T
4和3的操作顺序没有严格要求,并且都不要求持久化;因为如果宕机时4已经持久化,则恢复时可通过redo log来重做;如果宕机时4未持久化,则恢复时可通过undo log来回滚;在处理checkpoint时,可采用与redo log相同的处理方式。
记录时机
从上面的过程可以看出:事务开始之后就产生redo log,redo log的落盘并不是随着事务的提交才写入的,而是在事务的执行过程中,便开始写入redo log文件中。
释放时机
- 当对应事务的脏页写入到磁盘之后,redo log的使命也就完成了,重做日志占用的空间就可以重用(被覆盖)。
redo_log何时写入磁盘
之所以说重做日志是在事务开始之后逐步写入重做日志文件,而不一定是事务提交才写入重做日志缓存,原因就是,重做日志有一个缓存区InnoDB_log_buffer
,InnoDB_log_buffer
的默认大小为8M(这里设置的16M),InnoDB存储引擎先将重做日志写入InnoDB_log_buffer
中。
redo log包括两部分:一个是内存中的日志缓冲(redo log buffer),另一个是磁盘上的日志文件(redo log file)。mysql每执行一条DML语句,先将记录写入redo log buffer,后续某个时间点再一次性将多个操作记录写到redo log file。这种先写日志,再写磁盘的技术就是MySQL里经常说到的WAL(Write-Ahead Logging) 技术。
在计算机操作系统中,用户空间(user space)下的缓冲区数据一般情况下是无法直接写入磁盘的,中间必须经过操作系统内核空间(kernel space)缓冲区(OS Buffer)。因此,redo log buffer写入redo log file实际上是先写入OS Buffer,然后再通过系统调用fsync()将其刷到redo log file中。
mysql支持三种将redo log buffer写入redo log file的时机,可以通过InnoDB_flush_log_at_trx_commit
参数配置,各参数值含义如下:
然后会通过以下三种方式将InnoDB日志缓冲区的日志刷新到磁盘
- Master Thread 每秒一次执行刷新InnoDB_log_buffer到重做日志文件。当系统崩溃时,最多会丢失一秒的数据
- 每个事务提交时会将重做日志刷新到重做日志文件。系统崩溃也不会丢失数据,但是IO性能差
- 当重做日志缓存可用空间 少于一半时,重做日志缓存被刷新到重做日志文件
记录形式
前面说过,redo log实际上记录数据页的变更,而这种变更记录是没必要全部保存,因此redo log实现上采用了大小固定,循环写入的方式,当写到结尾时,会回到开头循环写日志。
write pos到check point之间的部分是redo log空着的部分,用于记录新的记录;check point到write pos之间是redo log待落盘的数据页更改记录。当write pos追上check point时,会先推动check point向前移动,空出位置再记录新的日志。
启动InnoDB的时候,不管上次是正常关闭还是异常关闭,总是会进行恢复操作。因为redo log记录的是数据页的物理变化,因此恢复的时候速度比逻辑日志(如bin_log)要快很多。
重启InnoDB时,首先会检查磁盘中数据页的LSN,如果数据页的LSN小于日志中的LSN,则会从checkpoint开始恢复。
还有一种情况,在宕机前正处于checkpoint的刷盘过程,且数据页的刷盘进度超过了日志页的刷盘进度,此时会出现数据页中记录的LSN大于日志中的LSN,这时超出日志进度的部分将不会重做,因为这本身就表示已经做过的事情,无需再重做。
3. bin_log日志
参考文章:腾讯工程师带你深入解析 MySQL bin_log
谈谈MySQL中的重做日志,回滚日志,以及二进制日志的区别及各自作用
bin_log是Mysql sever层维护的一种二进制日志,与InnoDB引擎中的redo/undo log是完全不同的日志;其主要是用来记录对mysql数据更新或潜在发生更新的SQL语句,并以"事务"的形式保存在磁盘中;
bin_log是通过追加的方式进行写入的,可以通过max_bin_log_size
参数设置每个bin_log文件的大小,当文件大小达到给定值之后,会生成新的文件来保存日志。
作用主要有:
- 主从复制:MySQL Replication在Master端开启bin_log,Master把它的二进制日志传递给slaves并回放来达到master-slave数据一致的目的
- 数据恢复:通过mysqlbin_log工具恢复数据
- 增量备份
记录时机
bin_log是通过追加的方式进行写入的,可以通过max_bin_log_size
参数设置每个bin_log文件的大小,当文件大小达到给定值之后,会生成新的文件来保存日志。
对于InnoDB存储引擎而言,只有在事务提交时才会记录biglog,此时记录还在内存中,那么biglog是什么时候刷到磁盘中的呢?mysql通过sync_bin_log
参数控制biglog的刷盘时机,取值范围是0-N:
- 0:不去强制要求,由系统自行判断何时写入磁盘;
- 1:每次commit的时候都要将bin_log写入磁盘;
- N:每N个事务,才会将bin_log写入磁盘。
从上面可以看出,sync_bin_log最安全的是设置是1,这也是MySQL 5.7.7之后版本的默认值。但是设置一个大一些的值可以提升数据库性能,因此实际情况下也可以将值适当调大,牺牲一定的一致性来获取更好的性能。
-
事务提交的时候,一次性将事务记录bin_log。
这里与redo log很明显的差异就是redo log并不一定是在事务提交的时候刷新到磁盘,redo log是在事务开始之后就开始逐步写入磁盘。
-
因此对于事务的提交,即便是较大的事务,提交(commit)都是很快的,但是在开启了bin_log的情况下,对于较大事务的提交,可能会变得比较慢一些。
-
这是因为bin_log是在事务提交的时候一次性写入的造成的,这些可以通过测试验证。
释放时机
bin_log的默认是保持时间由参数expire_logs_days
配置,也就是说对于非活动的日志文件,在生成时间超过expire_logs_days配置的天数之后,会被自动删除。
格式
Mysql bin_log日志有ROW,Statement,MiXED三种格式;可通过my.cnf配置文件及 set global bin_log_format=‘ROW/STATEMENT/MIXED’ 进行修改,命令行 show variables like ‘bin_log_format’ 命令查看binglog格式;。
-
Row level: 仅保存记录被修改细节,不记录sql语句上下文相关信息优点:能非常清晰的记录下每行数据的修改细节,不需要记录上下文相关信息,因此不会发生某些特定情况下的procedure、function、及trigger的调用触发无法被正确复制的问题,任何情况都可以被复制,且能加快从库重放日志的效率,保证从库数据的一致性
缺点:由于所有的执行的语句在日志中都将以每行记录的修改细节来记录,因此,可能会产生大量的日志内容,干扰内容也较多;比如一条update语句,如修改多条记录,则bin_log中每一条修改都会有记录,这样造成bin_log日志量会很大,特别是当执行alter table之类的语句的时候,由于表结构修改,每条记录都发生改变,那么该表每一条记录都会记录到日志中,实际等于重建了表。
tip: - row模式生成的sql编码需要解码,不能用常规的办法去生成,需要加上相应的参数(–base64-output=decode-rows -v)才能显示出sql语句; - 新版本bin_log默认为ROW level,且5.6新增了一个参数:bin_log_row_image;把bin_log_row_image设置为minimal以后,bin_log记录的就只是影响的列,大大减少了日志内容 -
Statement level: 每一条会修改数据的sql都会记录在bin_log中优点:只需要记录执行语句的细节和上下文环境,避免了记录每一行的变化,在一些修改记录较多的情况下相比ROW level能大大减少bin_log日志量,节约IO,提高性能;还可以用于实时的还原;同时主从版本可以不一样,从服务器版本可以比主服务器版本高
缺点:为了保证sql语句能在slave上正确执行,必须记录上下文信息,以保证所有语句能在slave得到和在master端执行时候相同的结果;另外,主从复制时,存在部分函数(如sleep)及存储过程在slave上会出现与master结果不一致的情况,而相比Row level记录每一行的变化细节,绝不会发生这种不一致的情况- statment level并不完全是sql语句这么简单,还包括了执行的sql语句(增删改)反向的信息,
- 也就意味着delete对应着delete本身和其反向的insert;update对应着update执行前后的版本的信息;insert对应着delete和insert本身的信息。
- 在使用mysqlbin_log解析bin_log之后一切都会真相大白。
- 因此可以基于bin_log做到类似于oracle的闪回功能,其实都是依赖于bin_log中的日志记录。
-
Mixedlevel level: 以上两种level的混合使用经过前面的对比,可以发现ROW level和statement level各有优势,如能根据sql语句取舍可能会有更好地性能和效果;Mixed level便是以上两种leve的结合。不过,新版本的MySQL对row level模式也被做了优化,并不是所有的修改都会以row level来记录,像遇到表结构变更的时候就会以statement模式来记录,如果sql语句确实就是update或者delete等修改数据的语句,那么还是会记录所有行的变更;因此,现在一般使用row level即可。
-
选取规则如果是采用 INSERT,UPDATE,DELETE 直接操作表的情况,则日志格式根据 bin_log_format 的设定而记录
如果是采用 GRANT,REVOKE,SET PASSWORD 等管理语句来做的话,那么无论如何都采用statement模式记录
复制
复制是mysql最重要的功能之一,mysql集群的高可用、负载均衡和读写分离都是基于复制来实现的;从5.6开始复制有两种实现方式,基于bin_log和基于GTID(全局事务标示符);本文接下来将介绍基于bin_log的一主一从复制;其复制的基本过程如下:
Master将数据改变记录到二进制日志(binary log)中
Slave上面的IO进程连接上Master,并请求从指定日志文件的指定位置(或者从最开始的日志)之后的日志内容
Master接收到来自Slave的IO进程的请求后,负责复制的IO进程会根据请求信息读取日志指定位置之后的日志信息,返回给Slave的IO进程。
返回信息中除了日志所包含的信息之外,还包括本次返回的信息已经到Master端的bin-log文件的名称以及bin-log的位置Slave的IO进程接收到信息后,将接收到的日志内容依次添加到Slave端的relay-log文件的最末端,并将读取到的Master端的 bin-log的文件名和位置记录到master-info文件中,以便在下一次读取的时候能够清楚的告诉Master从某个bin-log的哪个位置开始往后的日志内容
Slave的Sql进程检测到relay-log中新增加了内容后,会马上解析relay-log的内容成为在Master端真实执行时候的那些可执行的内容,并在自身执行
恢复
bin_log与redo_log的区分
二进制日志的作用之一是还原数据库的,这与redo log很类似,很多人混淆过,但是两者有本质的不同
- 作用不同:redo log是保证事务的持久性的,是事务层面的,bin_log作为还原的功能,是数据库层面的(当然也可以精确到事务层面的),虽然都有还原的意思,但是其保护数据的层次是不一样的。
- 内容不同:redo log是物理日志,是数据页面的修改之后的物理记录,bin_log一般来说是逻辑日志。
- 另外,两者日志产生的时间,可以释放的时间,在可释放的情况下清理机制,都是完全不同的。
- 恢复数据时候的效率,基于物理日志的redo log恢复数据的效率要高于语句逻辑日志的bin_log
关于事务提交时,redo log和bin_log的写入顺序,为了保证主从复制时候的主从一致(当然也包括使用bin_log进行基于时间点还原的情况),是要严格一致的,MySQL通过两阶段提交过程来完成事务的一致性的,也即redo log和bin_log的一致性的,理论上是先写redo log,再写bin_log,两个日志都提交成功(刷入磁盘),事务才算真正的完成。
下面,我们还会更详细地介绍关于两阶段提交的问题
由于多个事务共用一个 redo log buffer,当有其他并行事务提交时,未提交的事物的 redo log 也会被持久化到磁盘。这个时候,崩溃恢复时因为两阶段提交的机制不会导致未提交事务数据被恢复。
bin_log 区别于 redo log,每个事务所在的线程都会存在一个 bin_log cache,并且只有事务提交时会持久化到磁盘文件(bin_log file)。注意磁盘文件是共用的,只有 bin_log cache 是单个线程一个。具体流程是事务执行过程中先把日志写到 bin_log cache 中,在事务提交的时候持久化到磁盘文件。
两阶段提交
参考文章:Mysql基于两阶段实现redo、bin_log日志一致性
跟面试官侃半小时MySQL事务,说完原子性、一致性、持久性的实现 - 知乎 (zhihu.com)
redo log和bin_log都可以用于表示事务的提交的状态,而两阶段提交就是让这两个状态保持逻辑上的一致,保证数据正确地持久化。
2PC即InnoDB对于事务的两阶段提交机制。当MySQL开启bin_log的时候,会存在一个内部XA的问题:事务在存储引擎层(redo)commit的顺序和在bin_log中提交的顺序不一致的问题,如果不使用两阶段提交,那么数据库的状态有可能用它的日志恢复出来的库的状态不一致。
如果不使用两阶段提交会怎么样?
举例子
update T set name = 'god-jiang' where id = 6
没有两阶段提交会发生什么?
先写redo log后写bin_log。假设写完了redo log,bin_log还没有写完,这个时候MySQL异常重启。因为redo log写完了,恢复系统的时候name=‘god-jiang’。但是bin_log没有写完,所以bin_log没有记录这条语句,这个时候用bin_log恢复数据的时候,恢复出来的name就是原来值,与redo log不同。
同理可得,先写bin_log后写redo log也会发现两个日志恢复的数据不同。这个不一致会导致线上出现主从不一致的情况。
下面介绍一下两阶段提交的具体过程。
事务的commit分为prepare和commit两个阶段:(注意InnoDB作为RM)
1、prepare阶段:redo持久化到磁盘(redo group commit),并将回滚段置为prepared状态,此时bin_log不做操作。
2、commit阶段:InnoDB释放锁,释放回滚段,设置提交状态,如果前面 prepare 成功,bin_log 写盘,那么再继续将事务日志持久化到 bin_log,如果持久化成功,那么 InnoDB 事务则进入 commit 状态(在 redo log 里面写一个 commit 记录)
实际上,两阶段提交是分布式系统常用的机制。MySQL使用了两阶段提交后,也是为了保证事务的持久性。redo log 和bingo 有一个共同的数据字段,叫 XID,崩溃恢复的时候,会按顺序扫描 redo log。
- 假设在写入bin_log前系统崩溃,那么数据库恢复后顺序扫描 redo log,碰到只有 parepare、而没有 commit 的 redo log,就拿着 XID 去 bin_log 找对应的事务,而且bin_log也没写入,所以事务就直接回滚了。
- 假设在写入bin_log之后,事务提交前数据库崩溃,那么数据库恢复后顺序扫描 redo log,碰到既有 prepare、又有 commit 的 redo log,就直接提交,保证数据不丢失。
简单来说,在重启恢复数据时,如果redo log 状态为commit则说明bin_log也成功,直接恢复数据;如果redo log是prepare状态,则需要查询对应的bin_log事务是否成功,决定是回滚还是执行。
那么很自然地,我们要考虑一个重要的基础问题,也就是日志本身持久化的时机的问题
比如某个事务要往两个表中插入记录,插入数据的过程中,生成的日志都得先写入redo log buffer ,等到commit的时候,才真正把日志写到 redo log 文件。(当然,这里不绝对,因为redo log buffer可能因为其他原因被迫刷新到redo log)。
而为了确保每次日志都能写入日志文件,在每次将重做日志缓冲写重做日志文件后,InnoDB存储引擎都需要调用一次fsync操作,确保写入了磁盘。
对于redo log的持久化,可以如下图所示。
1)先写入redo log buffer,在蓝色区域。
2)写入redo log file,但是还没有fsync,这时候是处于黄色的位置,处于系统缓存。
3)调用fsync,真正写入磁盘。
redo log具体的写入策略由InnoDB_flush_log_at_trx_commit
指定,上文已经介绍过了。
bin_log的写入和redo log一样,也是包括bingo cache和bingo file,同样跟上面的三色层次类似(当然,bin_log是server层的,不是存储引擎层的),包括log buffer、文件系统page cache、hard disk。
写入page cache 和 fsync到disk 的时机,由参数 sync_bin_log
控制,上文同样介绍过了。
通常我们说 MySQL 的“双 1”配置,指的就是 sync_bin_log
和 InnoDB_flush_log_at_trx_commit
都设置成 1。也就是说,一个事务完整提交前,需要等待两次刷盘,一次是 redo log(prepare 阶段),一次是 bin_log。
注: 每个事务 bin_log 的末尾,会记录一个 XID event,标志着事务是否提交成功,也就是说,recovery 过程中,bin_log 最后一个 XID event 之后的内容都应该被 purge。
4. MVCC多版本并发控制
MVCC
MVCC,全称Multi-Version Concurrency Control,即多版本并发控制。MVCC是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。
MVCC在MySQL InnoDB引擎中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读
真正的MVCC用于读已提交和可重复读级别的控制,主要通过undo log日志版本链和read view来实现。
表中每条数据隐藏了两个字段:roll_pointer
和trx_id
。
roll_pointer
指向更新事务之前生成的undo log,undo log用于事务的回滚,保证事务的原子性。
trx_id
就是最近一次更新数据的事务ID。
假设对于一个user表,向其中插入两条数据
这时候假设小明去执行查询,就会开启一个read view,read view包含几个重要的东西。
m_ids
,就是还未提交的事务id集合low_limit_id
,m_ids
里最小的值up_limit_id
,下一次要生成的事务ID值creator_trx_id
,创建read view的事务ID,也就是自己的事务ID
小明来执行查询了,当前事务ID=3
select * from user where id<=3;
小红在这时候开启事务去修改id=1的记录,事务ID=4
update user set name='张三三' where id=1;
m_ids=[3,4]
low_limit_id=3
up_limit_id=5
creator_trx_id=3
小明在执行查询的时候,会去判断当前这条数据的trx_id
<read view的low_limit_id
,显然都小于,所以小明会正常查询到id=1,2的两条记录,而不会受到小红修改的影响。
这时候,小红的修改也完成了,小红数据于是就变成了这样。
如果小明再次去查询的话,就会发现现在的trx_id
>read view的low_limit_id
,也就是4>3,不符合条件,同时发现现在的trx_id=4
在low_limit_id
和up_limit_id
即在[3,5]之间,并且trx_id=4
在m_ids=[3,4]
之中,所以就会根据roll_pointer
指向的undo log去查找,trx_id=1
小于现在的low_limit_id=3
,符合条件,就找到了上一个版本name="张三"
的记录。
如果这时候小明自己去修改这条记录的值,把名字改成张五,结果就是这样。
然后小明去查询的话,就会发现当前的trx_id=3
就是自己的creator_trx_id
,就是自己,那么就直接返回这条数据。
所以,我们可以先总结下几种情况:
- 如果trx_id<low_limit_id,那么说明就是之前事务的数据,直接返回,也就对应了小明第一次开启事务查询的场景
- 如果trx_id>low_limit,trx_id还在[low_limit_id,up_limit_id]范围之内,并且trx_id在m_ids中,就会根据roll_pointer去查找undo log日志链,找到之前版本的数据,对应的就是小红修改后小明再次查询的场景
- 如果trx_id=creator_trx_id,那么说明就是自己修改的,直接返回就好了,对应的就是小明自己去修改数据的场景
5. 不同隔离级别的实现原理
可重复读
可重复读的实现实际上就是上面MVCC所阐述的原理,是read view不发生更改的情况,保证每次读取到的数据都是一致的。
读已提交
读已提交级别的实现原理其实就是每次查询都重新生成read view情况下的MVCC
以上述小红修改过张三后的场景来举例。
在可重复度级别下,由于trx_id>low_limit,trx_id还在[low_limit_id,up_limit_id]范围之内,并且trx_id在m_ids中,满足我们上述的条件2,所以就会根据roll_pointer找到之前的版本记录,保证可重复读。
而在读已提交的级别下,重新生成了read view,这时候trx_id不在m_ids之中,说明事务已经提交,所以可以直接返回这条数据,所以查到的数据就是小红修改后的name=张三三
的数据了。
6. 如何解决幻读问题
参考文章:面试官:InnoDB解决幻读的方案了解么?
InnoDB
默认的事务隔离级别是repeatable read
(后文中用简称 RR),它为了解决该隔离级别下的幻读的并发问题,提出了LBCC和MVCC两种方案。其中LBCC解决的是当前读情况下的幻读,MVCC解决的是普通读(快照读)的幻读。这两种方法的基本知识我们上面已经介绍过。
LBCC
LBCC是Lock-Based Concurrent Control的简称,意思是基于锁的并发控制。利用临键锁(Next-key Locks)就可以用来解决 RR 下的幻读问题。
当前读(Locking Read)也称锁定读,读取当前数据的最新版本,而且读取到这个数据之后会对这个数据加锁,防止别的事务更改即通过next-key锁(行锁+gap 锁)来解决当前读的问题。在进行写操作的时候就需要进行“当前读”,读取数据记录的最新版本,包含以下SQL类型:select ... lock in share mode
、select ... for update
、update
、delete
、insert
。
MVCC
LBCC是基于锁的并发控制,因为锁的粒度过大,会导致性能的下降,因此提出了比LBCC性能更优越的方法MVCC。MVCC是Multi-Version Concurremt Control的简称,即基于多版本的并发控制协议。MVCC提供的是普通读(快照读),是默认的读方法。
既然MVCC可以保存过去版本的快照,而且并发度高,只用MVCC为什么不行?还要再加锁的机制?
因为MVCC只能保证读的一致性,如果涉及到更新,不加锁的话就会产生更新丢失的问题(就像我们在上面介绍过的一样)
比如:
- 事务A修改
id=1
,name="张四四"
,未提交 - 事务B修改
id=2
,name="张五五"
,提交 - 这时A想要回滚,如果只有MVCC而没有在B修改之前加锁的话,这时候其实回滚就无法执行了
所以必须要MVCC(解决读)+ 锁(解决写)才能保证单机的一致性
5.6 SQl执行过程
参考文章:MySql 之一条查询sql的执行过程 - 知乎 (zhihu.com)
可以看到,整个执行过程主要分为两部分,分别是server层和引擎层
server层又分为连接器、分析器、查询缓存、优化器以及执行器
1. 连接器
要操作数据库,那么必须得连接上数据库,所以这时候就用到了连接器
如果连接的用户名和密码正确,那么连接器接下来就去权限表中查询你登录用户所拥有的权限,之后此用户操作数据的权限判断逻辑都将依赖此次查询到的权限。哪怕你修改了此用户的权限也还是没用,必须重新新建连接,修改的权限才会生效。
客户端连接mysql服务器时,如果连接一直处于空闲状态,那么到了一定的时候就会断开连接,多长时间是由 wait_timeout 控制的,其默认是8个小时。如果超过8个小时,你执行操作数据库时就回提示 “Lost connection to MySQL server during query”,这时只有重新连接数据库方能进行操作。
说到连接器,咱们得说一下长连接和短链接。长连接就是如果客户端一直都有请求操作数据库,那么就会一直使用这个连接进行操作。短链接就是每次执行完很少的数据库操作就断开连接了,如果再有请求就必须重新连接。
所以这里建议减少数据库的连接操作,尽量使用长连接。但是长时间使用长连接会导致一个问题,那就是随着查询的进行mysql的占用的内存会越来越大,要注意定期断开
2. 查询缓存
在MySQL后来的版本中,直接移除了查询缓存,其实是因为查询缓存不实用,因为每次更新都会让缓存清空
客户端连接成功mysql服务器后,执行上面的一条sql时,首先会去缓存中查询是否有数据,如果有数据,那么直接把数据返回给客户端,后面的步骤都省略了。一条查询sql的首次执行完成后,会把sql语句作为key,把查询出来的数据作为value放入到缓存中,如果后面再有相同的查询,那么直接从缓存中取值便可。
3. 分析器
开始sql的真正的执行流程,首先是分析器,其主要就是对sql语句进行 “词法分析” 和 “语法分析”。
词法分析 就是对sql中的单词进行逐个的分析,比如 从 select 可以识别出要执行查询操作,user则是识别成表user,id则识别成user表中字段id。
语法分析就是分析整条sql是否符合mysql的语法规范
4. 优化器
从分析器中我们已经知道这条sql是要执行更新还是查询操作。那么优化器便是要对这条sq执行之前l进行优化处理,有哪些优化处理呢?例如 某表有多个索引的时候 决定用哪一个索引;或者多关联(join)查询的时候,决定关联的顺序。比如下面一条sql
select * from user u join score s using(ID) where u.id=20 and s.scores=80;
上面一条 sql 可以分为两种情况
- 既可以先从表user里面取出id=20的记录的ID值,再根据ID值关联到表socre,再判断score表里scores的值是否等于80。
- 也可以先从表score里面取出scores=80的记录的ID值,再根据ID值关联到user,再判断user表里面id的值是否等于20。
这两种方案得出的结果是一样的,但是执行的效率是不一样的,而优化器作用便是从中选择一个方案。
5. 执行器
当优化器选择好了方案,那么便进入执行器阶段,这时候就要开始执行sql了。执行sql前要查询一下你对需要操作的表是否有对应的操作权限。
如果有对应表的操作权限,那么便打开表继续执行,执行器会根据定义的表的引擎,来执行引擎提供的对应读写的接口。
下面假如 id 是没有索引的
- 调用InnoDB引擎接口取这个表的第一行,判断ID值是不是8,如果不是则跳过,如果是则将这行存在结果集中;
- 调用引擎接口取“下一行”,重复相同的判断逻辑,直到取到这个表的最后一行。
- 执行器将上述遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端。
如果是 id 是有索引的,第一次调用的是“取满足条件的第一行”这个接口,之后循环取“满足条件的下一行”这个接口,基本和上面差不多。这些接口都是引擎中已经定义好的。
5.7 group by 、roll up和cube
参考文章SQL汇总统计: 在SQL中使用CUBE和ROLLUP实现数据多维汇总
首先,我们设定个需求,想要分别按【性别】、【籍贯】、【年龄】或【成绩级别】统计下表中学生的数量,再进一步,需要将这些条件相结合统计,同时满足某两项或更多条件的学生数量。数据表格如下:
我们可以逐层来理解【GROUP BY】【WITH ROLLUP】【WITH CUBE】如何完成数据汇总。
第二层:【GROUP BY】+【WITH ROLLUP】
为【GROUP BY】加上【WITH ROLLUP】子句,看ROLLUP能不能提供更多的统计结果。前面说到多条件,其实说多维度更准确些。看个例子先:
- 语句1 只用了【性别】一个维度进行汇总
SELECT 性别, COUNT(学号) AS 数量
FROM STUDENT
GROUP BY 性别 WITH ROLLUP
- 语句2 用了【性别】和【籍贯】两个维度进行汇总
SELECT 性别, 籍贯, COUNT(学号) AS 数量
FROM STUDENT
GROUP BY 性别, 籍贯 WITH ROLLUP
- 语句3 用了【性别】、【籍贯】、【年龄】三个维度进行汇总
SELECT 性别, 籍贯, 年龄, COUNT(学号) AS 数量
FROM STUDENT
GROUP BY 性别, 籍贯, 年龄 WITH ROLLUP
结果分析:可以看出,ROLLUP提供了更多的统计数据,并且在结果中包含了很多“NULL”值的数据行,其实这些含“NULL”的数据行就是ROLLUP提供的汇总项,再仔细分析一下,不难看出,ROLLUP计算了指定分组(就是汇总的维度)的多个层次的数量小计以及合计,先逐步创建高一级别的小计,最后再创建一行总计。整体结果都是以【性别】这一层次进行数据聚合(这也是与CUBE的不同之处)。
第三层:【GROUP BY】+【WITH CUBE】
还有没有更多组合的数据聚合,CUBE可以提供所选择列的所有组合的聚合。简单说,CUBE生成的结果是个多维数据集,就是包含各个维度的所有可能组合的交叉表格。看个例子:
–语句只用了【性别】和【籍贯】两个维度进行汇总
SELECT 性别, 籍贯, COUNT(学号) AS 数量
FROM STUDENT
GROUP BY 性别, 籍贯 WITH CUBE
结果分析:与上面的ROLLUP的结果进行对比,是不是可以看到更多的结果数据。不仅有性别的小计,还有籍贯的小计。CUBE可以为指定的列创建各种不同组合的小计,是一种比 ROLLUP更细粒度的分组统计语句。如果将统计维度调整到三个维度,会与ROLLUP有更大的差异
最后,引用一下书面的总结,CUBE和ROLLUP之间的区别在于:
CUBE 生成的结果集显示了所选列中值的所有组合的聚合。
ROLLUP 生成的结果集显示了所选列中值的某一层次结构的聚合。
感觉也可以这样来说:ROLLUP就是将GROUP BY后面的第一列名称求总和,而其他列并不要求,而CUBE则会将每一个列名称都求总和。
5.8 MySql执行顺序及执行计划
参考文章:(1条消息) 关于sql和MySQL的语句执行顺序(必看!!!)_猪哥-CSDN博客_sql执行顺序
sql和mysql执行顺序,发现内部机制是一样的。最大区别是在别名的引用上。
sql执行顺序
from
join
on
where
group by(开始使用select中的别名,后面的语句中都可以使用)
avg,sum…
having
select
distinct
order by
limit
从这个顺序中我们不难发现,所有的 查询语句都是从from开始执行的,在执行过程中,每个步骤都会为下一个步骤生成一个虚拟表,这个虚拟表将作为下一个执行步骤的输入。
第一步:首先对from子句中的前两个表执行一个笛卡尔乘积,此时生成虚拟表 vt1(选择相对小的表做基础表)。
第二步:接下来便是应用on筛选器,on 中的逻辑表达式将应用到 vt1 中的各个行,筛选出满足on逻辑表达式的行,生成虚拟表 vt2 。
第三步:如果是outer join 那么这一步就将添加外部行,left outer jion 就把左表在第二步中过滤的添加进来,如果是right outer join 那么就将右表在第二步中过滤掉的行添加进来,这样生成虚拟表 vt3 。
第四步:如果 from 子句中的表数目多余两个表,那么就将vt3和第三个表连接从而计算笛卡尔乘积,生成虚拟表,该过程就是一个重复1-3的步骤,最终得到一个新的虚拟表 vt3。
第五步:应用where筛选器,对上一步生产的虚拟表引用where筛选器,生成虚拟表vt4,在这有个比较重要的细节不得不说一下,对于包含outer join子句的查询,就有一个让人感到困惑的问题,到底在on筛选器还是用where筛选器指定逻辑表达式呢?on和where的最大区别在于,如果在on应用逻辑表达式那么在第三步outer join中还可以把移除的行再次添加回来,而where的移除的最终的。举个简单的例子,有一个学生表(班级,姓名)和一个成绩表(姓名,成绩),我现在需要返回一个x班级的全体同学的成绩,但是这个班级有几个学生缺考,也就是说在成绩表中没有记录。为了得到我们预期的结果我们就需要在on子句指定学生和成绩表的关系(学生.姓名=成绩.姓名)那么我们是否发现在执行第二步的时候,对于没有参加考试的学生记录就不会出现在vt2中,因为他们被on的逻辑表达式过滤掉了,但是我们用left outer join就可以把左表(学生)中没有参加考试的学生找回来,因为我们想返回的是x班级的所有学生,如果在on中应用学生.班级='x’的话,left outer join会把x班级的所有学生记录找回,所以只能在where筛选器中应用学生.班级=‘x’ 因为它的过滤是最终的。
第六步:group by 子句将列的唯一的值组合成为一组,得到虚拟表vt5。如果应用了group by,那么后面的所有步骤都只能得到的vt5的列或者是聚合函数(count、sum、avg等)。原因在于最终的结果集中只为每个组包含一行。这一点请牢记。
第七步:应用cube或者rollup选项,为vt5生成超组,生成vt6.
第八步:应用having筛选器,生成vt7。having筛选器是第一个也是为唯一一个应用到已分组数据的筛选器。
第九步:处理select子句。将vt7中的在select中出现的列筛选出来。生成vt8.
第十步:应用distinct子句,vt8中移除相同的行,生成vt9。事实上如果应用了group by子句那么distinct是多余的,原因同样在于,分组的时候是将列中唯一的值分成一组,同时只为每一组返回一行记录,那么所以的记录都将是不相同的。
第十一步:应用order by子句。按照order_by_condition排序vt9,此时返回的一个游标,而不是虚拟表。sql是基于集合的理论的,集合不会预先对他的行排序,它只是成员的逻辑集合,成员的顺序是无关紧要的。对表进行排序的查询可以返回一个对象,这个对象包含特定的物理顺序的逻辑组织。这个对象就叫游标。正因为返回值是游标,那么使用order by 子句查询不能应用于表表达式。排序是很需要成本的,除非你必须要排序,否则最好不要指定order by,最后,在这一步中是第一个也是唯一一个可以使用select列表中别名的步骤。
第十二步:应用top和limit。最后返回结果给请求者即用户。
MySQL的执行顺序
1、SELECT语句定义
一个完成的SELECT语句包含可选的几个子句。SELECT语句的定义如下:
SQL代码
<SELECT clause> [<FROM clause>] [<WHERE clause>] [<GROUP BY clause>] [<HAVING clause>] [<ORDER BY clause>] [<LIMIT clause>]
SELECT子句是必选的,其它子句如WHERE子句、GROUP BY子句等是可选的。
一个SELECT语句中,子句的顺序是固定的。例如GROUP BY子句不会位于WHERE子句的前面。
2、SELECT语句执行顺序
SELECT语句中子句的执行顺序与SELECT语句中子句的输入顺序是不一样的,所以并不是从SELECT子句开始执行的,而是按照下面的顺序执行:
开始->FROM子句->WHERE子句->GROUP BY子句->HAVING子句->ORDER BY子句->SELECT子句->LIMIT子句->最终结果
每个子句执行后都会产生一个中间结果,供接下来的子句使用,如果不存在某个子句,就跳过
对比了一下,mysql和sql执行顺序基本是一样的, 标准顺序的 SQL 语句为:
select 考生姓名, max(总成绩) as max总成绩
from tb_Grade
where 考生姓名 is not null
group by 考生姓名
having max(总成绩) > 600
order by max总成绩
在上面的示例中 SQL 语句的执行顺序如下:
-
首先执行 FROM 子句, 从 tb_Grade 表组装数据源的数据
-
执行 WHERE 子句, 筛选 tb_Grade 表中所有数据不为 NULL 的数据
-
执行 GROUP BY 子句, 把 tb_Grade 表按 “学生姓名” 列进行分组
注:这一步开始才可以使用select中的别名,他返回的是一个游标,而不是一个表,所以在where中不可以使用select中的别名,而having却可以使用。
-
计算 max() 聚集函数, 按 “总成绩” 求出总成绩中最大的一些数值
-
执行 HAVING 子句, 筛选课程的总成绩大于 600 分的.
-
执行 ORDER BY 子句, 把最后的结果按 “Max 成绩” 进行排序.
5.8 常见优化手段
为什么我只查一行的语句,也执行这么慢?
例子:构造一个表,这个表有两个字段id和c,并且在里面插入了10万行记录
CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;CREATE DEFINER=`root`@`%` 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
1. 长时间不返回
select * from t3 where id=1;
查询结果长时间不返回,使用show processlist
命令,查看当前语句处于什么状态
1) 等MDL锁
如下图所示,使用show processlist;
命令查看到Waiting for table metadata lock
的示意图
这个状态表示现在有一个线程正在表t上请求或者持有MDL写锁,把select语句堵住了
场景复现
sessionA通过lock table
命令持有表t的MDL写锁,而sessionB的查询需要获取MDL读锁。所以,sessionB进入等待状态
这类问题的处理方式,就是找到谁持有MDL写锁,然后把它kill掉。但是由于show processlist
的结果里,sessionA的Command列是Sleep,导致查找起来很不方便,可以通过查询sys.schema_table_lock_waits
这张表直接找出造成阻塞的process id,把这个连接kill命令断开即可(MySQL启动时需要设置performance_schema=on
,相比于设置为off会有10%左右的性能损失)
select blocking_pid from sys.schema_table_lock_waits;
2) 等待flush
在表t上执行如下的SQL语句:
select * from information_schema.processlist where id=1;
查出来某个线程状态为Waiting for table flush
这个状态表示的是,现在有一个线程政要对表t做flush操作。MySQL里面对表做flush操作的用法,一般有以下两个:
flush tables t with read lock;
flush tables with read lock;
这两个flush语句,如果指定表t的话,代表的是只关闭表t;如果没有指定具体的表名,则表示关闭MySQL里所有打开的表
但是正常情况下这两个语句执行起来都很快,除非它们被别的线程堵住了
所以,出现Waiting for table flush状态的可能情况是:有一个flush tables命令被别的语句堵住了,然后它有堵住了select语句
场景复现
sessionA中,每行调用一次sleep(1),这样这个语句默认要执行10万秒,在这期间表t一直是被sessionA打开着。然后,sessionB的flush tables t再去关闭表t,就需要等sessionA的查询结束。这样sessionC要再次查询的话,就会被flush命令堵住了
3) 等待行锁
select * from t where id=1 lock in share mode;
由于访问id=1这个记录时要加读锁,如果这时候已经有一个事务在这行记录上持有一个写锁,select语句就会被堵住
场景复现
sessionA启动了事务,占有写锁,还不提交,是导致sessionB被堵住的原因
2. 查询慢
sessionA先用start transaction with consistent snapshot
命令开启一个事务,建立事务的一致性读(又称为快照读。使用的是MVCC机制读取undo log中的已经提交的数据。所以它的读取是非阻塞的),之后sessionB执行update语句
sessionB执行完100万次update语句后,生成100万个回滚日志
带lock in share mode
的语句是当前读,因此会直接读到1000001这个结果,速度很快;而select * from t where id=1这个语句是一致性读,因此需要从1000001开始,依次执行undo log,执行了100万次以后,才将1这个结果返回
删除的优化
1、如果要删除一个表里面的前10000行数据,有以下三种方法可以做到:
第一种,直接执行delete from T limit 10000;
第二种,在一个连接中循环执行20次delete from T limit 500;
第三种,在20个连接中同时执行delete from T limit 500;
选择哪一种方式比较好?
参考答案:
第一种方式,单个语句占用时间长,锁的时间也比较长,而且大事务还会导致主从延迟
第三种方式,会人为造成锁冲突
第二种方式相对较好
6. 设计模式
6.1 单例模式
6.1.1实现单例模式的五种方法
懒汉式
1. 枚举(推荐)
见上面的枚举中的1.5.3.1
2. 静态内部类
通过静态内部类的方式实现单例模式是线程安全的,同时静态内部类不会在Singleton类加载时就加载,而是在调用getInstance()方法时才进行加载,达到了懒加载的效果。
似乎静态内部类看起来已经是最完美的方法了,其实不是,可能还存在反射攻击或者反序列化攻击,也就是说仍然可以通过反射和反序列化的方法获得不同的实例
public class Singleton {
private static class SingletonHolder {
private static Singleton instance = new Singleton();
}
private Singleton() {
}
public static Singleton getInstance() {
return SingletonHolder.instance;
}
}
3. 双重检查
双重校验的方式,对懒汉式单例模式做了线程安全处理。通过加锁,可以保证同时只有一个线程走到锁内部的代码中,这样保证了只创建 一个实例。还要用到volatile修饰singleton,防止指令重排产生县城安全问题。
public class SingletonSafe {
private static volatile SingletonSafe singleton;
private SingletonSafe() {
}
public static SingletonSafe getSingleton() {
if (singleton == null) {
synchronized (SingletonSafe.class) {
if (singleton == null) {
singleton = new SingletonSafe();
}
}
}
return singleton;
}
}
4.饿汉式
通过static变量保存实例,并让构造方法设计为private。
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return instance;
}
}
7. 编程思维
8. 计算机网络
8.1 OSI七层模型和TCP/IP五层模型
本部分参照了文章:终于把TCP协议与UDP协议给整明白了
8.2从输入url到页面展示的全过程
本篇参照了文章:从输入url到页面展示到底发生了什么
以及文章:从点击到呈现 — 详解一次HTTP请求
1. 输入url
当我们开始在浏览器中输入网址的时候,浏览器其实就已经在智能的匹配可能得url了,他会从历史记录,书签等地方,找到已经输入的字符串可能对应的 url,然后给出智能提示,让你可以补全url地址。对于google的chrome的浏览器,他甚至会直接从缓存中把网页展示出来,就是说,你还没有按下 enter,页面就找到了。
2. DNS
2.1 DNS解析过程
-
一般来说,浏览器会首先查看本地硬盘的 hosts 文件,看看其中有没有和这个域名对应的规则,如果有的话就直接使用 hosts 文件里面的 ip 地址。
-
如果在本地的 hosts 文件没有能够找到对应的 ip 地址,浏览器会发出一个 DNS请求到本地DNS服务器 。本地DNS服务器一般都是你的网络接入服务器商提供,比如中国电信,中国移动。
-
查询你输入的网址的DNS请求到达本地DNS服务器之后,本地DNS服务器会首先查询它的缓存记录,如果缓存中有此条记录,就可以直接返回结果,此过程是递归的方式进行查询。如果没有,本地DNS服务器还要向DNS根服务器进行查询。
-
根DNS服务器没有记录具体的域名和IP地址的对应关系,而是告诉本地DNS服务器,你可以到域服务器上去继续查询,并给出域服务器的地址。这种过程是迭代的过程。
-
本地DNS服务器继续向域服务器发出请求,在这个例子中,请求的对象是.com域服务器。.com域服务器收到请求之后,也不会直接返回域名和IP地址的对应关系,而是告诉本地DNS服务器,你的域名的解析服务器的地址。
-
最后,本地DNS服务器向域名的解析服务器发出请求,这时就能收到一个域名和IP地址对应关系,本地DNS服务器不仅要把IP地址返回给用户电脑,还要把这个对应关系保存在缓存中,以备下次别的用户查询时,可以直接返回结果,加快网络访问。
2.1 DNS负载均衡技术
DNS负载均衡技术,它的原理是在DNS服务器中为同一个主机名配置多个IP地址,在应答DNS查询时,DNS服务器对每个查询将以DNS文件中主机记录的IP地址按顺序返回不同的解析结果,将客户端的访问引导到不同的机器上去,使得不同的客户端访问不同的服务器,从而达到负载均衡的目的。例如可以根据每台机器的负载量,该机器离用户地理位置的距离等等。
3. 发送HTTP请求(应用层)
拿到域名对应的IP地址之后,浏览器会以一个随机端口(1024<端口<65535)向服务器的WEB程序(常用的有httpd,nginx等)80端口发起TCP的连接请求。这个连接请求到达服务器端后(这中间通过各种路由设备,局域网内除外),进入到网卡,然后是进入到内核的TCP/IP协议栈(用于识别该连接请求,解封包,一层一层的剥开),还有可能要经过Netfilter防火墙(属于内核的模块)的过滤,最终到达WEB程序,最终建立了TCP/IP的连接。
4. TCP(传输层)
5. IP(网络层)
IP数据包和路由器
6. 数据链路层
建立逻辑链接,用ARP获取物理地址
7. 到达服务器处理请求
经过Nginx反向代理服务器,到达真正的处理服务器
8. 服务器返回HTTP相应
8.2 传输层协议TCP和UDP
1. TCP
TCP特点:
- 面向连接
- 基于字节流
- 点对点
- 全双工通信
- 提供可靠交付
TCP的基本概念:可靠传输(滑动窗口、超时重传、确认应答)、流量控制(滑动窗口)、拥塞控制(四种算法控制拥塞窗口)、连接管理(三次握手四次挥手)
TCP的缺点: 慢,效率低,占用系统资源高,易被攻击 TCP在传递数据之前,要先建连接,这会消耗时间,而且在数据传递时,确认机制、重传机制、拥塞控制机制等都会消耗大量的时间,而且要在每台设备上维护所有的传输连接,,每个连接都会占用系统的CPU、内存等硬件资源。 因为TCP有确认机制、三次握手机制,这些也导致TCP容易被人利用,实现DOS、DDOS、CC等攻击。
可靠传输
参考文章:网络基础:TCP协议-如何保证传输可靠性
TCP/IP是如何实现可靠传输的
TCP怎么保证可靠传输:
1. CRC循环冗余校验
2. 序列号
利用序列号重新组装,并且在应答包中知道哪些数据报丢失
3. 超时重传
4. 确认应答
接到数据报返回ACK应答数据报,包含序列号
5. 连接管理
三次握手四次挥手,保证连接的可靠性
6. 流量控制
根据接收端对数据的处理能力,控制发送端的发送速度,方法是滑动窗口协议和连续ARQ协议(窗口前进的方法,停止等待和自动重传)
7. 拥塞控制
若网络中有许多资源同时出现供应不足,网络性能就要明显变化,整个网络的吞吐量将随着输入负荷的增大而下降,这就是拥塞。
拥塞控制就是防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不至于过载。
流量控制往往指点对点的通信量的控制。拥塞控制则是考察当整个网络的负载,控制发送端发送速率。
2. UDP
参考文章:UDP协议的详细解析
User Datagram Protocol,用户数据报协议
- UDP无连接,时间上不存在建立连接需要的时延。空间上,TCP需要在端系统中维护连接状态。
- UDP没有拥塞控制,应用层能够更好的控制要发送的数据和发送时间,网络中的拥塞控制也不会影响主机的发送速率。某些实时应用要求以稳定的速度发送,能容 忍一些数据的丢失,但是不能允许有较大的时延(比如实时视频,直播等)
- UDP提供尽最大努力的交付,不保证可靠交付。所有维护传输可靠性的工作需要用户在应用层来完成
- UDP是面向报文的,对应用层交下来的报文,添加首部后直接向下交付给IP层,既不合并,也不拆分
- UDP常用一次性传输比较少量数据的网络应用,如DNS,SNMP等,因为对于这些应用,若是采用TCP,为连接的创建,维护和拆除带来不小的开销。UDP也常用于多媒体应用(如IP电话,实时视频会议,流媒体等)数据的可靠传输对他们而言并不重要,TCP的拥塞控制会使他们有较大的延迟,也是不可容忍的
- CRC循环冗余校验
3. TCP和UDP的区别
此部分参考了文章TCP和UDP的区别超完整版本
- 基于连接与无连接;
- 对系统资源的要求(TCP较多,UDP少);
- UDP程序结构较简单;
- 流模式(TCP)与数据报模式(UDP);
- TCP保证数据正确性,UDP可能丢包,TCP保证数据顺序,UDP不保证。
类别 | TCP | UDP |
---|---|---|
是否连接 | 面向连接 | 面向非连接 |
传输可靠性 | 可靠 | 不可靠 |
应用场合 | 少量数据 | 传输大量数据 |
速度 | 慢 | 快 |
4. 应用场景
TCP应用场景
当对网络通信质量有要求时,比如:整个数据要准确无误的传递给对方,这往往对于一些要求可靠的应用,比如HTTP,HTTPS,FTP等传输文件的协议,POP,SMTP等邮件的传输协议。常见使用TCP协议的应用:
-
浏览器使用的:HTTP、HTTPS
-
FlashFXP : FTP
-
Outlook: POP,SMTP
-
QQ文件传输
UDP 应用场景
对当前网络通讯质量要求不高的时候,要求网络通讯速度尽量的快,这时就使用UDP
日常生活中常见使用UDP协议:
-
QQ语音
-
QQ视频
-
TFTP(简单文件传输协议)
-
DNS协议(因为DNS不在意可靠性但是在意开销和速度)
9. 操作系统
10. 工程经验
性传输比较少量数据的网络应用,如DNS,SNMP等,因为对于这些应用,若是采用TCP,为连接的创建,维护和拆除带来不小的开销。UDP也常用于多媒体应用(如IP电话,实时视频会议,流媒体等)数据的可靠传输对他们而言并不重要,TCP的拥塞控制会使他们有较大的延迟,也是不可容忍的
- CRC循环冗余校验
3. TCP和UDP的区别
此部分参考了文章TCP和UDP的区别超完整版本
- 基于连接与无连接;
- 对系统资源的要求(TCP较多,UDP少);
- UDP程序结构较简单;
- 流模式(TCP)与数据报模式(UDP);
- TCP保证数据正确性,UDP可能丢包,TCP保证数据顺序,UDP不保证。
类别 | TCP | UDP |
---|---|---|
是否连接 | 面向连接 | 面向非连接 |
传输可靠性 | 可靠 | 不可靠 |
应用场合 | 少量数据 | 传输大量数据 |
速度 | 慢 | 快 |
4. 应用场景
TCP应用场景
当对网络通信质量有要求时,比如:整个数据要准确无误的传递给对方,这往往对于一些要求可靠的应用,比如HTTP,HTTPS,FTP等传输文件的协议,POP,SMTP等邮件的传输协议。常见使用TCP协议的应用:
-
浏览器使用的:HTTP、HTTPS
-
FlashFXP : FTP
-
Outlook: POP,SMTP
-
QQ文件传输
UDP 应用场景
对当前网络通讯质量要求不高的时候,要求网络通讯速度尽量的快,这时就使用UDP
日常生活中常见使用UDP协议:
-
QQ语音
-
QQ视频
-
TFTP(简单文件传输协议)
-
DNS协议(因为DNS不在意可靠性但是在意开销和速度)