FileSystemWatcher 不适用于 tail -f,因其仅报告文件修改事件而无法识别追加内容;应采用轮询式 FileStream 定位+偏移量维护方案,配合 FileShare.Read|Write 打开、编码感知读取及截断/替换异常处理。

用 FileSystemWatcher 监听文件变化不适用于 tail -f
很多人第一反应是用 FileSystemWatcher,但它只报告「文件被修改」事件,不告诉你改了哪几行、新增了什么内容——尤其当文件被追加写入(如日志轮转、多进程写入)时,它无法区分是覆盖、截断还是追加,更不会提供新增字节位置。直接监听 Changed 事件 + 重读全文件,既低效又容易丢内容(比如两次事件间快速写入多段)。
真正接近 tail -f 的行为,必须自己维护文件偏移量,持续检查文件末尾是否有新数据。
核心做法:用 FileStream 定位 + 循环读取新增字节
关键不是“监听”,而是“轮询式检查”:打开文件保持句柄,记录上次读到的位置(Position),每次检查当前文件长度是否变大,若变大则从旧位置开始读新内容,并更新位置。这能准确捕获所有追加内容,兼容重定向、echo >>、logger 等常见写入方式。
- 必须用
FileShare.Read | FileShare.Write打开,否则其他进程写入时会抛IOException - 每次读前先调用
stream.Length获取当前大小,避免因缓存或 NTFS 延迟导致误判 - 读取后立即更新
Position,不要依赖Read()返回值自动移动(尤其遇到 \0 或编码边界时易出错) - 建议每次
Thread.Sleep(100),太短(如 10ms)会空转耗 CPU,太长(如 1s)会延迟感知新内容
处理换行与编码问题:别假设 UTF8 或 \n
日志文件可能用 GBK、UTF8-BOM、甚至混合编码;换行可能是 \n、\r\n,甚至单行超长没换行。硬切行会割裂中文或 JSON 字段。
推荐做法:
- 用
StreamReader包裹FileStream,并传入真实编码(如Encoding.UTF8),让其自动处理 BOM 和多字节边界 - 不要按行读(
ReadLine()),改用Read(char[], 0, buffer.Length)拿原始字符块,再手动找完整行(检查\n或\r\n结尾) - 缓冲区至少设为 4096 字节,避免频繁小读;读完后把未结束的行头缓存起来,下次拼接
应对文件被清空或重写:检测 Length 回退并重置位置
真实场景中,日志可能被 echo "" > app.log 清空,或 logrotate 重命名后新建同名文件。此时文件长度突降,若继续从原位置读会失败或乱码。
需主动检测:
- 每次循环先获取
stream.Length,若小于当前记录的lastPosition,说明文件被截断或重建 → 关闭旧流,重新用File.OpenRead(path)打开,并设lastPosition = 0 - 不要依赖
FileSystemWatcher.Renamed,它无法区分「重命名」和「删除+新建」,且 Linux Mono 下不可靠 - 可选加个简单哈希(如前 128 字节 MD5)比对,确认是否为同一文件实例(需注意性能)
最易被忽略的是:在 Windows 上,某些编辑器(如 Notepad++)保存时会先写临时文件再替换原文件,这时你持有的 FileStream 句柄实际指向已删除的旧文件,Length 仍返回旧大小,但后续读会触发 IOException。必须捕获该异常并触发重新打开逻辑。


















