本文介绍 OceanBase 的 SQL 执行类型。分布式数据库下数据分布在各个节点,SQL 很有可能会跨节点取数据。在分布式数据库里这个是常见的现象。常见但不简单,深入分析可以知道功能和性能都有很多讲究。
分区的位置
在前文《揭秘OceanBase的弹性伸缩和负载均衡原理》里介绍了 OceanBase 分区的分布和弹性伸缩原理。分区是表的子集,普通的表是一个分区,分区表有多个分区。分区表的全局索引也是一个独立的分区。如果全局索引还分区了,那就是独立的好几个分区。分区的位置跟 SQL请求的位置是否相同会影响 SQL 执行计划的形式。
前面几篇文章里分享的 SQL 执行计划都是默认分区的位置跟 SQL 请求的位置是相同的。本文分享如果这两个位置不同,前面的执行计划会有什么变化。
首先我们要会查看分区的位置。SQL 如下。
SELECT a.tenant_name,d.database_name, t.table_name, tg.tablegroup_name , t.part_num , t2.partition_id, t2.ZONE, t2.svr_ip, t2.role , a.primary_zone , t2.row_count, round(data_size/1024/1024,0) data_size_MB FROM oceanbase.__all_tenant AS a JOIN oceanbase.__all_virtual_database AS d ON ( a.tenant_id = d.tenant_id ) JOIN oceanbase.__all_virtual_table AS t ON (t.tenant_id = d.tenant_id AND t.database_id = d.database_id) JOIN oceanbase.__all_virtual_meta_table t2 ON (t.tenant_id = t2.tenant_id AND (t.table_id=t2.table_id OR t.tablegroup_id=t2.table_id) AND t2.ROLE IN (1) ) LEFT JOIN oceanbase.__all_virtual_tablegroup AS tg ON (t.tenant_id = tg.tenant_id and t.tablegroup_id = tg.tablegroup_id) WHERE a.tenant_id IN (1001 ) AND t.table_type IN (3) AND d.database_name = 'TPCC' ORDER BY t.tenant_id, tg.tablegroup_name, d.database_name, t.table_name, t2.partition_id ; +-------------+---------------+------------------+-----------------+----------+--------------+-------+---------------+------+--------------+-----------+--------------+ | tenant_name | database_name | table_name | tablegroup_name | part_num | partition_id | ZONE | svr_ip | role | primary_zone | row_count | data_size_MB | +-------------+---------------+------------------+-----------------+----------+--------------+-------+---------------+------+--------------+-----------+--------------+ | oboracle | TPCC | BMSQL_CONFIG | NULL | 1 | 0 | zone2 | 172.30.118.74 | 1 | RANDOM | 4 | 2 | | oboracle | TPCC | BMSQL_ITEM | NULL | 1 | 0 | zone1 | 172.30.118.75 | 1 | RANDOM | 100000 | 8 | | oboracle | TPCC | BMSQL_CUSTOMER | tpcc_group | 1 | 0 | zone2 | 172.30.118.74 | 1 | RANDOM | 3000000 | 1284 | | oboracle | TPCC | BMSQL_DISTRICT | tpcc_group | 1 | 0 | zone2 | 172.30.118.74 | 1 | RANDOM | 1000 | 2 | | oboracle | TPCC | BMSQL_HISTORY | tpcc_group | 1 | 0 | zone2 | 172.30.118.74 | 1 | RANDOM | 3000000 | 72 | | oboracle | TPCC | BMSQL_NEW_ORDER | tpcc_group | 1 | 0 | zone2 | 172.30.118.74 | 1 | RANDOM | 900000 | 2 | | oboracle | TPCC | BMSQL_OORDER | tpcc_group | 1 | 0 | zone2 | 172.30.118.74 | 1 | RANDOM | 3000000 | 16 | | oboracle | TPCC | BMSQL_ORDER_LINE | tpcc_group | 1 | 0 | zone2 | 172.30.118.74 | 1 | RANDOM | 29989150 | 844 | | oboracle | TPCC | BMSQL_STOCK | tpcc_group | 1 | 0 | zone2 | 172.30.118.74 | 1 | RANDOM | 10000000 | 2382 | | oboracle | TPCC | BMSQL_WAREHOUSE | tpcc_group | 1 | 0 | zone2 | 172.30.118.74 | 1 | RANDOM | 100 | 2 | +-------------+---------------+------------------+-----------------+----------+--------------+-------+---------------+------+--------------+-----------+--------------+ 10 rows in set (0.02 sec)
查看分区主副本位置的 SQL 倒不一定要这么复杂,这里只是顺便把相关信息都查询出来了。
SQL 路由节点
下面是应用访问 OceanBase 分区的链路示意图。应用访问的 obproxy 或者 F5 的 VIP 。

简单说明:
- OB 的每个分区至少有三个副本,其中一个主副本(
leader
)和两个备副本(follower
)。默认只有主副本提供读写服务。 - OBProxy 只提供 SQL 路由功能,不提供计算功能。OBProxy 的路由策略很丰富。简单理解就是把 SQL转发到第一个表分区的主副本所在节点。
- 如果开启事务了,OBProxy会把整个事务的 SQL 转发到开启事务的那条 SQL 被路由到的节点。
- OBServer自身也有 SQL 路由功能,如果被转发过来的 SQL 解析执行计划时发现数据在其他节点,会发起一个远程的取数据操作。
所以后续的 SQL 如果访问的数据分区不在当前节点,则该 SQL 类型就是一个远程 SQL。
为了测试生成远程执行计划,我们就不能通过 OBProxy 连接集群,而改为直连那个 OB 节点。
远程 SQL 测试
EXCHANGE IN|OUT REMOTE
首先看要访问表的主副本节点,然后直连另外一个节点。
比如说表 BMSQL_ITEM
主副本在节点 172.30.118.75
上,测试访问时故意从节点 172.30.118.74
访问。
- 示例 1
obclient -h172.30.118.74 -utpcc@oboracle -P2881 -p123456 -c -A tpcc -e " explain extended_Noaddr select /*+ test20210405 */ * from bmsql_item where i_id=100\G " obclient: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Query Plan: =================================================== |ID|OPERATOR |NAME |EST. ROWS|COST| --------------------------------------------------- |0 |EXCHANGE IN REMOTE | |1 |54 | |1 | EXCHANGE OUT REMOTE| |1 |53 | |2 | TABLE GET |BMSQL_ITEM|1 |53 | =================================================== Outputs & filters: ------------------------------------- 0 - output([BMSQL_ITEM.I_ID], [BMSQL_ITEM.I_NAME], [BMSQL_ITEM.I_PRICE], [BMSQL_ITEM.I_DATA], [BMSQL_ITEM.I_IM_ID]), filter(nil) 1 - output([BMSQL_ITEM.I_ID], [BMSQL_ITEM.I_NAME], [BMSQL_ITEM.I_PRICE], [BMSQL_ITEM.I_DATA], [BMSQL_ITEM.I_IM_ID]), filter(nil) 2 - output([BMSQL_ITEM.I_ID], [BMSQL_ITEM.I_NAME], [BMSQL_ITEM.I_PRICE], [BMSQL_ITEM.I_DATA], [BMSQL_ITEM.I_IM_ID]), filter(nil), access([BMSQL_ITEM.I_ID], [BMSQL_ITEM.I_NAME], [BMSQL_ITEM.I_PRICE], [BMSQL_ITEM.I_DATA], [BMSQL_ITEM.I_IM_ID]), partitions(p0), is_index_back=false, range_key([BMSQL_ITEM.I_ID]), range[100 ; 100], range_cond([BMSQL_ITEM.I_ID = 100])
说明:
Exchange
算子是分布式场景下,用于线程间进行数据交互的算子。它一般都是成对出现的,数据源端有一个out
算子,目的端会有一个in
算子。- 算子 2 是实际取数据的执行计划,
TABLE GET
是主键访问数据的算子。 - 算子 1 的
EXCHANGE OUT REMOTE
在远端机器上负责读取数据并传输出去。 - 算子 0 的
EXCHANGE IN REMOTE
在本地节点接收数据。
上面是主键扫描示例,下面是索引扫描的示例。
- 示例 2
obclient -h172.30.118.74 -utpcc@oboracle -P2881 -p123456 -c -A tpcc -e " explain extended_Noaddr select /*+ test20210405 */ * from bmsql_item where i_name LIKE 'Ax%'\G " obclient: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Query Plan: ==================================================================== |ID|OPERATOR |NAME |EST. ROWS|COST| -------------------------------------------------------------------- |0 |EXCHANGE IN REMOTE | |33 |294 | |1 | EXCHANGE OUT REMOTE| |33 |264 | |2 | TABLE SCAN |BMSQL_ITEM(BMSQL_ITEM_IDX1)|33 |264 | ==================================================================== Outputs & filters: ------------------------------------- 0 - output([BMSQL_ITEM.I_ID], [BMSQL_ITEM.I_NAME], [BMSQL_ITEM.I_PRICE], [BMSQL_ITEM.I_DATA], [BMSQL_ITEM.I_IM_ID]), filter(nil) 1 - output([BMSQL_ITEM.I_NAME], [BMSQL_ITEM.I_ID], [BMSQL_ITEM.I_PRICE], [BMSQL_ITEM.I_DATA], [BMSQL_ITEM.I_IM_ID]), filter(nil) 2 - output([BMSQL_ITEM.I_NAME], [BMSQL_ITEM.I_ID], [BMSQL_ITEM.I_PRICE], [BMSQL_ITEM.I_DATA], [BMSQL_ITEM.I_IM_ID]), filter(nil), access([BMSQL_ITEM.I_NAME], [BMSQL_ITEM.I_ID], [BMSQL_ITEM.I_PRICE], [BMSQL_ITEM.I_DATA], [BMSQL_ITEM.I_IM_ID]), partitions(p0), is_index_back=true, range_key([BMSQL_ITEM.I_NAME], [BMSQL_ITEM.I_ID]), range(Ax,MIN ; Ax������������������������������� ,MAX), range_cond([(T_OP_LIKE, BMSQL_ITEM.I_NAME, ?, '\')])
以上是直连 OB 节点人为构造的远程 SQL 访问,初看有点刻意为之,只是为了方便理解远程 SQL的执行计划。实际业务访问 OB 都是先通过 OBPROXY
,然后再被 OBPROXY
转发到后端 OB 节点。不过即使是只通过 OBORPXY
,也还是有可能存在一些远程 SQL 访问。
下面通过 OBPROXY
执行.
- 示例 3
CREATE INDEX bmsql_customer_idx1 ON bmsql_customer(c_last) LOCAL; CREATE INDEX bmsql_item_idx1 ON bmsql_item(i_name) LOCAL; EXPLAIN extended_Noaddr SELECT /*+ test2021040502 no_use_px */ o.O_W_ID , o.O_D_ID , o.O_OL_CNT , ol.OL_NUMBER ,ol.OL_QUANTITY , ol.OL_AMOUNT , i.I_NAME , i.I_PRICE FROM BMSQL_CUSTOMER c JOIN BMSQL_OORDER o ON (c.c_id = o.o_c_id AND c.c_w_id=o.o_w_id AND c.c_d_id=o.o_d_id) JOIN BMSQL_ORDER_LINE ol ON (o.O_ID = ol.OL_O_ID AND o.O_W_ID=ol.OL_W_ID AND o.O_D_ID=ol.OL_D_ID) JOIN BMSQL_ITEM i ON (ol.OL_I_ID = i.I_ID) WHERE c.c_last='BARBARESE' AND c.c_w_id=1 AND c.c_d_id=1 ORDER BY o.O_W_ID , o.O_D_ID , o.O_OL_CNT , ol.OL_NUMBER ;
从前面分区位置知 BMSQL_CUSTOMER
和 BMSQL_OORDER
和 BMSQL_ORDER_LINE
的主副本在节点 172.30.118.74
上, BMSQL_ITEM
的主副本在 172.30.118.75
上。这三个表的表连接如果发生在节点 74 上,对 BMSQL_ITEM
的访问就会是远程 SQL。如果发生在 75 上,对其他三个表就是远程访问。
所以这个 SQL 的执行计划可能有下面两种:
================================================================ |ID|OPERATOR |NAME |EST. ROWS|COST| ---------------------------------------------------------------- |0 |SORT | |16 |1046| |1 | NESTED-LOOP JOIN | |16 |803 | |2 | EXCHANGE IN DISTR | |16 |247 | |3 | EXCHANGE OUT DISTR| |16 |197 | |4 | NESTED-LOOP JOIN | |16 |197 | |5 | NESTED-LOOP JOIN| |2 |126 | |6 | TABLE SCAN |C(BMSQL_CUSTOMER_IDX1)|1 |36 | |7 | TABLE SCAN |O(BMSQL_OORDER_IDX1) |2 |89 | |8 | TABLE SCAN |OL |10 |39 | |9 | TABLE GET |I |1 |36 | ================================================================ Outputs & filters: ------------------------------------- 0 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT], [I.I_NAME], [I.I_PRICE]), filter(nil), sort_keys([O.O_OL_CNT, ASC], [OL.OL_NUMBER, ASC]) 1 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT], [I.I_NAME], [I.I_PRICE]), filter(nil), conds(nil), nl_params_([OL.OL_I_ID]), inner_get=false, self_join=false, batch_join=true 2 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT], [OL.OL_I_ID]), filter(nil) 3 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT], [OL.OL_I_ID]), filter(nil) 4 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT], [OL.OL_I_ID]), filter(nil), conds(nil), nl_params_([O.O_ID]), inner_get=false, self_join=false, batch_join=true 5 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [O.O_ID]), filter(nil), conds(nil), nl_params_([C.C_ID]), inner_get=false, self_join=false, batch_join=true 6 - output([C.C_ID]), filter(nil), access([C.C_ID]), partitions(p0), is_index_back=false, range_key([C.C_LAST], [C.C_W_ID], [C.C_D_ID], [C.C_ID]), range(BARBARESE,1,1,MIN ; BARBARESE,1,1,MAX), range_cond([C.C_LAST = 'BARBARESE'], [C.C_W_ID = 1], [C.C_D_ID = 1]) 7 - output([O.O_W_ID], [O.O_D_ID], [O.O_ID], [O.O_OL_CNT]), filter(nil), access([O.O_W_ID], [O.O_D_ID], [O.O_ID], [O.O_OL_CNT]), partitions(p0), is_index_back=true, range_key([O.O_C_ID], [O.O_W_ID], [O.O_D_ID], [O.O_ID]), range(MIN ; MAX), range_cond([O.O_W_ID = 1], [O.O_D_ID = 1], [? = O.O_C_ID]) 8 - output([OL.OL_I_ID], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT]), filter(nil), access([OL.OL_I_ID], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT]), partitions(p0), is_index_back=false, range_key([OL.OL_W_ID], [OL.OL_D_ID], [OL.OL_O_ID], [OL.OL_NUMBER]), range(MIN ; MAX), range_cond([OL.OL_W_ID = 1], [OL.OL_D_ID = 1], [? = OL.OL_O_ID]) 9 - output([I.I_NAME], [I.I_PRICE]), filter(nil), access([I.I_NAME], [I.I_PRICE]), partitions(p0), is_index_back=false, range_key([I.I_ID]), range(MIN ; MAX), range_cond([? = I.I_ID]) Query Plan: ================================================================= |ID|OPERATOR |NAME |EST. ROWS|COST | ----------------------------------------------------------------- |0 |SORT | |16 |87774| |1 | MERGE JOIN | |16 |87531| |2 | SORT | |16 |416 | |3 | NESTED-LOOP JOIN | |16 |197 | |4 | NESTED-LOOP JOIN | |2 |126 | |5 | TABLE SCAN |C(BMSQL_CUSTOMER_IDX1)|1 |36 | |6 | TABLE SCAN |O(BMSQL_OORDER_IDX1) |2 |89 | |7 | TABLE SCAN |OL |10 |39 | |8 | EXCHANGE IN DISTR | |100000 |73301| |9 | EXCHANGE OUT DISTR| |100000 |34254| |10| TABLE SCAN |I |100000 |34254| ================================================================= Outputs & filters: ------------------------------------- 0 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT], [I.I_NAME], [I.I_PRICE]), filter(nil), sort_keys([O.O_OL_CNT, ASC], [OL.OL_NUMBER, ASC]) 1 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT], [I.I_NAME], [I.I_PRICE]), filter(nil), equal_conds([OL.OL_I_ID = I.I_ID]), other_conds(nil) 2 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT], [OL.OL_I_ID]), filter(nil), sort_keys([OL.OL_I_ID, ASC]) 3 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT], [OL.OL_I_ID]), filter(nil), conds(nil), nl_params_([O.O_ID]), inner_get=false, self_join=false, batch_join=true 4 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [O.O_ID]), filter(nil), conds(nil), nl_params_([C.C_ID]), inner_get=false, self_join=false, batch_join=true 5 - output([C.C_ID]), filter(nil), access([C.C_ID]), partitions(p0), is_index_back=false, range_key([C.C_LAST], [C.C_W_ID], [C.C_D_ID], [C.C_ID]), range(BARBARESE,1,1,MIN ; BARBARESE,1,1,MAX), range_cond([C.C_LAST = 'BARBARESE'], [C.C_W_ID = 1], [C.C_D_ID = 1]) 6 - output([O.O_W_ID], [O.O_D_ID], [O.O_ID], [O.O_OL_CNT]), filter(nil), access([O.O_W_ID], [O.O_D_ID], [O.O_ID], [O.O_OL_CNT]), partitions(p0), is_index_back=true, range_key([O.O_C_ID], [O.O_W_ID], [O.O_D_ID], [O.O_ID]), range(MIN ; MAX), range_cond([O.O_W_ID = 1], [O.O_D_ID = 1], [? = O.O_C_ID]) 7 - output([OL.OL_I_ID], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT]), filter(nil), access([OL.OL_I_ID], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT]), partitions(p0), is_index_back=false, range_key([OL.OL_W_ID], [OL.OL_D_ID], [OL.OL_O_ID], [OL.OL_NUMBER]), range(MIN ; MAX), range_cond([OL.OL_W_ID = 1], [OL.OL_D_ID = 1], [? = OL.OL_O_ID]) 8 - output([I.I_ID], [I.I_NAME], [I.I_PRICE]), filter(nil) 9 - output([I.I_ID], [I.I_NAME], [I.I_PRICE]), filter(nil) 10 - output([I.I_ID], [I.I_NAME], [I.I_PRICE]), filter(nil), access([I.I_ID], [I.I_NAME], [I.I_PRICE]), partitions(p0), is_index_back=false, range_key([I.I_ID]), range(MIN ; MAX)always true
测试发现这个 SQL 出现了两种执行计划,因为走了不同的 OBPROXY
,被路由到不同的 OB 节点。这个结果很有趣(原因暂不深究),正好用来展示远程 SQL 执行计划。除了远程执行计划算子外,其他算子的原理跟此前介绍的都一样。
从这个例子可以看出,同样的 SQL 路由到不同节点执行,其执行计划是很可能不一样的,性能也可能会不一样。在这里,NESTED-LOOP JOIN
肯定比 MERGE JOIN
性能要好一些。
SQL 执行类型
OceanBase SQL 的执行计划类别包含三种:
plan_type=1
:本地执行计划。此前文章分享的都是。性能最好。plan_type=2
: 远程执行计划。本文里示例 1 和 2 都是。plan_type=3
: 分布式执行计划。本文里示例 3 是,包含本地执行计划和远程执行计划。
这个 plan_type
对应了 视图 gv$sql_audit
的 plan_type
,以及 视图 gv$plan_cache_plan_stat
。
所以,在对应用进行 SQL 诊断分析时,都是事后分析,从看这两个视图入手。
SELECT svr_ip,plan_Id,TYPE, d.database_name , sql_id, query_sql, first_load_time, avg_exe_usec, slow_count,executions, slowest_exe_usec ,round( rows_processed / executions,2 ) avg_rows, round(disk_reads/executions ) avg_disk_reads,round(buffer_gets /executions ) avg_buffer_gets FROM `gv$plan_cache_plan_stat` s LEFT JOIN `gv$database` d ON (s.tenant_id =d.tenant_id AND s.db_id =d.database_id ) WHERE s.tenant_id=1001 -- 改成具体的 tenant_id AND d.database_name in ('TPCC') and query_sql like '%test20210405%' ORDER BY avg_exe_usec desc, slow_count desc limit 10 ;

SELECT /*+ ob_querytimeout(100000000) */ usec_to_time(request_time) request_time_, s.svr_ip, s.query_sql, plan_type,TRANSACTION_HASH ,ret_code,RETURN_ROWS ,AFFECTED_ROWS , s.* FROM gv$sql_audit s WHERE s.tenant_id=1001 -- and user_name='TPCC' and QUERY_SQL like '%test20210405%' and QUERY_SQL not like 'EXPLAIN%' ORDER BY REQUEST_ID desc LIMIT 100;
列 svr_ip 就是该 SQL 被路由到的 OB 节点,列 client_ip 是 客户端IP (可能是obproxy 的 IP ,也可能是实际客户端的 IP ),列 sid 记录的该 SQL 在节点 svr_ip 上的会话 ID, 这个会话id 也等于 __all_virtual_processlist 的 ID 列。__all_virtual_processlist 的列 proxy_sessid 是该会话在 OBProxy 上的会话 ID ,根据这个值可以查出一个前端用户连接对应的多个后端连接。事务里所有表的路由都会跟随第一条开启事务的语句的路由,识别前后端连接关系方便找到完整的业务会话请求。
SQL 执行类型不一样,性能就不一样。远程 SQL 和分布式 SQL 执行耗时会增加网络上的来回延时。在互联网场景应用里了,应用做了服务化以及水平拆分,很容易降分布式 SQL 和远程 SQL 比例。在传统应用架构里,这个就有点困难,但也不是一点办法都没有。
OB 解决方案
第一个技术就是表分组(TABLE GROUP
)。此前已经深入介绍过,详情请看《OCEANBASE TABLEGROUP 用法介绍》。在上面的例子里,表 BMSQL_CUSTOMER
和 BMSQL_OORDER
和 BMSQL_ORDER_LINE
都是按相同的水平拆分设计(分区策略),所以它们是放在同一个表分组里。它们的同号分区是在同一个节点,做表连接的时候,SQL 执行计划自然都是本地 SQL。
第二个技术就是复制表技术。这个以前也简单介绍过(《OceanBase事务引擎特性和应用实践分享》)。这里再举例说明一下。
普通的表是三副本,一个主副本和两个备副本。主备副本同步使用 PAXOS
协议,多数派事务日志落盘成功,事务就成功了。复制表的不同之处在于副本数不一定是三副本,可以指定。常用的做法是把租户所在的资源单元里都放一个备副本(如果本来就有备副本了,那就不用增加了)。也就是如果租户的资源单元布局是 1-1-1
,复制表依然是三副本,一个主副本和两个备副本。但是同步策略是比 PAXOS
更严格的全同步策略,即所有副本的事务日志都要落盘成功,事务才算成功。如果租户的资源单元布局是 2-2-2
,复制表就是六副本,一个主副本和五个备副本,主备副本同步使用全同步策略。所以,复制表是严格保证每个备副本跟主副本强一致。复制表的异常处理逻辑还是看前面文章介绍。
上面表 BMSQL_ITEM
在业务上没有做水平拆分,即使做了也不能按仓库ID 拆分(没有这个字段)。所以只有对表 BMSQL_ITEM
做复制表技术。
ALTER TABLE bmsql_item duplicate_scope='cluster';
这个命令下去后,OB 会自动在后台异步补齐副本数。
如果是 2-2-2
的布局,查看表 BMSQL_ITEM
的副本分布可以看到一共有6个。我这里是三节点,所以看分布是看不出来区别。但是,此前的示例 SQL 的执行计划都会发生改变。
[root@observer00 ~]# obclient -h172.30.118.74 -utpcc@oboracle -P2881 -p123456 -c -A tpcc -e " > explain extended_Noaddr select /*+ test20210405 */ * from bmsql_item where i_id=100\G > " obclient: [Warning] Using a password on the command line interface can be insecure. *************************** 1. row *************************** Query Plan: ======================================== |ID|OPERATOR |NAME |EST. ROWS|COST| ---------------------------------------- |0 |TABLE GET|BMSQL_ITEM|1 |53 | ======================================== Outputs & filters: ------------------------------------- 0 - output([BMSQL_ITEM.I_ID], [BMSQL_ITEM.I_NAME], [BMSQL_ITEM.I_PRICE], [BMSQL_ITEM.I_DATA], [BMSQL_ITEM.I_IM_ID]), filter(nil), access([BMSQL_ITEM.I_ID], [BMSQL_ITEM.I_NAME], [BMSQL_ITEM.I_PRICE], [BMSQL_ITEM.I_DATA], [BMSQL_ITEM.I_IM_ID]), partitions(p0), is_index_back=false, range_key([BMSQL_ITEM.I_ID]), range[100 ; 100], range_cond([BMSQL_ITEM.I_ID = 100])
从上面可以看出,即使访问入口不是表 BMSQL_ITEM
的主副本所在节点,SQL 的执行计划也是本地执行计划。
EXPLAIN extended_Noaddr SELECT /*+ test2021040502 no_use_px */ o.O_W_ID , o.O_D_ID , o.O_OL_CNT , ol.OL_NUMBER ,ol.OL_QUANTITY , ol.OL_AMOUNT , i.I_NAME , i.I_PRICE FROM BMSQL_CUSTOMER c JOIN BMSQL_OORDER o ON (c.c_id = o.o_c_id AND c.c_w_id=o.o_w_id AND c.c_d_id=o.o_d_id) JOIN BMSQL_ORDER_LINE ol ON (o.O_ID = ol.OL_O_ID AND o.O_W_ID=ol.OL_W_ID AND o.O_D_ID=ol.OL_D_ID) JOIN BMSQL_ITEM i ON (ol.OL_I_ID = i.I_ID) WHERE c.c_last='BARBARESE' AND c.c_w_id=1 AND c.c_d_id=1 ORDER BY o.O_W_ID , o.O_D_ID , o.O_OL_CNT , ol.OL_NUMBER ; ============================================================== |ID|OPERATOR |NAME |EST. ROWS|COST| -------------------------------------------------------------- |0 |SORT | |16 |996 | |1 | NESTED-LOOP JOIN | |16 |753 | |2 | NESTED-LOOP JOIN | |16 |197 | |3 | NESTED-LOOP JOIN| |2 |126 | |4 | TABLE SCAN |C(BMSQL_CUSTOMER_IDX1)|1 |36 | |5 | TABLE SCAN |O(BMSQL_OORDER_IDX1) |2 |89 | |6 | TABLE SCAN |OL |10 |39 | |7 | TABLE GET |I |1 |36 | ============================================================== Outputs & filters: ------------------------------------- 0 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT], [I.I_NAME], [I.I_PRICE]), filter(nil), sort_keys([O.O_OL_CNT, ASC], [OL.OL_NUMBER, ASC]) 1 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT], [I.I_NAME], [I.I_PRICE]), filter(nil), conds(nil), nl_params_([OL.OL_I_ID]) 2 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT], [OL.OL_I_ID]), filter(nil), conds(nil), nl_params_([O.O_ID]) 3 - output([O.O_W_ID], [O.O_D_ID], [O.O_OL_CNT], [O.O_ID]), filter(nil), conds(nil), nl_params_([C.C_ID]) 4 - output([C.C_ID]), filter(nil), access([C.C_ID]), partitions(p0) 5 - output([O.O_W_ID], [O.O_D_ID], [O.O_ID], [O.O_OL_CNT]), filter(nil), access([O.O_W_ID], [O.O_D_ID], [O.O_ID], [O.O_OL_CNT]), partitions(p0) 6 - output([OL.OL_I_ID], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT]), filter(nil), access([OL.OL_I_ID], [OL.OL_NUMBER], [OL.OL_QUANTITY], [OL.OL_AMOUNT]), partitions(p0) 7 - output([I.I_NAME], [I.I_PRICE]), filter(nil), access([I.I_NAME], [I.I_PRICE]), partitions(p0)
从上面结果可以看出,这个跨节点的多表连接执行计划里也变成都是本地执行计划。
分布式数据库中间件的解决方案
别看 OB 的解决方案简单,实际上却只有原生的分布式数据库才可以实现复制表的强同步和高可用。
跨节点的表连接并不是 OB 特有的,而是所有分布式数据库都会面临的挑战。为了提升性能,分布式数据库中间件使用 广播表
技术来实现将一个业务写不是那么频繁的表逻辑复制到所有后端 MySQL 节点。这种复制技术通常是独立于 MySQL的逻辑复制。除非是使用多写的方案并使用分布式事务保证强一致,否则在异常的时候,很难保证广播表在各个 MySQL 里数据一致性。
分布式数据库中间件的分库分表方案,当多个逻辑表拆分维度一致的时候,或者分库策略一致的时候,其数据相同分片会自然在一个后端 MySQL 节点上,所以不需要 表分组
技术。不过缺点是数据位置比较固定,不能轻易变动,除非做逻辑迁移。
一致性问题
在 OB 1.x 版本里,只要 SQL的执行计划是分布式执行计划(plan_type=3
),OB 就会直接报错:strong consistent read across distributed nodes not supported
。这个是 OB 为了保证 SQL 语句级别的一致性,具体是指一个 SQL 运行时,其访问的表的数据必须都是同一个事务版本(简单理解为 ORACLE 的 SCN
)。
语句级别的一致性 对应的事务隔离级别 READ-COMMITTED
。是数据库(包括分布式数据库)最基本的要求。而要保证一条语句访问的数据在跨节点时还要统一版本,就依赖全局时间服务(GTS
)。OB 1.x 不支持全局时间服务,所以无法满足这个条件。直到 OB 2.x 开始才支持全局时间服务。
为了保证语句正确性,OB 1.x 选择了让 SQL 报错。而早期分布式数据库中间件在面临跨节点的表连接时,直接简单的从后端多个 MySQL 节点分别取数据然后在中间件计算节点内部汇总。这种做法并不能严格保证取出来的数据是同一个事务版本。
个别分布式数据库中间件产品实现了 全局事务管理模块(GTM
),在中间件层面拦截所有业务事务,并自行管理事务版本和对应的 UNDO
数据。这个算是间接解决了这个问题。不过它的架构方案也决定了使用有不少限制。比如说应用绕过 GTM
抽取数据的时候就要自行承担非强一致性的风险。
在 OB 1.x 版本里,SQL 远程执行计划(plan_type
=2) 是支持的,因为默认是语句级别的一致性,语句不跨节点就行。但是,如果要求是事务级别的一致性,那么依然会报错。所以 OB 1.x 版本不支持 SERIALIZABLE
事务隔离级别。OB 2.x 支持 SERIALIZABLE
事务隔离级别。
总结
分布式数据库里跨节点的 SQL 请求是常态,在 OB 里表现为远程执行计划和分布式执行计划。传统应用上分布式数据库会,会不可避免的碰到这个。
应对方案,理想情况是应用能做一些局部 SQL 调整,其次就是在 OB 内部使用表分组和复制表技术。