代码改变世界

OceanBase 局部索引和全局索引

2026-08-30 09:59  圣彼得堡的村长  阅读(3)  评论(0)    收藏  举报

OceanBase 局部索引和全局索引

1. 局部索引

1.1 核心概念

在 OceanBase 数据库中,局部索引(Local Index) 是默认且最常用的索引类型。它的核心特征是​索引的分区规则与主表完全保持一致​。

局部索引必须和主表保持相同的分区关系,索引的每个分区与主表对应分区绑定。

也就是:主表某个分区的数据,对应的局部索引数据也在相应的索引分区中。​对普通局部索引而言,索引列不要求包含主表分区键​。

  • 同置存储(Co-location)​: 局部索引的分片(Tablet)与主表的分片是一对一绑定的。也就是说,主表的第 N 个分区和该局部索引的第 N 个分区,其数据必然存储在同一台 OBServer 节点上。
  • 本地维护​: 当对主表的某个分区进行 INSERTUPDATEDELETE 时,对应的局部索引变更仅在该节点本地完成,​不需要发起跨节点的分布式事务(无需 2PC 两阶段提交)​。

1.2 主要优势与局限性

优势:

  • 极高的写入性能​:索引数据的变更与主表数据在同节点、同事务中处理,吞吐量高,延迟极低。
  • 极低的维护成本​:如果对主表执行按分区的 DDL 操作(如 TRUNCATE PARTITIONDROP PARTITION),对应的局部索引分区会被自动快速清理,无需重构整个索引。
  • 精准点查高效​:只要查询条件中包含了主表的分区键,就能触发​分区裁剪​,只在单个分区的本地进行索引扫描和回表。

局限性:

  • 跨分区查询存在广播(Fan-out)​:如果查询条件​没有包含主表的分区键​(例如只按局部索引列查询),优化器无法判断数据在哪个分区,必须向主表的所有分区并行发送查询请求,汇总结果后再返回,在大表场景下 RPC 开销较大。

1.3 语法示例

在创建索引时添加 LOCAL 关键字(在分区表上如果不显式指定,默认即为 LOCAL):

-- 建表并插入数据
CREATE TABLE user_table ( 
user_id INT NOT NULL, 
phone_number VARCHAR(20) NOT NULL, 
user_name VARCHAR(50), 
age INT, 
PRIMARY KEY (user_id) 
) 
PARTITION BY HASH(user_id) PARTITIONS 4;

-- 插入测试数据
INSERT INTO user_table (user_id, phone_number, user_name, age)
SELECT 
    idx AS user_id,
    CONCAT('138', LPAD(idx, 8, '0')) AS phone_number,
    CONCAT('User_', idx) AS user_name,
    (20 + (idx % 30)) AS age
FROM (
    SELECT @row := @row + 1 AS idx
    FROM 
        (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5) t1,
        (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5) t2,
        (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5) t3,
        (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5) t4,
        (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5) t5,
        (SELECT @row := 1000) r  -- 设置起始 ID 为 1000(避免与之前的测试数据冲突)
    LIMIT 10000
) sub;
  1. 创建局部索引(按 age 索引,索引分区与主表完全同构绑定)
-- 索引只包含 age , 没有 user_id, 仍然可以是局部索引。
CREATE INDEX idx_age_local ON user_table(age) LOCAL;
  1. 创建​唯一局部索引 UNIQUE LOCAL INDEX​,为了保证全表范围的唯一性,​唯一局部索引通常需要包含表的分区键​。
CREATE UNIQUE INDEX IDX_user_id_age ON user_table(user_id, age) LOCAL;
  1. 执行计划对比分析

(1)​局部索引查询 — 带主表分区键​(极速点查)

当查询条件同时包含索引列和主表分区键 user_id 时,优化器会直接进行​分区裁剪(Partition Pruning​,仅精准访问 1 个 Tablet:

EXPLAIN SELECT * FROM user_table WHERE age = 25 AND user_id = 1001;
+---------------------------------------------------------------------------------------------------------------------------------------------------+
| Query Plan                                                                                                                                        |
+---------------------------------------------------------------------------------------------------------------------------------------------------+
| ===============================================                                                                                                   |
| |ID|OPERATOR |NAME      |EST.ROWS|EST.TIME(us)|                                                                                                   |
| -----------------------------------------------                                                                                                   |
| |0 |TABLE GET|user_table|1       |7           |                                                                                                   |
| ===============================================                                                                                                   |
| Outputs & filters:                                                                                                                                |
| -------------------------------------                                                                                                             |
|   0 - output([user_table.user_id], [user_table.phone_number], [user_table.user_name], [user_table.age]), filter([user_table.age = 25]), rowset=16 |
|       access([user_table.user_id], [user_table.age], [user_table.phone_number], [user_table.user_name]), partitions(p1)                           |
|       is_index_back=false, is_global_index=false, filter_before_indexback[false],                                                                 |
|       range_key([user_table.user_id]), range[1001 ; 1001],                                                                                        |
|       range_cond([user_table.user_id = 1001])                                                                                                     |
+---------------------------------------------------------------------------------------------------------------------------------------------------+
  • partitions(p1) —— 命中分区裁剪​,由于查询条件带上了分区键 user_id = 1001OceanBase 计算出数据必然存放在 ​p1 分区​,因此直接裁剪掉其他 3 个分区,避免了全表/全分区广播。
  • range[1001 ; 1001] & range_cond(...) —— 定位主键​,明确告知存储引擎直接去主表定位 user_id = 1001 这条记录。
  • is_index_back=false —— 无回表开销 ,直接读取的主表,拿到了整行所有列,​不需要回表​。
  • filter([user_table.age = 25]) —— 二级过滤 定位到 user_id = 1001 的记录后,内存中检查其 age 是否等于 25。若匹配则输出,不匹配则抛弃。
  • rowset=16 —— 向量化引擎支持​,表明开启了 OceanBase 的向量化执行引擎(以 Rowset 为单位批处理数据),提升 CPU 缓存利用率。

(2)​局部索引查询 — 不带主表分区键​(广播全分区扫描)

如果仅按 age 查询,由于局部索引打散在各个分区中,数据库不知道目标数据存放在哪个 Tablet

EXPLAIN SELECT * FROM user_table WHERE age = 25;
+----------------------------------------------------------------------------------------------------------------------------------------------------+
| Query Plan                                                                                                                                         |
+----------------------------------------------------------------------------------------------------------------------------------------------------+
| ===============================================================                                                                                    |
| |ID|OPERATOR                 |NAME      |EST.ROWS|EST.TIME(us)|                                                                                    |
| ---------------------------------------------------------------                                                                                    |
| |0 |PX COORDINATOR           |          |104     |410         |                                                                                    |
| |1 |└─EXCHANGE OUT DISTR     |:EX10000  |104     |328         |                                                                                    |
| |2 |  └─PX PARTITION ITERATOR|          |104     |141         |                                                                                    |
| |3 |    └─TABLE FULL SCAN    |user_table|104     |141         |                                                                                    |
| ===============================================================                                                                                    |
| Outputs & filters:                                                                                                                                 |
| -------------------------------------                                                                                                              |
|   0 - output([INTERNAL_FUNCTION(user_table.user_id, user_table.phone_number, user_table.user_name, user_table.age)]), filter(nil), rowset=256      |
|   1 - output([INTERNAL_FUNCTION(user_table.user_id, user_table.phone_number, user_table.user_name, user_table.age)]), filter(nil), rowset=256      |
|       dop=1                                                                                                                                        |
|   2 - output([user_table.user_id], [user_table.age], [user_table.phone_number], [user_table.user_name]), filter(nil), rowset=256                   |
|       force partition granule                                                                                                                      |
|   3 - output([user_table.user_id], [user_table.age], [user_table.phone_number], [user_table.user_name]), filter([user_table.age = 25]), rowset=256 |
|       access([user_table.user_id], [user_table.age], [user_table.phone_number], [user_table.user_name]), partitions(p[0-3])                        |
|       is_index_back=false, is_global_index=false, filter_before_indexback[false],                                                                  |
|       range_key([user_table.user_id]), range(MIN ; MAX)always true                                                                                 |
+----------------------------------------------------------------------------------------------------------------------------------------------------+

执行计划关键特征:

  • EXCHANGE IN/OUT / PX COORDINATOR​:生成并行/分布式扫描计划。
  • partitions(p0-3)​:扇出(Fan-out)到所有 4 个分区并行扫描局部索引,汇总结果后再返回。在大表场景下 RPC 开销显著增加。

(3)​查询条件命中复合索引​(等值查询)

由于索引包含了 user_id(主表分区键兼索引第一列)和 age,且这是一个 UNIQUE 局部索引:

EXPLAIN SELECT user_id, age FROM user_table WHERE user_id = 1001 AND age = 25;
+------------------------------------------------------------------------------------------------+
| Query Plan                                                                                     |
+------------------------------------------------------------------------------------------------+
| ===============================================                                                |
| |ID|OPERATOR |NAME      |EST.ROWS|EST.TIME(us)|                                                |
| -----------------------------------------------                                                |
| |0 |TABLE GET|user_table|1       |7           |                                                |
| ===============================================                                                |
| Outputs & filters:                                                                             |
| -------------------------------------                                                          |
|   0 - output([user_table.user_id], [user_table.age]), filter([user_table.age = 25]), rowset=16 |
|       access([user_table.user_id], [user_table.age]), partitions(p1)                           |
|       is_index_back=false, is_global_index=false, filter_before_indexback[false],              |
|       range_key([user_table.user_id]), range[1001 ; 1001],                                     |
|       range_cond([user_table.user_id = 1001])                                                  |
+------------------------------------------------------------------------------------------------+
  • OPERATOR: TABLE GET​:因为是唯一索引(UNIQUE),且 (user_id, age) 的条件全部精确提供,优化器知道最多只有 1 条记录,直接定位取数。
  • partitions(p1)​:带上了分区键 user_id = 1001,精准裁减到 p1 分区。
  • is_index_back=false​:​索引覆盖​!由于查询只要求返回 user_idage,索引本身就包含了这两个字段,因此无需回表查主表,性能达到极致。

局部索引的使用可以总结成一句话:Local Index 与主表分区一一对应,索引分区与对应的主表分区绑定。普通 Local Index 不要求索引键包含分区键;Unique Local Index 为保证全局唯一性,需要关注分区键包含在唯一键中。

2. 全局索引

全局索引(Global Index)是 OceanBase 数据库在分布式架构下,为解决非分区键查询性能问题而设计的关键机制。

2.1 工作原理

OceanBase 的分布式表结构中,如果一张表按 user_id 作了 Hash 分区,当根据 phone_number(非分区键)进行查询时,数据库由于不知道目标数据在哪个分区,必须向所有分区(OBServer 节点)发送查询请求(广播),这会导致极高的网络 RPC 延迟。

  • 解耦分布​:全局索引的分区规则与主表完全独立,其索引分片(Tablet)可以分布在集群内的任意 OBServer 上。
  • 独立索引表​:在底层实现上,全局索引本质上就是一张​特殊的主从协同表​。它的主键由 索引列 + 主表主键 + (主表分区键) 构成。
  • 全域路由​:通过将索引列作为全局索引自己的分区键,使得任何基于索引列的查询,都能通过路由直接定位到具体的索引分片,实现 ​1 次 RPC 快速点查​。

2.2. 优势与劣势

  • 优势
    • 打破分区限制​:支持高效的跨分区单点查询和范围查询,无需在 WHERE 条件中强行带上主表分区键。
    • 灵活的分区策略​:全局索引自身也可以进行分区(Global Partitioned Index),例如主表按 Range(gmt_create) 切分,全局索引可以按 Hash(phone_number) 切分。
  • 代价与劣势
    • 分布式事务开销(2PC​:由于全局索引分片与主表分片大概率不在同一台 OBServer 上,对主表进行 INSERT/UPDATE/DELETE 时,系统必须使用两阶段提交协议(2PC)来保证主表数据与全局索引数据的强一致性,这对写入吞吐量有一定损耗。
    • 回表成本(Cross-Node Lookup​:如果查询的字段没有被全局索引完全覆盖(未命中覆盖索引),系统需要先查全局索引拿到主表主键,再跨节点 RPC 回表查询主表,带来额外的网络开销。

2.3 语法与使用示例

  1. 创建全局索引非分区索引(适合小数据量索引)
-- 在 user_name 列上创建全局非分区索引
CREATE INDEX idx_username_global ON user_table(user_name) GLOBAL;
  1. 创建全局分区索引(按 phone_number 索引,单独划分为 8 个 Hash 分区)
CREATE INDEX idx_phone_part ON user_table(phone_number) GLOBAL 
PARTITION BY KEY(phone_number) PARTITIONS 8;
  1. 执行计划对比分析

(1)全局非分区索引的单点查询

EXPLAIN SELECT * FROM user_table WHERE user_name = 'Alice';
+------------------------------------------------------------------------------------------------------------------------------
| Query Plan                                                                                                                   
+------------------------------------------------------------------------------------------------------------------------------
| =======================================================================================                                      
| |ID|OPERATOR                    |NAME                           |EST.ROWS|EST.TIME(us)|                                      
| ---------------------------------------------------------------------------------------                                      
| |0 |DISTRIBUTED TABLE RANGE SCAN|user_table(idx_username_global)|1       |6           |                                      
| =======================================================================================                                      
| Outputs & filters:                                                                                                           
| -------------------------------------                                                                                        
|   0 - output([user_table.user_id], [user_table.phone_number], [user_table.user_name], [user_table.age]), filter(nil), rowset=
|       access([user_table.user_id], [user_table.user_name], [user_table.phone_number], [user_table.age]), partitions(p0)      
|       is_index_back=true, is_global_index=true,                                                                              
|       range_key([user_table.user_name], [user_table.user_id]), range(Alice,MIN ; Alice,MAX),                                 
|       range_cond([user_table.user_name = 'Alice'])                                                                           
+-----------------------------------------------------------------------------------------------------------------------------

执行计划解读:

  • OPERATOR: DISTRIBUTED TABLE RANGE SCAN
    • DISTRIBUTED(分布式)​:说明该全局索引分片(或回表的目标主表分片)不在当前的客户端直连节点,或者涉及到跨节点的 RPC 数据请求。
    • TABLE RANGE SCAN(范围扫描)​:虽然查询条件是 user_name = 'Alice',但由于索引键是复合结构 (user_name, user_id),优化器构建了一个物理闭区间范围 [Alice, MIN ; Alice, MAX],在该区间内进行范围扫描。
  • NAME: user_table(idx_username_global)
    • 明确标识本次扫描驱动的起点是全局索引 idx_username_global
    • is_global_index=true & is_index_back=true
      • 确认使用了全局索引,且​触发了回表​(因为 SELECT * 需要获取索引中未包含的列,如 phone_numberage)。
  • partitions(p0)
    • 由于 idx_username_global 是全局非分区索引,它只有一个默认分片 p0,所以精准扫描索引的 p0 分区。
  • range_keyrange_cond
    • range_key​:全局索引在底层存储的实际 Physical Key,由 (user_name, user_id) 组成。
    • range_cond([user_table.user_name = 'Alice'])​:定位条件,只扫描 user_name 等于 'Alice' 的索引项。

(2)全局分区索引查询

按非分区键 phone_number 进行查询,虽然没有提供主表分区键 user_id,但由于全局索引按 phone_number 独立分区,路由可以直接命中对应的全局索引 Tablet

EXPLAIN SELECT * FROM user_table WHERE phone_number = '13800000003';
| Query Plan                                                                                                                   
+------------------------------------------------------------------------------------------------------------------------------
| ==================================================================================                                           
| |ID|OPERATOR                    |NAME                      |EST.ROWS|EST.TIME(us)|                                           
| ----------------------------------------------------------------------------------                                           
| |0 |DISTRIBUTED TABLE RANGE SCAN|user_table(idx_phone_part)|1       |6           |                                           
| ==================================================================================                                           
| Outputs & filters:                                                                                                           
| -------------------------------------                                                                                        
|   0 - output([user_table.user_id], [user_table.phone_number], [user_table.user_name], [user_table.age]), filter(nil), rowset=
|       access([user_table.user_id], [user_table.phone_number], [user_table.user_name], [user_table.age]), partitions(p1)      
|       is_index_back=true, is_global_index=true,                                                                              
|       range_key([user_table.phone_number], [user_table.user_id]), range(13800000003,MIN ; 13800000003,MAX),                  
|       range_cond([user_table.phone_number = '13800000003'])                                                                  
+------------------------------------------------------------------------------------------------------------------------------

执行计划解读:

  • OPERATOR: DISTRIBUTED TABLE RANGE SCAN
    • DISTRIBUTED(分布式):说明执行节点与目标索引/数据所在的节点不同,需要发起分布式 RPC 调度。
    • TABLE RANGE SCAN​:在全局索引树上,按 phone_number = '13800000003' 进行范围构建 [13800000003, MIN ; 13800000003, MAX] 扫描。
  • NAME: user_table(idx_phone_part),优化器成功选中了刚才创建的全局分区索引 idx_phone_part
  • EST.ROWS: 1 & EST.TIME(us): 6 ,预估只需返回 1 行 数据,预估开销仅 ​6 微秒​(之前全表扫描是 155 微秒),性能大幅提升。
  • partitions(p1) —— 全局索引分区裁剪​,优化器计算 phone_number = '13800000003' 的分区路由,精准裁剪并只扫描全局索引 8 个分区中的 ​p1 分区**​。
  • is_global_index=true —— 命中全局索引**​,确认使用了全局索引。
  • is_index_back=true —— 触发回表**​,因为使用了 SELECT *,需要获取索引未存储的字段(user_nameage),所以拿着查到的主键 user_id 跨节点回主表拉取全量数据。
  • range_keyrange_cond
    • range_key**​:索引表底层物理存储主键为 (phone_number, user_id)
    • range_cond**​:检索下推条件为 phone_number = '13800000003'

为了避免分布式回表带来的性能损耗,可以通过 STORING 子句将常用查询列直接存储在全局索引中:

-- 将 user_name 存储在索引中,查询 phone_number 和 user_name 时无需回表
CREATE INDEX idx_phone_name ON user_table(phone_number) STORING(user_name) GLOBAL;

EXPLAIN SELECT user_id, phone_number, user_name FROM user_table WHERE phone_number = '13800000003';
+---------------------------------------------------------------------------------------------------------------+
| Query Plan                                                                                                    |
+---------------------------------------------------------------------------------------------------------------+
| ======================================================================                                        |
| |ID|OPERATOR        |NAME                      |EST.ROWS|EST.TIME(us)|                                        |
| ----------------------------------------------------------------------                                        |
| |0 |TABLE RANGE SCAN|user_table(idx_phone_name)|1       |3           |                                        |
| ======================================================================                                        |
| Outputs & filters:                                                                                            |
| -------------------------------------                                                                         |
|   0 - output([user_table.user_id], [user_table.phone_number], [user_table.user_name]), filter(nil), rowset=16 |
|       access([user_table.user_id], [user_table.phone_number], [user_table.user_name]), partitions(p0)         |
|       is_index_back=false, is_global_index=true,                                                              |
|       range_key([user_table.phone_number], [user_table.user_id]), range(13800000003,MIN ; 13800000003,MAX),   |
|       range_cond([user_table.phone_number = '13800000003'])                                                   |
+---------------------------------------------------------------------------------------------------------------+

适用​:需要包含的辅助字段​数量少、长度固定或较短​(如状态码、名称、时间戳),且对应的查询属于高频热点 Query。

2.4 选型与调优建议

  1. 优先考虑局部索引​:如果高频查询的条件中可以带上主表分区键,优先使用局部索引(Local Index),以获得最佳的写入性能和最小的维护开销。
  2. 高频非分区键点查使用全局索引​:对于典型的“核心主键 vs 辅助业务键”场景(例如:按订单 ID 分区的表,但存在极高频的按手机号/用户 ID 点查的需求),必须建立全局索引。
  3. 尽量使用覆盖索引(Covering Index)​:将查询需要的字段包含进全局索引,将“索引查找 + 分布式回表”转化为“纯索引扫描”,大幅降低延迟。

3. 局部索引 VS 全局索引

局部索引(Local Index)全局索引(Global Index)是两种核心的索引组织形式。它们最本质的区别在于:​索引的分区规则是否与主表严格绑定​。

对比维度 局部索引 (Local Index) 全局索引 (Global Index)
物理分布 与主表对应分区​物理同置​(同节点、同生命周期) 独立划分为 Tablet,物理分布在集群任意 OBServer
分区规则 必须与主表完全一致 可以与主表完全解耦(可不分区,也可按不同列与主表不同分区)
唯一约束限制 必须包含主表的分区键​(如 PRIMARY KEY / UNIQUE 可以是任何列,无需包含主表的分区键
带分区键查询 1 次 RPC 精准点查​(触发主表分区裁剪) 1 次 RPC 精准点查
不带分区键查询 全分区广播扫描(Fan-out)​,延迟随分区数增加而上升 1 次 RPC 精准点查
写入与 DML 开销 单阶段提交​,无跨节点开销,写入吞吐高 两阶段提交(2PC)​,主表与索引不在同节点时有网络延迟开销
回表开销 本地回表​,在同节点完成 分布式回表​,需跨节点 RPC 查主表(除非命中覆盖索引)
DDL 运维 删除/清空主表单个分区,索引分区自动同步释放 删除/清空主表单个分区,需要重构或异步清理全局索引

生产环境中,能通过合理分区键 + Local Index 解决的,优先 Local Index;只有确实存在大量 不带分区键的跨分区查询 时,再考虑 Global Index

OceanBase 的 分区索引和全局索引先写到这里,如果本文对你有所帮助,欢迎点赞、推荐和转发,也欢迎关注后续文章,一起学习 OceanBase 分布式数据库。