OceanBase 局部索引和全局索引
2026-08-30 09:59 圣彼得堡的村长 阅读(3) 评论(0) 收藏 举报OceanBase 局部索引和全局索引
1. 局部索引
1.1 核心概念
在 OceanBase 数据库中,局部索引(Local Index) 是默认且最常用的索引类型。它的核心特征是索引的分区规则与主表完全保持一致。
局部索引必须和主表保持相同的分区关系,索引的每个分区与主表对应分区绑定。
也就是:主表某个分区的数据,对应的局部索引数据也在相应的索引分区中。对普通局部索引而言,索引列不要求包含主表分区键。
- 同置存储(Co-location): 局部索引的分片(
Tablet)与主表的分片是一对一绑定的。也就是说,主表的第 N 个分区和该局部索引的第 N 个分区,其数据必然存储在同一台OBServer节点上。 - 本地维护: 当对主表的某个分区进行
INSERT、UPDATE或DELETE时,对应的局部索引变更仅在该节点本地完成,不需要发起跨节点的分布式事务(无需 2PC 两阶段提交)。
1.2 主要优势与局限性
优势:
- 极高的写入性能:索引数据的变更与主表数据在同节点、同事务中处理,吞吐量高,延迟极低。
- 极低的维护成本:如果对主表执行按分区的 DDL 操作(如
TRUNCATE PARTITION或DROP 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;
- 创建局部索引(按 age 索引,索引分区与主表完全同构绑定)
-- 索引只包含 age , 没有 user_id, 仍然可以是局部索引。
CREATE INDEX idx_age_local ON user_table(age) LOCAL;
- 创建唯一局部索引
UNIQUE LOCAL INDEX,为了保证全表范围的唯一性,唯一局部索引通常需要包含表的分区键。
CREATE UNIQUE INDEX IDX_user_id_age ON user_table(user_id, age) LOCAL;
- 执行计划对比分析
(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 = 1001,OceanBase计算出数据必然存放在 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_id和age,索引本身就包含了这两个字段,因此无需回表查主表,性能达到极致。
局部索引的使用可以总结成一句话: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 回表查询主表,带来额外的网络开销。
- 分布式事务开销(2PC:由于全局索引分片与主表分片大概率不在同一台 OBServer 上,对主表进行
2.3 语法与使用示例
- 创建全局索引非分区索引(适合小数据量索引)
-- 在 user_name 列上创建全局非分区索引
CREATE INDEX idx_username_global ON user_table(user_name) GLOBAL;
- 创建全局分区索引(按 phone_number 索引,单独划分为 8 个 Hash 分区)
CREATE INDEX idx_phone_part ON user_table(phone_number) GLOBAL
PARTITION BY KEY(phone_number) PARTITIONS 8;
- 执行计划对比分析
(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_number和age)。
- 确认使用了全局索引,且触发了回表(因为
partitions(p0)- 由于
idx_username_global是全局非分区索引,它只有一个默认分片p0,所以精准扫描索引的p0分区。
- 由于
range_key与range_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]扫描。
- DISTRIBUTED(分布式):说明执行节点与目标索引/数据所在的节点不同,需要发起分布式
-
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_name和age),所以拿着查到的主键user_id跨节点回主表拉取全量数据。 range_key与range_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 选型与调优建议
- 优先考虑局部索引:如果高频查询的条件中可以带上主表分区键,优先使用局部索引(
Local Index),以获得最佳的写入性能和最小的维护开销。 - 高频非分区键点查使用全局索引:对于典型的“核心主键 vs 辅助业务键”场景(例如:按订单 ID 分区的表,但存在极高频的按手机号/用户 ID 点查的需求),必须建立全局索引。
- 尽量使用覆盖索引(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 分布式数据库。
浙公网安备 33010602011771号