MySQL最重要、最与众不同的特性是它的存储引擎架构,这种架构的设计将查询处理(Query Processing)及其他系统任务(Server Task)和数据的存储 / 提取相分离。这种处理和存储分离的设计可以在使用时根据性能、特性,以及其他需求来选择数据存储的方式。
MySQL的逻辑架构图如下图所示。它有助于深入理解MySQL服务器。
最上层的服务并不是MySQL所独有的,大多数基于网络的客户端 / 服务器的工具或服务都有类似的架构。比如连接处理、授权认证、安全等。
第二层架构是MySQL比较有意思的部分。大多数MySQL的核心服务功能都在这一层,包括查询解析、分析、优化、缓存以及所有的内置函数(如日期、时间、数据和加密函数),所有跨存储引擎的功能都在这一层实现:存储过程、触发器、视图等。
第三层包含了存储引擎。存储引擎负责MySQL中数据的存储和提取。和GUN / Linux下的各种文件系统一样,每个存储引擎都有它的优势和劣势。服务器通过API与存储引擎进行通信,这些接口屏蔽了存储引擎之间的差异,使得这些差异对上层的查询过程透明。存储引擎API包含几十个底层函数,用于执行诸如“开始一个事务”或“根据主键提取一行记录”等操作。但存储引擎不会去解析SQL,不同存储引擎间也不会相互通信,而只是简单地响应上层服务器的请求。
无论何时,只要有多个查询需要在同一时刻修改数据,都会产生并发控制的问题。MySQL在两个层面进行并发控制:服务器层与存储引擎层。
在处理并发读或写时,可以通过实现一个由两种类型的锁组成的锁系统来解决问题。这两种类型的锁通常称为共享锁(shared lock)和排他锁(exclusive lock),也叫读锁(read lock)和写锁(write lock)。
读锁是共享的,或说是互相不阻塞的。多个客户在同一时间可以同时读取同一个资源,而互不干扰。
写锁是排他的。也就是说一个写锁会阻塞其他的写锁和读锁,这是出于安全策略的考虑。只有这样,才能确保在给定的时间里,只有一个用户能执行写入,并防止其他用户读取正在写入的同一资源。
一种提高共享资源并发性的方式就是让锁定对象更有选择性。尽量只锁定需要修改的部分数据,而不是锁定所有的资源。更理想的方式是,只对修改的数据片进行精确的锁定。任何时候,在给定的资源上,锁定的数据赵少,系统的并发度越高,只要相互间不发生冲突即可。
加锁也是需要消耗资源的。锁的各种操作,包括获得锁、检查锁是否已经解除、释放锁等,都会增加系统的开销。若系统花费大量的时间来管理锁,而不是存取数据,那系统的性能可能因此受到影响。
所谓的锁策略,就是在锁的开销和数据的安全性之间寻求平衡,这种平衡当然也会影响到性能。一般都是在表上施加行级锁(row level lock),并以各种复杂的方式来实现,以便在锁比较多的情况下尽可能地提供更好的性能。
MySQL则提供了多种选择。每种存储引擎都可以实现自己的锁策略和锁粒度。将锁粒度固定在某个级别,可以为某些特定的应用场景提供更好的性能,但同时却会失去对另外一些应用场景的良好支持。好在MySQL支持多个存储引擎的架构,所以不需要单一的通用解决方案。下面介绍最重要的几种锁策略。
存储引擎的锁粒度如下:
表锁是MySQL中最基本的锁策略,并且是开销最小的策略。表锁会锁定整张表。一个用户在对表进行写操作(插入、删除、更新等)前,需要先获得写锁,这会阻塞其他用户对该表的所有写操作。只有没有写锁时,其他读取的用户才能获得读锁,读锁之间是不相互阻塞的。
在特定的场景中,表锁也可能有良好的性能。如READ LOCAL表锁支持某些类型的并发写操作。另外,写锁也比读锁有更高的优先级,因此一个写锁请求可能会被插入到读锁队列的前面(写锁可以插入到锁队列中读锁的前面,反之则不行)。
页销的加锁时间界于表锁和行锁之间,会出现死锁,锁的粒度界于表锁和行锁之间,并发度一般。应用于BDB引擎。
行级锁可以最大程序地支持并发处理,但同时也带来了最大的锁开销。在InnoDB和XtraDB,以及其他一些存储引擎中实现了行级锁。行级锁只在存储引擎层实现,而MySQL服务器层没有实现。服务器层完全不了解存储引擎中的锁实现。
锁粒度 | 特点 |
表锁 | 开销小,加锁快,不会出现死锁。但锁粒度大,发生锁冲突的概率最高,并发度最低。存储引擎总是一次性同时获得所需要的锁以及总是按相同的顺序获得表锁来避免死锁;更适合于以查询为主,并发用户少,只有少量按索引条件更新数据的应用。 |
页锁 | 开销的加锁时间界于表锁和行锁之间,会出现死锁,锁的粒度界于表锁和行锁之间,并发度一般。 |
行锁 | 开销大,加锁慢,会出现死锁。但锁粒度最小,发生锁冲突的概率也最低,并发度最高。可以最大程序的支持并发,同时也带来了最大的锁开销。在InnoDB中,除了单个SQL组成的事务外,锁是逐步获得的,这就决定了在InnoDB中发生死锁是可能的。行锁只在存储引擎层实现,而在MySQL服务器层没有实现。行级锁更适合于有大量按索引条件并发更新少量不同数据,同时又有并发查询的应用,如一些事务处理(OLTP)系统。 |
事务就是一组原子性的SQL查询,或说是一个独立的工作单元。事务内的语句,要么全部执行,要么全部执行失败。
银行应用是解释事务必要性的一个经典例子。假设一个银行的数据库有两张表:支票(checking)表和储蓄(savings)表。现在要从用户张三的支票账户转移1000元到他的储蓄账户,那需要至少三个步骤:
上述三个步骤的操作必须打包在一个事务中,任何一个步骤失败,则必须回滚所有的步骤。即三个步骤要么全部执行成功,要不全部执行失败,不能存在3步中有成功和不成功同时存在的情况。
可以用START TRANSACTION语句开始一个事务,然后要么使用COMMIT提交事务将修改的数据持久保留,要么使用ROLLBACK撤销所有的修改。事务SQL的样本如下:
1 START TRANSACTION;
2 SELECT balance FROM checking where customer_id = 10233276;
3 UPDATE checking SET balance = balance - 1000 WHERE customer_id = 10233276;
4 UPDATE savings SET balance = balance + 1000 WHERE customer_id = 10233276;
5 COMMIT;
即提交事务的语句是:
START TRANSACTION;
UPDATE ......; -- 修改语句,插入、删除、修改都可以
COMMIT;
回滚的语句如下。ROLLBACK只能在一个事务处理内使用(在执行一条START TRANSACTION命令后)。
START TRANSACTION;
UPDATE ......; -- 修改数据语句,如插入、删除和更新等
ROLLBACK;
脏读(Dirty Read):一个事务正对一条记录进行修改,在这个事务完成并提交前,这条记录的数据就处于不一致的状态,且这条数据对其它事务是可见的。这时,另一个事务也来读取同一条记录,若不加控制,第二个事务读取了这些脏的数据,并进数据进行处理,就会产生未提交的数据依赖关系。主要是因为其它事务读取了未提交的数据。
不可重复读(Non-Repeatable Read):一个事务在读取某些数据后的某个时间,再次读取之前读取过的数据,即发现后一次读取的数据已经发生了改变。两次执行同样的查询,得到的却是不一样的结果。被其它事务修改并且提交。
可重复读(Repeatable Read):同一个事务多次执行同样的查询,读取到的数据是一样的。
幻读(Phantom Read):当某个事务在读取某个范围内的记录时,另一个事务又在该范围插入了新的记录,当之前的事务再次读取该范围的记录时,会产生幻行(Phantom Row)。这里主要是说行数发生了变化,而非是数据值变化了,就是读取到的记录数发生了变化。被其它事务删除或插入了数据。
脏读、不可重复读和幻读,其实都是数据库一致性的问题,必须由数据库提供一定的事务隔离级别来解决。一是可以读取数据前对其加锁,阻止其它事务对数据进行修改。另一种方法是不加任何锁,通过一定机制生成一个数据库请求时间点的一致性快照(Snapshot),并用这个快照来提供一定级别的一致性读取。
不可重复读的重点是被其它事务修改了数据,这样导致读取到的数据值变化了,而记录数并没有变化。而幻读的重点是被其它事务删除或新增了数据,读取的记录数发生了变化。而脏读是其它事务读取了前一个事务执行的修改操作,这个修改操作完成了但未提交,两个事务间的数据又是可见的。
更新丢失(Lost Update):当两个或多个事务选择同一行数据,然后基于最初选定的值更新该行的数据。由于每个事务都不知道其他事务的存在,就会发生更新丢失的问题——最后的更新操作覆盖了前面的更新。
更新丢失通常是应该完全避免的。但防止丢失更新,不能仅靠数据库事务控制器来解决,需要应用程序对要更新的数据加必要的锁来解决。可以说,防止丢失更新是应该处理的责任。
ACID表示原子性(atomicity)、一致性(consistency)、隔离性(isolation)和持久性(durability)。一个运行良好的事务处理系统,必须具备这些标准特征。
原子性(atomicity)
一个事务必须是被视为一个不可分割的最小工作单元,整个事务中的所有操作要么全部提交成功,要么全部失败回滚。对一个事务来说,不可能只执行其中的一部分操作,这就是事务的原子性。
一致性(consistency)
数据库总是从一个一致性的状态转换到另一个一致性的状态。在前面的银行例子中,一致性得到了确保。即使在第三、四条语句之间时系统崩溃,支票账户中也不会损失1000元,因为事务最终没有提交,所以事务中所做的修改不会保存到数据库中。
隔离性(isolation)
一个事务所做的修改在最终提交以前,对其他事务是不可见的。在前面的例子中,当执行完第三条语句、第四条语句还未开始时,此时另一个账户汇总程序开始运行,则它看到支票账户的余额并没有被减去1000元。
持久性(durability)
一旦事务提交,则其所做的修改就会永远保存到数据库中。此时即使系统崩溃,修改的数据也不会丢失。
事务的ACID特性可以确保银行不会弄丢你的钱(在数据库层面来说的)。而在实际的应用逻辑中,要实现这一点非常难,甚至可以说是不可能完成的任务。一个兼容ACID的数据库系统,要做很多复杂但可能用户并没有觉察到的工作,才能确保ACID的实现。
就像锁粒度的升级会增加系统开销一样,这种事务处理过程中额外的安全性,也需要数据库系统做更多的额外工作。对MySQL,用户可以根据业务是否需要事务,来选择合适的存储引擎。对一些不需要事务的查询类应用,选择一个非事务类型的存储引擎,可以获得更高的性能。即使存储引擎不支持事务,也可以通过LOCK TABLES语句为应用提供一定程序的保护。
上面提到的隔离性其实比想象的要复杂。在SQL标准中定义了四种隔离级别,每一种级别都规定了一个事务中所做的修改,哪些在事务内和事物间是可见的,哪些是不可见的。较低级别的隔离通常可以执行更高的并发,系统的开销也更低。
每种存储引擎实现的隔离级别也不尽相同。若熟悉其它的数据库产品,可能会发现某些特性和期望的会有些不一样。这里不做详细的讨论,有兴趣可查阅相关手册。
READ UNCOMMITTED(未提交读)
在READ UNCOMMITTED级别,事务中的修改,即使没有提交,对其它事务也是可见的。事务可以读取未提交的数据,这也称为脏读(Dirty Read)。这个级别会导致很多问题,从性能上说,READ UNCOMMITED不会比其他级别好太多,但却缺乏其他级别事务的很多好处,除非真的有非常必要的理由,在实际应用中一般很少使用。
READ COMMITTED(提交读,简称RC)
大多数数据库系统默认的隔离级别都是READ COMMITTED,但MySQL却不是。READ COMMITTED满足前面提到的隔离性的简单定义:一个事务开始时,只能看见已经提交的事务所做的修改。换言之,就是一个事务从开始直接到提交前,所做的任何修改对其他事务都是不可见的。这个级别有时候也叫不可重复读(nonrepeatable read),因为两次执行同样的查询,可能会得到不一样的结果。
REPEATABLE READ(可重复读,简称RR)
REPEATABLE READ解决了脏读的问题。这是MySQL默认的隔离级别。该级别保证了在同一个事务中多次读取同样的记录的结果是一致的。但在理解上,可重复读隔离级别还是无法解决另一个幻读(Phantom Read)的问题。InnoDB和XtraDB存储引擎通过多版本并发控制(MVCC,Multiversion Concurrency Control)解决了幻读的问题。
SERIALIZABLE(可串行化)
SERIALIZABLE是最高的隔离级别。它通过强制事务串行执行,避免了前面说的幻读的问题。简单来说,串行化会在读取的每一行数据上都加锁,所以可能导致大量的超时和锁争用的问题。实际应用中也很少用到这个隔离级别,只有在非常需要确保数据的一致性且可以接受没有并发的情况下,才考虑采用该级别。
死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。当多个事务试图以不同的顺序锁定资源时,就可能产生死锁。多个事务同时锁定同一资源时,也会产生死锁。比如,设想下面两个事务同时处理StockPrice表:
事务1:
START TRANSACTION;
UPDATE StockPrice SET close = 45.50 WHERE stock_id = 4 AND date = '2002-05-01';
UPDATE StockPrice SET close = 19.80 WHERE stock_id = 3 AND date = '2002-05-02';
COMMIT;
事务2:
START TRANSACTION;
UPDATE StockPrice SET high = 20.12 WHERE stock_id = 3 AND date = '2002-05-02';
UPDATE StockPrice SET high = 47.20 WHERE stock_id = 4 AND date = '2002-05-01';
COMMIT;
如果凑巧,两个事务都执行了第一句UPDATE语句,更新了第一行数据,同时也锁定了该行的数据,接着每个事务都尝试去执行第二条UPDATE语句,却发现该行已经被对方锁定,然后两个事务都等待对方释放锁,同时又持有对方需要的锁,则陷入死循环。除非有外部因素介入才可能解除死锁。
为了解决这个问题,数据库系统实现了各种死锁检测和死锁超时机制。越复杂的系统,如InnoDB存储引擎,越能检测到死锁的循环依赖,并立即返回一个错误。这种解决方式很有效,否则死锁会导致出现非常慢的查询。还有一种解决方式,就是当查询时间达到锁等待超时的设定后放弃锁请求,这种方式通常不太好。InnoDB目前处理死锁的方法是将持有最少行级排他锁的事务进行回滚,这是相对比较简单的死锁回滚算法。
锁的行为和顺序是和存储引擎相关的。以同样的顺序执行语句,有些存储引擎会产生死锁,有些则不会。死锁的产生有双重原因:有些是因为真正的数据冲突,这种情况通常很难避免,但有些则完全是由存储引擎的实现方式导致的。
事务日志可帮助提高事务的效率。使用事务日志,存储引擎在修改表的数据时只需要修改其内存拷贝,再把该修改行为记录到持久在磁盘上的事务日志中,而不用每次都将修改的数据本身持久到磁盘中。
事务日志采用的是追加的方式,因此写日志的操作是磁盘上一小块区域内的顺序I / O,而不像随机I / O需要在磁盘的多个地方移动磁头,所以采用事务日志的方式相对来说要快得多。
目前大多数存储引擎都是这样实现的,通常称之为预写式日志(Write-Ahead Logging),修改数据需要写两次磁盘。
若数据的修改已经记录到事务日志中并持久了,但数据本身还没有写回磁盘,此时系统存储引擎在重启时能够自动恢复这部分修改的数据。具体的恢复方式要视存储引擎来定。
InnoDB的事务日志包含redo log和undo log。redo log是记录修改的日志,提供非回滚的数据恢复操作,undo log是回滚日志,提供回滚操作。undo log并不是redo log的逆向过程,它们都是用来恢复数据的日志。
redo log
若系统突然崩溃,一些还存在于缓存中的修改还未来的及同步到磁盘中,此时可以用redo log来恢复这些数据。redo log就是记录这些修改的日志。redo log通常是物理日志,记录的是数据页的物理修改,而不是将某一行或某几行修改成什么样。它是用来恢复提交后的物理数据页(恢复数据页,且只能恢复到最后一次提交的位置)。
redo log包含两部分内容:内存中的日志缓冲(redo log buffer,该部分数据是容易丢失的)和磁盘上的重做日志文件(redo log file,该部分日志内容是持久的)。InnoDB在事务提交时,必须先将事务的所有日志写入到磁盘上的redo log file和undo log file中进行持久化。为了确保每次事务操作时日志都能写入到事务日志文件中,在每次将log buffer中的日志写入到日志文件的过程中,都会调用一次操作系统的fsync操作(即fsync()系统调用)。需要注意的是,一般所说的log file并不是磁盘上的物理日志文件,而是操作系统 的缓存中的log file。
redo log不是二进制日志,虽然二进制日志中也记录了InnoDB的很多操作。redo log是记录数据库中每个而的修改,是物理格式上的日志。在数据准备修改前写入缓存中的redo log中,然后才对缓存中的数据进行修改,且保存在发出事务提指令时,先向缓存中的redo log写入日志,写入完成后再执行提交操作。在redo log中,同一个事务可能会有多次记录,最后一个提交的事务记录会覆盖所有未提交的事务记录。是并发写入的,所以不同事务间的不同版本的记录会穿插写入到redo log文件中。它具有幂等性,因此记录日志的方式很简练。幂等性的是指多次操作前后状态是一样的,比如插入一行数据后又把它删除,前后状态没有变化。
redo log是以块为单位来进行存储的,每个块占用512字节,称为redo log block。不管是log buffer、os buffer还是redo log file disk,都是这样以512字节的块来存储的。
undo log
undo log是为了事务的回滚而记录的日志信息,用来回滚行记录到某个版本。undo log一般是逻辑日志,根据每行记录进行日志记录。undo log有插入、删除、更新数据三种类型,每种类型都会产生不同的undo log。undo log主要用两个作用:提供回滚和多版本并发控制(MVCC)。
在修改数据时,不仅记录了redo log,还记录了相对应的undo log。若因为某些原因导致事务失败或回滚了,那可借助undo log进行回滚。说undo log记录的是逻辑日志,可以认为当delete一条数据时,undo log中会记录一条对应的insert记录,反之亦然;当update一条数据时,它记录一条相应的update记录。当执行回滚操作时,就可以根据undo log的逻辑记录读取到相应的内容进行回滚。不是怎么操作的就怎么记录,这样的记录在回滚时会很麻烦。
应用到多版本并发控制时,当读取的一行数据被其它事务锁定时,它可以从undo log中分析出该行记录的以前的数据是什么样的,从而提供该行版本信息,记用记实现非锁定一致性读取。
undo log是采用段(segment)的方式来记录的,在记录每个undo操作时会占用一个undo log segment。
另外,undo log也会产生redo log,因为undo log也要实现持久性保护。
如想了解更多,可查看详细分析MySQL事务日志(redo log和undo log)。
MySQL提供了两种事务型的存储引擎:InnoDB和NDB Cluster。另外还有一些第三方的存储引擎也支持事务,较知名的有XtraDB和PBXT。
在5.0前,MySQL的binlog格式只有statement一种格式,而主从复制存在了大量的不一致,故选用REPEATABLE为MySQL的隔离级别。
MySQL默认采用自动提交(AUTOCOMMIT)模式。就是若不是显式地开始一个事务,则每个查询都被当作一个事务执行提交操作。在当前连接中,可通过设置AUTOCOMMIT变量来启用或禁用自动提交模式。1或ON表示开启,0或OFF表示禁用。当禁用自动提交时,所有的查询都是在一个事务中,直到显式的执行COMMIT提交或ROLLBAK回滚,该事务结束。
MySQL通过SET TRANSACTION ISOLATION LEVEL命令来设置隔离级别。新的隔离级别会在下一个事务开始时生效。
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
在事务中混合使用存储引擎
MySQL服务器层不管理事务,事务是由下层的存储引擎实现的。所以在同一个事务中,使用多种存储引擎是不可靠的。若在事务中混合使用了事务型和非事务型的表(如InnoDB和MyISAM表),在正常提交的情况下不会有什么问题。
但若该事务需要回滚,非事务型的表上的变更就无法撤销,这会导致数据库处于不一致的状态,这种情况很难修改。事务的最终结果将无法确定。
在非事务型的表上执行事务相关操作时,MySQL通常不会发生提醒,也不会报错。有时只有回滚时才会发生一个警告:“某些非事务型的表上的变更不能被回滚”。但大多数情况下,对非事务型的操作都不会有提示。
隐式和显式锁定
InnoDB采用的是两阶段锁定协议(two-phase locking protocol)。在事务执行过程中,随时都可以执行锁定,锁只有在执行COMMIT或ROLLBACK时才会释放,且所有的锁是在同一时刻释放的。前面描述的锁都是隐式锁定,InnoDB会根据隔离级别在需要的时候自动加锁。
另外,InnoDB也支持通过特定的语句来显式锁定,这些语句不属于SQL规范。
SELECT ... LOCK IN SHARE MODE
SELECT ... FOR UPDATE
MySQL也支持LOCK TABLES和UNLOCK TABLES语句,这是在服务器层实现的,和存储引擎无关。它们有自己的用途,但不能替代事务处理。若需要用到事务,还是应该选择事务型存储引擎。
可以发现,应用已将表从MyISAM转换到InnoDB,但还是显式的使用LOCK TABLES语句。这不但没有必要,还会严重影响发,实际上InnoDB的行级锁工作的更好。
若LOCK TABLES和事务之间想到影响的话,情况会变得非常复杂。因此,除了事务中禁用了AUTOCOMMIT,可以使用LOCK TABLES外,其他任何时候都不要显式的执行LOCK TABLES,不管用的是什么存储引擎。
MySQL大多数事务型存储引擎实现的都不是简单的行级锁。基于提升性能的考虑,一般都同时使用了多版本并发控制(MVCC,Multiversion Concurrency Control)。Oracle、PostgreSQL也是如此,但各自的实现机制不尽相同,因为MVCC没有统一的实现标准。
可以认为MVCC是行级锁的一个变种,但它是在很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也只是锁定必要的行。
MVCC的实现,是通过保存数据在某个时间点的快照来实现的。即不管需要执行多长时间,每个事务看到的数据都是一致的。根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能是不一样的。
乐观锁(乐观并发控制)与悲观锁(悲观并发控制)是并发控制主要采用的手段。无论是悲观锁还是乐观锁,都是人们定义出来的概念,可认为是一种思想。其实不仅仅是关系型数据库,像memcache、hibernate、tair等都有类似的概念。
对不同业务场景,应该选用不同的并发控制方式。不要把这两种锁狭义的理解 为DBMS中的概念,更不要把数据库中提供的锁机制(行锁、表锁、排他锁、共享锁)混为一谈。其实,在DBMS中,悲观锁正是利用数据库本身提供的锁机制来实现的。
当对数据库的一条数据进行修改时,为避免同时被其他人修改,最直接的办法就是对该数据加锁以防止并发。这种借助数据库锁机制在修改数据之前先锁定,再修改的方式称之为悲观并发控制,也叫悲观锁(Pessimistic Concurrency Control,PCC)。
总是假设是最坏的情况,持一种悲观的态度,每次获得数据时都认为别人会修改这个数据,所以每次都对要拿的数据加锁,这样要拿这个数据时,因为这个已经上了锁,所以会阻塞,直接别人拿到锁。
它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作都某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。
悲观锁的流程
MySQL InnoDB中使用的悲观锁
要使用悲观锁,我们必须关闭mysql数据库的自动提交属性,因为MySQL默认使用autocommit模式,也就是说,当你执行一个更新操作后,MySQL会立刻将结果进行提交。set autocommit=0。
//0.开始事务
begin;/begin work;/start transaction; (三者选一就可以)
//1.查询出商品信息
select status from t_goods where id=1 for update;
//2.根据商品信息生成订单
insert into t_orders (id,goods_id) values (null,1);
//3.修改商品status为2
update t_goods set status=2;
//4.提交事务
commit;/commit work;
在以上SQL中,对id = 1的记录修改前,先通过for update的方式进行加锁。这就是典型的悲观锁策略。若以上修改库存的代码并发,同一时间只有一个线程可以开启事务并获得id = 1的锁,其它事务必须等待本次事务提交后才能执行。这样可以保证当前的数据不会被其他事务修改。
上面的查询中,使用select ... for update的方式,这样就通过开启排他锁的方式实现了悲观锁。此时在t_goods表中,id为1的数据就被锁定了,其它事务必须等本次事务提交了后才能执行。
优点与不足
悲观并发控制实际上是“先取锁再访问”的保守策略,为数据库处理的安全提供了保证。但在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外,在只读型事务处理场景中由于不会产生冲突,也没有必要加锁,这样做只会增加系统负载,降低系统的吞吐量。
在关系型数据库系统中,乐观并发控制又叫乐观锁(Optimistic Concurrency Control,OCC),是一种并发控制方法。它假设多用户并发情况下,事务在处理时不会彼此影响,各事务能在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。若其他事务有更新的话,正在提交的事务会进行回滚。乐观事务控制最早由孙祥重(H.T.Kung)教授提出。
乐观锁相对悲观锁而言,乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新时,才会正式对数据库的冲突与否进行检查,若发生冲突了,则返回错误信息,让用户决定如何去做。在对数据库进行处理时,乐观锁并不会使用提供的锁机制。
乐观锁总是假设是最乐观的情况,持乐观的态度,每次拿数据时都认为别人不会修改要拿的数据,所以不上锁。但会在修改时判断在此期间这个数据有没有被别人修改过。具体实现有版本号控制和CAS。
数据版本,为数据增加的一个版本标识。当读取数据时,将版本标识的值一同读出,数据每更新一次,同时对版本标识进行更新。当提交更新时,会判断数据库表对应记录的当前版本信息与第一次取出的版本标识进行比对,若一致则更新,否则认为是过期数据,不做更新。
实现数据版本的方式有两种,一是使用版本号,二是使用时间戳。使用版本号时,会在表中增加一个版本号字段,它是一个整数,初始值为0,每更新一次加1。比如说当前读取了数据,该条数据的版本号是3,过了一会更新该数据,此时再从表中取出该数据,若重新取出的数据的版本号还是3,则就更新,若不是3(比如是4,那说明有人已经更新过该条数据了)则不更新,说明当前要更新的数据已经过期了。
1.查询出商品信息
select (status,status,version) from t_goods where id=#{id}
2.根据商品信息生成订单
3.修改商品status为2
update t_goods set status=2,version=version+1 where id=#{id} and version=#{version};
上面的SQL还有一定的问题,一旦发生高并发,只有一个线程可以修改成功,那就会存在大量的失败。对像淘宝这样的电商网站,高并发是常有的事,总让用户感知到失败显然是不合理的。所以,还是要想办法减少乐观锁的粒度。
有一条比较好的建议,可减少乐观锁的力度,最大程度上提升吞吐率,提高并发能力。如下:
//修改商品库存
update item set quantity=quantity - 1 where id = 1 and quantity - 1 > 0
在上面的SQL中,若用户下单数为1,则通过 quantity - 1 > 0 的方式进行乐观锁控制。此update语句,在执行过程中,会在一次原子操作中自己查询一遍quantity的值,并将其减1。
它适用于多读的场合,这样能提高吞吐量。像数据库提供的类似write_condition机制,其实就是乐观锁思想的体现。若对数据库事务要求很高的场合。
优点与不足
乐观锁认为事务间的数据竞争的概率比较小,因此尽可能直接做下去,直到提交时才去处理,所以不会产生任何锁和死锁。
如何选择
在不同场合,是用悲观锁还是乐观锁呢?
1,乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概念就会比较高,容易发生业务失败;
2,悲观锁依赖数据库锁,效率低,更新失败的概念较低。
随着互联网高并发、高性能、高可用三高架构的提出,悲观锁已经越来越少的被使用到生产环境中了,尤其是并发量比较大的业务场景。
前面提到不同存储引擎的MVCC实现是不同的,典型的有乐观(optimistic)并发控制和悲观(pessimistic)并发控制。下面通过InnoDB的简单版行为来说明MVCC是如何工作的。
InnoDB的MVCC,是通过在每行记录后保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,另一个保存行的过期时间(或删除时间)。当然它存储的并不是实际的时间值,而是系统版本号(system version number)。每开始一个事务,系统版本号会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。下面看在REPEATABLE READ隔离级别下,MVCC具体是如何工作的。
SELECT(查询)
InnoDB会根据以下两个条件检查每行记录,只有符号下面两个条件的记录,才能返回作为查询结果。
INSERT(插入)
为新插入的每一行保存当前系统版本号作为行版本号。
DELETE(删除)
为删除的每一行保存当前系统版本号作为行删除标识。
UPDATE(更新)
为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识(更新就是把旧数据删除,同时插入一条新的数据)。
保存这两个额外的系统版本号,使大多数读操作都可以不用加锁。这样设计使得读数据操作很简单,性能也很好,且也能保证只会读取到符合标准的行。不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作。
MVCC只在REPEATABLE READ和READ COMMITTED两个隔离级别下工作。其它两个隔离级别都和MVCC不兼容,因为READ UNCOMMITTED总是读取最新的数据行,而不是符合当前事务版本的数据行。而SERIALIZABLE则会对所有读取的行都加锁。
参考:
1,《高性能MySQL》第三版
2,详细分析MySQL事务日志(redo log和undo log);
3,https://www.hollischuang.com/archives/934