在我们之前的“索引”章节中,我们介绍了MySQL的存储引擎,它才真正的去执行创建,查询,更新和删除数据的操作。
从上图来看,MySQL主要由 “Server层” 和 “存储引擎” 两部分构成。MySQL的Server层其实并不会直接的去操作数据,都是交由存储引擎来执行的。不同的存储引擎提供不同的存储机制,索引技术,锁技术以及事务支持等等。MySQL的存储引擎有很多,比较常用的就两个,一个是InnoDB,一个是MyISAM。MySQL之前的版本默认使用MyISAM引擎,从5.5开始默认使用InnoDB引擎。
查看MySQL支持的存储引擎:show engines;
MyISAM拥有较高的插入和查询速度,但不支持事物。而InnoDB是事务型数据库的首选引擎,支持事务,支持行级锁定,支持外键约束。如何进行存储引擎的选择:
第一,如果要提供事务完全能力,并要求实现并发控制,InnoDB是最好的选择。
第二,如果主要是插入和查询数据,则MyISAM是最好的选择。
第三,如果只是临时存放数据,且数据量不大,可以使用Memory(内存)引擎提高查询速度。
以上只是简单的建议,接下来,我们介绍本章节的重点内容-事务。注意,只有选择InnoDB引擎才支持事务哦。事务是一系列SQL操作的集合。这些操作要么全做,要么全不做。因为事务代表了一个完整的业务流程操作,如果中间发生了错误而导致整个业务没有正确执行,那么数据的正确性可能会遭受破坏。因此,要么数据正确执行,要么就不要执行。很明显,事务的存在是为了保证被操作数据的安全性。
如何在MySQL中的事务管理,其实就是如下三个关键字:
start transaction 开启事务
commit 提交事务
rollback 回滚事务
管理事务处理的关键在于对SQL语句执行结果的判断,并明确规定什么样的结果数据应该commit提交,什么样的结果数据应该rollback回退。请注意的是,事务处理用来管理insert、update和delete 语句,不适用于select语句。我们对事务的管理,大部分是在应用程序中实现(也可以在存储过程中实现后被应用程序调用)。例如,用户的下单流程,我们需要写入订单表,订单商品表,用户支付日志,商品库存减少等等一系列的操作。我们首先使用start transaction开启事务,然后依次执行insert订单表和订单商品表,insert支付日志,update商品库存的一系列SQL操作。假如有一个SQL操作失败,那么整个订单流程中涉及的SQL都将回滚(数据恢复到下单之前的状态),也就是下单失败了。一般情况下,SQL执行失败会抛出SQL异常,也就是说,如果我们发现有SQL异常的话,就可以使用rollback进行事务回滚了。当然,如果没有异常抛出,就代表事务执行成功,我们就可以使用commit提交事务了。因此,事务的使用主要就是根据业务需求将不同的SQL操作规划到一个事务中去。
另外,MySQL不支持嵌套事务,开启一个事务的情况下,再开启另一个事务,会隐式的提交上一个事务。很多应用框架会使用一个计数器来记录事务的层级,默认为0,每次调用开启事务时候,计数器加1,事务提交或者回滚的时候,计数器减1,当计数器为0的时候,事务才会被实际的触发并执行。在MySQL中可以使用savepoint语句间接实现事务的嵌套。它的大致流程为,首次开启事务后,使用savepoint创建一个保存点1,然后执行第一次的操作并提交。此时,我们可以继续创建一个保存点2,然后执行第二次操作。如果第二次操作失败,则使用rollback to savepoint语句回滚到保存点。如果选择保存点1,那么第一次操作也就被回滚了;如果选择保存点2,那么第一次的操作结果保留。但是,第二次的操作内容肯定是没有的。如果第二次操作执行成功,再次提交整个事务(所有修改全部提交)。请注意,第一次操作和第二次操作,仍然是在一个完整的事务中执行的。只不过,我们可以在这个完整的事务执行当中,可以按照指定的保存点进行指定数据回滚而已。
接下来,我们继续介绍,为了能够保证数据的安全性,我们要求事务具有四个特性:
第一,原子性(A),就是要么全做,要么全不做,它是一个整体。
第二,一致性(C),就是事务执行后,我们的数据都是正确的。
第三,隔离性(I), 就是事务与事务之间是互不干扰的。
第四,持续性(D),就是事务提交后,数据会永久性改变。
这里我们稍微介绍一下第二个特性(一致性)的理解。假如,张三给李四汇款100元,事务执行前后,张三的100块钱的数据是一直存在的。如果张三支出100元,李四接收100元时出错,那么就意味着100元凭空消失了。操作前有100元,操作后100元丢失。前后的数据一致性就没有了,这个结果显然是不能接受的。避免这个问题的产生,就是使用事务,要么汇款事务成功,100元从张三账户转移到李四上面,要么汇款事务失败,100元仍然留在张三的账户里面。
我们执行的一条条SQL语句,实际上都可以理解成一个个单独的事务。事务是基于数据库连接的,而每个数据库连接在MySQL中,又会用一个线程来维护。当出现多个事务同时执行时,就称之为并发事务,就是多线程并发执行事务。事务的ACID四要素是理想状态,在实际的多并发的场景中,会出现一些问题。如下所示:
第一,脏读:一个事务读到另一个事务未提交的数据。
第二,不可重复读:一个事务两次读取相同数据,读取的数据不一致。
第三,幻读:一个事务执行两次相同的范围查询,得到的结果不一样。
第四,丢失更新:事务执行后,将其他事务提交的数据覆盖了。
由此可见,事务在保证数据安全性的最大挑战就是并发。也就是说,当很多不同的事务同时修改相同数据的时候,就可能造成数据的不安全性。其实这个类似于Java下的多线程访问共享数据的情况,我们可以使用synchronized关键字来修饰方法,保证同一时间只有一个线程可以调用该方法访问共享数据。这个核心的逻辑就是一个“并”改“串”的过程,也就是同时并发过来的操作强制进行“排队”后一个一个的去访问共享数据。但是,这样做的结果就是降低整个系统的处理性能。因为,本来多线程就是为了提升程序的处理数据的能力,但是为了数据安全性,又将这种“并发”能力改为“串行”去执行,这前后是非常矛盾的。
为了解决这些问题,数据管理系统使用了不同的隔离级别。事务在并发执行时候应该相互独立互不干扰,即事务的隔离级。但是,实践中要实现完全的事务隔离,往往会导致系统的事务并发能力下降。因此,我们就需要通过“隔离级别”来权衡事务的隔离性和并发性。较低的隔离级可以增加并发,但代价是降低数据的正确性。相反,较高的隔离性可以确保数据的正确性,但又需要消耗过多的性能。SQL92标准定了了4种标准隔离级别,从低到高为:
第一,读取未提交(Read Uncommitted):最低隔离级别,任何事务都可以读取到其他未提交事务的结果。
第二,读取已提交(Read Committed):只能读取已提交事务的结果,不能读取正在执行事务修改的数据。
第三,可重复读(Repeatable Read):同一事务多次读取同一数据,得到的结果是一致的。
第四,可串行化(Serializable):最高隔离级别,强制事务串行执行。
显然,第一种和第四种就是事务隔离级的两个极端情况,我们一般不会选择。MySQL数据库默认使用第三种可重复读(Repeatable Read)的隔离级别,它有效避免了脏读和不可重复读的发生,但是仍然无法避免幻读的情况发生。所谓幻读指的是,当某个事务读取某个范围的纪录时候,另外一个事务在该范围内插入新的纪录,当之前的事务再次读取该范围的纪录时候,就会产生前后不一致的情况发生。除非我们对这个范围的数据进行“锁定”,也就是不允许其他事务对这个范围的数据进行修改操作,显然这个不太合理。InnoDB引擎通过多版本并发控制(MVCC)解决了幻读的问题。MVCC的实现原理就是保存数据在某个时间点的快照。也就是说,根据事务开始的时间点不同,每个事务对同一张表,同一个时刻看到的数据可能是不一致的。但同一个事务执行相同的查询,得到的结果是一致的。
接下来,我们继续说明“丢失更新”的问题。我们以修改库存为例说明这个问题。假如某件商品的库存为1000件,有两个出库单并发执行,第一个出库单是800件,第二个出库单是300件。很明显,两个出库单不能同时成功,否者就超出库存1000件了。但是,由于是并发操作,我们读取库存的时候,两个操作均读取的1000件库存,然后与各自的800件或者300件进行对比的话,都满足出库的条件,于是两个出库单都成功执行了。但是,到了修改库存的时候,就会出现一个严重问题,就是后面的库存修改会覆盖前面的库存修改。例如,先执行800件的出库,修改库存为“update 库存表 set 商品库存=200 where 商品id=xxx”;后执行300件的出库,修改库存为“update 库存表 set 商品库存=700 where 商品id=xxx”。最后我们发现,两个出库单都成功了,并且库存还剩余700件,显然这是错误的。反之,先执行300件库存,后执行800件库存,两个出库单都成功了,库存剩余200件,这也是不对的。如何解决这个问题呢?可以使用“悲观锁”或者“乐观锁”来解决。请注意,这里说的两个锁只是一种解决问题的思想,并不是一种技术是实现。在MySQL中主要是通过"读锁/写锁"来实现并发控制,这是一种技术实现。
读锁(read lock):也叫共享锁(share lock),多个读操作可以同时共享一把锁来读取数据,而不会造成阻塞。
写锁(write lock):也叫排他锁(exclusive lock),写锁会排斥其他所有获取锁的请求,一直阻塞,直到完成写入并释放锁,其他请求才会继续执行。
读锁和写锁是按照事务对数据对象的封锁程度进行划分的。所谓读锁就是读取数据的时候加的锁,就是对读取的数据进行锁定(不允许其他请求进行修改),但允许其他请求并行读取。原因很简单,只要数据不改变,无论何时读取,无论多少人读取都是正确的。而写锁就不一样了,它涉及到数据的修改(也就是update,insert,delete语句),此时不允许其他任何请求读取或者修改,以保证数据的安全性。显然,写锁就是我们之前介绍“并发”改为“串行”的技术实现。锁与事务的关系就是,不同的隔离级别底层就是用不同的锁来实现的。上面提及的读写锁是根据MySQL的锁类型来划分的,而读写锁能够施加的粒度在数据库中主要体现为表和行,也称为表锁(table lock)和行锁(row lock)。顾名思义,表锁就对整个表数据进行锁定,而行锁只是对涉及的行记录进行锁定,显然行锁更加合理化,并发效率也高。这里还要知道一个“死锁”的概念。如果一个事务申请锁而未获准,则须要等待其他事务释放该锁,这就形成了事务的等待关系。结果很巧的是,被等待的事务正好去需要申请当前事务持有的锁。说白了,两个事务相互等待对方的锁,就会出现“死锁”现象。MySQL本身会对死锁做出一定的处理,但是造成死锁的原因还是我们应用程序本身。
MySQL并发事务访问相同数据有如下三种情况:
1、读–读
读取操作不会对记录有任何影响,所以这种情况是不存任何问题的,通常我们也不会在读取操作上面增加事务的操作。
2、写–写
并发事务相继对相同的记录数据作出修改。在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种问题的发生。所以当多个事务并发对一条记录做修改时,需要让它们排队执行,这个排队的过程其实是通过写锁来实现的。
3、读–写 或 写–读
即一个事务进行读取操作,另一个进行修改操作。这种情况下可能发生脏读、不可重复读、幻读的并发问题。MySQL默认使用第三种可重复读(Repeatable Read)的隔离级别上就已经解决了脏读、不可重复读、幻读这个三个问题。
我们继续回到解决“丢失更新”的问题,也就是使用“悲观锁”或者“乐观锁”来解决。这个“悲观锁”或者“乐观锁”与数据库中的行锁/表锁,读锁/写锁有一定的关系。前者是一个解决问题的思想,后者是趋近于“悲观锁”或者“乐观锁”思想的技术实现。
悲观锁指的是采用一种持悲观消极的态度,默认数据被外界访问时,必然会产生冲突,所以在数据处理的整个过程中都采用加锁的状态,保证同一时间,只有一个线程可以访问到数据。悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,加锁的机制会让数据库产生额外的开销,还可能产生死锁的情况发生。通常,悲观锁是利用数据库本身提供的写锁机制去实现的,它可以解决读-写冲突和写-写冲突两种情况。虽然能保证数据的安全,但数据处理吞吐量低,不适合在读多写少的场合下使用。
乐观锁与悲观锁相反,它假设认为数据一般情况下不会造成冲突,只会在数据进行提交更新的时候,才真正对数据进行冲突检测,如果发现冲突了,则会返回错误信息,让开发人员决定如何去做。请注意,乐观锁的目的是不使用用锁的情况下,保证数据完全性。通常,有两种方式实现乐观锁,第一就是数据版本,第二就是业务限制;首先,介绍数据版本。它是通过为数据库表增加一个 “version” 字段来实现的。首先,我们读出业务数据时,会将此业务数据对应的版本号(version字段)一同读出,之后对业务数据做更新时,会同时对此版本号加1。此时,将本次业务中一开始读出的业务数据所对应的版本号与数据库表中对应记录的当前版本号进行比对,如果提交的业务数据版本号大于数据库表当前版本号,则予以更新,否则,则认为是过期数据。可能有人疑问了?为什么提交的版本号会小于等于数据库里面的版本号呢?那是因为有其他并发的业务对此进行了+1的修改了。请注意,对版本号+1的操作属于update操作,这个肯定是通过写锁来控制的。当时,由于并发的情况,当你准备修改的时候,很可能其他事务已经做完了+1的修改操作。那么此时,当前事务里面的版本号就“落后”了。第二就是业务限制,这个取决于业务本身的需求。我们以减少库存为例,当我们减少库存的时候,不要直接使用“update 库存表 set 商品库存=200 where 商品id=xxx”这样的更新语句,而要使用“update 库存表 set 商品库存=商品库存-800 where 商品id=xxx and商品库存-800>0”去更新。因为这条更新语句能够保证库存扣减正确,并且不会产生库存为零的情况发生。缺点在于,失败后,需要我们自己来处理,给客户一个什么样的响应信息。由此可见,乐观锁是基于不加锁的情况下保证数据的正确性,因此它的性能更加优越,可以处理更多的并发事务执行。
数据库中的乐观锁更倾向叫乐观并发控制(OCC),悲观锁叫悲观并发控制(PCC),还有区别于乐观悲观锁的一种控制叫MVCC,多版本并发控制。MVCC在InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突。说白了,就是当遇到读锁的时候,我们依然能够读取到想要的数据。在MVCC中,读操作可以分成两类,快照读(Snapshot read)和当前读(current read)。多版本并发控制就是生成一个 ReadView(数据快照)。快照读,读取的是记录数据的可见版本(数据快照),不会对返回的记录数据加锁;而当前读,读取的是记录的最新版本,并且会对返回的记录数据加锁,保证其他事务不会并发修改这条记录。MySQL数据库默认使用可重复读(Repeatable Read)的隔离级别。在这个隔离级别下:一个事务只有在第一次执行select操作才会生成一个 ReadView,之后select操作都复用这个ReadView,这样也就避免了不可重复读和幻读的情况。
那么,什么时候是快照读,什么时候是当前读呢?在InnoDB中,简单的select操作,如“select * from table where xxxx”都属于快照读;而当前读的包含以下操作:
第一,select * from table where xxxx lock in share mode; (读取加读锁)
第二,select * from table where xxxx for update; (读取加写锁)
第三,insert, update, delete操作(加写锁)
我们总结一下:
悲观锁,即悲观并发控制(PCC)是一种用来解决读-写冲突和写-写冲突的的加锁并发控制。同一时间下,只有获得该锁的事务才能有权利对该数据进行操作,没有获得锁的事务只能等待其他事务释放锁;所以可以解决脏读,幻读,不可重复读等问题。
乐观锁,即乐观并发控制(OCC)是一种用来解决写-写冲突的无锁并发控制,它认为事务间冲突没有那么多,只有在真正修改数据的时候,通过一定的机制(数据版本和业务限制)进行判断是否可以进行修改操作。乐观并发控制适用于低数据争用,写冲突比较少的环境。它无法解决脏读,幻读,不可重复读,但是可以解决更新丢失问题。
多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,读操作只读该事务开始前的数据库的快照。这样在读操作时就不用阻塞写操作,写操作也不用阻塞读操作;不仅可以提高并发性能,还可以解决脏读,幻读,不可重复读等问题。
接下来,我们再介绍一下间隙锁Gap Lock,它主要是为了解决幻读问题。当我们使用范围条件查询数据的时候,InnoDB引擎会给符合条件的数据记录的索引加间隙锁Gap Lock。它是锁定记录之间的间隙,而不是锁定记录本身,其主要目的是阻止在锁定范围内插入新记录,从而在大部分情况下防止了幻读现象。这种锁会在可重复读(Repeatable Read)隔离级别下的事务中根据操作类型自动被InnoDB引擎添加。以下是在可重复读(Repeatable Read)隔离级别下会自动添加间隙锁Gap Lock的情况:
第一, 当执行范围查询(如between或 > 操作符)时,InnoDB会在查询范围内的间隙自动添加间隙锁Gap Lock,以防止其他事务在查询范围内插入新记录。
第二,当执行update或delete操作时,如果涉及到一个范围内的记录,InnoDB会自动在该范围内的间隙上添加间隙锁Gap Lock,防止其他事务在这些间隙中插入新记录。
虽然间隙锁Gap Lock在大部分情况下能够有效防止幻读现象,但由于InnoDB的MVCC(多版本并发控制)机制,在某些特殊情况下仍可能出现幻读。并且,由于它锁定的范围无法确定大小,可能会对性能造成一定的危害,甚至产生死锁的情况。当InnoDB检测到死锁时,它会选择一个事务(通常是等待时间较长的事务)作为死锁的受害者,将其回滚以释放锁,从而解决死锁问题。我们在研发过程中,也要尽量避免死锁的情况发生。
最后我们在说一下行锁和表锁。
首先介绍表锁,语法如下:
给表加读锁:lock table 表名 read;
给表加写锁:lock table 表名 write;
解除表锁:unlock table;
一般情况下,我们不会对表进行加锁,因为它影响范围太大,性能不行。我们上文中介绍的读锁或写锁,都是指的行锁。默认情况下,select 不会上任何锁(快照读),而insert、update、delete 会自动上写锁。我们可以使用如下语句给select上读锁或者写锁,如下所示:
select * from tableName lock in share mode; // 读锁
select * from tableName for update; // 写锁
请注意,即使我们给select上的写锁,其他select语句也可以读取的,因为InnoDB有MVCC机制(多版本并发控制),可以使用快照读,而不会被阻塞。
最后,我们介绍一个“行锁”变“表锁”的情况。例如,当我们进行update操作的时候,我们的where条件中的字段没有创建索引的时候,这个“行锁”会变成“表锁”。这是因为,InnoDB 的行锁是在索引上实现的,而不是锁在物理行记录上。所以如果访问没有命中索引,也无法使用行锁,将要退化为表锁。