掘金 后端 ( ) • 2024-04-22 09:24

长期以来,自己都或多或少听到过这些概念,但是自己了解的都很碎片,不够整体,没能形成一个整体的认知框架;今天趁此机会,梳理了下他们之间的关系,目的是把这一切串起来形成一个整体的理解。

如果在看完本文后,能帮你建立一个整体的认知,那最好不过了。由于本文的理解比较个人化,可能在某些地方不够准确,如果您追求准确性,推荐去看专业的书籍。

1. 起源

首先,这些概念都是伴随着并发情况产生的,它们都和并发脱不了干系。

2. 锁

早期,为了保证数据一致性和隔离性,提出了锁的概念。在需要保证一致性的场景,直接加锁,不让其它事务操作就OK了,很暴力,但是很有效。

但是,使用锁也会导致很多问题,比如:

  1. 读阻塞(即使是最简单的共享锁,也不允许在更新的同时读取数据),以我们现在的眼光来看,这是不可接受的,我们想象一下,线上有一条数据在更新的时候,用户想要查看这条数据,确被阻塞了(卡住了)这是什么后果么?
  2. 锁开销太大(从感觉上来说,加锁还是比较重的)
  3. 非常容易死锁(在锁多了后,非常容易形成锁竞争,相互等待锁释放的情况,非常容易死锁)

3. mvcc

这个时候数据库的设计者,认识到了问题的严重性,后来慢慢提出了mvcc的方案,现在主流的关系数据库都是实现了mvcc

那什么是mvcc呢? mvcc是英文缩写,全程为(Multi-Version Concurrency Control 多版本并发控制),看到了吧!并发控制。

它的具体实现很复杂,我们不会深入到底层实现,了解下它实现的大体方式就行,它的实现主要有下面几个核心点:

  1. 版本控制/版本链

    每当要更新一个数据时,并不会直接在原来的数据上做覆盖修改,而是新生成一个数据版本;如果有其它事物也要对这个数据修改,也会生成数据版本,这些数据版本之间会形成一条链条就是版本链,可以通俗的理解成和git版本差不多。

  2. 视图控制

    由于我们已经有数据的版本链,那么我们可以结合根据隔离级别,控制在什么级别下,哪些数据版本对外是可见的,做到隐藏和可见

  3. 快照

    在可重复读的隔离级别下,事务一开始就会保存一个数据的快照,从而保证在整个事务执行过程中,随时读取数据都和最初一致。

有了mvcc的加入,我们可以做到减少锁的使用,减少了死锁的发生,在更新数据的同时也能读取数据(读不阻塞)啦。从此以后,锁和mvcc并肩作战,相互补充,一起成就了现在的数据库。

在日常的开发过程中,我们对数据库的使用,可能对锁是有察觉的,但是对mvcc的功劳却没有太直观的感受。它就像一个隐秘者一样,在一个使用者注意不到角落,默默发挥自己的功能,可以说现在的关系性数据库能有现在的性能,都和mvcc的默默支持密不可分。

4. 脏读、不可重复读、幻读

那什么又是隔离级别呢? 隔离级别并不能独立来看,它必须要和并发情况下产生的问题(脏读、不可重复读、幻读)一起来看。

  • 脏读:非常好理解就是读到了还未提交的数据
  • 不可重复读:在事务中对同一数据的两次读取,读到的值可能不一样——在第二次读之前,有事务提交做了修改。
  • 幻读:在事务中根据某一条件筛选得到的数据条数,前后两次不一样,在这个过程中可能有事务插入或删除数据。

PS:

不可重复读针对的是同一条数据,而幻读针对的是一个查询范围的数据

5. mvcc

好啦!并发产生的问题就为了,下面我们再来看看引申出来的隔离级别。

  1. 读未提交(Read Uncommitted)
  2. 读已提交(Read Committed)- 解决了 脏读
  3. 可重复读(Repeatable Read)- 解决 不可重复读
  4. 序列化(Serializable)- 解决了 幻读

后面三个隔离级别,分别解决了不同的问题;一般默认情况下,数据库的隔离级别是(Read Committed),这个级别不高不低刚刚好,级别越高越能解决的问题也就越多,但是代价也就越大;在串行化隔离级别下,所有的操作都不能一起执行,可以想象那是多么慢的一件事。

6. 回到sql

前面说了一大堆概念性的东西,但是我们实际开发时,面对的还是sql语句,所以在结尾部分我还是回到sql语句结合实践做一些分析。

在默认隔离级别下,数据的写操作(创建/更新/删除)数据库都会默认的加上排斥锁。比如:update users set name = '12' where id = 15;这句更新sql

开启三个数据库连接 session1:

begin transaction;
update users set name = '12' where id = 15;

session2:

update users set name = '23' where id = 15; // 更新 此时会阻塞

session3:

select * from users where id = 15; // 读此时不会阻塞

此时读不会阻塞,写会阻塞,因为写加的互斥锁,所以会阻塞;但是在读的时候,由于有mvcc的存在,它是不会读到未提交数据的,不会造成脏读,读取的时候,实际是读到的对外可见(已提交的版本),整个过程不发生阻塞。

其它的一些思考,在业务上我们很多时候会一次更新大量的数据,很多时候会直接采用如下方式update users set name = where age > 18

如果大于18的user很多,我们会有一个直观感受就是慢,或者过了很久后,看到数据库报了一个死锁的错误;那么这个更新一次会对很多行数据加锁,如果这个时候,有其它事务也尝试对其中的某一行或多行数据做修改,那么会阻塞,非常非常容器造成死锁。

因此,如果在涉及大量数据时候,我们最好缩小范围,确保加锁的数据行不要太多,不至于阻塞或者死锁