3. NLog进阶:结构化日志,布局渲染器,异步日志,日志归档与滚动策略

好,咱们接着聊NLog。上一章我们把NLog的基础用法过了一遍,说白了就是能写日志了。但真正到了生产环境,你会发现光能写远远不够。日志怎么写、写什么、写到哪里、写多大,这些细节才是决定你排障效率的关键。

这一章,我带你深入NLog的几个核心进阶特性。我个人习惯把这些特性称为「生产环境三件套」:结构化日志让日志可被机器读懂,布局渲染器让日志内容更丰富,异步与归档则让日志系统稳定可靠。咱们一个一个来。

3.1 结构化日志:让日志不再是「字符串垃圾」

先问个问题:你平时怎么记录一个订单的支付信息?

// 传统写法
logger.Info($"用户{userId}支付了订单{orderId},金额{amount}元");

这种写法,说白了就是拼字符串。你看着是中文,机器看着就是一堆字符。哪天你想统计一下「今天所有支付金额大于1000元的订单」,你怎么办?用正则去日志文件里抠?那效率太低了。

结构化日志就是来解决这个问题的。它的核心思想是:把日志中的变量和消息模板分开

// 结构化写法
logger.Info("用户{UserId}支付了订单{OrderId},金额{Amount}元", userId, orderId, amount);

看到区别了吗?大括号里的 {UserId}{OrderId} 不再是字符串的一部分,而是命名占位符。NLog会把它们解析成键值对,输出到目标时,可以保留为结构化数据。

核心价值:结构化日志可以被日志分析系统(如Elasticsearch、Splunk)直接索引和查询。你可以在Kibana里直接搜 UserId: 12345,而不是用正则去全文检索。

我在项目中遇到过一件事:有一次线上排查一个支付超时问题,传统日志里全是「用户xxx支付失败」,根本没法聚合。后来改成结构化日志,直接在Kibana里按 PaymentStatus: Failed 一筛,再按 Amount > 10000 排序,问题瞬间定位到是某几个大额订单触发了风控。嗯,这就是结构化日志的威力。

3.2 布局渲染器(Layout Renderers):给日志加点「料」

布局渲染器,名字听着挺唬人,其实说白了就是内置的变量占位符。NLog提供了几十种内置的渲染器,让你可以在输出日志时自动填充各种上下文信息。

比如,你想在每条日志里带上当前线程ID、请求ID、机器名,你不需要手动去取,直接用渲染器就行:

<target name="file" xsi:type="File"
        layout="${longdate} | ${level:uppercase=true} | ${threadid} | ${machinename} | ${message}" />

常用的布局渲染器我列个表,你直接收藏:

渲染器 说明 我的使用场景
${longdate} 完整日期时间,如 2025-01-15 14:30:22.1234 所有日志的标配
${level} 日志级别,可设置大小写 配合 uppercase=true 更醒目
${logger} 记录日志的类名 快速定位哪个类出的问题
${threadid} 当前线程ID 排查并发问题时必用
${machinename} 服务器机器名 多节点部署时区分日志来源
${aspnet-request:item=UserAgent} ASP.NET Core请求的User-Agent 分析客户端类型
${exception:format=ToString} 异常堆栈信息 记录异常时必加

我的小技巧:我习惯把 ${longdate} | ${level} | ${logger} | ${message}${exception:format=ToString} 作为默认布局。这样每条日志都自带时间、级别、来源,排查问题时一眼就能看出关键信息。

你还可以自定义布局渲染器。比如,我想在每条日志里带上当前登录用户的ID,可以写一个自定义渲染器。不过说实话,大部分场景下内置的已经够用了,别过度设计。

3.3 异步日志:别让日志拖慢你的业务

日志写入文件,本质上是I/O操作。如果你的业务线程直接写日志,遇到磁盘繁忙时,业务线程就得等着。你想想看,用户请求因为写日志而变慢,这合理吗?

NLog的异步日志机制,说白了就是把写日志这件事丢给后台线程去做。业务线程只管把日志消息扔进队列,然后立刻返回,继续处理业务。

配置方式很简单,用 async 包装一下你的target:

<targets>
  <target name="asyncFile" xsi:type="AsyncWrapper" queueLimit="5000" overflowAction="Discard">
    <target xsi:type="File"
            fileName="${basedir}/logs/app.log"
            layout="${longdate} | ${level} | ${message}" />
  </target>
</targets>

这里有几个关键参数:

  • queueLimit:队列最大长度。默认10000,我一般设5000,防止内存爆掉。
  • overflowAction:队列满了怎么办?有 Discard(丢弃新日志)、Block(阻塞业务线程)、Grow(扩容队列)。我建议用 Discard,宁可丢几条日志,也别拖垮业务。

避坑指南:我曾经在一个高并发项目里,把 overflowAction 设成了 Block。结果业务高峰期队列满了,所有写日志的线程都被阻塞,导致请求响应时间从50ms飙升到5秒。嗯,从那以后我再也不敢用 Block 了。

另外,异步日志的写入顺序是不保证的。如果你对日志顺序有严格要求(比如审计日志),那就别用异步,或者用 AsyncWrapperbatchSize 参数控制批量写入。

3.4 日志归档与滚动策略:别让日志撑爆磁盘

日志如果不加控制,几天就能把磁盘写满。我见过最夸张的一次,一个项目跑了三天,日志文件占了200GB,直接把服务器搞宕机了。所以,归档和滚动策略是必须的。

NLog的滚动策略主要通过 File target 的几个属性来控制:

<target xsi:type="File"
        fileName="${basedir}/logs/app.log"
        archiveFileName="${basedir}/logs/archives/app.{#}.log"
        archiveEvery="Day"
        archiveNumbering="Rolling"
        maxArchiveFiles="30"
        archiveAboveSize="104857600" />

我来解释一下这几个参数:

  • archiveEvery:按什么周期归档。可选 DayHourMinuteMonth。我一般用 Day,每天一个归档文件。
  • archiveAboveSize:单个文件超过多大就归档。单位是字节,104857600 就是100MB。建议设一个值,防止某天日志量暴增导致单个文件过大。
  • maxArchiveFiles:最多保留多少个归档文件。超过这个数,最老的会被自动删除。我一般设30,也就是保留最近30天的日志。
  • archiveNumbering:归档文件的编号方式。 Rolling 是滚动编号, Date 是按日期编号。我习惯用 Rolling,配合 maxArchiveFiles 自动清理。

最佳实践:我建议同时设置 archiveEveryarchiveAboveSize。比如每天归档一次,但如果某天日志量特别大,单个文件超过100MB也会触发归档。这样双重保险,不会出现单个文件过大的情况。

还有一个容易被忽略的点:归档文件的命名规则{#} 是编号占位符, {#} 会自动替换成 1、2、3... 如果你用 archiveNumbering="Date",可以用 {#} 或者 {#} 来生成带日期的文件名。

举个例子,我常用的配置:

<target xsi:type="File"
        fileName="${basedir}/logs/app-${shortdate}.log"
        archiveFileName="${basedir}/logs/archives/app.{#}.log"
        archiveEvery="Day"
        archiveAboveSize="104857600"
        maxArchiveFiles="30"
        archiveNumbering="Rolling"
        concurrentWrites="true"
        keepFileOpen="false" />

这里我加了 concurrentWrites="true"keepFileOpen="false"。前者允许多个进程同时写同一个日志文件(多实例部署时有用),后者是每次写完就关闭文件句柄,防止文件被锁定。

我的习惯:我会把归档文件单独放在 archives 子目录里,和当前日志文件分开。这样查看当前日志时不会被归档文件干扰,而且归档目录可以单独设置清理策略。

3.5 把这些串起来:一个生产级配置示例

好了,上面讲的这些,咱们串成一个完整的配置。这是我个人在多个项目中验证过的模板,你直接拿去改改就能用:

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

  <targets>
    <target name="asyncFile" xsi:type="AsyncWrapper"
            queueLimit="5000" overflowAction="Discard">
      <target xsi:type="File"
              fileName="${basedir}/logs/app-${shortdate}.log"
              archiveFileName="${basedir}/logs/archives/app.{#}.log"
              archiveEvery="Day"
              archiveAboveSize="104857600"
              maxArchiveFiles="30"
              archiveNumbering="Rolling"
              concurrentWrites="true"
              keepFileOpen="false"
              layout="${longdate} | ${level:uppercase=true} | ${logger} | ${threadid} | ${message} ${exception:format=ToString}" />
    </target>

    <target name="console" xsi:type="Console"
            layout="${longdate} | ${level:uppercase=true} | ${message}" />
  </targets>

  <rules>
    <logger name="*" minlevel="Info" writeTo="asyncFile" />
    <logger name="*" minlevel="Warn" writeTo="console" />
  </rules>
</nlog>

这个配置做了几件事:

  • 所有 Info 及以上级别的日志,异步写入文件
  • Warn 及以上级别的日志,同时输出到控制台(开发时方便看)
  • 文件每天归档,单个文件超过100MB也归档
  • 最多保留30个归档文件,自动清理最老的
  • 每条日志都包含时间、级别、类名、线程ID、消息和异常信息

嗯,这一章的内容就到这里。结构化日志、布局渲染器、异步日志、归档滚动,这四个点你掌握好了,NLog在生产环境里基本就稳了。下一章,咱们聊聊如何把日志和全链路监控结合起来,让日志真正成为你排查问题的「眼睛」。