数据库事务(transaction)是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。
事务由事务开始和事务结束之间执行的全部数据库操作组成。
比如:MySQL存储引擎分为MyISAM、InnoDB、Memory、Merge等,其中支持事务的引擎为InnoDB.
ACID
通常事务必须满足4个条件:
- 原子性(Atomicity):不可分割性
一个事务中的所有操作,要么全部完成,要么完全不完成,不会结束在中间某个环节。事务在执行过程中发生错误时会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。 - 一致性(Consistency)
一致性在事务开始之前和事务结束之后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。 - 隔离性(Isolation):独立性
隔离性数据库允许多个并发事务同时对其数据进行读写或修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。
事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read commited)、可重复读(repeatable read)、串行化(serializable)。 - 持久性(Durability)
持久性事务处理结束后,对数据的修改是永久的,即便系统故障也不会丢失。
事务并发
业务系统访问数据库往往是多个线程并发执行多个事务,对于数据库会存在多个事务同时执行,可能多个事务会同时更新和查询同一条数据,此时会出现四种问题:脏写、脏读、不可重复读、幻读。
每个线程可能都会开启一个事务,每个事务都会执行增删查改操作,数据库会并发执行多个事务,多个事务可能会并发地对缓存页里的同一批数据进行增删改查操作,于是并发增删改查同一批数据的问题,可能会导致脏写、脏读、不可重复读、幻读这些问题。
这些问题的本质都是数据库多事务并发问题,为了解决多事务并发问题,数据库才设计了事务隔离机制,MVCC多版本隔离机制、锁机制,用一整套机制来解决多事务并发问题。
脏写
当两个事务同时尝试更新某一条数据记录时,肯定是一个先一个后。当事务A在更新到提交这个阶段里,事务B也过来进行更新,会覆盖了事务A提交的更新数据。
脏写是指两个事务同时在更新一条数据时
- 事务A是先更新的,它在更新之前,此行数据值为
NULL
。
1.事务A先将数据值更新为A,更新后会记录一条undo log
日志。
-
undo log
日志:更新之前这行数据的值为NULL
,主键为XX。
2.事务B紧接着再把数据更新为B值,事务B是后更新数据的,所以此时数据的值为B。
3.此时事务A突然回滚,就会用它的undo log
日志回滚。此时事务A一回滚,直接就会把那行数据的值更新回NULL
值。
事务A反悔把数据值回滚成NULL
造成事务B更新的值不见了,对于事务B看到的场景而言,就是自己明明更新了结果值却没了,这就是脏写。
时序 | 事务A | 事务B |
---|---|---|
1 | 开启事务 | - |
2 | - | 开启事务 |
3 | 更新A值 | - |
4 | - | 更新B值 |
5 | 提交事务 | 提交事务 |
6 | 事务回滚 | - |
- 脏写会导致更新丢失
脏写造成刚明明修改一个数据值结果过了一会儿却没了,脏写的本质是事务B去修改了事务A修改过的值,但此时事务A还没有提交。事务A随时回滚,导致事务B修改的值没了。
事务隔离级别都不存在脏写情况,因为在隔离级别下,当两个事务A和B尝试去更新同一条数据时,假定A先更新数据会对更新的数据行记录添加排他锁(写锁、悲观锁),除非事务A提交或终止从而释放排他锁,否则事务B无法更新数据。
设计数据密集型应用只是说,读提交隔离级别一定可以隔离脏写问题,并未提到读未提交隔离级别,经实践,读未提交下事务B的更新操作也需要等待事务A的排他锁释放,才得以执行。
脏读
一事务对数据进行增删改但未提交,另一事务可读取到未提交的数据。若第一个事务此时回滚,第二个事务会读到脏数据。
事务A更新了某行的数据值为A,此时事务B去查询此行的数据值看到的是A值。接着,事务B拿到查询到的A值做各种业务处理。
此时,事务A突然回滚了事务,导致刚才功能的A值没有了,此时数据值回滚为NULL
值。事务B再次查询得到的是NULL
值。
这就是脏读,其本质是事务B去查询事务A修改过的数据时,事务A还未提交。所以事务随时会回滚导致事务B再次查询,就读取不到刚才事务A修改的数据。
事务A向数据库写入数据但还未提交或终止,事务B看到了事务A写进数据库的数据,即脏读。在读未提交(Read-Uncommitted)隔离级别下,会出现脏读。
脏读导致问题
- 给用户代理数据混乱的感觉
- 让用户看到根本不存在的数据
无论脏写还是脏读,都是因为一个事务去更新或查询了另一个还没有提交的事务更新过的数据。因为另外一个事务还没有提交,所以它随时可能会回滚,那么必然导致更新的数据没了,或之前查询到的数据就没了,这就是脏写或脏读两种场景。
不可重复读
一个事务中发生两次读操作,第一次读操作和第二次读操作之间,另一个事务对数据进行了修改,此时两次读取的数据不一致。
- 假设缓存页中某条数据原始值为A,此时事务A开启后第一次查询读取到的就是A值。
- 事务B更新了那行数据值为B,同时事务B立马提交了,然后此时事务A还未提交。
在事务执行期间事务A第二次查询时,此时查询到的是事务B修改过的值B,因为此时事务B已经提交,所以事务A是可以读取到的。
- 紧接着事务C再次更新数据为C值并提交事务,此时事务A还未提交,第三次查询数据,查询到值为C值。
事务A在执行期间多次查询一条数据,每次都可以查到其它已经提交的事务修改过的值,就是不可重复读。
MySQL默认级别为可重复读
- MySQL分布式中多节点同步数据时,可重复读可保证多个节点数据的一致性。
- 备份数据时,不可重复读将会导致备份一部分是旧数据,一部分是更新后的新数据。从这样的备份来恢复数据时会导致数据不一致。
- 对于分析查询,需要遍历大量数据来进行查询和数据完整性检查。若是不可重复读将导致一前一后数据不一致,影响到分析结果。
幻读
第一个事务对一定范围的数据进行批量修改,第二个事务在这个范围增加一条数据,此时第一个事务会丢失对新增数据的修改。
幻读就是同一个事务多次查询,结果每次查询都会发现查到一些之前没看到过的数据。幻读特指查询到之前查询没有看到过的数据。
事务隔离级别
事务隔离级别 | 标识 | 脏读 | 不可重复读 | 幻读 | 导致问题 |
---|---|---|---|---|---|
读未提交 | Read-Uncommitted | 有 | 无 | 无 | 导致脏读 |
读提交 | Read-Commited | 无 | 有 | 有 | 避免脏读,允许不可重复读和幻读。 |
可重复读 | Repeatable-Read | 无 | 无 | 有 | 避免脏读,不可重复读,允许幻读。 |
串行化读 | Serializable | 无 | 无 | 无 | 事务只能一个一个执行。执行效率慢,慎用。 |
- 事务隔离级别越高,越能保证数据的完整性和一致性,但对并发性能的影响也越大。
- 大多数数据库默认隔离级别为
Read-Commited
读提交,比如SqlServer、Oracle。 - 少数数据库默认隔离级别为
Repeatable-Read
可重复读,比如MySQL InnoDB。
事务操作
Golang中使用三个方法实现事务操作
事务操作 | 返回值 | 描述 |
---|---|---|
func (db *DB) Begin() | (*Tx, error) | 开始事务 |
func (tx *Tx) Rollback() | error | 回滚事务 |
func (tx *Tx) Commit() | error | 提交事务 |
sql.Tx
事务查询使用的是sql.Tx
对象,使用db
的Begin
方法后可创建tx
对象。tx
对象也拥有数据库交互的Query
、Exec
、Prepare
方法,用法和db
对象类似。不同之处在于查询或修改操作完毕后,需要调用tx
对象的Commit
方法提交或Rollback
方法回滚。
一旦创建了tx
对象,事务处理都依赖与tx
对象,tx
对象会先从连接池中取出一个空闲的连接,接下来的SQL
执行都是基于这个连接的,直到Commit
或Rollback
调用之后,才会将连接释放会连接池。
事务处理时不能使用db
对象的查询方法,虽然也能够获取数据,但不属于同一个事务处理,因此不会接收Commit
或Rollback
的改变。
典型事务处理的过程
tx,err := db.Begin()
tx.Exec(query)
tx.Commit()
事务处理中数据库连接的生命周期是从Begin
函数调用开始的,直到Commit
和Rollback
函数调用结束。事务也提供了Prepare
语句的使用方式,但需使用Tx.Stmt
方法创建。Prepare
设计的初衷是多次执行,对于事务,可能需要多次执行同一个SQL。然后无论是正常的Prepare
和事务处理,Prepare
对于连接的管理都比较复杂,因尽量避免在事务中使用Prepare
方式。
事务并发
对于sql.Tx
对象,由于事务过程只有一个连接,事务内的操作都是顺序执行的,在开始下一个数据库交互之前,必须先晚上上一个数据库交互。
例如:非事务中多个连接可以并存
rows, _ := db.Query("SELECT id FROM user")
for rows.Next() {
//rows中维护的原始的连接
var mid int
rows.Scan(&mid)
//从连接池获取新的连接执行查询
var did int
db.QueryRow("SELECT id FROM detail_user WHERE master = ?", mid).Scan(&did)
}
调用Query
操作后在Next
方法中获取结果的时候,rows
是维护了一个连接,再次调用QueryRow时,db
会再次从连接池中取出一个新的连接,rows
和db
的两者可以并存,并且互不影响。
例如:事务处理中多个连接会失效
rows,_ := tx.Query("SELECT id FROM user")
for rows.Next(){
var mid int
rows.Scan(&mid)
// tx无法再进行查询
var did int
tx.QueryRow("SELECT id FROM detail_user WHERE master=?", mid).Scan(&did)
}
tx
执行Query
查询后,连接会转义到rows
上。在Next
方法中tx.QueryRow
会尝试获取该连接进行数据库操作。由于没有调用rows.Close
因此底层的连接属于busy
状态,因此tx
是无法再进行查询的。使用Query
的JOIN
语句可以规避此类问题。
- 事务是单个连接
因为事务是单个连接,因此任何事务处理过程中出现异常都需要使用Rollback进行回滚,这样一方面为了保证数据完整一致性,另一方面是释放事务绑定的连接。
import (
"database/sql"
"log"
)
func rollback(tx *sql.Tx) {
err := tx.Rollback()
if err != nil && err != sql.ErrTxDone {
log.Fatalln(err)
}
}
func doSomething() {
}
func main() {
dsn := "root:@tcp(127.0.0.1:3306)/test?parseTime=true"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatalln(err)
}
defer db.Close()
//开启事务
tx, err := db.Begin()
if err != nil {
log.Fatalln(err)
}
defer rollback(tx) //事务回滚
//事务处理
query := "UPDATE user SET gold=100 WHERE 1=1 AND id = 10"
rs, err := tx.Exec(query)
if err != nil {
log.Fatalln(err)
}
n, err := rs.RowsAffected()
if err != nil {
log.Fatalln(err)
}
log.Println(n)
query = "UPDATE user SET gold=200 WHERE 1=1 AND id = 20"
rs, err = tx.Exec(query)
if err != nil {
log.Fatalln(err)
}
n, err = rs.RowsAffected()
if err != nil {
log.Fatalln(err)
}
log.Println(n)
doSomething()
//提交事务
if err := tx.Commit(); err != nil {
log.Fatalln(err)
}
}
事务处理过程中,任何一个错误都会导致函数退出,因此需要再函数退出执行defer
的rollback
操作以回滚事务并释放连接。若不添加defer
,只在最后Commit
后检查错误后再Rollback
,那么当doSomething
发生异常时函数就退出了,此时还没有执行到tx.Commit
,这样就会导致事务的连接没有关闭,事务也没有回滚。
有疑问加站长微信联系(非本文作者)