我们的用户群体,一级医院或者卫生院,二级医院、三级医院
大部分群体是二级医院一级卫生院,卫生院的话有点类似于医共体这种的,虽然每一家的结算量不大,但是一天的结算量2000左右。
医保一天结算量,三级医院一家机构:300~500
然后数据量统计的话,平均单表数据在十几万到几十万左右吧到现在。
然后目录的数据会比较多,大概一百万左右的数据量吧,所以对于目录的话,我们是用的es。
有一次,导入三目的数据,导致线上直接oom了,这个服务紧接着就挂了。因为当时导入的这个文件几十万条吧,我们用的是poi进行导入的,poi都知道,它是先把所有excel的数据都读取到内存中 ,然后才能查询每一行的数据,但是医保给的三目数据列太多了,所以直接导致读崩了。
没办法,那次手动用excel来编辑的sql,通过线上的jira任务来提交的。
后来我将这个导入换成了easyExcel,就没有再出现过类似的问题了。
这个是在去年的时候,当时大家还有权限去查询线上的库。有一次,实施同志执行了一个sql,具体的语句忘了,大概是select * 然后关联了几个大的表,然后也没有加where条件,导致数据库直接查崩了。当时影响挺大的,整个医院系统都用不了,过后还专门开会说了这个事,不允许查询全部,一定要加条件跟limit。而且后来,运维部门把查询线上库的权限也都收回了,如果线上排查问题,使用的是TIDB分析库,这样即使查崩了也不会对线上产生影响。
在数据库路由时,用到了ConcurrentHashMap
在预结算时,rcm会先生成一个settlementId,结算时,会根据这个settlementId来更新结算单状态。
如果出现异常,医保调用成功两次,但是insurance服务只调用了一次,那么到时候由对账的冲正来解决。
结算的数据一致性
insurance服务的拆分,因为insurance服务是所有的医保跟数据库打交道的服务,包括主业务与非主业务,如果把主业务与非主业务区分开来的话,那么,即使非主业务挂了,也不会影响主业务的运行,另外,还可以提升服务的响应时间。
不是,在以下情况会进行栈上分配
三次握手–>数据传输–>四次挥手
最小粒度,不可被分割。例如,负载均衡,第一次与第三次的握手如果不是同一台机器,那么连接建立不起来。
客户端要先发送跟接收一次消息,来确保输入输出没问题。
同样,服务端也需要发送跟接受一次消息,来确保输入输出没问题。
也就是,客户端先发送一次消息,服务端返回一个ack,那么对于客户端来说,确认了输入输出没有问题。
服务端返回ack的过程,其实也就相当于服务端发送了一次消息,那么客户端收到以后,再次发送一次ack。
那么此时,两端都确认了输入输出没有问题,开始开辟空间,创建线程,建立响应的资源,建立起连接进行通信。
第一次,客户端先跟服务端说,我要断开连接,服务端发送一个确认收到,服务端再次发送一个我也要分手,客户端再发送一个确认,所以一共需要四次。
65535
基于四层的负载均衡,数据包级别的转发,不会和客户端进行握手。因为是基于四层的协议,所以只能看到ip+端口。
4层反向代理: 可以根据ip、端口进行转发
7层反向代理: 可以根用户协议、方法、头、正文参数、cookie等进行转发
越往低层,掌握的信息越少,实现起来越简单,效率也越高
两个看的角度不同,并发是任务提交,并行是任务执行。
并发是从任务提交的角度看,多个任务可以同时运行,但是实际是不是同时运行不一定。
从行是从任务执行的角度看,多个任务同时处理。
并发包括并行。
需要统一接口的定义,彼此当做的黑盒。
C:一致性:在同一时刻,获取到的数据都是最新的数据(所有节点的写操作都是原子操作)
强一致性:写操作完成,后续所有的读操作都能获取到最新修改后的数据
弱一致性:写操作完成后,可能一段时间内读取到的是旧值,但是最终要读取到新值。
A::可用性:不管成功与失败,都能立刻返回结果(只要有数据返回就行,不管新值旧值。)
P:分区容错性:
分区:假如有四个服务,分别是ServiceA、ServiceB、ServiceC、ServiceD,此时,ServiceA与ServiceB能够正常通信,ServiceC与ServceD能够正常通信,但是ServiceA、ServiceB与ServiceC、ServiceD不能进行通信,这时候,这时候就产生了分区
分区容错性:即使产生分区,各个分区也要正常返回结果。
三者只能满足其中两个,其中P是必须要满足的。即使网络出现问题,我们的系统也要能正常使用。
如何保证P:
1. 尽量使用异步代替同步操作。
2. 主节点挂了,从节点顶替上来
常用的解决方案:
保证AP,兼顾C(最终一致性)
如果保证强一致性,那么会影响吞吐量。
CP与AP如何选择:
考虑业务对数据一致性的容忍程度
业务的读写频率(是否是读多写少的业务)
相同的服务启动多个
优点:每个进程之间资源独立,具有很强的隔离性
缓存设计的意义:以空间换时间
以上操作无非三步
以缓存为主,数据进来先存缓存,再保存数据库。
需要保证程序启动时,先将数据的的数据放入缓存,不能等待启动完成后放入缓存。
以缓存为主,数据进来只存缓存,数据库通过异步或者消息队列的方式进行存储
优点:降低了写操作的时间,提高了系统吞吐量
缺点:如果缓存一旦挂了,那么整个服务就挂了
与上面的方案不同的是,上面的方案写数据库是同步的,这个是异步的
fifo:先进先出
定长队列,当队列满时,从队尾删除数据
random:随机剔除
lru:剔除最近最少未使用的数据
ttl: 设置过期时间,到期自动清理
没有,因为以上出现的情况,一定是共享数据产生了并发压力,但是对于医院来说,每个患者的就诊的数据,是属于私有数据,不会造成大的并发,所以没有采用以上的处理方式。
对于同一个key的请求,先要保证服务内部的排他(JVM锁),再保证服务外部的排他(分布式锁),这样就可以大大减少获取锁的数量。
如果是以缓存为主的业务,需要对缓存进行提前加载,另外还有一些热数据,也需要提前进行预热缓存。
写缓存:减少了客户响应时间,提高了系统吞吐量,增加了系统处理时间。
读缓存:减少了客户响应时间,提高了系统吞吐量,减少了系统处理时间。
产生原因:系统压力大,负载过高,例如数据库慢查询,或者调用的服务出现问题
核心思想:优先保证核心业务,优先保证大部分用户。
将非核心业务停用,来保证主业务可用。
前提:
代码提前规划好
实现方案:
触发条件:
降级手段
减少不必要的操作,保留核心业务功能。
调用别的服务,别的服务挂了或者超时,为了当前服务不被拖垮,所以采用熔断策略。
熔断策略:
根据请求的失败率,如果到达阈值,则打开熔断开关,过一段时间,关闭熔断,放一个请求过去,如果还是不行,继续打开熔断(半开状态/Fail fast)。Hystrix
根据响应时间,如果服务响应时间超过指定的阈值,并且接下来5个请求都超过阈值,那么就开启熔断Sentinel
在对账中,我们使用了服务降级
因为前期是insurance与医保中心进行的对账,但是系统内与rcm没有进行校验,所以后期可能会出现系统内的不一致。所以后来加了与rcm对账,但是如果与rcm对账不通过,而医保又要求在规定时间内进行对账,所以在前端的菜单配置中,可以将于rcm对账去掉,来去掉与rcm对账的强校验。
服务不可用时,返回一个临时的状态,Generate,例如在发版过程中访问业务。
为了防止服务被拖垮,拒绝一些请求,来保证服务的正常运行。
触发情况:
外部:请求流量过大
内部:资源不够时
当超过阈值后,进行排队
先设定一个数,然后上线观察。
只接受时间窗口内的最后一个请求(例如百度的搜索,只请求1s内最后一次输入的关键字)
从总的阶段来看,一共分为五个动作,分别是加载、验证、准备、解析、初始化,当然,这几个动作不是依次执行的,像校验,是贯穿整个过程的。
第一步: 加载,这个过程主要完成3件事
通过一个类的全限定名来获取此类的二进制流
将二进制流中的静态存储结构,转化为方法区中的运行时数据结构
在内存中生成一个代表此类的Class对象,作为方法区中访问该对象数据的入口
第二步: 验证,主要分为4个阶段
文件格式验证:验证文件格式是否符合class文件格式规范(例如文件是否以0XCAFEBABE开头,版本号是否当前虚拟机能够解析等等)
元数据验证:验证描述信息是否符合JAVA规范(比如这个类是否有父类,是否继承了final修饰的类等等)
字节码验证:验证方法体的语义是否合法,符合逻辑
符号引用验证:验证符号引用能否转换成直接引用,包括直接引用能否被当前类所访问
第三步:准备
为类变量分配内存空间,并赋初始值。
第四步:解析
将符号引用替换成直接引用
第五步:初始化
初始化静态变量的值
执行静态代码块
初始化当前类的父类
Bootstrap ClassLoader(启动类加载器)
Extension ClassLoader(扩展类加载器)
Application ClassLoader(应用程序加载器)
自定义类加载器
当需要加载一个类时,先委托父类加载器去完成,如果父类加载器完成不了,才会尝试自己去加载。
安全,防止核心类被外部篡改
避免类重复加载
重新loadClass方法
设置上下文类加载器
堆: 堆中存放所有new出来的对象
方法区: 类信息、静态变量、常量、即时编译的代码
程序计数器: 记录当前线程运行到哪一步了
本地方法栈: JVM执行native方法的栈
java虚拟机栈: JVM执行java程序的栈
其中,堆,方法区线程共享,其他的线程私有
JDK1.6:永久代
JDK1.7以后:堆中
局部变量表: 方法中定义的局部变量以及方法的入参(局部变量表中的数据不能直接使用,如果要使用的话,必须调用相关指令将其加载到操作数栈中作为操作数使用)
操作数栈: 以压栈和出栈的形式存储操作数的
动态链接: 将常量池中的调用其他方法的符号引用转化为直接引用
方法返回地址
GC管理的是堆内存,一般只会对堆内存进行垃圾回收,方法区、虚拟机栈、本地方法栈不被GC管理,因而选择这些区域作为GC Root,被GC引用的对象不会被回收,一般情况下GC Root有以下几种
方法区中的静态变量、常量引用的对象
虚拟机栈中的局部变量表引用的对象
本地方法栈中的JNI引用的对象
Young区:年轻代,包含Eden区和Survivor区
Old区: 老年代
Young区: Young GC(minor GC)
Old区: Old GC(major GC)
Young区+Old区: Full GC,这个是当堆内存不足时触发
因为新生代使用的算法是复制回收算法,如果只有Survivor区,那么回收一次就会被送往Old区
这样会导致Old区很快被填满,触发Old GC(一般Old GC会伴随着Young GC,也就是Full GC)
老年代的空间一般大于新生代,所以消耗的时间比较长
另外老年代使用的回收算法是标记清除与标记压缩,不适合频繁的触发
所以,存在Survivor区的意义在于,对象不会很快被送到Old区,只有回收16次,才会被送往老年代
其实是为了解决碎片化问题,因为复制算法,必须有有一块连续并空余的内存,Eden区回收一次后进入Survivor区
那么找不到一块连续的空间,去进行复制回收算法。
对象头: markword、ClassPoint、length(数组独有)
实例数据: 成员变量
对其填充: 保证对象大小满足8字节的整数倍
Mark Word: 8字节
Class Pointer: 不开启压缩8字节,开启压缩4字节
length: 4字节
实例数据: 引用类型不开启压缩8字节,不开启压缩4字节
padding: 8字节的倍数对其
名称 | 大小 |
---|---|
markword | 8 |
ClassPointer | 默认为4字节,关闭指针压缩为8字节 |
boolean | 1 |
byte | 1 |
short | 2 |
char | 2 |
int | 4 |
float | 4 |
long | 8 |
double | 8 |
数组 | size占4个字节,加上实例数据大小 |
引用类型 | 开启指针压缩为4,不开启为8 |
padding | 8的倍数对齐 |
主流的方式有使用句柄跟直接指针两种,HotSpot是使用的直接指针
句柄访问: 变量中存储的是句柄的地址,而句柄中分别存储了对象的类型数据地址(方法区)与对象的实例数据地址(堆)
直接访问: 变量中存储对象的实例数据地址
优缺点:
句柄访问的方式,如果实例对象地址发生变化,不需要更新变量的地址,但是多了一层访问,访问速度低于直接访问
直接访问的优点: 访问速度快
程序执行时,并非所有地方都能停下来GC,只有在特定的位置,才会去去执行GC,这些特定的位置被称为安全点。
这些特定的位置,就是安全点,这些安全点的选定标准是"是否长时间执行"的特性,比如方法调用,循环跳转,异常跳转等
在GC的时候,有两种方案能够让线程准确的停留在安全点上
抢占式中断: 先让所有线程中断,然后让那些停留在不安全点上的线程跑到安全点上。
主动试中断: 当需要GC是,设置一个标志,线程执行过程中,当发现这个标志的时候,就会主动挂起线程。
除了在安全点上,还有一些情况,比如线程sleep或者blocked状态,那么安全区域来解决
只要在一个特定区域中,对象引用状态不会发生改变,就可以发起GC,当进入安全区域时,就标记自己已经进入了安全区域,那么,在这段时间发起GC时,就不用管是否在安全点上了
强引用:指代码中普遍存在的赋值行为,如:Object o = new Object(),只要强引用关系还在,对象就永远不会被回收。
软引用:还有用处,但不是必须存活的对象,JVM会在内存溢出前对其进行回收,例如:缓存。
弱引用:非必须存活的对象,引用关系比软引用还弱,不管内存是否够用,下次GC一定回收。
虚引用:也称“幽灵引用”、“幻影引用”,最弱的引用关系,完全不影响对象的回收,等同于没有引用,虚引用的唯一的目的是对象被回收时会收到一个系统通知。
堆外内存的垃圾回收。
单线程的垃圾回收器
适合client端使用
优点: 单核效率最高,简单高效
多线程的垃圾回收器
适合service端使用
优点: 适合多线程使用
对于Serial来说,优化的是STW的时间
与Parnew类似,也是多线程的垃圾回收器
不同点在于,更加注重的是吞吐量,可以手动指定吞吐量,也可以自适应
单线程的垃圾回收器
使用标记整理算法
JDK1.5之前的老年代垃圾回收器或者作为CMS的备选方案
多线程的垃圾回收器
与Parallel Scavenge配合,JDK1.6推出。
使用标记整理算法
Parallel Scavenge与Parallel Old配合,用于注重吞吐量的场合
并发的垃圾回收器
主要是为了优化减少停顿时间
垃圾回收的过程分为了
初始标记: 主要是找到所有的GC Root,这一步是STW的
并发标记: 标记这条引用链上的所有对象,这一步是并发执行
重新标记: 修正并发标记期间产生的变化,这一步是STW的,要比初始标记时间长点,但是远没有并发标记时间长
并发清除: 并发去清理垃圾,这一步是并发执行的
使用CMS也会产生一些问题
CMS的线程数的计算公式(CPU数量+3)/4,如果CPU线程数越少,工作线程执行效率越低,例如只有CPU数量只有两个的时候,那么用户线程的工作效率会降低50%
CMS当老年代分配不下时,会触发Full GC,使用Serial Old单线程垃圾回收器来回收
CMS采用的是标记清除,所以会产生浮动垃圾,由于工作线程与垃圾回收线程同时运行,那么很有可能会出现明明还有很大空间,但是却找不到一块连续的空间来放这个对象,这时候也会触发Full GC
CMS的CPU建议在四核以上
并发的垃圾回收器,可以由用户手动指定停顿时间
垃圾回收过程:
初始标记
并发标记
重新标记
筛选回收: 根据每个Regin区价值(回收获得的空间大小以及回收所需要的时间)排序,优先回收在用户指定时间内的垃圾
回收算法: 从两个Regin区间看的话,是采用的复制算法,如果从整体看的话,是标记压缩算法,可以减少内存碎片的产生。
逻辑分代,分为一个一个的Regin区,每一个Regin区可以是为Eden区、Survivor区、Old区,Humongouns可能跨好几个Regin区来存放大对象
G1分成了2048个Regin区,每一个Regin区大小1M-30M之间
Remembered Set中存放的是当前Regin区中,每个对象被哪些对象所引用,这个引用可能跨Rengin
引用关系的记录维护在Remembered Set中,判断存活对象,只需要扫描Remembered Set即可,就不需要扫描整个堆了
首先发生Full GC说明是老年代满了,那么有如下可能性
一般Full GC的原因是老年代满了,那么老年代满了又有很多种情况
1. 年轻代满了,对象直接进入老年代,那么像这种情况,调大Young区
2. 大对象直接进入老年代
3. 内存泄漏
4. 频繁调用System.gc()
因为并发标记阶段,用户线程与垃圾回收线程同时在运行,那么如果此时新进来对象新生代老年代都放不下,那么就可能导致晋升失败
如果是这种情况,那么有如下几个解决办法
1. 如果是年轻代设置的太小了,导致对象很容易进入老年代,那么年轻代空间设置的较大点即可
2. 如果老年代设置的太小了,导致对象放不下,那么老年代设置的大一点
3. 另外,增加老年代的回收频率
白色: 未被标记过的对象
灰色: 自身被标记,子节点没有被标记
黑色: 自身与子节点都有被标记
漏标: 满足漏标,必须是黑色对象指向灰色对象,灰色对象指向白色对象,这时候,黑色对象指向白色对象,同时,灰色对象对白色对象的引用消失,这时候就会产生漏标的情况。
那么解决漏标的话,有两种解决方案
CMS: increment update -> 关注引用增加,也就是将黑色对象重新标记成灰色对象
G1: SATB -> 关注引用删除,引用删除时,将他加入到栈中,由于有Remembered Set的存在,就不需要扫描整个堆去查找指向白色的引用,效率较高
SATB是关注的引用删除,当引用删除时,将他加入到一个栈中
当进行回收时,只需要将栈中数据拿出来遍历,并查询Remembered Set就可以解决漏标的问题了,这样就不用扫描整个堆了,效率比较高
JMM(Java Memory Model): java内存模型,虚拟机用来屏蔽各种硬件和操作系统的内存访问差异,以及如何保证原子性、可见性、有序性
硬件与java内存模型的关系
读取缓存是以缓存行(cache line)为单位的,目前是64个字节
位于同一缓存行的不同数据,同时被两个不同的cpu锁定,相互产生的影响就叫伪共享
如何解决: 缓存行对其
缓存一致性协议
这里列举一种协议MESI
MESI协议又叫Illinois协议,MESI,“M”, “E”, “S”, "I"这4个字母代表了一个cache line的四种状态,分别是Modified,Exclusive,Shared和Invalid。
Modified (M)
cache line只被当前cache所有,并且是dirty的。
Exclusive (E)
cache line仅存在于当前缓存中,并且是clean的。
Shared (S)
cache line在其他Cache中也存在并且都是clean的。
Invalid (I)
cache line无效,即没有被任何Cache加载。
我标记它为是M时,你只能标记为I
我标记它为是E时,你只能标记为I
我标记它为是S时,你只能标记为S或I
我标记它为是I时,你能标记为MESI
首先字节码的变量上加一个ACC_VOLATILE标识
jvm为了提高效率,允许指令重排序。而在字节码层面,new对象分为INVOKESPECIAL(调用构造方法) ASTORE(将引用指向该地址),
NEW java/lang/Object
DUP
INVOKESPECIAL java/lang/Object. ()V
ASTORE 1
如果指令重排序后,将这两个步骤调换,那么就会先将引用指向该地址,这时候,其他线程来访问时,会得到一个半初始化的对象。
NEW java/lang/Object
DUP
ASTORE 1
INVOKESPECIAL java/lang/Object. ()V
字节码层面:
方法上: ACC_SYNCHRONIZED
局部代码块: monitorenter monitorexit
JVM层面: 调用C/C++提供的同步机制
OS和操作系统层面: lock指令
synchronized在字节码层面是monitorenter和monitorexit,对应硬件层面是lock与unlock指令,在unlock之前,会把新值同步到主内存中(store、write操作)
一般情况下,只会使用到一个辅助索引,即使创建了多个辅助索引,也只会选择一个。
第一范式: 每个字段必须是原子的,不可拆分。
反例: json字段
第二范式: 必须有主键(一个主键或者组合主键),非主属性,必须完全依赖于主键,不能依赖部分主键
反例: 员工部门关系表 -> 主键(员工id, 部门id) 部门名称(只依赖于部门id)
或者如下图,产品id并不完全依赖于id字段。
第三范式: 没有传递依赖, 非主属性直接依赖主属性,而不能间接依赖主属性
反例: 员工表 -> 主键(员工id) 员工姓名 所属部门id 部门名称(部门名称依赖于部门id,不依赖于主键)
计数器表在Web应用中很常见。比如网站点击数、用户的朋友数、文件下载次数等。对于高并发下的处理,首先可以创建一张独立的表存储计数器,这样可使计数器表小且快,并且可以使用一些更高级的技巧。
比如假设有一个计数器表,只有一行数据,记录网站的点击次数,网站的每次点击都会导致对计数器进行更新,问题在于,对于任何想要更新这一行的事务来说,这条记录上都有一个全局的互斥锁(mutex)。这会使得这些事务只能串行执行,会严重限制系统的并发能力。
怎么改进呢?可以将计数器保存在多行中,每次随机选择一行进行更新。在具体实现上,可以增加一个槽(slot)字段,然后预先在这张表增加100行或者更多数据,当对计数器更新时,选择一个随机的槽(slot)进行更新即可。
TINYINT | SMALLINT | MEDIUMINT | INT | BIGINT |
---|---|---|---|---|
8位 | 16位 | 24位 | 32位 | 64位 |
1字节 | 2字节 | 3字节 | 4字节 | 8字节 |
存储上,没有区别,只是在可视化工具方面,展示的不同而已。
decimal实际内部使用字符串存储的
float | double | decimal |
---|---|---|
4字节 | 8字节 | 65个数字 |
char定长,更省空间
varchar 不定长,需要额外1-2个字节来存储长度(255以内,1个字节,超过255两个字节)
同一个字符串,char、varchar占用的磁盘空间是差不多的,但是,varchar读取到内存,可能占用的空间会更大。
所以,对于一些操作,char的性能高于varchar
blob、text存储一个1-4个字节的指针,来指向外部的存储
blob内部使用二进制存储
text有字符集以及排序规则
如果是一些固定的字符串,可以使用枚举类型来存储,可以提升效率
char | varchar | blob | text | enum | set |
---|---|---|---|---|---|
定长 | 不定长 | 1-4个字节存放指针 | 1-4个字节存放指针 | 内部使用整数存储 |
只精确到秒
datetime | timestamp |
---|---|
1010年-9999年 | 1970年-2038年 |
使用bigint,将原先的数据以10的倍数放大。
一般像这种大的类型,索引处理会比较复杂,可以单独放一行,不要与其他列放在同一行中,这样可以提升查询效率。
存储结构不同
InnoDB只有ibd文件,
MyISAM分别由.frm(表结构) .MYD(表数据) .MYI(表索引)三个文件组成
InnoDB支持行锁,MyISAM只支持表锁
InnoDB支持事务,但是MyISAM不支持事务
目的:高效的获取数据的数据
InnoDB
哈希索引
全文索引
适合准确查询,不适合范围查询
不适合排序
不适合组合索引
hash冲突后,会使用链表,查询效率低
B+树是通过二叉查找树、平衡二叉树、B树演化而来
特点:
左树都比小于头节点,右树大于等于头节点
叶子节点按照链表形式进行相连
一个节点有多个数据,也会进行排序
非叶子节点,只存储索引,叶子节点才会存储真实的数据
标准的B+树是叶子结点单链表,mysql是双向链表
区别在于B树非叶子结点也存放数据,而B+树只有叶子节点才会存放数据
B+树的叶子节点会进行相连,可以进行顺序读取,适合范围查询,而b树不可以
因为每个节点存储的大小有限,而B+树的所有节点都是索引,所以每个节点存储的更多,树的高度对于B树来说更低,所以效率更高。
oracle:B*树
mysql:B+树
数据跟索引存储在一起,叫做聚簇索引,没有存储在一起叫做非聚簇索引
InnoDB中既有聚簇索引也有非聚簇索引
myisam中只有非聚簇索引
innoDB存储引擎在进行数据插入时,数据必须跟某一个索引列存储在一起,这个索引列可以是主键。如果没有主键,选择唯一键,如果没有唯一键,选择6字节的rowid作为主键进行存储。
只要创建索引,mysql就会创建一棵B+树
辅助索引的叶子结点中存储的数据不再是整行的记录,而是索引值与主键值。
因为辅助索引的叶子结点中存储的数据是主键值,所以当使用辅助索引时,需要先根据辅助索引查询主键值,然后根据主键值查询到完整的数据。相当于查询一条数据,走了两次索引查询,一次辅助索引,一次主键索引。
由于回表的存在,回表的记录越少,性能提升就越高,需要回表的记录越多,使用辅助索引的性能就越低,甚至让某些查询宁愿使用全表扫描也不使用辅助索引。
那什么时候采用全表扫描的方式,什么时候使用采用辅助索引 + 回表的方式去执行查询呢?这个就是查询优化器做的工作,查询优化器会事先对表中的记录计算一些统计数据,然后再利用这些统计数据根据查询的条件来计算一下需要回表的记录数,需要回表的记录数越多,就越倾向于使用全表扫描,反之倾向于使用辅助索引 + 回表的方式。
从辅助索引中就可以查询到的记录,那么就不会走回表操作,这就叫做覆盖索引。
InnoDB存储引擎内部自己去监控索引表,如果监控到某个索引经常用,那么就认为是热数据,然后内部自己创建一个hash索引,称之为自适应哈希索引( Adaptive Hash Index,AHI),创建以后,如果下次又查询到这个索引,那么直接通过hash算法推导出记录的地址,直接一次就能查到数据,比重复去B+tree索引中查询三四次节点的效率高了不少。
创建索引应该选择选择性/离散性高的列。索引的选择性/离散性是指,不重复的索引值(也称为基数,cardinality)和数据表的记录总数(N)的比值,范围从1/N到1之间。索引的选择性越高则查询效率越高,因为选择性高的索引可以让MySQL在查找时过滤掉更多的行。唯一索引的选择性是1,这是最好的索引选择性,性能也是最好的。
很差的索引选择性就是列中的数据重复度很高,比如性别字段,不考虑政治正确的情况下,只有两者可能,男或女。那么我们在查询时,即使使用这个索引,从概率的角度来说,依然可能查出一半的数据出来。
哪列做为索引字段最好?当然是姓名字段,因为里面的数据没有任何重复,性别字段是最不适合做索引的,因为数据的重复度非常高。
怎么算索引的选择性/离散性?比如person这个表:
SELECT count(DISTINCT name)/count() FROM person;
SELECT count(DISTINCT sex)/count() FROM person;
SELECT count(DISTINCT age)/count() FROM person;
SELECT count(DISTINCT area)/count() FROM person;
对于很长的字段(例如blob、text、varchar),mysql不支持索引他们的全部长度,需要建立前缀索引。
语法: alter table tableName add key/index (column(X))
X是前缀长度
当使用order by、group by无法使用索引。
如何选择索引长度
SELECT COUNT(DISTINCT LEFT(order_note,3))/COUNT(*) AS sel3,
COUNT(DISTINCT LEFT(order_note,4))/COUNT(*)AS sel4,
COUNT(DISTINCT LEFT(order_note,5))/COUNT(*) AS sel5,
COUNT(DISTINCT LEFT(order_note, 6))/COUNT(*) As sel6,
COUNT(DISTINCT LEFT(order_note, 7))/COUNT(*) As sel7,
COUNT(DISTINCT LEFT(order_note, 8))/COUNT(*) As sel8,
COUNT(DISTINCT LEFT(order_note, 9))/COUNT(*) As sel9,
COUNT(DISTINCT LEFT(order_note, 10))/COUNT(*) As sel10,
COUNT(DISTINCT LEFT(order_note, 11))/COUNT(*) As sel11,
COUNT(DISTINCT LEFT(order_note, 12))/COUNT(*) As sel12,
COUNT(DISTINCT LEFT(order_note, 13))/COUNT(*) As sel13,
COUNT(DISTINCT LEFT(order_note, 14))/COUNT(*) As sel14,
COUNT(DISTINCT LEFT(order_note, 15))/COUNT(*) As sel15,
COUNT(DISTINCT order_note)/COUNT(*) As total
FROM order_exp;
可以看见,从第10个开始选择性的增加值很高,随着前缀字符的越来越多,选择度也在不断上升,但是增长到第15时,已经和第14没太大差别了,选择性提升的幅度已经很小了,都非常接近整个列的选择性了。
那么针对这个字段做前缀索引的话,从第13到第15都是不错的选择
在上面的示例中,已经找到了合适的前缀长度,如何创建前缀索引:
ALTER TABLE order_exp ADD KEY (order_note(14));
mysql只支持前缀索引,如果需要使用后缀索引,可以添加一个新列,保存当前列反转后的字符串,然后建立前缀索引。
三星索引概念
对于一个查询而言,一个三星索引,可能是其最好的索引。
满足的条件如下:
这三颗星,哪颗最重要?第三颗星。因为将一个列排除在索引之外可能会导致很多磁盘随机读(回表操作)。第一和第二颗星重要性差不多,可以理解为第三颗星比重是50%,第一颗星为27%,第二颗星为23%,所以在大部分的情况下,会先考虑第一颗星,但会根据业务情况调整这两颗星的优先度。
一星:
一星的意思就是:如果一个查询相关的索引行是相邻的或者至少相距足够靠近的话,必须扫描的索引片宽度就会缩至最短,也就是说,让索引片尽量变窄,也就是我们所说的索引的扫描范围越小越好。
二星(排序星) :
在满足一星的情况下,当查询需要排序,group by、 order by,如果查询所需的顺序与索引是一致的(索引本身是有序的),是不是就可以不用再另外排序了,一般来说排序可是影响性能的关键因素。
三星(宽索引星) :
在满足了二星的情况下,如果索引中所包含了这个查询所需的所有列(包括 where 子句和 select 子句中所需的列,也就是覆盖索引),这样一来,查询就不再需要回表了,减少了查询的步骤和IO请求次数,性能几乎可以提升一倍。
一般,我们要优化sql,是看type字段
常见的顺序 system>const>eq_ref>ref>range>index>ALL
一般来说,得保证查询至少达到range级别,最好能达到ref。
高度差不超过一一半,它是一棵比较均衡的树,查找、插入、删除 三者操作平均 效率最高)
默认一个表对应一个idb文件,data目录下
使用表分区,可以对数据按照规则进行水平分表,一个逻辑表对应多个idb文件。
优点:
使用条件查询,当判定为某个区,直接在这个分区中查找即可,不需要扫描整个表
业务代码不需要改动
分区单独管理,备份、恢复
缺点:
写入数据效率略低于不分区
跨区查询效率较低
常用方式:
parttition by range: 例如按照id数值分区 0-10 11-20
parttition by list:根据某个字段值进行分区xxxx values in (1, 2)
parttition by hash:根据hash函数进行分区
parttition by key
注意事项:
要根据查询规则进行分区,尽量保证查询落到一个分区中,尽量保证分区查询在where语句中。
例如:根据学生姓名查询老师姓名,那么以学生姓名作为分区较为合理。
垂直分表
水平分表
Sharding-JDBC、mycat
目的:分流
适用场景:读多写少的情况
原因:数据库的锁会对数据库并发产生影响
X锁-写锁,只能有一个线程去进行写操作,别的线程读写都进行阻塞
S锁-读锁,多个线程可以同时去读
一主(写)多从(读)
如何实现:
根据sql语句进行自动路由
select 路由到从库
insert、update、delete路由到主库
主从同步问题:
写主库,如何高效的同步到从库
主库开启binlog日志,传给从库,写入从库的relayLog,解析relayLog,重新数据。
时间差问题:
写数据后的查询,指向到主库中去。
先查询从库,如果从库没有再查询主库。
主业务走主库,非主业务走从库。
mysql在一般情况下,执行一个查询时最多只会用到单个二级索引,但也可能在一个查询中使用到多个二级索引。mysql将使用到多个索引来完成一次查询的执行方法称之为索引合并(index merge)
对于 Intersection 合并可以使用联合索引来进行优化。
事务应该具有4个属性:原子性、一致性、隔离性、持久性。这四个属性通常称为ACID特性。
l 原子性(atomicity)
l 一致性(consistency)
l 隔离性(isolation)
l 持久性(durability)
当一个事务读取到了另外一个事务修改但未提交的数据,被称为脏读。
1、在事务A执⾏过程中,事务A对数据资源进⾏了修改,事务B读取了事务A修改后的数据。
2、由于某些原因,事务A并没有完成提交,发⽣了RollBack操作,则事务B读取的数据就是脏数据。
这种读取到另⼀个事务未提交的数据的现象就是脏读(Dirty Read)。
当事务内相同的记录被检索两次,且两次得到的结果不同时,此现象称为不可重复读。
事务B读取了两次数据资源,在这两次读取的过程中事务A修改了数据,导致事务B在这两次读取出来的
数据不⼀致。
在事务执行过程中,另一个事务将新记录添加到正在读取的事务中时,会发生幻读。
事务B前后两次读取同⼀个范围的数据,在事务B两次读取的过程中事务A新增了数据,导致事务B后⼀
次读取到前⼀次查询没有看到的⾏。
幻读和不可重复读有些类似,但是幻读重点强调了读取到了之前读取没有获取到的记录。
脏读 > 不可重复读 > 幻读
如果你开启了一个事务,执行了很多语句,忽然发现某条语句有点问题,你只好使用ROLLBACK语句来让数据库状态恢复到事务执行之前的样子,然后一切从头再来,但是可能根据业务和数据的变化,不需要全部回滚。所以MySQL里提出了一个保存点(英文:savepoint)的概念,就是在事务对应的数据库语句中打几个点,我们在调用ROLLBACK语句时可以指定会滚到哪个点,而不是回到最初的原点。定义保存点的语法如下:
SAVEPOINT 保存点名称;
当我们想回滚到某个保存点时,可以使用下边这个语句(下边语句中的单词WORK和SAVEPOINT是可有可无的):
ROLLBACK TO [SAVEPOINT] 保存点名称;
当我们使用START TRANSACTION或者BEGIN语句开启了一个事务,或者把系统变量autocommit的值设置为OFF时,事务就不会进行自动提交,但是如果我们输入了某些语句之后就会悄悄的提交掉,就像我们输入了COMMIT语句了一样,这种因为某些特殊的语句而导致事务提交的情况称为隐式提交,这些会导致事务隐式提交的语句包括:
执行DDL
隐式使用或修改mysql数据库中的表
当我们使用ALTER USER、CREATE USER、DROP USER、GRANT、RENAME USER、REVOKE、SET PASSWORD等语句时也会隐式的提交前边语句所属于的事务。
事务控制或关于锁定的语句
当我们在一个会话里,一个事务还没提交或者回滚时就又使用START TRANSACTION或者BEGIN语句开启了另一个事务时,会隐式的提交上一个事务,比如这样:
BEGIN;
SELECT ... # 事务中的一条语句
UPDATE ... # 事务中的一条语句
... # 事务中的其它语句
BEGIN; # 此语句会隐式的提交前边语句所属于的事务
加载数据的语句
比如我们使用LOAD DATA语句来批量往数据库中导入数据时,也会隐式的提交前边语句所属的事务。
关于MySQL复制的一些语句
使用START SLAVE、STOP SLAVE、RESET SLAVE、CHANGE MASTER TO等语句时也会隐式的提交前边语句所属的事务。
其它的一些语句
使用ANALYZE TABLE、CACHE INDEX、CHECK TABLE、FLUSH、 LOAD INDEX INTO CACHE、OPTIMIZE TABLE、REPAIR TABLE、RESET等语句也会隐式的提交前边语句所属的事务。
全称Multi-Version Concurrency Control,即多版本并发控制,主要是为了提高数据库的并发性能。
同一行数据平时发生读写请求时,会上锁阻塞住。但MVCC用更好的方式去处理读—写请求,做到在发生读—写请求冲突时不用加锁。
这个读是指的快照读,而不是当前读,当前读是一种加锁操作,是悲观锁。
我们知道,对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列(row_id并不是必要的,我们创建的表中有主键或者非NULL的UNIQUE键时都不会包含row_id列):
trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。
roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
(补充点:undo日志:为了实现事务的原子性,InnoDB存储引擎在实际进行增、删、改一条记录时,都需要先把对应的undo日志记下来。一般每对一条记录做一次改动,就对应着一条undo日志,但在某些更新记录的操作中,也可能会对应着2条undo日志。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的undo日志,这些undo日志会被从0开始编号,也就是说根据生成的顺序分别被称为第0号undo日志、第1号undo日志、…、第n号undo日志等,这个编号也被称之为undo no。)
每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表,所以现在的情况就像下图一样:
对该记录每次更新后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务id。于是可以利用这个记录的版本链来控制并发事务访问相同记录的行为,那么这种机制就被称之为多版本并发控制(Mulit-Version Concurrency Control MVCC)。
对于使用READ UNCOMMITTED隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了(所以就会出现脏读、不可重复读、幻读)。
对于使用SERIALIZABLE隔离级别的事务来说,InnoDB使用加锁的方式来访问记录(也就是所有的事务都是串行的,当然不会出现脏读、不可重复读、幻读)。
对于使用READ COMMITTED和REPEATABLE READ隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是:READ COMMITTED和REPEATABLE READ隔离级别在脏读和不可重复读上的区别是从哪里来的,其实结合前面的知识,这两种隔离级别关键是需要判断一下版本链中的哪个版本是当前事务可见的。
为此,InnoDB提出了一个ReadView的概念(作用于SQL查询语句),
这个ReadView中主要包含4个比较重要的内容:
**m_ids:**表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。
**min_trx_id:**表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。
**max_trx_id:**表示生成ReadView时系统中应该分配给下一个事务的id值。注意max_trx_id并不是m_ids中的最大值,事务id是递增分配的。比方说现在有id为1,2,3这三个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。
**creator_trx_id:**表示生成该ReadView的事务的事务id。
如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
如果访问的版本的trx_id属性值在min_trx_id与max_trx_id之间(或者在m_ids中存在),并且creator_trx_id值不相同,那么说明该版本不可以被当前事务访问。
READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView。
trx 30: begin;
trx 20: begin;
trx 20: update person set age=22 where id=1;
trx 30: select * from person where id=1;
在执行SELECT语句时会先生成一个ReadView:
ReadView的m_ids列表的内容就是[20, 30],min_trx_id为20,max_trx_id为40,creator_trx_id为30。
从版本链中挑选可见的记录,此时最新版本的列age的内容是’22’,该版本的trx_id值为20,在m_ids列表内,所以不符合可见性要求(trx_id属性值在ReadView的min_trx_id和max_trx_id之间说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问),根据roll_pointer跳到下一个版本。
下一个版本的列age的内容是’20’,该版本的trx_id值为10,小于ReadView中的min_trx_id值,所以这个版本是符合要求的,最后返回给用户的版本就是这条列age为’20’的记录。
trx 20: update person set age=28 where id=1;
trx 20: commit;
trx 30: update person set name='王五' where id=1;
trx 30: select * from person where id=1;
在执行SELECT语句时会重新生成一个ReadView:
ReadView的m_ids列表的内容就是[30],min_trx_id为30,max_trx_id为40,creator_trx_id为30。
从版本链中挑选可见的记录,此时最新版本的列name的内容是’王五’,该版本的trx_id值为30,在m_ids列表内,所以不符合可见性要求(trx_id属性值在ReadView的min_trx_id和max_trx_id之间说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问),根据roll_pointer跳到下一个版本。
下一个版本的列age的内容是’28’,该版本的trx_id值为20,小于ReadView中的min_trx_id值,所以这个版本是符合要求的,最后返回给用户的版本就是这条列age为’28’的记录。
所以有了这种机制,就不会发生脏读问题!因为会去判断活跃版本,必须是不在活跃版本的才能用,不可能读到没有 commit的记录。
REPEATABLE READ解决不可重复读问题
对于使用REPEATABLE READ隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView,之后的查询就不会重复生成了。
trx 30: begin;
trx 20: begin;
trx 20: update person set age=22 where id=1;
trx 30: select * from person where id=1;
在执行SELECT语句时会生成一个ReadView:
ReadView的m_ids列表的内容就是[20, 30],min_trx_id为20,max_trx_id为40,creator_trx_id为30。
从版本链中挑选可见的记录,此时最新版本的列age的内容是’22’,该版本的trx_id值为20,在m_ids列表内,所以不符合可见性要求(trx_id属性值在ReadView的min_trx_id和max_trx_id之间说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问),根据roll_pointer跳到下一个版本。
下一个版本的列age的内容是’20’,该版本的trx_id值为10,小于ReadView中的min_trx_id值,所以这个版本是符合要求的,返回给用户的版本就是这条列age为’20’的记录。
trx 20: update person set age=28 where id=1;
trx 20: commit;
trx 30: update person set name='王五' where id=1;
trx 30: select * from person where id=1;
执行SELECT语句时不会重新生成一个ReadView,而是使用第一次生成的:
ReadView的m_ids列表的内容还是[30],min_trx_id为20,max_trx_id为40,creator_trx_id为30。
从版本链中挑选可见的记录,此时最新版本的列name的内容是’王五’,该版本的trx_id值为30,在m_ids列表内,所以不符合可见性要求(trx_id属性值在ReadView的min_trx_id和max_trx_id之间说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问),根据roll_pointer跳到下一个版本。
下一个版本的列age的内容是’28’,该版本的trx_id值为20,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
下一个版本的列age的内容是’22’,该版本的trx_id值也为20,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
下一个版本的列age的内容是’20’,该版本的trx_id值为10,小于ReadView中的min_trx_id值,所以这个版本是符合要求的,最后返回给用户的版本就是这条列age为’20’的记录。
根据前面的分析,返回的值还是age为’20’的这条记录。
也就是说两次SELECT查询得到的结果是重复的,记录的列age值都是’20’,这就是可重复读的含义。
幻读是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录,而这个记录来自另一个事务添加的新记录。
READ隔离级别下的事务T1先根据某个搜索条件读取到多条记录,然后事务T2插入一条符合相应搜索条件的记录并提交,然后事务T1再根据相同搜索条件执行查询。结果会是什么?按照ReadView中的比较规则(后两条):
3、如果被访问版本的trx_id属性值大于或等于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
4、如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间(min_trx_id < trx_id < max_trx_id),那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
不管事务T2比事务T1是否先开启,事务T1都是看不到T2的提交的。请自行按照上面介绍的版本链、ReadView以及判断可见性的规则来分析一下。
但是,在REPEATABLE READ隔离级别下InnoDB中的MVCC 可以很大程度地避免幻读现象,而不是完全禁止幻读
trx 30:begin;
trx 20: begin;
trx 30: select * from person where id=80;
trx 20: insert into person (id, name, age) values (80, '李四', '20');
trx 30: update person set age=28 where id=80;
trx 30: select * from person where id=80;
根据运行结果可以发现,在trx 30中,第二次读到了第一次没有读到的数据,产生了幻读。
X 不兼容X 不兼容S
S 不兼容X 兼容S
共享锁
事务中开启共享锁
SELECT * from test LOCK IN SHARE MODE;
排他锁(独占锁)
事务中开启独占锁
SELECT * from test FOR UPDATE;
insert
一般情况下,新插入一条记录的操作并不加锁,InnoDB通过一种称之为隐式锁来保护这条新插入的记录在本事务提交前不被别的事务访问。当然,在一些特殊情况下INSERT操作也是会获取锁的
delete
对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取一下这条记录的X锁,然后再执行delete mark操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁的锁定读。
update
在对一条记录做UPDATE操作时分为三种情况:
为了解决表锁与行锁的互斥,所以产生了意向锁。
如果要上行锁,首先要在表上,添加一个意向锁,来标识这行记录被上锁,这样当上表锁时,就不需要检查每一行的记录了。
意向共享锁 ,英文名:Intention Shared Lock,简称IS锁。当事务准备在某条记录上加S锁时,需要先在表级别加一个IS锁。
意向独占锁 ,英文名:Intention Exclusive Lock,简称IX锁。当事务准备在某条记录上加X锁时,需要先在表级别加一个IX锁。
表级别的各种锁的兼容性:
兼容性 | X | IX | S | IS |
---|---|---|---|---|
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
IX | 不兼容 | 不兼容 | ||
S | 不兼容 | 不兼容 | ||
IS | 不兼容 |
锁的组合性:(意向锁没有行锁)
组合性 | X | IX | S | IS |
---|---|---|---|---|
表锁 | 有 | 有 | 有 | 有 |
行锁 | 有 | 有 |
当我们在对使用InnoDB存储引擎的表的某些记录加S锁之前,那就需要先在表级别加一个IS锁,当我们在对使用InnoDB存储引擎的表的某些记录加X锁之前,那就需要先在表级别加一个IX锁。
IS锁和IX锁的使命只是为了后续在加表级别的S锁和X锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。我们并不能手动添加意向锁,只能由InnoDB存储引擎自行添加。
只有通过索引条件检索数据,InnoDB 才使用行级锁,否则,InnoDB 将使用表锁。
不论是使用主键索引、唯一索引或普通索引,InnoDB都会使用行锁来对数据加锁。
只有执行计划真正使用了索引,才能使用行锁
也叫记录锁,就是仅仅把一条记录锁上,官方的类型名称为:LOCK_REC_NOT_GAP。
记录锁是有S锁和X锁之分的,当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可以继续获取X型记录锁;当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁;
行锁的一种特殊情况:间隙锁:值在范围内,但却不存在
对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。
使用间隙锁,会对叶子节点的两端进行加锁
间隙锁的目的是为了防止幻读,以满足相关隔离级别的要求
以下sql会产生间隙锁:
-- 会对id大于10并且小于40之间进行加锁,加入这个时候,有id=20的想进行插入操作,那么会进行阻塞
update person set age =10 where id>10 and price<40;
-- 会对id在5跟7之间进行上锁
select * from person WHERE id BETWEEN 5 AND 7 FOR UPDATE;
-- 会对50这条记录页的上下进行上锁
insert into person (id, name, age) values (50, '张三', 20);
总结
有两种情况会产生间隙锁
产生情况:
两个事务互相抢占两个相同的资源,例如
事务A: select * from person where id = 1 for update;
事务B: select * from person where id = 2 for update;
事务A: select * from person where id = 2 for update;
事务B: select * from person where id = 1 for update;
mysql会自动处理死锁,这种情况,会将事务B进行回滚,并让事务A获取到id为2的资源。
优化:
被驱动表上的关联字段作为索引,就不需要进行全表扫描了,可以加快索引查询速度
如果没有索引,并且驱动表数据量过大,可以调大join buffer的大小,减少磁盘的IO次数。
Simple Nested-Loop Join(SNL,简单嵌套循环连接)
驱动表中每一行对应被驱动表的一次全表扫描
例如驱动表User,被驱动表UserInfo 的sql是 select * from User u left join User_info info on u.id = info.user_id,其实就是我们常用的for循环,伪代码的逻辑应该是
for(User u:Users){
for(UserInfo info:UserInfos){
if(u.id == info.userId){
//得到匹配数据
}
}
}
简单粗暴的算法,每次从User表中取出一条数据,然后扫描User_info中的所有记录匹配,最后合并数据返回。
假如驱动表User有10条数据,被驱动表UserInfo也有10条数据,那么实际上驱动表User会被扫描10次,而被驱动表会被扫描10*10=100次(每扫描一次驱动表,就会扫描全部的被驱动表),这种效率是很低的,对数据库的开销比较大,尤其是被驱动表。每一次扫描其实就是从硬盘中读取数据加载到内存中,也就是一次IO,目前IO是最大的瓶颈
Index Nested-Loop Join(INL,索引嵌套循环连接)
索引嵌套循环是使用索引减少扫描的次数来提高效率的,所以要求非驱动表上必须有索引才行。
在查询的时候,驱动表(User) 会根据关联字段的索引进行查询,当索引上找到符合的值,才会进行回表查询。如果非驱动表(User_info)的关联字段(user_id)是主键的话,查询效率会非常高(主键索引结构的叶子结点包含了完整的行数据(InnoDB)),如果不是主键,每次匹配到索引后都需要进行一次回表查询(根据二级索引(非主键索引)的主键ID进行回表查询),性能肯定弱于主键的查询。
上图中的索引查询之后不一定会回表,什么情况下会回表,这个要看索引查询到的字段能不能满足查询需要的字段
Block Nested-Loop Join(BNL,块嵌套循环连接)
如果存在索引,那么会使用index的方式进行join,如果join的列没有索引,被驱动表要扫描的次数太多了,每次访问被驱动表,其表中的记录都会被加载到内存中,然后再从驱动表中取一条与其匹配,匹配结束后清除内存,然后再从驱动表中加载一条记录 然后把被驱动表的记录在加载到内存匹配,这样周而复始,大大增加了IO的次数。为了减少被驱动表的IO次数,就出现了Block Nested-Loop Join的方式。
不再是逐条获取驱动表的数据,而是一块一块的获取,引入了join buffer缓冲区,将驱动表join相关的部分数据列(大小是join buffer的限制)缓存到join buffer中,然后全表扫描被驱动表,被驱动表的每一条记录一次性和join buffer中的所有驱动表记录进行匹配(内存中操作),将简单嵌套循环中的多次比较合并成一次,降低了被驱动表的访问频率。
驱动表能不能一次加载完,要看join buffer能不能存储所有的数据,默认情况下join_buffer_size=256k,查询的时候Join Buffer 会缓存所有参与查询的列而不是只有join关联的列,在一个有N个join关联的sql中会分配N-1个join buffer。所以查询的时候尽量减少不必要的字段,可以让join buffer中可以存放更多的列。
可以调整join_buffer_size的缓存大小show variables like '%join_buffer%'这个值可以根据实际情况更改。
线上环境,一般需要开启慢日志查询,以及bin_log日志。
mysql日志常见的有哪些
错误日志
慢查询日志
bin_log日志:记录全量的ddl语句与dml语句。
redo日志:确保事务的持久性(确保commit)
undo日志:保证事务的原子性(确保rollback)
redo log节省随机写磁盘的IO消耗
change buffer 节省随机读磁盘的IO操作(如果该页在内存中,可以直接操作内存,后续执行merge操作即可,如果写请求后,接着来了一个读请求,也会触发merge操作)
redo log称为重做日志,每当有操作时,在数据变更之前将操作写入redo log,这样当发生掉电之类的情况时系统可以在重启后继续操作。
redo log 是配合buffer pool来使用的
redo log是InnoDB独有的
redo log是实现了ACID中的D(持久性)
redo log是物理日志,所以恢复速度很快。redo log是mysql自己使用的,用于保证数据库崩溃时的事务持久性。
binlog 是逻辑日志,记录全量的ddl语句与dml语句,用来人工恢复数据使用
binlog 不能记录数据是否记录到磁盘中,但是redo log在执行完成以后,就会进行抹除。
binlog会记录所有存储引擎的日志,但是redo log只会记录InnoDB的日志。
redo日志本质上只是记录了一下事务对数据库做了哪些修改。 InnoDB们针对事务对数据库的不同修改场景定义了多种类型的redo日志,但是绝大部分类型的redo日志都有下边这种通用的结构:
type:该条redo日志的类型,redo日志设计大约有53种不同的类型日志。
space ID:表空间ID。
page number:页号。
data:该条redo日志的具体内容。
undo log称为撤销日志,当一些变更执行到一半无法完成时,可以根据撤销日志恢复到变更之间的状态。
undo log是记录的数据修改之前的值
如果表有索引
增删改变慢,因为要维护B+树
查询速度:
执行成本=I/O成本+CPU成本
对驱动表进行查询后得到的记录条数称之为驱动表的 扇出 (英文名:fanout)。
很显然驱动表的扇出值越小,对被驱动表的查询次数也就越少,连接查询的总成本也就越低。
连接查询总成本 = 单次访问驱动表的成本 + 驱动表扇出数 x 单次访问被驱动表的成本
移除无用的括号
有时候表达式里有许多无用的括号,比如这样:
((a = 5 AND b =c) OR ((a > c) AND (c < 5)))
看着就很烦,优化器会把那些用不到的括号给干掉,就是这样:
(a = 5 and b =c) OR (a > c AND c < 5)
常量传递
有时候某个表达式是某个列和某个常量做等值匹配,比如这样:
a = 5
当这个表达式和其他涉及列a的表达式使用AND连接起来时,可以将其他表达式中的a的值替换为5,比如这样:
a = 5 AND b >a
就可以被转换为:
a = 5 AND b >5
等值传递(equality_propagation)
有时候多个列之间存在等值匹配的关系,比如这样:
a = b and b = c and c = 5
这个表达式可以被简化为:
a = 5 and b = 5 and c = 5
移除没用的条件
对于一些明显永远为TRUE或者FALSE的表达式,优化器会移除掉它们,比如这个表达式:
(a < 1 and b= b) OR (a = 6 OR 5 != 5)
很明显,b = b这个表达式永远为TRUE,5 != 5这个表达式永远为FALSE,所以简化后的表达式就是这样的:
(a < 1 and TRUE) OR (a = 6 OR FALSE)
可以继续被简化为
a < 1 OR a =6
表达式计算
在查询开始执行之前,如果表达式中只包含常量的话,它的值会被先计算出来,比如这个:
a = 5 + 1
因为5 + 1这个表达式只包含常量,所以就会被化简成:
a = 6
但是这里需要注意的是,如果某个列并不是以单独的形式作为表达式的操作数时,比如出现在函数中,出现在某个更复杂表达式中,就像这样:
ABS(a) > 5
或者:
-a < -8
优化器是不会尝试对这些表达式进行化简的。我们前边说过只有搜索条件中索引列和常数使用某些运算符连接起来才可能使用到索引,所以如果可以的话,最好让索引列以单独的形式出现在表达式中。
常量表检测
MySQL觉得下边这种查询运行的特别快:
使用主键等值匹配或者唯一二级索引列等值匹配作为搜索条件来查询某个表。
MySQL觉得这两种查询花费的时间特别少,少到可以忽略,所以也把通过这两种方式查询的表称之为常量表(英文名:constant tables)。优化器在分析一个查询语句时,先首先执行常量表查询,然后把查询中涉及到该表的条件全部替换成常数,最后再分析其余表的查询成本,比方说这个查询语句:
SELECT
*
FROM
table1
INNER JOIN table2 ON table1.column1 = table2.column2
WHERE
table1.primary_key = 1;
很明显,这个查询可以使用主键和常量值的等值匹配来查询table1表,也就是在这个查询中table1表相当于常量表,在分析对table2表的查询成本之前,就会执行对table1表的查询,并把查询中涉及table1表的条件都替换掉,也就是上边的语句会被转换成这样:
SELECT
table1表记录的各个字段的常量值,
table2.*
FROM
table1
INNER JOIN table2 ON table1表column1列的常量值 = table2.column2;
我们前边说过,内连接的驱动表和被驱动表的位置可以相互转换,而左(外)连接和右(外)连接的驱动表和被驱动表是固定的。这就导致内连接可能通过优化表的连接顺序来降低整体的查询成本,而外连接却无法优化表的连接顺序。
我们之前说过,外连接和内连接的本质区别就是:对于外连接的驱动表的记录来说,如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录仍然会被加入到结果集中,对应的被驱动表记录的各个字段使用NULL值填充;而内连接的驱动表的记录如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录会被舍弃。查询效果就是这样:
SELECT * FROM e1 INNER JOIN e2 ON e1.m1 = e2.m2;
SELECT * FROM e1 LEFT JOIN e2 ON e1.m1 = e2.m2;
对于上边例子中的(左)外连接来说,由于驱动表e1中m1=1, n1='a’的记录无法在被驱动表e2中找到符合ON子句条件e1.m1 = e2.m2的记录,所以就直接把这条记录加入到结果集,对应的e2表的m2和n2列的值都设置为NULL。
因为凡是不符合WHERE子句中条件的记录都不会参与连接。只要我们在搜索条件中指定关于被驱动表相关列的值不为NULL,那么外连接中在被驱动表中找不到符合ON子句条件的驱动表记录也就被排除出最后的结果集了,也就是说:在这种情况下:外连接和内连接也就没有什么区别了!
另外再说下这个查询:
SELECT* FROM e1 LEFT JOIN e2 ON e1.m1 = e2.m2 WHERE e2.n2 IS NOT NULL
由于指定了被驱动表e2的n2列不允许为NULL,所以上边的e1和e2表的左(外)连接查询和内连接查询是一样的。当然,我们也可以不用显式的指定被驱动表的某个列IS NOT NULL,只要隐含的有这个意思就行了,比方说这样:
SELECT * FROM e1 LEFT JOIN e2 ON e1.m1 = e2.m2 WHERE e2.m2 = 2
在这个例子中,我们在WHERE子句中指定了被驱动表e2的m2列等于2,也就相当于间接的指定了m2列不为NULL值,所以上边的这个左(外)连接查询其实和下边这个内连接查询是等价的:
SELECT* FROM e1 INNER JOIN e2 ON e1.m1 = e2.m2 WHERE e2.m2 = 2
我们把这种在外连接查询中,指定的WHERE子句中包含被驱动表中的列不为NULL值的条件称之为空值拒绝(英文名:reject-NULL)。在被驱动表的WHERE子句符合空值拒绝的条件后,外连接和内连接可以相互转换。这种转换带来的好处就是查询优化器可以通过评估表的不同连接顺序的成本,选出成本最低的那种连接顺序来执行查询。
将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。
二进制位数 | 解释 | |
---|---|---|
预留位1 | 1 | 没有使用 |
预留位2 | 1 | 没有使用 |
delete_mask | 1 | 标记该记录是否被删除 |
min_rec_mask | 1 | B+树的每层非叶子节点中的最小记录都会添加该标记 |
n_owned | 4 | 表示当前记录拥有的记录数 |
heap_no | 13 | 表示当前记录在页的位置信息 |
record_type | 3 | 表示当前记录的类型, 0表示普通记录, 1表示B+树非叶子节点记录, 2表示最小记录, 3表示最大记录 |
next_record | 16 | 表示下一条记录的相对位置 |
MySQL会为每个记录默认的添加一些列(也称为隐藏列),包括:
DB_ROW_ID(row_id):非必须,6字节,表示行ID,唯一标识一条记录
InnoDB表对主键的生成策略是:优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个Unique键作为主键,如果表中连Unique键都没有定义的话,则InnoDB会为表默认添加一个名为row_id的隐藏列作为主键。
DB_TRX_ID:必须,6字节,表示事务ID
DB_ROLL_PTR:必须,7字节,表示回滚指
它是InnoDB管理存储空间的基本单位,一个页的大小一般是16KB。
表空间是一个抽象的概念,对于系统表空间来说,对应着文件系统中一个或多个实际文件;对于每个独立表空间来说,对应着文件系统中一个名为表名.ibd的实际文件。
区(extent)
表空间中的页可以达到2³²个页,实在是太多了,为了更好的管理这些页面,InnoDB中还有一个区(英文名:extent)的概念。对于16KB的页来说,连续的64个页就是一个区,也就是说一个区默认占用1MB空间大小。
不论是系统表空间还是独立表空间,都可以看成是由若干个区组成的,每256个区又被划分成一个组。
我们每向表中插入一条记录,本质上就是向该表的聚簇索引以及所有二级索引代表的B+树的节点中插入数据。而B+树的每一层中的页都会形成一个双向链表,如果是以页为单位来分配存储空间的话,双向链表相邻的两个页之间的物理位置可能离得非常远。
我们介绍B+树索引的适用场景的时候特别提到范围查询只需要定位到最左边的记录和最右边的记录,然后沿着双向链表一直扫描就可以了,而如果链表中相邻的两个页物理位置离得非常远,就是所谓的随机I/O。磁盘的速度和内存的速度差了好几个数量级,随机I/O是非常慢的,所以我们应该尽量让链表中相邻的页的物理位置也相邻,这样进行范围查询的时候才可以使用所谓的顺序I/O。
一个区就是在物理位置上连续的64个页。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区为单位分配,甚至在表中的数据十分非常特别多的时候,可以一次性分配多个连续的区,从性能角度看,可以消除很多的随机I/O。
我们提到的范围查询,其实是对B+树叶子节点中的记录进行顺序扫描,而如果不区分叶子节点和非叶子节点,统统把节点代表的页面放到申请到的区中的话,进行范围扫描的效果就大打折扣了。所以InnoDB对B+树的叶子节点和非叶子节点进行了区别对待,也就是说叶子节点有自己独有的区,非叶子节点也有自己独有的区。
存放叶子节点的区的集合就算是一个段(segment),存放非叶子节点的区的集合也算是一个段。也就是说一个索引会生成2个段,一个叶子节点段,一个非叶子节点段。
段其实不对应表空间中某一个连续的物理区域,而是一个逻辑上的概念。
它是一种特殊文件flush技术,带给InnoDB存储引擎的是数据页的可靠性。它的作用是,在把页写到数据文件之前,InnoDB先把它们写到一个叫doublewrite buffer(双写缓冲区)的连续区域内,在写doublewrite buffer完成后,InnoDB才会把页写到数据文件的适当的位置。如果在写页的过程中发生意外崩溃,InnoDB在稍后的恢复过程中在doublewrite buffer中找到完好的page副本用于恢复。
doublewrite buffer是InnoDB在系统表空间上的128个页(2个区,extend1和extend2),大小是2MB
所以在正常的情况下, MySQL写数据页时,会写两遍到磁盘上,第一遍是写到doublewrite buffer,第二遍是写到真正的数据文件中。如果发生了极端情况(断电),InnoDB再次启动后,发现了一个页数据已经损坏,那么此时就可以从doublewrite buffer中进行数据恢复了。
因为mysql是以页进行单位交互的,也就是16KB,但是实际操作系统是按照4k来进行内存与磁盘交互的,所以,引入了双写缓存区的概念。
另外,因为这个区的存储是连续的,所以效率要高于真实插入时的随机io。
因为redo log默认认为所有页是完整的,而不会校验页的完整性,但是双写缓冲区其实是为了保证页的完整性。
我们知道,对于使用InnoDB作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以页的形式存放在表空间中的,而所谓的表空间只不过是InnoDB对文件系统上一个或几个实际文件的抽象,也就是说我们的数据说到底还是存储在磁盘上的。
但是磁盘的速度慢,所以InnoDB存储引擎在处理客户端的请求时,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省去磁盘IO的开销了。
InnoDB为了缓存磁盘中的页,在MySQL服务器启动的时候就向操作系统申请了一片连续的内存,他们给这片内存起了个名,叫做Buffer Pool(中文名是缓冲池)
Buffer Pool中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是16KB。为了更好的管理这些在Buffer Pool中的缓存页,InnoDB为每一个缓存页都创建了一些所谓的控制信息,这些控制信息包括该页所属的表空间编号、页号、缓存页在Buffer Pool中的地址、链表节点信息、一些锁信息以及LSN信息,当然还有一些别的控制信息。
每个缓存页对应的控制信息占用的内存大小是相同的,我们称为控制块。控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边,所以整个Buffer Pool对应的内存空间看起来就是这样的:
每个控制块大约占用缓存页大小的5%,而我们设置的innodb_buffer_pool_size并不包含这部分控制块占用的内存空间大小,也就是说InnoDB在为Buffer Pool向操作系统申请连续的内存空间时,这片连续的内存空间一般会比innodb_buffer_pool_size的值大5%左右。
最初启动MySQL服务器的时候,需要完成对Buffer Pool的初始化过程,就是先向操作系统申请Buffer Pool的内存空间,然后把它划分成若干对控制块和缓存页。但是此时并没有真实的磁盘页被缓存到Buffer Pool中(因为还没有用到),之后随着程序的运行,会不断的有磁盘上的页被缓存到Buffer Pool中。
那么问题来了,从磁盘上读取一个页到Buffer Pool中的时候该放到哪个缓存页的位置呢?或者说怎么区分Buffer Pool中哪些缓存页是空闲的,哪些已经被使用了呢?最好在某个地方记录一下Buffer Pool中哪些缓存页是可用的,这个时候缓存页对应的控制块就派上大用场了,我们可以把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作free链表(或者说空闲链表)。刚刚完成初始化的Buffer Pool中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到free链表中,假设该Buffer Pool中可容纳的缓存页数量为n,那增加了free链表的效果图就是这样的:
有了这个free链表之后,每当需要从磁盘中加载一个页到Buffer Pool中时,就从free链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的free链表节点从链表中移除,表示该缓存页已经被使用了。
我们其实是根据表空间号 + 页号来定位一个页的,也就相当于表空间号 + 页号是一个key,缓存页就是对应的value,怎么通过一个key来快速找着一个value呢?
所以我们可以用表空间号 + 页号作为key,缓存页作为value创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从free链表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。
如果我们修改了Buffer Pool中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页(英文名:dirty page)。当然,最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能。所以每次修改缓存页后,我们并不着急立即把修改同步到磁盘上,而是在未来的某个时间点进行同步。
但是如果不立即同步到磁盘的话,那之后再同步的时候我们怎么知道Buffer Pool中哪些页是脏页,哪些页从来没被修改过呢?总不能把所有的缓存页都同步到磁盘上吧,假如Buffer Pool被设置的很大,比方说300G,那一次性同步会非常慢。
所以,需要再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫flush链表。链表的构造和free链表差不多。
Buffer Pool对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了Buffer Pool大小,那么就需要把某些旧的缓存页从Buffer Pool中移除,然后再把新的页放进来,那么这个淘汰机制就试采用的lru算法,这个链表与之前的free与flush链表不是同一个链表,而是单独一个链表,用来做淘汰使用
如果非常多的使用频率偏低的页被同时加载到Buffer Pool时,可能会把那些使用频率非常高的页从Buffer Pool中淘汰掉。
因为有这两种情况的存在,所以InnoDB把这个LRU链表按照一定比例分成两截,分别是:
一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做热数据,或者称young区域。
另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做冷数据,或者称old区域。
默认情况下,old区域在LRU链表中所占的比例是37%,也就是说old区域大约占LRU链表的3/8。
使用redo log,将已提交的数据,存储到redo log以物理结构的形式存储在redo log中
进程:资源分配的基本单位
线程:程序调度执行的最小单位
一个线程中可以有多个进程,多个线程共享进程中的资源。
(一个程序里不同的执行路劲就叫线程)
继承Thread类
实现Runnable接口
通过Callable和Future创建线程
当前线程进入阻塞状态
让出当前线程资源,重新回到等待队列中去
将其他线程加入到当前线程,使得其他线程运行结束后,才能继续执行当前线程
new: 初始状态
runnable 运行状态
ready: 就绪状态,等待被cpu调度
running状态,运行状态
blocked状态,阻塞状态 等待获取sychronized锁
waiting状态,等待状态 调用了sleep()/join()/park()方法,没有固定时间的等待
time_waiting 给定时间的等待状态,例如调用了sleep()方法
terminated状态,死亡状态
在对象的markword中最后两位记录了锁状态,两位的不同组合来表示不同的锁的状态。
Synchronized进过编译,会在同步块的前后分别形成monitorenter和monitorexit这个两个字节码指令
synchronized this与synchronized方法这两是一样的,都是使用当前对象作为锁
静态的synchronized方法,使用的是当前的class类作为锁对象。
一个synchronized方法调用另外一个synchronized方法
synchronized方法快程序报错,会使得锁释放,这时候会造成程序的乱入,会使得数据出现问题。所以,需要处理好synchronized中的异常处理。
在JDK1.6之前,主要使用synchronized,那么jvm会直接向操作系统申请锁。由于申请系统锁需要用户态切换到内核态,而大部分情况下,是不需要直接切换到内核态的,所以在JDK1.6以后,为了提高效率,提出了锁升级的概念。
标志位 | 状态 |
---|---|
01 | 未锁定/偏向锁 |
00 | 轻量级锁 |
10 | 重量级锁 |
如果是执行时间长,或者等待线程比较多,那么适合使用重量级锁
否则,适合使用自旋锁。
包装类型内部使用了享元模式,所以如果用包装类型,会出问题
String不能使用的原因是使用String会使得字符串常量池有很多对象,极端情况下会发生oom
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* 读写锁
* 排他锁/共享锁
*/
public class TestReadWriteLock {
private static Lock lock = new ReentrantLock();
private static ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private static Lock readLock = readWriteLock.readLock();
private static Lock writeLock = readWriteLock.writeLock();
private static void read(Lock lock) {
lock.lock();
sleep(1000);
System.out.println("read over");
lock.unlock();
}
private static void write(Lock lock) {
lock.lock();
sleep(1000);
System.out.println("write over");
lock.unlock();
}
public static void main(String[] args) {
// Runnable read = () -> read(lock);
// Runnable write = () -> write(lock);
Runnable read = () -> read(readLock);
Runnable write = () -> write(writeLock);
for (int i=0; i<18; i++) {
new Thread(read).start();
}
for (int i=0; i<2; i++) {
new Thread(write).start();
}
}
private static void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2.3 LockSupport: 锁支持,可以用来让一个线程在任意位置进入阻塞状态
底层是调用了Unsafe的park()与unpark()方法实现的。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;
/**
* 锁支持
* 用于让一个线程在任意位置进入阻塞状态
*/
public class TestLockSupport {
public static void main(String[] args) {
Thread t = new Thread(() -> {
for (int i=0; i<10; i++) {
System.out.println(i);
if (i == 5) {
LockSupport.park();
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
// 如果先unpark,然后才上锁,那么这把锁还是解锁状态
// unpark可以先与park调用。
// LockSupport.unpark(t);
try {
TimeUnit.SECONDS.sleep(8);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("after 8 seconds!");
LockSupport.unpark(t);
}
}
import java.util.concurrent.Phaser;
/**
* 阶段
*/
public class TestPhase {
private static MarriagePhase phase = new MarriagePhase();
public static void main(String[] args) {
// 指定初始人数,也可以在new时指定
phase.bulkRegister(7);
for (int i=0; i<5; i++) {
new Person("p"+i).start();
}
new Person("新郎").start();
new Person("新娘").start();
}
static class Person extends Thread {
private String name;
public Person(String name) {
this.name = name;
}
private void arrive() {
System.out.println(name+"到达");
phase.arriveAndAwaitAdvance();
}
private void eat() {
System.out.println(name+"吃饭");
phase.arriveAndAwaitAdvance();
}
private void leave() {
System.out.println(name+"离开");
phase.arriveAndAwaitAdvance();
}
private void hug() {
if (name.equals("新郎") || name.equals("新娘")) {
System.out.println(name+"拥抱");
phase.arriveAndAwaitAdvance();
} else {
phase.arriveAndDeregister();
}
}
@Override
public void run() {
arrive();
eat();
leave();
hug();
}
}
static class MarriagePhase extends Phaser {
// 这里的phase代表的是步骤,固定从0开始
// registeredParties目前的人数
@Override
protected boolean onAdvance(int phase, int registeredParties) {
switch (phase) {
case 0:
System.out.println("所有人到期,婚礼开始 人数"+registeredParties);
return false;
case 1:
System.out.println("所有人到期,吃饭~ 人数"+registeredParties);
return false;
case 2:
System.out.println("所有人离开! 人数"+registeredParties);
return false;
case 3:
System.out.println("新郎新娘洞房! 人数"+registeredParties);
return true;
}
return true;
}
}
}
3.4 Seamphore 信号量:可以指定最多有几个线程可以拿到这把锁,semaphore.acquire()获取锁,semaphore.release()释放锁。可以指定公平/非公平。
import java.util.concurrent.Semaphore;
/**
* 信号量
* 用作限流使用
*/
public class TestSemaphore {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(1);
new Thread(() -> {
try {
semaphore.acquire();
System.out.println("t1 Running");
Thread.sleep(1000);
System.out.println("t1 end");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
}
}).start();
new Thread(() -> {
try {
semaphore.acquire();
System.out.println("t2 Running");
Thread.sleep(1000);
System.out.println("t2 end");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
}
}).start();
}
}
3.5 Exchanger 交换器:用来两个线程之间交换数据。
import java.util.concurrent.Exchanger;
/**
* 交换器
* 用来线程之间交换两个数据
*/
public class TestExchanger {
public static void main(String[] args) {
Exchanger<String> exchanger = new Exchanger<>();
new Thread(() -> {
try {
String s = exchanger.exchange("t1");
System.out.println("t1 线程获取到值"+s);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
try {
String s = exchanger.exchange("t2");
System.out.println("t2 线程获取到值"+s);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
CAS(compare and swap): 包含内存地址V,旧的值A,新的值B
如果内存地址V的值是等于旧值A,那么就将V更新成B,否则重新读取
因为CAS操作是属于CPU的原语操作,所以比较与交换的过程是原子操作。
假如内存地址V,旧的值A
在比较V跟A是否相等之前,V的值先由A变成了B,再由B变成了A,虽然看起来V的值还是A,与期望值相同,但是这个其实是已经发生改变后的值了。
那么这种情况如何避免呢?
加版本号,只要值发生改变,就累加版本号,比较时,不仅仅只比较值,也比较版本号。
AQS(AbstractQueuedSynchronizer),主要由volatile修饰的state变量与一个双向链表组成的等待队列。
state的值是通过CAS的方式来完成的,其具体含义由子类自己来定义实现。
例如:
ReentrantLock: state 的值表示是否有锁以及重入次数,state=0代表无锁,state>0表示有锁并且当前重入次数。
CountDownLatch:state的值代表还需要countDown几次进行就可以进行解锁了。
双向链表的node节点记录了当前的线程以及等待状态,本质上,是等待队列去争抢修改state的值。
对于非公平锁来说,来了一个线程,直接去尝试获得这把锁,如果这把锁获取不成功,那么就用cas的方式将自己插入到队列的尾部。
插入完成以后,循环去判断前一个节点是否是头结点,如果是,那么尝试获取锁,如果获取成功了,就将自己设置为头结点。
这样做的好处是不需要对整个链表进行加锁,效率高。
而对于公平锁,来了一个线程,是直接排队到tail后面。
ThreadLocal是将变量设置到当前线程的threadLocals中的,这是一个ThreadLocalMap对象,key存放的是当前ThreadLocal自身,value存放的是设置的值。
为了节省系统资源,所以会将thread放入线程池当中,当有任务时,从线程池中拿到这个线程并运行,所以,有可能这个线程是一直使用的。而当运行当前任务时,在线程中设置了变量,如果当前任务运行完毕以后,由于这个线程没有被销毁,所以,这个值还一直存在,那么就会造成内存泄露。
在ThreadLocal中的Entry对象,继承了WeakReference,并且在设置值时,将当前的key(ThreadLocal对象)设置为弱引用,所以当线程运行完以后,当前对象自动被回收。但是value并不是弱引用,也就意味着,内存泄露依旧存在,所以,在使用完ThreadLocal以后,要将当前设置的值remove掉。
为什么放入ThreadLocalMap的弱引用不会被回收掉?
因为有个弱引用的对象是ThreadLocal对象,除了被ThreadLocalMap弱引用外,还有强引用指向这个对象,所以不会被gc回收,如果等到这个任务运行完毕,资源被回收,那么这个对象只有弱引用所指向了,所以可以被回收掉。
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
与Runnable类似,并且可以有返回值
用于存储任务运行的结果,get()方法阻塞获取结果。
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<String> c = new Callable() {
@Override
public String call() throws Exception {
return "Hello Callable";
}
};
ExecutorService service = Executors.newCachedThreadPool();
Future<String> future = service.submit(c); //异步
System.out.println(future.get());//阻塞
service.shutdown();
}
相当于是Callable+Future,并且实现了Runnable接口可以放到Thread中执行。
public static void main(String[] args) throws InterruptedException, ExecutionException {
FutureTask<Integer> task = new FutureTask<>(()->{
TimeUnit.MILLISECONDS.sleep(500);
return 1000;
}); //new Callable () { Integer call();}
new Thread(task).start();
System.out.println(task.get()); //阻塞
}
可以对多个Future进行管理,并且可以对返回结果进行统一处理。
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
public class Test_CompletableFuture {
public static void main(String[] args) throws ExecutionException, InterruptedException {
CompletableFuture<Double> futureTM = CompletableFuture.supplyAsync(()->priceOfTM());
CompletableFuture<Double> futureTB = CompletableFuture.supplyAsync(()->priceOfTB());
CompletableFuture<Double> futureJD = CompletableFuture.supplyAsync(()->priceOfJD());
CompletableFuture.allOf(futureTM, futureTB, futureJD)
.thenApply(String::valueOf)
.thenApply(str-> "price " + str)
.join();
}
private static double priceOfTM() {
delay();
return 1.00;
}
private static double priceOfTB() {
delay();
return 2.00;
}
private static double priceOfJD() {
delay();
return 3.00;
}
private static void delay() {
int time = new Random().nextInt(500);
try {
TimeUnit.MILLISECONDS.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.printf("After %s sleep!\n", time);
}
}
输出:
After 81 sleep!
After 261 sleep!
After 285 sleep!
如果没有达到核心线程数,那么就去创建一个核心线程,这里不管核心线程有没有空闲。
如果核心线程数满了,那么加入任务队列。
如果任务队列满了,那么就创建临时线程。
临时线程满了,那么执行拒绝策略。
核心线程不会被回收,非核心线程会被回收。
public void execute(Runnable command) {
// 非空!!
if (command == null)
throw new NullPointerException();
// 拿到ctl
int c = ctl.get();
// 通过ctl获取当前工作线程个数
if (workerCountOf(c) < corePoolSize) {
// true:代表是核心线程,false:代表是非核心线程
if (addWorker(command, true))
// 如果添加核心线程成功,return结束掉
return;
// 如果添加失败,重新获取ctl
c = ctl.get();
}
// 核心线程数已经到了最大值、添加时,线程池状态变为SHUTDOWN/STOP
// 判断线程池是否是运行状态 && 添加任务到工作队列
if (isRunning(c) && workQueue.offer(command)) {
// 再次获取ctl的值
int recheck = ctl.get();
// 再次判断线程池状态。 DCL
// 如果状态不是RUNNING,把任务从工作队列移除。
if (! isRunning(recheck) && remove(command))
// 走一波拒绝策略。
reject(command);
// 线程池状态是RUNNING。
// 判断工作线程数是否是0个,防止放入队列后,没有线程去处理任务。
// 可以将核心线程设置为0,所有工作线程都是非核心线程。
// 核心线程也可以通过keepAlived超时被销毁,所以如果恰巧核心线程被销毁,也会出现当前效果
else if (workerCountOf(recheck) == 0)
// 添加空任务的非核心线程去处理工作队列中的任务
addWorker(null, false);
}
// 可能工作队列中的任务存满了,没添加进去,到这就要添加非核心线程去处理任务
else if (!addWorker(command, false))
// 执行拒绝策略!
reject(command);
}
因为Executors提供了很多默认的创建线程池的方式,而作为开发人员来说,最好了解线程池的原理以及对每个配置
jstack
公式1:cpu核心数 * cpu利用率 * (1+w/c)《java并发编程实践》
cpu利用率:一般按照100%算即可
w:等待时间(IO的时间或者程序阻塞的时间)
c:计算时间(CPU参与计算的时间)
等待越久,线程数越多。
公式2:cpu核心数 * (1-阻塞系数)《java虚拟机并发编程》
统一公式:cpu核心数 * cpu利用率 * (1+w/c)=cpu核心数 * (1-阻塞系数)
阻塞系数=w/(w+c)
实际以压测为准(线程数、qps、机器配置 以压测为准)。
IO密集型w/c预估为1,计算密集型的w/c预估为0
每个线程维护一个队列,当前线程来消费队列中的任务。
当自己队列中的任务都执行完成以后,会去别的队列获取任务进行执行。
另外还有一个功能是,将一个任务拆分成多个子任务,然后对任务的结果进行汇总。
定义任务:
ForkJoinTask< T >
常用的有两个实现
RecursiveAction 无返回值的任务
RecursiveTask< T > 带返回值的任务
Executors.newWorkStealingPool():任务窃取的线程池
1、计数器:
在单位时间内,有且仅有N数量的请求能够访问我的接口。
比如我们需要在10秒内限定20个请求,那么我们在setnx的时候可以设置过期时间10,当请求的setnx数量达到20时候即达到了限流效果。
缺点:限流不均匀,粒度较粗,比如当统计1-10秒的时候,无法统计2-11秒之内,再有就是Redis中需要保持N个key等问题。
2、滑动窗口:
将请求包装成一个zset数组,当请求进来时,value中保存唯一标识,score中保存时间戳,用来计算当前时间戳内有多少个请求的数量。
zset的range方法可以轻松获取到两个时间戳之间有多少个请求,即每m秒内有n个请求。
缺点:zset的结构会越来越大。
解决方案:定时任务或mq消息异步删除时间戳较早的value数据。
3、令牌桶:
请求到来时先从redis中获取一个令牌,如果能拿到就放行,否则拦截返回。
可以使用list的结构,在Java代码中采用定时任务定时地往list中rightPush令牌,请求通过leftPot来获取令牌。
令牌需要保证唯一性,例如使用uuid生成。
Google开源项目Guava中的RateLimiter使用的就是令牌桶控制算法。
4、漏桶算法:
可以看作是令牌桶算法的一种特殊情况,可以使用list来实现,定时往list中rightPush前检查一下list中元素的数量,如果有一个就不执行rightPush操作了,没有再执行。
5、redis-cell:
是Redis4.0提供的一个限流Redis模块。该模块使用了漏桶算法,并提供了原子的限流指令。
1、6.0版本之前Redis是单线程的,这指的是事件处理器在处理任务的时候是单线程的。
2、但在6.0开始,事件处理器变成了多线程模式,它是将work线程和io线程做了区分,work线程专注于计算工作,io线程则专注于io的读写(work线程可以有多个)。
memcache只有key-value一种数据结构,
redis支持5种数据类型,对每一种类型,都提供了对应的api,可以支持不同的应用场景。
一方面,使用redis可以减少对json的解析操作
另一方面,也可以减少网络的IO,不需要每次都拿全量的数据进行计算。
计算向数据移动
一个连接中的多次请求,可以保证顺序处理,但是如果是多个连接,那么就不保证顺序了。
Redis默认分为16个库,0-15,每个库之间,数据相互隔离。
默认进入后,是在0号库。
命令 | 备注 |
---|---|
keys pattern | 以正则表达式查询key,常用的如key *查询全部 |
del key | 删除指定key |
exists key | 判定key是否存在 |
type key | 获取 key 的类型 |
expire key seconds | 设置key有效期为seconds秒 |
pexpire key milliseconds | 设置key有效期为milliseconds毫秒 |
expireat key timestamp | 设置key失效 的 秒级时间戳 |
pexpireat key milliseconds-timestamp | 设置key失效的 毫秒级时间戳 |
ttl key | 获取key的秒级有效时间 |
pttl key | 获取key的毫秒级有效时间 |
object encoding key | 返回具体的类型,例如string区分字符串与数字类型 |
命令 | 备注 |
---|---|
set key value | 设置值 |
get key | 获取值 |
set key value nx | 只有不存在,才设置(只能新建) |
set key value xx | 只有存在,才设置(只能更新) |
mset k1 v1 k2 v2 | 设置多个key的值 |
mget k1 k2 | 获取多个key的值 |
append k1 v | 追加值 |
getrange key start end | 截取字符串 |
getset | 取旧值,赋新值 |
setrange key offset value | 按照偏移量覆盖值 |
strlen key | 获取字符串长度 |
–num– | – |
incr key | value自增 |
incrby key num | value+num |
decr key | value自减 |
decrby key num | value-num |
incrbyfloat key float | 加一个小数 |
–bitmap– | – |
setbit key offset value | 设置二进制位的偏移量 |
bitpos key bit [start] [end] | 查找bit第一次出现的位置(位图中的位置) start、end是字节的位置 |
bitcount key [start] [end] | 查询二进制位1出现的次数 start、end是字节的位置 |
bitop op distkey k1 k2… | k1 k2… 进行op操作,并把结果赋值到distkey中 op: and or |
抢购、秒杀访问人数、点赞、评论数、好友数
规避高并发下,对数据库事务操作,完全由redis内存操作代替。
例如表示最后一个字母的偏移量,可以是正着数从0开始,也可以用-1来表示。
对于redis来说,是按照字节流来存储的,而不是按照字符
所以,像9999,实际上存储的是四个9,长度为4
另外,如果像使用了utf-8编码来存储了一个中文,那么长度就是3
这样做的好处是,对于多个平台数据交互,只要编码一致就可以正常读取。
而正因为有object encoding key 可以知道上一次key的类型,那么如果做计算操作,就不需要做类型判断了。
redis可以达到秒级十万的存储速度
而mysql秒级大概在几千。
1-5问题,可以使用redison来实现。
1-4 redis 抢到锁自身出现的问题
5 redis 没抢到锁出现的问题
一般情况下,要么采用单节点redis的情况,忽略可靠性(性能更好)。
要么采用zookeeper情况,可靠性更好一些(更可靠)。
在spring中使用注解@Transitional可以添加事物管理,但是很多时候,似乎注解失效即发生了异常,却没有回滚了。这里列举一下失效的几种情况
首先@EnableEurekaService会去创建一个Marker的标识类
spring.factories中指定的类中,有一个@ConditionalOnBean({Marker.class})的注解,会去检查这个类是否被创建,如果被创建,那么就执行eureka-service的加载逻辑。
server:
# 自我保护,看服务多少。
enable-self-preservation: false
# 自我保护阈值
renewal-percent-threshold: 0.85
# 剔除服务时间间隔
eviction-interval-timer-in-ms: 1000
# 关闭从readOnly读注册表
use-read-only-response-cache: false
# readWrite 和 readOnly 同步时间间隔。
response-cache-update-interval-ms: 1000
服务下线,使用的Timer来写的,Timer会有一个问题,加入执行多个任务,其中一个任务抛异常后,后面的任务将不会再去运行,建议使用ScheduledExecutorService-> ScheduledThreadPoolExecutor
ap(可用性,分区容忍性),不保证c(一致性)
Eureka各个节点都是平等的,几个节点挂掉不会影响正常节点的工作,剩余的节点依然可以提供注册和查询服务。而Eureka的客户端在向某个Eureka注册时,如果发现连接失败,则会自动切换至其他节点,只要有一台Eureka还在,就能保住注册服务的可用性,只不过查到的信息可能不是最新的。
MQ消息堆积是指生产者发送的消息短时间内在Broker端大量堆积,无法被消费者及时消费,从而导致业务功能无法正常使用。
消息堆积常见于以下几种情况:
(1)新上线的消费者功能有BUG,消息无法被消费。
(2)消费者实例宕机或因网络问题暂时无法同Broker建立连接。
(3)生产者短时间内推送大量消息至Broker,消费者消费能力不足。
(4)生产者未感知Broker消费堆积持续向Broker推送消息。
解决上述问题就要做到:
(1)解决问题一,要做好 灰度发布。每次新功能上线前,选取一定比例的消费实例做灰度,若出现问题,及时回滚;若消费者消费正常,平稳运行一段时间后,再升级其它实例。如果需要按规则选出一部分账号做灰度,则需要做好消息过滤,让正常消费实例排除灰度消息,让灰度消费实例过滤出灰度消息。
(2)解决问题二,要做到 多活。极端情况下,当一个IDC内消费实例全部宕机时,需要做到让其他IDC内的消费实例正常消费消息。同时,若一个IDC内Broker全部宕机,需要支持生产者将消息发送至其它IDC的Broker。
(3)解决问题三,要 增强消费能力。增强消费能力,主要是增加消费者线程数或增加消费者实例个数。增加消费者线程数要注意消费者及其下游服务的消费能力,上线前就要将线程池参数调至最优状态。增加消费者实例个数,要注意Queue数量,消费实例的数量要与Queue数量相同,如果消费实例数量超过Queue数量,多出的消费实例分不到Queue,只增加消费实例是没用的,如果消费实例数量比Queue数量少,每个消费实例承载的流量是不同的。
(4)解决问题四,要做到 熔断与隔离。当一个Broker的队列出现消息积压时,要对其熔断,将其隔离,将新消息发送至其它队列,过一定的时间,再解除其隔离。
方法1:
1-启动:java -jar 2_cpu-0.0.1-SNAPSHOT.jar 8 > log.file 2>&1 &
2-一般来说,应用服务器通常只部署了java应用,可以top一下先确认,是否是java应用导致的:命令:top
3-如果是,查看java进场ID,命令:jps -l
4-找出该进程内最好非CPU的线程,命令:top -Hp pid 25128
5-将线程ID转化为16进制,命令:printf "%x\n" 线程ID 623c 25148
6-导出java堆栈信息,根据上一步的线程ID查找结果:命令:
jstack 11976 >stack.txt
grep 2ed7 stack.txt -A 20
方法2:
在线工具:https://gceasy.io/ft-index.jsp
1-方法1中导出的对快照文件,上传到该网站即可
如何导出堆栈信息:
https://blog.fastthread.io/2016/06/06/how-to-take-thread-dumps-7-options/