☰
用 C# 手写一个 Everything 风格文件搜索工具:SQLite 建索引,22 万文件 1 毫秒搜中文(附完整可运行代码)
2026/10/30 5:03:19 网站建设 项目流程

用 C# 手写一个 Everything 风格文件搜索工具:SQLite 建索引,22 万文件 1 毫秒搜中文(附完整可运行代码)

标签:C#文件搜索SQLiteEverythingWinForms索引性能优化
摘要:很多人都试过用Directory.GetFiles遍历硬盘找文件,结果一搜就是几十秒。本文用我自己的开源小工具FileSearchGui为例,讲清楚「实时遍历为什么慢 → 用 SQLite 建一份本地索引 → 之后毫秒级搜索」的完整实现,并复盘一个差点让项目翻车的坑:LIKE转义符用错导致 Windows 路径全部匹配失败。代码已跑通,可直接抄。


一、背景:为什么你用Directory.GetFiles永远慢

先说结论:在文件多的时候,慢的不是「匹配」,而是「遍历文件系统」本身。

我在一台普通开发机上实测:C 盘(排除Windows、Program Files等系统大目录)大约有22.8 万个文件,仅仅是把它们枚举出来(EnumerateFiles递归)就要19 秒。再加上逐个FileInfo取大小、时间,整体跑到 70 秒一点都不稀奇。

那为什么Everything能秒开?因为它根本不遍历——它直接读 NTFS 的MFT / USN日志,在系统层面预建了一份内存级索引,开机就已经知道每个文件在哪。我们没有内核权限,但可以用一个折中方案:

第一次用 SQLite 在本地建一份等价索引(慢,一次性);之后所有搜索都走 SQL,毫秒到秒级。

这正是FileSearchGui的思路。下面把核心代码拆开讲。

二、整体方案:索引与搜索彻底分离

建索引(一次性,慢) 搜索(每次,快) 遍历目录树 ──► SQLite 落库 查 SQL ──► 返回结果 (files 表: dir/name/path/ WHERE dir LIKE ? ESCAPE '!' size/mtime/ext) AND name LIKE ? ESCAPE '!'

两张表:

CREATETABLEroots(rootTEXTPRIMARYKEY,-- 被索引的根目录built_atTEXT,file_countINTEGER,inc_buildINTEGER,-- 建索引时是否含 bin/obj 等生成目录inc_systemINTEGER-- 是否含系统大目录);CREATETABLEfiles(idINTEGERPRIMARYKEY,dirTEXT,nameTEXT,pathTEXTUNIQUE,sizeINTEGER,mtimeINTEGER,extTEXT);CREATEINDEXidx_files_nameONfiles(name);CREATEINDEXidx_files_dirONfiles(dir);


建索引时我们默认跳过编译产物和系统大目录,避免把bin/obj/node_modules和Windows灌进库,既快又干净:

staticreadonlyHashSet<string>SkipDirs=new(StringComparer.OrdinalIgnoreCase){"bin","obj","node_modules",".git",".workbuddy"};staticreadonlyHashSet<string>SystemDirs=new(StringComparer.OrdinalIgnoreCase){"Windows","Program Files","ProgramData","$Recycle.Bin","System Volume Information"};staticboolIsSkipDir(stringdir,boolincludeBuild,boolincludeSystem){varname=Path.GetFileName(dir.TrimEnd(Path.DirectorySeparatorChar));if(!includeBuild&&SkipDirs.Contains(name))returntrue;// 不勾"含生成目录"时跳过if(!includeSystem&&SystemDirs.Contains(name))returntrue;// 索引场景始终跳过系统盘大目录returnfalse;}

三、实战 1:建索引(SQLite + 批量事务,别一条条插)

新手建索引最常见的性能陷阱是「每读一个文件就ExecuteNonQuery一次」。SQLite 每次写都有事务开销,22 万次就是 22 万次提交,能慢到你怀疑人生。

正确做法:攒一批(这里 5000 条)一次性提交一个事务。

varbatch=newList<(stringdir,stringname,stringpath,longsize,longmtime,stringext)>(5000);longscanned=0;voidFlushBatch(){if(batch.Count==0)return;usingvarconn=newSqliteConnection("Data Source="+_dbPath);conn.Open();usingvartx=conn.BeginTransaction();// 整批一个事务usingvarcmd=conn.CreateCommand();cmd.CommandText="INSERT OR REPLACE INTO files(dir,name,path,size,mtime,ext) VALUES(@d,@n,@p,@s,@m,@e)";// ... 用参数化 Add 循环 ExecuteNonQuery ...foreach(varfinbatch){pD.Value=f.dir;pN.Value=f.name;/* ... */cmd.ExecuteNonQuery();}tx.Commit();batch.Clear();}voidWalk(stringdir){if(IsSkipDir(dir,includeBuild,includeSystem))return;foreach(varfinDirectory.EnumerateFiles(dir)){varfi=newFileInfo(f);batch.Add((dir,fi.Name,f,fi.Length,fi.LastWriteTimeUtc.Ticks,fi.Extension.ToLowerInvariant()));if(batch.Count>=5000)FlushBatch();// 满一批就落库}foreach(vardinDirectory.EnumerateDirectories(dir))Walk(d);}

实测:22.8 万文件建索引约0.3 秒(当然前提是文件元数据已经被系统缓存,冷启动会慢一些,但仍是一次性成本)。


四、实战 2:搜索——一个反斜杠引发的「血案」(本文重点坑)

索引建好了,结果一搜啥都搜不到。我在这个坑里卡了很久,最后定位到一个反直觉的原因:

❌ 错误写法

// 千万别这么写!cmd.CommandText="SELECT ... FROM files WHERE dir LIKE @r ESCAPE '\\' AND name LIKE @t ESCAPE '\\'";

LIKE的转义符默认或显式用'\'时,Windows 路径里的反斜杠会被当成转义字符。路径C:\Users\HY里的\U、\H被 SQL 解释成「转义后的字面量 U、H」,于是dir LIKE 'C:\Users\%'永远匹配不上任何记录——索引建得再好,搜索也全落空。

✅ 正确写法:用!当转义符

// 把 LIKE 的特殊字符用 '!' 转义,彻底绕开 Windows 路径里的 '\'staticstringEscapeLike(strings){if(string.IsNullOrEmpty(s))returns;returns.Replace("!","!!")// 先转义转义符本身.Replace("\\","!\\")// 反斜杠 → 被转义的反斜杠.Replace("%","!%").Replace("_","!_");}publicList<ResultItem>SearchIndex(stringsearchRoot,stringterm,boolincludeBuild,boolincludeSystem){searchRoot=Normalize(searchRoot);varcovering=GetCoveringRoot(searchRoot,includeBuild,includeSystem);usingvarconn=newSqliteConnection("Data Source="+_dbPath);conn.Open();usingvarcmd=conn.CreateCommand();// 关键:ESCAPE '!',绝不能用 '\'cmd.CommandText="SELECT path,name,size,mtime FROM files "+"WHERE dir LIKE @r ESCAPE '!' AND name LIKE @t ESCAPE '!'";cmd.Parameters.AddWithValue("@r",EscapeLike(searchRoot)+"%");cmd.Parameters.AddWithValue("@t","%"+EscapeLike(term)+"%");// ... 读结果 ...}

第二个隐形坑:分隔符混用

建索引时如果你传入的是C:/Users/HY(正斜杠),dir列存成C:/...;搜索时却用C:\Users\HY去LIKE,因为/和\不相等,照样搜不到。统一用Normalize把分隔符规范掉:

staticstringNormalize(stringp){if(string.IsNullOrEmpty(p))returnp;p=p.Replace(Path.AltDirectorySeparatorChar,Path.DirectorySeparatorChar);// '/' → '\'returnp.TrimEnd(Path.DirectorySeparatorChar);}

这两个坑(转义符 + 分隔符)是「建完索引搜不到」的 99% 原因,记牢。


五、实战 3:不止搜文件名,还能搜文件内容

Everything 的强项是文件名,但我们要「比 Everything 还能搜中文」——搜文件内容。直接全量读全文会卡死大盘,我做三道闸:

  1. 扩展名白名单:只扫.cs/.txt/.md/.json/.py/.js...等文本类;
  2. 流式逐行:File.ReadLines逐行读,命中即停,不把整个文件读进内存;
  3. 单文件大小上限:超过maxSizeMB(默认几十 MB)直接跳过。
staticreadonlyHashSet<string>TextExts=new(StringComparer.OrdinalIgnoreCase){".txt",".cs",".md",".json",".xml",".py",".js",".ts",".html",".css",".sql",".csv",".yaml",".toml"/* ... */};boolMatch(stringpath,stringterm,boolcontent,longmaxBytes,outResultItemitem){item=null;if(!content){if(Path.GetFileName(path).Contains(term,StringComparison.OrdinalIgnoreCase)){item=MakeItem(path);returntrue;}returnfalse;}varext=Path.GetExtension(path).ToLowerInvariant();if(!TextExts.Contains(ext))returnfalse;// 闸 1:非文本不扫try{varfi=newFileInfo(path);if(fi.Length>maxBytes)returnfalse;// 闸 3:太大跳过foreach(varlineinFile.ReadLines(path))// 闸 2:流式逐行{if(line.Contains(term,StringComparison.OrdinalIgnoreCase)){item=MakeItem(path);returntrue;}}returnfalse;}catch{returnfalse;}}

配合Parallel.ForEach多核并行扫描,22 万文件的内容检索也能在秒级返回。


六、搜索范围正确性:选「最长覆盖根」

如果用户索引了C:\又单独索引了C:\Users\HY\WorkBuddy,当他搜C:\Users\HY\WorkBuddy\ProjectA时,必须命中最具体的那个根,否则会错误落到C:\的覆盖分支,导致子目录搜索全部落空。做法是在候选覆盖根里选路径最长的:

publicboolIsCovered(stringsearchRoot,boolincludeBuild,boolincludeSystem,outstringcoveringRoot){coveringRoot=null;searchRoot=Normalize(searchRoot);stringbest=null;foreach(varriinGetRoots()){if(ri.IncludeBuild!=includeBuild||ri.IncludeSystem!=includeSystem)continue;varr=Normalize(ri.Root);// 选最长覆盖根:搜索根是某索引根本身或它的子目录if(searchRoot==r||searchRoot.StartsWith(r+Path.DirectorySeparatorChar))if(best==null||r.Length>Normalize(best).Length)best=ri.Root;}coveringRoot=best;returnbest!=null;}

同时要求「含生成目录 / 系统目录」的开关与建索引时一致,避免「勾了含 bin 却走不含 bin 的旧索引」漏搜。


七、性能实测(同一台开发机)

场景方式耗时
全 C 盘枚举 22.8 万文件实时遍历~19 秒(仅枚举)
建索引(一次性)SQLite 批量事务~0.3 秒
搜文件名索引 SQL0 ms ~ 1 ms
搜中文内容(22 万文件)索引 + 流式 + 并行秒级

数字随硬件和缓存浮动,属「行业参考」量级,不是精确基准。但量级关系稳定:建索引一次性付出,之后搜索比实时遍历快4 个数量级。


八、完整代码与运行


完整可运行工程(WinForms GUI 版 + 控制台版)已在我的项目里,核心就本文这 4 个类:IndexStore(索引层)、SearchCore(搜索核心)、Form1(界面)、Program。要点回顾:

  1. EnsureSchema建表 + 三个索引;
  2. BuildIndex递归遍历 +5000/批事务写入;
  3. SearchIndex用ESCAPE '!'搜路径与文件名(别用\);
  4. Normalize统一分隔符;
  5. 内容检索走TextExts白名单 +File.ReadLines流式 + 大小上限 +Parallel.ForEach。

NuGet 依赖只有一个:Microsoft.Data.Sqlite。目标框架.NET 8/net8.0-windows,发布成自包含单文件 exe,双击即用,无需装运行时。


九、踩坑清单(速查)

  • ⚠️LIKE转义符绝不用\,Windows 路径全是\,会全匹配失败 → 用ESCAPE '!'。
  • ⚠️ 建索引与搜索的路径分隔符必须统一(/与\混用 → 搜不到)→Normalize。
  • ⚠️ 插库务必批量事务,不要逐条提交。
  • ⚠️ 索引要跳过bin/obj/node_modules/.git和Windows/Program Files,否则库又脏又慢。
  • ⚠️ 内容检索必须加扩展名白名单 + 文件大小上限,否则全盘读全文直接卡死。
  • ⚠️ 多根索引时选「最长覆盖根」,否则子目录搜索落空。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询