
为什么不能直接用 FileStream
长轮询读取日志文件
因为
FileStream默认不支持“等待新内容到达”,
Read()在文件末尾会立即返回 0 字节,而非阻塞等待。若用循环
Seek()+
Read()轮询,CPU 占用高、延迟不可控,且容易漏掉瞬间写入的多行内容(比如 Log4net 一次刷盘写入几行)。
真正可行的方式是监听文件变化 + 增量读取,核心依赖:
FileSystemWatcher捕获
Changed事件(注意:它只通知“文件被修改”,不告诉改了哪几行),再配合偏移量管理做安全续读。
FileSystemWatcher的
NotifyFilter必须包含
NotifyFilters.LastWrite,仅监听
FileName或
Attributes会丢事件 Windows 上对大文件(>1GB)或高频写入(如每毫秒一行),
Changed事件可能合并或丢失,需加防抖(例如延迟 100ms 后再触发读取) 不能在事件回调里直接调用
File.OpenText()—— 文件可能正被写入进程独占锁定,应重试 +
IOException捕获
如何用 IAsyncEnumerable<string></string>
实现流式响应
ASP.NET Core 6+ 的 Web API 支持直接返回
IAsyncEnumerable<string></string>,客户端用
text/event-stream(SSE)接收,服务端按行 yield 新内容,无需手动管理连接生命周期。
关键点在于:每次 yield 前必须确认当前读取位置未被截断(日志轮转常见),所以得先用
FileInfo.Length校验,再从上次偏移处开始读取新增字节,最后按
\n或
\r\n切分有效行。 响应头必须显式设置:
Response.Headers.Add("Content-Type", "text/event-stream");
每行数据要包装成 SSE 格式:yield return $"data: {line.TrimEnd()}\n\n";(注意双换行)
避免 StreamReader.ReadLineAsync()直接读 —— 它内部缓冲可能导致“读到一半就 yield”,应改用
Stream.ReadAsync()+ 自己解析行边界 客户端断连时,
cancellationToken会触发,需及时清理
FileSystemWatcher和文件句柄
FileSystemWatcher
和文件锁冲突怎么破
典型报错:
System.IO.IOException: The process cannot access the file because it is being used by another process.—— 这是因为日志文件正被 NLog/Log4net 独占打开(
FileShare.None),而你的
FileStream试图以
FileAccess.Read打开失败。
解法只有两个:一是降级为共享读(
FileShare.ReadWrite),二是退化为“无锁轮询”作为兜底。生产环境建议组合使用: 优先尝试
new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, FileOptions.SequentialScan)若抛
IOException,启动后台定时任务(如
Task.Run(() => PollLoopAsync())),每 500ms 检查
FileInfo.Length是否增长,仅当增长时才尝试打开(仍带重试) 永远不要用
Thread.Sleep()阻塞主线程;所有等待必须用
await Task.Delay()+
cancellationToken
客户端如何稳定接收 SSE 并处理断连
浏览器原生
EventSource会在连接中断后自动重连(默认 3s),但重连时无法携带上次读取偏移,所以服务端必须支持“从某行号/字节位置恢复”。简单方案是让客户端在 URL 中传参,如
/api/tail?path=/var/log/app.log&offset=12345。
更健壮的做法是服务端生成唯一
tailId,首次连接返回该 ID,后续断连重连时带上,服务端查内存字典恢复上下文(注意:跨实例部署需用 Redis 存储偏移)。 客户端示例:
const es = new EventSource("/api/tail?path=C%3A%5Clogs%5Capp.log"); es.onmessage = e => console.log(e.data);
服务端需校验 path参数是否在白名单内(如只允许
C:\logs\下的文件),防止路径遍历攻击 单个连接最大持续时间建议设限(如 30 分钟),超时后返回
event: timeout\ndata:\n\n并关闭,避免长连接堆积
最易被忽略的是编码问题:日志文件可能是 UTF-8 with BOM、GBK 或 UTF-16,
StreamReader默认用 UTF-8 但不检测 BOM,导致首行乱码。务必用
new StreamReader(stream, Encoding.Default)或先读前 3 字节判断 BOM 再选编码。
