mysql 谈谈innodb存储引擎

  1. 由于不同版本对事务系统都有改变,本文的所有分析基于当前GA的最新版本MySQL5.7.9,但也会在阐述的过程中,顺带描述之前版本的一些内容。本文也会介绍5.7版本对事务系统的一些优化点。 另外尽管 InnoDB 锁系统和事务有着非常密切的联系,但鉴于本文主要介绍事务模块,并且计划中的篇幅已经足够长。而锁系统又是一个非常复杂的模块,将在后面的月报中单独开一篇文章来讲述。 在阅读本文之前,强烈建议先阅读下之前两节的内容,因为事务系统和这些模块有着非常紧密的联系: MySQL · 引擎特性 · InnoDB undo log 漫游 MySQL · 引擎特性 · InnoDB redo log漫游 MySQL · 引擎特性 · InnoDB 崩溃恢复过程 事务开启 InnoDB 提供了多种方式来开启一个事务,最简单的就是以一条 BEGIN 语句开始,也可以以 START TRANSACTION 开启事务,你还可以选择开启一个只读事务还是读写事务。所有显式开启事务的行为都会隐式的将上一条事务提交掉。 所有显示开启事务的入口函数均为trans_begin,如下列出了几种常用的事务开启方式。 BEGIN 当以BEGIN开启一个事务时,首先会去检查是否有活跃的事务还未提交,如果没有提交,则调用ha_commit_trans提交之前的事务,并释放之前事务持有的MDL锁。 执行BEGIN命令并不会真的去引擎层开启一个事务,仅仅是为当前线程设定标记,表示为显式开启的事务。 和BEGIN等效的命令还有“BEGIN WORK”及“START TRANSACTION”。 START TRANSACTION READ ONLY 使用该选项开启一个只读事务,当以这种形式开启事务时,会为当前线程的thd->tx_read_only设置为true。当Server层接受到任何数据更改的SQL时,都会直接拒绝请求,返回错误码ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTION,不会进入引擎层。 这个选项可以强约束一个事务为只读的,而只读事务在引擎层可以走优化过的逻辑,相比读写事务的开销更小,例如不用分配事务id、不用分配回滚段、不用维护到全局事务链表中。 该事务开启的方式从5.6版本开始引入。我们知道,在MySQL5.6版本中引入的一个对事务模块的重要优化:将全局事务链表拆成了两个链表:一个用于维护只读事务,一个用于维护读写事务。这样我们在构建一个一致性视图时,只需要遍历读写事务链表即可。但是在5.6版本中,InnoDB并不具备事务从只读模式自动转换成读写事务的能力,因此需要用户显式的使用以下两种方式来开启只读事务:
    1. 执行START TRANSACTION READ ONLY
    2. 或者将变量tx_read_only设置为true

    5.7版本引入了模式自动转换的功能,但该语法依然保留了。 另外一个有趣的点是,在5.7版本中,你可以通过设置session_track_transaction_info变量来跟踪事务的状态,这货主要用于官方的分布式套件(例如fabric),例如在一个负载均衡系统中,你需要知道哪些 statement 开启或处于一个事务中,哪些 statement 允许连接分配器调度到另外一个 connection。只读事务是一种特殊的事务状态,因此也需要记录到线程的Transaction_state_tracker中。 关于Session tracker,可以参阅官方WL#6631。 START TRANSACTION READ WRITE 和上述相反,该SQL用于开启读写事务,这也是默认的事务模式。但有一点不同的是,如果当前实例的 read_only 打开了且当前连接不是超级账户,则显示开启读写事务会报错。 同样的事务状态TX_READ_WRITE也要加入到Session Tracker中。另外包括上述几种显式开启的事务,其标记TX_EXPLICIT也加入到session tracker中。 读写事务并不意味着一定在引擎层就被认定为读写事务了,5.7版本InnoDB里总是默认一个事务开启时的状态为只读的。举个简单的例子,如果你事务的第一条SQL是只读查询,那么在InnoDB层,它的事务状态就是只读的,如果第二条SQL是更新操作,就将事务转换成读写模式。 START TRANSACTION WITH CONSISTENT SNAPSHOT 和上面几种方式不同的是,在开启事务时还会顺便创建一个视图(Read View),在InnoDB中,视图用于描述一个事务的可见性范围,也是多版本特性的重要组成部分。 这里会进入InnoDB层,调用函数innobase_start_trx_and_assign_read_view,注意只有你的隔离级别设置成REPEATABLE READ(可重复读)时,才会显式开启一个Read View,否则会抛出一个warning。 使用这种方式开启事务时,事务状态已经被设置成ACTIVE的。 状态变量TX_WITH_SNAPSHOT会加入到Session Tracker中。 AUTOCOMMIT = 0 当autocommit设置成0时,就无需显式开启事务,如果你执行多条SQL但不显式的调用COMMIT(或者执行会引起隐式提交的SQL)进行提交,事务将一直存在。通常我们不建议将该变量设置成0,因为很容易由于程序逻辑或使用习惯造成事务长时间不提交。而事务长时间不提交,在MySQL里简直就是噩梦,各种诡异的问题都会纷纷出现。一种典型的场景就是,你开启了一条查询,但由于未提交,导致后续对该表的DDL堵塞住,进而导致随后的所有SQL全部堵塞,简直就是灾难性的后果。 另外一种情况是,如果你长时间不提交一个已经构建Read View的事务,purge线程就无法清理一些已经提交的事务锁产生的undo日志,进而导致undo空间膨胀,具体的表现为ibdata文件疯狂膨胀。我们曾在线上观察到好几百G的Ibdata文件。 TIPS:所幸的是从5.7版本开始提供了可以在线truncate undo log的功能,前提是开启了独立的undo表空间,并保留了足够的 undo 回滚段配置(默认128个),至少需要35个回滚段。其truncate 原理也比较简单:当purge线程发现一个undo文件超过某个定义的阀值时,如果没有活跃事务引用这个undo文件,就将其设置成不可分配,并直接物理truncate文件。 事务提交 事务的提交分为两种方式,一种是隐式提交,一种是显式提交。 当你显式开启一个新的事务,或者执行一条非临时表的DDL语句时,就会隐式的将上一个事务提交掉。另外一种就是显式的执行“COMMIT” 语句来提交事务。 然而,在不同的场景下,MySQL在提交时进行的动作并不相同,这主要是因为 MySQL 是一种服务器层-引擎层的架构,并存在两套日志系统:Binary log及引擎事务日志。MySQL支持两种XA事务方式:隐式XA和显式XA;当然如果关闭binlog,并且仅使用一种事务引擎,就没有XA可言了。 关于隐式XA的控制对象,在实例启动时决定使用何种XA模式,如下代码段: if (total_ha_2pc > 1 || (1 == total_ha_2pc && opt_bin_log)) { if (opt_bin_log) tc_log= &mysql_bin_log; else tc_log= &tc_log_mmap; }

    • 若打开binlog,且使用了事务引擎,则XA控制对象为mysql_bin_log
    • 若关闭了binlog,且存在不止一种事务引擎时,则XA控制对象为tc_log_mmap
    • 其他情况,使用tc_log_dummy,这种场景下就没有什么XA可言了,无需任何协调者来进行XA。

    这三者是TC_LOG的子类,关系如下图所示:

TC LOG 具体的,包含以下几种类型的XA(不对数据产生变更的只读事务无需走XA) Binlog/Engine XA 当开启binlog时, MySQL默认使用该隐式XA模式。 在5.7版本中,事务的提交流程包括: Binlog Prepare 设置thd->durability_property= HA_IGNORE_DURABILITY, 表示在innodb prepare时,不刷redo log。 InnoDB Prepare (入口函数innobase_xa_prepare --> trx_prepare): 更新InnoDB的undo回滚段,将其设置为Prepare状态(TRX_UNDO_PREPARED)。 进入组提交 (ordered_commit)

  1. Flush Stage:此时形成一组队列,由leader依次为别的线程写binlog文件 在准备写binlog前,会调用ha_flush_logs接口,将存储的日志写到最新的LSN,然后再写binlog到文件。这样做的目的是为了提升组提交的效率,具体参阅之前的一篇月报
  2. Sync Stage:如果sync_binlog计数超过配置值,则进行一次文件fsync,注意,参数sync_binlog的含义不是指的这么多个事务之后做一次fsync,而是这么多组事务队列后做一次fsync。
  3. Semisync Stage (RDS MySQL only):如果我们在事务commit之前等待备库ACK(设置成AFTER_SYNC模式),用户线程会释放上一个stage的锁,并等待ACk。这意味着在等待ACK的过程中,我们并不堵塞上一个stage的binlog写入,可以增加一定的吞吐量。
  4. Commit Stage:队列中的事务依次进行innodb commit,将undo头的状态修改为TRX_UNDO_CACHED/TRX_UNDO_TO_FREE/TRX_UNDO_TO_PURGE任意一种 (undo相关知识参阅之前的月报);并释放事务锁,清理读写事务链表、readview等一系列操作。每个事务在commit阶段也会去更新事务页的binlog位点。

TIPS:如果你关闭了binlog_order_commits选项,那么事务就各自进行提交,这种情况下不能保证innodb commit顺序和binlog写入顺序一致,这不会影响到数据一致性,在高并发场景下还能提升一定的吞吐量。但可能影响到物理备份的数据一致性,例如使用 xtrabackup(而不是基于其上的innobackup脚本)依赖于事务页上记录的binlog位点,如果位点发生乱序,就会导致备份的数据不一致。 Engine/Engine XA 当binlog关闭时,如果事务跨引擎了,就可以在事务引擎间进行XA了,典型的例如InnoDB和TokuDB(在RDS MySQL里已同时支持这两种事务引擎)。当支持超过1种事务引擎时,并且binlog关闭了,就走TC LOG MMAP逻辑。对应的XA控制对象为tc_log_mmap。 由于需要持久化事务信息以用于重启恢复,因此在该场景下,tc_log_mmap模块会创建一个文件,名为tc.log,文件初始化大小为24KB,使用mmap的方式映射到内存中。 tc.log 以PAGE来进行划分,每个PAGE大小为8K,至少需要3个PAGE,初始化的文件大小也为3个PAGE(TC_LOG_MIN_SIZE),每个Page对应的结构体对象为st_page,因此需要根据page数,完成文件对应的内存控制对象的初始化。初始化第一个page的header,写入magic number以及当前的2PC引擎数(也就是total_ha_2pc) 下图描述了tc.log的文件结构:

tc.log 文件结构 在事务执行的过程中,例如遇到第一条数据变更SQL时,会注册一个唯一标识的XID(实际上通过当前查询的query_id来唯一标识),之后直到事务提交,这个XID都不会改变。事务引擎本身在使用undo时,必须加上这个XID标识。 在进行事务Prepare阶段,若事务涉及到多个引擎,先在各自引擎里做事务Prepare。 然后进入commit阶段,这时候会将XID记录到tc.log中(如上图所示),这类涉及到相对复杂的page选择流程,这里不展开描述,具体的参阅函数TC_LOG_MMAP::commit 在完成记录到tc.log后,就到引擎层各自提交事务。这样即使在引擎提交时失败,我们也可以在crash recovery时,通过读取tc.log记录的xid,指导引擎层将符合XID的事务进行提交。 Engine Commit 当关闭binlog时,且事务只使用了一个事务引擎时,就无需进行XA了,相应的事务commit的流程也有所不同。 首先事务无需进入Prepare状态,因为对单引擎事务做XA没有任何意义。 其次,因为没有Prepare状态的保护,事务在commit时需要对事务日志进行持久化。这样才能保证所有成功返回的事务变更, 能够在崩溃恢复时全部完成。 显式XA MySQL支持显式的开启一个带命名的XA事务,例如: XA BEGIN "xidname" do something..... XA END 'xidname' XA PREPARE 'xidname' // 当完成这一步时,如果崩溃恢复,是可以在启动后通过XA RECOVER获得事务信息,并进行显式提交 XA COMMIT 'xidname' // 完全提交事务 一个有趣的问题是,在5.7之前的版本中,如果执行XA的过程中,在完成XA PREPARE后,如果kill掉session,事务就丢失了,而不是像崩溃恢复那样,可以直接恢复出来。这主要是因为MySQL对Kill session的行为处理是直接回滚事务。 为了解决这个问题,MySQL5.7版本做了不小的改动,将XA的两阶段都记录到了binlog中。这样状态是持久化了的,一次干净的shutdown后,可以通过扫描binlog恢复出XA事务的状态,对于kill session导致的XA事务丢失,逻辑则比较简单:内存中使用一个transaction_cache维护了所有的XA事务,在断开连接调用THD::cleanup时不做回滚,仅设置事务标记即可。 具体的参阅我之前写的这篇月报 事务回滚 当由于各种原因(例如死锁,或者显式ROLLBACK)需要将事务回滚时,会调用handler接口ha_rollback_low,进而调用InnoDB函数trx_rollback_for_mysql来回滚事务。回滚的方式是提取undo日志,做逆向操作。 由于InnoDB的undo是单独写在表空间中的,本质上和普通的数据页是一样的。如果在事务回滚时,undo页已经被从内存淘汰,回滚操作(特别是大事务变更回滚)就可能伴随大量的磁盘IO。因此InnoDB的回滚效率非常低。有的数据库管理系统,例如PostgreSQL,通过在数据页上冗余数据产生版本链的方式来实现多版本,因此回滚起来非常方便,只需要设置标记即可,但额外带来的问题就是无效数据清理开销。 SavePoint管理 在事务执行的过程中,你可以通过设置SAVEPOINT的方式来管理事务的执行过程。 在介绍Savepoint之前,需要先介绍下trx_t::undo_no。在事务每次成功写入一次undo后,这个计数都会递增一次(参阅函数trx_undo_report_row_operation)。事务的undo_no也会记录到undo page中进行持久化,因此在undo链表上的undo_no总是有序递增的。 总的来说,主要有以下几种操作类型。 设置SAVEPOINT 语法:SAVEPOINT sp_name 入口函数:trans_savepoint 在事务中设置一个SAVEPOINT,你可以随意命名一个名字,在事务中设置的所有 savepoint 实际上维护了两份链表,一份挂在THD变量上(thd->get_transaction()->m_savepoints),包含了基本的savepoint信息及到引擎层的映射,另一份在引擎层的事务对象上(维持在链表trx_t::trx_savepoints中)。 如下图所示:

savepoint 链表 总共分为以下几步:

  1. 在增加新的SAVEPOINT时,总是先判断下是否同名的SAVEPOINT已经存在,如果存在,就用后者替换前者;
  2. Server层维护的savepoint信息记录了命名信息及MDL锁的savepoint点。其中MDL锁的savepoint,可以实现回滚操作时释放该savepoint之后再获得的MDL锁;
  3. 在当前线程的Binlog cache中写入设置Savepoint的SQL, 并保存binlog cache中的位点 (binlog_savepoint_set);
  4. 引擎层的savepoint中记录了最近一次的trx_t::undo_no及SAVEPOINT名字。通过这些信息可以准确的定位在设置SAVEPOINT点时Undo位点。(参阅引擎层入口函数:trx_savepoint_for_mysql)。

回滚SAVEPOINT 语法:ROLLBACK TO [ SAVEPOINT ] sp_name 入口函数:trans_rollback_to_savepoint 检查点的回滚主要包括:

  1. 如果事务是一个XA事务,且已经处于XA PREPARE状态时是不允许回滚到某个SAVEPOINT的;
  2. 如果涉及非事务引擎,在binlog中写入回滚SQL,否则直接将binlog cache truncate到之前设置sp时保存的位点。(binlog_savepoint_rollback
  3. 在引擎层进行回滚(trx_rollback_to_savepoint_for_mysql) 根据之前记录的undo_no,可以逆向操作当前事务占用的undo slot上的undo记录来进行回滚。
  4. 判断是否允许回滚MDL锁:
    • binlog关闭的情况下,总是允许回滚MDL锁
    • 或者由引擎来确认(ha_rollback_to_savepoint_can_release_mdl),同时满足:
      • InnoDB:如果当前事务不持有任何事务锁(表级或者行级),则认为可以回滚MDL锁
      • Binlog:如果没有更改非事务引擎,则可以释放MDL锁

如果允许回滚MDL,则通过之前记录的st_savepoint::mdl_savepoint进行回滚 释放SAVEPOINT 语法为:RELEASE SAVEPOINT sp_name 入口函数:trans_rollback_to_savepoint 顾名思义,就是删除一个SAVEPOINT,操作也很简单,直接根据命名从server层和innodb层的清理掉,并释放对应的内存。 隐式SAVEPOINT 在InnoDB中,还有一种隐式的savepoint,通过变量trx_t::last_sql_stat_start来维护。 初始状态下trx_t::last_sql_stat_start的值为0,当执行完一条SQL时,会调用函数trx_mark_sql_stat_end将当前的trx_t::undo_no保存到trx_t::last_sql_stat_start中。 如果SQL执行失败,就可以据此进行statement级别的回滚操作(trx_rollback_last_sql_stat_for_mysql)。 无论是显式SAVEPOINT还是隐式SAVEPOINT,都是通过undo_no来指示回滚到哪个事务状态。 两个有趣的bug bug#79493 在一个只读事务中,如果设置了SAVEPOINT,任意执行一次ROLLBACK TO SAVEPOINT都会将事务从只读模式改变成读写模式。这主要是因为在活跃事务中执行ROLLBACK 操作会强制转换READ-WRITE模式。实际上这是没必要的,因为并没有造成任何的数据变更。 bug#79596 这个bug可以认为是一个相当严重的bug:在一个活跃的做过数据变更操作的事务中,任意执行一次ROLLBACK TO SAVEPOINT(即使SAVEPOINT不存在),然后kill掉客户端,会发现事务却提交了,并且没有写到binlog中。这会导致主备的数据不一致。 重现步骤如下: mysql> create table test (value int) engine=innodb; Query OK, 0 rows affected (3.88 sec) mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> insert into test set value = 1; Query OK, 1 row affected (4.43 sec) mysql> rollback to savepoint tx_0; ERROR 1305 (42000): SAVEPOINT tx_0 does not exist mysql> Killed 最后一步直接对session的进程kill -9时会导致事务commit。这主要是因为如果直接kill客户端,服务器端在清理线程资源,进行事务回滚时,相关的变量并没有被重设,thd的command类型还是SQLCOM_ROLLBACK_TO_SAVEPOINT,在函数MYSQL_BIN_LOG::rollback函数中将不会调用ha_rollback_low的引擎层回滚逻辑。原因是回滚到某个savepoint有特殊的处理流程,如果是通过ctrl+c的方式关闭client端,实际上会发送一个类型为COM_QUIT的command,它会将thd->lex->sql_command设置为SQLCOM_END,这时候会走正常的回滚逻辑。 事务执行管理 在事务执行的过程中,需要多个模块来辅助事务的正常执行:

  • Server层的MDL锁模块,维持了一个事务过程中所有涉及到的表级锁对象。通过MDL锁,可以堵塞DDL,避免DDL和DML记录binlog乱序;
  • InnoDB的trx_sys子系统,维持了所有的事务状态,包括活跃事务、非活跃事务对象、读写事务链表、负责分配事务id、回滚段、Readview等信息,是事务系统的总控模块;
  • InnoDB的lock_sys子系统,维护事务锁信息,用于对修改数据操作做并发控制,保证了在一个事务中被修改的记录,不可以被另外一个事务修改;
  • InnoDB的log_sys子系统,负责事务redo日志管理模块;
  • InnoDB的purge_sys子系统,则主要用于在事务提交后,进行垃圾回收,以及数据页的无效数据清理。

总的来说,事务管理模块的架构图,如下图所示:

InnoDB 事务管理 下面就几个事务模块的关键点展开描述。 事务ID 在InnoDB中一直维持了一个不断递增的整数,存储在trx_sys->max_trx_id中;每次开启一个新的读写事务时,都将该ID分配给事务,同时递增全局计数。事务ID可以看做一个事务的唯一标识。 在MySQL5.6及之前的版本中,总是为事务分配ID。但实际上这是没有必要的,毕竟只有做过数据更改的读写事务,我们才需要去根据事务ID判断可见性。因此在MySQL5.7版本中,只有读写事务才会分配事务ID,只读事务的ID默认为0。 那么问题来了,怎么去区分不同的只读事务呢?这里在需要输出事务ID时(例如执行SHOW ENGINE INNODB STATUS 或者查询INFORMATION_SCHEMA.INNODB_TRX表),使用只读事务对象的指针或上一个常量来标识其唯一性,具体的计算方式见函数trx_get_id_for_print。所以如果你show出来的事务ID看起来数字特别庞大,千万不要惊讶。 对于全局最大事务ID,每做256次赋值(TRX_SYS_TRX_ID_WRITE_MARGIN)就持久化一次到ibdata的事务页(TRX_SYS_PAGE_NO)中。 已分配的事务ID会加入到全局读写事务ID集合中(trx_sys->rw_trx_ids),事务ID和事务对象的map加入到trx_sys->rw_trx_set中,这是个有序的集合(std::set),可以用于通过trx id快速定位到对应的事务对象。 事务分配得到的ID并不是立刻就被使用了,而是在做了数据修改时,需要创建或重用一个undo slot时,会将当前事务的ID写入到undo page头,状态为TRX_UNDO_ACTIVE。这也是崩溃恢复时,InnoDB判断是否有未完成事务的重要依据。 在执行数据更改的过程中,如果我们更新的是聚集索引记录,事务ID + 回滚段指针会被写到聚集索引记录中,其他会话可以据此来判断可见性以及是否要回溯undo链。 对于普通的二级索引页更新,则采用回溯聚集索引页的方式来判断可见性(如果需要的话)。关于MVCC,后文会有单独描述。 事务链表和集合 事务子系统维护了三个不同的链表,用来管理事务对象。 trx_sys->mysql_trx_list 包含了所有用户线程的事务对象,即使是未开启的事务对象,只要还没被回收到trx_pool中,都被放在该链表上。当session断开时,事务对象从链表上摘取,并被回收到trx_pool中,以待重用。 trx_sys->rw_trx_list 读写事务链表,当开启一个读写事务,或者事务模式转换成读写模式时,会将当前事务加入到读写事务链表中,链表上的事务是按照trx_t::id有序的;在事务提交阶段将其从读写事务链表上移除。 trx_sys->serialisation_list 序列化事务链表,在事务提交阶段,需要先将事务的undo状态设置为完成,在这之前,获得一个全局序列号trx->no,从trx_sys->max_trx_id中分配,并将当前事务加入到该链表中。随后更新undo等一系列操作后,因此进入提交阶段的事务并不是trx->id有序的,而是根据trx->no排序。当完成undo更新等操作后,再将事务对象同时从serialisation_listrw_trx_list上移除。 这里需要说明下trx_t::no,这是个不太好理清的概念,从代码逻辑来看,在创建readview时,会用到序列化链表,链表的第一个元素具有最小的trx_t::no,会赋值给ReadView::m_low_limit_no。purge线程据此创建的readview,只有小于该值的undo,才可以被purge掉。 总的来说,mysql_trx_list包含了rw_trx_list上的事务对象,rw_trx_list包含了serialisation_list上的事务对象。 事务ID集合有两个: trx_sys->rw_trx_ids 记录了当前活跃的读写事务ID集合,主要用于构建ReadView时快速拷贝一个快照 trx_sys->rw_trx_set 这是<trx_id, trx_t>的映射集合,根据trx_id排序,用于通过trx_id快速获得对应的事务对象。一个主要的用途就是用于隐式锁转换,需要为记录中的事务id所对应的事务对象创建记录锁,通过该集合可以快速获得事务对象 事务回滚段 对于普通的读写事务,总是为其指定一个回滚段(默认128个回滚段)。而对于只读事务,如果使用到了InnoDB临时表,则为其分配(1~32)号回滚段。(回滚段指定参阅函数trx_assign_rseg_low) 当为事务指定了回滚段后,后续在事务需要写undo页时,就从该回滚段上分别分配两个slot,一个用于update_undo,一个用于insert_undo。分别处理的原因是事务提交后,update_undo需要purge线程来进行回收,而insert_undo则可以直接被重利用。 关于undo相关知识可以参阅之前的月报 事务引用计数 在介绍事务引用计数之前,我们首先要了解下什么是隐式锁。所谓隐式锁,其实并不是一个真正的事务锁对象,可以理解为一个标记:对于聚集索引页的更新,记录本身天然带事务ID,对于二级索引页,则在page上记录最近一次更新的最大事务ID,通过回表的方式判断可见性。 由于事务锁涉及到全局资源,创建锁的开销高昂,InnoDB对于新插入的记录,在没有冲突的情况下是不创建记录锁的。举个例子,Session 1插入一条记录,并保持未提交状态。另外一个session想更新这条记录,从数据页上读取到这条记录后,发现对应的事务ID还处于活跃状态,根据当前的并发规则,这个更新需要被阻塞住。因此第二个session需要为session 1创建一条记录锁,然后将自己放入等待队列中。 在MySQL5.7版本之前,隐式锁转换的逻辑为(函数lock_rec_convert_impl_to_expl

  1. 首先判断记录对应的事务ID是否还处于活跃状态 聚集索引: lock_clust_rec_some_has_impl 二级索引: lock_sec_rec_some_has_impl 如果不活跃,说明事务已提交,我们可以对这条记录做任何更改操作,直接返回;否则返回获取的trx_id
  2. 持有lock_sys->mutex;
  3. 持有trx_sys->mutex ,并获取当前记录中的事务ID对应的内存事务对象trx_t;
  4. 为该事务创建一个锁对象,并加入到锁队列中;
  5. 释放lock_sys->mutex。

上述流程中长时间持有lock_sys->mutex,目的是防止在为其转换隐式锁为显式锁时事务被提交掉。尤其是在第三步,同时持有两把大锁去查找事务对象。在5.6官方版本中,这种查找操作还需要遍历链表,开销巨大,推高了临界资源的竞争。 因此在5.7中引入事务计数trx_t::n_ref来辅助判断,在隐式锁转换时,通过读写事务集合(rw_trx_set)快速获得事务对象,同时对trx_t::n_def递增。这个过程无需加lock_sys->mutex锁。随后再持有Lock_sys->mutex去创建显式锁。在完成创建后,递减trx_t::n_ref。 为了防止为一个已提交的事务创建显式锁;在事务提交阶段也做了处理:在事务释放事务锁之前,如果引用计数非0,则表示有人正在做隐式锁转换,这里需要等待其完成。(参考函数lock_trx_release_locks)。 实际上上述修改是在官方优化读写事务链表之前完成的。由于在5.7里已经使用一个有序的集合保存了trx_idtrx_t的关联,可以非常快速的定位到事务对象,这个优化带来的性能提升已经没那么明显了。 关于隐式锁更详细的信息,我们将在之后专门讲述“事务锁”的月报中再单独描述。 事务并发控制 在MySQL5.7中,由于消除了大量临界资源的竞争,InnoDB只读查询的性能非常优化,几乎可以随着CPU线性扩展。但如果进入到读写混合的场景,就不可避免的使用到一些临界资源,例如事务、锁、日志等子系统。当竞争越激烈,就可能导致性能的下降。通常系统会有个吞吐量和响应时间最优的性能拐点。 InnoDB本身提供了并发控制机制,一种是语句级别的并发控制,另外一种是事务提交阶段的并发控制。 语句级别的并发通过参数innodb_thread_concurrency来控制,表示允许同时在InnoDB层活跃的并发SQL数。 每条SQL在进入InnoDB层进行操作之前都需要先递增全局计数,并为当前SQL分配innodb_concurrency_tickets个ticket。也就是说,如果当前SQL需要进出InnoDB层很多次(例如一个大查询需要扫描很多行数据时),innodb_concurrency_tickets次都可以自由进入InnoDB,无需判断innodb_thread_concurrency。当ticket用完时,就需要重新进入,当SQL执行完成后,会将ticket重置为0。 如果当前InnoDB层的并发度已满,用户线程就需要等待,目前的实现使用sleep一段时间的方式,sleep的时间是自适应的,但你可以通过参数innodb_adaptive_max_sleep_delay来设置一个最大sleep事件,具体的算法参阅函数srv_conc_enter_innodb_with_atomics。 提到并发控制,另外一个不得不提的问题就是热点更新问题。事务在进入InnoDB层,准备更新一条数据,但发现行记录被其他线程锁住,这时候该线程会强制退出InnoDB并发控制,同时将自己suspend住,进入睡眠等待。如果有大量并发的更新同一条记录,就意味着大量线程进入InnoDB层,访问热点竞争资源锁系统,然后再退出。最终会呈现出大量线程在InnoDB中suspend住,相当于并发控制并没有达到降低临界资源争用的效果。早期我们对该问题的优化就是将线程从堵在InnoDB层,转移到堵在进入InnoDB层时的外部排队中,这样就不涉及到InnoDB的资源争用了。具体的实现是将statement级别的并发控制提升为事务级别的并发控制,因此这个方案的缺陷是对长事务不友好。 另外还有一些并发控制方案,例如线程池、水位限流、按pk排队等策略,我们的RDS MySQL也很早就支持了。如果你存在热点争用(例如秒杀场景),并且正在使用RDS MySQL,你可以去咨询售后如何使用这些特性。 除了语句级别的并发外,InnoDB也提供了提交阶段的并发控制,主要通过参数innodb_commit_concurrency来控制。该参数的默认值为0,表示不控制commit阶段的并发。在进入函数innobase_commit时,如果该参数被设置,且当前并发度超过,就需要等待。然而由于当前在默认配置下所有事务都走组提交(ordered_commit),InnoDB层的提交大多数情况下只会有一个活跃线程。你只有关闭binlog或者关闭参数binlog_order_commits,这个参数设置才有意义。 高优先级事务 MySQL5.7 实现了一种高优先级的事务调度方式。当事务处于高优先级模式时,它将永远不会被选作deadlock场景的牺牲者,拥有获得锁的最高优先级,并能kill掉阻塞它的的低优先级事务。这个特性主要是为了支持官方开发的Group Replication Plugin套件,以保证事务总是能在所有的节点上提交。 如何使用 目前GA版本还没有提供公共接口来使用该功能,但代码实现都是完备的,如果想使用该功能,直接写一个设置变量的接口即可,非常简单。在server层,每个THD上新增了两个变量来标识事务的优先级:

  • THD::tx_priority 事务级别有效,当两个事务在InnoDB层冲突时,拥有更高值的事务将赢得锁;
  • THD::thd_tx_priority 线程级别有效,当该变量被设置时,选择该值作为事务优先级,否则选择tx_priority。

死锁检测 在进行死锁检测时,需要对死锁的两个事务的优先级进行比较,低优先级的总是会被优先回滚掉,以保证高优先级的事务正常执行(DeadlockChecker::check_and_resolve)。 处理锁等待 在对记录尝试加锁时,如果发现有别的事务和当前事务冲突(lock_re_other_has_conflicting),需要判断是否要加入到等待队列中(RecLock::add_to_wait):

  • 如果两个事务都设置了高优先级、但当前事务优先级较低,或者冲突的事务是一个后台进程开启的事务(例如dict_stat线程进行统计信息更新),则立刻失败该事务,并返回DB_DEADLOCK错误码;
  • 尝试将当前锁对象加入到等待队列中(RecLock::enqueue_priority),高优先级的事务可以跳过锁等待队列(RecLock::jump_queue),被跳过的事务需要被标记为异步回滚状态(RecLock::mark_trx_for_rollback),搜集到当前事务的trx_t::hit_list链表中。当阻塞当前事务的另外一个事务也处于等待状态、但等待另外一个不同的记录锁时,调用rollback_blocking_trx直接回滚掉,否则在进入锁等待之前再调用trx_kill_blocking依次回滚。

这里涉及到事务锁模块,本文不展开描述,下次专门在事务锁相关主题的月报讲述,你可以通过官方WL#6835 获取更过关于高优先级事务的信息。 trx_t::flush_observer 阅读代码时发现这个在5.7版本新加的变量,从它的命名可以看出,其应该和脏页flush相关。flush_observer可以认为是一个标记,当某种操作完成时,对于带这种标记的page(buf_page_t::flush_observer),需要保证完全刷到磁盘上。 该变量主要解决早期5.7版本建索引耗时太久的bug#74472:为表增加索引的操作非常慢,即使表上没几条数据。原因是InnoDB在5.7版本修正了建索引的方式,采用自底向上的构建方式,并在创建索引的过程中关闭了redo,因此在做完加索引操作后,必须将其产生的脏页完全写到磁盘中,才能认为索引构建完毕,所以发起了一次完全的checkpoint,但如果buffer pool中存在大量脏页时,将会非常耗时。 为了解决这一问题,引入了flush_observer,在建索引之前创建一个FlushObserver并分配给事务对象(trx_set_flush_observer),同时传递给BtrBulk::m_flush_observer。 在构建索引的过程中产生的脏页,通过mtr_commit将脏页转移到flush_list上时,顺便标记上flush_observer(add_dirty_page_to_flush_list —> buf_flush_note_modification)。 当做完索引构建操作后,由于bulk load操作不记redo,需要保证DDL产生的所有脏页都写到磁盘,因此调用FlushObserver::flush,将脏页写盘(buf_LRU_flush_or_remove_pages)。在做完这一步后,才开始apply online DDL过程中产生的row log(row_log_apply)。 如果DDL被中断(例如session被kill),也需要调用FlushObserver::flush,将这些产生的脏页从内存移除掉,无需写盘。 事务对象池 为了减少构建事务对象时的内存操作开销,尤其是短连接场景下的性能,InnoDB引入了一个池结构,可以很方便的分配和释放事务对象。实际上事务的事务锁对象也引用了池结构。 事务池对应的全局变量为trx_pools,初始化为: trx_pools = UT_NEW_NOKEY(trx_pools_t(MAX_TRX_BLOCK_SIZE)); trx_pools是操作trx pool的接口,类型为trx_pools_t,其定义如下: typedef Pool<trx_t, TrxFactory, TrxPoolLock> trx_pool_t; typedef PoolManager<trx_pool_t, TrxPoolManagerLock > trx_pools_t; 其中,trx_t表示事务对象类型,TrxFactory封装了事务的初始化,TrxPoolLock封装了POOL锁的创建、销毁、加锁、解锁,PoolManager封装了池的管理方法。 这里涉及到多个类:

  • Pool 及 PoolManager 是公共用的类;
  • TrxFactory 和 TrxPoolLock, TrxPoolManagerLock是trx pool私有的类;
  • TrxFactory用于定义池中事务对象的初始化和销毁动作;
  • TrxPoolLock用于定义每个池中对象的互斥锁操作;
  • 由于POOL的管理结构支持多个POOL对象,TrxPoolManagerLock用于互斥操作增加POOL对象。支持多个POOL对象的目的是分拆单个POOL对象的锁开销,避免引入热点。因为从POOL中获取和返还对象,都是需要排他锁的。

相关类的关系如下图所示:

事务池相关类 InnoDB MVCC实现 InnoDB有两个非常重要的模块来实现MVCC,一个是undo日志,用于记录数据的变化轨迹,另外一个是Readview,用于判断该session对哪些数据可见,哪些不可见。实际上我们已经在之前的月报中介绍过这部分内容,这里再简要介绍下。 事务视图ReadView 前面已经多次提到过ReadView,也就是事务视图,它用于控制数据的可见性。在InnoDB中,只有查询才需要通过Readview来控制可见性,对于DML等数据变更操作,如果操作了不可见的数据,则直接进入锁等待。 ReadView包含几个重要的变量:

  • ReadView::id 创建该视图的事务ID;
  • ReadView::m_ids 创建ReadView时,活跃的读写事务ID数组,有序存储;
  • ReadView::m_low_limit_id 设置为当前最大事务ID;
  • ReadView::m_up_limit_id m_ids集合中的最小值,如果m_ids集合为空,表示当前没有活跃读写事务,则设置为当前最大事务ID。

很显然ReadView的创建需要在trx_sys->mutex的保护下进行,相当于拿到了当时的一个全局事务快照。基于上述变量,我们就可以判断数据页上的记录是否对当前事务可见了。 为了管理ReadView,MVCC子系统使用多个链表进行分配、维护、回收ReadView:

  • MVCC::m_free 用于维护空闲的ReadView对象,初始化时创建1024个ReadView对象(trx_sys_create),当释放一个活跃的视图时,会将其加到该链表上,以便下次重用;
  • MVCC::m_views 这里存储了两类视图,一类是当前活跃的视图,另一类是上次被关闭的只读事务视图。后者主要是为了减少视图分配开销。因为当系统的读占大多数时,如果在两次查询中间没有进行过任何读写操作,那我们就可以重用这个ReadView,而无需去持有trx_sys->mutex锁重新分配;

目前自动提交的只读事务或者RR级别下的只读都支持read view缓存,但目前版本还存在的问题是,在RC级别下不支持视图缓存,见bug#79005。 另外purge系统在开始purge任务时,需去克隆MVCC::m_views链表上未被close的最老视图,并在本地视图中将该最老事务的事务ID也加入到不可见的事务DI集合ReadView::m_ids中(MVCC::clone_oldest_view)。 回滚段指针 回滚段undo是实现InnoDB MVCC的根基。每次修改聚集索引页上的记录时,变更之前的记录都会写到undo日志中。回滚段指针包括undo log所在的回滚段ID、日志所在的page no、以及page内的偏移量,可以据此找到最近一次修改之前的undo记录,而每条Undo记录又能再次找到之前的变更。 当有可能undo被访问到时,purge_sys将不会去清理undo log,如上所述,purge_sys只会去清理最老ReadView不会看到的事务。这意味着,如果你运行了一个长时间的查询SQL,或者以大于RC的隔离级别开启了一个事务视图但没有提交事务,purge系统将一直无法前行,即使你的会话并不活跃。这时候undo日志将无法被及时回收,最直观的后果就是undo空间急剧膨胀。 关于undo这里不赘述,详细参阅之前月报 可见性判断 如上所述,聚集索引的可见性判断和二级索引的可见性判断略有不同。因为二级索引记录并没有存储事务ID信息,相应的,只是在数据页头存储了最近更新该page的trx_id。 对于聚集索引记录,当我们从btree获得一条记录后,先判断(lock_clust_rec_cons_read_sees)当前的readview是否满足该记录的可见性:

  • 如果记录的trx_id小于ReadView::m_up_limit_id,则说明该事务在创建ReadView时已经提交了,肯定可见;
  • 如果记录的trx_id大于等于ReadView::m_low_limit_id,则说明该事务是创建readview之后开启的,肯定不可见;
  • trx_idm_up_limit_idm_low_limit_id之间时,如果在ReadView::m_ids数组中,说明创建readview时该事务是活跃的,其做的变更对当前视图不可见,否则对该trx_id的变更可见。

如果基于上述判断,该数据变更不可见时,就尝试通过undo去构建老版本记录(row_sel_build_prev_vers_for_mysql -->row_vers_build_for_consistent_read),直到找到可见的记录,或者到达undo链表头都未找到。 注意当隔离级别设置为READ UNCOMMITTED时,不会去构建老版本。 如果我们查询得到的是一条二级索引记录:

  • 首先将page头的trx_id和当前视图相比较:如果小于ReadView::m_up_limit_id,当前事务肯定可见;否则就需要去找到对应的聚集索引记录(lock_sec_rec_cons_read_sees);
  • 如果需要进一步判断,先根据ICP条件,检查是否该记录满足push down的条件,以减少回聚集索引的次数;
  • 满足ICP条件,则需要查询聚集索引记录(row_sel_get_clust_rec_for_mysql),之后的判断就和上述聚集索引记录的判断一致了。

在InnoDB中,只有读查询才会去构建ReadView视图,对于类似DML这样的数据更改,无需判断可见性,而是单纯的发现事务锁冲突,直接堵塞操作。 隔离级别 然而在不同的隔离级别下,可见性的判断有很大的不同。

  1. READ-UNCOMMITTED 在该隔离级别下会读到未提交事务所产生的数据更改,这意味着可以读到脏数据,实际上你可以从函数row_search_mvcc中发现,当从btree读到一条记录后,如果隔离级别设置成READ-UNCOMMITTED,根本不会去检查可见性或是查看老版本。这意味着,即使在同一条SQL中,也可能读到不一致的数据。
  2. READ-COMMITTED 在该隔离级别下,可以在SQL级别做到一致性读,当事务中的SQL执行完成时,ReadView被立刻释放了,在执行下一条SQL时再重建ReadView。这意味着如果两次查询之间有别的事务提交了,是可以读到不一致的数据的。
  3. REPEATABLE-READ 可重复读和READ-COMMITTED的不同之处在于,当第一次创建ReadView后(例如事务内执行的第一条SEELCT语句),这个视图就会一直维持到事务结束。也就是说,在事务执行期间的可见性判断不会发生变化,从而实现了事务内的可重复读。
  4. SERIALIZABLE 序列化的隔离是最高等级的隔离级别,当一个事务在对某个表做记录变更操作时,另外一个查询操作就会被该操作堵塞住。同样的,如果某个只读事务开启并查询了某些记录,那么另外一个session对这些记录的更改操作是被堵塞的。内部的实现其实很简单:
    • 对InnoDB表级别加LOCK_IS锁,防止表结构变更操作
    • 对查询得到的记录加LOCK_S共享锁,这意味着在该隔离级别下,读操作不会互相阻塞。而数据变更操作通常会对记录加LOCK_X锁,和LOCK_S锁相冲突,InnoDB通过给查询加记录锁的方式来保证了序列化的隔离级别。

注意不同的隔离级别下,数据具有不同的隔离性,甚至事务锁的加锁策略也不尽相同,你需要根据自己实际的业务情况来进行选择。 一个有趣的可见性问题 在READ-COMMITTED隔离级别下,我们考虑如下执行序列: Session 1: mysql> CREATE TABLE t1 (c1 INT PRIMARY KEY, c2 INT, c3 INT, key(c2)); Query OK, 0 rows affected (0.00 sec) mysql> BEGIN; Query OK, 0 rows affected (0.00 sec) mysql> INSERT INTO t1 VALUES (1,2,3); Query OK, 1 row affected (0.00 sec) Session 2: mysql> BEGIN; Query OK, 0 rows affected (0.00 sec) mysql> UPDATE t1 SET c3=c3+1 WHERE c3 = 3; // 扫描聚集索引进行查询,记录不可见,但未被记录锁堵塞 Query OK, 0 rows affected (0.00 sec) Rows matched: 0 Changed: 0 Warnings: 0 mysql> UPDATE t1 SET c3=c3+1 WHERE c2 = 2; // 根据二级索引进行查询,记录不可见,且被记录锁堵塞 查询条件不同,但指向的确是同一条已插入未提交的记录,为什么会有两种不同的表现呢? 这主要是不同索引在数据检索时的策略不同造成的。 实际上session2的第一条update也为session1做了隐式锁转换,但是在返回到row_search_mvcc时,会走到如下判断: Line 5312~5318, row0sel.cc: if (UNIV_LIKELY(prebuilt->row_read_type != ROW_READ_TRY_SEMI_CONSISTENT) || unique_search || index != clust_index) { goto lock_wait_or_error; }

  • 对于第一条和第二条update,prebuilt->row_read_type值均为ROW_READ_TRY_SEMI_CONSISTENT,不满足第一个条件;
  • 均不满足unique_search(通过pk,或uk作为where条件进行查询);
  • 第一个使用的聚集索引,三个条件都不满足;而第二个update使用的二级索引,因此走lock_wait_or_error的逻辑,进入锁等待。

第一条update继续往下走,根据undo去构建老版本记录(row_sel_build_committed_vers_for_mysql),一条新插入的记录老版本就是空了,所以认为这条更新没有查询到目标记录,从而忽略了锁阻塞的逻辑。 如果使用pk或者二级索引作为where条件查询的话,都会走到锁等待条件。 推而广之,如果表上没有索引的话,那么对于任意插入的记录,更新操作都见不到插入的记录(但是会为插入操作创建记录锁)。 InnoDB ACID 本小节针对ACID这四种数据库特性分别进行简单描述。 Atomicity (原子性) 所谓原子性,就是一个事务要么全部完成变更,要么全部失败。如果在执行过程中失败,回滚操作需要保证“好像”数据库从没执行过这个事务一样。 从用户的角度来看,用户发起一个COMMIT语句,要保证事务肯定成功完成了;若发起ROLLBACK语句,则干净的回滚掉事务所有的变更。 从内部实现的角度看,InnoDB对事务过程中的数据变更总是维持了undo log,若用户想要回滚事务,能够通过Undo追溯最老版本的方式,将数据全部回滚回来。若用户需要提交事务,则将提交日志刷到磁盘。 Consistency (一致性) 一致性指的是数据库需要总是保持一致的状态,即使实例崩溃了,也要能保证数据的一致性,包括内部数据存储的准确性,数据结构(例如btree)不被破坏。InnoDB通过doublewrite buffer 和crash recovery实现了这一点:前者保证数据页的准确性,后者保证恢复时能够将所有的变更apply到数据页上。如果崩溃恢复时存在还未提交的事务,那么根据XA规则提交或者回滚事务。最终实例总能处于一致的状态。 另外一种一致性指的是数据之间的约束不应该被事务所改变,例如外键约束。MySQL支持自动检查外键约束,或是做级联操作来保证数据完整性,但另外也提供了选项foreign_key_checks,如果您关闭了这个选项,数据间的约束和一致性就会失效。有些情况下,数据的一致性还需要用户的业务逻辑来保证。 Isolation (隔离性) 隔离性是指多个事务不可以对相同数据同时做修改,事务查看的数据要么就是修改之前的数据,要么就是修改之后的数据。InnoDB支持四种隔离级别,如上文所述,这里不再重复。 Durability(持久性) 当一个事务完成了,它所做的变更应该持久化到磁盘上,永不丢失。这个特性除了和数据库系统相关外,还和你的硬件条件相关。InnoDB给出了许多选项,你可以为了追求性能而弱化持久性,也可以为了完全的持久性而弱化性能。 和大多数DBMS一样,InnoDB 也遵循WAL(Write-Ahead Logging)的原则,在写数据文件前,总是保证日志已经写到了磁盘上。通过Redo日志可以恢复出所有的数据页变更。 为了保证数据的正确性,Redo log和数据页都做了checksum校验,防止使用损坏的数据。目前5.7版本默认支持使用CRC32的数据校验算法。 为了解决半写的问题,即写一半数据页时实例crash,这时候数据页是损坏的。InnoDB使用double write buffer来解决这个问题,在写数据页到用户表空间之前,总是先持久化到double write buffer,这样即使没有完整写页,我们也可以从double write buffer中将其恢复出来。你可以通过innodb_doublewrite选项来开启或者关闭该特性。 InnoDB通过这种机制保证了数据和日志的准确性的。你可以将实例配置成事务提交时将redo日志fsync到磁盘(innodb_flush_log_at_trx_commit = 1),数据文件的FLUSH策略(innodb_flush_method)修改为0_DIRECT,以此来保证强持久化。你也可以选择更弱化的配置来保证实例的性能。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏法海无边

Spring-data-jpa 让数据访问更简单、更优雅

JPA不属于ORM框架,只是一套持久化API使用规范,能够更加灵活方便的管理数据库操作。从一定意义上来讲,吸取了Hibernate和Mybatis各自的优缺点,...

21560
来自专栏c#开发者

分析Oracle数据库日志文件(1)

分析Oracle数据库日志文件(1) 一、如何分析即LogMiner解释 从目前来看,分析Oracle日志的唯一方法就是使用Oracle公司提供的LogMin...

49650
来自专栏代码世界

MYSQL之视图、触发器、存储过程、函数、事物、数据库锁和数据库备份

一、视图 -- view 视图:是一个虚报表,其内容由查询定义。同真实的表一样,视图包含一系列带有名称的列和行数据。 视图有如下特点:   1.视图的列可以来自...

40590
来自专栏IT 指南者专栏

【SSH框架】之Hibernate系列(一)

微信公众号:compassblog 欢迎关注、转发,互相学习,共同进步! 有任何问题,请后台留言联系! 1、Hibernate框架概述 (1)、什么是Hiber...

28580
来自专栏祝威廉

ElasticSearch QueryCache漫谈

这些天在做ES调优,因为之前更多的是考虑ES的架构和可运维性,并没有过多关注query调优这块。今天一查Query Cache相关的内容,发现是少之又少。于是自...

19920
来自专栏芋道源码1024

数据库中间件 Sharding-JDBC 源码分析 —— 事务(一)之BED

本文主要基于 Sharding-JDBC 1.5.0 正式版 1. 概述 2. 最大努力送达型 3. 柔性事务管理器 3.3.1 创建柔性事务 3.1 概念 3...

65550
来自专栏张戈的专栏

常用MySQL语句搜集整理

折腾 WordPress 多少要接触到 MySQL 的一些操作,比如,玛思阁之前重装 postview 插件后发现所有的 view 数据都被清空了!只好到 ph...

40750
来自专栏GreenLeaves

oracle 表空间tablespace

一、Oracle 表空间的组成 Everoone knows Oracle数据库真正存放数据的是数据文件,Oracle表空间是逻辑上的概念,他在物理上是并不存在...

36880
来自专栏Java帮帮-微信公众号-技术文章全总结

Mysql锁机制分析【面试+工作】

数据库锁定机制简单来说就是数据库为了保证数据的一致性而使各种共享资源在被并发访问访问变得有序所设计的一种规则;对于任何一种数据库来说都需要有相应的锁定机制,My...

14920
来自专栏魏琼东

一步一步教你使用AgileEAS.NET基础类库进行应用开发-基础篇-演示ORM的批量删除与更新

系列回顾           前面的文章一步一步教你使用AgileEAS.NET基础类库进行应用开发-基础篇-演示ORM的基本操作和一步一步教你使用AgileE...

22580

扫码关注云+社区

领取腾讯云代金券