高并发下直接用 File.AppendAllText
或同步 ILogger.Log
写日志,等于给系统埋雷——不是慢,是卡死、丢日志、甚至拖垮业务线程。真正可用的方案,必须同时满足:无锁缓冲、异步落盘、批量刷写、可控回压。下面直说怎么做。
为什么不能只用 Task.Run(() => File.AppendAllText(...))
这是最常见也最危险的“伪异步”写法。表面看不阻塞主线程,但问题一堆:
每条日志都新建一个Task,高并发时线程池被迅速耗尽,引发
ThreadPool.GetAvailableThreads返回 0,后续所有异步操作排队等待 仍用
lock保护文件写入?那只是把锁从主线程搬到了后台线程,本质还是串行,吞吐上不去 没缓冲、没批处理,磁盘 I/O 次数和请求量 1:1,SSD 都扛不住每秒几千次小写 进程崩溃时,内存里还没刷出的日志永久丢失
用 NLog 的 <asyncwrapper></asyncwrapper>
是最快落地的方案
NLog 4.0+ 原生支持零侵入异步日志,不用改一行业务代码,靠配置就能实现缓冲+批写+独立写线程:
<configuration>
<targets>
<target name="file" xsi:type="File"
fileName="${basedir}/logs/${shortdate}.log"
layout="${longdate} ${uppercase:${level}} ${logger} ${message}" />
<target name="asyncFile" xsi:type="AsyncWrapper" queueLimit="5000" timeToSleepWhenIdle="50">
<target ref="file" />
</target>
</targets>
<rules>
<logger name="*" minlevel="Info" writeTo="asyncFile" />
</rules>
</configuration>关键参数说明:
queueLimit="5000":内存队列上限,超限时默认丢弃(可配
overflowAction="Grow"或
"Block")
timeToSleepWhenIdle="50":空闲时线程休眠毫秒数,太小=空转耗 CPU,太大=日志延迟升高 它自动启用
BlockingCollection<logeventinfo></logeventinfo>+ 后台专属消费者线程,全程无锁
调用时和原来完全一样:
logger.LogInformation("Order processed: {OrderId}", id);
自研轻量级异步日志器:适合不能引入 NLog 的场景
若项目受限(如嵌入式、极简部署),可用
Channel<string></string>+ 后台
Task实现可控、低开销的方案:
public sealed class SimpleAsyncLogger
{
private readonly Channel<string> _channel = Channel.CreateBounded<string>(new BoundedChannelOptions(10000)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
});
private readonly string _logPath;
<pre class='brush:php;toolbar:false;'>public SimpleAsyncLogger(string logPath) => _logPath = logPath;
public void Log(string msg) => _channel.Writer.TryWrite($"[{DateTime.Now:HH:mm:ss.fff}] {msg}");
public async Task StartAsync(CancellationToken ct)
{
await foreach (var line in _channel.Reader.ReadAllAsync(ct))
{
try
{
await File.AppendAllTextAsync(_logPath, line + Environment.NewLine, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch (IOException) { /* 磁盘满/权限错等,可降级到 Console 或丢弃 */ }
}
}}
使用要点:
启动时调用StartAsync并 长期持有该
Task引用(比如注册为
IHostedService),别让它被 GC
FullMode = DropOldest防止突发流量打爆内存;若需保序保全,改用
Wait模式并监控队列长度 不要在
Log()方法里 await —— 它必须是纯内存入队,否则就退化成同步了
真正的难点不在“怎么写异步”,而在“怎么不让异步变成新瓶颈”:队列大小、刷盘频率、错误隔离、OOM 防御、以及——日志本身是否结构化(影响后续检索)。这些没对齐,再快的日志器,最后查问题时也只会让你更绝望。
