在数据库(五),事务里面我们讲了事务ACID属性,事务最重要的能在异常情况的修复以及并发连接的处理上。
异常情况的修复主要通过日志来完成,那么并发连接的处理主要通过锁。本章主要整理的是锁的相关知识。
为什么需要锁?
现在Bob的账户里面有1000块钱,此时程序突然同时来了两个要求,一个要把Bob的钱转给Smith 20块,一个要把Bob的钱转Joe 30块。这两个要求一查Bob的账户,都发现现在Bob有1000块,所以要求A算出现在Bob应该有980块,要求B算出来Bob应有970。要求A的数据被要求B的数据覆盖了。
这样就出问题了,明明应该扣50块钱,现在却只是扣了30块。
锁就是用来解决这样的并发访问的问题。当每次访问Bob账户之前,都加一个锁,禁止别人再次访问,只有等待持有锁的人来释放
悲观锁和乐观锁
悲观锁
如果事务A把Bob账户锁住了,事务B自然不能操作Bob账户,也就是说其他线程只能在外面等待。
这种加锁的方式就是悲观锁。它每次取读写数据时总认为数据会被别人修改,所以将数据加锁,置于锁定状态,不让别人访问。
缺点是如果持有锁的时间太长,其他用户需要等待很长的时间。
悲观锁主要适用于并发争抢比较严重的场景。
乐观锁
悲观锁的问题显而易见,如果将数据加锁了以后,其他的线程是无法访问的,只能等待。如果持有锁的时间太长,需要等待大量的时间。
所以我们引入了乐观锁,所谓乐观锁是认为一般情况下不会有太多的人修改余额,所有没有加锁,只有在最后更新的时候才去看是否有冲突。
那具体怎么做呢?
可以在日志中加上一个version(版本)字段,
每次读的时候,不仅需要读出余额,还需要读出版本号。
- 等修改了余额以后,往回写之前需要检查一下版本号,看看与读的时候版本号是否一样。
如果不一样,说明数据已经被改变了,所以需要放弃写操作,重新读取余额和版本号
如果一样,则将新余额写回去,把版本号加1 。
比如
事务1把Bob的余额减去30,此时它读到了(Bob余额=1000,版本=1)
事务2也需要将Bob的余额减去50,他也读到了(Bob余额=1000,版本=1)
然后事务1率先完成计算,把新的余额值970写回了,版本 加 1 ,变成了版本2。
事务2写回去的时候,发现最新的版本号变为2,表示之前读的数据已经改变,所以需要重新读一遍
这就是乐观锁,这种方式适合于冲突不多的场景,如果冲突很多,数据争用激烈,会导致不断的尝试,反而降低了性能。
死锁
死锁产生的条件
如果出现如下这种情况
有两个线程同时参与
这两个线程在不同方向给同一个资源加锁
争抢相同的资源
那么很可能出现死锁
比如事务1是Bob给Smith转账,事务2是Smith给Bob转账。
当这两个事务单元同时发生的时候,就有问题呢。
事务单元1会先锁定Bob,然后锁定Smith,而事务单元2会先锁定Smith,然后锁定Bob
事务1会等待事务2把Bob给释放了,而事务2在等待事务1把Smith释放了。
如何解决
那么如何解决死锁呢?最好的方法是尽可能不出现死锁,当然很难。或者说如果锁定时间超时了,则强行释放,不过这种方法效率比较低,因为如果有用户的事务本来时间就很长,则每个死锁的检测时间将会很长。
所以最优的方案在于预测死锁,可以把事务单元等待的锁记录下来
比如下图中,事务单元1持有"Lock Bob"的锁,现在又在申请一把"Lock Smith"的锁,在申请之前,可以查看同样申请了"Lock Smith"的有哪些事务单元。明显事务单元2也申请过这把锁。好了,下一步是看事务单元2在申请什么锁呢,发现它居然在申请"Lock Bob"这把锁,而这把锁目前由事务单元1持有。所以现在已经发现有死锁的可能了,也就是发生了碰撞。所以可以提前补救。
U锁
下面来讨论一种死锁的情况。如下图
事务1 Trx1
开始事务1
读A(读锁)
A - 100(读锁需要升级为写锁)
提交事务1
事务2 Trx2
开始事务2
读A(读锁)
A - 100(读锁需要升级为写锁)
提交事务2(解锁)
事务1和事务2的读锁是可以并行的,所以读锁可以同时进入临界区,但是写锁不能,会被挡在外面。此时事务2又发起了写锁。那么尴尬的局面就产生了。
事务1的写锁需要等事务2的读锁释放资源。
事务2的写锁需要等待事务1的读锁释放资源。
所以形成了死锁。其实这种死锁的形成条件非常的简单,只需要针对同一个数据进行读写。比如说update set A=A-1 where id = 100
如果运行多次,就会出现死锁
解决的办法是引入U锁,可以将读锁直接升级为写锁。
对于事务1,读以后马上就是写,所以直接就使用写锁,而不是读锁呢。
同理事务2也是如此。