二、计算机功底(八)

第6章 数据库

6.4事务与锁

6.4.1事务的四个隔离级别

通俗地讲,事务就是一个“代码块”,这个代码块要么不执行,要么全部执行。事务与事务并发地操作数据库的表记录,可能会导致下面几类问题:

事务并发导致的几类问题

为了解决上面几类问题,数据库设置了不同的事务隔离级别,以MySQL InnoDB引擎为例:

  • 读未提交:解决了更新丢失,但还是可能会出现脏读
  • 读提交:解决了更新丢失和脏读问题
  • 可重复读取:解决了更新丢失、脏读、不可重复读、但是还会出现幻读
  • 串行化:解决了更新丢失、脏读、不可重复读、幻读(虚读)
InnoDB事务隔离级别

隔离级别4就是串行化,所有事务串行执行,虽然能解决上面的四个问题,但性能无法接受,所以一般不会采用;隔离级别1没有任何作用,也不会釆用;所以常用的是隔离级别2和隔离级别3。

6.4.2悲观锁和乐观锁

丢失更新在业务场景中非常常见,例如:

用户余额表

两个事务并发的对同一条记录进行修改,一个充钱,一个扣钱:

start transaction
int b = select balance from T where user_id = 1
b = b + 50
update T set balance = b where user_id = 1
commit

start transaction
start transaction
int b = select balance from T where user_id = 1
b = b - 50
update T set balance = b where user_id = 1
commit

正确地执行了事务A和事务B(无论谁先谁后),执行完成之后,应该是30。但是实际上可能会是80或者-20。

解决方案:

  • 利用单条语句的原子性:
事务A:
start transaction
update T set balance = balance + 50 where user_id = 1
commit
事务B:
start transaction
update T set balance = balance -50 where user_id = 1
commit

这种方法简单可行,但很有局限性。因为实际的业务场景往往需要把balance先读岀来,做各种逻辑计算之后再写回去。如果不读,直接修改balance,没有办法知道修改之前的balance 的值是多少。

  • 悲观锁:
    悲观锁,就是认为数据发生并发冲突的概率很大,所以读之前就上锁,利用select xxx for update语句:
事务A:
start transaction
//对user_id=1的记录上悲观锁
int b = select balance from T where user_id = 1 for update
b = b + 50
update T set balance = b where user_id = 1
commit
事务B:
start transaction
//对user_id=1的记录上悲观锁
int b = select balance from T where user_id = 1 for update
b = b - 50
update T set balance = b where user_id = 1
commit

悲观锁有潜在问题,假如事务A在拿到锁之后、Commit之前出问题了,会造成锁不能释放,数据库死锁。另外,一个事务拿到锁之后,其他访问该记录的事务都会被阻塞,这在高并发场景下会造成用户端的大量请求阻塞。

  • 乐观锁:
    对于乐视锁,认为数据发生并发冲突的概率比较小,所以读之前不上锁。等到写回去的时候再判断数据是否被其他事务改了,即多线程里面经常会讲的CAS (ComapreAnd Set)的思路。
乐观锁
事务A
while(!result) //CAS不成功,把数据重新读出来,修改之后,重新CAS
{
start transaction
int b, v1 = select balance, version from T where user_id = 1 ;
b = b + 50;
result = update T set balance = b, version = version + 1 where user_id = 1 and version = v1; //CAS
commit
}
事务B
while(!result)
{
start transaction
int b, v1 = select balance, version from T where user_id = 1 ;
b = b - 50;
result = update T set balance = b, version = version + 1 where user_id = 1 and version = v1; //CAS
commit
)

CAS的核心思想是:数据读出来的时候有一个版本vl,然后在内存里面修改,当再写回去的时候,如果发现数据库中的版本不是vl (比vl大),说明在修改的期间内别的事务也在修改,则放弃更新,把数据重新读出来,重新计算逻辑,再重新写回去,如此不断地重试。

在实现层面,就是利用update语句的原子性实现了 CAS,当且仅当versions 1时,才能把 balance更新成功。在更新balance的同时,version也必须加1。version的比较、version的加1 > balance的更新,这三件事情都是在一条update语句里面完成的,这是这个事情的关键所在!

当然,可以限制重试次数。

  • 分布式锁:
    乐观锁的方案可以很好地应对上述场景,但有一个限制是select和update的是同一张表的同一条记录,如要实现update表T3的同时,表T1和表T2是锁住状态,不能让其他事务修改。在这种场景下,乐观锁也不能解决,需要分布式锁。

6.4.3死锁检测

上层应用开发会加各种锁,有些锁是隐式的,数据库会主动加;而有些锁是显式的,比如上文所说的悲观锁。因为开发使用的不当,数据库会发生死锁。所以,作为数据库,必须有机制检测出死锁,并解决死锁问题。

先看一下死锁发生的原理:事务A持有锁1,事务B持有锁2,然后事务A请求锁2,但请求不到;事务B请求锁1,也请求不到。

两个事务发生死锁示意图

以事务为顶点,以事务请求的锁为边,构建一个有向图,这个图被称为Wait-for Graph。死锁检测就是发现这种有向图中存在的环,关于如何判断一个有向图是否存在环属于图论中的基本问题。

多个事务发生死锁示意图

检测到死锁后,数据库可以强制让其中某个事务回滚,释放掉锁,把环断开,死锁就解除了。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容