乐于分享
好东西不私藏

DuckDB 插件开发实战:Mac 版 Everything–双引擎拆解——fts 遍历与 Spotlight 索引

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 = truebreak; }

// 跳过后序目录访问(避免重复计入目录)
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_info
含义
处理方式
FTS_F
普通文件
→ file_type = “file”
FTS_D
目录(前序)
→ file_type = “directory”
FTS_DP
目录(后序)
跳过

(避免重复)
FTS_SL
符号链接
→ file_type = “symlink”
FTS_SLNONE
symlink 指向不存在
→ file_type = “symlink”
FTS_ERR
错误
跳过

,继续扫描
FTS_DNR
无法读取的目录
跳过
FTS_NS
无法 stat 的文件
跳过

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,所以需要清洗。

SanitizeUtf8src/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
214
0.016s
~13,000
~/Downloads
105,833
1.64s
~64,500

瓶颈在哪? 不在 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。

Spotlight 模式
查索引数据库
mds 已提前建好索引
读内存 → 快
fts 模式
遍历文件系统
stat() 每个文件
读磁盘 → 慢

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, nullptrnullptr);
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 列的映射

Spotlight 属性键
对应列
说明
kMDItemPath
path
完整路径
kMDItemFSName
name
文件名
kMDItemFSSize
size
文件大小
kMDItemContentType
file_type
内容类型(public.folder → directory)
kMDItemContentModificationDate
modified_at
修改时间
kMDItemContentCreationDate
created_at
创建时间
kMDItemLastUsedDate
accessed_at
最后使用时间
—(lstat 补充)
permissions
Spotlight 没有,用 lstat 补
—(lstat 补充)
owner
同上

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/
隐藏目录 / Spotlight 排除规则
外接 FAT32/exFAT 磁盘
可能未启用索引
Time Machine 备份卷
单独的索引策略
新创建的文件(< 几秒)
mds 索引有少量延迟

这就是为什么 apfs 扩展提供两种模式——Spotlight 快但不全,fts 全但不快


Part 3:两种引擎对比总结

选择引擎

快速分析日常搜索

100% 覆盖扫描 /tmp .git

检测有没有变更

全盘文件名搜索

你的需求是?
spotlight 模式1-3 秒全盘
fts 模式30-120 秒全盘
apfs_has_changes50-200ms
apfs_search走 Spotlight
维度
fts 模式
spotlight 模式
速度
慢(stat() I/O bound)
快(查内存索引)
覆盖率
100% 可访问文件
仅 Spotlight 已索引
权限
需要完全磁盘访问
无需额外权限
增量扫描
不支持
支持(since 参数)
实现复杂度
184 行
385 行(CF 内存管理)
瓶颈
stat() 系统调用
MDQuery 执行
适用场景
精确扫描、未索引目录
日常分析、快速搜索

总结

  1. 1. fts(3) = fts_open → fts_read 循环 → fts_close,标准 POSIX API,100% 覆盖但慢
  2. 2. Spotlight = MDQueryCreate → MDQueryExecute → MDQueryGetResultAtIndex,查索引快但不全
  3. 3. FTSENT 的 fts_info 字段区分文件类型——注意跳过 FTS_DP 避免目录重复
  4. 4. Core Foundation 的内存管理是 Spotlight 引擎最容易出错的地方——每个 Copy 必须有 Release
  5. 5. apfs_has_changes 用 first_max_num=1 优化——50-200ms 检测变更
  6. 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 查找)