DuckDB 插件开发实战:Mac 版 Everything–双引擎拆解——fts 遍历与 Spotlight 索引
双引擎拆解——fts 遍历与 Spotlight 索引
—— 一个慢而准,一个快而全
本文是《DuckDB 插件开发实战:Mac 版 Everything》系列的第 4 篇。上一篇拆解了表函数的 Bind/Init/Execute 三板斧和扩展入口,这篇深入两个引擎的实现细节——fts(3) 怎么遍历文件系统,Spotlight 怎么查索引。
版本基线:本文代码引用均基于
duckdb-apfs仓库duckdb/submodule 锁定的 DuckDB v1.5.2、macOS 15.6 arm64。实测行数:apfs_fts_scanner.cpp = 184 行、apfs_spotlight_scanner.cpp = 385 行、apfs_has_changes.cpp = 133 行、apfs_utils.cpp = 129 行。所有性能数字(fts 模式 1.64s / 10.5 万文件等)均为作者环境实测(M2 MacBook Pro 16GB + SSD),读者环境会有细微差异。仅基本功能实现,实际运行体验仍与Windows版本有不小差距,源码见参考资料。
Part 1:fts(3) 遍历引擎——慢而准
fts(3) 是什么?
fts 是 POSIX 标准定义的目录遍历 API(<fts.h>),macOS、FreeBSD、Linux 都有。三个核心函数:
FTS *fts_open(char * const *path_argv, int options, int (*compar)());
FTSENT *fts_read(FTS *ftsp);
intfts_close(FTS *ftsp);
打开 → 逐条读取 → 关闭。每次 fts_read 返回一个 FTSENT 结构体,包含文件路径、名称、stat 信息、遍历深度等。
为什么不用 opendir / readdir?因为 fts 帮你处理了递归遍历、符号链接检测、错误跳过——这些用 readdir 得自己写。为什么不用 C++ 的 std::filesystem::recursive_directory_iterator?因为它的错误处理不如 fts 灵活(无法在遇错时跳过继续),而且不提供 stat 信息的完整字段(如 macOS 特有的 st_birthtimespec 创建时间)。
Init:打开遍历句柄
// src/apfs_fts_scanner.cpp
unique_ptr<GlobalTableFunctionState> ApfsFtsScanInit(
ClientContext &context, TableFunctionInitInput &input)
{
auto &bind_data = input.bind_data->Cast<ApfsFtsScanBindData>();
auto state = make_uniq<ApfsFtsScanGlobalState>();
// 验证路径
structstat path_stat;
if (stat(bind_data.root_path.c_str(), &path_stat) != 0) {
throwIOException("path '%s' does not exist", bind_data.root_path);
}
// fts_open 要求 NULL 结尾的字符串数组
char *path_argv[2];
path_argv[0] = const_cast<char *>(bind_data.root_path.c_str());
path_argv[1] = nullptr;
// FTS_LOGICAL: 跟随符号链接
// FTS_NOCHDIR: 不改变工作目录(安全)
FTS *fts = fts_open(path_argv, FTS_LOGICAL | FTS_NOCHDIR, nullptr);
if (!fts) {
throwIOException("failed to open '%s': %s",
bind_data.root_path, strerror(errno));
}
state->fts_handle = fts;
return std::move(state);
}
FTS_LOGICAL 让 fts 跟随符号链接——symlink 指向的目录也会被遍历。FTS_NOCHDIR 防止 fts 内部调用 chdir(),避免影响其他线程。
Execute:逐条读取并填充 DataChunk
voidApfsFtsScanFunction(ClientContext &context,
TableFunctionInput &data, DataChunk &output)
{
auto &state = data.global_state->Cast<ApfsFtsScanGlobalState>();
if (state.finished) { output.SetCardinality(0); return; }
auto fts = static_cast<FTS *>(state.fts_handle);
idx_t count = 0;
while (count < STANDARD_VECTOR_SIZE) {
FTSENT *entry = fts_read(fts);
if (!entry) { state.finished = true; break; }
// 跳过后序目录访问(避免重复计入目录)
if (entry->fts_info == FTS_DP) continue;
// 跳过错误项但不中断扫描
if (entry->fts_info == FTS_ERR || entry->fts_info == FTS_DNR
|| entry->fts_info == FTS_NS) continue;
string full_path = SanitizeUtf8(string(entry->fts_path));
string file_name = SanitizeUtf8(string(entry->fts_name));
output.SetValue(0, count, Value(full_path)); // path
output.SetValue(1, count, Value(file_name)); // name
output.SetValue(2, count, Value(ApfsGetFileExtension(file_name)));
// ... size, file_type, timestamps, permissions, owner, depth
count++;
}
output.SetCardinality(count);
}
FTSENT 的 info 字段
|
|
|
|
FTS_F |
|
|
FTS_D |
|
|
FTS_DP |
|
跳过
|
FTS_SL |
|
|
FTS_SLNONE |
|
|
FTS_ERR |
|
跳过
|
FTS_DNR |
|
跳过 |
FTS_NS |
|
跳过 |
FTS_DP 跳过这一点容易遗漏——fts 遍历目录时会先返回 FTS_D(进入目录前),再返回 FTS_DP(离开目录后)。不跳过就会每个目录计两次。
元数据采集:stat 结构体
entry->fts_statp 指向 struct stat,文件的所有元数据都在这里:
if (entry->fts_statp) {
structstat *st = entry->fts_statp;
output.SetValue(3, count, Value::BIGINT(st->st_size));
output.SetValue(5, count, TimespecToTimestamp(st->st_mtimespec));
output.SetValue(6, count, TimespecToTimestamp(st->st_birthtimespec));
output.SetValue(7, count, TimespecToTimestamp(st->st_atimespec));
output.SetValue(8, count, Value(ApfsModeToPermissionString(st->st_mode)));
output.SetValue(9, count, Value(ApfsGetOwnerName(st->st_uid)));
}
st_birthtimespec 是 macOS/BSD 特有的——Linux 的 struct stat 没有创建时间。
时间戳转换
macOS 的 timespec 是秒+纳秒,DuckDB 的 timestamp_t 是微秒:
static Value TimespecToTimestamp(conststruct timespec &ts){
int64_t micros = static_cast<int64_t>(ts.tv_sec) * Interval::MICROS_PER_SEC
+ static_cast<int64_t>(ts.tv_nsec) / 1000;
return Value::TIMESTAMP(timestamp_t(micros));
}
UTF-8 安全处理
macOS 文件系统允许非 UTF-8 字节序列的文件名(比如从 Windows 拷贝的文件)。DuckDB 的 VARCHAR 要求合法 UTF-8,所以需要清洗。
SanitizeUtf8(src/apfs_utils.cpp)逐字节验证 UTF-8 编码,把非法字节替换为 U+FFFD(Unicode 替换字符 �)。不做这一步的话,DuckDB 处理这些字符串时会报 UTF-8 validation error 然后整个查询失败。
权限和 owner 工具函数
// 权限位转字符串:rwxr-xr-x
string ApfsModeToPermissionString(mode_t mode){
string result;
result += (mode & S_IRUSR) ? 'r' : '-';
result += (mode & S_IWUSR) ? 'w' : '-';
result += (mode & S_IXUSR) ? 'x' : '-';
// ... group, other
return result; // 9 个字符
}
// UID 转用户名
string ApfsGetOwnerName(uid_t uid){
structpasswd *pw = getpwuid(uid);
return pw ? string(pw->pw_name) : std::to_string(uid);
}
性能特征
实测数据(M2 MacBook Pro 16GB,release 版):
|
|
|
|
|
/tmp |
|
|
|
~/Downloads |
|
|
|
瓶颈在哪? 不在 DuckDB,不在 C++ 代码——在 stat() 系统调用。每个文件都要调一次 stat() 获取大小、时间、权限,这是 I/O 操作,受磁盘速度限制。Spotlight 之所以快,就是因为它不需要 stat()——所有元数据已经在索引数据库里了。
macOS 权限坑
fts 模式需要”完全磁盘访问权限”才能扫描受 TCC 保护的目录(~/Desktop、~/Documents、~/Downloads、~/Library/Mail 等)。没有权限时 fts 会返回 FTS_DNR,扫描代码跳过并继续——不会崩溃,但结果不完整。
授权步骤:系统设置 → 隐私与安全性 → 完全磁盘访问权限 → 添加你的终端应用。
Part 2:Spotlight 引擎——快而全
Spotlight 是什么?
macOS 后台有一个叫 mds(metadata server)的进程,它持续监控文件系统变更,把文件的元数据存进一个索引数据库。你在 Finder 按 ⌘+Space 搜文件,走的就是这个索引。
apfs 扩展做的事情是:用 C++ 调用 Spotlight 的查询 API(MDQuery),把结果灌进 DuckDB 的 DataChunk。
Init:构建并执行 MDQuery
// src/apfs_spotlight_scanner.cpp
unique_ptr<GlobalTableFunctionState> ApfsSpotlightScanInit(
ClientContext &context, TableFunctionInitInput &input)
{
auto &bind_data = input.bind_data->Cast<ApfsSpotlightScanBindData>();
auto state = make_uniq<ApfsSpotlightScanGlobalState>();
string query_string;
if (bind_data.has_since) {
// 增量扫描:只要修改时间 >= since 的文件
query_string = "kMDItemFSContentChangeDate >= $time.iso(...)";
} else {
// 全量扫描:所有文件和目录
query_string = "kMDItemFSName = \"*.*\" || kMDItemContentType = \"public.folder\"";
}
RunMDQuery(query_string, bind_data.root_path,
state->query, state->total_count);
return std::move(state);
}
RunMDQuery 是核心函数,它做四件事:
staticvoidRunMDQuery(const string &query_string, const string &scope_path,
void *&out_query, idx_t &out_count)
{
// 1. 创建查询字符串(CFString)
CFStringRef query_cf = CFStringCreateWithCString(...);
// 2. 创建 MDQuery 对象
MDQueryRef md_query = MDQueryCreate(kCFAllocatorDefault, query_cf, nullptr, nullptr);
CFRelease(query_cf);
// 3. 设置搜索范围
if (!scope_path.empty() && scope_path != "/") {
CFURLRef scope_url = CFURLCreateWithFileSystemPath(...);
CFArrayRef scopes = CFArrayCreate(..., &scope_url, 1, ...);
MDQuerySetSearchScope(md_query, scopes, 0);
}
// 4. 同步执行查询
MDQueryExecute(md_query, kMDQuerySynchronous);
out_count = MDQueryGetResultCount(md_query);
out_query = md_query;
}
kMDQuerySynchronous 意味着 MDQueryExecute 会阻塞直到所有结果就绪。对于全盘查询,这通常在 1-3 秒内完成。
Execute:逐条提取 MDItem 属性
voidApfsSpotlightScanFunction(ClientContext &context,
TableFunctionInput &data, DataChunk &output)
{
auto &state = data.global_state->Cast<ApfsSpotlightScanGlobalState>();
auto md_query = static_cast<MDQueryRef>(state.query);
idx_t count = 0;
while (count < STANDARD_VECTOR_SIZE && state.current_index < state.total_count) {
MDItemRef item = (MDItemRef)MDQueryGetResultAtIndex(
md_query, static_cast<CFIndex>(state.current_index));
if (item) {
FillRowFromMDItem(item, bind_data.root_path, output, count);
count++;
}
state.current_index++;
}
output.SetCardinality(count);
}
和 fts 模式一样的循环模式——每批最多 2048 行,用 current_index 追踪进度。
Spotlight 属性键到 11 列的映射
|
|
|
|
kMDItemPath |
|
|
kMDItemFSName |
|
|
kMDItemFSSize |
|
|
kMDItemContentType |
|
|
kMDItemContentModificationDate |
|
|
kMDItemContentCreationDate |
|
|
kMDItemLastUsedDate |
|
|
|
|
|
lstat 补 |
|
|
|
|
permissions 和 owner:Spotlight 索引不存储文件权限和所有者信息,所以 Spotlight 模式下这两列是通过额外的 lstat() 调用获取的。
CFDate 到 DuckDB Timestamp 的转换
Core Foundation 的时间基准和 Unix 不同:
static Value CFDateToTimestamp(CFDateRef cf_date){
if (!cf_date) returnValue();
// CFAbsoluteTime 基准: 2001-01-01 00:00:00 UTC
// Unix epoch 基准: 1970-01-01 00:00:00 UTC
// 差值: 978307200 秒
staticconstexprdouble CF_TO_UNIX_OFFSET = 978307200.0;
double abs_time = CFDateGetAbsoluteTime(cf_date);
double unix_time = abs_time + CF_TO_UNIX_OFFSET;
int64_t micros = static_cast<int64_t>(unix_time * Interval::MICROS_PER_SEC);
return Value::TIMESTAMP(timestamp_t(micros));
}
apfs_search:全盘文件名搜索
apfs_search 也走 Spotlight,但用不同的查询字符串:
// 搜索模式:文件名包含关键词
string query_string = "kMDItemFSName == '*" + keyword + "*'cdw";
cdw 后缀是 MDQuery 的修饰符:c = 不区分大小写,d = 不区分重音符号,w = 词边界匹配。搜索不限定目录范围——全盘搜索。
apfs_has_changes:轻量变更检测
// 关键优化:只需要知道有没有,不需要全部结果
MDQueryBatchingParams params;
params.first_max_num = 1; // 找到 1 条就够了
params.first_max_ms = 1000;
MDQuerySetBatchingParameters(md_query, params);
MDQueryExecute(md_query, kMDQuerySynchronous);
result_data[i] = (MDQueryGetResultCount(md_query) > 0);
first_max_num = 1——这是性能优化的关键。我们只想知道”有没有变更”,不需要拿到所有变更的文件列表。告诉 Spotlight “找到 1 个就停”,查询耗时从秒级降到 50-200ms。
Core Foundation 内存管理
Spotlight API 全部基于 Core Foundation,遵循手动引用计数规则:
-
• 函数名含 Create/Copy→ 调用方 own,必须CFRelease -
• 函数名含 Get→ 调用方不 own,不能CFRelease
// 正确的模式:Copy → 使用 → Release
CFStringRef cf_name = (CFStringRef)MDItemCopyAttribute(item, kMDItemFSName);
string name = cf_name ? CFStringToString(cf_name) : "";
if (cf_name) CFRelease(cf_name); // 必须释放,CFRelease(NULL) 会崩溃
全量扫描百万文件时,每个文件 7-8 个属性,一次扫描有几百万次 CFRelease 调用。
Spotlight 的局限
|
|
|
/tmp
/var |
|
.git/
node_modules/ |
|
|
|
|
|
|
|
|
|
|
这就是为什么 apfs 扩展提供两种模式——Spotlight 快但不全,fts 全但不快。
Part 3:两种引擎对比总结
|
|
|
|
| 速度 |
|
|
| 覆盖率 |
|
|
| 权限 |
|
|
| 增量扫描 |
|
|
| 实现复杂度 |
|
|
| 瓶颈 |
|
|
| 适用场景 |
|
|
总结
-
1. fts(3) = fts_open→fts_read循环 →fts_close,标准 POSIX API,100% 覆盖但慢 -
2. Spotlight = MDQueryCreate→MDQueryExecute→MDQueryGetResultAtIndex,查索引快但不全 -
3. FTSENT 的 fts_info字段区分文件类型——注意跳过FTS_DP避免目录重复 -
4. Core Foundation 的内存管理是 Spotlight 引擎最容易出错的地方——每个 Copy必须有Release -
5. apfs_has_changes用first_max_num=1优化——50-200ms 检测变更 -
6. 两种引擎互补——Spotlight 快但不全,fts 全但不快
下一篇往上走一层——怎么在 DuckDB 扩展之上构建 Go Web UI 和实时文件监听。
参考资料
-
• mac-everything -
• duckdb-apfs -
• fts(3) man page -
• Apple MDQuery Reference -
• Spotlight Metadata Attributes -
• duckdb-apfs 源码: src/apfs_fts_scanner.cpp(fts 引擎完整实现) -
• duckdb-apfs 源码: src/apfs_spotlight_scanner.cpp(Spotlight 引擎完整实现) -
• duckdb-apfs 源码: src/apfs_has_changes.cpp(变更检测标量函数) -
• duckdb-apfs 源码: src/apfs_utils.cpp(UTF-8 清洗 + 权限转换 + owner 查找)
夜雨聆风