数据操纵语言(Data Manipulation Language, DML)是 SQL 语言中,负责对数据库对象运行数据访问工作的指令集。它的三种核心指令:INSERT(插入)、UPDATE(更新)、DELETE(删除),是在以数据为中心的应用程序开发中必定会使用到的指令。
OceanBase 数据库还支持 REPLACE 和 INSERT INTO…ON DUPLICATED KEY UPDATE 两种 DML 语句,DML 的主要功能是访问数据,因此其语法都是以读取与写入数据库为主,除了 INSERT 以外,其他指令都可能需搭配 WHERE 指令来过滤数据范围,或是不加 WHERE 指令来访问全部的数据。
执行计划
在 OceanBase 数据库中,所有的 DML 语句都会生成对应的执行计划来进行数据的读写操作,每一种 DML 语句都会生成一种对应的 DML 算子。DML 算子负责读取数据,并通过存储层提供的数据写入接口将 DML 语句涉及到的数据写入存储引擎中。对于 INSERT/REPLACE 语句而言,由于其不用读取表中的已有数据,因此,INSERT 语句的执行计划相对简单,其执行计划为简单的 EXPR VALUES+INSERT OP 算子构成。而对于 UPDATE 或者 DELETE 语句而言,优化器会通过代价模型对 WHERE 条件进行访问路径的选择,或者 ORDER BY 数据顺序的选择,详细选择方式请参考 访问路径。
INSERT 执行计划示例
obclient>create table t1(a int primary key, b int, index idx1(b));
obclient>explain insert into t1 values(1, 1), (2, 2);
| ====================================
|ID|OPERATOR |NAME|EST. ROWS|COST|
------------------------------------
|0 |INSERT | |0 |0 |
|1 | EXPRESSION| |0 |0 |
====================================
Outputs & filters:
-------------------------------------
0 - output([column_conv(INT,PS:(11,0),NOT NULL,__values.a)], [column_conv(INT,PS:(11,0),NULL,__values.b)]), filter(nil),
columns([t1.a], [t1.b]), partitions(p0)
1 - output([__values.a], [__values.b]), filter(nil)
values({1, 1}, {2, 2})
|
UPDATE 执行计划示例
obclient>create table t1(a int primary key, b int, index idx1(b));
obclient>explain update t1 set b=10 where b=1;
| =======================================
|ID|OPERATOR |NAME |EST. ROWS|COST|
---------------------------------------
|0 |UPDATE | |1 |37 |
|1 | TABLE SCAN|t1(idx)|1 |36 |
=======================================
Outputs & filters:
-------------------------------------
0 - output(nil), filter(nil), params([{t1: (t1.a, t1.b)}]), update([t1.b=?])
1 - output([t1.a], [t1.b], [?]), filter(nil),
access([t1.b], [t1.a]), partitions(p0)
|
DELETE 执行计划示例
obclient> explain delete from t1 where b=1 \G
*************************** 1. row ***************************
Query Plan:
========================================
|ID|OPERATOR |NAME |EST. ROWS|COST|
----------------------------------------
|0 |DELETE | |990 |1636|
|1 | TABLE SCAN|t1(idx1)|990 |646 |
========================================
Outputs & filters:
-------------------------------------
0 - output(nil), filter(nil), table_columns([{t1: ({t1: (t1.a, t1.b)})}])
1 - output([t1.a], [t1.b]), filter(nil),
access([t1.a], [t1.b]), partitions(p0)
1 row in set (0.00 sec)
跨分区更新限制
OceanBase 数据库是一个分布式关系数据库,所有的表都可以通过一定的分区方式扩展成分区表,不同的分区可以被打散存储在不同的物理位置。
如下示例为单机部署的环境,有两条 UPDATE 语句。第一条显然是单分区的更新,第二条语句虽然是多分区更新,但是不存在分区之间进行数据交叉访问,并且分区的物理位置都在一台机器上,所以第二条 UPDATE 语句也能执行成功,进一步展开第二条 UPDATE 语句的计划可以看出,该语句确实更新了多个分区,但是分区之间各自独立更新,没有数据交叉访问。
obclient>create table t1(a int primary key, b int) partition by hash(a) partitions 5;
obclient>insert into t1 values(1, 1), (2, 2), (3, 3);
obclient> update t1 set b=10 where a=1;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0
obclient> update t1 set b=a+1 where a in (1, 2, 3);
Query OK, 3 rows affected (0.03 sec)
Rows matched: 3 Changed: 3 Warnings: 0
obclient>explain update t1 set b=1 where a in (1, 2, 3);
| ============================================
|ID|OPERATOR |NAME|EST. ROWS|COST|
--------------------------------------------
|0 |EXCHANGE IN DISTR | |9 |70 |
|1 | EXCHANGE OUT DISTR| |9 |70 |
|2 | UPDATE | |9 |70 |
|3 | TABLE GET |t1 |9 |61 |
============================================
Outputs & filters:
-------------------------------------
0 - output(nil), filter(nil)
1 - output(nil), filter(nil)
2 - output(nil), filter(nil), params([{t1: (t1.a, t1.b)}]), update([t1.b=?])
3 - output([t1.a], [t1.b], [?]), filter(nil),
access([t1.a], [t1.b]), partitions(p[1-3])
OceanBase 数据库 V2.x 版本支持全局时间戳服务,在开启全局时间戳服务的情况下,事务支持单语句跨物理位置的多分区写操作。因此,在 OceanBase 数据库 V2.x 版本中,DML 语句支持跨分区的读写操作。由于分区信息跟数据密切相关,因此只有在执行阶段能够读取到具体数据才能确定分区信息,所以在优化器生成执行计划阶段,DML 语句只能判断是否进行分区写操作,而真实的分区写操作只能等到执行阶段通过具体数据生成执行信息,通过数据 shuffle 出具体的分区信息然后生成分区写任务,多分区写操作通过 RPC 机制来维护,而对于小规模的数据写操作而言,RPC 是其性能的主要消耗,因此,在 OceanBase 数据库 V2.x 版本中,为了节省 RPC 次数,执行计划的 RPC 操作会按物理的 server 进行聚合。
事务管理
OceanBase 数据库提供 READ-COMMITTED 隔离级别的事务管理,所有写入 OceanBase 数据库的数据都必须满足事务的 ACID 约束,因此,在每一条 DML 语句的执行过程中,也伴随着事务对写入数据的控制。
DML 语句的执行和事务相关的交互包括 start_stmt
、start_participant
、end_participant
、end_stmt
四个步骤:
在一条 DML 语句执行前需要开启
start_stmt
,告诉事务控制层,该 DML 语句的写操作将被开启。start_stmt
需要收集该 DML 的分区信息,用来区分事务的类型。如果事务涉及到一个分区,即为本地事务;如果涉及到多个分区,则该事务需要进入分布式事务流程。对于单分区的 DML 来说涉及到的分区信息在start_stmt
阶段就已经确定,对于跨分区 DML 而言,由于分区信息是在执行过程中动态确定的,因此,事务层还提供了extend_stmt
接口用来动态的增加该语句参与事务的分区信息。当 DML 具体对某个分区进行分区读写时,必须调用
start_participant
操作来开启该分区的事务上下文。如果该分区发生了写失败操作,
end_participant
会回滚该分区的写入脏数据。end_stmt 会回滚该 DML 语句写入的所有数据,如果 DML 语句执行成功,
end_stmt
会记录该语句的 save point,当该事务内后续的其它语句写失败的时候,save point 用来决定回滚的起始位置。
一致性校验
DML 操作的表对象每一列都有相关的约束性定义,例如列的 NOT NULL 约束,UNIQUE KEY 约束等。在 DML语句最终写入数据前,需要对列的 NOT NULL、UNIQUE KEY 约束进行检查,同时还要对写入数据进行类型转换,保证写入数据的类型同 Schema 中列定义的类型保持一致。当约束性检查失败,需要回滚该 DML 语句写入的脏数据,NOT NULL 检查和类型转换通过 SQL 层生成的 COLUMN_CONVERT 表达式来完成,执行计划会为 DML语句写入表中的每一列都添加该表达式。在执行算子中,数据以行的形式被流式的迭代,在迭代过程中,COLUMN_CONVERT 表达式被计算,即可完成相应的类型转换和约束性检查,而 UNIQUE KEY 约束的检查是在存储层的 data buffer 中完成。
锁管理
加锁时机
OceanBase 数据库的 DML 通过 MVCC 和锁结合的机制来完成,DML 读操作是读一个数据的快照,并不会对读到的数据进行加锁处理,而写操作需要对涉及到的每一行加 record X lock 来保证同一行上的事务对该行的修改是串行执行,在 DML 语句对一行数据加上行锁后,会进入 TSC(transaction set consistency) 检查阶段,检查事务中DML操作每一行上的 commit version 是否最新版本(即读取的数据是否最新,是否基于最新版本的数据做修改),从而防止更新丢失 (lost update) 的发生,如果在加锁后发现其 commit version 不是最新,TSC 检查失败,标志该行已被并发的其它事务修改,该 DML 语句需要失败回滚掉写入的脏数据并对该语句进行重试逻辑。因此,为了避免事务被频繁的回滚而引起写入性能的下降,应该尽量避免大量 DML 语句对同一行进行频繁的并发读写,一种保守的做法是,如果某一行需要被频繁的更新,应当使用 SELECT…FOR UPDATE 先对该行加锁,然后再执行 DML 操作。
加锁顺序
如果写数据的表中含有索引,DML 操作还需要级联的同步更新数据表中的数据和索引表中的数据。当并发 DML 操作存在的时候,如果不规定数据表和索引更新和加锁的先后顺序,实现层面上将极其容易发生死锁,为了规避实现上的死锁,这个时候,就涉及到加锁顺序的约定:
如果 DML 涉及的表对象只含有 LOCAL INDEX,由于 LOCAL INDEX 同数据表的存储位置是绑定在一起的,为了避免并发带来的实现上的死锁,我们规定必须先在主表上加锁,如果是普通的 LOCAL 索引,因为其主键包含数据表主键,因此没必要再对普通的 LOCAL 索引再加锁,而对于 UNIQUE 索引,由于其主键不包含数据表主键,因此对于UNIQUE 索引的更新,当数据表主键加锁更新后还需要再对 UNIQUE 索引主键单独加锁并更新。
如果 DML 涉及的表对象含有 GLOBAL INDEX,如果 GLOBAL INDEX 和数据表都是非分区表,那么其存储位置是相互绑定的,其本质和 LOCAL INDEX 是一样的,DML 对于其的处理也和 LOCAL INDEX 一致。
如果 GLOBAL INDEX 或者数据表是分区表,GLOBAL INDEX 和数据表的存储位置就是是完全独立的,因此从数据存储层来看,GLOBAL INDEX 是一张独立的表,在 DML 更新数据表和 GLOBAL INDEX 的时候,由于物理位置的隔离,产生 RPC 交互也是无法避免的,权衡了 RPC 次数带来的性能损失和加锁死锁的概率后,规定数据表和 GLOBAL INDEX 的加锁顺序为:
如果 DML 涉及到的数据表分区分布在同一个 server 上,那么 DML 操作会先对主表加锁,再对不同 server 上的各个 GLOBAL INDEX 并行加锁,
如果 DML 涉及到的数据表和 GLOBAL INDEX 都分布在不同的 server 上,将不再约定 server 间的先后顺序,各个 server 并行执行,但是在每个 server 内部如果同时存在更新数据表和 GLOBAL INDEX,依然会先对数据表加锁,在对 GLOBAL INDEX 加锁,这是出于尽力避免死锁的考虑。
当包含分区的 GLOBAL INDEX 后,由于 DML 操作加锁顺序不可预期,理论上存在实现带来的死锁问题,为了尽可能避免死锁,对于 GLOBAL INDEX 的 DML 操作,用户应当尽量保证 DML 操作涉及到的数据表中的数据位于同一个 server 上。