问题
当客户端A检查还有一张票时,将票买下,还没有执行更新数据库时,客户端B检查了票数,发现大于0,于是又买了一次票。然后A将票数更新会数据库,这就出现了同一张票被卖了两次。
CURD满足什么属性,能解决上述问题?
数据库中的「事务(Transaction)」就能达到这样的效果。
事务就是一组DML语句组成,这些语句在逻辑上存在相关性,这一组DML语句要么全部成功,要么全部失败,是一个整体。MySQL提供一种机制,保证我们达到这样的效果。事务还规定不同的客户端看到的数据是不相同的。
事务就是要做的或所做的事情,主要用于处理操作量大,复杂度高的数据。假设一种场景:你毕业了,学校的教务系统后台 MySQL 中,不在需要你的数据,要删除你的所有信息(一般不会:) ), 那么要删除你的基本信息(姓名,电话,籍贯等)的同时,也删除和你有关的其他信息,比如:你的各科成绩,你在校表现,甚至你在论坛发过的文章等。这样,就需要多条 MySQL 语句构成,那么所有这些操作合起来,就构成了一个事务。
所以,一个完整的事务,绝对不是简单的 sql 集合,还需要满足如下四个属性:
回滚(Rollback)
到事务开始前的状态,就像这个事务从来没有执行过一样。读未提交( Read uncommitted )
、读提交( read committed )
、可重复读( repeatable read )
和串行化( Serializable )
。上面四个属性,可以简称为 ACID 。
事务被 MySQL 编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题。
可以想一下当我们使用事务时,要么提交、要么回滚,我们不会去考虑网络异常了、服务器宕机了、同时更改一个数据怎么办对吧?因此事务本质上是为了应用层服务的,而不是伴随着数据库系统天生就有的。备注:我们后面把 MySQL 中的一行信息,称为一行记录。
Innodb
数据库引擎的数据库或表才支持事务, MyISAM 不支持。#mysql> show engines \G
*************************** 1. row ***************************
Engine: InnoDB // 引擎名称
Support: DEFAULT // 默认引擎
Comment: Supports transactions, row-level locking, and foreign keys -- 描述
Transactions: YES // 支持事务
XA: YES
Savepoints: YES // 支持事务保存点
*************************** 2. row ***************************
Engine: MRG_MYISAM
Support: YES
Comment: Collection of identical MyISAM tables
Transactions: NO
XA: NO
Savepoints: NO
*************************** 3. row ***************************
Engine: MEMORY //内存引擎
Support: YES
Comment: Hash based, stored in memory, useful for temporary tables
Transactions: NO
....
*************************** 5. row ***************************
Engine: MyISAM
Support: YES
Comment: MyISAM storage engine
Transactions: NO //MyISAM不支持事务
XA: NO
Savepoints: NO
....
#mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+
SET
来改变 MySQL 的自动提交模式:
# mysql> SET AUTOCOMMIT=0; //SET AUTOCOMMIT=0 禁止自动提交
#mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | OFF |
+---------------+-------+
#mysql> SET AUTOCOMMIT=1; //SET AUTOCOMMIT=1 开启自动提交
...
//使用netstat查看链接情况,可知:mysql本质是一个客户端进程
#netstat -ntp
...
#mysql> set global transaction isolation level READ UNCOMMITTED; //设置成读未提交
#mysql> select @@tx_isolation; //需要重启终端,进行查看
+------------------+
| @@tx_isolation |
+------------------+
| READ-UNCOMMITTED |
+------------------+
#create table if not exists account(
id int primary key,
name varchar(50) not null default '',
blance decimal(10,2) not null default 0.0
)ENGINE=InnoDB DEFAULT CHARSET=UTF8;
证明事务的开始与回滚
查看事务是否自动提交。我们故意设置成自动提交,看看该选项是否影响begin
#mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+
#mysql> start transaction; //开始一个事务begin也可以,推荐begin
#mysql> savepoint save1; // 创建一个保存点save1
mysql> insert into account values (1, '张三', 100); // 插入一条记录
mysql> savepoint save2; //-- 创建一个保存点save2
mysql> insert into account values (2, '李四', 10000); // 在插入一条记录
mysql> select * from account; // 两条记录都在了
//张三。。。
//李四。。。
#mysql> rollback to save2; //回滚到保存点save2
mysql> select * from account; //一条记录没有了
//张三。。。
#mysql> rollback; //直接rollback,回滚在最开始
mysql> select * from account; //所有刚刚的记录没有了
empty...
证明未commit,客户端崩溃,MySQL自动会回滚(隔离级别设置为读未提交)
//终端A
mysql> select * from account; //当前表内无数据
Empty set (0.00 sec)
#mysql> show variables like 'autocommit'; //依旧自动提交
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+
#mysql> begin; //开启事务
mysql> insert into account values (1, '张三', 100); //插入记录
mysql> select * from account; //数据已经存在,但没有commit,此时同时查看终端B
#mysql> Aborted // ctrl + \ 异常终止MySQL
//终端B
mysql> select * from account; //终端A崩溃前
+----+--------+--------+
| id | name | blance |
+----+--------+--------+
| 1 | 张三 | 100.00 |
+----+--------+--------+
mysql> select * from account; //数据自动回滚
Empty set (0.00 sec)
证明commit了,客户端崩溃,MySQL数据不会在受影响,已经持久化
//--终端 A
mysql> show variables like 'autocommit'; //-- 依旧自动提交
mysql> select * from account; //-- 当前表内无数据
mysql> begin; //-- 开启事务
mysql> insert into account values (1, '张三', 100); //-- 插入记录
mysql> commit; //--提交事务
mysql> Aborted //-- ctrl + \ 异常终止MySQL
//--终端 B
mysql> select * from account; //--数据存在了,所以commit的作用是将数据持久化到MySQL中
//张三。。。
对比试验。证明begin操作会自动更改提交方式,不会受MySQL是否自动提交影响
begin
或 start transaction
后,已经切换为手动提交,自动提交的设置与否不影响。出意外没提交要回滚。//-- 终端 A
mysql> select *from account; //--查看历史数据
//张三。。。
mysql> show variables like 'autocommit'; // --查看事务提交方式
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+
mysql> set autocommit=0; //--关闭自动提交
mysql> show variables like 'autocommit'; //--查看关闭之后结果
+---------------+-------+
| autocommit | OFF |
+---------------+-------+
mysql> begin; //--开启事务
mysql> insert into account values (2, '李四', 10000);
mysql> select *from account; //--查看插入记录,同时查看终端B
//张三。。。
//李四。。。
mysql> Aborted //--再次异常终止
//-- 终端B
mysql> select * from account; //--终端A崩溃前
//张三。。。
//李四。。。
mysql> select * from account; //--终端A崩溃后,自动回滚
//张三。。。
证明单条 SQL 与事务的关系
//-- 终端A
mysql> select * from account;
+----+--------+--------+
| id | name | blance |
+----+--------+--------+
| 1 | 张三 | 100.00 |
+----+--------+--------+
mysql> show variables like 'autocommit';
mysql> set autocommit=0; //--关闭自动提交
mysql> insert into account values (2, '李四', 10000); //--插入记录
mysql> select *from account; //--查看结果,已经插入。此时可以在查看终端B
mysql> ^DBye //--ctrl + \ or ctrl + d,终止终端
//--终端B
mysql> select * from account; //--终端A崩溃前
//张三...
//李四...
mysql> select * from account; --终端A崩溃后
//张三。。。
//--终端A
mysql> show variables like 'autocommit'; //--开启默认提交
+---------------+-------+
| autocommit | ON |
+---------------+-------+
mysql> select * from account;
//张三。。。
mysql> insert into account values (2, '李四', 10000);
mysql> select *from account; //--数据已经插入
//张三。。。
//李四。。。
mysql> Aborted //--异常终止
//--终端B
mysql> select * from account; //--终端A崩溃前
//张三。。。
//李四。。。
mysql> select * from account; //--终端A崩溃后,
//终端A崩溃后,并不影响,已经持久化。autocommit起作用
begin
或者start transaction
,事务便必须要通过commit
提交,才会持久化,与是否设置set autocommit
无关。InnoDB
每一条 SQL
语言都默认封装成事务,自动提交。(select
有特殊情况,因为 MySQL 有MVCC
)事务操作注意事项
rollback
(前提是事务还没有提交)InnoDB
支持事务, MyISAM 不支持事务start transaction
或者 begin
。如何理解隔离性1
隔离级别如何实现:隔离,基本都是通过锁实现的,不同的隔离级别,锁的使用是不同的。常见有,表锁,行锁,读锁,写锁,间隙锁(GAP),Next-Key锁(GAP+行锁)等。不过,我们目前现有这个认识就行,先关注上层使用。
并行事务会引发什么问题?那么在同时处理多个事务的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题。
脏读
举个栗子。假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库中读取小林的余额数据,然后再执行更新操作,如果此时事务 A 还没有提交事务,而此时正好事务 B 也从数据库中读取小林的余额数据,那么事务 B 读取到的余额数据是刚才事务 A 更新后的数据,即使没有提交事务。
因为事务 A 是还没提交事务的,也就是它随时可能发生回滚操作,如果在上面这种情况事务 A 发生了回滚,那么事务 B 刚才得到的数据就是过期的数据,这种现象就被称为脏读。
不可重复读
举个栗子。假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库中读取小林的余额数据,然后继续执行代码逻辑处理,在这过程中如果事务 B 更新了这条数据,并提交了事务,那么当事务 A 再次读取该数据时,就会发现前后两次读到的数据是不一致的,这种现象就被称为不可重复读。
幻读
举个栗子。假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库查询账户余额大于 100 万的记录,发现共有 5 条,然后事务 B 也按相同的搜索条件也是查询出了 5 条记录。
接下来,事务 A 插入了一条余额超过 100 万的账号,并提交了事务,此时数据库超过 100 万余额的账号个数就变为 6。
然后事务 B 再次查询账户余额大于 100 万的记录,此时查询到的记录数量有 6 条,发现和前一次读到的记录数量不一样了,就感觉发生了幻觉一样,这种现象就被称为幻读。
查看
//-- 查看
#mysql> SELECT @@global.tx_isolation; //--查看全局隔级别
+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| REPEATABLE-READ |
+-----------------------+
#mysql> SELECT @@session.tx_isolation; //--查看会话(当前)全局隔级别
+------------------------+
| @@session.tx_isolation |
+------------------------+
| REPEATABLE-READ |
+------------------------+
#mysql> SELECT @@tx_isolation; --默认同上
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
设置当前会话 or 全局隔离级别语法
#SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL
{READ UNCOMMITTED |
READ COMMITTED |
REPEATABLE READ |
SERIALIZABLE}
#mysql> SELECT @@global.tx_isolation; //--全局隔离性还是RR
+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| REPEATABLE-READ |
+-----------------------+
#mysql> SELECT @@session.tx_isolation; //--会话隔离性成为串行化
+------------------------+
| @@session.tx_isolation |
+------------------------+
| SERIALIZABLE |
+------------------------+
#mysql> SELECT @@tx_isolation; //--同上
mysql> set global transaction isolation level READ UNCOMMITTED;
mysql> SELECT @@global.tx_isolation;
+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| READ-UNCOMMITTED |
mysql> SELECT @@session.tx_isolation;
mysql> SELECT @@tx_isolation;
--终端A
//-- 设置隔离级别为 读未提交
mysql> set global transaction isolation level read uncommitted;
//--重启客户端
mysql> select @@tx_isolation;
mysql> select * from account;
//张三。。。
//李四。。。
mysql> begin; //--开启事务
mysql> update account set blance=123.0 where id=1; //--更新指定行
//--没有commit哦!!!
--终端B
mysql> begin;
mysql> select * from account;
//张三。。。
//李四。。。
//--读到终端A更新但是未commit的数据[insert,delete同样]
--- 终端A
mysql> set global transaction isolation level read committed;
//--重启客户端
mysql> select * from account; //--查看当前数据
//张三。。。1
//李四。。。2
mysql> begin; //--手动开启事务,同步的开始终端B事务
mysql> update account set blance=321.0 where id=1; //--更新张三数据
//--切换终端到终端B,查看数据。
--- 终端B
mysql> begin; //--手动开启事务,和终端A一前一后
mysql> select * from account; //--终端A commit之前,查看不到
//张三。。。1,老的值
//李四。。。
//--终端A commit之后,看到了!
mysql> select *from account; //新的值
+----+--------+----------+
| id | name | blance |
+----+--------+----------+
| 1 | 张三 | 321.00 |
| 2 | 李四 | 10000.00 |
+----+--------+----------+
---终端A
mysql> set global transaction isolation level repeatable read; //--设置全局隔离级别RR
//--关闭终端重启
mysql> select @@tx_isolation; //--隔离级别RR
mysql> select *from account; //--查看当前数据
mysql> begin; //--开启事务,同步的,终端B也开始事务
mysql> update account set blance=4321.0 where id=1; //--更新数据
//--切换到终端B,查看另一个事务是否能看到
mysql> commit; // --提交事务
//--切换终端到终端B,查看数据。
---终端B
mysql> begin;
mysql> select * from account; //--终端A中事务 commit之前,查看当前表中数据,数据未更新
mysql> select * from account; //--终端A中事务 commit 之后,查看当前表中数据,数据未更新
//--可以看到,在终端B中,事务无论什么时候进行查找,看到的结果都是一致的,这叫做可重复读!
mysql> commit; //--结束事务
mysql> select * from account; //--再次查看,看到最新的更新数据
+----+--------+----------+
| id | name | blance |
+----+--------+----------+
| 1 | 张三 | 4321.00 |
| 2 | 李四 | 10000.00 |
+----+--------+----------+
在RR级时候,多个事务的增删改现象。
在RR级别的时候,多个事务的update,多个事务的insert,多个事务的delete,会有加锁现象。但是select和这些操作是不冲突的。这就是通过读写锁(锁有行锁或者表锁)+MVCC完成隔离性。
多版本并发控制( MVCC )是一种用来解决 读-写冲突 的无锁并发控制。为事务分配单向增长的事务ID,为每个修改保存一个版本,版本与事务ID关联,读操作只读该事务开始前的数据库的快照。
理解 MVCC 需要知道三个前提知识:3个记录隐藏字段、undo 日志、Read View。
DB_TRX_ID
:6 byte,最近修改( 修改/插入 )事务ID,记录创建这条记录/最后一次修改该记录的事务IDDB_ROLL_PTR
: 7 byte,回滚指针,指向这条记录的上一个版本(简单理解成,指向历史版本就行,这些数据一般在 undo log 中)DB_ROW_ID
: 6 byte,隐含的自增ID(隐藏主键),如果数据表没有主键, InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引。undo 日志
现在有一个事务10(仅仅为了好区分),对student表中记录进行修改(update):将name(张三)改成name(李四)。
现在又有一个事务11,对student表中记录进行修改(update):将age(28)改成age(38)。
这样,我们就有了一个基于链表记录的历史版本链。所谓的回滚,无非就是用历史数据,覆盖当前数据。
一些思考
delete
呢?一样的,别忘了,删数据不是清空,而是设置flag为删除即可。也可以形成版本。insert
呢?因为insert
是插入,也就是之前没有数据,那么insert
也就没有历史版本。但是一般为了回滚操作,insert的数据也是要被放入undo log中,如果当前事务commit了,那么这个undo log 的历史insert记录就可以被清空了。update
和delete
可以形成版本链,insert
暂时不考虑。select
呢?首先,select
不会对数据做任何修改,维护多版本的意义就是为了select
。不过,此时有个问题,就是:select读取,是读取最新的版本呢?还是读取历史版本?当前读:读取最新的记录,就是当前读。增删改,都叫做当前读,select也有可能当前读,比如:select lock in share mode(共享锁), select for update。
快照读:读取历史版本(一般而言),就叫做快照读。
我们可以看到,在多个事务同时删改查的时候,都是当前读,是要加锁的。那同时有select过来,如果也要读取最新版(当前
读),那么也就需要加锁,这就是串行化。但如果是快照读,读取历史版本的话,是不受加锁限制的。也就是可以并行执行!换言之,提高了效率,即MVCC的意义所在。
那么,是什么决定了,select是当前读,还是快照读呢?隔离级别!那为什么要有隔离级别呢?事务都是原子的。所以,无论如何,事务总有先有后。
经过上面的操作我们发现,事务从begin->CURD->commit
,是有一个阶段的。也就是事务有执行前,执行中,执行后的阶段。但,不管怎么启动多个事务,总是有先有后的。
那么多个事务在执行中,CURD操作是会交织在一起的。那么,为了保证事务的“有先有后”,应该让不同的事务看到它该
看到的内容,这就是所谓的隔离性与隔离级别要解决的问题。
先来的事务,应不应该看到后来的事务所做的修改呢?不应该。
那么,如何保证,不同的事务,看到不同的内容呢?也就是如何如何实现隔离级别?
select
的时候,就会生成一个ReadView
类的对象。下面是 简化 的 ReadView 源码结构:
class ReadView
{
// 省略...
private:
trx_id_t m_low_limit_id /** 高水位,大于等于这个ID的事务均不可见*/
trx_id_t m_up_limit_id; /** 低水位:小于这个ID的事务均可见 */
trx_id_t m_creator_trx_id; /** 创建该 Read View 的事务ID*/
ids_t m_ids; /** 创建视图时的活跃事务id列表*/
/** 配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG,
* 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/
trx_id_t m_low_limit_no;
bool m_closed; /** 标记视图是否被关闭*/
// 省略...
};
m_ids
:一张列表,用来维护Read View生成时刻,系统正活跃的事务IDup_limit_id
:记录m_ids列表中事务ID最小的ID(没有写错)low_limit_id
:ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1(也没有写错)creator_trx_id
:创建该ReadView的事务ID。 我们在实际读取数据版本链的时候,是能读取到每一个版本对应的事务ID的,即:当前记录的 DB_TRX_ID
。那么,我们现在手里面有的东西就有,当前快照读的 ReadView 和 版本链中的某一个记录的 DB_TRX_ID
。所以现在的问题就是,当前快照读,应该读到当前版本记录吗?
一张图,试图解决所有问题:
changes_visible,源码如下:
事务操作:
//事务2的 Read View
m_ids; // 1,3
up_limit_id; // 1
low_limit_id; // 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id // 2
我们的事务2在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID
去跟 up_limit_id
,low_limit_id
和活跃事务ID列表(trx_list
) 进行比较,判断当前事务2能看到该记录的版本。
//比较步骤
DB_TRX_ID(4)< up_limit_id(1) ? 不小于,下一步
DB_TRX_ID(4)>= low_limit_id(5) ? 不大于,下一步
m_ids.contains(DB_TRX_ID) ? 不包含,说明,事务4不在当前的活跃事务中。
select * from user lock in share mode
,以加共享锁方式进行读取,对应的就是当前读。RR 与 RC的本质区别