文章目录
- 本文内容一览(快速理解)
- 一、什么是 TRUNCATE(Truncate Table):理解清空表数据的本质
- 二、TRUNCATE 的执行流程(Execution Flow):从 SQL 到数据清空的 7 个阶段
- 三、锁机制深度解析(Lock Mechanism):为什么会被阻塞
- 四、性能瓶颈分析(Performance Bottleneck Analysis):找出问题根源
- 五、优化建议(Optimization Recommendations):如何避免问题
- 📝 本章总结
📌 适合对象: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 的优势是速度快,因为它不需要逐行删除数据,而是直接替换整个分区
实际例子:
-- 清空整个表
TRUNCATE TABLE my_db.user_table;
-- 只清空指定分区
TRUNCATE TABLE my_db.user_table PARTITION(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
关键源码:
@Override
public ParseNode visitTruncateTableStatement(StarRocksParser.TruncateTableStatementContext context) {
QualifiedName qualifiedName = getQualifiedName(context.qualifiedName());
TableName targetTableName = qualifiedNameToTableName(qualifiedName);
Token start = context.start;
Token stop = context.stop;
PartitionNames partitionNames = null;
if (context.partitionNames() != null) {
stop = context.partitionNames().stop;
partitionNames = (PartitionNames) visit(context.partitionNames());
}
NodePosition pos = createPos(start, stop);
return new TruncateTableStmt(new TableRef(targetTableName, null, partitionNames, pos));
}
AST 节点结构:
// 文件位置:fe/fe-core/src/main/java/com/starrocks/sql/ast/TruncateTableStmt.java
public class TruncateTableStmt extends DdlStmt {
private final TableRef tblRef; // 包含表名和分区信息
public TableRef getTblRef() {
return tblRef;
}
public String getDbName() {
return tblRef.getName().getDb();
}
public String getTblName() {
return tblRef.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
关键源码:
public static void analyze(TruncateTableStmt statement, ConnectContext context) {
// 1. 规范化表名(处理大小写、默认数据库等)
MetaUtils.normalizationTableName(context, statement.getTblRef().getName());
// 2. 检查是否使用别名(不支持)
if (statement.getTblRef().hasExplicitAlias()) {
throw new SemanticException("Not support truncate table with alias");
}
// 3. 检查分区信息
PartitionNames partitionNames = statement.getTblRef().getPartitionNames();
if (partitionNames != null) {
// 不支持清空临时分区
if (partitionNames.isTemp()) {
throw new SemanticException("Not support truncate temp partitions");
}
// 检查分区名是否为空
if (partitionNames.getPartitionNames().stream().anyMatch(entity -> Strings.isNullOrEmpty(entity))) {
throw new SemanticException("there are empty partition name");
}
}
}
调用路径:
// 文件位置:fe/fe-core/src/main/java/com/starrocks/sql/analyzer/AnalyzerVisitor.java
@Override
public Void visitTruncateTableStatement(TruncateTableStmt statement, ConnectContext context) {
TruncateTableAnalyzer.analyze(statement, context);
return null;
}
功能说明:
- 规范化表名(处理大小写、默认数据库)
- 验证语法约束(不支持别名、不支持临时分区)
- 验证分区名有效性
阶段3:权限检查(Authorization)
文件位置:fe/fe-core/src/main/java/com/starrocks/sql/analyzer/AuthorizerStmtVisitor.java
关键源码:
@Override
public Void visitTruncateTableStatement(TruncateTableStmt statement, ConnectContext context) {
// 检查用户是否有 TRUNCATE 权限
Authorizer.checkTableAction(context.getCurrentUserIdentity(),
context.getCurrentRoleIds(),
statement.getDbName(),
statement.getTblName(),
PrivilegeType.DELETE);
return null;
}
功能说明:
- 验证用户是否有表的 DELETE 权限(TRUNCATE 使用 DELETE 权限)
- 如果权限不足,抛出
AccessDeniedException
2.4 阶段4:执行入口(Execution Entry):路由到元数据操作
文件位置:fe/fe-core/src/main/java/com/starrocks/qe/DDLStmtExecutor.java
关键源码:
@Override
public ShowResultSet visitTruncateTableStatement(TruncateTableStmt stmt, ConnectContext context) {
ErrorReport.wrapWithRuntimeException(() -> {
context.getGlobalStateMgr().truncateTable(stmt);
});
return null;
}
调用链:
// 文件位置:fe/fe-core/src/main/java/com/starrocks/server/GlobalStateMgr.java
public void truncateTable(TruncateTableStmt truncateTableStmt) throws DdlException {
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 {
Table table = db.getTable(dbTbl.getTbl());
if (table == null) {
ErrorReport.reportDdlException(ErrorCode.ERR_BAD_TABLE_ERROR, dbTbl.getTbl());
}
// 只支持 OLAP 表或 LAKE 表
if (!table.isOlapOrCloudNativeTable()) {
throw new DdlException("Only support truncate OLAP table or LAKE table");
}
OlapTable olapTable = (OlapTable) table;
if (olapTable.getState() != OlapTable.OlapTableState.NORMAL) {
throw InvalidOlapTableStateException.of(olapTable.getState(), olapTable.getName());
}
// 收集需要清空的分区信息
if (!truncateEntireTable) {
// 清空指定分区
for (String partName : tblRef.getPartitionNames().getPartitionNames()) {
Partition partition = olapTable.getPartition(partName);
if (partition == null) {
throw new DdlException("Partition " + partName + " does not exist");
}
origPartitions.put(partName, partition);
GlobalStateMgr.getCurrentState().getAnalyzeMgr().recordDropPartition(partition.getId());
}
} else {
// 清空整个表的所有分区
for (Partition partition : 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()) {
long oldPartitionId = entry.getValue().getId();
long newPartitionId = getNextId(); // 生成新的分区ID
String newPartitionName = entry.getKey();
// 复制分区属性(存储介质、副本数、数据属性等)
PartitionInfo partitionInfo = 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());
// 创建新分区
Partition newPartition =
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 (DdlException e) {
// 如果创建失败,清理已创建的 Tablet
deleteUselessTablets(tabletIdSet);
throw e;
}
这段代码展示了如何创建新分区:生成新分区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
关键源码:
private void truncateTableInternal(OlapTable olapTable, List<Partition> newPartitions,
boolean isEntireTable, boolean isReplay) {
// 使用新分区替换旧分区
Set<Tablet> oldTablets = Sets.newHashSet();
for (Partition newPartition : newPartitions) {
Partition oldPartition = olapTable.replacePartition(newPartition); // ← 替换操作
for (PhysicalPartition physicalPartition : oldPartition.getSubPartitions()) {
// 收集旧 Tablet 用于后续删除
for (MaterializedIndex index : physicalPartition.getMaterializedIndices(MaterializedIndex.IndexExtState.ALL)) {
// let HashSet do the deduplicate work
oldTablets.addAll(index.getTablets());
}
}
}
if (isEntireTable) {
// 如果是清空整个表,删除所有临时分区
olapTable.dropAllTempPartitions();
}
// 从 InvertedIndex 中删除旧 Tablet
for (Tablet tablet : oldTablets) {
TabletInvertedIndex index = 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 再次检查表状态(防止在创建分区期间表被删除或修改)
OlapTable olapTable = (OlapTable) db.getTable(copiedTbl.getId());
if (olapTable == null) {
throw new DdlException("Table[" + copiedTbl.getName() + "] is dropped");
}
if (olapTable.getState() != OlapTable.OlapTableState.NORMAL) {
throw InvalidOlapTableStateException.of(olapTable.getState(), olapTable.getName());
}
// 3.2 检查分区是否发生变化
for (Map.Entry<String, Partition> entry : origPartitions.entrySet()) {
Partition partition = olapTable.getPartition(entry.getValue().getId());
if (partition == null || !partition.getName().equalsIgnoreCase(entry.getKey())) {
throw new DdlException("Partition [" + entry.getKey() + "] is changed during truncating table, " +
"please retry");
}
}
// 3.3 检查元数据是否发生变化(Schema、索引等)
boolean metaChanged = false;
if (olapTable.getIndexNameToId().size() != copiedTbl.getIndexNameToId().size()) {
metaChanged = true;
} else {
// 比较 SchemaHash
Map<Long, Integer> copiedIndexIdToSchemaHash = copiedTbl.getIndexIdToSchemaHash();
for (Map.Entry<Long, Integer> entry : olapTable.getIndexIdToSchemaHash().entrySet()) {
long indexId = 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) {
throw new DdlException("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 (DdlException e) {
LOG.info("table {} update colocation info failed when truncate table, {}", olapTable.getId(), e.getMessage());
}
// 3.6 写入 EditLog(阻塞点)
TruncateTableInfo info = new TruncateTableInfo(db.getId(), olapTable.getId(), newPartitions,
truncateEntireTable);
GlobalStateMgr.getCurrentState().getEditLog().logTruncateTable(info); // ← 阻塞等待 BDBJE 写入
// 3.7 刷新物化视图
Set<MvId> relatedMvs = olapTable.getRelatedMaterializedViews();
for (MvId mvId : relatedMvs) {
MaterializedView materializedView = (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()) {
Database mvDb = getDb(mvId.getDbId());
refreshMaterializedView(mvDb.getFullName(), getTable(mvDb.getId(), mvId.getId()).getName(), false, null,
Constants.TaskRunPriority.NORMAL.value(), true, false);
}
}
} catch (DdlException e) {
deleteUselessTablets(tabletIdSet);
throw e;
} catch (MetaNotFoundException e) {
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):
public void logTruncateTable(TruncateTableInfo info) {
logEdit(OperationType.OP_TRUNCATE_TABLE, info);
}
logEdit 方法(EditLog.java:1243-1246):
protected void logEdit(short op, Writable writable) {
JournalTask task = submitLog(op, writable, -1);
waitInfinity(task); // ← 阻塞等待 BDBJE 写入完成
}
waitInfinity 方法(EditLog.java:1299-1324):关键阻塞点
public static void waitInfinity(JournalTask task) {
long startTimeNano = task.getStartTimeNano();
boolean result;
int cnt = 0;
while (true) {
try {
if (cnt != 0) {
Thread.sleep(1000); // 失败后等待1秒重试
}
// 等待 JournalWriter 写入完成
result = task.get(); // ← 阻塞等待
break;
} catch (InterruptedException | ExecutionException e) {
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
public class TruncateTableInfo implements Writable {
@SerializedName(value = "dbId")
private long dbId; // 数据库ID
@SerializedName(value = "tblId")
private long tblId; // 表ID
@SerializedName(value = "partitions")
private List<Partition> partitions; // 新分区列表
@SerializedName(value = "isEntireTable")
private boolean isEntireTable; // 是否清空整个表
public TruncateTableInfo(long dbId, long tblId, List<Partition> partitions, boolean isEntireTable) {
this.dbId = dbId;
this.tblId = tblId;
this.partitions = partitions;
this.isEntireTable = isEntireTable;
}
@Override
public void write(DataOutput out) throws IOException {
String json = GsonUtils.GSON.toJson(this); // 序列化为 JSON
Text.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
public void replayTruncateTable(TruncateTableInfo info) {
Database db = getDb(info.getDbId());
db.writeLock();
try {
OlapTable olapTable = (OlapTable) db.getTable(info.getTblId());
truncateTableInternal(olapTable, info.getPartitions(), info.isEntireTable(), true);
if (!GlobalStateMgr.isCheckpointThread()) {
// 将新 Tablet 添加到 InvertedIndex
TabletInvertedIndex invertedIndex = GlobalStateMgr.getCurrentInvertedIndex();
for (Partition partition : info.getPartitions()) {
long partitionId = partition.getId();
TStorageMedium medium = olapTable.getPartitionInfo().getDataProperty(
partitionId).getStorageMedium();
for (PhysicalPartition physicalPartition : partition.getSubPartitions()) {
for (MaterializedIndex mIndex : 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
public class Database extends MetaObject {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(true);
public void readLock() {
long startMs = TimeUnit.MILLISECONDS.convert(System.nanoTime(), TimeUnit.NANOSECONDS);
String threadDump = getOwnerInfo(rwLock.getOwner());
this.rwLock.sharedLock(); // 获取共享锁(读锁)
logSlowLockEventIfNeeded(startMs, "readLock", threadDump);
}
public void writeLock() {
long startMs = TimeUnit.MILLISECONDS.convert(System.nanoTime(), TimeUnit.NANOSECONDS);
String threadDump = getOwnerInfo(rwLock.getOwner());
this.rwLock.exclusiveLock(); // 获取排他锁(写锁)
logSlowLockEventIfNeeded(startMs, "writeLock", threadDump);
}
public void readUnlock() {
this.rwLock.sharedUnlock();
}
public void writeUnlock() {
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:使用分区表
优势:
- 只清空需要的分区
- 减少锁持有时间
实际例子:
-- 推荐:只清空需要的分区
TRUNCATE TABLE orders PARTITION(p20251210);
-- 不推荐:清空整个表
TRUNCATE TABLE orders;
5.2 长期优化(Long-term Optimization):需要代码修改
方案1:异步 EditLog 写入
思路:
- 在替换分区后立即释放写锁
- 异步写入 EditLog
- 需要处理一致性问题
方案2:表级别锁
思路:
- 将数据库级别锁改为表级别锁
- 需要大量重构
方案3:批量 EditLog 写入
思路:
- 将多个操作合并为一个 EditLog
- 减少 BDBJE 写入次数
📝 本章总结
核心要点回顾:
- TRUNCATE 的本质:通过创建新分区替换旧分区实现快速清空
- 执行流程:7 个阶段,其中写锁替换阶段是关键阻塞点
- 锁机制:使用数据库级别的写锁,在等待持久化时阻塞其他操作
- 性能瓶颈:EditLog 写入耗时 1-5 秒,占总耗时的 80%
- 优化方向:降低频率、使用分区表、异步写入等
知识地图:
关键决策点:
- 是否使用 TRUNCATE:如果需要快速清空表,TRUNCATE 比 DELETE 快
- 频率控制:单个数据库建议 ≤ 1-2 次/分钟
- 分区策略:使用分区表,只清空需要的分区
- 超时配置:根据实际情况调整超时时间
2044

被折叠的 条评论
为什么被折叠?



