「SQL数据分析系列」12. 事务

数据与智能 本公众号关注大数据与人工智能技术。由一批具备多年实战经验的技术极客参与运营管理,持续输出大数据、数据分析、推荐系统、机器学习、人工智能等方向的原创文章,每周至少输出7篇精品原创。同时,我们会关注和分享大数据与人工智能行业动态。欢迎关注。

来源 | Learning SQL Generate, Manipulate, and Retrieve Data, Third Edition

作者 | Alan Beaulieu

译者 | Liangchu

校对 | gongyouliu

编辑 | auroral-L


全文共5410字,预计阅读时间30分钟。


第十二章 事务

1. 多用户数据库

1.1 锁

1.2 锁的粒度

2. 什么是事务

2.1 启动事务

2.2 结束事务

2.3 事务保存点



到目前为止,本书中的所有示例都是独立的SQL语句,这可能是临时报表或者数据维护脚本的规范,但是应用程序逻辑往往会包含多个SQL语句,它们会作为一个逻辑工作单元整体一起执行。本章探讨事务(transaction),事务是用于将一组SQL语句组合在一起的机制,使得这些语句要么全部执行成功要么全部不执行。



1. 多用户数据库


数据库管理系统允许单个用户查询和修改数据,但在当今世界,可能会有成千上万的人同时对数据库进行更改。如果每个用户都只执行查询(比如在正常工作时间使用数据仓库时),那么数据库服务器几乎不用处理什么额外问题了。但是如果一些用户正同时执行增加或修改数据的操作,那么服务器必须处理更多的簿记。


例如,假设你正生成一个报表,用于总结本周的电影租赁操作。但在生成报表的同时,发生了以下活动:

• 客户租赁电影;

• 客户在截止日期后归还电影并支付滞纳金;

• 库存中增加了五部新影片。


因此,当你生成报表的同时,有多个用户正在修改基础数据,那么报表上到底应该显示哪些数据呢?答案在某种程度上取决于服务器如何处理锁(locking),我会在下一节介绍它。


1.1 锁


锁是数据库服务器用来控制数据资源被并行使用的一种机制。当数据库的某部分被锁定时,任何要修改(或可能读取)该数据的用户必须等待直到锁被释放。大多数据库服务器使用下面两种锁定策略之一:


• 数据库写操作必须向服务器申请和获得用于修改数据的写锁(write lock),而数据库读操作必须向服务器申请和获得用于查询数据的读锁(read lock)。多个用户可以同时读取数据,但是一个表(或其中的一部分)每次只能分配一个写锁,在该锁被释放之前,将拒绝读请求;


• 数据库写操作必须向服务器申请和获得用于修改数据的写锁,而读操作并不需要任何类型的锁就可以查询数据。此外,服务器要保证从查询开始一直到结束的整个过程中,读操作看到的数据视图一致(即使其他用户修改,数据也要看上去相同)。此方法称作版本控制(versioning)。


这两种方法各有利弊。如果有许多并发读写请求,第一种方法可能会导致较长的等待时间;如果在修改数据时存在长时间运行的查询,第二种方法也可能有问题。在本书讨论的三种服务器中,Microsoft SQL Server使用第一种方法,Oracle Database使用第二种方法,而MySQL使用这两种方法(取决于你选择的存储引擎,我们将在本章稍后讨论这点)。


1.2 锁的粒度


在决定如何锁定资源时,还可以使用许多不同的策略。服务器可将锁应用于以下三个不同级别(或称粒度(granularities))中:


• 表锁(Table locks)

防止多个用户同时修改同一表中的数据。


• 页锁(Page locks)

防止多个用户同时修改表中同一页上的数据(一页是通常在2 KB到16 KB范围内的内存空间)。


• 行锁(Row locks)

防止多个用户同时修改表中的同一行的数据。


这些方法也是各有利弊的。表锁几乎不需要什么簿记就能锁定整个表,但是随着用户数量增加,它的等待时间会迅速增长到让人难以接受的地步。另一方面,行锁需要更多的簿记,但只要各个用户感兴趣的是不同的行,它就能允许多用户修改同一个表。在本书讨论的三种服务器中,Microsoft SQL Server使用表锁、页锁和行锁,Oracle Database只使用行锁,而MySQL使用表锁、页锁或行锁(同样取决于所选择的存储引擎)。在某些情况下,SQL Server会将锁从行锁升级到页锁,再从页锁升级到表锁,而Oracle Database永远不会升级锁(因为只使用行锁)。


再谈上文提到的报表问题:报表上显示的数据要么反映报表开始生成时的数据库状态(如果服务器使用版本控制方法),要么反映服务器向报表程序发出读锁时数据库的状态(如果服务器使用读锁和写锁)。



2. 什么是事务


如果数据库服务器正常运行时间为100%、用户总是允许程序完成执行、并且应用程序不会遇到任何导致执行停止的致命错误,那么就没什么必要讨论并发数据库访问了。但我们并不能沉浸于我们的假设中,毕竟现实情况中上面的完美情形不会一直存在,所以要允许多用户访问同一数据则是需要考虑另一个因素。


另一个并发性难题是事务(transaction),它是一种将多个SQL语句组合在一起的机制,保证所有语句全部执行或者一个都不执行(该属性称为原子性(atomicity))。假设你要把500美元从你的储蓄账户转到支票账户:你的钱成功地从你的储蓄账户取走了,但却没有成功存入支票账户,那你肯定会很沮丧。无论导致失败的原因是什么(服务器因维护而关闭,或者对account表的页锁定请求超时等),你都希望拿回你的500美元。


为了避免这种错误,处理转账申请的程序首先会启动一个事务,然后执行SQL语句将钱从储蓄账户转移到支票账户,如果一切顺利,则发出提交(commit)命令结束事务,否则,如果有意外发生,那么程序会发出回滚(rollback)命令指示服务器撤销事务开始以来做的所有更改。整个过程大致如下:


START TRANSACTION;
 /* withdraw money from first account, making sure balance is sufficient */
UPDATE account SET avail_balance = avail_balance - 500
WHERE account_id = 9988
 AND avail_balance > 500;
IF <exactly one row was updated by the previous statement> THEN
 /* deposit money into second account */
 UPDATE account SET avail_balance = avail_balance + 500
 WHERE account_id = 9989;
 IF <exactly one row was updated by the previous statement> THEN
 /* everything worked, make the changes permanent */
 COMMIT;
 ELSE
 /* something went wrong, undo all changes in this transaction */
 ROLLBACK;
 END IF;
ELSE
 /* insufficient funds, or error encountered during update */
 ROLLBACK;
END IF;


注意

虽然前面的代码块看起来挺像主流数据库公司提供的过程语言(如Oracle的PL/SQL或Microsoft的Transact-SQL),但它只是伪代码,并没想模仿哪种语言。


前面的代码块首先启动一个事务,然后尝试从支票帐户中扣除500美元并将其存入储蓄帐户。如果一切顺利,则提交事务,但若出现了任何问题,则回滚事务,这意味着将撤销自事务开始以来所有的数据更改操作。


通过使用事务,程序可以保证你的500美元要么留在你的储蓄账户中,要么转到你的支票账户,而不至于凭空消失。无论事务是提交还是回滚,在事务执行期间获取的所有资源(例如写锁)都将在事务完成后被释放。


当然,如果程序成功地完成了两个update语句,但服务器在执行提交或回滚之前意外关闭了,那么事务将在服务器重新上线后回滚。(数据库服务器在上线之前必须完成的任务之一是查找服务器关闭时所有正在运行且未完成的事务,并将其回滚)。此外,如果你的程序完成事务并提交,但服务器在将变化持久到永久存储区前(即修改后的数据位于内存中,但尚未刷新到磁盘)宕机了,则数据库服务器必须在服务器重启时重新应用事务的变化(这种属性叫持久性(durability))。


2.1 启动事务


数据库服务器通过以下两种方式之一创建事务:


• 一个活动事务始终与数据库会话相关联,因此没必要也没什么法子来显式地启动一个事务。当前事务结束时,服务器会自动为会话启动一个新事务;

• 除非显式地创建一个事务,否则每个SQL语句都会自动独立地提交。要启动事务前必须首先发出一个命令。


本书提及的三种服务器中,Oracle Database采用第一种方法,而Microsoft SQL Server和MySQL采用第二种方法。Oracle Database事务创建方法的优点在于:即使你只发出一个SQL命令,如果你不喜欢结果或者改变了主意,那么你也可以回滚所有变化。因此,如果你忘了在delete语句中添加where子句,你将有机会撤消该操作(假设你足够清醒到能认识该操作可能导致删除表中的所有125000行数据的结果)。然而,对于MySQL和SQL Server,一旦按下Enter键,SQL语句带来的更改将是永久性的(除非数据库管理员可以从备份中或通过其他方法获取原始数据)。


SQL:2003标准包含了start transaction命令,它用于显式启动一个事务。MySQL是符合该标准的,然而SQL Server用户需要使用替代命令begin transaction。对于这两个服务器,在显式开始事务之前,你都处于所谓的自动提交模式(autocommit mode),这意味着服务器会自动提交单个语句。因此,你可以决定是否进入事务模式并提交启动事务命令,或只是简单地默认让服务器自动提交单个语句。


MySQL和SQL Server都允许你关闭单个会话的自动提交模式,在这种情况下,对事务来说服务器就像Oracle Database一样工作。对于SQL Server,可以发出以下命令来禁用自动提交模式:

SET IMPLICIT_TRANSACTIONS ON


MySQL允许你通过以下方式禁用自动提交模式:

SET AUTOCOMMIT=0


离开自动提交模式后,所有SQL命令都将在事务范围内执行,必须显式提交或回滚。


注意

建议每次登录时关闭自动提交模式,并养成在事务中运行SQL语句的习惯。就算没什么额外好处,但它可以让你省去在意外删除所有数据后请求数据库管理员重建数据的尴尬。


2.2 结束事务


一旦事务启动(无论是通过start transaction命令显式地启动还是由数据库服务器隐式地启动),都必须显式地结束事务,才能使所作更改持久化,这可以通过commit命令来实现。commit命令指示服务器将变化标记为永久性的,并释放事务执行期间使用的任何资源(即页锁或行锁)。


如果要撤消自启动事务以来所做的所有更改,则必须提交rollback命令,该命令指示服务器将数据返回到其事务开始前的状态。回滚完成后,同样会释放会话中使用的所有资源。


除了提交commit或rollback命令外,还有其他几种情况可以结束事务,这些情况可能是操作的间接结果,也可能是不属于你控制范围内的结果:


• 服务器宕机,在这种情况下,当服务器重新启动时,事务将自动回滚;

• 你发出一个SQL模式语句,例如alter table,它将提交当前事务并启动一个新事务;

• 你发出另一个start transaction命令,这将导致上一个事务的提交;

• 由于服务器检测到死锁(deadlock)并确定当前事务是罪魁祸首,那么服务器会提前结束当前事务。在这种情况下,事务将回滚,并且你将收到一条错误消息。


在这四种情况中,第一种和第三种情况比较好理解,但其他两种情况值得讨论一下。就第二种情况而言,对数据库的更改,无论是添加新表或新索引还是从表中删除列,都不能回滚,因此更改模式的命令必须在事务外部执行。如果事务当前正在进行,那么服务器将先提交当前事务,执行SQL模式语句命令,然后为会话自动启动一个新事务。服务器不会通知你发生了什么,因此你应该注意保护那些组成一个工作单元的语句不被服务器意外地分解为多个事务。


第四种情况处理死锁检测。当两个不同的事务正在等待另一个事务当前持有的资源时,就会发生死锁。例如,事务A可能刚刚更新了account表,正在等待transaction表上的写锁,而事务B已在transaction表中插入了一行,正在等待account表上的写锁。如果两个事务碰巧都在修改同一页或同一行(取决于数据库服务器使用的锁粒度),那么它们将永远等待另一个事务完成并释放所需的资源。数据库服务器必须时刻注意这些情况才能使吞吐量不陷入停滞。当检测到死锁时,需要任意地或根据某些标准来选择其中一个事务进行回滚,以便其他事务继续下去。大多数情况下,终止的事务可以重新启动,在不遇到其他死锁的情况下能成功执行。


与前面讨论的第二种情况不同,此时数据库服务器将抛出一个错误,通知你由于死锁检测,事务已回滚。例如,对于MySQL而言,你将收到error 1213,并包含以下消息:

Message: Deadlock found when trying to get lock; try restarting transaction


正如错误消息所示,重试由于死锁检测而回滚的事务是一种合理的做法。但是,如果死锁变得太频繁,那么你可能需要修改访问数据库的应用程序以降低死锁的可能性(一个常见的策略是确保总是按顺序访问数据资源,例如总是在插入transaction数据之前修改account数据)。


2.3 事务保存点


在某些情况下,你可能会在事务中遇到需要回滚的问题,但你可能不希望撤消所有已完成的工作。此时,你可以在事务中创建一个或多个保存点(savepoint),如此一来就可以利用它们回滚到事务的特定位置,而不是一直回滚到事务的启动状态。




选择存储引擎

当使用Oracle Database或Microsoft SQL Server时,会有一组代码负责低级别数据库操作,例如根据主键值从表中检索特定行。然而,MySQL数据库服务器设计成可以用多个存储引擎来提供低级别数据库功能,包括资源锁定和事务管理。从8.0版开始,MySQL包括以下存储引擎:

• MyISAM

一种采用表锁的非事务引擎。


• MEMORY

一种内存表使用的非事务引擎。


• CSV

一种以CSV文件存储数据的事务引擎。


• InnoDB

一种采用行级锁定的事务引擎。


• Merge

一种专用引擎,用于使多个相同的MyISAM表看起来像一个表(又称表分区)。


• Archive

一种专用引擎,用于存储大量未索引的数据,主要用于存档。


尽管你可能认为自己必须为数据库选择某一个存储引擎,但MySQL足够灵活,允许你逐表选择一个存储引擎。然而对于那些可能参与事务的表,你应该选择InnoDB引擎,它使用行级锁定和版本控制提供所有存储引擎中最高级别的并行能力。


你可以在创建表时显式指定存储引擎,也可以更改现有表的存储引擎。如果不知道表当前使用的引擎,可以使用show table命令,如下所示:

mysql> show table status like 'customer' \G;
*************************** 1. row ***************************
 Name: customer
 Engine: InnoDB
 Version: 10
 Row_format: Dynamic
 Rows: 599
 Avg_row_length: 136
 Data_length: 81920
Max_data_length: 0
 Index_length: 49152
 Data_free: 0
 Auto_increment: 599
 Create_time: 2019-03-12 14:24:46
 Update_time: NULL
 Check_time: NULL
 Collation: utf8_general_ci
 Checksum: NULL
 Create_options:
 Comment:
1 row in set (0.16 sec)



注意第二项,你可以看到customer表使用的是InnoDB引擎。如果没有指定引擎,你可以通过以下命令将InnoDB引擎分配给customer表:

ALTER TABLE customer ENGINE = INNODB;




所有保存点都必须有一个名字,这样你就在一个事务中有多个保存点了。要创建名为my_savepoint的保存点,可以执行以下操作:

SAVEPOINT my_savepoint;


要回滚到特定的保存点,只需发出rollback命令,后跟关键词to savepoint和保存点的名字,如下所示:

ROLLBACK TO SAVEPOINT my_savepoint;


下面是使用保存点的示例:

START TRANSACTION; 
UPDATE product 
SET date_retired = CURRENT_TIMESTAMP() 
WHERE product_cd = 'XYZ'; 
SAVEPOINT before_close_accounts; 
UPDATE account 
SET status = 'CLOSED', close_date = CURRENT_TIMESTAMP(), 
 last_activity_date = CURRENT_TIMESTAMP() 
WHERE product_cd = 'XYZ'; 
ROLLBACK TO SAVEPOINT before_close_accounts; 
COMMIT;



该事务的影响是使得虚构的XYZ产品退出市场,但没有关闭相关账户。


使用保存点时,请记住以下几点:


• 创建保存点时,除了名字,不会保存其他任何内容。如果要使得事务持久化,则必须最终发出一个commit命令。

• 如果在未命名保存点的情况下发出rollback命令,事务中的所有保存点都将被忽略,整个事务将被撤消。


如果你使用的是SQL Server,则需要使用专用命令save transaction来创建保存点,并使用rollback transaction命令回滚到保存点,每个命令后面都要跟有保存点的名字。


举报
评论 0