概述

    数据操纵语言(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数据顺序的选择,详细选择方式请参考访问路径

    DML语句执行计划展示:

    • INSERT执行计划
    1. create table t1(a int primary key, b int, index idx1(b));
    2. 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. create table t1(a int primary key, b int, index idx1(b));
    2. 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. create table t1(a int primary key, b int, index idx1(b));
    2. explain delete from t1 where b=1;
    3. | ========================================
    4. |ID|OPERATOR |NAME |EST. ROWS|COST|
    5. ----------------------------------------
    6. |0 |DELETE | |2 |39 |
    7. |1 | TABLE SCAN|t1(idx1)|2 |37 |
    8. ========================================
    9. Outputs & filters:
    10. -------------------------------------
    11. 0 - output(nil), filter(nil), params([{t1: (t1.a, t1.b)}])
    12. 1 - output([t1.a], [t1.b]), filter(nil),
    13. access([t1.a], [t1.b]), partitions(p0)
    14. |

    跨分区更新限制

    OceanBase是一个分布式关系数据库,所有的表都可以通过一定的分区方式扩展成分区表,不同的分区可以被打散 存储在不同的物理位置。在OceanBase1.x版本中,事务无法获取不同机器间的全局版本号信息,因此在READ-COMMITTED隔离级别(OceanBase目前只支持该隔离级别)下,一条DML语句不能进行跨server读数据和写数据,因此在OceanBase1.x版本中,DML语句只允许读写分区表的单分区或者物理位置相同的简单多分区写操作,分区之间不允许出现数据交叉访问的可能。举例说明,在单机部署的环境中:

    1. create table t1(a int primary key, b int) partition by hash(a) partitions 5;
    2. insert into t1 values(1, 1), (2, 2), (3, 3);
    3. OceanBase (root@test)> 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. OceanBase (root@test)> 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

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

    1. 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])
    17. |

    如果DML语句存在跨分区的数据访问或者读写分区不在同一台物理机器上,1.4.x版本上,该DML语句将会执行失败:

    1. create table t1(a int primary key, b int) partition by hash(a) partitions 5;
    2. create table t2(a int primary key, b int);
    3. explain update t1 set b=2 where a in (select a from t2);
    4. ERROR 1235 (0A000): DML operation across distributed node with exchange not supported

    OceanBase2.x版本事务支持单语句跨物理位置的多分区写操作,因此,在OceanBase2.x版本中,DML语句支持跨分区的读写操作。由于分区信息跟数据密切相关,因此只有在执行阶段能够读取到具体数据才能确定分区信息,所以在优化器生成执行计划阶段,DML语句只能判断是否进行分区写操作,而真实的分区写操作只能等到执行阶段通过具体数据生成执行信息,通过数据shuffle出具体的分区信息然后生成分区写任务,多分区写操作通过RPC机制来维护,而对于小规模的数据写操作而言,RPC是其性能的主要消耗,因此,在OceanBase2.x版本中,为了节省RPC次数,执行计划的RPC操作会按物理的Server进行聚合,如下图所示。

    DML语句处理 - 图1

    事务管理

    OceanBase提供READ-COMMITTED隔离级别的事务管理,所有写入OceanBase数据库的数据都必须满足事务的ACID约束,因此,在每一条DML语句的执行过程中,也伴随着事务对写入数据的控制。DML语句的执行和事务相关的交互包括start_stmt、start_participant、end_participant、end_stmt四个步骤,在一条DML语句执行前需要开启start_stmt步骤告诉事务控制层,该语句的写操作将被开启,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操作存在的时候,如果不规定数据表和索引更新和加锁的先后顺序,实现层面上将极其容易发生死锁,为了规避实现上的死锁,这个时候,就涉及到加锁顺序的约定:

    1. 如果DML涉及的表对象只含有LOCAL INDEX,由于LOCAL INDEX同数据表的存储位置是绑定在一起的,为了避免并发带来的实现上的死锁,我们规定必须先在主表上加锁,如果是普通的LOCAL索引,因为其主键包含数据表主键,因此没必要再对普通的LOCAL索引再加锁,而对于UNIQUE索引,由于其主键不包含数据表主键,因此对于UNIQUE索引的更新,当数据表主键加锁更新后还需要再对UNIQUE索引主键单独加锁并更新。
    2. 如果DML涉及的表对象含有GLOBAL INDEX,如果GLOBAL INDEX和数据表都是非分区表,那么其存储位置是相互绑定的,其本质和LOCAL INDEX是一样的,DML对于其的处理也和LOCAL INDEX一致。
    3. 如果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上。