博客 > 巨杉 Tech | 并发性与锁机制解析与实践

巨杉 Tech | 并发性与锁机制解析与实践

 2020-03-05  SequoiaDB,原理解析

01

概述

数据库是一个多用户使用的共享资源。当多个用户并发地存取数据时,在数据库中就会产生多个事务同时存取同一数据的情况。若对并发操作不加控制就可能会读取和存储不正确的数据,破坏数据库的一致性。加锁是实现数据库并发控制的一个非常重要的技术。当事务在对某个数据对象进行操作前,先向系统发出请求,对其加锁。加锁后事务就对该数据对象有了一定的控制,在该事务释放锁之前,其他的事务不能对此数据对象进行更新操作。

OLTP 场景下通常要求具有很高的并发性。并发事务实际上取决于资源的使用状况,原则上应尽量减少对资源的锁定时间,减少对资源的锁定范围,从而能够尽量增加并发事务的数量,那么影响并发的因素有哪些呢?本文将从巨杉分布式数据库本身的机制以及隔离级别、数据库锁、参数、及实际例子进行详解,读完本文将对巨杉数据库并发性与锁机制有一个初步的了解。

02

隔离级别与并发性

在单用户环境中,每个事务都是顺序执行的,而不会遇到与其他事务的冲突。但是,在多用户环境下,多个事务并发执行。因此每个事务都有可能与其他正在运行的事务发生冲突。有可能与其他事务发生冲突的事务称为交错的或并行的事务,而相互隔离的事务称为串行化事务,这意味着同时运行它们的结果与一个接一个连续地运行它们的结果没有区别。在多用户环境下,在使用并行事务时,会发生四种现象:

  • 丢失更新:这种情况发生在两个事务读取并尝试更新同一数据时,其中一个更新会丢失。例如:事务 1 和事务 2 读取同一行数据,并都根据所读取的数据计算出该行的新值。如果事务 1 用它的新值更新该行以后,事务 2 又更新了同一行,则事务 1 所执行的更新操作就丢失了。

  • 脏读:当事务读取尚未提交的数据时,就会发生这种情况。例如:事务 1 更改了一行数据,而事务 2 在事务1 提交更改之前读取了已更改的行。如果事务 1 回滚该更改,则事务 2 就会读取被认为是不曾存在的数据。

  • 不可重复的读:当一个事务两次读取同一行数据,但每次获得不同的数据值时,就会发生这种情况。例如:事务 1 读取了一行数据,而事务 2 在更改或删除该行后提交了更改。当事务 1 尝试再次读取该行时,它会检索到不同的数据值(如果该行已经被更新的话),或发现该行不复存在了(如果该行被删除的话)。

  • 幻像:当最初没有看到某个与搜索条件匹配的数据行,而在稍后的读操作中又看到该行时,就会发生这种情况。例如:事务 1 读取满足某个搜索条件的一组数据行,而事务 2 插入了与事务 1 的搜索条件匹配的新行。如果事务 1 再次执行产生原先行集的查询,就会检索到不同的行集。

维护数据库的一致性和数据完整性,同时又允许多个应用程序同时访问同一数据,这样的特性称为并发性。巨杉数据库目前通过事务、隔离级别、锁等机制来对并发性进行控制,它决定在第一个事务访问数据时,如何对其他事务锁定或隔离该事务所使用的数据。目前巨杉数据库支持以下隔离级别来实现并发性:
  • 读未提交(ReadUncommitted):该隔离级别指即使一个事务的更新语句没有提交,但是别的事务可以读到这个改变,几种异常情况都可能出现。会出现读取的数据是不对的。

  • 读已提交(Read Committed):该隔离级别指一个事务只能看到其他事务的已经提交的更新,看不到未提交的更新,消除了脏读和第一类丢失更新,这是大多数数据库的默认隔离级别。保证了一个事务不会读到另一个并行事务已修改但未提交的数据,避免了“脏读取”,但不能避免“幻读”和“不可重复读取”。该级别适用于大多数系统。

  • 读稳定性(RepeatableStability):该隔离级别指一个事务中进行两次或多次同样的对于数据内容的查询,得到的结果是一样的。假设SQL语句中包括查询条件, 则会对全部符合条件的纪录加对应的锁。假设没有条件语句。也就是对表中的全部记录进行处理。则会对全部的纪录加锁。

  • 可重复读(Repeatable Read):REPEATABLE READ隔离级解决了READUNCOMMITTED隔离级导致的问题。它确保同一事务的多个实例在并发读取数据时,会“看到同样的”数据行。不过理论上,这会导致另一个棘手问题:幻读(Phantom Read)。简单来说,幻读指当用户读取某一范围的数据行时,另一个事务又在该范围内插入了新行,当用户再读取该范围的数据行时,会发现有新的“幻影”行。数据库存储引擎可以通过多版本并发控制 (Multiversion Concurrency Control)机制解决了幻读问题,如MySQL的InnoDB和Falcon。巨杉数据库对于多版本控制(MVCC)技术是通过采用事务锁、内存老版本以及磁盘回滚段重建老版本的设计来实现。此架构设计的理论基础是通过对内存结构的合理利用,存储数据和索引的老版本信息,从而实现数据的快速的并发访问。

03

数据库锁参数与并发性实践

1. SequoiaDB的事务配置

事务作为一个完整的工作单元执行,事务中的操作要么全部执行成功要么全部执行失败。SequoiaDB事务中的操作只能是插入数据、修改数据以及删除数据,在事务过程中执行的其它操作不会纳入事务范畴,也就是说事务回滚时非事务操作不会被执行回滚。如果一个表或表空间中有数据涉及事务操作,则该表或表空间不允许被删除。

  • 事务开启、提交与回滚

在SDB中,关于事务启停的配置项如下:

image.png

默认情况下,SequoiaDB 所有节点的事务功能都是开启的。若用户不需要使用事务功能,可参考以下方法,关闭事务功能。

步骤1:通过sdb shell设置集群所有节点都关闭事务。

db.updateConf( { transactionon: false }, { Global: true } )

步骤2:在集群每台服务器上都重启 SequoiaDB 的所有节点。

[sdbadmin@ubuntu-dev1 ~]$ /opt/sequoiadb/bin/sdbstop -t all
[sdbadmin@ubuntu-dev1 ~]$ /opt/sequoiadb/bin/sdbstart -t all

注意:

1. 开启及关闭节点的事务功能都要求重启该节点。
2. 在开启节点事务功能的情况下,节点的配置项logfilenum(该配置项默认值为20)的值不能小于 5。
SequoiaDB 事务支持的操作如下:
  • 写事务操作:INSERT、UPDATE、DELETE。

  • 读事务操作:QUERY。

SequoiaDB的其它操作(如:创建表、创建索引、创建并读写LOB等其它非 CRUD 操作)不在事务功能的考虑范围。
支持隔离级别配置参数及取值如下:

image.png

   可以通过以下方式修改:

db.updateConf( { transisolation: 1 }, { Global: true } )

注意:该参数在线生效,会在下一次事务中生效

通过 "transBegin"、"transCommit" 及"transRollback" 方法,用户可以在一个事务中,对若干个操作进行事务控制。其使用方式如下:

> db.transBegin()
> 操作1
> 操作2
> 操作3
> ...
> db.transCommit() or db.transRollback()

在上述使用模式中,用户必须显式调用"transCommit" 及 "transRollback" 方法来结束当前事务。然而,对于写事务操作,若在操作过程发生错误,数据库配置中的 transautorollback 配置项可以决定当前会话所有未提交的写操作是否自动回滚。transautorollback 的描述如下:

注意:该配置项只有在事务功能开启(即 transactionon 为 true )的情况下才生效。

默认情况下,transautorollback 配置项的值为 true。所以,当写事务操作过程出现失败时,当前事务所有未提交的写操作都将被自动回滚。

  • 事务自动提交

数据库配置中,关于事务自动提交的配置项如下:

image.png

事务自动提交功能默认情况下是关闭的。当transautocommit 设置为 true 时,事务自动提交功能将开启。此时,使用事务存在以下两点不同:

  • 用户不需要显式调用 "transBegin" 和"transCommit" 或者 "transRollback" 方法来控制事务的开启、提交或者回滚。

  • 事务提交或者回滚的范围仅仅局限于单个操作。当单个操作成功时,该操作将被自动提交;当单个操作失败时,该操作将被自动回滚。

例如,如下操作中:

> /* transautocommit 设置为 true */
> db.foo.bar.update({$inc:{"salary": 1000}}, {"department": "A"}) // 更新 1
> db.foo.bar.update({$inc:{"salary": 2000}}, {"department": "B"}) // 更新 2
> db.foo.bar.update({$inc:{"salary": 3000}}, {"department": "C"}) // 更新 3 
...

更新 1、更新 2、更新 3 分别为独立的操作。假设更新 1 和 更新 2 操作成功,而更新 3失败。那么更新 1 和 更新 2 修改的记录将全部被自动提交。而更新 3 修改的记录将全部被自动回滚。

  • 其它配置

数据库配置中,关于事务的其它主要配置项如下:

image.png

  • 调整设置

当用户希望调整事务的设置时(如:是否开启事务、调整事务配置项等),有如下 3 种方式供用户选择使用:

  1. 用户可以将数据库配置描述的事务配置项,配置到集群所有(或者部分)节点的配置文件中。若修改的配置项要求重启节点才能生效,用户需重启相应的节点。
  2. 使用 updateConf()命令在 sdb shell 中修改集群的事务配置项。若修改的配置项要求重启节点才能生效,用户需重启相应的节点。
  3. 使用 setSessionAttr()命令在会话中修改当前会话的事务配置项。该设置只在当前会话生效,并不影响其它会话的设置情况。


2. SequoiaDB并发与锁操作实践

示例:
建立数据库以及表
mysql> use company;
Database changed
mysql>  create table t1 (a int,b int);
Query OK, 0 rows affected (0.03 sec)

1)事务提交与回滚

例子1:
使用事务回滚插入操作。事务回滚后,插入的记录将被回滚,集合中无记录:
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into t1 values(1,1);
Query OK, 1 row affected (0.08 sec)
mysql> select * from t1;
+------+------+
| a    | b    |
+------+------+
|    1 |    1 |
+------+------+
1 row in set (0.00 sec)
mysql> rollback;
Query OK, 0 rows affected (0.01 sec)
mysql> select * from t1;
Empty set (0.00 sec)

例子2:
使用事务提交插入操作。提交事务后,插入的记录将被持久化到数据库:
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into t1 values(1,1);
Query OK, 1 row affected (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.01 sec)
mysql> select * from t1;
+------+------+
| a    | b    |
+------+------+
|    1 |    1 |
+------+------+
1 row in set (0.01 sec)

2)隔离级别为RU并发与锁

例子3:

在隔离级别为RU(transisolation 设置为0)的情况下,设置当前会话级(会话1及会话2同时设置)隔离级别为read uncommitted :

mysql> SELECT @@tx_isolation;
+-----------------+
| @@tx_isolation  |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)
mysql> set session transaction isolation level read uncommitted;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT @@tx_isolation;
+------------------+
| @@tx_isolation   |
+------------------+
| READ-UNCOMMITTED |
+------------------+
1 row in set, 1 warning (0.00 sec)

在窗口1:会话1对该表写入数据,并不提交。

mysql> create table t3(a int,b int);
Query OK, 0 rows affected (0.02 sec)
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into t3 values(1,1);
Query OK, 1 row affected (0.03 sec)
在窗口2:会话2对该表进行查询,查到的是未提交的数据。
mysql>  select * from t3;
+------+------+
| a    | b    |
+------+------+
|   1 |    1 |
+------+------+
1 row in set (0.01 sec)

小结:由于采用了隔离级别是RU,允许脏读,在第二个会话中不会产生锁等待,而直接会读到未提交的数据。该隔离级别建议设置在以读为主的历史数据平台应用中,在真实的OLTP环境中,不能满足业务需求。这样业务查询会读取到未提交事务的修改,如果事务发生回滚,那么读取的数据是错误的。不能满足一致性的要求。


3)隔离级别为RC并发与锁

RR隔离级别的实现概述:

巨杉数据库在RC隔离级别上除了支持传统关系型数据库的读已提交以外,通过MVCC多版本访问的方式支持读取最后一次提交的版本而不会产生锁等待,从而提高业务的并行处理能力。

例子4:在隔离级别为RC(transisolation 设置为1,translockwait为true)的情况下,看看并发情况:

image.png

首先,修改隔离级别为1,translockwait为true该修改将在下一次连接的时候生效。

> db.updateConf({transisolation:1},{Global:true});
Takes 0.051656s.
> db.updateConf({translockwait:true},{Global:true});
Takes 0.041699s.
> db.updateConf({transactiontimeout:30},{Global:true});
Takes 0.099934s.
> db.updateConf({transautocommit:true},{Global:true});
Takes 0.040183s.

备注:也可以通过mysql端进行当前session隔离级别参数的修改。

设置当前会话级(会话1及会话2同时设置)隔离级别为read committed :

mysql> set session transaction isolation level read committed;  
Query OK, 0 rows affected (0.00 sec)

在窗口1:事务1对该表写入数据,并不提交。

mysql> create table t4 (a int,b int);
Query OK, 0 rows affected (0.09 sec)
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into t4 values(1,1);
Query OK, 1 row affected (0.03 sec)

在窗口2:事务2对该表进行查询,可以看到一直会处于等待锁的状态,直到锁超时(transactiontimeout设置为30秒)退出。

mysql> select * from t4;
ERROR 1030 (HY000): Got eror 40013 from storage engine

通过捉取锁的快照,可以看到第一个事务持有锁的信息,持有该表上的IX,IS锁以及记录上的X锁。如下图所示:

image.png

而第二个事务等待锁的情况,在等锁该表记录上的S锁。如下图所示:

image.png

小结:由于采用了隔离级别是RC 并且translockwait设置为true的情况下,在第二个事务中会产生锁等待,直到第一个事务释放该表上的行锁,第二个事务才能执行,否则会一直等待到锁超时退出为止。这也是大多数传统关系型数据库的默认隔离级别。


例子5:

在隔离级别为RC(transisolation 设置为1,translockwait为false)的情况下,看看并发情况:

我们先来看看translockwait设置为false的说明: 不等待记录锁,直接从系统读取最后一次提交的版本。

设置SDB参数配置:

> db.updateConf({translockwait:false},{Global:true});Takes 0.041699s.

备注:可以通过mysql端进行当前session隔离级别参数的修改。

设置当前会话级(会话1及会话2同时设置)隔离级别为read committed :

mysql> set session transaction isolation level read committed;  
Query OK, 0 rows affected (0.00 sec)

在窗口1:事务1对该表写入数据,并不提交。

mysql> create table t5 (a int,b int);
Query OK, 0 rows affected (0.09 sec)
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into t5 values(1,1);
Query OK, 1 row affected (0.03 sec)

在窗口2:事务2对该表进行查询,可以看到马上返回,并没有发生锁等待的情况。这时候查到的数据是最后一次提交的版本

mysql> select * from t5;
Empty set (0.01 sec)

小结:由于采用了隔离级别是RC 并且translockwait设置为false的情况下,在第二个事务不会产生锁等待,而是会读到最后一次版本已提交的数据。通过锁快照也可以看到没有任何锁等待的情况出现。该隔离级别设置适用于绝大多数的OLTP场景。


   4)隔离级别为RS并发与锁

例子6:

在隔离级别为RS(transisolation 设置为2)的情况下,看看并发情况:

> db.updateConf({transisolation:1},{Global:true});
Takes 0.051656s.

在窗口1:事务1对该表进行查询数据,不提交。

mysql> create table t6 (a int,b int,primary key(a));
Query OK, 0 rows affected (0.09 sec)
mysql> insert into t6 values(1,1);
Query OK, 1 row affected (0.03 sec)
mysql> insert into t6 values(2,1);
Query OK, 1 row affected (0.03 sec)
mysql> begin;Query OK, 0 rows affected (0.00 sec)
mysql> select * from t6;
+------+------+
| a    | b    |
+------+------+
|    1 |    1 |
|    2 |    1 |
+------+------+
1 row in set (0.01 sec)

在窗口2:事务2对该表进行更新,可以看到处于锁等待的状态。最终锁超时事务进行回滚:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update t6 set b=11 where b=1;
ERROR 1030 (HY000):Got error 40013 from storage engine

通过捉取锁的快照,可以看到第一个事务持有锁的情况,查询拿到了该表上的S锁。如下图所示:

image.png

而事务2需要取到该表上的X锁而产生了等待,如下图所示:

image.png

小结:由于采用了隔离级别是RS ,在第二个事务更新的事务会产生锁等待,任何事务查找的记录都不允许更新,直到该读取的表的锁被释放。通过锁快照也可以看到有锁等待的情况出现。RS场景并发性较差,一般适应于总帐计算系统系统。查到的数据该事务不提交,侧不允许被修改。


5)隔离级别为RR并发与锁

RR隔离级别的实现概述:

在多版本控制技术的事务锁实现中,RR(可重复读)配置下的读操作可以在使用完记录之后立即释放锁,不需要一直持有,直到事务提交或者回滚。但是写事务操作则需要一直持有插入、更改和删除的锁,直到事务完成提交或者回滚。巨杉数据库锁的实现是采用悲观锁机制,与传统关系型数据库的采用的主流锁机制类似。

在多版本控制技术的实现中,除了引入悲观锁的机制以外,巨杉数据库还采用了内存老版本机制提升数据库并发访问及操作的能力。内存老版本是通过在记录锁上附加有一个存储原版本数据和索引相关的结构,于内存中存储了老版本的数据。

以下通过实例操作进行详解:

例子7:

在隔离级别为RR(transisolation 设置为3)的情况下,看看并发情况:

> db.updateConf({transisolation:3},{Global:true});
Takes 0.051656s.
> db.updateConf({mvccon:true},{Global:true})
Takes 0.156197s.
> db.updateConf({globtranson:true},{Global:true})
Takes 0.051241s.

备注:打开RR隔离,除了transisolation 设置为3以外,需要修改以上多二个参数。mvccon及globtranson这二个参数为true。通过mysql端也可以直接进行设置。

通过mysql直接设置当前会话级(会话1及会话2同时设置)隔离级别为REPEATABLEREAD;

mysql> set session transaction isolation level REPEATABLE READ;  
Query OK, 0 rows affected (0.00 sec)

在窗口1:事务1对该表rr进行查询数据,不提交

mysql> create table rr (a int);
Query OK, 0 rows affected (0.06 sec)
mysql> insert into rr values(1);
Query OK, 1 row affected (0.19 sec)
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from rr;
+------+
| a    |
+------+
|    1 |
+------+
1 row in set (0.00 sec)

在窗口2:事务2对该表rr(事务1第一次查询后)进行数据更新后提交。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update rr set a=2 where a=1;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1  
Changed: 1  
Warnings: 0
mysql> commit;
Query OK, 0 rows affected (0.01 sec)

在窗口1:事务1对该表rr进行再次查询数据,查询到的数据可以看到不会由于事务2的更新提交而改变,而是读到事务开始前的版本数据。

mysql> select * from rr;
+------+
| a    |
+------+
|    1 |
+------+
1 row in set (0.00 sec)


通过捉取锁的快照,可以看到第一个查询的事务1在整个事务的查询中没有持任何锁,而事务2更新的操作持用该表的行锁。如下所示:

事务1持有锁的情况(未持有锁):

image.png

事务2持有锁的情况,持有该表的X锁,如下图所示:

image.png

小结:由于采用了隔离级别是RR ,任何查询都不会持用锁,也不会等锁,可以看到在第二个事务更新的操作不会影响事务1,任何更新的操作不会影响查询,由于事务1是在事务2之前执行查询,当前事务1始终查到的是rr表的事务2的更新前版本。该隔离级别适应于大并发查询的交易场景,能有效提高整个应用的并发性。


05

总结

巨杉数据库完整支持传统关系型数据库的几种常用隔离级别,可满足所有核心生产场景(OLTP及OLAP等场景)需求。创新性采用事务锁、内存老版本以及磁盘回滚段重建老版本的设计来实现了多版本并发控制技术。通过对内存结构的合理利用,存储数据和索引的老版本信息,从而实现多版本数据的快速的并发访问。

准备开始体验 SequoiaDB 巨杉数据库?