第十一章 事务
# 第十一章 事务
在MySQL架构中,实现事务的大部分工作由存储引擎承担。不同存储引擎在事务日志记录、行锁或页锁、隔离级别实现、提交和回滚,以及其他事务实现的关键部分的细节上差异很大。然而,每个存储引擎都必须使用相同的接口与上层SQL层进行通信。因此,本章的重点将是概述如何将现有的事务性存储引擎集成到MySQL中。
InnoDB是MySQL中最强大的事务性存储引擎。因此,我将以它为例,分析为什么有些操作要以特定的方式进行。
# 事务性存储引擎实现概述
第7章讨论了实现存储引擎的基础知识。你可能还记得,将自定义存储引擎集成到MySQL中有两个部分:定义和实现handler
子类,以及定义和实现handlerton
。我们对这些内容进行了相当详细的讨论。
虽然正确实现事务确实需要在实现handler
子类的虚方法时格外注重细节,但事务相关工作的核心发生在几个handlerton
函数中。这是可以理解的:handler
子类方法在概念上与特定的表实例相关联,而handlerton
函数仅与线程或连接相关联。因此,诸如COMMIT
(提交)、ROLLBACK
(回滚)和SAVEPOINT
(保存点)等操作自然适合在handlerton
的集成模式中进行。
要明白,事务基本功能的实际实现完全由存储引擎自行决定。既可以有像InnoDB这样功能完备的事务性存储引擎,也可以编写一个原型,它只是报告已提交或回滚了事务,但实际上什么都没做。只要遵循正确的接口/通信协议,核心SQL层就无法区分它们的差异 。
即使你已经有了一个功能完备的事务性存储引擎,集成过程也并非易事。有许多问题需要处理。如何处理属于其他存储引擎的非事务性甚至事务性表?如何处理可能的查询缓存?如何处理复制日志记录?如何避免死锁?
查看InnoDB的源代码(从sql/ha_innodb.cc
开始),你会发现这种集成涉及到许多难题。你还会看到针对各种挑战的解决方案,你可以尝试理解这些方案并应用到自己的实际情况中。
# 实现handler子类
首先要实现的一个简单但非常重要的方法是handler::has_transactions()
。它用于向上层SQL层报告存储引擎是否支持事务。返回值1(TRUE
)表示支持事务。
接下来两个重要的方法是handler::start_stmt()
和handler::external_lock()
。事务性存储引擎可以使用这两个方法来启动一个事务。
在解析过程中,每个表实例至少会调用一次handler::external_lock()
。最初,这个方法的目的是防止MySQL服务器外部的某些应用程序修改可能正在被使用的表。现在,handler::external_lock()
的这种用法已经有些过时了。然而,它在调用层级中的关键位置,使得它对于事务性存储引擎在启动事务时进行每个表实例的初始化非常有用。
有一种特殊情况可以绕过对handler::external_lock()
的调用启动事务,那就是LOCK TABLES
语句,它会在当前连接会话中对要使用的表列表手动添加表级锁。为了解决这个问题,handler
类中添加了handler::start_stmt()
方法,在执行LOCK TABLES
时,每个表实例会调用一次该方法。
事务性存储引擎通常需要一个数据结构来跟踪当前事务的状态。MySQL存储引擎架构通过在THD
类中为事务描述符的指针分配内存来满足这一需求。这块内存位于THD
的ha_data
数组中。每个存储引擎在该数组中都有一个固定偏移量的位置,由handlerton
的slot
成员中自动生成的值指定。
在通过handler::external_lock()
或handler::start_stmt()
启动事务时,可以初始化该内存位置。例如,InnoDB是这样初始化的:
trx = trx_allocate_for_mysql( );
...
thd->ha_data[innobase_hton.slot] = trx;
2
3
启动事务时,存储引擎需要通过调用trans_register_ha()
在核心SQL层注册该事务。对于InnoDB来说,通过两个函数来满足这一要求:innobase_register_stmt()
和innobase_register_trx_and_stmt()
。innobase_register_stmt()
调用trans_register_ha()
的方式如下:
trans_register_ha(thd,FALSE,&innobase_hton);
而innobase_register_trx_and_stmt()
首先调用innobase_register_stmt()
,然后如下调用trans_register_ha()
:
trans_register_ha(thd,TRUE,&innobase_hton);
可以看到,区别在于第二个参数的值。如果为FALSE
,则仅注册事务的当前语句。否则,注册整个事务。
注册事务和语句的目的是为了便于执行COMMIT
和ROLLBACK
操作。在操作进行过程中,核心服务器代码需要定位handlerton
,以便能够调用特定于存储引擎的代码。
事务可以通过两种方式启动:外部启动和内部启动。当客户端发出BEGIN
或START TRANSACTION
语句时,事务外部启动。或者,仅仅发出一个使用支持事务的存储引擎表的查询,就会在内部启动一个事务。当事务外部启动时,上层SQL层可以控制并在需要时记录存储引擎的当前状态。然而,当事务内部启动时,上层SQL层无法控制。因此,事务注册过程用于通知上层SQL层事务内部启动的情况。
事务性存储引擎还可以实现handler::try_semi_consistent_read()
、handler::was_semi_consistent_read()
和handler::unlock_row()
,以帮助避免在UPDATE
和DELETE
查询中出现额外的锁等待。
然而,在很大程度上,handler
子类的实现与存储引擎密切相关。实现handler
的核心工作是定义通过各种方法(基于键读取、扫描中下一行读取、范围读取等)读取记录,以及写入、更新和删除记录的具体含义。因此,handler
方法本身通常不会做太多与事务支持直接相关的工作。相反,它们充当底层引擎API调用的包装器,这些API调用在存储和检索记录时负责维护事务完整性。
让我们简要研究一下InnoDB是如何实现handler
的(见sql/ha_innodb.cc
)。为了弥合原生InnoDB结构与来自MySQL上层SQL层的原生格式数据之间的差距,需要做大量工作(大部分与事务无关),而MySQL上层SQL层的数据格式最初是为MyISAM设计的。我们可以看到对InnoDB核心API函数的调用,如row_search_for_mysql()
、row_unlock_for_mysql()
、row_insert_for_mysql()
、row_update_for_mysql()
等。正如它们的名字所示,这些函数有一个共同的主题:它们接受MySQL上层SQL层格式的记录,执行必要的格式转换,然后执行各自的操作,如查找记录、更新记录或插入记录。这些以及其他格式转换函数可以在storage/innobase/row/row0mysql.c
中找到。
ha_innobase
(InnoDB的handler
)的一个关键数据成员innobase_prebuilt
(类型为struct row_prebuilt_struct*
)深度参与了格式转换操作(以及InnoDB的handler
内部执行的几乎任何其他操作)。它是一个指向某种结构的指针,该结构以一种能够最有效地使用MySQL上层SQL层格式记录进行操作的方式组织InnoDB表数据。它在ha_innodb::open()
中通过调用row_create_prebuilt()
进行初始化。若要研究InnoDB的内部机制,以便了解如何集成事务性引擎,可以参考storage/innobase/include/row0mysql.h
中row_prebuilt_struct
的定义,以及storage/innobase/row/row0mysql.c
中row_create_prebuilt()
的初始化过程。不过,这个结构中有一个成员值得更详细地关注:类型为struct trx_struct*
的trx
。
trx
是一个指向事务描述符的指针,该描述符包含诸如事务ID、事务隔离级别、事务是否创建或删除了表或索引、上次提交时事务的日志序列号、与事务对应的二进制复制日志中的日志名称和偏移量,以及各种其他与事务处理相关的标志、计数变量和描述符指针等数据。InnoDB将trx
指针放入THD
中为主要事务描述符提供的内存槽中(thd->ha_data[innobase_hton.slot]
)。
对于那些试图集成自己的事务性存储引擎的人来说,这个结构可能特别有用。trx_struct
的定义可以在storage/innobase/include/trx0trx.h
中找到。它通过storage/innobase/trx/trx0trx.c
中的trx_create()
进行初始化。然而,当从handler
内部调用时,InnoDB使用trx_allocate_for_mysql()
包装器,而不是直接调用trx_create()
。
ha_innobase
的大多数记录操作方法(rnd_next()
、index_first()
、index_next()
、index_prev()
等)都遵循一种模式。它们通常先进行一些简单的初始化,然后调用ha_innobase::general_fetch()
,该函数进而将执行操作分发到InnoDB API的更底层,通常是通过storage/innobase/row/row0mysql.c
中的某个函数进入。事务相关问题会在出现时一并处理。其他操作,如打开或创建表,也遵循一种模式。通常先是一段冗长的初始化,接着调用一个InnoDB核心API函数,然后进行一些清理工作。
总体而言,研究InnoDB的handler
实现可以揭示将强大的事务性存储引擎集成到MySQL中所涉及的复杂性。
# 定义handlerton
你可能还记得第7章的内容,handlerton
是一个包含特定于存储引擎的数据成员和回调函数指针的结构。与handler
类不同,handlerton
并不特定于某个表实例。单例模式是一种广为人知的设计模式,当一个类以只能在整个应用程序中存在一个实例的方式创建时,就会应用这种模式。handlerton
本质上是一个与表handler
相关联的单例,因此得名。
如果你查看第7章中handlerton
函数回调的列表(表7 - 3),你会发现它们大多数都与事务有关。引入handlerton
是为了支持XA事务,这导致了MySQL内部事务处理的重大重构。因此,handlerton
成为了存储引擎事务功能集成的关键枢纽。
handlerton
中与事务相关的回调函数有:
savepoint_set()
savepoint_rollback()
savepoint_release()
commit()
rollback()
prepare()
recover()
commit_by_xid()
rollback_by_xid()
这些函数会直接响应相应的SQL命令而被调用。事务性存储引擎还会使用其他回调函数:
close_connection()
panic()
flush_logs()
start_consistent_snapshot()
binlog_func()
release_latches()
让我们简要研究一下sql/ha_innodb.cc
中的InnoDB的handlerton
。回调函数的命名约定相当直观。每个handlerton
成员都以innobase_
为前缀,形成实际的InnoDB回调函数名称。有几个例外情况:
prepare()
和recover()
分别被称为innobase_xa_prepare()
和innobase_xa_recover()
,这样命名是为了更清晰,并强调它们处理的是XA事务。panic()
对应innobase_end()
。start_consistent_snapshot()
指向innobase_start_trx_and_assign_read_view()
。
一些handlerton
回调函数遵循一种简单的模式。它们先进行一些初始化,调用一个InnoDB核心API函数来实际执行任务,然后可能进行一些清理工作。其他回调函数则需要多次调用InnoDB核心API。但在这两种情况下,handlerton
回调函数主要充当核心MySQL代码和InnoDB核心API之间的纽带,以确保事务能够按预期进行。
需要注意的是,handlerton
回调函数的复杂程度比handler
方法低得多。原因可能是InnoDB拥有一套精简的事务系统,因此,当被要求执行标准的事务操作(如提交、回滚或保存点)时,从核心MySQL代码内部调用它时,并不需要太多额外的工作来使其正常运行。然而,当通过handler
方法单独访问记录时,情况就不同了。MySQL上层SQL层的期望可能并不总是与InnoDB的原生功能一致。因此,需要做大量的衔接工作,代码也会更复杂。
# 使用查询缓存
MySQL有一个数据库中独有的功能:查询缓存(query cache)。可以对服务器进行配置,使其缓存每个SELECT
的结果。然后,如果接收到的另一个SELECT
与缓存中的某个查询完全相同,并且查询涉及的表没有更改,则会立即返回缓存的结果,而无需MySQL实际去表中提取匹配的记录。
这个功能为许多应用程序带来了显著的性能提升,特别是那些严重依赖数据库,并且经常被大量用户以向MySQL服务器发送相同查询的方式访问的Web应用程序。自引入查询缓存以来,许多MySQL用户报告性能提升了两到三倍。因此,任何存储引擎,无论是否支持事务,都需要能够与查询缓存正确协同工作。
使用查询缓存的主要问题是能够轻松判断表是否发生了变化。对于非事务性存储引擎来说,回答这个问题并不困难,但对于支持多种隔离级别的多版本事务性存储引擎来说,情况就没那么简单了。因此,handler
接口提供了一个方法handler::register_query_cache_table()
,让事务性存储引擎有机会回答某个查询是否可以安全缓存的问题。这个方法是可选的。如果handler
不支持该方法,查询缓存将采用保守策略:每次提交时,它会使所有引用了提交事务中使用的表的查询缓存失效。
register_query_cache_table()
有机会设置一个回调函数,以及传递给该回调函数的参数,查询缓存将调用这个回调函数来决定涉及该表的查询是否可以安全缓存。
InnoDB使用innobase_query_caching_of_table_permitted()
作为回调函数。它会进行相当复杂的分析来做出决定。必须注意避免死锁。如果涉及给定表的查询可以安全缓存,该函数会处理所有相关问题并返回TRUE
,否则返回FALSE
。
# 使用复制二进制日志
MySQL复制的工作原理是,主库维护一个更新的二进制日志(称为binlog
),从库读取并应用这些更新。因此,对于事务性存储引擎来说,确保二进制日志的内容与数据库的状态一致至关重要。
MySQL核心代码已经提供了很多帮助。在事务提交之前,SQL语句不会写入二进制日志,如果事务回滚,则根本不会写入。然而,事务性存储引擎可能需要解决几个关键问题:
- 为了在崩溃时保证二进制日志和表数据的一致性,存储引擎必须实现XA事务。
- 在基于语句的复制中,从库在一个线程中顺序执行二进制日志中的更新。因此,主库上的所有更新都必须在
SERIALIZABLE
事务隔离级别下进行,以确保从库上得到相同的结果。
# 避免死锁
具有行级锁定的事务性存储引擎,尤其是与SQL服务器集成的存储引擎,自然容易出现死锁。因此,制定避免或解决死锁的方案非常重要。
InnoDB有一个死锁检测算法。在添加新锁时,InnoDB会确保不会导致死锁。当发现死锁时,它会回滚有问题的事务。然而,死锁检测算法仅能识别InnoDB的锁,当部分问题锁不属于InnoDB时,它无法检测到死锁。为了解决这个问题,InnoDB还采用了基于超时的死锁检测机制,回滚执行时间过长的事务。这个时间限制由服务器变量innodb_lock_wait_timeout
控制。因此,应用程序程序员应该准备好,如果事务因超时或可能出现死锁而被回滚,就重新发出该事务。虽然这在性能方面看似是一个重大缺陷,但在实际应用中,经过适当优化的查询在设计良好的应用程序中几乎不会导致死锁。
与MySQL服务器协同工作给死锁问题带来的另一个麻烦是,需要注意MySQL的表锁。MySQL允许用户在事务开始时通过LOCK TABLES
命令直接锁定表。InnoDB通过服务器变量innodb_table_locks
来感知表锁,该变量默认设置为1。当设置为1时,InnoDB在执行LOCK TABLES
命令时会获取一个存储引擎内部级别的表锁。
随着越来越多的存储引擎被添加到MySQL中,一个以前大多只存在于理论层面的可能性正逐渐成为现实。存储引擎开发者现在需要关注跨存储引擎的死锁问题。InnoDB的锁超时方法可用于解决这个问题。