【源码分析】StarRocks TRUNCATE 语句执行流程:从 SQL 到数据清空的完整旅程

文章目录

    • 本文内容一览(快速理解)
    • [一、什么是 TRUNCATE(Truncate Table):理解清空表数据的本质](#一、什么是 TRUNCATE(Truncate Table):理解清空表数据的本质)
    • [二、TRUNCATE 的执行流程(Execution Flow):从 SQL 到数据清空的 7 个阶段](#二、TRUNCATE 的执行流程(Execution Flow):从 SQL 到数据清空的 7 个阶段)
      • [2.1 完整执行链路(Complete Execution Chain):7 个阶段的旅程](#2.1 完整执行链路(Complete Execution Chain):7 个阶段的旅程)
      • [2.2 阶段1-3:SQL 解析到权限检查(SQL Parsing to Authorization):快速验证阶段](#2.2 阶段1-3:SQL 解析到权限检查(SQL Parsing to Authorization):快速验证阶段)
      • [2.4 阶段4:执行入口(Execution Entry):路由到元数据操作](#2.4 阶段4:执行入口(Execution Entry):路由到元数据操作)
      • [2.3 阶段5:元数据操作(Metadata Operations):核心执行阶段](#2.3 阶段5:元数据操作(Metadata Operations):核心执行阶段)
        • [2.3.1 子阶段1:读锁检查阶段(Read Lock Check Phase):快速验证](#2.3.1 子阶段1:读锁检查阶段(Read Lock Check Phase):快速验证)
        • [2.3.2 子阶段2:创建新分区阶段(Create New Partitions Phase):无锁操作](#2.3.2 子阶段2:创建新分区阶段(Create New Partitions Phase):无锁操作)
        • [2.3.3 子阶段3:写锁替换阶段(Write Lock Replace Phase):关键阻塞点](#2.3.3 子阶段3:写锁替换阶段(Write Lock Replace Phase):关键阻塞点)
          • [替换分区(Replace Partitions)](#替换分区(Replace Partitions))
          • [EditLog 写入(EditLog Write):关键阻塞点](#EditLog 写入(EditLog Write):关键阻塞点)
      • [2.5 阶段6-7:持久化和同步(Persistence and Synchronization):确保数据一致性](#2.5 阶段6-7:持久化和同步(Persistence and Synchronization):确保数据一致性)
    • [三、锁机制深度解析(Lock Mechanism):为什么会被阻塞](#三、锁机制深度解析(Lock Mechanism):为什么会被阻塞)
      • [3.1 数据库锁类型(Database Lock Types):读锁和写锁的区别](#3.1 数据库锁类型(Database Lock Types):读锁和写锁的区别)
      • [3.2 TRUNCATE 锁持有时间线(Lock Holding Timeline):理解阻塞过程](#3.2 TRUNCATE 锁持有时间线(Lock Holding Timeline):理解阻塞过程)
      • [3.3 锁竞争场景(Lock Contention Scenarios):实际影响分析](#3.3 锁竞争场景(Lock Contention Scenarios):实际影响分析)
    • [四、性能瓶颈分析(Performance Bottleneck Analysis):找出问题根源](#四、性能瓶颈分析(Performance Bottleneck Analysis):找出问题根源)
      • [4.1 各阶段耗时统计(Stage Time Statistics):找出慢的地方](#4.1 各阶段耗时统计(Stage Time Statistics):找出慢的地方)
      • [4.2 性能瓶颈分析(Performance Bottleneck Analysis):三大瓶颈](#4.2 性能瓶颈分析(Performance Bottleneck Analysis):三大瓶颈)
    • [五、优化建议(Optimization Recommendations):如何避免问题](#五、优化建议(Optimization Recommendations):如何避免问题)
      • [5.1 短期优化(Short-term Optimization):不修改核心逻辑](#5.1 短期优化(Short-term Optimization):不修改核心逻辑)
      • [5.2 长期优化(Long-term Optimization):需要代码修改](#5.2 长期优化(Long-term Optimization):需要代码修改)
    • [📝 本章总结](#📝 本章总结)

📌 适合对象 :StarRocks 开发者、运维人员、对数据库内部机制感兴趣的初学者

⏱️ 预计阅读时间 :40-50分钟

🎯 学习目标:理解 TRUNCATE 语句在 StarRocks 中的完整执行流程,掌握锁机制和性能瓶颈


第一步
理解 TRUNCATE 是什么
(清空表数据) 第二步
了解执行流程的7个阶段
(重点) 第三步
理解锁机制
(为什么会被阻塞) 第四步
认识性能瓶颈
(EditLog 写入) 第五步
掌握优化方法
(如何避免问题)


本文内容一览(快速理解)

  1. TRUNCATE 的本质:清空表数据,通过创建新分区替换旧分区实现
  2. 执行流程:从 SQL 解析到数据清空,经历 7 个关键阶段
  3. 锁机制:使用数据库级别的写锁,在等待持久化时阻塞其他操作
  4. 性能瓶颈:EditLog 写入需要 1-5 秒,期间一直持有写锁
  5. 优化方向:降低频率、使用分区表、异步写入等

一、什么是 TRUNCATE(Truncate Table):理解清空表数据的本质

这一章要建立的基础:理解 TRUNCATE 语句的作用和实现原理

核心问题 :当我们执行 TRUNCATE TABLE db.tbl 时,StarRocks 内部到底发生了什么?


!NOTE

📝 关键点总结:TRUNCATE 不是删除数据,而是用新的空分区替换旧分区,这样速度更快

概念的本质

TRUNCATE 是数据库提供的一种快速清空表数据的方法。与 DELETE 不同,TRUNCATE 不是逐行删除数据,而是通过替换分区的方式实现清空。

图解说明
旧分区
包含数据 创建新分区
(空分区) 替换操作
用新分区替换旧分区 结果:表被清空
但结构保留

💡 说明:TRUNCATE 的优势是速度快,因为它不需要逐行删除数据,而是直接替换整个分区

实际例子

sql 复制代码
-- 清空整个表
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 解析
解析语法树 阶段2:语义分析
验证表名和分区 阶段3:权限检查
验证用户权限 阶段4:执行入口
路由到元数据操作 阶段5:元数据操作
(核心阶段) 阶段6:EditLog 持久化
写入 BDBJE 阶段7:BE 节点同步
同步到后端节点 5.1 读锁检查
检查表信息 5.2 创建新分区
(无锁操作) 5.3 写锁替换
(阻塞点)

各阶段耗时统计

阶段 操作 锁类型 耗时 是否阻塞
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

关键源码

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 节点结构

java 复制代码
// 文件位置: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 语法树,提取表名和分区信息
  • 创建 TruncateTableStmt AST 节点
  • 支持两种格式:
    • 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

关键源码

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");
        }
    }
}

调用路径

java 复制代码
// 文件位置: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

关键源码

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

关键源码

java 复制代码
@Override
public ShowResultSet visitTruncateTableStatement(TruncateTableStmt stmt, ConnectContext context) {
    ErrorReport.wrapWithRuntimeException(() -> {
        context.getGlobalStateMgr().truncateTable(stmt);
    });
    return null;
}

调用链

java 复制代码
// 文件位置: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() 检查表是否存在 检查表类型
(只支持 OLAP/LAKE 表) 检查表状态
(必须是 NORMAL) 收集分区信息 创建表的影子副本 释放读锁
db.readUnlock()

关键操作

  1. 获取读锁db.readLock() - 数据库级别的读锁(共享锁)
  2. 验证表状态:检查表是否存在、类型是否支持、状态是否正常
  3. 收集分区信息:根据是否指定分区,收集需要清空的分区列表
  4. 创建影子副本:创建表的副本,用于后续创建新分区
  5. 释放读锁db.readUnlock()

锁持有时间 :通常 1-10ms,不会阻塞其他读操作

关键源码

文件位置fe/fe-core/src/main/java/com/starrocks/server/LocalMetastore.java
代码位置LocalMetastore.java:4495-4531

java 复制代码
// 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 复制分区属性
(存储介质、副本数等) 创建新分区 构建分区结构
(创建 Tablet、索引) 完成

关键操作

  1. 生成新分区ID:为每个要清空的分区生成新的分区ID
  2. 复制分区属性:从旧分区复制存储介质、副本数、数据属性等配置
  3. 创建新分区 :调用 createPartition() 创建新分区
  4. 构建分区结构 :调用 buildPartitions() 创建 Tablet 和索引结构
  5. 错误处理:如果创建失败,清理已创建的 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

java 复制代码
// 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):关键阻塞点

操作流程
获取写锁
db.writeLock() 再次检查表状态
(防止表被删除) 检查分区是否变化 检查元数据是否变化 替换分区
(核心操作) 更新 Colocation 信息 写入 EditLog
(阻塞点) 刷新物化视图 释放写锁
db.writeUnlock()

关键操作详解

替换分区(Replace Partitions)

文件位置fe/fe-core/src/main/java/com/starrocks/server/LocalMetastore.java
代码位置LocalMetastore.java:4670-4702

关键源码

java 复制代码
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

写锁替换阶段完整源码

java 复制代码
// 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):

java 复制代码
public void logTruncateTable(TruncateTableInfo info) {
    logEdit(OperationType.OP_TRUNCATE_TABLE, info);
}

logEdit 方法EditLog.java:1243-1246):

java 复制代码
protected void logEdit(short op, Writable writable) {
    JournalTask task = submitLog(op, writable, -1);
    waitInfinity(task);  // ← 阻塞等待 BDBJE 写入完成
}

waitInfinity 方法EditLog.java:1299-1324):关键阻塞点

java 复制代码
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);
    }
}

阻塞机制分析
TRUNCATE线程 EditLog 任务队列 JournalWriter线程 BDBJE存储 logTruncateTable(info) 提交日志任务 waitInfinity() 阻塞等待 取出任务 写入 BDBJE 写入完成 通知完成 继续执行 TRUNCATE线程 EditLog 任务队列 JournalWriter线程 BDBJE存储

关键问题

  • 写锁持有时间长:在等待 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

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);
    }
}

流程说明

  1. JournalWriter 线程:从队列中取出日志任务
  2. 序列化 :将 TruncateTableInfo 序列化为 JSON
  3. BDBJE 写入:写入 Berkeley DB Java Edition(持久化存储)
  4. 同步等待:等待写入完成(同步写入)
  5. 回调通知:通知等待的线程

阶段7:BE 节点同步(Backend Node Synchronization)

回放方法源码

文件位置fe/fe-core/src/main/java/com/starrocks/server/LocalMetastore.java
代码位置LocalMetastore.java:4704-4730

java 复制代码
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();
    }
}

流程说明

  1. EditLog 回放:Follower FE 节点回放 EditLog
  2. 元数据同步:BE 节点通过心跳获取元数据变更
  3. 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

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();
    }
}

图解说明
读锁 ReadLock
共享锁 多个读操作
可以同时进行 写锁 WriteLock
排他锁 独占访问
阻塞所有其他操作

实际例子

java 复制代码
// 读锁:多个操作可以同时获取
线程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):理解阻塞过程

时间线分析
000 ms 000 ms 000 ms 000 ms 000 ms 000 ms 000 ms 000 ms 000 ms 000 ms 000 ms 读锁检查 创建新分区 获取写锁 替换分区 写入EditLog 刷新物化视图 释放写锁 读锁阶段 无锁阶段 写锁阶段(阻塞) TRUNCATE 锁持有时间线

关键发现

  • 写锁持有时间:从获取写锁到释放,约 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

时间线
TRUNCATE线程 ReportHandler线程 数据库锁 获取写锁 尝试获取读锁(被阻塞) 等待 BDBJE 写入(1-5秒) 释放写锁 获取读锁成功 TRUNCATE线程 ReportHandler线程 数据库锁

结果:ReportHandler 被阻塞 1-5 秒,可能导致 BE 心跳超时

场景2:多个 TRUNCATE 并发

时间线
000 ms 000 ms 000 ms 000 ms 000 ms 000 ms 000 ms 000 ms 000 ms 000 ms 000 ms 执行 等待 执行 TRUNCATE1 TRUNCATE2 多个 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秒 是(异步)

可视化分析
80% 15% 5% 各阶段耗时占比(典型情况) EditLog 写入 创建分区 其他阶段

💡 说明: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 频率

方法

  • 错开执行时间
  • 使用队列控制并发

实际例子

bash 复制代码
# 将 200 个任务分散到不同时间点
# 例如:每 30 秒执行一个任务
# 200 个任务 × 30 秒 = 6000 秒 = 100 分钟

方案2:增加超时配置

配置项

  • catalog_try_lock_timeout_ms = 30000(数据库锁超时时间)
  • thrift_rpc_timeout_ms = 30000(Thrift RPC 超时时间)

方案3:使用分区表

优势

  • 只清空需要的分区
  • 减少锁持有时间

实际例子

sql 复制代码
-- 推荐:只清空需要的分区
TRUNCATE TABLE orders PARTITION(p20251210);

-- 不推荐:清空整个表
TRUNCATE TABLE orders;

5.2 长期优化(Long-term Optimization):需要代码修改

方案1:异步 EditLog 写入

思路

  • 在替换分区后立即释放写锁
  • 异步写入 EditLog
  • 需要处理一致性问题

方案2:表级别锁

思路

  • 将数据库级别锁改为表级别锁
  • 需要大量重构

方案3:批量 EditLog 写入

思路

  • 将多个操作合并为一个 EditLog
  • 减少 BDBJE 写入次数

📝 本章总结

核心要点回顾

  1. TRUNCATE 的本质:通过创建新分区替换旧分区实现快速清空
  2. 执行流程:7 个阶段,其中写锁替换阶段是关键阻塞点
  3. 锁机制:使用数据库级别的写锁,在等待持久化时阻塞其他操作
  4. 性能瓶颈:EditLog 写入耗时 1-5 秒,占总耗时的 80%
  5. 优化方向:降低频率、使用分区表、异步写入等

知识地图
TRUNCATE 语句 SQL 解析 语义分析 权限检查 元数据操作 读锁检查 创建新分区 写锁替换 EditLog 写入
(瓶颈) BE 节点同步

关键决策点

  • 是否使用 TRUNCATE:如果需要快速清空表,TRUNCATE 比 DELETE 快
  • 频率控制:单个数据库建议 ≤ 1-2 次/分钟
  • 分区策略:使用分区表,只清空需要的分区
  • 超时配置:根据实际情况调整超时时间
相关推荐
ClouGence2 小时前
从 0 到 1 构建 TDSQL MySQL 实时同步链路
数据库·分布式·sql·mysql
雨中飘荡的记忆2 小时前
Spring状态机深度解析:从入门到生产实战
java·spring
Kings902 小时前
线程池导致的 shutdown失败的完整排查过程
java·spring boot
在坚持一下我可没意见2 小时前
Spring 后端安全双剑(下篇):JWT 无状态认证 + 密码加盐加密实战
java·开发语言·spring boot·后端·安全·spring
期待のcode2 小时前
MyBatis-Plus通用枚举
java·数据库·后端·mybatis·springboot
天天摸鱼的java工程师2 小时前
支付回调处理,咱得整得 “幂等可靠” 不翻车
java·后端
⑩-2 小时前
Spring 事务失效
java·后端·spring
编织幻境的妖2 小时前
数据库物化视图与普通视图区别
数据库·oracle