本地事务相对比较简单,很容易实现。但是上升到全局事务再到分布式事务就比较麻烦,对保证数据一致性就需要做很多额外的处理。

从全局事务(Global Transactions)谈起

全局事务即外部事务(External Transaction):一种适用于单个服务使用多个数据源场景的事务解决方案。

实际上DTP(Distributed Transaction Processing)中没有上边这种限定。本篇分为两大类:

  1. 在分布式(单服务多个数据源)环境中追求强一致性。
  2. 在分布式(微服务)中放弃ACID追求弱一致性。

XA(Extended Architecture)协议

定义了全局事务管理器(Transaction Manager):用于协调全局事务局部的资源管理器(Resources Manager):用于驱动本地事务之间的通讯接口。

XA接口是双向的,一个事务管理器和多个资源管理器之间通信的桥梁,协调多个数据源保持一致,来实现全局事务的统一提交或者统一回滚。XADataSource XAResource。XA并不是java规范,是一套通用技术。 Java后来专门定义了一套全局事务处理标准JTA

JTA(Java Transaction API)

Java定义的一套全局事务处理标准。

  • 事务管理器接口: javax.transaction.TransactionManager,这套接口是给Java EE服务提供容器事务(由容器自动负责事务管理)使用。javax.transaction.UserTransaction接口,给程序员使用用于通过程序代码手动开启,提交和回滚事务。
  • 满足XA规范的资源定义接口:javax.transaction.xa.XAResource。任何资源(JDBC,JMS等)如果需要支持JTA,只要实现XAResource接口中的方法即可。

JTA 原本是 Java EE 中的技术,一般情况下应该由 JBoss、WebSphere、WebLogic 这些 Java EE 容器来提供支持,但现在Bittronix、Atomikos和JBossTM(以前叫 Arjuna)都以 JAR 包的形式实现了 JTA 的接口,也就是 JOTM(Java Open Transaction Manager)。有了 JOTM 的支持,我们就可以在 Tomcat、Jetty 这样的 Java SE 环境下使用 JTA 了。

XA 和 JTA的关系

XA refers to eXtended Architecture, which is a specification for distributed transaction processing. The goal of XA is to provide atomicity in global transactions involving heterogeneous components.

XA specification provides integrity through a protocol known as a two-phase commit. Two-phase commit is a widely-used distributed algorithm to facilitate the decision to commit or rollback a distributed transaction.

Java Transaction API (JTA) is a Java Enterprise Edition API developed under the Java Community Process. It enables Java applications and application servers to perform distributed transactions across XA resources.

JTA is modeled around XA architecture, leveraging two-phase commit. JTA specifies standard Java interfaces between a transaction manager and the other parties in a distributed transaction.

XA指的是eXtended Architecture,是一种用于分布式事务处理的规范。XA的目标是在涉及异构组件的全局事务中提供原子性。

XA规范通过一个称为两阶段提交的协议提供了一致性。两阶段提交是一种广泛使用的分布式算法,用于促进决定是提交还是回滚分布式事务。

Java事务API(JTA)是在Java社区流程下开发的Java企业版API。它使Java应用程序和应用服务器能够跨XA资源执行分布式事务。

JTA围绕XA架构建模,利用两阶段提交。JTA规定了分布式事务中事务管理器与其他参与方之间的标准Java接口。

两阶段提交(2 Phase Commit 2PC)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

public void buyBook(PaymentBill bill) {
userTransaction.begin();
warehouseTransaction.begin();
businessTransaction.begin();
try {
userAccountService.pay(bill.getMoney());
warehouseService.deliver(bill.getItems());
businessAccountService.receipt(bill.getMoney());
userTransaction.commit();
warehouseTransaction.commit();
businessTransaction.commit();
} catch(Exception e) {
userTransaction.rollback();
warehouseTransaction.rollback();
businessTransaction.rollback();
}
}

如上开启了三个事务,业务处理完成后做了三次事务提交。但是如果三个commit中第二个和第三个commit出现了Exception那么已经提交的事务rollback不了,这样就破坏了全局事务的一致性。为了解决这种问题提出了两阶段提交。

  • 准备阶段:投票阶段,协调者询问所有事务参与者是否已经准备好,准备好:Prepared 否则:Non-Prepared。对于数据库来讲,准备操作是是在重做日志中记录全部事务提交操作所要做的内容,与本地事务主要区别是:暂时不写入最后一条Commit Record。这意味着做完数据持久化后暂时不会释放隔离性,也就是依然持有锁。
  • 提交阶段:协调者受到所有事务参与者恢复的Prepared消息,就会首先在本地持久化事务状态未Commit,然后向所有参与者发送Commit指令。否则任意一个参与者回复Non-Prepared消息,协调者都会将自己事务状态持久化未Abort并且发送给所有参与者。

因为提交阶段相对轻量级,仅仅是持久化一条指令 Commit Record能够快速完成。回滚阶段则相对耗时,收到Abort时需要根据Undo log清理已经提交的数据。

缺点:

  • 单点问题:协调者单点
  • 性能问题: 两阶段提交过程中,所有参与者相当于被绑定成为一个统一调度整体,期间要经历两次远程服务调用,三次数据持久化(准备阶段写Redo Log,协调者做状态持久化,提交阶段在日志写入 Commit Record),整个过程将持续到参与者集群中最慢的处理操作结束为止。
  • 一致性风险: 网络稳定性带来的一致性风险。尽管提交阶段时间很短,但仍是明确存在的危险期。如果协调者在发出准备指令后,根据各个参与者发回的信息确定事务状态是可以提交的,协调者就会先持久化事务状态,并提交自己的事务。如果这时候网络忽然断开了,无法再通过网络向所有参与者发出 Commit 指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)既未提交也没办法回滚,导致数据不一致。

java中如何使用两阶段提交

Java-atomikos: https://www.baeldung.com/java-atomikos

三段式提交

将两阶段提交的准备阶段再细分两个阶段:

  1. CanCommit: 询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务释放有可能顺利完成。
  2. PreCommit:

提交阶段改为:

  1. DoCommit

将准备阶段一分为二的理由是,这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,这时候涉及的数据资源都会被锁住。如果此时某一个参与者无法完成提交,相当于所有的参与者都做了一轮无用功。

在事务需要回滚的场景中,三段式的性能通常要比两段式好很多,但在事务能够正常提交的场景中,两段式和三段式提交的性能都很差,三段式因为多了一次询问,性能还要更差一些。

如果协调者在 PreCommit 阶段开始之后发生了宕机,参与者没有能等到 DoCommit 的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待。你看,这就相当于避免了协调者的单点问题。

image-20220715092405378

共享事务

  1. 全局事务是单个服务使用多个数据源,共享事务是指多个服务共用一个数据源。

  2. “数据源”与“数据库”的区别:数据源是指提供数据的逻辑设备,不必与物理设备一一对应。

  3. 现实中只有类似ProxySQL和MaxScale这样用于对多个数据库实例做负载均衡的数据库代理,而几乎没有反过来代理一个数据库为多个应用提供事务协调的交易服务代理。

  4. 让多个微服务去共享一个数据库这个方案,其实还有另一种应用形式:使用消息队列服务器来代替交易服务器,用户、商家、仓库的服务操作业务时,通过消息将所有对数据库的改动传送到消息队列服务器,然后通过消息的消费者来统一处理,实现由本地事务保障的持久化操作。这就是“单个数据库的消息驱动更新”(Message-Driven Update of a Single Database)。

  5. “共享事务”这种叫法,以及我们刚刚讲到的通过交易服务器或者通过消息驱动来更新单个数据库这两种处理方式,在实际应用中并不常见,也几乎没有相应的成功案例,能够查到的资料几乎都来源于十多年前 Spring 的核心开发者Dave Syer的文章“Distributed Transactions in Spring, with and without XA”。

两段式提交和三段式提交仍然追求 ACID 的强一致性,这个目标不仅给它带来了很高的复杂度,而且吞吐量和使用效果上也不佳。因此,现在系统设计的主流,已经变成了不追求 ACID 而是强调 BASE 的弱一致性事务,这就是我们要在下一讲学习的分布式事务了。

分布式事务

多个服务同时访问多个数据源的处理机制。

CAP理论

  1. 一致性(Consistency):代表在任何时刻,任何分布式节点中,我们所看到的数据都是没有矛盾的。与ACID中的C单词相同含义不同。
  2. 可用性(Available):代表系统不间断提供服务。
  3. 分区容忍性(Partition Tolerance):代表在分布式环境中,当部分节点因为网络原因而彼此失联(即与其他节点形成“网络分区”)时,系统仍然能够正常的工作。

放弃分区容忍性:

CA: 这意味着,我们将假设节点之间的通讯永远是可靠的。可是永远可靠的通讯在分布式系统中必定是不成立的,这不是你想不想的问题,而是网络分区现象始终会存在。

在现实场景中,主流的 RDBMS(关系数据库管理系统)集群通常就是采用放弃分区容错性的工作模式。以 Oracle 的 RAC 集群为例,它的每一个节点都有自己的 SGA(系统全局区)、重做日志、回滚日志等,但各个节点是共享磁盘中的同一份数据文件和控制文件的,也就是说,RAC 集群是通过共享磁盘的方式来避免网络分区的出现。

放弃可用性:

CP: 这意味着,我们将假设一旦发生分区,节点之间的信息同步时间可以无限制地延长,那么这个问题就相当于退化到了上一讲所讨论的全局事务的场景之中,即一个系统可以使用多个数据源。我们可以通过 2PC/3PC 等手段,同时获得分区容错性和一致性。

在现实中,除了 DTP 模型的分布式数据库事务外,著名的 HBase 也是属于 CP 系统。以它的集群为例,假如某个 RegionServer 宕机了,这个 RegionServer 持有的所有键值范围都将离线,直到数据恢复过程完成为止,这个时间通常会是很长的。

放弃一致性:

这意味着,我们将假设一旦发生分区,节点之间所提供的数据可能不一致。

AP : 系统目前是分布式系统设计的主流选择,大多数的 NoSQL 库和支持分布式的缓存都是 AP 系统。因为 P 是分布式网络的天然属性,你不想要也无法丢弃;而 A 通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就没有存在的价值了(除非银行这些涉及到金钱交易的服务,宁可中断也不能出错)。

以 Redis 集群为例,如果某个 Redis 节点出现网络分区,那也不妨碍每个节点仍然会以自己本地的数据对外提供服务。但这时有可能出现这种情况,即请求分配到不同节点时,返回给客户端的是不同的数据。

ACID和CAP

把前面我们在 CAP、ACID 中讨论的一致性称为“强一致性”(Strong Consistency),有时也称为“线性一致性”(Linearizability),而把牺牲了 C 的 AP 系统,又要尽可能获得正确的结果的行为,称为追求“弱一致性”。

弱一致性:最终一致性

强一致性:刚性事务

可靠消息队列

eBay系统架构师:丹.普利切特(Dan Pritchett)提出了BASE理论提出了最终一致性概念。

系统建立一个消息服务,定时轮询消息表,将状态是“进行中”的消息同时发送到下游关联系统。

有一些支持分布式事务的消息框架,如 RocketMQ,原生就支持分布式事务操作,这时候前面提到的情况 2、4 也可以交给消息框架来保障。

最大努力交付(Best-Effort Delivery)

靠着持续重试来保证可靠性的操作。比如 TCP 协议中的可靠性保障,就属于最大努力交付。

最大努力一次交付(Best-Effort 1PC)

把可能出错的业务,以本地事务的方式完成后,经过不断的重试(不限于消息系统)来促使同个事务的关联业务完成。

TCC(Try-Confirm-Cancel)和SAGA

可靠消息队列的缺点就是没有隔离性。

TCC

TCC方案,它天生适用于需要强调隔离性的分布式事务中。它是一中业务侵入性比较强的事务方案,要求处理过程必须拆分为:“预留业务资源”和“确认/释放消费资源”两个子过程。

  • Try: 尝试阶段,完成所有业务可执行性的检查(保障一致性),并且预留好事务需要用到的业务资源(保障隔离性)
  • Confirm: 确认执行阶段,不进行任何业务检查,直接使用Try阶段准备的资源来完成业务处理。注意,Confirm阶段可能会重复执行,因此需要满足幂等性。
  • Cancel:取消执行阶段,释放Try阶段预留的业务资源。注意:Cancel阶段也可能重复执行,因此也需要满足幂等性。

实际的业务请求如下:

  1. 业务发出更新请求。
  2. 创建事务,生成事务ID,记录在活动日志中,进入Try阶段。
  3. 如果第二步中所有的业务都反馈业务可行,就将活动日志中的记录为Confirm,进入Confirm阶段
  4. 如果第三步的操作全部完成了,事务就会宣告正常结束。而如果第三步中的任何一方出现了异常,不论是业务异常还是网络异常,都将会根据活动日志中的记录,来重复执行该服务的 Confirm 操作,即进行“最大努力交付”。
  5. 如果是在第二步,有任意一方反馈业务不可行,或是任意一方出现了超时,就将活动日志的状态记录为 Cancel,进入 Cancel 阶段:
  6. 如果第五步全部完成了,事务就会宣告以失败回滚结束。而如果第五步中的任何一方出现了异常,不论是业务异常还是网络异常,也都将会根据活动日志中的记录,来重复执行该服务的 Cancel 操作,即进行“最大努力交付”。

优点:

  • TCC 其实有点类似于 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这就为它的实现带来了较高的灵活性,我们可以根据需要设计资源锁定的粒度。

  • TCC 在业务执行的时候,只操作预留资源,几乎不会涉及到锁和资源的争用,所以它具有很高的性能潜力。

缺点: TCC 最主要的限制是它的业务侵入性很强,但并不是指由此给开发编码带来的工作量,而是指它所要求的技术可控性上的约束。

SAGA

TCC 在业务执行的时候,只操作预留资源,几乎不会涉及到锁和资源的争用,所以它具有很高的性能潜力。通常我们并不会完全靠裸编码来实现 TCC,而是会基于某些分布式事务中间件(如阿里开源的Seata)来完成,以尽量减轻一些编码工作量。

来源:SAGA 事务模式的历史十分悠久,比分布式事务的概念提出还要更早。SAGA 的意思是“长篇故事、长篇记叙、一长串事件”,它起源于 1987 年普林斯顿大学的赫克托 · 加西亚 · 莫利纳(Hector Garcia Molina)和肯尼斯 · 麦克米伦(Kenneth Salem)在 ACM 发表的一篇论文《SAGAS》(这就是论文的全名)

文中提出了一种如何提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务的集合。原本提出 SAGA 的目的,是为了避免大事务长时间锁定数据库的资源,后来才逐渐发展成将一个分布式环境中的大事务,分解为一系列本地事务的设计模式。

SAGA的组成部分
  1. 把大事务T拆分成为若干小事务 T1…Tn,每个子事务都能被看作是原子行为。如果分布式事务T能够正常提交,那么它对数据的影响(最终一致性)就应该与连续按顺序成功提交子事务 Ti 等价。

    T - commit == T1 - commit + T2 - commit …Ti - commit… Tn - commit

  2. 每个子事务T1…Tn涉及对应的补偿动作,命名为:C1,C2,…, Ci, …,Cn

    • Ti 与 Ci 都具备幂等性;
    • Ti 与 Ci 满足交换律(Commutative),即不管是先执行 Ti 还是先执行 Ci,效果都是一样的;
    • Ci 必须能成功提交,即不考虑 Ci 本身提交失败被回滚的情况,如果出现就必须持续重试直至成功,或者要人工介入。
恢复模式

如果 T1 到 Tn 均成功提交,那么事务就可以顺利完成。否则,我们就要采取以下两种恢复策略之一:

  • 正向恢复(Forward Recovery):如果 Ti 事务提交失败,则一直对 Ti 进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,比如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。
  • 反向恢复(Backward Recovery):如果 Ti 事务提交失败,则一直执行 Ci 对 Ti 进行补偿,直至成功为止(最大努力交付)。这里要求 Ci 必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。

与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。

SAGA 必须保证所有子事务都能够提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log),以保证系统恢复后可以追踪到子事务的执行情况,比如执行都到哪一步或者补偿到哪一步了。

SAGA 事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成。

AT 事务

AT 事务是参照了 XA 两段提交协议来实现的,但针对 XA 2PC 的缺陷,即在准备阶段,必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及到的锁和资源,都需要等到最慢的事务完成后才能统一释放),AT 事务也设计了针对性的解决方案。

  1. 自动拦截所有 SQL,分别保存 SQL 对数据修改前后结果的快照,生成行锁,通过本地事务一起提交到操作的数据源中,这就相当于自动记录了重做和回滚日志。
  2. 如果分布式事务成功提交了,那么我们后续只需清理每个数据源中对应的日志数据即可;而如果分布式事务需要回滚,就要根据日志数据自动产生用于补偿的“逆向 SQL”。
  3. 基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。AT 事务这种异步提交的模式,相比 2PC 极大地提升了系统的吞吐量水平。而使用的代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚不一定总能成功。
  4. 当在本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Wirte),而这个时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向 SQL 来实现补偿,只能由人工介入处理了。
  5. 一般来说,对于脏写我们是一定要避免的,所有传统关系数据库在最低的隔离级别上,都仍然要加锁以避免脏写。因为脏写情况一旦发生,人工其实也很难进行有效处理。
  6. GTS 增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,而在没有获得全局锁之前就必须一直等待。
  7. 这种设计以牺牲一定性能为代价,避免了在两个分布式事务中,数据被同一个本地事务改写的情况,从而避免了脏写。
  8. 在读隔离方面,AT 事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能会产生脏读(Dirty Read)。读隔离也可以采用全局锁的方案来解决,但直接阻塞读取的话,我们要付出的代价就非常大了,一般并不会这样做。

Comment and share

引言

事务管理是日常开发过程中绕不开的技术,引入事务的目的是为了保证数据持久化过程中的 一致性(Consistency) 。事务的概念最初是源于数据库,但今天的信息系统中,所有需要保证数据正确性(一致性)的场景下,包括但不限于数据库、缓存、事务内存、消息、队列、对象文件存储等等,都有可能会涉及到事务处理。这篇总结主要是总结清楚事务的来龙去脉,无关具体的某个中间件。深度推荐《周志明的软件架构课》

定义事务的ACID

  • 原子性(Atomic)同一业务处理过程中,事务保证了多项对数据的修改,要么全部成功,要么全部失败。
  • 隔离性(Isolation)在不同的业务处理过程中,事务保证了各自读写的业务数据相互不可见,不会彼此影响。
  • 持久性(Durability)事务保证了当业务处理完成后,事务提交后能够被持久化,不会丢失。
  • 一致性(Consistency)一致性。利用A, I, D 保证了业务数据的一致性。

A, I, D是手段,C 是目的。 ACID更像是凑单词。

本地事务(局部事务)Local Transaction

  • 本地事务是指仅操作特定单一事务资源,不需要全局事务管理器进行协调的事务。

  • 本地事务是最基础的一种事务处理方案,通常只适用于单个服务使用单个数据源的场景,它是直接依赖于数据源(通常是数据库系统)本身的事务能力来工作的。

  • 本地事务的开启、终止、提交、回滚、嵌套、设置隔离级别、乃至与应用代码贴近的传播方式,全部都要依赖底层数据库的支持。

  • 例如:Spring提供的事务注解,如果数据库引擎不支持那么这个注解也是无意义的。

    1
    2
    3
    4
    @Transactional(propagation = Propagation.REQUIRED, //传播方式
    rollbackFor = Exception.class, //回滚异常
    isolation = ISOLATION_REPEATABLE_READ //隔离级别
    )

原子性和持久性

扩展知识:ARIES 理论,现代主流数据库的事务实现都受到该理论的影响

原子性和持久性密切相关,原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态。持久性则保证了事务一旦生效则不会因为任何原因而导致其修改的内容被撤销或者丢失。

崩溃回复(Crash Recovery)

  • 未提交事务:程序还没修改完一系列整个业务数据,数据库已经将其中一个或者两个数据的变动写入了磁盘,此时崩溃,一旦重启后,数据库必须想办法得知崩溃前发生过一次不完整的购物操作,将已经修改过的数据从磁盘中恢复成为之前没有改过的样子。
  • 已提交事务:程序已经修改完了所有数据,数据还没将全部的数据写入磁盘,此时出现崩溃。重启之后数据库必须知道崩溃前发生过一次的完整操作,将还没来得及写入磁盘的数据重新写入。保证持久性。

这种数据恢复操作被称作是:崩溃恢复。

Commit Logging

为了能够顺利完成崩溃恢复,那么需要记录下数据操作的全部信息:比如修改的什么数据,位于哪个内存页和磁盘块中,从什么值改成了什么值等等,以顺序追加的文件写入方式记录的磁盘中(顺序写磁盘是高效的)。只有日志记录全部落盘,见到代表事务成功提交的“Commit Record”,数据库才会根据日志上的信息真正的对数据进行修改。修改完成后在日志中加一条“End Record”标识已经完成持久化,这种实现方式被称为“Commit Logging”

另外一种实现:Shadow Paging

SQLite Version 3采用这种方式实现。

将原来数据复制一份副本也可以称之为临时数据,原来数据保持不变,在副本数据上进行操作。再修改完成后改变“两份数据的指针”。

Shadow Paging 相对简单,但涉及到隔离性与锁时,Shadow Paging 实现的事务并发能力相对有限,因此在高性能的数据库中应用不多。

Commit Logging保证持久性:日志一旦写入Commit Logging,那么整个事务就是成功的。即使崩溃,重启后根据日志进行恢复现场,继续修改即可。

Commit Logging保证原子性:如果日志没有写入成功就发生崩溃,重启后会看到一部分没有Commit Logging的数据,那么将这部分数据标识为回滚状态即可,整个事务就像没有发生过。保证了原子性。

Commit Logging 存在一个巨大的缺陷:所有对数据的真实修改都必须发生在事务提交、日志写入了 Commit Record 之后,即使事务提交前磁盘 I/O 有足够空闲、即使某个事务修改的数据量非常庞大,占用大量的内存缓冲,无论何种理由,都决不允许在事务提交之前就开始修改磁盘上的数据,这一点对提升数据库的性能是很不利的。

Write-Ahead Logging(先写日志)

为了修复Commit Logging缺陷ARIES 提出了“Write-Ahead Logging”的日志改进方案,其名字里所谓的“提前写入”(Write-Ahead),就是允许在事务提交之前,提前写入变动数据的意思。

Write-Ahead Logging 先将何时写入变动数据,按照事务提交时点为界,分为了 FORCE 和 STEAL 两类:

  • FORCE:当事务提交后,要求变动数据必须同时完成写入—则称为 FORCE,如果不强制变动数据必须同时完成写入—则称为 NO-FORCE。现实中绝大多数数据库采用的都是 NO-FORCE 策略,只要有了日志,变动数据随时可以持久化,从优化磁盘 I/O 性能考虑,没有必要强制数据写入立即进行。
  • STEAL:在事务提交前,允许变动数据提前写入则称为 STEAL,不允许则称为 NO-STEAL。从优化磁盘 I/O 性能考虑,允许数据提前写入,有利于利用空闲 I/O 资源,也有利于节省数据库缓存区的内存。

Commit Logging允许NO-FORCE但是不允许STEAL。因为在事务提交前写入了一部分数据,如果发生崩溃,那么提前写入的数据就变成了脏数据。

Write-Ahead Logging允许NO-FORCE也允许STEAL,给出的解决办法是增加了Undo Log日志。当数据写入磁盘前,必须先记录Undo Log,写明修改了什么值并生成对应的回滚段。以便事务再发生崩溃恢复的时候根据Undo Log对提前写入的数据变动进行擦除。

Undo Log 现在一般被翻译为“回滚日志”,此前记录的用于崩溃恢复时重演数据变动的日志,就相应被命名为 Redo Log,一般翻译为“重做日志”。

Write-Ahead Logging 在崩溃恢复时,会以此经历以下三个阶段:

  1. 分析阶段(Analysis):从最后一次检查点Checkpoint(从这个检查点之前所有的数据都成功落盘)开始扫描日志,找出所有没有End Record的事务,组成待恢复的事务集合(一般包括 Transaction Table 和 Dirty Page Table);
  2. 重做阶段(Redo):这个阶段依据分析阶段产生的待恢复的事务集合来重演历史(Repeat History)找出所有的包含Commit Record的日志,将它们写入磁盘,写入完成后追加一个End Record,然后移除待恢复 集合。
  3. 回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务(被称为 Loser),根据 Undo Log 中的信息回滚这些事务。

数据库按照“是否允许 FORCE 和 STEAL”可以产生四种组合,从优化磁盘 I/O 的角度看,NO-FORCE 加 STEAL 组合的性能无疑是最高的;从算法实现与日志的角度看,NO-FORCE 加 STEAL 组合的复杂度无疑是最高的。这四种组合与 Undo Log、Redo Log 之间的具体关系如下图所示:

image-20220705093501892

隔离性

隔离性保证了每个事务各自读写的相互独立性。主要是为了解决事务并发问题,有并发的地方就有锁。

  • 写锁(Write Lock 也叫排他锁eXclusive Lock,简写X-Lock):只有持有写锁的事务才能对数据进行写入操作,数据加写锁时,其它事务不能写入数据,也不能施加读锁(写锁禁止其他事务施加读锁,而不是禁止事务读取数据。)。

  • 读锁(Read Lock 也叫共享锁Shared Lock,简写S-Lock):多个事务可以对同一个数据添加读锁,添加了读锁后就不能添加写锁,所以事务不能对数据进行写入,但仍然可以读取。对持有读锁的事务,如果该事务只有一个读锁就可以直接升级为写锁,然后写入数据。

  • 范围锁(Range Lock)对某个范围数据直接添加排他锁,在这个范围的数据不能被读取,也不能被写入。如 select * from books where price < 100 for update;

    请注意“范围不能写入”与“一批数据不能写入”的差别,也就是我们不要把范围锁理解成一组排他锁的集合。加了范围锁后,不仅无法修改该范围内已有的数据,也不能在该范围内新增或删除任何数据,这是一组排他锁的集合无法做到的。

可串行化(Serializable)

对事务所有读、写的数据全都加上读锁、写锁和范围锁即可(这种可串行化的实现方案称为 Two-Phase Lock)。

强度最高的隔离性。粗粒度锁,事务顺序执行那么就不会互相产生影响。但是吞吐量最低。

可重复读(Repeatable Read)

可重复读的意思是对事务所涉及到的数据加读锁和写锁,并且一直持续到事务结束,但是不再添加范围锁。

缺点:在可读事务中的幻读问题(MySQL 中的InnoDB在只读事务中不存在幻读问题,只在读写事务提升为当前读后存在这个问题)如下读事务:

1
2
3
4
5
6
7
8
9

/* 时间顺序:1,事务: T1 */
SELECT count(1) FROM books WHERE price < 100

/* 时间顺序:2,事务: T2 */
INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90)

/* 时间顺序:3,事务: T1 会查出来 T2 插入的数据,但是InnoDB不会*/
SELECT count(1) FROM books WHERE price < 100

读提交(Read Committed)

读提交会对事务涉及到的数据加写锁,会一直持续到事务结束,加的读锁会在读完之后立马释放。

读提交有了不可重复读—-对同一行数据两次查询得到了不同的结果。

原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化。

读未提交(Read UnCommitted)

读未提交只会对事务所涉及到的数据加写锁,不会加读锁。

读未提交有了脏读—-一个事务读到了另外一个事务未提交的数据

其实,不同隔离级别以及幻读、脏读等问题都只是表面现象,它们是各种锁在不同加锁时间上组合应用所产生的结果,锁才是根本的原因。除了锁之外,以上对四种隔离级别的介绍还有一个共同特点,就是一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性。针对这种“一个事务读 + 另一个事务写”的隔离问题,有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。

多版本并发控制MVCC(Multi-Version Concurrency Control)无锁化。

MVCC是一种读取优化策略,它的“无锁”是特指读取数据时不需要加锁。

MVCC只针对读+写的场景优化,如果是两个事务同时修改数据,即写+写的场景那么几乎没有什么优化的空间

MVCC 的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。这句话里的“版本”是个关键词,你不妨将其理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION 和 DELETE_VERSION,这两个字段记录的值都是事务 ID(事务 ID 是一个全局严格递增的数值),然后:

  1. 数据被插入时:CREATE_VERSION 记录插入数据的事务 ID,DELETE_VERSION 为空。
  2. 数据被删除时:DELETE_VERSION 记录删除数据的事务 ID,CREATE_VERSION 为空。
  3. 数据被修改时:将修改视为“删除旧数据,插入新数据”,即先将原有数据复制一份,原有数据的 DELETE_VERSION 记录修改数据的事务 ID,CREATE_VERSION 为空。复制出来的新数据的 CREATE_VERSION 记录修改数据的事务 ID,DELETE_VERSION 为空。
  • 隔离级别是可重复读:总是读取 CREATE_VERSION 小于或等于当前事务 ID 的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务 ID 最大)的。
  • 隔离级别是读已提交:总是取最新的版本即可,即最近被 Commit 的那个版本的数据记录。

Comment and share

经常听到别人讲数据库就像书的目录一样,是为了提高查询效率,那么索引实现和书的目录区别又是什么?

一、索引的常见模型

  1. 哈希表
  2. 有序数组
  3. 搜索树(InnoDB采用的是N叉B+树InnoDB引擎使用的数据结构后边重点介绍
    img

二、各模型分析

1. 哈希表模型图解

img

如图所示:跟java中的hashMap数据结构一致

  1. 图中,User2 和 User4 根据身份证号算出来的值都是 N,但没关系,后面还跟了一个链表。假设,这时候你要查 ID_card_n2 对应的名字是什么,处理步骤就是:首先,将 ID_card_n2 通过哈希函数算出 N;然后,按顺序遍历,找到 User2。
  2. 需要注意的是,图中四个 ID_card_n 的值并不是递增的,这样做的好处是增加新的 User 时速度会很快,只需要往后追加。但缺点是,因为不是有序的,所以哈希索引做区间查询的速度是很慢的。
  3. 你可以设想下,如果你现在要找身份证号在 [ID_card_X, ID_card_Y] 这个区间的所有用户,就必须全部扫描一遍了。
    由上可以推断:哈希表这种结构适用于只有等值查询的场景,比如 Memcached 及其他一些 NoSQL 引擎。
2. 有序数组模型图解(等值查询和区间查询效率都很高)

img

优点:

  1. 这里我们假设身份证号没有重复,这个数组就是按照身份证号递增的顺序保存的。这时候如果你要查 ID_card_n2 对应的名字,用二分法就可以快速得到,这个时间复杂度是 O(log(N))。
  2. 同时很显然,这个索引结构支持范围查询。你要查身份证号在 [ID_card_X, ID_card_Y] 区间的 User,可以先用二分法找到 ID_card_X(如果不存在 ID_card_X,就找到大于 ID_card_X 的第一个 User),然后向右遍历,直到查到第一个大于 ID_card_Y 的身份证号,退出循环。如果仅仅看查询效率,有序数组就是最好的数据结构了。
    缺点:
    但是,在需要更新数据的时候就麻烦了,你往中间插入一个记录就必须得挪动后面所有的记录,成本太高。有序数组索引只适用于静态存储引擎。
3. 二叉搜索树模型图解(等值查询和区间查询效率都很高)

img

优点:

  1. 二叉搜索树的特点是:每个节点的左儿子小于父节点,父节点又小于右儿子。这样如果你要查 ID_card_n2 的话,按照图中的搜索顺序就是按照 UserA -> UserC -> UserF -> User2 这个路径得到。这个时间复杂度是 O(log(N))。
  2. 当然为了维持 O(log(N)) 的查询复杂度,你就需要保持这棵树是平衡二叉树。为了做这个保证,更新的时间复杂度也是 O(log(N))。树可以有二叉,也可以有多叉。多叉树就是每个节点有多个儿子,儿子之间的大小保证从左到右递增。二叉树是搜索效率最高的

缺点:
但是实际上大多数的数据库存储却并不使用二叉树。其原因是,索引不止存在内存中,还要写到磁盘上。你可以想象一下一棵 100 万节点的平衡二叉树,树高 20。一次查询可能需要访问 20 个数据块。在机械硬盘时代,从磁盘随机读一个数据块需要 10 ms 左右的寻址时间。也就是说,对于一个 100 万行的表,如果使用二叉树来存储,单独访问一个行可能需要 20 个 10 ms 的时间,这个查询可真够慢的。

4. N叉B+树(N差不多是1200)
  1. 每一个索引在InnoDB中都是一棵B+树
  2. 这个 N 差不多是 1200。这棵树高是 4 的时候,就可以存 1200 的 3 次方个值,这已经 17 亿了。考虑到树根的数据块总是在内存中的,一个 10 亿行的表上一个整数字段的索引,查找一个值最多只需要访问 3 次磁盘。其实,树的第二层也有很大概率在内存中,那么访问磁盘的平均次数就更少了。

三、InnoDB索引模型案例分析

假设,我们有一个主键列为 ID 的表,表中有字段 k,并且在 k 上有索引。

1
2
3
4
5
mysql> create table T(
id int primary key,
k int not null,
name varchar(16),
index (k))engine=InnoDB;

表中 R1~R5 的 (ID,k) 值分别为 (100,1)、(200,2)、(300,3)、(500,5) 和 (600,6),两棵树的示例示意图如下。
img

  1. 主键索引的叶子节点存的是整行数据。在 InnoDB 里,主键索引也被称为聚簇索引(clustered index)
  2. 非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引(secondary index)
    基于主键索引和普通索引的查询有什么区别?
  • 如果语句是 select * from T where ID=500,即主键查询方式,则只需要搜索 ID 这棵 B+ 树;
  • 如果语句是 select * from T where k=5,即普通索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表
    也就是说,基于非主键索引的查询需要多扫描一棵索引树(回表)。因此,我们在应用中应该尽量使用主键查询

四、InnoDB索引维护

  1. B+ 树为了维护索引有序性,在插入新值的时候需要做必要的维护。以上面这个图为例,如果插入新的行 ID 值为 700,则只需要在 R5 的记录后面插入一个新记录。如果新插入的 ID 值为 400,就相对麻烦了,需要逻辑上挪动后面的数据,空出位置。
  2. 而更糟的情况是,如果 R5 所在的数据页已经满了,根据 B+ 树的算法,这时候需要申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。在这种情况下,性能自然会受影响。
  3. 除了性能外,页分裂操作还影响数据页的利用率。原本放在一个页的数据,现在分到两个页中,整体空间利用率降低大约 50%。
  4. 当然有分裂就有合并。当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。
  5. 你可能在一些建表规范里面见到过类似的描述,要求建表语句里一定要有自增主键。当然事无绝对,我们来分析一下哪些场景下应该使用自增主键,而哪些场景下不应该?
  1. 自增主键是指自增列上定义的主键,在建表语句中一般是这么定义的: NOT NULL PRIMARY KEY AUTO_INCREMENT。
  2. 插入新记录的时候可以不指定 ID 的值,系统会获取当前 ID 最大值加 1 作为下一条记录的 ID 值。
  3. 也就是说,自增主键的插入数据模式,正符合了我们前面提到的递增插入的场景。每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。
  4. 而有业务逻辑的字段做主键,则往往不容易保证有序插入,这样写数据成本相对较高。
  5. 除了考虑性能外,我们还可以从存储空间的角度来看。假设你的表中确实有一个唯一字段,比如字符串类型的身份证号,那应该用身份证号做主键,还是用自增字段做主键呢?
  6. 由于每个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键,那么每个二级索引的叶子节点占用约 20 个字节,而如果用整型做主键,则只要 4 个字节,如果是长整型(bigint)则是 8 个字节。
  7. 显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。
  8. 所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。

6.有没有什么场景适合用业务字段直接做主键的呢?还是有的。比如,有些业务的场景需求是这样的:
只有一个索引;该索引必须是唯一索引。
由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。这时候我们就要优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。

Comment and share

一. ACID解释

A: Atomicity 原子性
C: Consistencey 一致性
I: Isolation 一致性
D: Durability 持久性

二. 事务隔离级别
  1. 读未提交(read uncommited)一个事务还未提交,它的更改可以被其他事务读到。
  2. 读提交(read commited)只有一个事务提交了后,它的更改才可以被其他事务读到。
  3. 可重复读(repeatable read)一个事务执行过程中看到的数据,总是跟这个事务启动前看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
  4. 串行化(serializable)对于同一行记录,读会加读锁,写会加写锁。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成。

知识点 读提交和可重复读的区别是:有两个事务A,B。读提交是:如果A事务在开启过程中,B事务对记录进行了更改并且提交了,A是可以读到的B事务更改后的记录。可重复读则是:就算A事务在开启过程中B事务对记录进行了更改并且提交了,A也是读不到B更改后的记录。A事务仍然读到的事它开启时记录最初的状态。只有当A事务进行提交后才能读到B更改后的记录。

Oracle默认的隔离级别是读提交
配置的方式是,将启动参数 transaction-isolation 的值设置成READ-COMMITTED。你可以用 show variables 来查看当前的值。

可重复读的应用场景

假设你在管理一个个人银行账户表。一个表存了每个月月底的余额,一个表存了账单明细。这时候你要做数据校对,也就是判断上个月的余额和当前余额的差额,是否与本月的账单明细一致。你一定希望在校对过程中,即使有用户发生了一笔新的交易,也不影响你的校对结果。

三. 事务隔离实现为什么要避免大量的大事务

在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。
假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。

img

当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到。同时你会发现,即使现在有另外一个事务正在将 4 改成 5,这个事务跟 read-view A、B、C 对应的事务是不会冲突的。

当系统里没有比这个回滚日志更早的 read-view 的时候才删除日志。
长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。
除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库。
在工作中有的公司代码中可能采用的AOP来进行事务管理,根据service层入口的方法名前缀来判断是否开启事务,经常能看到有的开发者为了不必要的麻烦所有都采用了开启事务,这是不合理的。

问题:如何避免长事务对业务的影响?

首先,从应用开发端来看:

  1. 确认是否使用了 set autocommit=0。这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1。
  2. 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。
  3. 业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。(为什么会意外?在后续的文章中会提到这类案例)

其次,从数据库端来看:

  1. 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill;
  2. 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill;
  3. 如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。

阅读《MySQL实战45讲》

InnoDB的undo log文件存储在MySQL数据目录下的ibdata文件中,这个文件包含了多种不同的数据结构和信息,其中包括了InnoDB的undo log。

具体来说,每个InnoDB表都有一个undo log,用于记录对该表进行的事务操作,如INSERT、UPDATE和DELETE。当需要回滚一个事务时,InnoDB会使用undo log中的信息来撤消该事务所做的更改。

在默认情况下,InnoDB的undo log被存储在ibdata文件的系统表空间中。如果使用了多个独立的表空间,每个表空间也会包含一个undo段,其中包含与该表空间关联的所有表的undo log。

值得注意的是,如果启用了innodb_undo_tablespaces选项,每个InnoDB表将会有一个独立的undo表空间文件,这些文件将会存储在指定的目录中,而不是在ibdata文件中。

Comment and share

概述

一句update的语句:Update T set C=c+1 where id = 2;

和查询语句一样会走一遍如下的流程:

img点击并拖拽以移动

与查询语句不一样的是,更新语句设计上有两个重要的模块:redo log 和 binlog

一、重要日志模块: redo log InnoDB引擎特有的日志

Write-Ahead Logging(WAL技术)它的关键点就是先写日志,再写磁盘,也就是先写粉板,等不忙的时候再写账本。

  1. 当有一条记录需要更新的时候,InnoDB引擎就会先把记录写入到redo log(粉板)理。

  2. 进行内存的更新。

    以上两步操作后更新就算完成了。

  3. 同时InnoDB引擎会在适当的时候将这个操作记录更新到磁盘里面。而这个更新往往是系统比较空闲的时候。类比掌柜下班后将粉板上的赊账记录誊写到账本上,

但是:如果今天赊账的不多,掌柜可以等打烊后再整理。但如果某天赊账的特别多,粉板写满了,又怎么办呢?这个时候掌柜只好放下手中的活儿,把粉板中的一部分赊账记录更新到账本中,然后把这些记录从粉板上擦掉,为记新账腾出空间。

与此类似,InnoDB 的 redo log 是固定大小的,比如可以配置为一组 4 个文件,每个文件的大小是 1GB,那么这块“粉板”总共就可以记录 4GB 的操作。从头开始写,写到末尾就又回到开头循环写,如下面这个图所示。

img点击并拖拽以移动

  1. write pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。

  2. write pos 和 checkpoint 之间的是“粉板”上还空着的部分,可以用来记录新的操作。如果 write pos 追上 checkpoint,表示“粉板”满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下。

  3. crash-safe:有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失。

二、重要日志模块: binlog server层归档日志

因为最开始 MySQL 里并没有 InnoDB 引擎。MySQL 自带的引擎是 MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog 日志只能用于归档。而 InnoDB 是另一个公司以插件形式引入 MySQL 的,既然只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 使用另外一套日志系统——也就是 redo log 来实现 crash-safe 能力。

三、redo log 和 binlog的差异:

  1. redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
  2. redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
  3. redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

四、update语句执行流程:

  1. 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。

  2. 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。

  3. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。

  4. 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。

  5. 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

img点击并拖拽以移动

将 redo log 的写入拆成了两个步骤:prepare 和 commit,这就是”两阶段提交”.

五、两阶段提交:

怎样让数据库恢复到半个月内任意一秒的状态?

  1. 首先,找到最近的一次全量备份,如果你运气好,可能就是昨天晚上的一个备份,从这个备份恢复到临时库;

  2. 然后,从备份的时间点开始,将备份的 binlog 依次取出来,重放到中午误删表之前的那个时刻。

    简单说,redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。

>阅读《MySQL实战45讲》

Comment and share

MySQL的架构示意:

img

MySQL大体分为两层:Server 层和存储引擎层

  1. server层: 连接器,查询缓存,分析器,优化器等,涵盖MySQL的大多数核心服务功能,一级所有内置函数(如日期,时间,数学和加密函数等),所有夸存储引起的功能都在这一层实现,比如:存过,触发器,视图等。
  2. 存储引擎负责数据的存储和提取:innoDB,MyISAM,Memory等 MySql5.5.5版本开始默认为InnoDB

个层次分工:

  1. 连接器:顾名思义连接器负责跟客户端建立连接、获取权限、维持和管理连接
    你可以在 show processlist 命令中看到它。Command列显示为Sleep则表示该连接为空闲链接。
  2. 查询缓存:连接建立完成后,你就可以执行 select 语句了。执行逻辑第二步查询缓存。
    优势:提高查询效率,适合表数据不经常做更新的。
    劣势:一张表有更新机会清空缓存,命中率会很低。
    使用参数 query_cache_type 设置成 DEMAND这样对应默认的SQL语句是不适用查询缓存的,显示指定的时候才会查询缓存如下:
    1
    select SQL_CACHE * FROM T WHERE ID = 10;
  3. 分析器:如果没有命中缓存则开始对SQL语句进行解析,生成解析树。
  4. 经过了分析器,MySQL 就知道你要做什么了。在开始执行之前得经过优化器的处理,包括表里有多个索引时决定使用哪个索引;一个语句有多表关联的时候决定各个表的连接顺序;
    比如:

    mysql> select * from t1 join t2 using(ID) where t1.c=10 and t2.d=20;

    1. 既可以先从表 t1 里面取出 c=10 的记录的 ID 值,再根据 ID 值关联到表 t2,再判断 t2 里面 d 的值是否等于20。
    2. 也可以先从表 t2 里面取出 d=20 的记录的 ID 值,再根据 ID 值关联到 t1,再判断 t1 里面 c 的值是是否等于 10。

后边仔细分析对索引的选择
5. 执行器:MySql通过分析器知道了你要做什么,通过优化器知道了该怎么做,于是就进入了之情器阶段开始执行语句。
如以下语句的执行过程:

1
2
mysql> select * from T where ID=10;

执行器会根据表定义的引擎取调用这个引擎所提供的接口。比如我们例句中提供的表T,ID无索引则会:

  1. 调用InnoDB引擎接口取这个表的第一行,判断ID是否为10,如果不是则跳过,如果是则将这行存在结果集中;
  2. 调用引擎接口取“下一行”,重复相同的判断逻辑,指导取到这个表的最后一行。
  3. 执行器将上述遍历过程中所有满足的条件行组成记录集作为结果返回给客户端。

至此这个语句执行就完成了。
对于有索引的表,执行的逻辑也差不多。第一次调用的是“满足条件得第一行“这个接口,之后循环取“满足条件的下一行”这个接口。 这些接口都是存储殷勤中定义好的。
** rows_examined **:表示语句扫描了多少行,这个值就是执行器每次调用引擎获取数据行时累加的。
在某些场景下,执行器调用一次,在引擎内部则扫描了多行,因此引擎扫描行数跟rows_examined(调用次数可能小于扫描行数)并不是完全相同的

阅读《MySQL实战45讲》

Comment and share

  • page 1 of 1
Author's picture

Topsion

Fullstack Developer


Coder


Xi'an China