数据库并发访问问题

数据库使用中通常存在多个客户端同时访问数据库,因此数据库系统要能够处理这种并发访问的情况。在实际工作中,并发访问时数据库使用中的常态,然而并发访问时数据库时,可能出现以下问题:

  • 脏读:
    当前事务读到其他事务未提交的数据(脏数据),这种现象是脏读。在这里我们使用一个简单的订单表说明什么情况下会出现脏读,订单表(order)的表结构如下:
字段名数据类型描述
idbigint(20)自增id
novarchar(64)订单编号
statustinyint(4)订单状态:0 待支付 1 已支付 2 完成

假设当前事务为A,在事务A中读取订单的状态。并假设事务B与事务A并发执行,在事务B中进行订单状态的修改操作。

时间点事务A事务B
T1开始事务开始事务
T2修改订单状态:0=>1
T3查询订单状态(此时是1)
T4回滚操作(此时是0)

在这个例子中,事务A读取了未提交的数据,获取了错误的数据。

  • 不可重复读:
    在当前事务中先后两次读取同一个数据,两次读取的结果不一样,这种现象称为不可重复读。脏读与不可重复读的区别在于:前者读到的是其他事务未提交的数据,后者读到的是其他事务已提交的数据。同样采用订单状态的例子来说明那种情况下会出现不可重复读。
时间点事务A事务B
T1开始事务开始事务
T2查询订单状态(此时是0)
T2修改订单状态:0=>1
T4提交事务
T5查询订单状态(此时是1)

在一个事务中前后两次读取的结果并不致,导致了不可重复读。

  • 幻读:
    在当前事务中按照某个条件先后两次查询数据库,两次查询结果的条数不同,这种现象称为幻读。不可重复读与幻读的区别可以通俗的理解为:前者是数据变了,后者是数据的行数变了。 还是采用订单状态的例子来说明,不过在这个例子中将事务A与事务B的功能修改一下,在事务A中执行查询全部订单的操作,在并发执行的的事务B中执行添加订单的操作。
时间点事务A事务B
T1开始事务开始事务
T2查询订单数量(此时订单数量是1)
T2添加一个新的订单(此时订单数量是2)
T4提交事务
T5查询订单数量(此时订单数量是2)

在一个事务中前后两次读取的结果的条数不同,导致了幻读。

数据持久化问题:

InnoDB以数据页为单位来读写文件,为了提升性能InnoDB采用了缓冲池(Buffer Pool)。读数据会首先从缓冲池中读取,如果缓冲池中没有,则首先从磁盘读取数据页并加入缓冲池。
之后如果修改了一个数据页,也不会立即写到磁盘文件,而是把这个数据页添加到缓冲池的flush链表中,然后再某个时间再将数据保存到磁盘。缓冲池技术虽然会提交数据库的性能,但是会存在丢失数据的问题。对于一个已经提交的事务,如果数据还没有刷新到磁盘时出现异常,则会丢失这些数据。可能的异常情况包括:

  • 数据库异常
  • 操作系统异常
  • 系统断电

事务的定义

在数据库中使用事务来解决数据库并发访问问题以及数据持久化问题。在数据库中一个事务是由一条或多条SQL语句所组成的一个的执行单元,只有当事务中的所有操作都正常执行完了,整个事务才会被提交给数据库。如果有部分SQL语句处理失败,那么事务就回退到最初的状态。数据库事务应该具有以下的四大特性:

  • 原子性
    事务的原子性事务中的操作必须作为一个整体来执行,一个事务中的操作要么全部成功提交,要么全部失败回滚,对于一个事务来说不可能只执行其中的部分操作。
  • 持久性
    事务的持久性是指当事务提交之后,数据库的改变就应该是永久性的,即事务一旦提交,其所作做的修改会永久保存到数据库中,此时即使系统崩溃修改的数据也不会丢失。
  • 隔离性
    隔离性是指,事务内部的操作与其他事务是隔离的,并发执行的各个事务之间不能互相干扰。
  • 一致性
    一致性是指事务执行结束后,数据库的完整性约束没有被破坏,事务执行的前后都是合法的数据状态。一致性是事务追求的最终目标,原子性、持久性和隔离性,实际上都是为了保证数据库状态的一致性而存在的。

Mysql事务隔离级别

Mysql隔离级别有以下四种(级别由低到高):

  • read uncommited(未提交读)
    最低的隔离级别,这种级别下可以脏读、不可重复读、幻读都有可能发生。
  • read commited(已提交读)
    一个事务的修改在他提交之前的所有修改,对其他事务都是不可见的。其他事务能读到已提交的修改变化。在很多场景下这种逻辑是可以接受的。但是该级别会产生不可重读以及幻读问题。
  • repeatable read(可重复读)
    在一个事务内的多次读取的结果是一样的。这种级别下可以避免,脏读,不可重复读等查询问题。
  • serializable(串行化)
    事务串行化执行,隔离级别最高,这种级别下不会造成数据不一致问题,但是会牺牲系统的并发性。

在实际应用中,读未提交在并发时会导致很多问题,而性能相对于其他隔离级别提高却很有限,因此使用较少。
可串行化强制事务串行,并发效率很低,只有当对数据一致性要求极高且可以接受没有并发时使用,因此使用也较少。
因此在大多数数据库系统中,默认的隔离级别是读已提交(如Oracle)或可重复读。

使用redo日志实现事务持久性

MySQL为了提高的性能,对于增、删、改这种操作都是在内存中完成的。数据的增删改查都是先将磁盘中数据页的数据加载到缓冲池中,然后再对缓存页中的数据进行操作。MySQL有专门的后台线程等其他机制负责将脏数据页刷新到磁盘。由于脏数据页不是实时刷新到磁盘,若发生异常,那些已经提交但是没有保存的数据将会丢失,这样就不能保证事务的持久性了。为了解决这个问题,InnoDB数据库引擎采用了redo日志的机制。redo日志会把事务在执行过程中对数据库所做的修改都记录下来,在之后系统崩溃重启后可以把事务所做的任何修改都恢复出来。

为什么不直接将数据页刷新到磁盘,而是采用redo日志来实现数据的持久性,主要因为:

  • 首先一个缓存页的大小为16K,如果一个缓存页中只修改了少量的数据,也要将一个完整的缓存页刷入磁盘,这样显然不是很合理。
  • 另外的原因是一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,这些页面通常都不是一个连续的存储区域。直接保存这些页面需要进行多个磁盘IO,形成对磁盘进行随机访问,从而降低系统的性能。

使用redo日志能够避免上述问题,redo日志有以下有点:

  • redo日志占用的空间非常小。
  • redo日志是顺序写入磁盘的。

另外需要说明的是redo日志也不一定是立即刷新的磁盘,MySQL提供了系统变量:innodb_flush_log_at_trx_commit来控制redo日志的刷盘。该系统变量有3个选项:

  • 0:提交事务不会立即刷盘,MySQL使用后台线程来刷新日志到磁盘。该做法的优点是能够降低磁盘IO次数,提高系统的性能。缺点是系统异常时可能会丢失数据。
  • 1:若提交事务会立即将日志刷新到磁盘,该选项能够确保事务的持久性。
  • 2:若提交事务会立即将日志刷新到操作系统的缓存,然后依托于操作系统的刷新机制将数据同步到磁盘中。这种情况下数据库异常时不会丢失数据,但若是操作系统异常,也不能保证数据的持久性。

除了事务提交时,还有其他刷盘时机:

  • redo log buffer的空间不足时,若日志量达到redo log buffer的总容量的50%时会将这些日志刷新到磁盘。
  • 后台线程每隔一秒将redo log buffer里面的redo log block刷入磁盘。
  • 保存数据页时会将相关的redo日志刷新到磁盘。

使用undo日志实现事务原子性

Mysql数据库undo日志用于实现事务的原子性。事务执行过程中,以下两种情况会造成事务只执行了一部分操作:

  • 事务执行过程中出现了异常,例如:数据库异常、操作系统异常,服务器断电。
  • 事务执行了部分语句后,数据库的客户端输入ROLLBACK语句结束当前事务的执行。

上述两种情况中事务只执行了部分逻辑,在事务的执行中可能已经修改数据库的数据。为了确保事务的原子性,需要将已经修改的内容恢复为事务初始执行的状态,即对事务进行回滚。

Mysql数据库使用undo日志来实现事务回滚。在事务执行过程中,除了记录redo日志,还会记录undo日志。msyql执行事务时,会记录数据库操作(insert、delete、update)的回滚信息,这些回滚信息称为:undo日志。有了undo日志,若事务执行过程中出现异常或者客户端手动回滚,就可以使用undo日志将数据恢复为事务执行前的状态。

undo日志有两个作用,一是用于事务回滚,二是实现MVCC功能。

使用MVCC实现事务的隔离

Mysql中有两种机制可用于解决读写并发冲突,一种方式是采用锁机制,读、写操作都要进行加锁;另外一种方式是读操作使用MVCC,写操作加锁。由于使用MVCC时读不加锁,读写可以并发执行,系统的性能好,因此Mysql数据库在隔离等级read commited、repeatable read下缺省采用了MVCC机制来解决读写并发冲突。MVCC(Multi-Version Concurrency Control)被称为多版本并发控制,主要是依赖undo日志以及Read View(一致性视图)来实现事务的并发读写。在事务执行过程中,不同的事务对一个记录进行的修改,都会生成一条undo日志。每条undo日志都有一个roll_pointer属性,可以将这些undo日志都连起来,串成一个链表,这个链表就叫做:版本链。下图是一个版本链的示意图:

在这里插入图片描述
在事务中查询该记录时,会产生一个Read view(一致性视图),Read view相当于给当前数据库生成了一个快照,记录并且维护当前活跃的事务的ID。然后Mysql读取undo日志的版本链,把当前undo日志的事务ID与Read view中的活跃事务ID进行比较,来获取最新的已提交的记录数据。比较的规则如下:

  • 当前事务ID如果小于min_id(最小的活跃事务ID),则当前事务可见。
  • 当前事务ID如果大于max_id(系统的最大事务ID),则当前事务不可见。
  • 当前事务大于min_id小于max_id,再判断是活跃事务,如果是说明事务还没提交,当前事务不可见。如果不是活跃事务,则当前事务可见。
  • 若当前事务为不可见,则需要继续遍历undo日志的版本链,并按照上述规则进行比较。

使用锁实现事务的隔离

使用MVCC时读不加锁,读写可以并发执行,系统的性能好,但是MVCC只能用于读写事务的场景中,其他的场景则必须使用锁来解决事务的并发问题。另外有些情况下需要使用锁定读语句来查询记录。

锁的分类

1)从操作的粒度可分为表级锁、行级锁

  • 表级锁:每次操作锁住整张表。锁定粒度大,发生锁冲突的概率最高,并发度最低。
  • 行级锁:每次操作锁住一行数据。锁定粒度最小,发生锁冲突的概率最低,并发度最高。

2)从操作的类型可分为共享锁和排他锁

  • 共享锁:
    共享锁也称为读锁、S锁。一条数据被加了S锁之后,其他事务也能来读数据,可以共享一把锁。
  • 排他锁
    排他锁也称为写锁,X锁。当前写操作没有完成前,它会阻断其他写锁和读锁。

3)意向锁
意向锁为表锁,分为两种类型:意向共享锁(简称为IS)和意向排他锁(简称为IX)。意向锁有使用规则为:

  • 当需要给一行数据加上S锁的时候,MySQL会先给这张表加上IS锁。
  • 当需要给一行数据加上X锁的时候,MySQL会先给这张表加上IX锁。

InnoDB中的行级锁

  • Record Locks(记录锁)
    官方的类型名称为:LOCK_REC_NOT_GAP,记录锁又分为S锁和X锁。
  • Gap Locks(间隙锁)
    官方的类型名称为:LOCK_GAP,间隙锁的提出仅仅是为了防止插入幻影记录。
  • Next-Key Locks(临键锁)
    官方的类型名称为:LOCK_ORDINARY,临键锁的本质就是一个记录锁和一个间隙锁的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙。
  • Insert Intention Locks(插入意向锁)
    官方的类型名称为:LOCK_INSERT_INTENTION。一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了间隙锁,如果有的话,插入操作需要等待,在等待时事务需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待,而这个锁结构就是插入意向锁。

SQL语句加锁说明

  1. SELECT语句
    在不同的隔离级别下,普通的SELECT语句有不同的加锁状态:
  • 在READ UNCOMMITTED隔离级别下,不加锁。
  • 在READ COMMITTED 隔离级别下,不加锁。
  • 在REPEATABLE READ隔离级别下,不加锁。
  • 在Seralizable隔离级别下,加读锁。
  1. 锁定读语句
  • 对读取的记录加S锁
    SELECT … LOCK IN SHARE MODE;
  • 对读取的记录加X锁
    SELECT … FOR UPDATE;
  1. UPDATE语句和DELETE语句
    UPDATE语句和DELETE语句在执行过程需要首先定位到被改动的记录并给记录加锁,也可以被认为是一种锁定读。

  2. INSERT语句
    NSERT语句一般情况下不加锁,不过当前事务在插入一条记录前需要先定位到该记录在B+树中的位置,如果该位置的下一条记录已经被加了间隙锁,那么当前事务会在该记录上加上插入意向锁,并且事务进入等待状态。

在Go语言中使用数据库事务

Go语言提供了标准库database/sql用于访问数据库,我们开使用database/sql标准库提供的功能来连接数据库,进行数据库的增删改查操作,以及数据库的事务操作。本文不介绍database/sql标准库的使用方法,在本文中将介绍如何使用wego/worm来进行数据库事务操作。wego/worm是一款方便易用的Go语言ORM库,worm具有使用简单,运行性能高,功能强大的特点。具体特征如下:

  • 通过Struct的Tag与数据库字段进行映射,让您免于经常拼写SQL的麻烦。
  • 支持Struct映射、原生SQL以及SQLBuilder三种模式来操作数据库,并且Struct映射、原生SQL以及SQLBuilder可混合使用。
  • Struct映射、SQL builder支持链式API,可使用Where, And, Or, ID, In, Limit, GroupBy, OrderBy, Having等函数构造查询条件。
  • 支持事务支持,可在会话中开启事务,在事务中可以混用Struct映射、原生SQL以及SQL builder来操作数据库。
  • 支持预编译模式访问数据库,会话开启预编译模式后,任何SQL都会使用缓存的Statement,可以提升数据库访问效率。

wego/worm的下载地址:
go get github.com/haming123/wego/worm

ORM初始化

package main
import (
	"database/sql"
	"log"
	_ "github.com/go-sql-driver/mysql"
	"github.com/haming123/wego/worm"
)
func main() {
	var err error
	cnnstr := "user:pwd@tcp(127.0.0.1:3306)/db?charset=utf8&parseTime=True"
	dbcnn, err := sql.Open("mysql", cnnstr)
	if err != nil {
		log.Println(err)
		return
	}
	err = dbcnn.Ping()
	if err != nil {
		log.Println(err)
		return
	}
	err = worm.InitMysql(dbcnn)
	if err != nil {
		log.Println(err)
		return
	}
}

创建实体类

CREATE TABLE `user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(30) DEFAULT NULL,
  `age` int(11) DEFAULT NULL,
  `passwd` varchar(32) DEFAULT NULL,
  `created` datetime DEFAULT NULL,
  `updated` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
);

数据库表user对应的实体类的定义如下:

type User struct {
	Id          int64   	`db:"id;autoincr"`
	Name        string  	`db:"name"`
	Age         int64   	`db:"age"`
	Passwd      string  	`db:"passwd"`
	Created     time.Time	`db:"created;n_update"`
}
func (ent *User) TableName() string {
	return "user"
}

worm使用名称为"db"的Tag映射数据库字段,"db"后面是字段的名称,autoincr用于说明该字段是自增ID,n_update用于说明该字段不可用于update语句中。

事务处理

当使用事务处理时,需要调用worm.NewSession()来创建Session对象,并调用TxBegin()函数开启数据库事务。在事务中可以混用Struct映射、原生SQL以及SQLBuilder来访问数据库:

func demoTxCommit() {
	tx := worm.NewSession()
	tx.TxBegin()

	var user = User{Name:"name1", Age: 21, Created: time.Now()}
	id, err := tx.Model(&user).Insert()
	if err != nil{
		tx.TxRollback()
		return
	}

	_, err = tx.Table("user").Value("age", 20).Value("name", "zhangsan").Where("id=?", id).Update()
	if err != nil{
		tx.TxRollback()
		return
	}

	_, err = tx.SQL("delete from user where id=?", id).Exec()
	if err != nil{
		tx.TxRollback()
		return
	}

	tx.TxCommit()
}