如果这篇文章对您有些用处,请点赞告诉我O(∩_∩)O
序
起因是我们项目中对于分布式事务处理的方法过于复杂,具体是:业务逻辑上先进行“预占”,接口调用借助消息确认+幂等重试,最后在异常情况下调用接口补偿或回滚。这样写的代码变得非常臃肿,于是想能不能将事务控制的代码和业务代码分离,同事分享了一个分布式事务框架seata,优点是较少入侵,缺点非常明显会锁表,对性能有影响。自然是不能用的。接着了解到TCC-Transaction。沿着这条线索,逆流而上,要想较为完整的理解分布式事务:
TCC-Transaction <-- TCC <-- Spring 分布式事务JTA <- XA <-- 数据库事务 <-- 锁 <-- 并发
准备分两章来写:
追本溯源-分布式事务(一)并发、锁、事务
追本溯源-分布式事务(二)XA、JTA、TCC、TCC-Transaction
(写着写着容易跑题,希望能拽的回来)
预先准备一张示例表,my_stock库存表,记录每个库位上商品库存数量。

记录如下:(不全)

一、并发
计算机原理中,并发一般指的是多个应用争夺一个CPU。(以下定义来自百度百科)
并发,在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。
而WEB应用中我们常说的并发操作通常指的是多个用户对内存或数据库中同一份数据的读写。
(下图中蓝色部分是主要争夺的资源)

那么在并发过程中,如何避免发生或及时纠正数据错误的机制称为并发控制。
1、多线程争夺内存
每一个用户请求,从tomcat线程池中取出一个空闲线程。如果线程不够则会等待或拒绝请求。此时多请求转化为多线程。
当多线程执行同一块代码时,会发生什么并发问题呢?
public class ConTest {
private int x = 0;
public void addX() {
x = x + 1; //发生并发问题
System.out.println(x);
}
public static void main(String[] args) {
ConTest test = new ConTest();
for (int i = 0; i < 100000; i++) {
new Thread(new Runnable() {
@Override
public void run() {
test.addX();
}
}).start();
}
}
}
我们期望控制台最后输出的是100000,实际却是99997(不固定),使用javap查看下字节码指令,发现问题出在x = x + 1。

可以看到主要分为3个指令:
(1)getfield 读取成员变量x,并压入栈顶
(2)iadd 计算x + 1
(3)putfield 将计算结果回写成员变量x
在单线程执行过程中,所有指令串行执行,在多线程执行过程中发生线程切换,指令分开执行导致数据错误(x应该等于2),这就是原子性的并发问题。

java中并发控制的方法之一是给需要同步执行的方法加上synchronized,即给调用该方法的实例对象加锁,多线程调用同步方法前必须先获取此实例锁,执行完后释放,如此保证串行执行,避免并发问题。
public synchronized void addX() {
x = x + 1; //不会发生并发问题
System.out.println(x);
}
(java中并发问题以及并发控制当然不止这一种,如可见性&指令重排等,这些不是本文重点,这里引出并发控制的概念)
2、多连接争夺数据库
上面提到多请求转化为多线程,如果每个线程都需要访问数据库。即多线程访问数据库,我们通常使用数据库连接池访问数据库,每一次访问数据库,都会从数据库连接池中获取一个连接,如果当前连接数等于或大于最大连接数,则会等待,此时java多线程转化为数据库多连接。
(注意数据库连接池是线程安全,但数据库连接不是线程安全,因此不能在多线程中共享数据库连接。)
当多连接访问同一份数据时,会发生什么问题呢?
这是仓库系统中的一个补货场景:向A库存补货1个商品。
update my_stock set num = num + 1 where location = 'A';
这里我们无法像java程序一样开100000个连接来测试下,会不会同"x = x + 1"一样有原子性的并发问题。
直接给出答案:因为mysql的innodb存储引擎默认给每一条UPDATE语句加独占锁(排他锁),保证一个SQL中的所有步骤串行执行,目前可以先把独占锁当做java中的synchronized理解。(后面详细举例解释)
二、事务
数据库存储数据,自然要保证数据的一致性。
一致性主要体现在以下4个方面:
1、多个用户执行一个数据库操作,那么逐个执行和同时执行,结果应当一致。
即:多连接执行单条SQL,串行执行和并行执行,结果应当一致。
如:100000名员工同时向同一个库位补货1个商品,库存应当增加100000个,而不是9xxxxxx个,这是并发问题,由锁并发控制。
2、一个用户执行多个数据库操作,操作前后数据库逻辑上应当状态一致。
即:一个连接执行多个SQL,执行前后数据库逻辑上状态应当一致。
如:从A库位移动10个商品到B库位,那么A库位应当少10个,B库位应当多10个,商品总数应当一致。而不是A库位减少了10个,B库位却没有增加,库存总数变少了。这不是并发问题,而是原子性问题,需要一个新概念-事务 。(以下定义来自百度百科)
数据库事务( transaction)是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。事务由事务开始与事务结束之间执行的全部数据库操作组成。

由事务的定义可知:事务是操作数据库的最小单位,即数据库连接转化为数据库事务。
3、多个用户执行多个数据库操作,操作前后数据的状态应当保持一致。
即:多个事务并发执行数据库操作,每个事务执行期间,需要相互避免干扰,这是隔离性问题。
假如仓库系统中A库位上有10个商品,且库位库存必须都小于等于20。
现在对库存做各种操作,看下如果事务没有隔离性,会发生哪些问题?
(1)更新丢失:一个事务覆盖了另一个事务中提交的数据。
如:员工甲从A库位上拣货5个商品,同时员工乙向A库位补货10个商品。

(2)脏读:一个事务读取到另一个事务未提交的数据。
如:员工甲向A库位上补货20,同时员工乙查询A库位库存。

(3)不可重复读:在一个事务的执行过程中,读取到另一个事务执行的更新的数据。
如:员工甲先从库位上拣货5个商品(提交事务),再补货10个商品(提交事务),同时员工乙查询两次库存。

(4)幻读:在一个事务的执行过程中,读取到另一个事务执行的新增数据。
如:仓库总共10个库位,员工甲需要冻结所有库位,同时员工乙新增库位(状态默认启用)。

注:不可重复读或幻读的问题,某些场景下不算是bug,争议之处在于,一个事务执行过程中的数据,不应当受其他事务的影响(更新或新增)。
4、即使硬件发生问题,恢复之后,数据库状态应当和发生事故之前保持一致。
即:每个事务提交之后,需要及时保存到数据文件中,这是持久性问题,使用redo log 解决。
综上,
(1)事务是操作数据库的最小单位,Innodb默认自动提交事务,即一个数据库操作(SQL)一个事务。也可以使用begin.....commit/rollback。包裹一系列数据库操作(SQL),作为一个事务,这些操作要么都执行,要么都不执行。
(2)要想事务保证数据的一致性(目的),那么单个事务的执行就需要原子性,多个事务的并发执行就需要隔离性,事务执行期间发生硬件等不可逆事件时就需要持久性。即事务需要具备4个特性(ACID):
原子性(A):事务中全部操作不可分割,要么全部不执行,要么全部不执行。
一致性(C):事务的执行结果必须使数据库从一个一致性状态到另一个一致性状态。
隔离性(I):事务的执行不收其他事务干扰,事务执行的中间结果对其他事务不可见。
持久性(D):对于任何已提交的事务,哪怕数据库出现故障,也不能丢失。
其中,事务的隔离级别分为4个级别:

越往下隔离级别越高,数据一致性更强,同时性能越差。其中可串行化表示所有事务一个一个排队执行,当然不存在并发的问题,但对于性能也是最低的。
三、锁
同synchronize对于java程序一样,数据库中锁的作用是也是并发控制。mysql中根据存储引擎(数据库的心脏)的不同,有不同的锁机制。
mysql5.5前后有两种默认的存储引擎,MyISAM和InnoDB。其中MyISAM只有表锁,没有事务,InnoDB支持表锁和行锁,以及事务。
1、InnoDB中常用的锁分类
行锁:
(1)共享锁(S)允许一个事务读取一行。
(2)独占锁(X)允许一个事务更新或删除一行。
这里可能会问为什么是“一行”,因为行级锁(S或X)都是加在每一行的索引上。
如果一个事务在某一行上持有独占锁(X),那么其他事务不可以获取此行的独占锁(X)或共享锁(S),都会阻塞等待。
如果一个事务在某一行上持有共享锁(S),那么其他事务可以获取此行的共享锁(S),但不能获取独占锁(X),会阻塞等待。
表锁:
(3)意图共享锁(IS):事务在获取某一行S锁之前,必须先获取该表的IS锁或更强锁定(IX)。
(4)意图独占锁(IX):事务在获取某一行X锁之前,必须先获取该表的IX锁。
IS和IX锁的作用是表示某个事务正在或将要锁定其中的一行,意图锁不阻塞任何操作,除了对表的完全请求,如:
LOCK TABLES ..... WRITE :锁住整张表,只能写 update/insert/delete,不能读select。
LOCK TABLES ..... READ:锁住整张表,只能读select,不能写 update/insert/delete。
(UNLOCK TABLES:解锁)
2、InnoDB加锁方法
(1)对于update、insert、delete,InnoDB自动给涉及的行添加独占锁(X)。
(2)select ...... from ...... where ...... for update 加独占锁(X),select ..... from ...... where ......lock in share mode 加共享锁(S),普通的select语句不加锁。
3、InnoDB加锁时机
执行SQL时加锁,事务执行完毕释放锁。
Conn1:
begin;
update my_stock set num = num + 2 where id = 2;
select sleep(10);
commit;
Conn2:阻塞
begin;
select * from my_stock where id = 2 lock in share mode;
commit;
(Conn表示mysql连接,按照Conn1,Conn2顺序执行)
注意:Conn1如果先执行sleep,再执行update,Conn2不会阻塞。
4、锁的兼容矩阵

(上图来自MYSQL中文官方文档)
(1)锁添加在行的索引上(后面解释),如果一个事务请求的锁和当前数据的锁冲突,则当前事务获取不到请求的锁,只能等待该行数据的原来的锁被释放。如果不冲突,则该事务可以获得请求的锁。
(2)行锁只能和行锁冲突,表锁只能和表锁冲突。
X和S冲突很好理解,IX和X为什么冲突?难道不能同时修改不同行的数据,只能串行化执行所有事务?
其实不是,这里的冲突是指整表的IX锁 和 整表的X锁冲突。
(3)如果多个事务之间相互等待对方的锁释放,就会造成死锁。如:
Conn1:
begin;
update my_stock set num = num + 1 where id = 3; //T1
select sleep(10);
update my_stock set num = num + 1 where id = 6; //T3
commit;

Conn2:
begin;
update my_stock set num = num + 1 where id = 6;//T2
select sleep(10);
update my_stock set num = num + 1 where id = 3; //T4 Deadlock
commit;

(按照Conn1,Conn2顺序执行)
四、InnoDB如何实现可重复读(RR)
1、为什么需要MVCC
对于一个事务写,另一个事务同时写,造成的覆盖问题(写-写)。
如:更新丢失问题,InnoDB给update、delete、insert涉及的数据行加独占锁(X)即可。
对于一个事务写,另一个事务同时读,造成的错误读问题(写-读)。
如:脏读、可重复度、幻读问题,InnoDB中一个事务已经加了独占锁(X)写数据,另一个事务不能试图加共享锁(S)读数据,如此可以解决脏读和可重复读的问题(不能解决幻读),但会造成阻塞等待,变成串行执行,低性能在大多数情况下是无法接受的。
既然锁无法帮助我们解决写-读问题,那么我们想如果在事务中无论读多少次,所读到的数据,就如定格在事务开始时一样(具体是第一个select执行时),就解决大部分问题。这就是快照读的由来。那么新的问题来了,快照数据存放在哪?产生的时机?查询的方式?
由此,为了解决读的问题,InnoDB引入了MVCC(multi-Version Concurrency Controller)多版本并发控制(只在RC,RR级别奏效)
2、MVCC工作原理
MVCC名字是多版本并发控制,这里的版本指的是快照版本。每一行数据变化时产生一个快照。
(1)undo log,每当数据将要被修改时,先将原值存入undo log再修改。如果修改出现问题,则用于恢复数据。
undo log原本用于事务回滚(原子性),如今刚好可以当做MVCC中所需要的快照数据,其存储结构大致如下:

(2)每行记录增加隐藏字段(按照含义改成了容易理解的字段名称)
row_id:默认的行标识
trx_id:事务标识,表明被哪个事务插入或修改/删除(mysql内部删除会先被标记,然后由purge线程异步删除)
roll_ptr:回滚指针,可以解析获取segment id,page no,offset,指明该行数据上一个版本在undo log的具体位置。
如:哪个undo segment中哪页 undo page 中的那条 undo log。
(3)ReadView
事务中第一个select执行时,会生成一个ReadView,主要用于记录当前哪些事务正在执行。
ReadView包含:(为了便于理解字段名称改了)
trx_ids:当前事务开始时,正在执行的事务(多个)
creator_trx_id:当前事务id
min_trx_id:当前正在执行事务中,最小事务id
max_trx_id:当前正在执行事务中,最大事务id
(4)我们看下MVCC如何利用上面这三个工具,实现快照读,即查看当前事务第一个select语句执行时的快照数据。

a、从数据库表中查询当前数据(多行)
b、根据每行数据的隐藏字段trx_id(当前事务id)和 当前事务的ReadView,判断此行数据是否可见,规则如下:

c、如果不可见,则根据此行数据的隐藏字段roll_ptr 从 undo log中取出上一个版本的快照数据(最近一次修改之前的数据)。
d、将undo log取出的快照数据,重复(2)判断可见性。直至找到可见版本或者roll_ptr = null(最开始的插入版本)。
e、返回结果集中所有行的可见数据,即完成快照读。
3、快照读和当前读
MVCC实现了普通select的快照读,mysql文档中称为持续非锁定读。(绕口)
那么如果我们需要读取当前数据库中最新的值,则称为当前读,如:
select ...... lock in share mode
select ...... for update
当前读并不仅限于select,update也需要基于最新的数据修改,如:
begin;
update my_stock set num = num + 1 where id = 3;
commit;
其中 where id = 3 匹配出的库存不可能是快照库存,否则两个同时开始的事务,会相互覆盖。
类似的还有delete、insert。
综上,所有需要加锁的SQL语句都是当前读。这也是为什么mysql文档又称之为锁定读的原因。
4、又见幻读
很明显MVCC的快照读,解决了脏读和不可重复读的问题,至于幻读,回顾第二部分-3-(4)提出的问题:

如果使用MVCC的快照读,事务1在T3时生成了ReadView,此时没有新增库位,那么在T7时就不会读出事务2在T6提交新增的库位。看样子解决了普通select的幻读问题。
此时有人提出了新的幻读问题:

这类问题根本原因在于事务执行过程中,update、delete使用了当前读(锁定读),修改了数据库中当前新增的行,和幻读问题本质一样(读到了数据库中当前新增的行),因此也将它归于幻读问题。
(update使用当前读,一般看来无错,但基于事务的隔离原则,当前事务不应当影响到事务之外的数据。严格来说,这也算是隔离性问题。)
综上,独占锁解决了更新丢失的问题,MVCC的快照读解决了脏读,不可重复读,和幻读一半的问题。而另外一半当前读的问题,终归还是得回到锁上。
五、加锁算法
第三部分介绍了锁的分类,那么SQL语句如何给行记录加锁呢,这是锁的算法,主要有三种。
1、Record Lock:记录锁,又称为行锁。
innoDB记录锁加在索引上,要了解记录锁的加锁算法,需要先大致理解InnoDB索引(容我跑跑题,很快回来):
(1)InnoDB索引结构
从有序列表到B+树,查找和修改数据的效率不断优化:

innoDB索引结构使用B+Tree,相比B-Tree不同之处在于:
a、B-Tree 每个节点内容分为关键字和数据两部分,而B+Tree非叶子节点只包含key,只有叶子节点才包含data。
如此一来,B+Tree在非叶子节点中可以包含更多的索引,从而更加减少树的高度,即磁盘的访问次数。
b、所有叶子节点有独立的一个指针串成链表,如果需要全表扫描,B-Tree需要遍历整树,B+Tree只需要遍历叶子节点链表,速度提高。
my_stock表具体索引结构如下,这里有两种索引:主键索引和唯一索引

(2)访问主键索引获取记录锁
Conn1:
begin;
select id, location, num from my_stock where id = 1 for update;
select sleep(10);
commit;
Conn2:阻塞
begin;
update my_stock set num = num + 1 where id = 1;
commit;
(按照Conn1,Conn2顺序执行)
(3)访问唯一索引获取记录锁
Conn1:
begin;
select id, location, num from my_stock where location = 'a' for update;
select sleep(10);
commit;
Conn2:阻塞
begin;
update my_stock set num = num + 1 where id = 1;
commit;
(按照Conn1,Conn2顺序执行)
Conn1和Conn2走的不同索引为什么会阻塞?要解释这个,需要引入新的概念,聚集索引和非聚集索引:
a、按照数据的物理存储顺序建的索引就是聚集索引,物理数据只有一份,存储顺序也就只有一个,因此一张表只能有一个聚集索引,反之,不影响物理存储顺序的就是非聚集索引。
b、聚集索引叶子节点中存放数据,而非聚集索引,除了key,只存放主键,也就是说,如果按照非聚集索引查找数据,第一次只能找到主键,需要第二次根据聚集索引才能找到具体数据。
主键索引就是聚集索引,而这里的唯一索引就是非聚集索引,又称辅助索引。
既然非聚集索引需要二次查找,因此也会锁在两个索引上,因此虽然访问不同的索引,一样会阻塞。

(4)不访问索引获取记录锁
Conn1:
begin;
select id, location, num from my_stock where type = 2 for update;
select sleep(10);
commit;
Conn2:阻塞
begin;
update my_stock set num = num + 1 where id = 1;
commit;
(按照Conn1,Conn2顺序执行)
Conn1中type列没有索引(my_stock只有主键索引和location列的唯一索引),此时获取独占锁,升级为表锁,会锁住整张表。Conn2中就算访问的是聚集索引并且id=1的库位type=1,一样会被锁。
综上,InnoDB锁加在列的索引上,如果该列没有索引或者不走索引(如:<>,like '%xxxx'等),则会全表扫描,行锁升级为表锁。
2、Gap Lock(间隙锁) 和 Next-key Lock(临键锁)
my_stock表,有主见索引(id),唯一索引(location),现在对type字段增加非唯一索引。
alter table my_stock add index idx_type (type) ;
my_stock表数据修改为:

(1)什么是间隙?
type字段的是非唯一索引结构中(B+Tree),叶子节点的key值有:1,3,6,13。
所谓间隙就是叶子节点之间的区间:(负无穷,1),(1,3),(3,6),(6,13),(13,正无穷) 。
那么间隙锁指的是对这些区间上锁,而不是单个值(记录锁),假设锁住的是(6,13)这个区间,就无法新增type=7的数据。
(2)Next-key Lock(临键锁)
临键锁结合了记录锁和临键锁,是InnoDB在RR级别下默认的加锁方式。目的是解决彻底幻读,既要锁住索引结构中的叶子节点,也要锁住叶子节点左右的间隙。Next-key Lock加锁的最小单位是一个左开右闭的区间(最后一个除外):(负无穷,1],(1,3],(3,6],(6,13],(13,正无穷)。
3、Next-key Lock(临键锁)加锁步骤
(1)访问非唯一索引加Next-key Lock
Conn1:
begin;
update my_stock set status = 1 where type = 3;
select sleep(10);
commit;
Conn2:阻塞
begin;
insert into my_stock(location, type, num, status) values('a2', 2, 10, 0);
commit;
Conn3:不阻塞
begin;
insert into my_stock(location, type, num, status) values('a3', 6, 10, 0);
commit;
(按照Conn1,Conn2,Conn3顺序执行)
加锁步骤:
a、在索引树中找到第一个正确的叶子节点(type = 3),如果找不到,则降为间隙锁,锁住对应间隙锁区间(左开右开)结束。
b、如果找到,判断该节点的临键锁加锁区间是(1,3],从此开始向右遍历加锁(1,3],(3,6],(6,13].....,直到不满足条件的叶子节点6为止,最后一个区间(3,6]降为间隙锁(3,6),最终加锁区间为(1,6)。
除了精确匹配(=),还有范围匹配的情况,也满足上面的加锁步骤,测试数据如下:

其中灰色部分是非唯一索引在范围匹配时的补充规则(不知原因?),当where条件中要判断的key存在对应记录时,无论是否满足条件,该key都不能插入,但能够修改!!
(2)访问唯一索引/主键索引加Next-key Lock
Conn1:
begin;
update my_stock set status = 1 where location = 'C';
select sleep(10);
commit;
Conn2:不阻塞
begin;
update my_stock set status = 1 where location = 'G';
commit;
(按照Conn1,Conn2顺序执行)
加锁步骤:
a、在索引树中找到第一个正确的叶子节点(location = 'C'),如果找不到,降为间隙锁,锁住对应间隙锁区间(左开右开)结束。
b、如果找到,分两种情况,第一种精确匹配(=),降为记录锁。第二种范围匹配,同“访问非唯一索引加Next-key Lock--加锁步骤b”。
测试数据如下:

综上,再回到第四部分末尾,未能解决的半个幻读问题-当前读,我们事先加锁解决:

Conn1:
begin;
select * from my_stock where id > 3 for update;
select sleep(5);
update my_stock set status=1 where id > 3;
commit;
Conn2:阻塞
begin;
insert into my_stock(location, type, num, status) values('a100', 1, 10, 0);
commit;
(按照Conn1,Conn2顺序执行)
4、插入相关的锁
(1)Insert Intention Locks插入意向锁
如果insert和update/detele一样获取间隙锁,那么并发插入能力将大大降低,所有插入都将变为串行,于是有了一种特殊的间隙锁-Insert Intention Locks插入意向锁,它的目的在于同一区间内不同数值(主键/唯一索引)插入不阻塞。insert语句先获取插入意向锁,再获取独占锁。
(2)自增锁
如果表中有自增字段,在插入数据时会获取自增锁。目的保证获取的自增id不重复。有三种模式:
a、innodb_autoinc_lock_mode = 0 (“traditional” lock mode)
先获取AUTO_INC锁(表锁),再获取最大AUTO_INCREMENT值并加1,执行插入操作,释放锁。
b、innodb_autoinc_lock_mode = 1 (“consecutive” lock mode)
这是mysql8.0以前的默认模式,对于“批量插入”(无法预知要插入的行数,如insert.....select.....)同样需要获取表级锁(过程同上)。但对于“简单插入”(可以预知要插入的行数),在互斥锁(轻量级锁)的控制下获取自增量,分配完立即释放锁,不必等到插入语句执行完。但如果有其他“批量插入”锁表,“简单插入”也会阻塞。
c、innodb_autoinc_lock_mode = 2 (“interleaved” lock mode)
这是mysql8.0的默认模式,这种模式下,无论“批量操作”还是“简单操作”都不获取AUTO_INC锁(表锁),全都通过互斥锁(轻量级锁)获取自增量。所有互斥锁都是串行获取,因此虽然最终获取自增量还是单调递增,但对于每个批量SQL本身获取到的自增量可能不连续。如:


未完待续!
本文详细探讨了并发控制、数据库事务及其特性,并深入讲解了MySQL中的锁机制、MVCC以及InnoDB的事务隔离级别。通过对并发问题、并发控制、事务的ACID属性、锁的种类及加锁算法的分析,揭示了分布式事务处理的复杂性和解决方案,特别关注了如何在性能和一致性间取得平衡。
8万+

被折叠的 条评论
为什么被折叠?



