-
这篇文章原本我发在内网上,由于没有牵扯到内部资料,就发到博客上了
-
前言
因为最近在做数据库和搜素引擎同步相关的工作,学习了数据库事务相关的知识。事务相关的知识,尤其是数据库内的各种锁机制也是相当复杂的,在此仅作为一个初学者和使用者的角度作为一个分析和整理。整理过程中我放弃了许多书面而难懂的定义解释(网上很多资料杂乱难懂废话又多还不一定对),尝试找到比较容易理解的解释。有问题的话也欢迎修正。
ACID
首先作为数据库事务,需要保障的性质如下:
原子性:要么全部完成,要么全部不完成。
隔离性:事务的中间状态不能被另一个事务看到。(详见下面的隔离级别)
持久性:事务提交后即持久化到磁盘不会丢失。
一致性:数据库的完整性约束要得到保证。(例如转账,两个账户的和应该不变)
事务的隔离级别
在复习隔离级别之前,先看一下几个与之相关的定义:
- 脏读:一个事务读取了其他事务还没有提交的变更
- 不可重复读:同样的条件,读取过的数据,再次读取出来发现值不一样了。(因为另一个事务对该条件的数据 修改提交了)
- 幻读:同样的条件,第一次和第二次读出来的记录数不一样。(因为另一个事务对该条件的数据 插入/删除 提交了)
- 第一类丢失更新:一个事务的回滚覆盖了另一个事务提交的update。
- 第二类丢失更新:一个事务提交的update覆盖了另一个事务提交的update。
而数据库的隔离级别可以看做这几种条件满足的组合:
隔离级别 | 脏读 | 不可重复读 | 幻读 | 第一类丢失更新 | 第二类丢失更新 |
---|---|---|---|---|---|
READ UNCOMMITED | 允许 | 允许 | 允许 | 不允许 | 允许 |
READ COMMITTED | 不允许 | 允许 | 允许 | 不允许 | 允许 |
REPEATABLE READ | 不允许 | 不允许 | 允许 | 不允许 | 不允许 |
SERIALIZABLE | 不允许 | 不允许 | 不允许 | 不允许 | 不允许 |
MySql的隔离级别
需要明确的是,以上的ACID和隔离级别定义是在SQL规范层面的定义,不同数据库的实现方式和使用方式并不相同。(类似于JVM规范和JVM厂商的关系)
上面的隔离级别标准是SQL92基于读写锁的实现方式制定的规范,可惜之后的MVCC成为了各大厂商的主流实现方式(实际上可以理解为第五个隔离级别:SNAPSHOT ISOLATION),为了兼容落后的规范,数据库引擎们都想办法“贴”近四大隔离级别,但是和标准的预期肯定不会完全一致的。
除了MySql,包括Oracle在内的大多数数据库的默认隔离级别都是READ_COMMITED,而MySql的默认事务隔离级别是REPEATABLE_READ。(以下关于MySql的说明均指InnoDB引擎下的MySql)
MySql的REPEATABLE_READ隔离级别
先一句话总结:由于实现方式已经偏离了标准的预期,MySql没有严格遵守SQL92规范,在REPEATABLE_READ级别中,不会出现幻读,而且第二类丢失更新需要用户自己加锁实现。
从隔离级别规范上看,它阻止了脏读和不可重复读,这是通过MVCC多版本并发控制解决的。解决脏读的办法是consistent read,通过时间戳避免读到事务提交之前的操作。对于不可重复读,在consistent read的基础上,让一个事务的每次读操作 都去读该事务第一次读操作时的快照版本。详见http://dev.mysql.com/doc/refman/5.7/en/innodb-consistent-read.html
对于幻读,尽管SQL规范中,REPEATABLE READ允许幻读,但是MySql的REPEATABLE READ级别上不会出现幻读,因为它使用了间隙锁(Next-key lock)解决了幻读的问题。详见 http://dev.mysql.com/doc/refman/5.7/en/innodb-record-level-locks.html
但是。如果想解决第二类丢失更新,并不像我们想象中直接把代码或者sql语句套进事务里就可以了。如下:
悲观锁解决丢失更新的问题
举个例子:
begin;
SET @a = (SELECT restnum FROM book WHERE id =1)
SET @b = function(@a) //抽象为某个业务逻辑操作
UPDATE book SET restnum=@b WHERE id =1
commit;
类似于上面的代码套进事务中,其实在传统数据库的REPEATABLE_READ下是安全的;传统数据库对于事务的处理方式为:如果有写操作,则加独占锁,直到commit才会释放。独占锁意味着其他session既不能读也不能写这块数据。在高并发下依然很容易出现丢失更新。但是现代数据库引擎都是基于MVCC实现的,其最大特点就是即使独占锁锁住了这份数据,其他session是可以读到它写之前的副本的,带来的弊端就是没有严格的保证写读的先后顺序关系。
因此在REPEATABLE_READ级别下,我们需要手动加锁。正确的写法是:
begin; //注意要关闭auto commit
SET @a = (SELECT restnum FROM book WHERE id =1 FOR UPDATE)
SET @b = function(@a) //抽象为某个业务逻辑操作
UPDATE book SET restnum=@b WHERE id =1
commit;
如上,SELECT...FOR UPDATE 是排他锁,会锁住数据(如果where指定了主键的话会锁行,否则会锁表),直到该事务结束(提交或回滚)。
与此类似的语句还有SELECT ... LOCK IN SHARE MODE是共享锁,session拿到该锁后,其他session只能读不能修改锁住的数据直到session结束,如果session拿锁时其他session对数据还有未提交的更改则会阻塞等待对方提交。
两种锁都是悲观锁,一行数据的排他锁同时只能一个session持有,而共享锁是可以多个session共享持有的,共享锁也因此容易产生 死锁 。
MySql的SERIALIZABLE隔离级别就是在REPEATABLE READ的基础上,把所有纯SELECT操作加上LOCK IN SHARE MODE。因此,上面的第一份代码,如果将隔离级别换为SERIALIZABLE,在高并发下的结果并不会像我们想象中逐个执行,而是会产生死锁。
乐观锁解决丢失更新的问题
在实际使用中,我们其实很少使用悲观锁,因为阻塞可能会造成未知的性能问题。使用乐观锁,通常是通过版本号或者时间戳,在最后提交时进行比对:
SELECT (restnum,version) FROM book WHERE id =1)
...... //业务逻辑操作
UPDATE book SET restnum=@xx WHERE id =1 AND version = @version
当然,理想状态下,最好能直接用一个UPDATE语句实现,因为UPDATE会加排他锁,也就是不会出现上面的问题了。