在关系数据库中,一个事务可以是一条SQL语句,一组SQL语句或整个程序。
数据库事务的特征
- 原子性:一个事务是一个不可分割的工作单位,事务中包括的诸操作要么都做,要么都不做
- 一致性:事务必须是使用数据库从一个一致性状态变到另一个一致性状态。
- 隔离性:一个事务的执行不能被其他事务干扰。即一个事务内部操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能相互干扰。
- 持久性:一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。
事务的传播性
- @Transactional(propagation=Propagation.REQUIRED):默认的spring事务传播级别,使用该级别的特点是,如果上下文中已经存在事务,那么就加入到事务中执行,如果当前上下文中不存在事务,则新建事务执行,所以这个级别通常能满足处理大多数的业务场景。
- @Transactional(propagation=PROPAGATION.SUPPORTS):从字面意思就知道,supports(支持),该传播级别的特点是,如果上下文存在事务,则支持当前事务,加入到事务执行,如果没有事务,则使用非事务的方式执行。所以说,并非所有的包在transactionTemplate.execute中的代码都会有事务支持。这个通常是用来处理那些并非原子性的非核心业务逻辑操作,应用场景较少。
- @Transactional(propagation=PROPAGATION.MANDATORY):该级别的事务要求上下文中必须要存在事务,否则就会抛出异常!配置该方式的传播级别是有效的控制上下文调用代码遗漏添加事务控制的保证手段。比如一段代码不能单独被调用执行,但是一旦被调用,就必须有事务包含的情况,就可以使用这个传播级别。
-
@Transactional(propagation=PROPAGATION.REQUIRES_NEW):从字面即可知道,每次都要一个新的事务,该传播级别的特点是,每次都会新建一个事务,并且同时将上下文中的事务挂起,当新建事务执行完成以后,上下文事务再恢复执行。
这是一个很有用的传播级别,举一个应用场景:现在有一个发送100个红包的操作,在发送之前,要做一些系统的初始化、验证、数据记录操作,然后发送100封红包,然后再记录发送日志,发送日志要求100%的准确,如果日志不准确,那么整个父事务逻辑需要回滚。
怎么处理整个业务需求呢?就是通过这个PROPAGATION.REQUIRES_NEW 级别的事务传播控制就可以完成。发送红包的子事务不会直接影响到父事务的提交和回滚。 -
@Transactional(propagation=PROPAGATION.NOT_SUPPORTED) :这个也可以从字面得知,not supported(不支持),当前级别的特点是,如果上下文中存在事务,
则挂起事务,执行当前逻辑,结束后恢复上下文的事务。
这个级别有什么好处?可以帮助你将事务极可能的缩小。我们知道一个事务越大,它存在的风险也就越多。所以在处理事务的过程中,要保证尽可能的缩小范围。比如一段代码,是每次逻辑操作都必须调用的,比如循环1000次的某个非核心业务逻辑操作。这样的代码如果包在事务中,势必造成事务太大,导致出现一些难以考虑周全的异常情况。所以这个事务这个级别的传播级别就派上用场了,用当前级别的事务模板抱起来就可以了。 - @Transactional(propagation=PROPAGATION.NEVER):该事务更严格,上面一个事务传播级别只是不支持而已,有事务就挂起,而PROPAGATION_NEVER传播级别要求上下文中不能存在事务,一旦有事务,就抛出runtime异常,强制停止执行!
- @Transactional(propagation=PROPAGATION.NESTED):字面也可知道,nested,嵌套级别事务。该传播级别特征是,如果上下文中存在事务,则嵌套事务执行,如果不存在事务,则新建事务。
脏读、不可重复读、幻读
- 脏读:一个事务还未提交,另外一个事务访问此事务修改的数据,并使用,读取了事务中间状态数据。
- 不可重复读:一个事务读取同一条记录两次,得到的结果不一致,由于在第二次读取之间另外一个事务对此行数据进行了修改。
- 幻读:一个事务读取了2次,得到的记录条数不一致,由于第二次读取之间另外一个事务对数据进行了增删。
事务的隔离级别
读未提交(Read Uncommitted):允许脏读取,但不允许更新丢失。如果一个事务已经开始写数据,则另外一个事务则不允许同时进行写操作,但允许其他事务读此行数据。该隔离级别可以通过“排他写锁”实现。
读提交(Read Committed):允许不可重复读取,但不允许脏读取。这可以通过“瞬间共享读锁”和“排他写锁”实现。读取数据的事务允许其他事务继续访问该行数据,但是未提交的写事务将会禁止其他事务访问该行。
可重复读取(Repeatable Read):禁止不可重复读取和脏读取,但是有时可能出现幻读数据。这可以通过“共享读锁”和“排他写锁”实现。读取数据的事务将会禁止写事务(但允许读事务),写事务则禁止任何其他事务。
序列化(Serializable):提供严格的事务隔离。它要求事务序列化执行,事务只能一个接着一个地执行,不能并发执行。仅仅通过“行级锁”是无法实现事务序列化的,必须通过其他机制保证新插入的数据不会被刚执行查询操作的事务访问到。
Dirty Reads(脏读) | non-repeatable reads(不可重复读) | phantom reads(幻读) | |
---|---|---|---|
Read Uncommitted(读未提交) | 会 | 会 | 会 |
Read Committed(读已提交) | 不会 | 会 | 会 |
Repeatable Read(可重复读) | 不会 | 不会 | 会 |
Serializable(可串行化) | 不会 | 不会 | 不会 |
@Transactional注解中常用参数说明
参数名称 | 功能描述 |
---|---|
readOnly | 该属性用于设置当前事务是否为只读事务,设置为true表示只读,false则表示可读写,默认值为false。例如:@Transactional(readOnly=true) |
rollbackFor | 该属性用于设置需要进行回滚的异常类数组,当方法中抛出指定异常数组中的异常时,则进行事务回滚。例如: 指定单一异常类:@Transactional(rollbackFor=RuntimeException.class) 指定多个异常类:@Transactional(rollbackFor={RuntimeException.class, Exception.class}) |
rollbackForClassName | 该属性用于设置需要进行回滚的异常类名称数组,当方法中抛出指定异常名称数组中的异常时,则进行事务回滚。例如: 指定单一异常类名称:@Transactional(rollbackForClassName="RuntimeException") 指定多个异常类名称:@Transactional(rollbackForClassName={"RuntimeException","Exception"}) |
noRollbackFor | 该属性用于设置不需要进行回滚的异常类数组,当方法中抛出指定异常数组中的异常时,不进行事务回滚。例如: 指定单一异常类:@Transactional(noRollbackFor=RuntimeException.class) 指定多个异常类:@Transactional(noRollbackFor={RuntimeException.class, Exception.class}) |
noRollbackForClassName | 该属性用于设置不需要进行回滚的异常类名称数组,当方法中抛出指定异常名称数组中的异常时,不进行事务回滚。例如: 指定单一异常类名称:@Transactional(noRollbackForClassName="RuntimeException") 指定多个异常类名称: @Transactional(noRollbackForClassName={"RuntimeException","Exception"}) |
propagation | 该属性用于设置事务的传播行为,具体取值可参考表6-7。 例如:@Transactional(propagation=Propagation.NOT_SUPPORTED,readOnly=true) |
isolation | 该属性用于设置底层数据库的事务隔离级别,事务隔离级别用于处理多事务并发的情况,通常使用数据库的默认隔离级别即可,基本不需要进行设置 |
timeout | 该属性用于设置事务的超时秒数,默认值为-1表示永不超时 |
锁
1.锁简介
数据库中的锁是指一种软件机制,用来控制防止某个用户(进程会话)在已经占用了某种数据资源时,其他用户做出影响本用户数据操作或导致数据非完整性和非一致性问题发生的手段。
2.锁的级别
按照锁级别划分,锁可分为共享锁,排它锁。
A、共享锁(读锁)
针对同一块数据,多个读操作可以同时进行而不会互相影响。
共享锁只针对UPDATE时候加锁,在未对UPDATE操作提交之前,其他事务只能够获取最新的记录但不能够UPDATE操作。
B、排它锁(写锁)
当前写操作没有完成前,阻断其他写锁和读锁。
3、锁的粒度
按锁的粒度划分,锁可以分为表级锁,行级锁,页级索。
A、行级锁
开销大,加锁慢,会出现死锁,锁定力度最小,发生锁冲突的概率最低,并发度高。
B、表级锁
开销小,加锁快,不会出现死锁,锁定力度大,发生锁冲突的概率高,并发度低。
C、页面锁
开销和加锁时间介于表锁和行锁之间,会出现死锁,锁定力度介于表和行级锁之间,并发度一般。
4、MySQL存储引擎和锁机制
MySQL的锁机制比较简单,最显著的特点是不同的存储引擎支持不同的锁机制。
MyISAM和MEMORY存储引擎采用表级锁。
InnoDB支持行级锁、表级锁、默认情况采用行级锁。
1.表级锁简介
MyISAM存储引擎和InnoDB存储引擎都支持表级锁。
MyISAM存储引擎支持表级锁,为了保证数据的一致性,更改数据时,防止其他人更改数据,可以人工添加表级锁。可以使用命令对数据库的表加锁,使用命令对数据库的表解锁。
给表加锁的命令Lock Tables,给表解锁的命令Unlock Tables
MyISAM引擎在用户读数据自动加READ锁,更改数据自动加WRITE锁,使用Lock Tables和Unlock Tables显示加锁和解锁。
2.添加表级读锁
打开会话1,创建表
CREATE TABLE tc(
id INT,
name VARCHAR(10),
age INT
)ENGINE=MyISAM DEFAULT CHARSET=UTF8;
插入两条数据:
insert into tc values(1,'孙悟空',500);
insert into tc values(1,'猪八戒',500);
对表加READ锁
lock tables tc read;
加锁后只可以查询已加锁的表,
select * from tc;
查询没有加锁的表将失败
select * from ta;
打开会话2,对已经加锁的表进行查询,成功。
select * from tc;
对加锁的表tc进行更新操作,将失败
update tc set age=100 where id=1;
会话1中使用LOCK TABLE命令给表加了读锁,会话1可以查询锁定表中的记录,但更新或访问其他表都会提示错误;会话2可以查询表中的记录,但更新就会出现锁等待。
在会话1中进行表解锁,会话2的更新操作成功。
unlock tables;
在会话1,再次锁定表tc,后面带local参数。
local tables tc read local;
Local参数允许在表尾并发插入,只锁定表中当前记录,其他会话可以插入新的记录
在会话2插入一条记录
insert into tc values(2,'唐僧',20);
在会话1查看tc表的记录,误插入记录
select * from tc;
3.设置表级索并发性
READ锁是共享锁,不影响其他会话的读取,但不能更新已经加READ锁的数据。MyISAM表的读写是串行的,但是总体而言的,在一定条件下,MyISAM表也支持查询和插入操作的并发进行。
MyISAM存储引擎有一个系统变量concurrent_insert,用以控制其并发插入的行为,其值分别可以为0、1或2。
0:不允许并发操作
1:如果MyISAM表中没有空洞(即表的中间没有被删除的行),MyISAM允许在一个进程读表的同时,另一个进程从表尾插入记录,是MySQL的默认设置。
2:无论MyISAM表中有没有空洞,都允许在表尾并发插入记录。
在MySQL配置文件添加,concurrent_insert=2,重启mySQL服务设置生效。
4、验证表级锁的并发性
设置concurrent_insert为0
在会话1对表tc加锁
lock tables tc read local;
在会话2插入一条记录,此时tc表被锁定,进入等待
insert into tc values(4,'沙悟净',30);
在会话1解锁表tc,此时会话2插入成功
unlock tables;
设置concurrent_insert为1
在会话1删除ID为3的记录
delete from tc where id=3;
在会话1对表tc加锁
lock tables tc read local;
在会话2插入一条记录,此时tc表被锁定,并且表中有空洞,进入等待
insert into tc values(5,'白骨精',1000);
在会话1解锁tc,此时会话2插入成功,此时表中已经没有空洞
unlock tables;
设置concurrent_insert为2
在会话1删除ID为5的距离,创造一个空洞
delete from tc where id=5;
在会话1对表tc加锁
lock tables tc read local;
在会话2插入一条记录,插入成功,支持无条件并发插入
insert into tc values(7,'蜘蛛精',1000);
在会话1解锁表tc
unlock tables;
5.添加表级写锁
添加表级写锁语法如下:
LOCK TABLES tablename WRITE;
不允许其他会话查询、修改、插入记录。
1、行级锁简介
InnoDB存储引擎实现的是基于多版本的并发控制协议——MVCC(Multi-Version Concurrency Control)。
MVCC的优点是读不加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能。
在MVCC并发控制中,读操作可以分成两类:快照读(snapshot read)与当前读(current read)。
快照读,读取的是记录的可见版本(有可能是历史版本),不用加锁。
当前度,读取的是最新版本,并且当前读返回的记录都会加上锁,保证其他事务不会再并发修改。事务加锁,是针对所操作的行,对其他行不进行加锁处理。
快照度:简单的SELECT操作,属于快照读,不加锁。
SELECT * FROM TABLE WHERE ? ;
当前读:特殊的读操作,INSERT/UPDATE/DELETE,属于当前读,需要加锁。
SELECT * FROM TABLE WHERE ? LOCK IN SHARE MODE;
SELECT * FROM TABLE WHERE ? FOR UPDATE;
INSERT INTO TABLE VALUES (...);
UPDATE TABLE SET ? WHERE ?;
DELETE FROM TABLE WHERE ?;
以上SQL语句属于当前读,读取记录为最新版本。并且读取之后,还需要保证其他并发事务不能修改当前记录,对读取记录加锁。其中,除了第一条语句,对读取记录加S锁(共享锁)外,其他的操作,都加的是X锁(排它锁)。
2.验证快照读
打开会话1,创建一个表,含ID、姓名、年龄
CREATE TABLE td
(
id INT,
name VARCHAR(10),
age INT
)ENGINE=innoDB DEFAULT CHARSET = utf8;
在插入两条记录
INSERT INTO td VALUES (1,'hello',500);
INSERT INTO td VALUES (2,'world',100);
在会话1开始事务
START TRANSACTION;
在会话1查询ID为1的记录信息
SELECT * FROM td WHERE id=1;
打开会话2,更新ID为1的age为1000
UPDATE td SET age=1000 WHERE id=1;
在会话2查看ID为1的age已经更新为1000。
SELECT * FROM td WHERE id=1;
在会话1查看ID为1的age,仍然为500。
SELECT * FROM td WHERE id=1;
在会话1提交事务
COMMIT;
在会话1查看ID为1的age,已经为1000。
3.验证当前读
在会话1开始事务
START TRANSACTION;
在会话1给SELECT语句添加共享锁。
SELECT * FROM td WHERE id=1 LOCK IN SHARE MODE;
在会话2,更新ID为1的age的值为1000,进入锁等待
UPDATE td SET age=1000 WHERE id=1;
在会话1提交事务
COMMIT;
会话2的更新操作成功。
4、验证事务给记录加锁
在会话1开始事务
START TRANSACTION;
在会话1更新ID为1的age的值为500。
UPDATE td SET age=500 WHERE id=1;
在会话2开始事务
START TRANSACTION;
在会话2更新ID为2的age的值为1000,此时进入锁等待
UPDATE td SET age=1000 WHERE id=2;
td表没有指定主键,事务不支持行级锁。会话1的事务给整张表加了锁。
在会话1提交事务,此时会话2的修改成功
COMMIT;
在会话2提交事务,解除对表的锁定
COMMIT;
在会话1,给表的ID增加主键
alter table td add primary key(id);
在会话1开始事务
start transaction;
在会话1更新ID为1的age的值为5000
UPDATE td SET age=5000 WHERE id=1;
在会话2上开始事务
START TRANSACTOIN;
在会话2上修改ID为2的get的值为10000,更新成功,说明会话1只锁定了ID为1的行。
UPDATE td SET age=10000 WHERE id=2;
在会话2上更新ID是1的age值为100,出现等待。因为会话1给ID为1的行添加了独占锁。
UPDATE td SET age=100 WHERE id=1;
在会话1提交事务
COMMIT;
在会话2提交事务
COMMIT;
在会话1查询,会话1和会话2对age列的修改都生效
SELECT * FROM td;
5、死锁的产生
A事务添加共享锁后,B事务也可以添加共享锁。A事务UPDATE锁定记录,处于等待中,于此同时B事务也UPDATE更新锁定的记录,就产生死锁。
在会话1开始事务
START TRANSACTION;
在会话1查询ID是1的记录,并添加共享锁。
SELECT * FROM td WHERE id LOCK IN SHARE MODE;
在会话2开始事务
START TRANSACTION;
在会话2查询ID是1的记录,并添加共享锁。
SELECT * FROM td WERE id=1 LOCK IN SHARE MODE;
在会话1更新ID为1的age值为,等待会话2释放共享锁
UPDATE td SET age=200 WHERE id=1;
在会话2更新ID为1的age值为,会话2发现死锁,回滚事务。
UPDATE td SET age=200 WHERE id=1;
在会话1提交事务
COMMIT;
乐观锁与悲观锁
在关系型数据库管理系统里,乐观并发控制(又名”乐观锁“,Optimistic Concurrency Control,缩写”OCC“)是一种并发控制的方法。他假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新前会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。
乐观锁(Optimistic Locking)相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。
相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制,一般的实现乐观锁的方式就是记录数据版本。
数据版本,为数据增加的一个版本标识。当读取数据时,将版本标识的值一同读出,数据每更新一次,同时对版本标识进行更新。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的版本标识进行比对,如果数据库表当前版本号与第一次取出来的版本标识值相等,则予以更新,否则认为是过期数据。
实现数据版本有两种方式,第一种是使用版本号,第二种是使用时间戳。
使用版本号实现乐观锁:
使用版本号时,可以在数据初始化时指定一个版本号,每次对数据的更新操作都对版本号执行+1操作。并判断当前版本号是不是该数据的最新的版本号。
1.查询出商品信息
select (status,status,version) from t_goods where id=#{id}
2.根据商品信息生成订单
3.修改商品status为2
update t_goods
set status=2,version=version+1
where id=#{id} and version=#{version};
优点与不足:
乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。但如果直接简单这么做,还是有可能会遇到不可预期的结果,例如两个事务都读取了数据库的某一行,经过修改以后写回数据库,这时就遇到了问题。
在关系数据库管理系统里,悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作对某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。
悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护的成本要低于回滚事务的成本的环境。
悲观锁,正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度(悲观),因此,在整个数据处理过程中,数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)
在数据库中,悲观锁的流程如下:
在对任意记录进行修改前,先尝试为该记录加上排它锁(exclusive locking)。
如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛异常。具体响应方式由开发者根据实际需要决定。
如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁。
其间如果有其他对记录做修改或加排它锁的操作,都会等待我们解锁或直接抛出异常。
MySQL InnoDB中使用悲观锁:
要使用悲观锁,我们必须关闭MySQL数据库的自动提交属性,因为MySQL默认使用auto commit模式,也就是说,当你执行一个更新操作后,MySQL会立刻进行提交。set autocommit=0;
//0.开始事务
begin;/begin work;/start transaction;(三者选一就可以)
//1.查询出商品信息
SELECT status FROM t_goods WHERE id=1 FOR UPDATE;
//2.根据商品信息生成订单
INSERT INTO t_orders (id,goods_id) VALUES (NULL,1);
//3.修改商品status为2
UPDATE t_goods SET status=2;
//4.提交事务
commit;//commit work;
上面的查询语句中,我们使用了SELECT...FOR UPDATE
的方式,这样就通过开启排它锁的方式实现了悲观锁。此时在t_goods表中,id为1的那条数据就被我们锁定了,其他的事务必须等本次事务提交之后才能执行。这样我们可以保证当前的数据不会被其他事务修改。
上面我们提到,使用SELECT...FOR UPDATE
会把数据给锁住,不过我们需要注意一些锁的级别,MySQL InnoDB默认行级锁。行级锁都是基于索引的,如果一条SQL语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住,这点需要注意。
优点与不足:
悲观并发控制实际上是“先取锁再访问”的保所策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加死锁的机会;另外,在只读型事务处理中由于不会产生冲突,也没必要使用锁,这样做只能增加系统负载;还有会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。