当我们执行 SELECT * FROM db.table 时,ClickHouse 到底是怎么理解这个名字,并把它落到一个可用的运行时实例上的?
一、表实例:从 CREATE TABLE 到运行时实例
1. 表实例是什么
在 ClickHouse 里,一张表在运行时不是一个名字、也不是一组磁盘目录,而是一个具体的 IStorage 实例,并以 StoragePtr 的形式被系统持有和传递。
// 代码路径: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 中。查询时系统拿到的也是这个实例本身。RENAME、ALTER 会继续改动这张表的名字和定义,DETACH 会把它从系统里摘出去,ATTACH 则把它重新挂回系统;DROP 则意味着这张表从系统中退出,并进入最终的删除流程。
2. 启动恢复时,表如何被加载为实例
系统启动时,ClickHouse 要做的第一件事不是执行新的 CREATE TABLE,而是把已经存在的数据库和表重新恢复到运行时。这里必须把“数据库级加载”和“表级加载”拆开看。
整体调用路径可以先看成这样:
Server.cpp
-> loadMetadata(...)
-> TablesLoader::loadTablesAsync(...)
-> DatabaseOrdinary::loadTableFromMetadataAsync(...)
-> makeLoadJob(...)
-> loadTableFromMetadata(...)
-> createTableFromAST(...)
-> attachTable(...)
最外层入口在 server 启动流程里:
// 代码路径: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”两类任务,并把它们启动起来。
// 代码路径: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,构建依赖关系,再按依赖顺序为每张表创建异步加载任务。
// 代码路径: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。
// 代码路径: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。
// 代码路径: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 实例。
// 代码路径: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 分支,而不是立刻创建真实实例。
// 代码路径:src/Databases/DatabaseOrdinary.cpp
// 函数:DatabaseOrdinary::loadTableFromMetadata(...)
if (shouldLazyLoad(query, mode))
{
loadTableLazy(local_context, name, ast, mode);
return;
}
// 代码路径: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. 注册:什么动作会把表实例挂进系统
注册的核心不是“再创建一次表”,而是把已经创建出来的实例正式挂进系统。这里有两条路径。
第一条是启动恢复路径:
loadMetadata(...)
-> TablesLoader::loadTablesAsync(...)
-> DatabaseOrdinary::loadTableFromMetadataAsync(...)
-> loadTableFromMetadata(...)
-> createTableFromAST(...)
-> attachTable(...)
这条路径里,metadata 早就已经在磁盘上了,所以系统不需要再提交一份新的表定义。它要做的事情很直接:重新创建实例,然后调用 attachTable(...),把这张表重新挂回 database 的表映射。
// 代码路径: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 路径:
InterpreterCreateQuery::doCreateTable(...)
-> database->createTable(...)
-> DatabaseOnDisk::createTable(...)
-> commitCreateTable(...)
-> attachTable(...)
这一条路径和启动恢复最大的区别在于:它不只是把实例挂进系统,还要把这次 DDL 对应的 metadata 一并提交下去。对应到代码上,commitCreateTable() 并不直接出现在解释器层,而是位于 DatabaseOnDisk::createTable(...) 内部。
// 代码路径: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() 本身不是准备阶段,而是提交阶段。
它的调用流程很短,但正好把注册动作最核心的两步固定下来:
// 代码路径: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 做了什么。
// 代码路径: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 映射关系。
// 代码路径: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 TABLE 和 ATTACH TABLE 会触发这一步,启动恢复也会走到这一步。
4. 查找:SELECT 时如何拿到表实例
SELECT * FROM db.table 进入查找路径后,最外层入口会先落到 DatabaseCatalog::getTable(...):
// 代码路径: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 做的是同一条查询里的复用优化。命中缓存时,不必每次都重新按名字解析一遍。
// 代码路径: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 去取。
// 代码路径: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 取出来。
// 代码路径: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 任务拉到前台并同步等待,避免拿到一个还没完成启动的表实例。
// 代码路径: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(...):
// 代码路径: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
表实例进入系统之后,并不会一直原样存在。用户可以通过 ALTER、RENAME、DETACH、DROP 控制它进入不同的状态。
先看 ALTER。例如:
ALTER TABLE db.t ADD COLUMN c UInt64 DEFAULT 0;
这条路径会先把表实例取出来,然后直接在 storage 层执行变更。
// 代码路径: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());
// 代码路径: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 COLUMN、DROP COLUMN 这样的 metadata 变更,会先变成 AlterCommands;而 UPDATE、DELETE 这类需要 mutation 的动作,则会落到 MutationCommands。这些 segments 经过组合校验之后,才会继续进入 runCommandSegments(...),最后再落到具体 storage 的 alter(...)。
这一段可以拆开看:
-
lockForAlter(...)就是在这里上的表级 alter 锁。它直接保证的是同一张表上的ALTER串行执行;普通查询里走的lockForShare(...)不受它影响。至于RENAME、DETACH、DROP这类 DDL,则主要通过DDLGuard以及更重的独占锁来协调 -
getInMemoryMetadataPtr(...)先取当前内存里的 metadata snapshot,后面的validate(...)、prepare(...)都是基于这份快照展开 -
checkAlterIsPossible(...)负责做能力和约束检查 -
最后真正落到 storage 自己的
table->alter(...)
这里先拿 metadata snapshot,是因为 ALTER 后面的准备和校验不能一边读当前元数据、一边又让这份元数据在过程中继续变化。先拿一份内存快照,后面的列检查、默认值展开、表达式准备,看到的就是同一版表定义。
如果再往下看 MergeTree 的 alter 实现,这个过程会更清楚。StorageMergeTree::alter(...) 一开始就先取了两份内存 metadata:一份作为 old_metadata,一份作为后续修改的 new_metadata。
// 代码路径: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 写回当前表对象:
// 代码路径:src/Storages/MergeTree/MergeTreeData.cpp
// 函数:MergeTreeData::setProperties(...)
checkProperties(
new_metadata,
old_metadata,
attach,
...);
setInMemoryMetadata(new_metadata);
再来看 database->alterTable。以 DatabaseOrdinary 为例,它不是直接改内存,而是先把原 metadata 文件读出来,改写成新的 CREATE TABLE 定义,再原子替换回去:
// 代码路径: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。例如:
RENAME TABLE db.t TO db.t_new;
它的入口不是 InterpreterAlterQuery,而是单独走 InterpreterRenameQuery -> database->renameTable(...):
// 代码路径: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 和注册关系:
// 代码路径: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 -
DROP、TRUNCATE这类需要等待表静下来的操作
拿到这把独占锁之后,这张表就会先进入“其他线程不能再继续操作”的状态,当前线程才继续做 detachTable(...)、改 metadata、改路径,再 createTable(...) 挂回去。
如果这时还有线程持有 lockForShare(...),lockExclusively(...) 不会立刻成功,而是要等这些读锁全部释放。因为两者底层用的是同一把 drop_lock:前者是读锁,后者是写锁。所以 RENAME、DROP 这类路径,都会先等当前查询或后台任务退出,再继续执行。
再看 DETACH / ATTACH。例如:
DETACH TABLE db.t;
ATTACH TABLE db.t;
DETACH 发生时,表会先从当前 database 的注册结构中移除:
// 代码路径: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 对应的表重新挂回系统:
// 代码路径: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。例如:
DROP TABLE db.t;
DROP 有两条执行路径:DatabaseOnDisk::dropTable(...) 和 DatabaseAtomic::dropTable(...),分别对应 Ordinary 和 Atomic。这里可以先抓住一个最核心的区别:Ordinary 更接近“同步删”,前台线程会一路把表从注册结构、metadata 文件和数据目录里清掉;Atomic 更接近“先逻辑删除,再异步最终清理”,前台先把表标记为 dropped 并移出名字空间,后面的物理删除再交给后台任务完成。
对于 Ordinary 来说,表的身份和 db.table 绑定更紧,metadata 文件和数据路径也更贴着名字组织,所以 DROP 更像沿着这条名字路径同步删下去。而在 Atomic 里,表除了名字之外,还有一个独立的 UUID 作为更稳定的内部标识;RENAME 改变的是名字与实例的映射关系,但不会改变这张表的 UUID,也不会改变按 UUID 组织的数据路径。这样在名字已经解绑之后,系统仍然可以继续通过 UUID 追踪同一个表对象,并把后续的最终清理放到后台异步完成。
先看 Ordinary 这条路径:
// 代码路径: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() 主要做的是停掉表上的后台活动,然后清掉数据部分:
// 代码路径:src/Storages/StorageMergeTree.cpp
// 函数:StorageMergeTree::drop()
void StorageMergeTree::drop()
{
shutdown(true);
dropAllData();
}
继续往下,dropAllData() 的主路径是先拿 parts 锁,把现有 part 标成 Deleting,再从磁盘和内存里清掉这些 part:
// 代码路径: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 这条路径:
// 代码路径: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。
// 代码路径: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:
// 代码路径: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() 里提前创建好的后台任务:
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(...)。
// 代码路径: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(...)。
// 代码路径: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;而 ALTER、RENAME、DETACH、DROP 则分别对应实例状态变化、退出注册表以及最终销毁的不同路径。
二、表元数据:区分数据元数据
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 数据元数据
这里专门把“表元数据”与“数据元数据(数据状态)”拆开,否则后面讨论 ALTER、RENAME、DROP 时很容易把“表定义变了”与“数据组织变了”混在一起。
表元数据:以 StorageInMemoryMetadata 为中心,描述 schema 与引擎定义,决定 parser/optimizer/executor 如何理解这张表。典型内容包括:columns、partition_key、sorting_key、primary_key、secondary_indices、table_ttl、settings_changes。virtuals 也属于这一层,但它没有直接的 SQL 对应,通常由 storage 在 attach/启动时构造,例如 MergeTree 的 _part、_partition_id。
数据元数据(数据状态):描述“有哪些 data part、各自处在什么状态、后台任务推进到哪一步”。典型内容包括:active part 集合、part 状态迁移(Active/Outdated/Deleting)、mutation 版本与队列、merge 任务,以及每个 part 目录里随数据存在的描述文件(例如 columns.txt、checksums.txt、primary.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 为例,整体调用路径可以先看成这样:
InterpreterRenameQuery::execute
-> DatabaseCatalog::getDDLGuard
-> DDLGuard::DDLGuard
InterpreterRenameQuery::execute 先收集本次 RENAME 涉及的所有表名,再逐个调用 DatabaseCatalog::getDDLGuard 获取 guard。
// 代码路径: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 对象,把真正的互斥语义交给构造函数。
// 代码路径: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 层。
// 代码路径: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,先把表名级互斥建立起来,再继续往下执行。
// 代码路径: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_lock 与 alter_lock
先看 drop_lock。它主要出现在 SELECT、INSERT,以及对象级 DDL 的实例摘挂路径上。
以 SELECT 为例,整体调用路径可以先看成这样:
TableNode::TableNode
-> IStorage::lockForShare
-> IStorage::getInMemoryMetadataPtr
-> IStorage::getStorageSnapshot
TableNode::TableNode 在建表节点时,直接拿 lockForShare,然后基于当前 metadata 构造 StorageSnapshot。
// 代码路径:src/Analyzer/TableNode.cpp
// 函数:TableNode::TableNode
storage_->lockForShare(...);
storage_->getStorageSnapshot(storage_->getInMemoryMetadataPtr(context, false), context);
INSERT 是同一模式。它先拿 lockForShare,再读取 StorageInMemoryMetadata 的 snapshot,随后构造写入所需的 sample block 与 pipeline。
InterpreterInsertQuery::execute
-> IStorage::lockForShare
-> IStorage::getInMemoryMetadataPtr
// 代码路径: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 的读锁获取:
// 代码路径: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:
InterpreterRenameQuery::execute
-> DatabaseCatalog::getDDLGuard
-> DDLGuard::DDLGuard
-> DatabaseOnDisk::renameTable
-> IStorage::lockExclusively
前半段负责表名级串行化,后半段负责表实例级独占;两者的先后顺序也在这里固定下来。
// 代码路径: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 入口一路分发下来:
InterpreterDropQuery::execute
-> InterpreterDropQuery::executeSingleDropQuery
-> InterpreterDropQuery::executeToTable
-> InterpreterDropQuery::executeToTableImpl
-> DatabaseCatalog::getDDLGuard
-> DDLGuard::DDLGuard
-> IStorage::lockExclusively
// 代码路径: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 的写锁获取:
// 代码路径: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-元数据变更为例,整体调用路径可以先看成这样:
InterpreterAlterQuery::executeToTable
-> IStorage::lockForShare
-> runCommandSegments
-> IStorage::lockForAlter
-> IStorage::alter
InterpreterAlterQuery::executeToTable 先拿 lockForShare,保证表实例与当前 metadata 视图稳定;随后在 runCommandSegments 中,对真正的 AlterCommands 获取 lockForAlter,再调用 table->alter。
// 代码路径:src/Interpreters/InterpreterAlterQuery.cpp
// 函数:InterpreterAlterQuery::executeToTable
auto table_lock = table->lockForShare(getContext()->getCurrentQueryId(), settings[Setting::lock_acquire_timeout]);
...
return runCommandSegments(segments, table, getContext());
// 代码路径:src/Interpreters/InterpreterAlterQuery.cpp
// 函数:runCommandSegments
auto alter_lock = table->lockForAlter(settings[Setting::lock_acquire_timeout]);
...
table->alter(*alter_commands, context, alter_lock);
drop_lock 与 alter_lock 的类型差异在定义处就已经固定下来:前者是读写锁,后者是互斥锁。
// 代码路径: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_lock 在 SELECT/INSERT/ALTER-mutation 与对象级 DDL 之间提供表实例级的一致性边界(共享/独占互斥);alter_lock 负责把同一表上的 ALTER-元数据变更串行化。
同步原语视角(锁原语):
| 机制 | 锁住的对象/粒度 | 主要用途 | 会阻塞谁 | 会被谁阻塞 |
|---|---|---|---|---|
DDLGuard(DatabaseCatalog::getDDLGuard) | 表名级:db.table(以及数据库级:db) | 串行化同名 DDL,避免注册关系/名字相关变更被打散;RENAME 一次拿多把(按字典序)避免死锁 | 同一个 db.table 上的其它 DDL | 同名 DDLGuard 持有者 |
drop_lock 读锁(lockForShare) | 表实例级:IStorage 的 drop_lock | SELECT/INSERT/ALTER-mutation 建立稳定的 snapshot | drop_lock 写锁(lockExclusively) | drop_lock 写锁 |
drop_lock 写锁(lockExclusively) | 表实例级:IStorage 的 drop_lock | 等待所有 lockForShare 退出,并禁止新进入,然后执行 RENAME/DROP/DETACH 等需要摘表的动作 | 新的 lockForShare(以及其它写锁) | 现有的 lockForShare(以及其它写锁) |
alter_lock(lockForAlter) | 表实例级: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 的写锁要等读锁释放 |