ClickHouse 表的生老“并”死:表实例、表元数据与并发 DDL

当我们执行 SELECT * FROM db.table 时,ClickHouse 到底是怎么理解这个名字,并把它落到一个可用的运行时实例上的?


一、表实例:从 CREATE TABLE 到运行时实例

1. 表实例是什么

在 ClickHouse 里,一张表在运行时不是一个名字、也不是一组磁盘目录,而是一个具体的 IStorage 实例,并以 StoragePtr 的形式被系统持有和传递。

cpp 复制代码
// 代码路径:src/Storages/IStorage.h
// 函数:class IStorage

class IStorage : public std::enable_shared_from_this<IStorage>, public TypePromotion<IStorage>, public IHints<>
{
public:
    explicit IStorage(StorageID storage_id_, std::unique_ptr<StorageInMemoryMetadata> metadata_ = nullptr);

    virtual std::string getName() const = 0;
    StorageID getStorageID() const;
    virtual bool supportsSampling() const;
    virtual bool supportsFinal() const { return false; }
    virtual bool supportsPartitionBy() const { return false; }
    virtual bool supportsTTL() const { return false; }
    virtual bool supportsReplication() const { return false; }
    ...
};

这个定义已经把"表实例"说得很明确了。IStorage 不是一份静态描述,而是一个具体的实例:它带着自己的 StorageID、内存里的 metadata,以及一组和引擎能力直接相关的虚函数接口。后面查询、加锁、拿快照、检查引擎能力,最终都要落到这个实例上。

ENGINE = MergeTree 对运行时实例的影响,也正是在这一层体现出来。它不是简单给表打一个标签,而是决定 StorageFactory 最后创建出的实例类型。以 MergeTree 为例,继承关系可以概括成下面这张图:

也就是说,ENGINE = MergeTree 对应的是一个具体的 IStorage 实例。它由 StorageFactory 创建出来,随后被注册到所属 database 中。查询时系统拿到的也是这个实例本身。RENAMEALTER 会继续改动这张表的名字和定义,DETACH 会把它从系统里摘出去,ATTACH 则把它重新挂回系统;DROP 则意味着这张表从系统中退出,并进入最终的删除流程。

2. 启动恢复时,表如何被加载为实例

系统启动时,ClickHouse 要做的第一件事不是执行新的 CREATE TABLE,而是把已经存在的数据库和表重新恢复到运行时。这里必须把"数据库级加载"和"表级加载"拆开看。

整体调用路径可以先看成这样:

text 复制代码
Server.cpp
  -> loadMetadata(...)
     -> TablesLoader::loadTablesAsync(...)
        -> DatabaseOrdinary::loadTableFromMetadataAsync(...)
           -> makeLoadJob(...)
              -> loadTableFromMetadata(...)
                 -> createTableFromAST(...)
                 -> attachTable(...)

最外层入口在 server 启动流程里:

cpp 复制代码
// 代码路径:programs/server/Server.cpp

database_catalog.loadMarkedAsDroppedTables();
database_catalog.createBackgroundTasks();
load_metadata_tasks = loadMetadata(global_context, default_database, server_settings[ServerSetting::async_load_databases]);
database_catalog.startupBackgroundTasks();

数据库级入口才是 loadMetadata()。它先把 database 恢复出来,然后通过 TablesLoader::loadTablesAsync()startupTablesAsync() 分别创建"表加载"和"后置 startup"两类任务,并把它们启动起来。

cpp 复制代码
// 代码路径:src/Interpreters/loadMetadata.cpp
// 函数:loadMetadata(...)

LoadTaskPtrs loadMetadata(ContextMutablePtr context, const String & default_database_name, bool async_load_databases)
{
    ...
    for (const auto & [name, metadata_file] : databases)
    {
        loadDatabase(context, name, metadata_file, has_force_restore_data_flag);
        loaded_databases.insert({name, DatabaseCatalog::instance().getDatabase(name)});
    }

    auto mode = getLoadingStrictnessLevel(/* attach */ true, /* force_attach */ true, has_force_restore_data_flag, /* secondary */ false);
    TablesLoader loader{context, std::move(loaded_databases), mode};
    auto load_tasks = loader.loadTablesAsync();
    auto startup_tasks = loader.startupTablesAsync();

    if (async_load_databases)
    {
        scheduleLoad(load_tasks);
        scheduleLoad(startup_tasks);
        return joinTasks(load_tasks, startup_tasks);
    }

    waitLoad(TablesLoaderForegroundPoolId, load_tasks);
    waitLoad(TablesLoaderForegroundPoolId, startup_tasks);
    return {};
}

这里的顺序很清楚:先恢复 database,再创建两类任务,最后根据 async_load_databases 决定是异步调度,还是同步等待执行完成。loadTablesAsync() 负责把表对象真正加载进来;startupTablesAsync() 则负责实例创建之后的后置启动。这里主要梳理 loadTablesAsync();等后续讲数据部分时,再详细梳理 startupTablesAsync()

TablesLoader 的职责更贴近"表恢复"本身。它先读取并解析各个 database 下的表 metadata,构建依赖关系,再按依赖顺序为每张表创建异步加载任务。

cpp 复制代码
// 代码路径:src/Databases/TablesLoader.cpp
// 函数:TablesLoader::loadTablesAsync(...)

LoadTaskPtrs TablesLoader::loadTablesAsync(LoadJobSet load_after)
{
    ...
    for (auto & database_name : databases_to_load)
    {
        databases[database_name]->beforeLoadingMetadata(global_context, strictness_mode);
        databases[database_name]->loadTablesMetadata(global_context, metadata, isLoadingFromExistingMetadata(strictness_mode));
    }
    ...
    for (const auto & table_id : all_loading_dependencies.getTablesSortedByDependency())
    {
        const auto & path_and_query = metadata.parsed_tables[table_name];
        auto task = databases[table_name.database]->loadTableFromMetadataAsync(
            async_loader,
            ...,
            path_and_query.path,
            table_name,
            path_and_query.ast,
            strictness_mode);
        ...
    }
}

loadTablesAsync() 自己并不直接创建表实例,它只是调用各个 database 的 loadTableFromMetadataAsync(),先把"加载这张表"的任务包成一个 job。

cpp 复制代码
// 代码路径:src/Databases/DatabaseOrdinary.cpp
// 函数:DatabaseOrdinary::loadTableFromMetadataAsync(...)

auto job = makeLoadJob(
    std::move(load_after),
    TablesLoaderBackgroundLoadPoolId,
    fmt::format("load table {}", name.getFullName()),
    [this, local_context, file_path, name, ast, mode](AsyncLoader &, const LoadJobPtr &)
    {
        ...
        loadTableFromMetadata(local_context, file_path, name, ast, mode);
    });

return load_table[name.table] = makeLoadTask(async_loader, {job});

到了具体 database,表恢复才真正下钻到"从 AST 创建实例"。以 DatabaseOrdinary 为例,loadTableFromMetadata() 里会从 metadata 对应的 ASTCreateQuery 重新创建表,再 attach 回 database。

cpp 复制代码
// 代码路径:src/Databases/DatabaseOrdinary.cpp
// 函数:DatabaseOrdinary::loadTableFromMetadata(...)

auto [table_name, table] = createTableFromAST(
    query,
    name.database,
    getTableDataPath(query),
    local_context,
    mode);

attachTable(local_context, table_name, table, getTableDataPath(query));

createTableFromAST() 的核心只有一件事:把 ASTCreateQuery 重新喂给 StorageFactory,得到一个新的 IStorage 实例。

cpp 复制代码
// 代码路径:src/Databases/DatabaseOnDisk.cpp
// 函数:createTableFromAST(...)

return {
    ast->getTable(),
    StorageFactory::instance().get(*ast, table_data_path_relative, context, context->getGlobalContext(), columns, constraints, mode)};

所以,系统启动后,绝大多数"已经存在的表"并不是等第一次 SELECT 时才临时 new 出来的,而是在恢复阶段就会按 metadata 重新创建出来。这里只剩一个例外:某些 database engine 会在 loadTableFromMetadata() 里走 lazy load 分支,而不是立刻创建真实实例。

cpp 复制代码
// 代码路径:src/Databases/DatabaseOrdinary.cpp
// 函数:DatabaseOrdinary::loadTableFromMetadata(...)

if (shouldLazyLoad(query, mode))
{
    loadTableLazy(local_context, name, ast, mode);
    return;
}
cpp 复制代码
// 代码路径:src/Databases/DatabaseOrdinary.cpp
// 函数:DatabaseOrdinary::loadTableLazy(...)

auto proxy = std::make_shared<StorageTableProxy>(
    table_id, std::move(get_nested), std::move(columns));

attachTable(local_context, query.getTable(), proxy, table_data_path);

如果 database 开启了 lazy load,启动阶段挂进去的可能先是一个 StorageTableProxy,真正的底层实例等第一次访问时再展开。这也是为什么"启动恢复会实例化表"这句话只能说成常规路径,而不能说成绝对规则。

3. 注册:什么动作会把表实例挂进系统

注册的核心不是"再创建一次表",而是把已经创建出来的实例正式挂进系统。这里有两条路径。

第一条是启动恢复路径:

text 复制代码
loadMetadata(...)
  -> TablesLoader::loadTablesAsync(...)
     -> DatabaseOrdinary::loadTableFromMetadataAsync(...)
        -> loadTableFromMetadata(...)
           -> createTableFromAST(...)
           -> attachTable(...)

这条路径里,metadata 早就已经在磁盘上了,所以系统不需要再提交一份新的表定义。它要做的事情很直接:重新创建实例,然后调用 attachTable(...),把这张表重新挂回 database 的表映射。

cpp 复制代码
// 代码路径:src/Databases/DatabaseOrdinary.cpp
// 函数:DatabaseOrdinary::loadTableFromMetadata(...)

auto [table_name, table] = createTableFromAST(
    query,
    name.database,
    getTableDataPath(query),
    local_context,
    mode);

attachTable(local_context, table_name, table, getTableDataPath(query));

第二条是在线 CREATE TABLE / ATTACH TABLE 路径:

text 复制代码
InterpreterCreateQuery::doCreateTable(...)
  -> database->createTable(...)
     -> DatabaseOnDisk::createTable(...)
        -> commitCreateTable(...)
           -> attachTable(...)

这一条路径和启动恢复最大的区别在于:它不只是把实例挂进系统,还要把这次 DDL 对应的 metadata 一并提交下去。对应到代码上,commitCreateTable() 并不直接出现在解释器层,而是位于 DatabaseOnDisk::createTable(...) 内部。

cpp 复制代码
// 代码路径:src/Databases/DatabaseOnDisk.cpp
// 函数:DatabaseOnDisk::createTable(...)

String table_metadata_tmp_path = table_metadata_path + create_suffix;

{
    String statement = getObjectDefinitionFromCreateQuery(query);
    writeMetadataFile(...);
}

commitCreateTable(create, table, table_metadata_tmp_path, table_metadata_path, local_context);
removeDetachedPermanentlyFlag(local_context, table_name, table_metadata_path, false);

可以看到,commitCreateTable() 之前已经完成了一步关键准备:把表定义先写成临时 metadata 文件。commitCreateTable() 本身不是准备阶段,而是提交阶段。

它的调用流程很短,但正好把注册动作最核心的两步固定下来:

cpp 复制代码
// 代码路径:src/Databases/DatabaseOnDisk.cpp
// 函数:DatabaseOnDisk::commitCreateTable(...)

void DatabaseOnDisk::commitCreateTable(const ASTCreateQuery & query, const StoragePtr & table,
                                       const String & table_metadata_tmp_path, const String & table_metadata_path,
                                       ContextPtr query_context)
{
    ...
    attachTable(query_context, query.getTable(), table, getTableDataPath(query));
    db_disk->replaceFile(table_metadata_tmp_path, table_metadata_path);
}

这段代码主要在做三件事:

  • 调用 attachTable(...),把实例正式挂进 database 的表映射

  • .sql.tmp 原子替换成正式 metadata 文件,完成定义提交

  • 如果中途失败,就删掉临时文件,避免留下半提交状态

所以,启动恢复路径的注册核心是 attachTable(...);在线 CREATE TABLE / ATTACH TABLE 路径的注册核心则是 DatabaseOnDisk::createTable(...) -> commitCreateTable(...) -> attachTable(...) 这条链。我们看看 attachTable 做了什么。

cpp 复制代码
// 代码路径:src/Databases/DatabasesCommon.cpp
// 函数:DatabaseWithOwnTablesBase::attachTable(...)

void DatabaseWithOwnTablesBase::attachTable(ContextPtr /* context_ */, const String & table_name, const StoragePtr & table, const String &)
{
    std::lock_guard lock(mutex);
    attachTableUnlocked(table_name, table);
}

真正把实例放进 database 自己维护的 tables 映射里的,是下面这层 attachTableUnlocked(...)。如果表带 UUID,这一步还会同步更新 DatabaseCatalog 里的 UUID 映射关系。

cpp 复制代码
// 代码路径:src/Databases/DatabasesCommon.cpp
// 函数:DatabaseWithOwnTablesBase::attachTableUnlocked(...)

void DatabaseWithOwnTablesBase::attachTableUnlocked(const String & table_name, const StoragePtr & table)
{
    auto table_id = table->getStorageID();
    if (table_id.hasUUID())
        DatabaseCatalog::instance().addUUIDMapping(table_id.uuid, shared_from_this(), table);

    if (!tables.emplace(table_name, table).second)
        throw Exception(...);
}

所以,"注册"不是单独一条隐藏动作,而是实例创建后的下一步:把实例放进 database 的 tables map,并在需要时同步 UUID 映射。CREATE TABLEATTACH TABLE 会触发这一步,启动恢复也会走到这一步。

4. 查找:SELECT 时如何拿到表实例

SELECT * FROM db.table 进入查找路径后,最外层入口会先落到 DatabaseCatalog::getTable(...)

cpp 复制代码
// 代码路径:src/Interpreters/DatabaseCatalog.cpp
// 函数:DatabaseCatalog::getTable(...)

auto table = local_context->hasQueryContext() ?
    local_context->getQueryContext()->getOrCacheStorage(table_id, [&](){ return getTableImpl(table_id, local_context, &exc); }).second :
    getTableImpl(table_id, local_context, &exc).second;

如果当前有 query context,查找会先尝试走 getOrCacheStorage(...)。这里的 query cache 做的是同一条查询里的复用优化。命中缓存时,不必每次都重新按名字解析一遍。

cpp 复制代码
// 代码路径:src/Interpreters/Context.cpp
// 函数:Context::getOrCacheStorage(...)

if (auto it = shard.set.find(id); it != shard.set.end())
{
    DatabaseAndTable storage = DatabaseCatalog::instance().tryGetByUUID(it->uuid);
    if (storage.second)
        return storage;
}

auto storage = storage_getter();
...

这里缓存的也不是一个字符串结果,而是 StorageID -> UUID 的对应关系。后续命中时,再通过 UUID 把实例取回来。只有缓存未命中时,才继续调用 getTableImpl(...) 做真正的名字解析。

我们看 DatabaseCatalog::getTableImpl(...) 里的具体代码。如果当前 StorageID 已经带 UUID,就优先按 UUID 直接取表;否则就要先把对应的 database 找出来,再按 db.table 去取。

cpp 复制代码
// 代码路径:src/Interpreters/DatabaseCatalog.cpp
// 函数:DatabaseCatalog::getTableImpl(...)

if (table_id.hasUUID())
{
    auto db_and_table = tryGetByUUID(table_id.uuid);
    ...
    db_and_table.first->waitTableStarted(table_id.getTableName());
    return db_and_table;
}

...
DatabasePtr database;
{
    std::lock_guard lock{databases_mutex};
    auto it = databases.find(table_id.getDatabaseName());
    if (databases.end() != it)
        database = it->second;
}

...
table = database->getTable(table_id.table_name, context_);

到了具体 database,逻辑就简单得多。以 DatabaseWithOwnTablesBase 这一类 database 为例,tryGetTable(...) 会先等待表进入 started 状态,然后再从内部表映射里把 StoragePtr 取出来。

cpp 复制代码
// 代码路径:src/Databases/DatabasesCommon.cpp
// 函数:DatabaseWithOwnTablesBase::tryGetTable(...)

StoragePtr DatabaseWithOwnTablesBase::tryGetTable(const String & table_name, ContextPtr) const
{
    waitTableStarted(table_name);
    return tryGetTableNoWait(table_name);
}

这里之所以要先 waitTableStarted(...),是因为表虽然已经在加载路径里创建出来了,但在异步加载或启动阶段,它未必已经完全进入 started 状态。查询路径走到这里时,会先把对应的 load/startup 任务拉到前台并同步等待,避免拿到一个还没完成启动的表实例。

cpp 复制代码
// 代码路径:src/Databases/DatabaseOrdinary.cpp
// 函数:DatabaseOrdinary::waitTableStarted(...)

void DatabaseOrdinary::waitTableStarted(const String & name) const
{
    LoadTaskPtr task;
    {
        std::scoped_lock lock(mutex);
        if (auto it = startup_table.find(name); it != startup_table.end())
            task = it->second;
    }

    if (task)
        waitLoad(currentPoolOr(TablesLoaderForegroundPoolId), task);
}

真正取表实例的是下面这层 tryGetTableNoWait(...)

cpp 复制代码
// 代码路径:src/Databases/DatabasesCommon.cpp
// 函数:DatabaseWithOwnTablesBase::tryGetTableNoWait(...)

StoragePtr DatabaseWithOwnTablesBase::tryGetTableNoWait(const String & table_name) const
{
    std::lock_guard lock(mutex);
    auto it = tables.find(table_name);
    if (it != tables.end())
        return it->second;
    return {};
}

所以,查询并不会临时"再造"一个表实例。它只是顺着 DatabaseCatalog -> database -> tables 这条链,把前面已经注册进去的那个 StoragePtr 重新取出来。 这样同一条查询在执行过程中,看到的是稳定的实例引用。

5. 变更与注销:ALTER / RENAME / DROP / DETACH

表实例进入系统之后,并不会一直原样存在。用户可以通过 ALTERRENAMEDETACHDROP 控制它进入不同的状态。

先看 ALTER。例如:

sql 复制代码
ALTER TABLE db.t ADD COLUMN c UInt64 DEFAULT 0;

这条路径会先把表实例取出来,然后直接在 storage 层执行变更。

cpp 复制代码
// 代码路径:src/Interpreters/InterpreterAlterQuery.cpp
// 函数:InterpreterAlterQuery::executeToTable(...)

auto table_id = getContext()->tryResolveStorageID(alter);
StoragePtr table;

if (table_id)
{
    query_ptr->as<ASTAlterQuery &>().setDatabase(table_id.database_name);
    table = DatabaseCatalog::instance().tryGetTable(table_id, getContext());
}

auto segments = parseAlterCommandSegments(alter, table, getContext());
validateSegmentsCombination(segments);
validateMutationsAllowed(segments, database, getContext());
validateReplicatedDatabaseSegments(segments, database);
...
return runCommandSegments(segments, table, getContext());
cpp 复制代码
// 代码路径:src/Interpreters/InterpreterAlterQuery.cpp
// 函数:runCommandSegments(...)

auto alter_lock = table->lockForAlter(settings[Setting::lock_acquire_timeout]);
auto metadata_snapshot = table->getInMemoryMetadataPtr(context, true);
alter_commands->validate(table, context);
alter_commands->prepare(*metadata_snapshot, share_nested);
table->checkAlterIsPossible(*alter_commands, context);
table->alter(*alter_commands, context, alter_lock);

也就是说,像 ADD COLUMNDROP COLUMN 这样的 metadata 变更,会先变成 AlterCommands;而 UPDATEDELETE 这类需要 mutation 的动作,则会落到 MutationCommands。这些 segments 经过组合校验之后,才会继续进入 runCommandSegments(...),最后再落到具体 storage 的 alter(...)

这一段可以拆开看:

  • lockForAlter(...) 就是在这里上的表级 alter 锁。它直接保证的是同一张表上的 ALTER 串行执行;普通查询里走的 lockForShare(...) 不受它影响。至于 RENAMEDETACHDROP 这类 DDL,则主要通过 DDLGuard 以及更重的独占锁来协调

  • getInMemoryMetadataPtr(...) 先取当前内存里的 metadata snapshot,后面的 validate(...)prepare(...) 都是基于这份快照展开

  • checkAlterIsPossible(...) 负责做能力和约束检查

  • 最后真正落到 storage 自己的 table->alter(...)

这里先拿 metadata snapshot,是因为 ALTER 后面的准备和校验不能一边读当前元数据、一边又让这份元数据在过程中继续变化。先拿一份内存快照,后面的列检查、默认值展开、表达式准备,看到的就是同一版表定义。

如果再往下看 MergeTreealter 实现,这个过程会更清楚。StorageMergeTree::alter(...) 一开始就先取了两份内存 metadata:一份作为 old_metadata,一份作为后续修改的 new_metadata

cpp 复制代码
// 代码路径:src/Storages/StorageMergeTree.cpp
// 函数:StorageMergeTree::alter(...)

void StorageMergeTree::alter(
    const AlterCommands & commands,
    ContextPtr local_context,
    AlterLockHolder & table_lock_holder)
{
StorageInMemoryMetadata new_metadata = *getInMemoryMetadataPtr(local_context, false);
StorageInMemoryMetadata old_metadata = *getInMemoryMetadataPtr(local_context, false);

auto maybe_mutation_commands = commands.getMutationCommands(new_metadata, ...);
if (!maybe_mutation_commands.empty())
    delayMutationOrThrowIfNeeded(nullptr, local_context);

...
commands.apply(new_metadata, local_context);
...
setProperties(new_metadata, old_metadata, false, local_context);

try
{
    DatabaseCatalog::instance().getDatabase(table_id.database_name)->alterTable(local_context, table_id, new_metadata, /*validate_new_create_query=*/true);
}
catch (...)
{
    changeSettings(old_metadata.settings_changes, table_lock_holder);
    setProperties(old_metadata, new_metadata, false, local_context);
    throw;
}
...
if (!maybe_mutation_commands.empty())
    mutation_version = startMutation(maybe_mutation_commands, local_context);

if (!maybe_mutation_commands.empty() && query_settings[Setting::alter_sync] > 0)
    waitForMutation(mutation_version, false);
}

这里可以看出 ALTER 的基本动作:

  • 先从当前实例取出旧版 metadata

  • 在内存里生成一份 new_metadata

  • ALTER ADD COLUMN 这样的命令先应用到这份新 metadata 上

  • 调用 setProperties(new_metadata, ...)new_metadata 写回当前 IStorage 实例,也就是修改内存里的表定义

  • 再调用 database->alterTable(...),把新的表定义提交回 database 层

  • 如果 alterTable(...) 失败,再用 old_metadata 把已经改过的内存状态回滚回去

也就是说,这里其实分成两层:

  • commands.apply(new_metadata, ...) 先改表定义

  • setProperties(new_metadata, ...) 把新定义写回当前表对象

  • maybe_mutation_commands 再决定后面是否还要继续改已有数据

回滚动作就在 StorageMergeTree::alter(...)database->alterTable(...) 后面的 catch 分支。也就是说,前面已经改过的 settings 和 properties,如果在 metadata 提交阶段失败,会立刻按 old_metadata 恢复。

这里"修改内存实例"的落点就在 setProperties(...) 里。对 MergeTree 来说它会走到 MergeTreeData::setProperties(...),最终调用 setInMemoryMetadata(new_metadata) 把新 metadata 写回当前表对象:

cpp 复制代码
// 代码路径:src/Storages/MergeTree/MergeTreeData.cpp
// 函数:MergeTreeData::setProperties(...)

checkProperties(
    new_metadata,
    old_metadata,
    attach,
    ...);

setInMemoryMetadata(new_metadata);

再来看 database->alterTable。以 DatabaseOrdinary 为例,它不是直接改内存,而是先把原 metadata 文件读出来,改写成新的 CREATE TABLE 定义,再原子替换回去:

cpp 复制代码
// 代码路径:src/Databases/DatabaseOrdinary.cpp
// 函数:DatabaseOrdinary::alterTable(...)

String statement = readMetadataFile(db_disk, table_metadata_path);
...
ASTPtr ast = parseQuery(...);
...
applyMetadataChangesToCreateQuery(ast, metadata, local_context, validate_new_create_query);
...
statement = getObjectDefinitionFromCreateQuery(ast);
...
writeMetadataFile(
    db_disk,
    /*file_path=*/table_metadata_tmp_path,
    /*content=*/statement,
    /*fsync_metadata=*/getContext()->getSettingsRef()[Setting::fsync_metadata]);
...
commitAlterTable(table_id, table_metadata_tmp_path, table_metadata_path, statement, local_context);

这一段里的几个函数可以按顺序看:

  • readMetadataFile(...):先把当前表的 metadata 文件完整读出来。这里拿到的还是旧版本的 CREATE TABLE 定义文本。

  • parseQuery(...):把这段 SQL 文本重新解析成 AST。到这一步,后面的修改就不再是字符串替换,而是基于 AST 做结构化修改。

  • applyMetadataChangesToCreateQuery(...):把前面 storage 层已经准备好的 metadata 套回这棵 ASTCreateQuery。像列、索引、TTL、settings 这类定义变化,都是在这一步写回 AST。

  • getObjectDefinitionFromCreateQuery(ast):把改完后的 AST 再序列化回新的 CREATE TABLE 文本。

  • writeMetadataFile(...):先把新定义写到 .tmp 文件,而不是直接覆盖正式 metadata 文件。

  • commitAlterTable(...):最后再把 .tmp 文件原子替换成正式文件,完成这次 metadata 提交。

所以 alterTable(...) 的职责不是再去改一次 storage,而是把 storage 里已经准备好的 new_metadata 写回 database 的 metadata 文件。前面的 StorageMergeTree::alter(...) 更像是在内存里准备"新定义";这里才是把这份新定义真正落回磁盘上的 metadata。

再看 RENAME。例如:

sql 复制代码
RENAME TABLE db.t TO db.t_new;

它的入口不是 InterpreterAlterQuery,而是单独走 InterpreterRenameQuery -> database->renameTable(...)

cpp 复制代码
// 代码路径:src/Interpreters/InterpreterRenameQuery.cpp
// 函数:InterpreterRenameQuery::execute()

database->renameTable(
    getContext(),
    elem.from_table_name,
    *database_catalog.getDatabase(elem.to_database_name),
    elem.to_table_name,
    exchange_tables,
    rename.dictionary);

DatabaseOnDisk::renameTable(...) 为例,这条路径会更新表名、metadata 和注册关系:

cpp 复制代码
// 代码路径:src/Databases/DatabaseOnDisk.cpp
// 函数:DatabaseOnDisk::renameTable(...)

table_lock = table->lockExclusively(...);
detachTable(local_context, table_name);
...
table->rename(to_database.getTableDataPath(create), StorageID(create));
to_database.createTable(local_context, to_table_name, table, attach_query);

这里的 table->lockExclusively(...) 比前面的 lockForAlter(...) 更重。它对应的是 IStorage 里的独占锁,目的是确保在 RENAME 过程中,不会再有其他线程继续对这张表做操作。

它影响的范围,不只是 ALTER 这类 DDL,而是更广的一组动作:

  • 普通查询里拿的 lockForShare(...)

  • 后台 merge / mutation / cleanup 这类后台线程

  • ALTER

  • DROPTRUNCATE 这类需要等待表静下来的操作

拿到这把独占锁之后,这张表就会先进入"其他线程不能再继续操作"的状态,当前线程才继续做 detachTable(...)、改 metadata、改路径,再 createTable(...) 挂回去。

如果这时还有线程持有 lockForShare(...)lockExclusively(...) 不会立刻成功,而是要等这些读锁全部释放。因为两者底层用的是同一把 drop_lock:前者是读锁,后者是写锁。所以 RENAMEDROP 这类路径,都会先等当前查询或后台任务退出,再继续执行。

再看 DETACH / ATTACH。例如:

sql 复制代码
DETACH TABLE db.t;
ATTACH TABLE db.t;

DETACH 发生时,表会先从当前 database 的注册结构中移除:

cpp 复制代码
// 代码路径:src/Databases/DatabasesCommon.cpp
// 函数:DatabaseWithOwnTablesBase::detachTableUnlocked(...)

tables.erase(it);
table_storage->is_detached = true;

auto table_id = table_storage->getStorageID();
if (table_id.hasUUID())
    DatabaseCatalog::instance().removeUUIDMapping(table_id.uuid);

ATTACH TABLE db.t; 这条短语法会重新进入 createTable(...) 的 attach 分支,把已经存在的 metadata 对应的表重新挂回系统:

cpp 复制代码
// 代码路径:src/Databases/DatabaseOnDisk.cpp
// 函数:DatabaseOnDisk::createTable(...)

if (create.attach_short_syntax)
{
    assert(db_disk->existsFileOrDirectory(getObjectMetadataPath(table_name)));
    removeDetachedPermanentlyFlag(local_context, table_name, table_metadata_path, true);
    attachTable(local_context, table_name, table, getTableDataPath(create));
    return;
}

最后看 DROP。例如:

sql 复制代码
DROP TABLE db.t;

DROP 有两条执行路径:DatabaseOnDisk::dropTable(...)DatabaseAtomic::dropTable(...),分别对应 OrdinaryAtomic。这里可以先抓住一个最核心的区别:Ordinary 更接近"同步删",前台线程会一路把表从注册结构、metadata 文件和数据目录里清掉;Atomic 更接近"先逻辑删除,再异步最终清理",前台先把表标记为 dropped 并移出名字空间,后面的物理删除再交给后台任务完成。

对于 Ordinary 来说,表的身份和 db.table 绑定更紧,metadata 文件和数据路径也更贴着名字组织,所以 DROP 更像沿着这条名字路径同步删下去。而在 Atomic 里,表除了名字之外,还有一个独立的 UUID 作为更稳定的内部标识;RENAME 改变的是名字与实例的映射关系,但不会改变这张表的 UUID,也不会改变按 UUID 组织的数据路径。这样在名字已经解绑之后,系统仍然可以继续通过 UUID 追踪同一个表对象,并把后续的最终清理放到后台异步完成。

先看 Ordinary 这条路径:

cpp 复制代码
// 代码路径:src/Databases/DatabaseOnDisk.cpp
// 函数:DatabaseOnDisk::dropTable(...)

StoragePtr table = detachTable(local_context, table_name);
...
if (table)
{
    table->drop();
    table->is_dropped = true;
}
...
for (const auto & [disk_name, disk] : getContext()->getDisksMap())
{
    if (disk->isReadOnly() || !disk->existsDirectory(table_data_path_relative))
        continue;

    disk->removeRecursive(table_data_path_relative);
}
db_disk->removeFileIfExists(table_metadata_path_drop);

这条路径里,表会先 detach,然后执行 table->drop(),最后直接删除数据目录和 metadata 文件。

如果再往下看常见的 MergeTree 实现,table->drop() 主要做的是停掉表上的后台活动,然后清掉数据部分:

cpp 复制代码
// 代码路径:src/Storages/StorageMergeTree.cpp
// 函数:StorageMergeTree::drop()

void StorageMergeTree::drop()
{
    shutdown(true);
    dropAllData();
}

继续往下,dropAllData() 的主路径是先拿 parts 锁,把现有 part 标成 Deleting,再从磁盘和内存里清掉这些 part:

cpp 复制代码
// 代码路径:src/Storages/MergeTree/MergeTreeData.cpp
// 函数:MergeTreeData::dropAllData()

auto lock = lockParts();
...
modifyPartState(it, DataPartState::Deleting, lock);
...
clearPartsFromFilesystemImpl(all_parts, true, &part_names_failed);
...
data_parts_indexes.clear();
all_data_dropped = true;

也就是说,在 Ordinary 这条路径里,table->drop() 负责的是 storage 自己的数据清理;而外层的 DatabaseOnDisk::dropTable(...) 再继续把 metadata 文件和表目录删掉,整个 DROP 才算走完。

再看 Atomic 这条路径:

cpp 复制代码
// 代码路径:src/Databases/DatabaseAtomic.cpp
// 函数:DatabaseAtomic::dropTable(...)

auto table = tryGetTable(table_name, local_context);
...
table->dropInnerTableIfAny(sync, local_context);
...
dropTableImpl(local_context, table_name, sync);

我们再来看 dropTableImpl

cpp 复制代码
// 代码路径:src/Databases/DatabaseAtomic.cpp
// 函数:DatabaseAtomic::dropTableImpl(...)

db_disk->replaceFile(table_metadata_path, table_metadata_path_drop); /// Mark table as dropped
DatabaseOrdinary::detachTableUnlocked(table_name);
...
DatabaseCatalog::instance().enqueueDroppedTableCleanup(table->getStorageID(), table, db_disk, table_metadata_path_drop, sync);

这里的顺序是:先把 metadata 挪到 metadata_dropped,再从当前 database 的注册结构里摘掉表,然后把"最终清理"交给 DatabaseCatalog

接下来先进入 enqueueDroppedTableCleanup(...),它会把这张表放进待清理队列,并调度后台的 drop_task

cpp 复制代码
// 代码路径:src/Interpreters/DatabaseCatalog.cpp
// 函数:DatabaseCatalog::enqueueDroppedTableCleanup(...)

tables_marked_dropped.push_back(...);
...
tables_marked_dropped_ids.insert(table_id.uuid);
...
if (drop_task && (tables_marked_dropped.size() == 1 || ignore_delay))
    (*drop_task)->schedule();

这里的 drop_task 不是在这里临时创建的,而是在 DatabaseCatalog::createBackgroundTasks() 里提前创建好的后台任务:

cpp 复制代码
auto drop_task_holder = getContext()->getSchedulePool().createTask(
    StorageID::createEmpty(),
    "DatabaseCatalogDropTableTask",
    [this](){ this->dropTableDataTask(); });
drop_task = std::make_unique<BackgroundSchedulePoolTaskHolder>(std::move(drop_task_holder));

enqueueDroppedTableCleanup(...) 把表放进 tables_marked_dropped,然后调度 drop_task

dropTableDataTask() 里先调用 getTablesToDrop() 取出当前可以清理的表,再把结果交给 dropTablesParallel(...);而 dropTablesParallel(...) 里真正执行的是 dropTableFinally(...)

cpp 复制代码
// 代码路径:src/Interpreters/DatabaseCatalog.cpp
// 函数:DatabaseCatalog::dropTableFinally(...)

disk->removeRecursive(data_path);
...
db_disk->removeFileIfExists(fs::path(table.metadata_path));
removeUUIDMappingFinally(table.table_id.uuid);

DatabaseCatalog 的注释把这几类动作区分得很直白:attach 时调用 addUUIDMapping(...),detach 时调用 removeUUIDMapping(...),真正 drop 完并移除磁盘数据之后,才调用 removeUUIDMappingFinally(...)

cpp 复制代码
// 代码路径:src/Interpreters/DatabaseCatalog.h

/// If table has UUID, addUUIDMapping(...) must be called when table attached to some database
/// removeUUIDMapping(...) must be called when it detached,
/// and removeUUIDMappingFinally(...) must be called when table is dropped and its data removed from disk.

到这里,第一章真正想回答的问题就完整了。表实例是 IStorage;它可以在启动恢复阶段被重新创建,也可以在在线 CREATE TABLE 时被新建出来;创建之后要先注册进 database 和 DatabaseCatalog,查询才能稳定地把 db.table 解析成一个 StoragePtr;而 ALTERRENAMEDETACHDROP 则分别对应实例状态变化、退出注册表以及最终销毁的不同路径。


二、表元数据:区分数据元数据

1. 表元数据字段映射

CREATE TABLE 的角度看,表元数据本质上就是"哪些表级定义会被解析并挂到表实例上"。对 ClickHouse 来说,这份内存表示就是 StorageInMemoryMetadata

代码路径:src/Storages/StorageInMemoryMetadata.h

函数:struct StorageInMemoryMetadata

StorageInMemoryMetadata 字段 CREATE TABLE 里的对应片段 说明
columns 列定义:col_name Type ... 列名、类型、默认表达式、列注释等
constraints CONSTRAINT ... 约束(主要用于 MergeTree)
secondary_indices INDEX ... 二级索引(主要用于 MergeTree)
projections PROJECTION ... projection 定义(主要用于 MergeTree)
partition_key PARTITION BY ... 分区键表达式
sorting_key ORDER BY ... 排序键表达式(MergeTree 必备)
primary_key PRIMARY KEY ... 主键表达式;缺省时可能等同 sorting_key
sampling_key SAMPLE BY ... 抽样键表达式
unique_key UNIQUE KEY ... 实验特性(如果启用)
table_ttl / column_ttls_by_name TTL ... / TTL col ... 表级/列级 TTL
settings_changes SETTINGS ... 表级 settings(例如 MergeTree settings)
comment COMMENT '...' 表注释
select AS SELECT ...(View/MV) View/MV 的 select 描述
refresh REFRESH ...(MV) MV refresh 参数
definer / sql_security_type DEFINER ... / SQL SECURITY ... 与 view/MV 的安全语义相关
virtuals 无直接 SQL 对应 由 storage 在 attach/启动时构造(例如 MergeTree 的 _part_partition_id);不写入 CREATE TABLE 文本
metadata_version 无直接 SQL 对应 主要由 ReplicatedMergeTree 维护

2. 表元数据 vs 数据元数据

这里专门把"表元数据"与"数据元数据(数据状态)"拆开,否则后面讨论 ALTERRENAMEDROP 时很容易把"表定义变了"与"数据组织变了"混在一起。

表元数据:以 StorageInMemoryMetadata 为中心,描述 schema 与引擎定义,决定 parser/optimizer/executor 如何理解这张表。典型内容包括:columnspartition_keysorting_keyprimary_keysecondary_indicestable_ttlsettings_changesvirtuals 也属于这一层,但它没有直接的 SQL 对应,通常由 storage 在 attach/启动时构造,例如 MergeTree_part_partition_id

数据元数据(数据状态):描述"有哪些 data part、各自处在什么状态、后台任务推进到哪一步"。典型内容包括:active part 集合、part 状态迁移(Active/Outdated/Deleting)、mutation 版本与队列、merge 任务,以及每个 part 目录里随数据存在的描述文件(例如 columns.txtchecksums.txtprimary.idx 等)。

两者的差异不在于"是否落盘",而在于"管理主体与稳定性语义":表元数据围绕 CREATE TABLE 定义(可序列化/可重放);数据元数据围绕 data part 与后台任务(强运行态、可变、可并发推进)。


三、并发 DDL:从实现目标理解源码

1. 语义分组

撇开 DCL 与运维类语句,ClickHouse 并发与锁的语义可以压缩成下面 5 组:

分组 典型语句 互斥核心
对象级 DDL(除 ALTER CREATE / RENAME / DROP / DETACH / ATTACH / TRUNCATE 表名/注册关系与实例摘挂
ALTER-元数据变更 ADD COLUMN / DROP COLUMN / MODIFY COLUMN 同表表元数据变更串行化 + 多版本可见性
ALTER-mutation ALTER ... UPDATE/DELETE、语法上的 UPDATE/DELETE 稳定的表元数据视图 + mutation 推进
SELECT SELECT 表实例生命周期与 snapshot 稳定
INSERT INSERT 表实例生命周期与 snapshot 稳定

2. 用户视角的并发期望

DDL vs DDL:

  • 对不同表的 DDL,用户侧的直觉期望是"互不影响、可并行推进"。

  • 对同一表的 DDL,用户侧的直觉期望是"可线性化":并发执行的最终效果等价于某个确定的串行顺序。

  • 对涉及多个表名的 DDL(典型是 RENAME),用户侧的直觉期望是"整体原子",不接受中间状态被其它 DDL 观察到。

DDL vs SELECT/INSERT/ALTER-mutation:

  • 对象级 DDL 与正在进行的读写,用户侧的直觉期望是"读写要么自然跑完,要么 DDL 等待/失败",不接受"读写一半对象被摘走"。

  • ALTER-元数据变更与读写并发,用户侧的直觉期望是"既有读写沿旧定义完成,新读写看到新定义",不要求把所有读写赶走。

  • ALTER-mutation 的直觉期望是"把变更意图提交给系统并推进",对读写可见性与最终一致性语义由 mutation 子系统定义。

3. 数据库内核必须满足的约束

对象级 DDL 的内核约束分两层:

  • 表名级一致性:db.table 的注册关系必须可线性化,否则会出现"先查到表,再发现表不存在/指向不同对象"的异常路径。

  • 表实例级一致性:DROP/DETACH/RENAME 等摘挂操作必须在实例层面等待既有使用者退出,并阻止新的使用者进入,保证生命周期边界清晰。

ALTER-元数据变更的内核约束也分两层:

  • 同表 ALTER 串行化:同一表上的表元数据变更不可并发写入,否则难以定义最终 schema。

  • 多版本可见性:既有读写沿旧 snapshot 工作,新读写获得新 snapshot;避免通过全局阻塞将 ALTER 退化为"摘表类 DDL"。

ALTER-mutation 的内核约束聚焦在"计划与推进":

  • 建计划阶段需要稳定的表元数据视图。

  • 执行推进由 mutation 子系统接管;它不是一个"立即重写数据"的同步 DML,而是一个可并发推进的后台状态机。

回到 ClickHouse 源码,DDLGuard 负责表名级一致性,避免同名对象级 DDL 打散注册关系;drop_lock 负责表实例级一致性,保证实例摘挂与既有使用者之间的生命周期边界;alter_lock 负责同表 ALTER-元数据变更串行化。下面分别看它们的实现。

4. DDLGuard

DDLGuard 的职责很单纯:把同名对象级 DDL 串行化。

RENAME 为例,整体调用路径可以先看成这样:

text 复制代码
InterpreterRenameQuery::execute
  -> DatabaseCatalog::getDDLGuard
     -> DDLGuard::DDLGuard

InterpreterRenameQuery::execute 先收集本次 RENAME 涉及的所有表名,再逐个调用 DatabaseCatalog::getDDLGuard 获取 guard。

cpp 复制代码
// 代码路径:src/Interpreters/InterpreterRenameQuery.cpp
// 函数:InterpreterRenameQuery::execute

/// Must do it in consistent order.
for (auto & table_guard : table_guards)
    table_guard.second = database_catalog.getDDLGuard(
        table_guard.first.database_name,
        table_guard.first.table_name,
        nullptr);

DatabaseCatalog::getDDLGuard 本身并不实现阻塞逻辑,它的职责是创建一个 DDLGuard 对象,把真正的互斥语义交给构造函数。

cpp 复制代码
// 代码路径:src/Interpreters/DatabaseCatalog.cpp
// 函数:DatabaseCatalog::getDDLGuard

guard = std::make_unique<DDLGuard>(
    db_guard.table_guards,
    db_guard.database_ddl_mutex,
    std::move(lock),
    table,
    database);

DDLGuard::DDLGuard 的关键点只有两步:先在 map 里按表名找到或创建对应 entry,再对这把 entry 里的 mutex 做 unique_lock。因此,同名 DDL 的阻塞发生在构造阶段,而不是发生在 storage 层。

cpp 复制代码
// 代码路径:src/Interpreters/DatabaseCatalog.cpp
// 函数:DDLGuard::DDLGuard

it = map.emplace(elem, Entry{std::make_unique<std::mutex>(), 0}).first;
++it->second.counter;
...
table_lock = std::unique_lock(*it->second.mutex);

这也解释了为什么 DDLGuard 本质上是"表名级锁":它保护的是 db.table 这层名字/注册关系,而不是 IStorage 实例本身。

DROP/DETACH 也是同一模式:在 interpreter 层直接调用 DatabaseCatalog::getDDLGuard,先把表名级互斥建立起来,再继续往下执行。

cpp 复制代码
// 代码路径:src/Interpreters/InterpreterDropQuery.cpp
// 函数:InterpreterDropQuery::executeToTableImpl

/// NOTE: it does not contain UUID, we will resolve it with locked DDLGuard
auto ddl_guard = (!query.no_ddl_lock
    ? DatabaseCatalog::instance().getDDLGuard(table_id.database_name, table_id.table_name, nullptr)
    : nullptr);

RENAME 这类一次涉及多个表名的语句,还必须按一致顺序获取多把 DDLGuard,否则会出现锁顺序反转:例如线程 A 先拿 t1 再等 t2,线程 B 先拿 t2 再等 t1,两边互相等待,最终形成死锁。这里使用 std::map 保存 table_guards,天然按字典序迭代,因此所有线程对同一组表名都会按相同顺序加锁。

5. drop_lockalter_lock

先看 drop_lock。它主要出现在 SELECTINSERT,以及对象级 DDL 的实例摘挂路径上。

SELECT 为例,整体调用路径可以先看成这样:

text 复制代码
TableNode::TableNode
  -> IStorage::lockForShare
  -> IStorage::getInMemoryMetadataPtr
  -> IStorage::getStorageSnapshot

TableNode::TableNode 在建表节点时,直接拿 lockForShare,然后基于当前 metadata 构造 StorageSnapshot

cpp 复制代码
// 代码路径:src/Analyzer/TableNode.cpp
// 函数:TableNode::TableNode

storage_->lockForShare(...);
storage_->getStorageSnapshot(storage_->getInMemoryMetadataPtr(context, false), context);

INSERT 是同一模式。它先拿 lockForShare,再读取 StorageInMemoryMetadata 的 snapshot,随后构造写入所需的 sample block 与 pipeline。

text 复制代码
InterpreterInsertQuery::execute
  -> IStorage::lockForShare
  -> IStorage::getInMemoryMetadataPtr
cpp 复制代码
// 代码路径:src/Interpreters/InterpreterInsertQuery.cpp
// 函数:InterpreterInsertQuery::execute

auto table_lock = table->lockForShare(context->getInitialQueryId(), settings[Setting::lock_acquire_timeout]);
...
auto metadata_snapshot = table->getInMemoryMetadataPtr(context, false);

IStorage::lockForShare 的实现直接对应 drop_lock 的读锁获取:

cpp 复制代码
// 代码路径:src/Storages/IStorage.cpp
// 函数:IStorage::lockForShare

TableLockHolder result = tryLockTimed(drop_lock, RWLockImpl::Read, query_id, acquire_timeout);
...
if (!table_id.hasUUID() && (is_dropped || is_detached))
    throw Exception(ErrorCodes::TABLE_IS_DROPPED, ...);

因此 SELECT/INSERT 可以并发共享,而 RENAME/DROP/DETACH 这类实例摘挂操作必须等待所有读锁退出。

对象级 DDL 的写锁调用点需要和上一节的 DDLGuard 放在一条主线上看。以 RENAME 为例,它的顺序是先获取 DDLGuard,再进入 DatabaseOnDisk::renameTable 获取 lockExclusively

text 复制代码
InterpreterRenameQuery::execute
  -> DatabaseCatalog::getDDLGuard
     -> DDLGuard::DDLGuard
  -> DatabaseOnDisk::renameTable
     -> IStorage::lockExclusively

前半段负责表名级串行化,后半段负责表实例级独占;两者的先后顺序也在这里固定下来。

cpp 复制代码
// 代码路径:src/Databases/DatabaseOnDisk.cpp
// 函数:DatabaseOnDisk::renameTable

table_lock = table->lockExclusively(local_context->getCurrentQueryId(), local_context->getSettingsRef()[Setting::lock_acquire_timeout]);
...
detachTable(local_context, table_name);

DROP/DETACH 也是同一模式,只是调用链更长一些。它不是直接进入 executeToTableImpl,而是先从 interpreter 入口一路分发下来:

text 复制代码
InterpreterDropQuery::execute
  -> InterpreterDropQuery::executeSingleDropQuery
     -> InterpreterDropQuery::executeToTable
        -> InterpreterDropQuery::executeToTableImpl
           -> DatabaseCatalog::getDDLGuard
              -> DDLGuard::DDLGuard
           -> IStorage::lockExclusively
cpp 复制代码
// 代码路径:src/Interpreters/InterpreterDropQuery.cpp
// 函数:InterpreterDropQuery::executeToTableImpl

auto ddl_guard = (!query.no_ddl_lock ? DatabaseCatalog::instance().getDDLGuard(table_id.database_name, table_id.table_name, nullptr) : nullptr);
...
if (database->getUUID() == UUIDHelpers::Nil)
    table_lock = table->lockExclusively(context_->getCurrentQueryId(), context_->getSettingsRef()[Setting::lock_acquire_timeout]);

IStorage::lockExclusively 的实现则直接对应 drop_lock 的写锁获取:

cpp 复制代码
// 代码路径:src/Storages/IStorage.cpp
// 函数:IStorage::lockExclusively

TableExclusiveLockHolder result;
result.drop_lock = tryLockTimed(drop_lock, RWLockImpl::Write, query_id, acquire_timeout);
...
if (is_dropped || is_detached)
    throw Exception(ErrorCodes::TABLE_IS_DROPPED, ...);

再看 alter_lock。它服务的不是对象级 DDL,而是同一表上的 ALTER-元数据变更。

ALTER-元数据变更为例,整体调用路径可以先看成这样:

text 复制代码
InterpreterAlterQuery::executeToTable
  -> IStorage::lockForShare
  -> runCommandSegments
     -> IStorage::lockForAlter
     -> IStorage::alter

InterpreterAlterQuery::executeToTable 先拿 lockForShare,保证表实例与当前 metadata 视图稳定;随后在 runCommandSegments 中,对真正的 AlterCommands 获取 lockForAlter,再调用 table->alter

cpp 复制代码
// 代码路径:src/Interpreters/InterpreterAlterQuery.cpp
// 函数:InterpreterAlterQuery::executeToTable

auto table_lock = table->lockForShare(getContext()->getCurrentQueryId(), settings[Setting::lock_acquire_timeout]);
...
return runCommandSegments(segments, table, getContext());
cpp 复制代码
// 代码路径:src/Interpreters/InterpreterAlterQuery.cpp
// 函数:runCommandSegments

auto alter_lock = table->lockForAlter(settings[Setting::lock_acquire_timeout]);
...
table->alter(*alter_commands, context, alter_lock);

drop_lockalter_lock 的类型差异在定义处就已经固定下来:前者是读写锁,后者是互斥锁。

cpp 复制代码
// 代码路径:src/Storages/IStorage.h
// 函数:class IStorage(字段注释)

/// Allows to execute only one simultaneous alter query.
mutable std::timed_mutex alter_lock;

/// table is not dropped have to table this lock for read (lockForShare).
/// DROP-like queries take this lock for write (lockExclusively), to be sure
/// that all table threads finished.
mutable RWLock drop_lock = RWLockImpl::create();

因此,第 5 节里两把锁的分工可以直接收束成两句:drop_lock 负责表实例生命周期边界,协调 SELECT/INSERT 与对象级 DDL 的共享/独占互斥;alter_lock 负责同表 ALTER-元数据变更串行化,不参与对象级 DDL 的表名级互斥。

6. ClickHouse 的互斥结果

一句话总结:DDLGuard 负责把对象级 DDL 在表名级串行化;drop_lockSELECT/INSERT/ALTER-mutation 与对象级 DDL 之间提供表实例级的一致性边界(共享/独占互斥);alter_lock 负责把同一表上的 ALTER-元数据变更串行化。

同步原语视角(锁原语):

机制 锁住的对象/粒度 主要用途 会阻塞谁 会被谁阻塞
DDLGuardDatabaseCatalog::getDDLGuard 表名级:db.table(以及数据库级:db 串行化同名 DDL,避免注册关系/名字相关变更被打散;RENAME 一次拿多把(按字典序)避免死锁 同一个 db.table 上的其它 DDL 同名 DDLGuard 持有者
drop_lock 读锁(lockForShare 表实例级:IStoragedrop_lock SELECT/INSERT/ALTER-mutation 建立稳定的 snapshot drop_lock 写锁(lockExclusively drop_lock 写锁
drop_lock 写锁(lockExclusively 表实例级:IStoragedrop_lock 等待所有 lockForShare 退出,并禁止新进入,然后执行 RENAME/DROP/DETACH 等需要摘表的动作 新的 lockForShare(以及其它写锁) 现有的 lockForShare(以及其它写锁)
alter_locklockForAlter 表实例级:ALTER 专用锁 串行化同一表上的 ALTER 类元数据变更 其它 ALTER 其它 ALTER

语句视角(互斥关系):

语义分组 是否持有 DDLGuard 是否持有 alter_lock 是否持有 drop_lock 读锁 是否持有 drop_lock 写锁 主要互斥关系
对象级 DDL(除 ALTER 视操作而定(涉及实例摘挂则是) 同名 DDL 在表名级串行;涉及实例摘挂则写锁等待所有读锁退出
ALTER-元数据变更 通常否(replicated 入队路径除外) 通常是 通常否 同表 ALTER 串行;读写沿旧 snapshot 继续,新读写看到新 snapshot
ALTER-mutation(UPDATE/DELETE 对象级 DDL 的写锁要等读锁释放;mutation 由后台推进
SELECT 对象级 DDL 的写锁要等读锁释放
INSERT 对象级 DDL 的写锁要等读锁释放
相关推荐
数据库小学妹1 小时前
MySQL redo刷盘实测:innodb_flush_log_at_trx_commit与故障矩阵
运维·数据库·mysql
哈__1 小时前
破除数据库排障碎片化:一体化全链路故障根因诊断实践
数据库
严同学正在努力1 小时前
SQL Server 15.0.2000.5(2019 CU5)性能分析基线构建实战教程
数据库·ai·oracle·dba
MC丶科1 小时前
软考架构师90天冲刺|DAY44·Redis高级应用
数据库·数据仓库·redis·缓存·oracle·容器·规格说明书
Boop_wu2 小时前
[redis] redis 快速入门
数据库·redis·github
reasonsummer3 小时前
【办公类-115-01】20260906育儿知识(家园小报)批量制作(2026年9月-2027年6月)
开发语言·数据库·c#
AC赳赳老秦3 小时前
环保监测公开数据应用:OpenClaw 抓取空气与水质公开监测数据,开展区域环境质量趋势分析
大数据·数据库·人工智能·python·php·deepseek·openclaw
Zhu7583 小时前
部署安全的pg数据库,搭配pgadmin实现web界面管理
前端·数据库·安全
她说..3 小时前
Redis项目实战整理
数据库·redis·缓存