用 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 还能搜中文」——搜文件内容。直接全量读全文会卡死大盘,我做三道闸:
- 扩展名白名单:只扫
.cs/.txt/.md/.json/.py/.js...等文本类; - 流式逐行:
File.ReadLines逐行读,命中即停,不把整个文件读进内存; - 单文件大小上限:超过
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 秒 |
| 搜文件名 | 索引 SQL | 0 ms ~ 1 ms |
| 搜中文内容(22 万文件) | 索引 + 流式 + 并行 | 秒级 |
数字随硬件和缓存浮动,属「行业参考」量级,不是精确基准。但量级关系稳定:建索引一次性付出,之后搜索比实时遍历快4 个数量级。
八、完整代码与运行
完整可运行工程(WinForms GUI 版 + 控制台版)已在我的项目里,核心就本文这 4 个类:IndexStore(索引层)、SearchCore(搜索核心)、Form1(界面)、Program。要点回顾:
EnsureSchema建表 + 三个索引;BuildIndex递归遍历 +5000/批事务写入;SearchIndex用ESCAPE '!'搜路径与文件名(别用\);Normalize统一分隔符;- 内容检索走
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,否则库又脏又慢。 - ⚠️ 内容检索必须加扩展名白名单 + 文件大小上限,否则全盘读全文直接卡死。
- ⚠️ 多根索引时选「最长覆盖根」,否则子目录搜索落空。