文章目录
- 本文内容一览(快速理解)
- 一、什么是 TRUNCATE(Truncate Table):理解清空表数据的本质
- 二、TRUNCATE 的执行流程(Execution Flow):从 SQL 到数据清空的 7 个阶段
- 2.1 完整执行链路(Complete Execution Chain):7 个阶段的旅程
- 2.2 阶段1-3:SQL 解析到权限检查(SQL Parsing to Authorization):快速验证阶段
- 2.4 阶段4:执行入口(Execution Entry):路由到元数据操作
- 2.3 阶段5:元数据操作(Metadata Operations):核心执行阶段
- 2.3.1 子阶段1:读锁检查阶段(Read Lock Check Phase):快速验证
- 2.3.2 子阶段2:创建新分区阶段(Create New Partitions Phase):无锁操作
- 2.3.3 子阶段3:写锁替换阶段(Write Lock Replace Phase):关键阻塞点
- 替换分区(Replace Partitions)
- EditLog 写入(EditLog Write):关键阻塞点
- 2.5 阶段6-7:持久化和同步(Persistence and Synchronization):确保数据一致性
- 三、锁机制深度解析(Lock Mechanism):为什么会被阻塞
- 3.1 数据库锁类型(Database Lock Types):读锁和写锁的区别
- 3.2 TRUNCATE 锁持有时间线(Lock Holding Timeline):理解阻塞过程
- 3.3 锁竞争场景(Lock Contention Scenarios):实际影响分析
- 四、性能瓶颈分析(Performance Bottleneck Analysis):找出问题根源
- 4.1 各阶段耗时统计(Stage Time Statistics):找出慢的地方
- 4.2 性能瓶颈分析(Performance Bottleneck Analysis):三大瓶颈
- 五、优化建议(Optimization Recommendations):如何避免问题
- 5.1 短期优化(Short-term Optimization):不修改核心逻辑
- 5.2 长期优化(Long-term Optimization):需要代码修改
- 📝 本章总结
📌适合对象:StarRocks 开发者、运维人员、对数据库内部机制感兴趣的初学者
⏱️预计阅读时间:40-50分钟
🎯学习目标:理解 TRUNCATE 语句在 StarRocks 中的完整执行流程,掌握锁机制和性能瓶颈
本文内容一览(快速理解)
- TRUNCATE 的本质:清空表数据,通过创建新分区替换旧分区实现
- 执行流程:从 SQL 解析到数据清空,经历 7 个关键阶段
- 锁机制:使用数据库级别的写锁,在等待持久化时阻塞其他操作
- 性能瓶颈:EditLog 写入需要 1-5 秒,期间一直持有写锁
- 优化方向:降低频率、使用分区表、异步写入等
一、什么是 TRUNCATE(Truncate Table):理解清空表数据的本质
这一章要建立的基础:理解 TRUNCATE 语句的作用和实现原理
核心问题:当我们执行TRUNCATE TABLE db.tbl时,StarRocks 内部到底发生了什么?
[!NOTE]
📝 关键点总结:TRUNCATE 不是删除数据,而是用新的空分区替换旧分区,这样速度更快
概念的本质:
TRUNCATE 是数据库提供的一种快速清空表数据的方法。与 DELETE 不同,TRUNCATE 不是逐行删除数据,而是通过替换分区的方式实现清空。
图解说明:
💡说明:TRUNCATE 的优势是速度快,因为它不需要逐行删除数据,而是直接替换整个分区
实际例子:
-- 清空整个表TRUNCATETABLEmy_db.user_table;-- 只清空指定分区TRUNCATETABLEmy_db.user_tablePARTITION(p20251210);二、TRUNCATE 的执行流程(Execution Flow):从 SQL 到数据清空的 7 个阶段
核心问题:一条 TRUNCATE SQL 语句是如何一步步执行完成的?
[!NOTE]
📝 关键点总结:TRUNCATE 执行分为 7 个阶段,其中第 5 阶段的写锁替换是最关键的阻塞点
2.1 完整执行链路(Complete Execution Chain):7 个阶段的旅程
流程概览:
各阶段耗时统计:
| 阶段 | 操作 | 锁类型 | 耗时 | 是否阻塞 |
|---|---|---|---|---|
| 1. SQL 解析 | 语法解析 | 无 | < 1ms | 否 |
| 2. 语义分析 | 表名规范化 | 无 | < 1ms | 否 |
| 3. 权限检查 | 权限验证 | 无 | < 1ms | 否 |
| 4. 读锁检查 | 表信息检查 | 读锁 | 1-10ms | 否 |
| 5. 创建分区 | 创建新分区 | 无锁 | 100-500ms | 否 |
| 6. 写锁替换 | 替换分区 | 写锁 | 1-5秒 | 是 |
| 7. EditLog 写入 | BDBJE 持久化 | 写锁持有 | 1-5秒 | 是 |
💡说明:阶段 6 和 7 是性能瓶颈,因为需要等待 BDBJE 写入完成,期间一直持有写锁
实际例子:
假设执行TRUNCATE TABLE my_db.orders PARTITION(p20251210):
时间轴: T0 (0ms): 开始执行 TRUNCATE T1 (1ms): SQL 解析完成 T2 (2ms): 语义分析完成 T3 (3ms): 权限检查通过 T4 (10ms): 读锁检查完成,确认分区存在 T5 (300ms): 创建新分区完成(无锁,不阻塞) T6 (310ms): 获取写锁,开始替换分区 T7 (350ms): 分区替换完成 T8 (350ms): 开始写入 EditLog T9 (3350ms):EditLog 写入完成(等待了3秒!) T10 (3400ms):释放写锁 T11 (3400ms):完成可以看到,在 T8 到 T9 这 3 秒期间,写锁一直被持有,其他操作都被阻塞。
2.2 阶段1-3:SQL 解析到权限检查(SQL Parsing to Authorization):快速验证阶段
阶段1:SQL 解析(SQL Parsing)
文件位置:fe/fe-core/src/main/java/com/starrocks/sql/parser/AstBuilder.java
关键源码:
@OverridepublicParseNodevisitTruncateTableStatement(StarRocksParser.TruncateTableStatementContextcontext){QualifiedNamequalifiedName=getQualifiedName(context.qualifiedName());TableNametargetTableName=qualifiedNameToTableName(qualifiedName);Tokenstart=context.start;Tokenstop=context.stop;PartitionNamespartitionNames=null;if(context.partitionNames()!=null){stop=context.partitionNames().stop;partitionNames=(PartitionNames)visit(context.partitionNames());}NodePositionpos=createPos(start,stop);returnnewTruncateTableStmt(newTableRef(targetTableName,null,partitionNames,pos));}AST 节点结构:
// 文件位置:fe/fe-core/src/main/java/com/starrocks/sql/ast/TruncateTableStmt.javapublicclassTruncateTableStmtextendsDdlStmt{privatefinalTableReftblRef;// 包含表名和分区信息publicTableRefgetTblRef(){returntblRef;}publicStringgetDbName(){returntblRef.getName().getDb();}publicStringgetTblName(){returntblRef.getName().getTbl();}}功能说明:
- 解析 SQL 语法树,提取表名和分区信息
- 创建
TruncateTableStmtAST 节点 - 支持两种格式:
TRUNCATE TABLE db.tbl(清空整个表)TRUNCATE TABLE db.tbl PARTITION(p1, p2)(清空指定分区)
阶段2:语义分析(Semantic Analysis)
文件位置:fe/fe-core/src/main/java/com/starrocks/sql/analyzer/TruncateTableAnalyzer.java
关键源码:
publicstaticvoidanalyze(TruncateTableStmtstatement,ConnectContextcontext){// 1. 规范化表名(处理大小写、默认数据库等)MetaUtils.normalizationTableName(context,statement.getTblRef().getName());// 2. 检查是否使用别名(不支持)if(statement.getTblRef().hasExplicitAlias()){thrownewSemanticException("Not support truncate table with alias");}// 3. 检查分区信息PartitionNamespartitionNames=statement.getTblRef().getPartitionNames();if(partitionNames!=null){// 不支持清空临时分区if(partitionNames.isTemp()){thrownewSemanticException("Not support truncate temp partitions");}// 检查分区名是否为空if(partitionNames.getPartitionNames().stream().anyMatch(entity->Strings.isNullOrEmpty(entity))){thrownewSemanticException("there are empty partition name");}}}调用路径:
// 文件位置:fe/fe-core/src/main/java/com/starrocks/sql/analyzer/AnalyzerVisitor.java@OverridepublicVoidvisitTruncateTableStatement(TruncateTableStmtstatement,ConnectContextcontext){TruncateTableAnalyzer.analyze(statement,context);returnnull;}功能说明:
- 规范化表名(处理大小写、默认数据库)
- 验证语法约束(不支持别名、不支持临时分区)
- 验证分区名有效性
阶段3:权限检查(Authorization)
文件位置:fe/fe-core/src/main/java/com/starrocks/sql/analyzer/AuthorizerStmtVisitor.java
关键源码:
@OverridepublicVoidvisitTruncateTableStatement(TruncateTableStmtstatement,ConnectContextcontext){// 检查用户是否有 TRUNCATE 权限Authorizer.checkTableAction(context.getCurrentUserIdentity(),context.getCurrentRoleIds(),statement.getDbName(),statement.getTblName(),PrivilegeType.DELETE);returnnull;}功能说明:
- 验证用户是否有表的 DELETE 权限(TRUNCATE 使用 DELETE 权限)
- 如果权限不足,抛出
AccessDeniedException
2.4 阶段4:执行入口(Execution Entry):路由到元数据操作
文件位置:fe/fe-core/src/main/java/com/starrocks/qe/DDLStmtExecutor.java
关键源码:
@OverridepublicShowResultSetvisitTruncateTableStatement(TruncateTableStmtstmt,ConnectContextcontext){ErrorReport.wrapWithRuntimeException(()->{context.getGlobalStateMgr().truncateTable(stmt);});returnnull;}调用链:
// 文件位置:fe/fe-core/src/main/java/com/starrocks/server/GlobalStateMgr.javapublicvoidtruncateTable(TruncateTableStmttruncateTableStmt)throwsDdlException{localMetastore.truncateTable(truncateTableStmt);}功能说明:
- 将执行委托给
GlobalStateMgr,再转发到LocalMetastore - 使用
ErrorReport.wrapWithRuntimeException包装异常
2.3 阶段5:元数据操作(Metadata Operations):核心执行阶段
核心问题:如何在不影响数据一致性的前提下,快速清空表数据?
2.3.1 子阶段1:读锁检查阶段(Read Lock Check Phase):快速验证
操作流程:
关键操作:
- 获取读锁:
db.readLock()- 数据库级别的读锁(共享锁) - 验证表状态:检查表是否存在、类型是否支持、状态是否正常
- 收集分区信息:根据是否指定分区,收集需要清空的分区列表
- 创建影子副本:创建表的副本,用于后续创建新分区
- 释放读锁:
db.readUnlock()
锁持有时间:通常 1-10ms,不会阻塞其他读操作
关键源码:
文件位置:fe/fe-core/src/main/java/com/starrocks/server/LocalMetastore.java
代码位置:LocalMetastore.java:4495-4531
// 1. 获取数据库读锁(检查阶段)db.readLock();try{Tabletable=db.getTable(dbTbl.getTbl());if(table==null){ErrorReport.reportDdlException(ErrorCode.ERR_BAD_TABLE_ERROR,dbTbl.getTbl());}// 只支持 OLAP 表或 LAKE 表if(!table.isOlapOrCloudNativeTable()){thrownewDdlException("Only support truncate OLAP table or LAKE table");}OlapTableolapTable=(OlapTable)table;if(olapTable.getState()!=OlapTable.OlapTableState.NORMAL){throwInvalidOlapTableStateException.of(olapTable.getState(),olapTable.getName());}// 收集需要清空的分区信息if(!truncateEntireTable){// 清空指定分区for(StringpartName:tblRef.getPartitionNames().getPartitionNames()){Partitionpartition=olapTable.getPartition(partName);if(partition==null){thrownewDdlException("Partition "+partName+" does not exist");}origPartitions.put(partName,partition);GlobalStateMgr.getCurrentState().getAnalyzeMgr().recordDropPartition(partition.getId());}}else{// 清空整个表的所有分区for(Partitionpartition:olapTable.getPartitions()){origPartitions.put(partition.getName(),partition);GlobalStateMgr.getCurrentState().getAnalyzeMgr().recordDropPartition(partition.getId());}}// 创建表的影子副本(用于后续创建新分区)copiedTbl=getShadowCopyTable(olapTable);}finally{db.readUnlock();// 释放读锁}实际例子:
这段代码展示了读锁检查阶段的完整流程,包括表存在性检查、类型验证、状态检查、分区信息收集和影子副本创建。
2.3.2 子阶段2:创建新分区阶段(Create New Partitions Phase):无锁操作
操作流程:
关键操作:
- 生成新分区ID:为每个要清空的分区生成新的分区ID
- 复制分区属性:从旧分区复制存储介质、副本数、数据属性等配置
- 创建新分区:调用
createPartition()创建新分区 - 构建分区结构:调用
buildPartitions()创建 Tablet 和索引结构 - 错误处理:如果创建失败,清理已创建的 Tablet
特点:
- 无锁操作:此阶段不持有任何锁,不会阻塞其他操作
- 耗时较长:创建分区和 Tablet 需要 100-500ms
- 可回滚:如果失败,会清理已创建的资源
实际例子:
假设要清空 3 个分区:
时间轴: T0: 开始创建新分区(无锁) T1 (50ms): 创建分区1完成 T2 (150ms): 创建分区2完成 T3 (300ms): 创建分区3完成,所有新分区创建完成在这 300ms 期间,其他操作可以正常进行,不会被阻塞。
关键源码:
文件位置:fe/fe-core/src/main/java/com/starrocks/server/LocalMetastore.java
代码位置:LocalMetastore.java:4533-4566
// 2. 使用影子副本创建新分区(无锁操作)List<Partition>newPartitions=Lists.newArrayListWithCapacity(origPartitions.size());Set<Long>tabletIdSet=Sets.newHashSet();try{for(Map.Entry<String,Partition>entry:origPartitions.entrySet()){longoldPartitionId=entry.getValue().getId();longnewPartitionId=getNextId();// 生成新的分区IDStringnewPartitionName=entry.getKey();// 复制分区属性(存储介质、副本数、数据属性等)PartitionInfopartitionInfo=copiedTbl.getPartitionInfo();partitionInfo.setTabletType(newPartitionId,partitionInfo.getTabletType(oldPartitionId));partitionInfo.setIsInMemory(newPartitionId,partitionInfo.getIsInMemory(oldPartitionId));partitionInfo.setReplicationNum(newPartitionId,partitionInfo.getReplicationNum(oldPartitionId));partitionInfo.setDataProperty(newPartitionId,partitionInfo.getDataProperty(oldPartitionId));if(copiedTbl.isCloudNativeTable()){partitionInfo.setDataCacheInfo(newPartitionId,partitionInfo.getDataCacheInfo(oldPartitionId));}copiedTbl.setDefaultDistributionInfo(entry.getValue().getDistributionInfo());// 创建新分区PartitionnewPartition=createPartition(db,copiedTbl,newPartitionId,newPartitionName,null,tabletIdSet);newPartitions.add(newPartition);}// 构建分区(创建 Tablet、索引等)buildPartitions(db,copiedTbl,newPartitions.stream().map(Partition::getSubPartitions).flatMap(p->p.stream()).collect(Collectors.toList()));}catch(DdlExceptione){// 如果创建失败,清理已创建的 TabletdeleteUselessTablets(tabletIdSet);throwe;}这段代码展示了如何创建新分区:生成新分区ID、复制分区属性、创建分区结构,以及错误处理机制。
2.3.3 子阶段3:写锁替换阶段(Write Lock Replace Phase):关键阻塞点
操作流程:
关键操作详解:
替换分区(Replace Partitions)
文件位置:fe/fe-core/src/main/java/com/starrocks/server/LocalMetastore.java
代码位置:LocalMetastore.java:4670-4702
关键源码:
privatevoidtruncateTableInternal(OlapTableolapTable,List<Partition>newPartitions,booleanisEntireTable,booleanisReplay){// 使用新分区替换旧分区Set<Tablet>oldTablets=Sets.newHashSet();for(PartitionnewPartition:newPartitions){PartitionoldPartition=olapTable.replacePartition(newPartition);// ← 替换操作for(PhysicalPartitionphysicalPartition:oldPartition.getSubPartitions()){// 收集旧 Tablet 用于后续删除for(MaterializedIndexindex:physicalPartition.getMaterializedIndices(MaterializedIndex.IndexExtState.ALL)){// let HashSet do the deduplicate workoldTablets.addAll(index.getTablets());}}}if(isEntireTable){// 如果是清空整个表,删除所有临时分区olapTable.dropAllTempPartitions();}// 从 InvertedIndex 中删除旧 Tabletfor(Tablettablet:oldTablets){TabletInvertedIndexindex=GlobalStateMgr.getCurrentInvertedIndex();index.deleteTablet(tablet.getId());// 确保只有 Leader FE 记录 truncate 信息if(!isReplay){index.markTabletForceDelete(tablet);}}}功能说明:
- 使用新创建的空分区替换旧分区
- 收集旧 Tablet 并标记删除
- 如果是清空整个表,删除所有临时分区
EditLog 写入(EditLog Write):关键阻塞点
文件位置:fe/fe-core/src/main/java/com/starrocks/server/LocalMetastore.java
代码位置:LocalMetastore.java:4568-4656
写锁替换阶段完整源码:
// 3. 获取数据库写锁(关键操作阶段)db.writeLock();// ← 关键:数据库级别的写锁try{// 3.1 再次检查表状态(防止在创建分区期间表被删除或修改)OlapTableolapTable=(OlapTable)db.getTable(copiedTbl.getId());if(olapTable==null){thrownewDdlException("Table["+copiedTbl.getName()+"] is dropped");}if(olapTable.getState()!=OlapTable.OlapTableState.NORMAL){throwInvalidOlapTableStateException.of(olapTable.getState(),olapTable.getName());}// 3.2 检查分区是否发生变化for(Map.Entry<String,Partition>entry:origPartitions.entrySet()){Partitionpartition=olapTable.getPartition(entry.getValue().getId());if(partition==null||!partition.getName().equalsIgnoreCase(entry.getKey())){thrownewDdlException("Partition ["+entry.getKey()+"] is changed during truncating table, "+"please retry");}}// 3.3 检查元数据是否发生变化(Schema、索引等)booleanmetaChanged=false;if(olapTable.getIndexNameToId().size()!=copiedTbl.getIndexNameToId().size()){metaChanged=true;}else{// 比较 SchemaHashMap<Long,Integer>copiedIndexIdToSchemaHash=copiedTbl.getIndexIdToSchemaHash();for(Map.Entry<Long,Integer>entry:olapTable.getIndexIdToSchemaHash().entrySet()){longindexId=entry.getKey();if(!copiedIndexIdToSchemaHash.containsKey(indexId)){metaChanged=true;break;}if(!copiedIndexIdToSchemaHash.get(indexId).equals(entry.getValue())){metaChanged=true;break;}}}if(olapTable.getDefaultDistributionInfo().getType()!=copiedTbl.getDefaultDistributionInfo().getType()){metaChanged=true;}if(metaChanged){thrownewDdlException("Table["+copiedTbl.getName()+"]'s meta has been changed. try again.");}// 3.4 替换分区(核心操作)truncateTableInternal(olapTable,newPartitions,truncateEntireTable,false);// 3.5 更新 Colocation 信息try{colocateTableIndex.updateLakeTableColocationInfo(olapTable,true/* isJoin */,null/* expectGroupId */);}catch(DdlExceptione){LOG.info("table {} update colocation info failed when truncate table, {}",olapTable.getId(),e.getMessage());}// 3.6 写入 EditLog(阻塞点)TruncateTableInfoinfo=newTruncateTableInfo(db.getId(),olapTable.getId(),newPartitions,truncateEntireTable);GlobalStateMgr.getCurrentState().getEditLog().logTruncateTable(info);// ← 阻塞等待 BDBJE 写入// 3.7 刷新物化视图Set<MvId>relatedMvs=olapTable.getRelatedMaterializedViews();for(MvIdmvId:relatedMvs){MaterializedViewmaterializedView=(MaterializedView)getTable(mvId.getDbId(),mvId.getId());if(materializedView==null){LOG.warn("Table related materialized view {}.{} can not be found",mvId.getDbId(),mvId.getId());continue;}if(materializedView.isLoadTriggeredRefresh()){DatabasemvDb=getDb(mvId.getDbId());refreshMaterializedView(mvDb.getFullName(),getTable(mvDb.getId(),mvId.getId()).getName(),false,null,Constants.TaskRunPriority.NORMAL.value(),true,false);}}}catch(DdlExceptione){deleteUselessTablets(tabletIdSet);throwe;}catch(MetaNotFoundExceptione){LOG.warn("Table related materialized view can not be found",e);}finally{db.writeUnlock();// 释放写锁}EditLog 写入源码:
文件位置:fe/fe-core/src/main/java/com/starrocks/persist/EditLog.java
logTruncateTable 方法(EditLog.java:1789-1791):
publicvoidlogTruncateTable(TruncateTableInfoinfo){logEdit(OperationType.OP_TRUNCATE_TABLE,info);}logEdit 方法(EditLog.java:1243-1246):
protectedvoidlogEdit(shortop,Writablewritable){JournalTasktask=submitLog(op,writable,-1);waitInfinity(task);// ← 阻塞等待 BDBJE 写入完成}waitInfinity 方法(EditLog.java:1299-1324):关键阻塞点
publicstaticvoidwaitInfinity(JournalTasktask){longstartTimeNano=task.getStartTimeNano();booleanresult;intcnt=0;while(true){try{if(cnt!=0){Thread.sleep(1000);// 失败后等待1秒重试}// 等待 JournalWriter 写入完成result=task.get();// ← 阻塞等待break;}catch(InterruptedException|ExecutionExceptione){LOG.warn("failed to wait, wait and retry {} times..: {}",cnt,e);cnt++;}}assert(result);if(MetricRepo.hasInit){MetricRepo.HISTO_EDIT_LOG_WRITE_LATENCY.update((System.nanoTime()-startTimeNano)/1000000);}}阻塞机制分析:
关键问题:
- 写锁持有时间长:在等待 BDBJE 写入期间,一直持有数据库写锁
- 阻塞所有读操作:写锁持有期间,所有需要读锁的操作(如 ReportHandler)都被阻塞
- BDBJE 写入耗时:正常情况下 1-5 秒,高负载时可能更长
实际例子:
时间轴: T0: TRUNCATE 获取写锁 T1: 替换分区完成(50ms) T2: 开始写入 EditLog T3: 等待 BDBJE 写入...(3秒) T4: BDBJE 写入完成 T5: 释放写锁 在这 3 秒期间(T2-T4),写锁一直被持有!2.5 阶段6-7:持久化和同步(Persistence and Synchronization):确保数据一致性
阶段6:EditLog 持久化(EditLog Persistence)
文件位置:fe/fe-core/src/main/java/com/starrocks/journal/bdbje/
TruncateTableInfo 数据结构:
文件位置:fe/fe-core/src/main/java/com/starrocks/persist/TruncateTableInfo.java
publicclassTruncateTableInfoimplementsWritable{@SerializedName(value="dbId")privatelongdbId;// 数据库ID@SerializedName(value="tblId")privatelongtblId;// 表ID@SerializedName(value="partitions")privateList<Partition>partitions;// 新分区列表@SerializedName(value="isEntireTable")privatebooleanisEntireTable;// 是否清空整个表publicTruncateTableInfo(longdbId,longtblId,List<Partition>partitions,booleanisEntireTable){this.dbId=dbId;this.tblId=tblId;this.partitions=partitions;this.isEntireTable=isEntireTable;}@Overridepublicvoidwrite(DataOutputout)throwsIOException{Stringjson=GsonUtils.GSON.toJson(this);// 序列化为 JSONText.writeString(out,json);}}流程说明:
- JournalWriter 线程:从队列中取出日志任务
- 序列化:将
TruncateTableInfo序列化为 JSON - BDBJE 写入:写入 Berkeley DB Java Edition(持久化存储)
- 同步等待:等待写入完成(同步写入)
- 回调通知:通知等待的线程
阶段7:BE 节点同步(Backend Node Synchronization)
回放方法源码:
文件位置:fe/fe-core/src/main/java/com/starrocks/server/LocalMetastore.java
代码位置:LocalMetastore.java:4704-4730
publicvoidreplayTruncateTable(TruncateTableInfoinfo){Databasedb=getDb(info.getDbId());db.writeLock();try{OlapTableolapTable=(OlapTable)db.getTable(info.getTblId());truncateTableInternal(olapTable,info.getPartitions(),info.isEntireTable(),true);if(!GlobalStateMgr.isCheckpointThread()){// 将新 Tablet 添加到 InvertedIndexTabletInvertedIndexinvertedIndex=GlobalStateMgr.getCurrentInvertedIndex();for(Partitionpartition:info.getPartitions()){longpartitionId=partition.getId();TStorageMediummedium=olapTable.getPartitionInfo().getDataProperty(partitionId).getStorageMedium();for(PhysicalPartitionphysicalPartition:partition.getSubPartitions()){for(MaterializedIndexmIndex:physicalPartition.getMaterializedIndices(MaterializedIndex.IndexExtState.ALL)){// 添加 Tablet 到索引// ...}}}}}finally{db.writeUnlock();}}流程说明:
- EditLog 回放:Follower FE 节点回放 EditLog
- 元数据同步:BE 节点通过心跳获取元数据变更
- Tablet 清理:BE 节点删除旧 Tablet 的数据文件
三、锁机制深度解析(Lock Mechanism):为什么会被阻塞
这一章要建立的基础:理解 StarRocks 的锁机制,明白为什么 TRUNCATE 会阻塞其他操作
核心问题:为什么 TRUNCATE 执行时,其他操作会被阻塞?
[!NOTE]
📝 关键点总结:TRUNCATE 使用数据库级别的写锁,在等待持久化时一直持有锁,导致其他操作被阻塞
3.1 数据库锁类型(Database Lock Types):读锁和写锁的区别
锁类型:
StarRocks 使用两种类型的锁:
- 读锁(ReadLock):
db.readLock()- 共享锁,多个读操作可以并发 - 写锁(WriteLock):
db.writeLock()- 排他锁,独占访问
锁实现源码:
文件位置:fe/fe-core/src/main/java/com/starrocks/catalog/Database.java
publicclassDatabaseextendsMetaObject{privatefinalReentrantReadWriteLockrwLock=newReentrantReadWriteLock(true);publicvoidreadLock(){longstartMs=TimeUnit.MILLISECONDS.convert(System.nanoTime(),TimeUnit.NANOSECONDS);StringthreadDump=getOwnerInfo(rwLock.getOwner());this.rwLock.sharedLock();// 获取共享锁(读锁)logSlowLockEventIfNeeded(startMs,"readLock",threadDump);}publicvoidwriteLock(){longstartMs=TimeUnit.MILLISECONDS.convert(System.nanoTime(),TimeUnit.NANOSECONDS);StringthreadDump=getOwnerInfo(rwLock.getOwner());this.rwLock.exclusiveLock();// 获取排他锁(写锁)logSlowLockEventIfNeeded(startMs,"writeLock",threadDump);}publicvoidreadUnlock(){this.rwLock.sharedUnlock();}publicvoidwriteUnlock(){this.rwLock.exclusiveUnlock();}}图解说明:
实际例子:
// 读锁:多个操作可以同时获取线程1:db.readLock()// 获取读锁线程2:db.readLock()// 也可以获取读锁(共享)线程3:db.readLock()// 也可以获取读锁(共享)// 三个线程可以同时读取// 写锁:独占访问线程1:db.writeLock()// 获取写锁线程2:db.readLock()// 被阻塞,必须等待线程1释放写锁线程3:db.writeLock()// 被阻塞,必须等待线程1释放写锁3.2 TRUNCATE 锁持有时间线(Lock Holding Timeline):理解阻塞过程
时间线分析:
关键发现:
- 写锁持有时间:从获取写锁到释放,约 1-5 秒
- 阻塞时间:EditLog 写入期间(1-5秒),一直持有写锁
- 阻塞影响:写锁持有期间,所有读锁操作被阻塞
实际例子:
时间轴: T0: 开始执行 TRUNCATE T1: 获取读锁 (db.readLock) T2: 检查表信息 (1-10ms) T3: 释放读锁 (db.readUnlock) T4: 创建新分区 (无锁,100-500ms) T5: 获取写锁 (db.writeLock) ← 关键点 T6: 替换分区 (10-50ms) T7: 写入 EditLog (logTruncateTable) T8: 等待 BDBJE 写入 (1-5秒) ← 阻塞点 T9: BDBJE 写入完成 T10: 刷新物化视图 (可选,100-500ms) T11: 释放写锁 (db.writeUnlock) T12: 完成3.3 锁竞争场景(Lock Contention Scenarios):实际影响分析
场景1:TRUNCATE + ReportHandler
时间线:
结果:ReportHandler 被阻塞 1-5 秒,可能导致 BE 心跳超时
场景2:多个 TRUNCATE 并发
时间线:
结果:多个 TRUNCATE 串行执行,总耗时 = N × (1-5秒)
实际例子:
假设有 3 个 TRUNCATE 操作:
TRUNCATE1: 0-3秒(持有写锁) TRUNCATE2: 3-6秒(等待 TRUNCATE1,然后执行) TRUNCATE3: 6-9秒(等待 TRUNCATE2,然后执行) 总耗时:9秒(串行执行)四、性能瓶颈分析(Performance Bottleneck Analysis):找出问题根源
这一章要建立的基础:理解 TRUNCATE 的性能瓶颈,知道哪些地方可以优化
核心问题:为什么 TRUNCATE 会阻塞其他操作?主要瓶颈在哪里?
[!NOTE]
📝 关键点总结:EditLog 写入是主要瓶颈,在持有写锁期间等待 BDBJE 写入完成,阻塞所有读操作
4.1 各阶段耗时统计(Stage Time Statistics):找出慢的地方
耗时对比表:
| 阶段 | 操作 | 平均耗时 | 最大耗时 | 是否可优化 |
|---|---|---|---|---|
| SQL 解析 | 语法解析 | < 1ms | < 5ms | 否 |
| 语义分析 | 表名规范化 | < 1ms | < 5ms | 否 |
| 读锁检查 | 表信息检查 | 1-10ms | 50ms | 否 |
| 创建分区 | 创建新分区 | 100-500ms | 2秒 | 是(异步) |
| 写锁替换 | 替换分区 | 10-50ms | 200ms | 否 |
| EditLog 写入 | BDBJE 持久化 | 1-5秒 | 10秒+ | 是(异步) |
| 刷新物化视图 | MV 刷新 | 100-500ms | 2秒 | 是(异步) |
可视化分析:
💡说明:EditLog 写入占总耗时的 80%,是主要瓶颈
4.2 性能瓶颈分析(Performance Bottleneck Analysis):三大瓶颈
瓶颈1:EditLog 写入阻塞(EditLog Write Blocking)
问题:
- 在持有写锁期间等待 BDBJE 写入完成
- 阻塞所有读操作 1-5 秒
优化方向:
- 方案1:异步写入 EditLog(需要处理一致性)
- 方案2:优化 BDBJE 写入性能(硬件、配置)
- 方案3:减少 EditLog 写入频率(批量写入)
瓶颈2:创建分区耗时(Partition Creation Time)
问题:
- 创建分区和 Tablet 需要 100-500ms
- 虽然无锁,但增加总耗时
优化方向:
- 方案1:预创建分区池
- 方案2:优化 Tablet 创建逻辑
瓶颈3:锁粒度(Lock Granularity)
问题:
- 使用数据库级别的写锁,不是表级别
- 同一数据库下的所有操作竞争同一把锁
优化方向:
- 方案1:改为表级别锁(需要大量重构)
- 方案2:使用更细粒度的锁(分区级别)
五、优化建议(Optimization Recommendations):如何避免问题
这一章要建立的基础:掌握优化 TRUNCATE 性能的方法,避免阻塞问题
核心问题:如何优化 TRUNCATE 操作,减少对系统的影响?
[!NOTE]
📝 关键点总结:优化方向包括降低频率、使用分区表、增加超时配置、异步写入等
5.1 短期优化(Short-term Optimization):不修改核心逻辑
方案1:降低 TRUNCATE 频率
方法:
- 错开执行时间
- 使用队列控制并发
实际例子:
# 将 200 个任务分散到不同时间点# 例如:每 30 秒执行一个任务# 200 个任务 × 30 秒 = 6000 秒 = 100 分钟方案2:增加超时配置
配置项:
catalog_try_lock_timeout_ms = 30000(数据库锁超时时间)thrift_rpc_timeout_ms = 30000(Thrift RPC 超时时间)
方案3:使用分区表
优势:
- 只清空需要的分区
- 减少锁持有时间
实际例子:
-- 推荐:只清空需要的分区TRUNCATETABLEordersPARTITION(p20251210);-- 不推荐:清空整个表TRUNCATETABLEorders;5.2 长期优化(Long-term Optimization):需要代码修改
方案1:异步 EditLog 写入
思路:
- 在替换分区后立即释放写锁
- 异步写入 EditLog
- 需要处理一致性问题
方案2:表级别锁
思路:
- 将数据库级别锁改为表级别锁
- 需要大量重构
方案3:批量 EditLog 写入
思路:
- 将多个操作合并为一个 EditLog
- 减少 BDBJE 写入次数
📝 本章总结
核心要点回顾:
- TRUNCATE 的本质:通过创建新分区替换旧分区实现快速清空
- 执行流程:7 个阶段,其中写锁替换阶段是关键阻塞点
- 锁机制:使用数据库级别的写锁,在等待持久化时阻塞其他操作
- 性能瓶颈:EditLog 写入耗时 1-5 秒,占总耗时的 80%
- 优化方向:降低频率、使用分区表、异步写入等
知识地图:
关键决策点:
- 是否使用 TRUNCATE:如果需要快速清空表,TRUNCATE 比 DELETE 快
- 频率控制:单个数据库建议 ≤ 1-2 次/分钟
- 分区策略:使用分区表,只清空需要的分区
- 超时配置:根据实际情况调整超时时间