数据操纵语言(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 执行计划示例

  1. obclient>create table t1(a int primary key, b int, index idx1(b));
  2. obclient>explain insert into t1 values(1, 1), (2, 2);
  3. | ====================================
  4. |ID|OPERATOR |NAME|EST. ROWS|COST|
  5. ------------------------------------
  6. |0 |INSERT | |0 |0 |
  7. |1 | EXPRESSION| |0 |0 |
  8. ====================================
  9. Outputs & filters:
  10. -------------------------------------
  11. 0 - output([column_conv(INT,PS:(11,0),NOT NULL,__values.a)], [column_conv(INT,PS:(11,0),NULL,__values.b)]), filter(nil),
  12. columns([t1.a], [t1.b]), partitions(p0)
  13. 1 - output([__values.a], [__values.b]), filter(nil)
  14. values({1, 1}, {2, 2})
  15. |

UPDATE 执行计划示例

  1. obclient>create table t1(a int primary key, b int, index idx1(b));
  2. obclient>explain update t1 set b=10 where b=1;
  3. | =======================================
  4. |ID|OPERATOR |NAME |EST. ROWS|COST|
  5. ---------------------------------------
  6. |0 |UPDATE | |1 |37 |
  7. |1 | TABLE SCAN|t1(idx)|1 |36 |
  8. =======================================
  9. Outputs & filters:
  10. -------------------------------------
  11. 0 - output(nil), filter(nil), params([{t1: (t1.a, t1.b)}]), update([t1.b=?])
  12. 1 - output([t1.a], [t1.b], [?]), filter(nil),
  13. access([t1.b], [t1.a]), partitions(p0)
  14. |

DELETE 执行计划示例

  1. obclient> explain delete from t1 where b=1 \G
  2. *************************** 1. row ***************************
  3. Query Plan:
  4. ========================================
  5. |ID|OPERATOR |NAME |EST. ROWS|COST|
  6. ----------------------------------------
  7. |0 |DELETE | |990 |1636|
  8. |1 | TABLE SCAN|t1(idx1)|990 |646 |
  9. ========================================
  10. Outputs & filters:
  11. -------------------------------------
  12. 0 - output(nil), filter(nil), table_columns([{t1: ({t1: (t1.a, t1.b)})}])
  13. 1 - output([t1.a], [t1.b]), filter(nil),
  14. access([t1.a], [t1.b]), partitions(p0)
  15. 1 row in set (0.00 sec)

跨分区更新限制

OceanBase 数据库是一个分布式关系数据库,所有的表都可以通过一定的分区方式扩展成分区表,不同的分区可以被打散存储在不同的物理位置。

如下示例为单机部署的环境,有两条 UPDATE 语句。第一条显然是单分区的更新,第二条语句虽然是多分区更新,但是不存在分区之间进行数据交叉访问,并且分区的物理位置都在一台机器上,所以第二条 UPDATE 语句也能执行成功,进一步展开第二条 UPDATE 语句的计划可以看出,该语句确实更新了多个分区,但是分区之间各自独立更新,没有数据交叉访问。

  1. obclient>create table t1(a int primary key, b int) partition by hash(a) partitions 5;
  2. obclient>insert into t1 values(1, 1), (2, 2), (3, 3);
  3. obclient> update t1 set b=10 where a=1;
  4. Query OK, 1 row affected (0.01 sec)
  5. Rows matched: 1 Changed: 1 Warnings: 0
  6. obclient> update t1 set b=a+1 where a in (1, 2, 3);
  7. Query OK, 3 rows affected (0.03 sec)
  8. Rows matched: 3 Changed: 3 Warnings: 0
  1. obclient>explain update t1 set b=1 where a in (1, 2, 3);
  2. | ============================================
  3. |ID|OPERATOR |NAME|EST. ROWS|COST|
  4. --------------------------------------------
  5. |0 |EXCHANGE IN DISTR | |9 |70 |
  6. |1 | EXCHANGE OUT DISTR| |9 |70 |
  7. |2 | UPDATE | |9 |70 |
  8. |3 | TABLE GET |t1 |9 |61 |
  9. ============================================
  10. Outputs & filters:
  11. -------------------------------------
  12. 0 - output(nil), filter(nil)
  13. 1 - output(nil), filter(nil)
  14. 2 - output(nil), filter(nil), params([{t1: (t1.a, t1.b)}]), update([t1.b=?])
  15. 3 - output([t1.a], [t1.b], [?]), filter(nil),
  16. 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 进行聚合。

image

事务管理

OceanBase 数据库提供 READ-COMMITTED 隔离级别的事务管理,所有写入 OceanBase 数据库的数据都必须满足事务的 ACID 约束,因此,在每一条 DML 语句的执行过程中,也伴随着事务对写入数据的控制。

DML 语句的执行和事务相关的交互包括 start_stmtstart_participantend_participantend_stmt 四个步骤:

  1. 在一条 DML 语句执行前需要开启 start_stmt,告诉事务控制层,该 DML 语句的写操作将被开启。start_stmt 需要收集该 DML 的分区信息,用来区分事务的类型。如果事务涉及到一个分区,即为本地事务;如果涉及到多个分区,则该事务需要进入分布式事务流程。对于单分区的 DML 来说涉及到的分区信息在 start_stmt 阶段就已经确定,对于跨分区 DML 而言,由于分区信息是在执行过程中动态确定的,因此,事务层还提供了 extend_stmt 接口用来动态的增加该语句参与事务的分区信息。

  2. 当 DML 具体对某个分区进行分区读写时,必须调用 start_participant 操作来开启该分区的事务上下文。

  3. 如果该分区发生了写失败操作,end_participant 会回滚该分区的写入脏数据。

  4. 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 上。