事务与锁
事务
初学的时候,感觉事务的四大特性就那么回事,不就是一堆事要么完成,要么全部失败吗。还有经常说的脏读,幻读,不可重复读根本无法理解,就是那个存款取款的例子,我修改了数据,对方看到我修改的数据,这不很正常吗。现在看来,当时根本就不知道并发是什么鬼,更何谈并发事物了。
然后给你来一堆名词,共享锁,排它锁,悲观锁,乐观锁...... 想想就觉得那时候能记下来已经是奇迹了。
spring 还给事务弄了一个传播机制的家伙,spring 事务传播机制可以看这篇文章 。 本文应该来说是对初学者的福音,有一定经验的人看的话应该也会有收获。
事务的四大特性acid
这个是刚入门面试的时候必问一个面试题,刚入行的时候我是硬生生背下来的。
- 原子性(atomicity) 一件事情的所有步骤要么全部成功,要么全部失败,不存在中间状态。
- 一致性(consistency) 事务执行的结果必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的。
-
隔离性(isolation) 两个事务之间是隔离程度,具体的隔离程度由隔离级别决定,隔离级别有
- 读未提交的 (read-uncommitted)
- 读提交的 (read-committed)
- 可重复读 (repeatable-read)
- 串行 (serializable)
- 持久性 (durability) 一个事务提交后,数据库状态就永远的发生改变,不会因为数据库宕机而让提交不生效。
一个事务和并发事务
事务指的是从开始事务->执行操作->提交/回滚 整个过程,在程序中使用一个连接对应一个事务
-- sql 中的事务 start transaction; select * from question; commit ;
// 最原始的 jdbc 事务 connection connection = 获取数据库连接; try{ connection.setautocommit(false); // todo something connection.commit(); }catch(exception e){log(e); connection.rollback(); }finally{ try{connection.close()}catch(exception e){log(e);}; }
并发事务是指两个事务一同开始执行,如果两个事务操作的数据之间有交集,则很有可能产生冲突。这时怎么办呢,其实这也是 的一种,在应用程序中,我们解决这类问题的关键是加锁,在数据库的实现也是一样,但在数据库中需要考虑更多。常见的需要考虑的问题有(下面说的我和人都是指一个会话)
- 对整张表数据加锁还是对当前操作的数据行加锁,这时有表锁和行锁,myisam 引擎只支持表锁,而 innodb 支持行锁和表锁
- 如果数据量庞大,比如选到了百万数据,千万数据,不可能一次性全部加锁, 会很影响性能,innodb 是逐条加锁的
- 数据库的操作其实有很大一部分是查询操作,如果锁住数据,任何人都不让进的话,性能也会很低下,所以会有读锁和写锁,也叫共享锁和排它锁
- 根据检测冲突的时间不同,可以在一开始就把数据锁住,直到我使用完,还有就是在真正操作数据的时候才去锁住,就是悲观锁和乐观锁
- 就算是让别人可以读数据,在两个事务也可能互相影响,比如脏读。
事务的隔离级别及会带来的问题
看过网上的大部分文章,基本都是一个表格来演示两个事务的并发,有的根本就是直接抄的,不知道那作者真的懂了没,其实我们是可以用客户端来模拟两个事务并发的情况的,打开两个 session ,让两个事务互相穿插。
下面的演示都是基于 mysql5.7
版本,查询事务隔离级别和修改隔离级别语句
-- 查看事务隔离级别 select @@tx_isolation; -- 修改当前 session 事务隔离级别 set session transaction isolation level read uncommitted; set session transaction isolation level read committed ; set session transaction isolation level repeatable read ; set session transaction isolation level serializable; -- 开启事务提交和回滚 start transaction; select * from question; commit ;rollback;
准备数据表,暂时先使用 innodb 引擎
create table `account` ( `id` int(11) not null auto_increment, `name` varchar(64) default null, `balance` decimal(10,2) default null, primary key (`id`) ) engine=innodb default charset=utf8; insert into `test`.`account` (`id`, `name`, `balance`) values ('1', 'sanri', '100.00'); insert into `test`.`account` (`id`, `name`, `balance`) values ('2', '9420', '100.00');
脏读
打开两个 session ,设置隔离级别为 read uncommitted
时间(相对时间) | 事务a | 事务b |
---|---|---|
1 | start transaction | |
2 | start transaction | |
3 | update account set balance = balance - 20 where id = 1; | |
4 | select * from account where id = 1 -- 80 | |
5 | rollback | |
6 | commit |
这个会有什么问题呢,网上说可能事务 b 可能会去存款,但我试过了,事务b 在这时候存款会被阻塞,因为事务a 在更新的时候已经加了排它锁,只有等事务a 提交或回滚事务b 才能执行。
它真正的问题出在,如果程序来读到了这个 80 块钱返回到了第三方的系统,而事务a 回滚了,这时候问题就大了,它主要体现在读不一致。或者用户看到我自己取款失败了钱没取到但为什么我帐户余额少了的不一致问题。
解决脏读是设置隔离级别为读提交的数据 read committed
不可重复读
打开两个 session 设置隔离级别为 read committed
时间(相对时间) | 事务a | 事务b |
---|---|---|
1 | start transaction | |
2 | start transaction | |
3 | select * from account where id = 1 -- 100 | |
4 | update account set balance = balance - 20 where id = 1; | |
5 | commit; | |
6 | select * from account where id = 1 -- 80 | |
7 | commit; |
两次同样条件的查询,结果确不一致。刚开始的时候一定会觉得,这没问题啊,事务b 做了更新操作,我这少 20 块钱变 80 有问题吗?
其实还是有问题的,主要出现在复杂的业务逻辑查了两次相同的数据集(在程序员看来是相同数据集),又比如 mapper 中有两个方法名不一样,但做了同样功能的 sql 语句 (这个在代码多次接手后会出现),再或者在一个 sql 块中有两个更新语句使用了同一个查询,刚好数据被改了
begin update xxx inner join (select balance from account where id = 1) set xxx = xxoo; update xoxo inner join (select balance from account where id = 1) set xxbb = mmcc; end
解决办法是设置隔离级别为可重复读 repeatable read
或者显示的加上共享锁 (select * from account where id = 1 lock in share mode;
),但这会阻塞事务b,因为共享锁是一种悲观锁
mysql 的多事务并发版本控制
使用可重复读之后会发现,发现查询和更新并没有互相阻塞,推测 mysql 应该不是简单的使用共享锁来实现可重复读, 使用共享锁会使性能特别低下,因为一个查询也要加锁。
mysql 的可重复读使用的是 mvcc 机制,当一个事务开始后,select 查询多次都会和第一次查询的结果一致,这种查询称为快照读,与之相对的是当前读,对于加锁语句,或更新语句都是使用当前读 ,比如
-- 这里的更新会使用最新的 balance 来更新,同时会加上排它锁,不用担心最终结果是错的 update account set balance = balance - 20 where id = 1
幻读
幻读相比较于不可重复读来说有点类似,都是同一个查询条件查到了不一致的结果,但幻读更注重于添加或删除数据,而不可重复读注重于修改数据,产生的影响也是和不可重复读类似的。
more actions时间(相对时间) | 事务a | 事务b |
---|---|---|
1 | start transaction | |
2 | start transaction | |
3 | select * from account | |
4 | delete from account where id = 1 | |
5 | commit; | |
6 | select * from account -- 少了一行 |
幻读的解决办法一种就是修改隔离级别为 serializable
,或者锁定整张表,但不管是串行化执行事务或锁定整张表,都是同一时刻只有一个事务在执行的意思,也即没有并发事务了,性能会特别低下。
mysql 有一个 gap 锁的机制,它在 repeatable read
隔离级别下防止了幻读,也没有锁整张表,它取了一个平衡值,锁定索引间的间隙。具体查看这篇文章或查看官网说明
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
read uncommitted | 允许 | 允许 | 允许 |
read committed | 不允许 | 允许 | 允许 |
repeatable read | 不允许 | 不允许 | 允许 |
serializable | 不允许 | 不允许 | 不允许 |
事务和程序锁的冲突问题
这个问题是我在工作中遇到的,先来看一段代码
@transactional public synchronized void insertxx(xx){ long maxno = xxmapper.selectmaxno(); return maxno + 1; xxentity xx = new xxentity(maxno,'x','xx'); xxmapper.insert(xx); }
初一看这个方法,没啥问题,获取最大编号并添加进数据库,为防止并发导致编号重复加了同步锁。
但在实际生产环境中这个方法出问题了,出现了相同的编号导致程序出错。
其实这里的原因是因为锁并没有完整的包含事务,事务是 spring 用 aop 实现的,在代理方法中去调用了目标方法,但是锁是加在了目标方法上,事务在锁释放后才提交,又因为隔离级别使用的是可重复读,读不到未提交的数据,所以如果在事务提交的过程中,有线程执行此方法,是没有上锁的,进来查到的编号还是原来的编号,解决办法有两种 ,一种是把锁上移,使用 aop 来实现锁,一种是再加一个方法不加事务,并包裹本方法。
方法一:
@autowized private xxservice xxservice; @transactional(propagation = propagation.not_supported) public synchronized void proxyxx(){ xxservice.insertxx(); } @transactional public void insertxx(xx){ long maxno = xxmapper.selectmaxno(); return maxno + 1; xxentity xx = new xxentity(maxno,'x','xx'); xxmapper.insert(xx); }
这里必须另启一个类,因为 spring aop 是对类生效的
方法二:
定义一个切面,比如用注解来实现切点,然后加锁
@lock @transactional public void insertxx(xx){ long maxno = xxmapper.selectmaxno(); return maxno + 1; xxentity xx = new xxentity(maxno,'x','xx'); xxmapper.insert(xx); }
myisam 和 innodb 及行级锁的条件
都知道 myisam 只支持表锁,myisam 能支持行锁和表锁,但 innodb 使用行锁也是有条件的,就是查询列必须是索引的,否则将使用表锁
还有一个特点就是 innodb 是支持事务的,但 myisam 不支持事务
对于 myisam来说更加适合那种不经常做更新操作只提供查询和 统计操作的数据,比如
统计表,配置表,冷数据表...
对于 innodb 来说适合的主要对象就是经常做更新操作的表,比如
业务表,热数据表
一点小推广
创作不易,希望可以支持下我的开源软件,及我的小工具,欢迎来 gitee 点星,fork ,提 bug 。
excel 通用导入导出,支持 excel 公式
博客地址:
gitee:
使用模板代码 ,从数据库生成代码 ,及一些项目中经常可以用到的小工具
博客地址:
gitee:
上一篇: Mybatis-Plus根据时间段去查询数据的实现示例
下一篇: go并发实现素数筛的代码