数据库的并发、锁机制与MVCC

  created  by  鱼鱼 {{tag}}
创建于 2018年12月23日 22:20:50 最后修改于 2021年01月24日 15:51:35

    在日常开发中,经常遇到数据库进行高并发操作的情况,但是我们处理并发一般都只在代码范畴而并不处理具体的数据库操作,这是因为数据库对基本的数据库操作做了锁处理,让我们可以忽略这一层的并发问题。详细可以参考Mysql的官方文档

    注意:这一篇博客是针对MySQL数据库,且实用默认的 引擎InnoDb,使用其他数据库可能存在略微的差异。

    数据库的事务

    MySQL默认的数据库引擎InnoDB中Autocommit值为0(即自动提交事务)执行SQL语句的时候,每一条SQL语句都是一条单独的事务,所以并不存在并发的问题,数据库的锁机制已经做了很好的处理。

    但是当我们开启事务时,若不加处理,可能会产生一系列并发带来的问题。

    事务控制

    在事务中,执行完所有的语句后提交事务时才会使事务操作生效(原子性),使用如下语句实现事务的控制:

BEGIN;        #开启事务,也可以使用 START TRANSCATION; 或是 BEGIN WORK;

SELECT * FROM cw_user WHERE `id` = 1;
UPDATE cw_user SET name = "yuyu" WHERE `id` = 1;

# ROLLBACK;  事务回滚
COMMIT;       #提交事务


    在执行COMMIT后才会返回查询数据并且更新数据。 

    事务并发存在的问题

    数据库事务在高并发环境下很容易产生各种问题,并发环境下有些事务中的操作会告知其他事务,即使此时事务并没有被提交,常见的问题如下:

  • 脏读:一个事务读到了另一个事务执行但未提交的数据;

  • 不可重读:同一个事务中前后读取到的数据不一致(两次涉及同一条数据),因为在两次读取中数据被其他事务修改了(并且修改已经提交);

  • 虚读:一个事务批量修改了一些数据,另一个事务进行了新增操作,符合修改前的特征,此时前者再读取数据会发现实际上有未被修改的记录;

    其实,只有脏读是真正意义上的并发问题,因为面临着数据回滚的风险,在使用数据库事务时,我们可以根据想要避免的问题设定对应的数据库隔离级别。

    数据库隔离级别

    MySQL中有四种隔离级别:

  • Serializable (串行化):可避免脏读、不可重读、虚读的发生。

  • Repeatable read (可重复读):可避免脏读、不可复读的发生。

  • Read committed (读已提交):可避免脏读的发生。

  • Read uncommitted (读未提交):最低级别,只能保证效率,数据库事务的并发性没有体现出来。

    可以使用如下语句查询数据库事务的隔离级别:

SELECT @@tx_isolation;


    默认使用的是REPEATABLE-READ级别,ORM框架和一些数据库连接中也可以设定隔离级别,其实都是直接改变了数据库本身的隔离级别,我们可以根据需要设定,在保证数据准确性的情况下维持一定的效率。

数据库的锁

    数据库的锁也是用来防止事务并发产生的问题的,锁可以理解为是数据库隔离级别的具体实现,锁的应用对象是数据。少数情况下,我们可能需要自己加锁老避免种种问题的发生。

锁的分类

    1. 按加锁时机划分:

        分为乐观锁和悲观锁。

        悲观锁:悲观的去看待数据并发,假设每次操作都可能会发生问题。所以使用悲观锁意味着每次操作都要进行上锁,我们接下来接触到的数据库默认加锁和我们java程序中自带的锁(Synchronized)都是悲观锁的实现。

        乐观锁:乐观的去看待并发问题,只有在某些规定的情形下才会上锁,其实乐观锁更类似于一种人为订立的规则,并不是一行简单地加锁实现,而是一种较复杂的算法实现,在这里不详细讲述。

    2. 按类型划分

        分为共享锁(S锁,也叫作读锁)、独占锁(X锁,也叫排它锁、写锁)、更新锁。

        共享锁:多个事务可以共享,用于查询,有共享锁的数据不允许被修改,查询结束释放锁,只有仅有将要修改数据的事务本身拥有这个锁的时候,才可以对数据进行修改。

        独占锁:单个事务独享,用于修改,当某个数据将被修改时标记为独占锁,仅有不被当前事务以外的事务上锁(包括共享锁)的数据才能上独占锁,修改结束释放锁,拥有独占锁的数据不会再被上锁(其他想要上锁的事务只能等待),而且当且仅当其他锁释放时,当前拥有锁的事务才会继续执行修改操作。

        更新锁:一种需要依靠自己定义的锁,用以避免死锁的情况发生。当将要修改某数据的时候,将其标注为更新锁。

    3. 按粒度划分:

        分为行锁、页锁、间隙锁(一次锁多条数据,可以理解为页锁)和表锁,一目了然,不再详述。

依赖锁的隔离级别实现(较早版本)

    这里谈一谈具体的隔离级别是怎么样依赖锁实现的。

    Read uncommitted (读未提交):在将要进行修改的时候为数据添加共享锁,从而保证当前数据只被当前事务修改,直到事务结束。

    Read committed (读已提交):在将要修改的数据上添加排它锁,直到事务结束;在将要查询的数据上添加共享锁,并在查询完毕释放。

    Repeatable read (可重读):在将要修改的数据上添加排它锁,在将要查询的数据上添加共享锁,均等到事务结束才释放。

    Serializable (串行化):上锁范围是全表,事务只能串行执行。

    至于上锁的粒度,InnoDB是根据表的索引判断的,若是没有索引,则只会使用页锁锁住当前整页的数据。

更新与插入锁的实现

    详细实现参考另一篇博客:MySQL的数据锁 加在哪?

取代数据库事务查询锁的实现 - mvcc(多版本并发控制)

    因为数据库是一个高并发的存储物件,现在使用的MySQL均使用了 MVCC,而不再依赖并发处理性能较低的锁实现事务。有关这一官方解释可以查看官方文档,我们来看他是如何实现的:

    添加版本链,针对每一条数据都有一个隐形的版本id字段,当在事务中被修改时,其旧值会留存一份快照,同时版本id+1,针对不同的隔离级别,会去读取不同版本的值,从而能做到并发的读写,因此,Mysql可以选择相对更加安全的隔离级别Repeatable Read作为默认的隔离级别而不同担心其带来的性能影响。

    而利用 MVCC进行版本控制也就意味着只要存在并发,通过修改事务隔离级别来优化查询性能将会成为一件不太可能的事情

    注意,在MySQL的其他数据库引擎中并不支持 MVCC

     MVCC是为了查询更加高效而并非数据更新操作,对于更新、插入、删除操作仍会加锁。

死锁现象

    并发所引起的还有一种严重的情形:死锁,类似于java中的死锁,两个事务相互等待资源,就造成了阻塞,比如如下情形:

    在默认的隔离级别中,


A事务 B事务 操作说明
SELECT * FROM cw_user WHERE `id`=1
A事务为cw_user表id为1的数据条目添加了一个共享锁

UPDATE al_user SET name = "mumu" WHERE `id`=10 B事务为al_user表id为10的数据条目添加了一个独占锁
DELETE FROM al_user WHERE `id`=10

A事务尝试为al_user表id为10的数据条目添加独占锁,等待B事务释放独占锁


UPDATE cw_user SET name = "" WHERE `id`=1 B事务尝试为cw_user表id为1的数据条目添加独占锁,等待A事务释放共享锁

    在这里,二者都在等待另一个事务释放锁,如果此处只是设定了独占锁,按上表的事务执行顺序,便会造成死锁。

    其实MySQL已经做出了很多处理竭力避免这种现象的发生,之所以出现上面描述的死锁现象,大部分是因为业务代码设定不合理,我们应该首先考虑优化代码中的逻辑。遇到无可避免的情况下,我们就可以利用上面的知识来解决死锁问题了。

    需要说一下的是MySQL的InnoDB对死锁会进行事务回滚的处理,而判断死锁的方法是通过事务阻塞超时时间。

    相关语法

    1.手动加锁

    在事务中,可以在SQL语句后面手动加锁,像这样:

SELECT * FROM al_user  LOCK IN SHARE MODE;       #持有共享锁
SELECT  *  FROM  al_user   FOR UPDATE;       #持有排他锁


    这些都是对我们要操作的数据执行的锁,我们还可以对整表加锁:

LOCK TABLES al_user WRITE;                #为al_user表加共享锁
LOCK TABLES cw_user READ;                #为cw_user表加独占锁
UNLOCK TABLES;                       #释放当前事务的所有锁


    当我们的事务中含有表锁语句时,我们需要确保事务中操作的任何一个表都要确认已经执行了LOCK TABLES,否则执行中会报错:Table 'xxx' was not locked with LOCK TABLES;

    2.设定事务隔离级别

    在事务执行前,设定事务的隔离级别:

SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE};
#SESSION代表当前链接下,GROBAL是全局的配置
SELECT @@global.tx_isolation; 
SELECT @@session.tx_isolation; 
#查询对应的事务隔离级别
#上面提到的@@tx_isolation;指代当前应用的隔离级别,可以理解为SELECT @@session.tx_isolation;


    问题解决

    巧妙地运用加锁和隔离级别的设定,可以摆脱死锁现象,这里举个栗子:

    这是最常见的死锁产生原因

A事务 B事务 操作说明
UPDATE aw_user SET flag="ok" WHERE id = 1
id是主键,A为id为1的条目加了锁

UPDATE aw_user SET flag="block" WHERE id =2 B为id为2的条目加了锁
UPDATE aw_user SET flag="ok" WHERE id =2


UPDATE aw_user SET flag="ok" WHERE id =1

        解决:在进行更新前用独占锁锁掉所有将要操作的条目

SELECT * FROM aw_user WHERE id in (1,2);


    应用举例

    在掘金上面看到了支付宝单据并发的问题处理(这里只是其中很小一部分的逻辑,实际上为了保证性能,它的并发处理要复杂的多):

    锁定数据  ->   判断单据状态(是否已更新完毕)  ->   未更新业务,则更新    ->    释放锁



评论区
评论
{{comment.creator}}
{{comment.createTime}} {{comment.index}}楼
评论

数据库的并发、锁机制与MVCC

数据库的并发、锁机制与MVCC

    在日常开发中,经常遇到数据库进行高并发操作的情况,但是我们处理并发一般都只在代码范畴而并不处理具体的数据库操作,这是因为数据库对基本的数据库操作做了锁处理,让我们可以忽略这一层的并发问题。详细可以参考Mysql的官方文档

    注意:这一篇博客是针对MySQL数据库,且实用默认的 引擎InnoDb,使用其他数据库可能存在略微的差异。

    数据库的事务

    MySQL默认的数据库引擎InnoDB中Autocommit值为0(即自动提交事务)执行SQL语句的时候,每一条SQL语句都是一条单独的事务,所以并不存在并发的问题,数据库的锁机制已经做了很好的处理。

    但是当我们开启事务时,若不加处理,可能会产生一系列并发带来的问题。

    事务控制

    在事务中,执行完所有的语句后提交事务时才会使事务操作生效(原子性),使用如下语句实现事务的控制:

BEGIN;        #开启事务,也可以使用 START TRANSCATION; 或是 BEGIN WORK;

SELECT * FROM cw_user WHERE `id` = 1;
UPDATE cw_user SET name = "yuyu" WHERE `id` = 1;

# ROLLBACK;  事务回滚
COMMIT;       #提交事务


    在执行COMMIT后才会返回查询数据并且更新数据。 

    事务并发存在的问题

    数据库事务在高并发环境下很容易产生各种问题,并发环境下有些事务中的操作会告知其他事务,即使此时事务并没有被提交,常见的问题如下:

    其实,只有脏读是真正意义上的并发问题,因为面临着数据回滚的风险,在使用数据库事务时,我们可以根据想要避免的问题设定对应的数据库隔离级别。

    数据库隔离级别

    MySQL中有四种隔离级别:

    可以使用如下语句查询数据库事务的隔离级别:

SELECT @@tx_isolation;


    默认使用的是REPEATABLE-READ级别,ORM框架和一些数据库连接中也可以设定隔离级别,其实都是直接改变了数据库本身的隔离级别,我们可以根据需要设定,在保证数据准确性的情况下维持一定的效率。

数据库的锁

    数据库的锁也是用来防止事务并发产生的问题的,锁可以理解为是数据库隔离级别的具体实现,锁的应用对象是数据。少数情况下,我们可能需要自己加锁老避免种种问题的发生。

锁的分类

    1. 按加锁时机划分:

        分为乐观锁和悲观锁。

        悲观锁:悲观的去看待数据并发,假设每次操作都可能会发生问题。所以使用悲观锁意味着每次操作都要进行上锁,我们接下来接触到的数据库默认加锁和我们java程序中自带的锁(Synchronized)都是悲观锁的实现。

        乐观锁:乐观的去看待并发问题,只有在某些规定的情形下才会上锁,其实乐观锁更类似于一种人为订立的规则,并不是一行简单地加锁实现,而是一种较复杂的算法实现,在这里不详细讲述。

    2. 按类型划分

        分为共享锁(S锁,也叫作读锁)、独占锁(X锁,也叫排它锁、写锁)、更新锁。

        共享锁:多个事务可以共享,用于查询,有共享锁的数据不允许被修改,查询结束释放锁,只有仅有将要修改数据的事务本身拥有这个锁的时候,才可以对数据进行修改。

        独占锁:单个事务独享,用于修改,当某个数据将被修改时标记为独占锁,仅有不被当前事务以外的事务上锁(包括共享锁)的数据才能上独占锁,修改结束释放锁,拥有独占锁的数据不会再被上锁(其他想要上锁的事务只能等待),而且当且仅当其他锁释放时,当前拥有锁的事务才会继续执行修改操作。

        更新锁:一种需要依靠自己定义的锁,用以避免死锁的情况发生。当将要修改某数据的时候,将其标注为更新锁。

    3. 按粒度划分:

        分为行锁、页锁、间隙锁(一次锁多条数据,可以理解为页锁)和表锁,一目了然,不再详述。

依赖锁的隔离级别实现(较早版本)

    这里谈一谈具体的隔离级别是怎么样依赖锁实现的。

    Read uncommitted (读未提交):在将要进行修改的时候为数据添加共享锁,从而保证当前数据只被当前事务修改,直到事务结束。

    Read committed (读已提交):在将要修改的数据上添加排它锁,直到事务结束;在将要查询的数据上添加共享锁,并在查询完毕释放。

    Repeatable read (可重读):在将要修改的数据上添加排它锁,在将要查询的数据上添加共享锁,均等到事务结束才释放。

    Serializable (串行化):上锁范围是全表,事务只能串行执行。

    至于上锁的粒度,InnoDB是根据表的索引判断的,若是没有索引,则只会使用页锁锁住当前整页的数据。

更新与插入锁的实现

    详细实现参考另一篇博客:MySQL的数据锁 加在哪?

取代数据库事务查询锁的实现 - mvcc(多版本并发控制)

    因为数据库是一个高并发的存储物件,现在使用的MySQL均使用了 MVCC,而不再依赖并发处理性能较低的锁实现事务。有关这一官方解释可以查看官方文档,我们来看他是如何实现的:

    添加版本链,针对每一条数据都有一个隐形的版本id字段,当在事务中被修改时,其旧值会留存一份快照,同时版本id+1,针对不同的隔离级别,会去读取不同版本的值,从而能做到并发的读写,因此,Mysql可以选择相对更加安全的隔离级别Repeatable Read作为默认的隔离级别而不同担心其带来的性能影响。

    而利用 MVCC进行版本控制也就意味着只要存在并发,通过修改事务隔离级别来优化查询性能将会成为一件不太可能的事情

    注意,在MySQL的其他数据库引擎中并不支持 MVCC

     MVCC是为了查询更加高效而并非数据更新操作,对于更新、插入、删除操作仍会加锁。

死锁现象

    并发所引起的还有一种严重的情形:死锁,类似于java中的死锁,两个事务相互等待资源,就造成了阻塞,比如如下情形:

    在默认的隔离级别中,


A事务 B事务 操作说明
SELECT * FROM cw_user WHERE `id`=1
A事务为cw_user表id为1的数据条目添加了一个共享锁

UPDATE al_user SET name = "mumu" WHERE `id`=10 B事务为al_user表id为10的数据条目添加了一个独占锁
DELETE FROM al_user WHERE `id`=10

A事务尝试为al_user表id为10的数据条目添加独占锁,等待B事务释放独占锁


UPDATE cw_user SET name = "" WHERE `id`=1 B事务尝试为cw_user表id为1的数据条目添加独占锁,等待A事务释放共享锁

    在这里,二者都在等待另一个事务释放锁,如果此处只是设定了独占锁,按上表的事务执行顺序,便会造成死锁。

    其实MySQL已经做出了很多处理竭力避免这种现象的发生,之所以出现上面描述的死锁现象,大部分是因为业务代码设定不合理,我们应该首先考虑优化代码中的逻辑。遇到无可避免的情况下,我们就可以利用上面的知识来解决死锁问题了。

    需要说一下的是MySQL的InnoDB对死锁会进行事务回滚的处理,而判断死锁的方法是通过事务阻塞超时时间。

    相关语法

    1.手动加锁

    在事务中,可以在SQL语句后面手动加锁,像这样:

SELECT * FROM al_user  LOCK IN SHARE MODE;       #持有共享锁
SELECT  *  FROM  al_user   FOR UPDATE;       #持有排他锁


    这些都是对我们要操作的数据执行的锁,我们还可以对整表加锁:

LOCK TABLES al_user WRITE;                #为al_user表加共享锁
LOCK TABLES cw_user READ;                #为cw_user表加独占锁
UNLOCK TABLES;                       #释放当前事务的所有锁


    当我们的事务中含有表锁语句时,我们需要确保事务中操作的任何一个表都要确认已经执行了LOCK TABLES,否则执行中会报错:Table 'xxx' was not locked with LOCK TABLES;

    2.设定事务隔离级别

    在事务执行前,设定事务的隔离级别:

SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE};
#SESSION代表当前链接下,GROBAL是全局的配置
SELECT @@global.tx_isolation; 
SELECT @@session.tx_isolation; 
#查询对应的事务隔离级别
#上面提到的@@tx_isolation;指代当前应用的隔离级别,可以理解为SELECT @@session.tx_isolation;


    问题解决

    巧妙地运用加锁和隔离级别的设定,可以摆脱死锁现象,这里举个栗子:

    这是最常见的死锁产生原因

A事务 B事务 操作说明
UPDATE aw_user SET flag="ok" WHERE id = 1
id是主键,A为id为1的条目加了锁

UPDATE aw_user SET flag="block" WHERE id =2 B为id为2的条目加了锁
UPDATE aw_user SET flag="ok" WHERE id =2


UPDATE aw_user SET flag="ok" WHERE id =1

        解决:在进行更新前用独占锁锁掉所有将要操作的条目

SELECT * FROM aw_user WHERE id in (1,2);


    应用举例

    在掘金上面看到了支付宝单据并发的问题处理(这里只是其中很小一部分的逻辑,实际上为了保证性能,它的并发处理要复杂的多):

    锁定数据  ->   判断单据状态(是否已更新完毕)  ->   未更新业务,则更新    ->    释放锁




数据库的并发、锁机制与MVCC2021-01-24鱼鱼

{{commentTitle}}

评论   ctrl+Enter 发送评论