天天动画片 > 八卦谈 > 详解事务、隔离级别、悲观锁和乐观锁

详解事务、隔离级别、悲观锁和乐观锁

八卦谈 佚名 2024-03-06 10:27:24

        所谓事务,就是一组SQL操作,它们不可分割,不能被打断,要么都成功,要么都失败。具体地说,就是要满足ACID性质。

        引入事务之后,应用层再也不用担心上述异常了,因为数据库已经为我们处理得很好了。很多书籍把ACID放在一起叙述,我认为有点扯,因为他们并不正交。在我看来,C是AID的最终目的。下面,我们来看下ACID性质。

1. Atomicity(原子性)

        原子性这个词的含义就是不可分割。要么同时成功,要么同时失败。

2. Consistency(一致性)

        一致性是我们最终的目的,笼统地说,一致性就是要确保数据是正确无误的。原子性没法完全保证一致性,因为在多个事务操作数据库时,还需要涉及到隔离性。

3. Isolation(隔离性)

        隔离性,就是要隔离不同事务,隔离性是本文的重点,我们会针对不同的隔离级别进行介绍,先来看一眼:

隔离性

        需要强调的是,每种存储引擎的实现不尽一致,在可重复读隔离级别下,有的朋友在进行验证时,并未出现所谓的幻读,这是因为:

  • InnoDB通过MVCC部分地解决了幻读问题:a. 针对select不会有幻读;b. 针对select for update会有幻读。

  • 其它很多数据库引擎,还是存在幻读问题。

        关于InnoDB是否存在幻读问题,我们将在本文的实验部分进行验证。

4. Durability(持久性)

        持久性的意思是,一旦事务提交,它对数据库的变更是永久性的。实际上,事务提交后,最后不一定会落地到数据库中(比如落地时机器断电了),那怎么保证一定要落地成功呢?
这就涉及到redo log了,我们也不需要具体知道redo log的细节,但是,我们从逻辑上可以缕清:redo log要记录什么?redo log为什么能保证持久性?

redo log

        很多时候,就是这样,对于不太相关的东西,可以不精通,但至少要了解大概逻辑和思路。这样才能说服自己,才不会有一种玄乎其玄的感觉。
        接下来,我们看这个问题:客户端A的事务,是否应该看到客户端B的事务所作的修改?这就涉及到数据库事务的隔离级别。
        在本文中,如下图示都是基于我的实际验证。建议有兴趣的朋友一起动手,感受一下。说明:事务A和事务B位于两个不同的终端窗口,对应两个不同的进程,在改变隔离级别时,仅改A的隔离级别来进行验证。

   a. 读未提交

读未提交

        可见,设置读未提交后,事务B在未提交时,事务A读出了a=10,  这是脏数据(B事务被回滚了),这就是所谓的“脏读”。

b. 读已提交

读已提交

        可见,设置读已提交后,事务B在未提交时,事务A读出了a=0, 在事务B提交后,又读出了a=10,  出现了“不可重复读”。

c. 可重复读

可重复读

        可以看到,看事务A内,读取的值具有前后不变的特点,这就是“可重复读”。只有当事务A提交后,才能读出a=10. 在MySql中,默认的隔离级别就是可重复读。
        接下来,我们看一个魔幻现象:

魔幻现象

        在B事务提交后,A事务执行select ... where a = 100时,发现还是无记录,可见此时并未产生“幻读”。但是,如果用select for update, 则出现了“幻读”现象。

        可见,在InnoDB可重复读的隔离级别中,并未完全解决“幻读”问题,而是解决了读数据情况下的“幻读”问题,而对于修改的操作依然存在“幻读”问题。

d. 串行化

串行化
可以看到,即使对于读操作,也会加锁,一个事务要等待另一个事务完成。串行化是完全的隔离级别,会导致大量超时和锁竞争问题,在高并发场景中,较少用到串行化。在SQLite中,默认的隔离级别就是串行化。

        可以看到,即使对于读操作,也会加锁,一个事务要等待另一个事务完成。串行化是完全的隔离级别,会导致大量超时和锁竞争问题,在高并发场景中,较少用到串行化。在SQLite中,默认的隔离级别就是串行化。

丢失更新问题

        有了这些隔离级别,就万事大吉了吗? 当然不是。以MySql为例,在默认隔离级别下,会有丢失更新的问题。领导A给你加了30元的鸡腿,领导B给你加了40元的鸡腿,最终结果发现,只有40元鸡腿,显然,这是不合理的:

丢失更新问题

        怎么解决这种问题呢?可以考虑引入悲观锁或乐观锁。

悲观锁

        所谓悲观锁,就是持悲观态度,认为一定会有冲突,所以提前加强保护。悲观锁可以用select for update来实现,之前项目中就经常这样玩,但后来重构了代码,统一优化成了分布式锁。使用分布式锁, 代码示意如下(如下使用方法有问题):

        上述代码的使用是有问题的,想一下为什么?

        当两个进程都读取money=0后,进程A获取锁,并且执行完毕后,money=30,然后进程B获取锁,执行完毕后,显然可知,最后的结果是money=40,仍然存在丢失更新的问题。

        曾经在项目中,就出现过这种错误,导致了低概率的金额不匹配,比较难发现问题,最后还是通过对账发现了,然后查出上述错误的用法。

        正确使用悲观锁代码示意如下:

乐观锁

        所谓乐观锁,就是抱有很乐观的态度,也就是假定不会存在数据冲突(即使有冲突也不怕,乐观得很)。具体实现时,可以在数据上打一个version标记,基于version进行控制,代码示意如下:

        分析一下:进程A和进程B都读到了version=100的数据,进程A在加完30元后,同时让version变成了101;此时进程B去执行,突然发现不满足where version=100这个条件,所以更新失败,这是合理的,符合预期,宁可执行失败,也不能产生数据错误。
        这里有一个极为微妙的问题:在MySql可重复读隔离级别下,当进程A的update执行成功并且提交事务后,version变为了101, 但是在进程B看来,version还是100(可重复读),  为什么B在执行update的时候,在where version=100条件下又无法真正执行update呢?
        要注意,可重复读是针对select而言的,而不是select for update或者update之类的操作,当A进程事务提交后,B进程事务看到的情况如下:

        可见,对B事务而言,用select看,看不到B事务的更新,这满足事务的可重复读。但是,当使用select for update时,能看到B事务的更新。

        所以,当B事务使用update尝试更新where  version=100的记录时,发现更新失败,这是我们期望的结果,宁可执行失败,也不能产生数据错误。针对这种失败,可以采用多次重试。

        至于悲观锁和乐观锁的选择,还是要依赖于具体业务。数据的一致性如此重要,可千万别把用户的钱给算错了。

        对于频繁写冲突的业务,用乐观锁肯定是不太好的,重试操作会增加各种开销,此时可以考虑使用悲观锁。对于写冲突较少发生的场景,那乐观锁就非常适合了。

本文标题:详解事务、隔离级别、悲观锁和乐观锁 - 八卦谈
本文地址:www.ttdhp.com/article/50260.html

天天动画片声明:登载此文出于传递更多信息之目的,并不意味着赞同其观点或证实其描述。
扫码关注我们