☰
log4net 1.2.10 遗留系统日志配置、滚动与排查指南
2026/10/26 8:59:52 网站建设 项目流程

简介:log4net-1.2.10 是面向 .NET 开发者的一份经典日志框架版本包,包含源码与 DLL,适合需要在 ASP.NET、Windows 服务、WPF 或 WinForms 项目中快速接入日志能力的初中级开发者,也便于进阶读者通过源码理解其配置解析、Appender 与 Layout 扩展机制。压缩包约 6.79MB,以上游提供的源码和程序集文件为主;DLL 可直接引用,源码则可用于研读内部实现与定制思路。框架支持 XML、代码或属性文件配置,覆盖 DEBUG、INFO、WARN、ERROR、FATAL 等级别,并可输出到文件、控制台、数据库、SMTP 等目标,同时兼顾线程安全与高负载下的性能表现。已有 270 人学习,读者可据此掌握日志级别控制、输出端扩展、异常记录、性能分析与系统监控等常见用法,并对照示例完成初始化与调用,对实际项目中的日常开发调试、异常追踪、线上排障以及审计合规需求也有参考价值。

1. 一个 1.2.10 的 log4net 程序集,为什么值得单独留档

接手一个跑了七八年的 .NET Framework 后台服务,最尴尬的不是代码看不懂,而是它某天凌晨开始不写日志了,bin 目录里只有一个 log4net.dll,没有包管理清单也没有注释。log4net-1.2.10 就是这么个东西:一套面向 .NET Framework 2.0 时代的日志框架程序集,配套配置文件骨架、PatternLayout 转换词说明和一批 appender 实现,专门解决老系统“日志写在哪、写多细、写满了怎么办”。它适合还在维护 .NET Framework 项目的人、要给遗留系统补日志的人,以及不方便动框架版本、又必须把日志接到文件、数据库或事件日志里的人。后面按“程序集身份 → 配置怎么摆 → 滚动与锁 → 出问题看哪里”往下拆。

2. 版本锚点:1.2.10 卡在 .NET 的哪一段

老项目里最容易被忽略的一步,是先确认手上这个 dll 到底是什么身份。同名的 log4net 在不同年代编译出来的程序集版本、强命名公钥、目标框架都不一样,引用关系一旦错位,报出来的错会离谱到你怀疑代码写错了。这一章先把身份钉死,再讨论该留还是该换。

2.1 用反射读出程序集身份,别靠文件名猜

文件名可以随便改,程序集身份改不了。在一个能跑起来的进程里加三行代码,比翻十份文档都准。

// 放在 Main 的第一行,或者任意静态构造函数里 var asm = typeof(log4net.ILog).Assembly; Console.WriteLine(asm.FullName); // 完整标识:名字、版本、区域、公钥令牌 Console.WriteLine(asm.Location); // 实际加载的物理路径,排查"到底加载了哪一份" Console.WriteLine(asm.ImageRuntimeVersion); // 编译时的 CLR 版本

FullName输出形如log4net, Version=1.2.10.0, Culture=neutral, PublicKeyToken=1b44e1d426115821。这三段信息各有用途:版本号决定 bindingRedirect 的写法,公钥令牌决定配置文件里<assemblyIdentity>能不能匹配上,Location决定你改的到底是不是运行时真正加载的那一份——我见过有人在调试目录改配置改了半天,结果进程加载的是 GAC 里的旧副本,血泪经验。

下面这张表是老项目交接时我必填的一张卡,建议你也照着填一遍:

属性典型值影响什么
程序集简单名log4net引用名、配置节类型名里的那一段
程序集版本1.2.10.0绑定重定向的目标版本
公钥令牌1b44e1d426115821强命名匹配,写错就加载失败
目标框架.NET Framework 2.0能否被 3.5 / 4.x 项目直接引用
区域neutral一般不用管,配置里照抄即可

注意:不同来源、不同编译配置的同一个版本号,公钥令牌理论上应当一致,但如果你手上是从某个旧系统 bin 目录直接抠出来的 dll,务必先用上面的代码读一遍再写进配置文件,不要照抄网上搜到的字符串。

2.2 三个“必须留在 1.2.10”的现实场景

第一个场景是目标机器上的 .NET Framework 版本被锁死。某些内网服务器只装到 2.0 或 3.5,新版本的日志库根本加载不了,这时候不是“想不想升级”的问题,而是“升了就起不来”。

第二个场景是强命名链锁。项目里可能有个第三方组件,它的清单里写死了log4net, Version=1.2.10.0,你换掉 dll 之后它直接抛FileLoadException。这种依赖你改不了源码,只能在配置层面做绑定重定向,把请求统一导到同一个版本上。

第三个场景是行为一致性。日志组件的输出格式、滚动行为、异常吞掉的方式,都会影响下游的采集脚本和告警规则。老系统跑了好几年,日志解析正则早就按某种格式固化了,为了“用新版”去改一整套采集链路,性价比很低。

反过来说,出现下面这些情况就该认真考虑换掉:需要跑在 .NET Core / .NET 5+ 上(1.2.10 是纯 .NET Framework 时代的产物,跨平台运行时加载不了);需要结构化日志输出而现有 PatternLayout 撑不住;或者你发现线上问题是日志组件自身的并发缺陷导致的,那就不是配置能绕过去的了。

2.3 绑定重定向:让所有引用都落到同一份 dll

多项目解决方案里最常见的翻车点,是 A 项目引用 1.2.10、B 项目引用另一个版本,编译能过,运行时随机报程序集加载失败。解决办法是在入口项目的 app.config / web.config 里加一段重定向,把低版本请求全部拉到实际部署的版本上。

<configuration> <runtime> <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <dependentAssembly> <assemblyIdentity name="log4net" publicKeyToken="1b44e1d426115821" culture="neutral" /> <!-- oldVersion 写范围,表示"这一区间内的请求都重定向" --> <bindingRedirect oldVersion="0.0.0.0-1.2.10.0" newVersion="1.2.10.0" /> </dependentAssembly> </assemblyBinding> </runtime> </configuration>

oldVersion用范围写法比写死一个版本号更稳,因为它能兜住那些清单里写着更早版本号的第三方组件。newVersion必须与部署目录里那份 dll 的真实版本完全一致,差一个数字都不生效。改完之后别急着发布,先在本地把bin清空重编译一次,确认没有残留的旧 dll 混在里面——清理不干净导致的“改了没用”,比配置写错更常见。

3. 装配顺序:XmlConfigurator 和 configSections 摆对位置

日志组件“不工作”的案例里,八成不是配置项写错,而是装配顺序错了:配置文件没被读、读的时候路径不对、读到了但被后面的初始化覆盖。这一章把加载链路从头走一遍。

3.1 独立配置文件还是内嵌到 app.config

两种方式都行,区别在于谁来管这份配置。独立文件(通常叫 log4net.config)的好处是运维可以直接改、可以按环境替换,不用动主配置文件;坏处是多一个“必须拷贝到输出目录”的步骤,漏了就是静默不写日志。

<?xml version="1.0" encoding="utf-8"?> <log4net> <!-- 控制台输出,本地调试用 --> <appender name="Console" type="log4net.Appender.ConsoleAppender"> <layout type="log4net.Layout.PatternLayout"> <conversionPattern value="%date{HH:mm:ss.fff} %-5level %logger - %message%newline" /> </layout> </appender> <!-- 文件输出,注意 file 是相对路径 --> <appender name="FileAppender" type="log4net.Appender.FileAppender"> <file value="logs/app.log" /> <appendToFile value="true" /> <encoding value="utf-8" /> <layout type="log4net.Layout.PatternLayout"> <conversionPattern value="%date{yyyy-MM-dd HH:mm:ss.fff} [%thread] %-5level %logger - %message%newline%exception" /> </layout> </appender> <root> <level value="INFO" /> <appender-ref ref="Console" /> <appender-ref ref="FileAppender" /> </root> </log4net>

内嵌到 app.config 的方式少一次文件拷贝,但有两个硬约束:<configSections>必须是<configuration>的第一个子元素,<log4net>节必须写在<configSections>之后。

<?xml version="1.0" encoding="utf-8"?> <configuration> <!-- 必须是第一个子元素,顺序错了直接配置解析失败 --> <configSections> <section name="log4net" type="log4net.Config.Log4NetConfigurationSectionHandler, log4net" /> </configSections> <log4net> <!-- appender 与 root 的写法和独立文件完全一致 --> </log4net> <startup> <supportedRuntime version="v2.0.50727" /> </startup> </configuration>

type属性里的, log4net是程序集简单名,如果你做了绑定重定向或者 dll 改了名,这一段也要跟着改,否则会报“无法识别的配置节”。这个报错信息很不友好,往往只说“配置节 log4net 无法读取”,不会告诉你到底是顺序问题还是类型名问题。

3.2 初始化时机:特性声明与显式调用

特性声明是最省事的做法,把一行代码写进 AssemblyInfo.cs,程序集加载时自动执行:

// AssemblyInfo.cs using log4net.Config; // ConfigFile 相对路径的基准是应用根目录,Watch=true 表示监听文件变更 [assembly: XmlConfigurator(ConfigFile = "log4net.config", Watch = true)]

但特性方式有个隐蔽问题:它依赖程序集被加载的时机。如果你的日志写在某个类库的静态构造函数里,而那个类库的程序集还没被加载,特性自然没跑,日志就是空的。所以我一般会在入口处显式调用一次,把路径算清楚:

// Program.cs / Global.asax 的 Application_Start using System; using System.IO; using log4net.Config; internal static class LogBootstrapper { public static void Init() { // 用 BaseDirectory 而不是当前工作目录,避免快捷方式/计划任务改变 CWD var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "log4net.config"); var file = new FileInfo(path); if (!file.Exists) { // 配置缺失必须显式暴露,不能默默降级 throw new FileNotFoundException("日志配置文件缺失", path); } XmlConfigurator.Configure(file); } }

Configure(FileInfo)会把配置一次性读进来;如果要支持运行期改配置,用ConfigureAndWatch(TimeSpan)或特性里的Watch=true。注意配置文件在项目里的“复制到输出目录”属性必须设成“如果较新则复制”或“始终复制”,这是最容易被漏掉的一步,没有之一。

3.3 Logger 名字里的点号,决定了级别怎么继承

log4net 的 logger 是按名字组织成树的,名字用点号分层,子级没显式配置级别时向上找父级,一直找到<root>。理解这一点,才能写出“整体 INFO、某个模块 DEBUG”这种精细配置。

// 用 typeof 而不是手写字符串,重构改名后配置才不会失配 private static readonly log4net.ILog Log = log4net.LogManager.GetLogger(typeof(OrderService)); // logger 名 = 类型的完整名称,例如 模拟项目X.Order.OrderService

对应的配置:

<root> <level value="INFO" /> <!-- 兜底级别 --> <appender-ref ref="FileAppender" /> </root> <!-- 名字是 root 子树下的一个节点,级别 DEBUG 会传给它的所有子 logger --> <logger name="模拟项目X.Order"> <level value="DEBUG" /> </logger> <!-- additivity=false 表示不再向 root 传递,避免同一条日志被两个 appender 写两遍 --> <logger name="模拟项目X.Audit" additivity="false"> <level value="INFO" /> <appender-ref ref="AuditFileAppender" /> </logger>

additivity是个双刃剑:设成false能避免重复写,但如果这个 logger 下面没有挂任何 appender,日志就凭空消失了,而且不报错。我在排查“某个模块日志全空”时,第一步就是去看它是不是被加了additivity="false"却忘了挂 appender。

4. RollingFileAppender:滚动策略、锁模型与参数怎么配

生产环境最常用的就是这个 appender,也是最容易配出问题的。滚动策略决定文件怎么切,锁模型决定别人能不能在你写的时候读,两者互相牵制,参数改一个就要连带看另一个。

4.1 RollingStyle 的四种取值与 StaticLogFileName 的联动

Size按大小切,Date按时间切,Composite是两者任一满足就切,Once只在启动时切一次(特殊场景才用,日常配它基本等于不滚动)。

staticLogFileName决定当前正在写的那个文件是不是固定名。设成true时,当前文件永远叫app.log,历史文件带日期或序号后缀;设成false时,当前文件名本身就带日期,跨天之后会直接换一个新文件。两种都合理,但和采集脚本的约定要对上——采集端如果是按app.log*通配匹配的,改成另一种就会漏采。

countDirection控制序号增长方向:1表示新文件序号更大,-1表示新文件序号更小(即.1永远是最新的)。日志分析工具通常默认.1是最新,如果你的运维脚本按这个假设写的,就把countDirection设成-1。

4.2 锁模型:三种选择,各有代价

FileAppender的lockingModel有三个常见取值,选错了要么性能塌方,要么日志文件被独占导致运维打不开。

ExclusiveLock是默认值,进程持有文件句柄期间独占,写入性能最好。代价是你没法用记事本打开正在写的日志,运维想看实时日志只能复制一份。

MinimalLock每次写都打开、写完立刻关闭,文件不再被长期占用,随时可以用编辑器打开看。代价是每写一条日志都要走一次文件系统调用,高频写入场景下开销明显,而且频繁开关在机械盘上更容易触发 IO 抖动。

InterProcessLock试图用命名互斥体协调多进程写入,看起来很美好,实际上在 1.2.10 这个年代实现得并不健壮:进程异常退出可能留下锁残留,路径大小写或映射盘符不同还会导致两个进程认为自己在锁不同的对象。多进程写同一个文件这件事,我的一贯做法是直接放弃,改成按进程或按实例拆文件,把并发问题消灭在文件命名阶段。

4.3 一份可以直接抄的滚动配置

<appender name="RollingFile" type="log4net.Appender.RollingFileAppender"> <file value="logs/app.log" /> <appendToFile value="true" /> <rollingStyle value="Composite" /> <datePattern value="yyyyMMdd" /> <maximumFileSize value="20MB" /> <maxSizeRollBackups value="20" /> <staticLogFileName value="true" /> <countDirection value="-1" /> <lockingModel type="log4net.Appender.FileAppender+MinimalLock" /> <threshold value="DEBUG" /> <encoding value="utf-8" /> <layout type="log4net.Layout.PatternLayout"> <conversionPattern value="%date{yyyy-MM-dd HH:mm:ss.fff} [%thread] %-5level %logger{1} - %message%newline%exception" /> </layout> </appender>

参数逐个说明:

参数本例取值说明
filelogs/app.log相对路径的基准是进程工作目录,不是程序集目录
appendToFiletrue重启后追加,设 false 会在每次启动时清空,生产环境绝对不要
rollingStyleComposite大小或日期任一触发即滚动
datePatternyyyyMMdd.NET 日期格式串,写成YYYYMMDD会原样输出成字母
maximumFileSize20MB支持 KB / MB / GB 后缀,不带后缀按字节算
maxSizeRollBackups20保留的历史份数,设 0 等于不保留,设负数等于无限增长
staticLogFileNametrue当前文件固定名,历史文件带后缀
countDirection-1序号越小越新
thresholdDEBUGappender 级过滤,比 logger 级更细
encodingutf-8不写默认按系统 ANSI,中文容易乱码

maxSizeRollBackups设成负数这件事值得单独提醒一句:它表示“不限制”,磁盘写满只是时间问题。我见过一台机器因为这个参数被设成-1,三个月后日志目录吃了 40 多个 G。

4.4 PatternLayout 转换词与它们的性能成本

conversionPattern里%开头的都是转换词,常用的这些:

转换词含义相对成本
%date / %d时间戳,可带格式低
%level / %p级别低
%logger / %clogger 名,{1}表示只留最后一段低
%message / %m消息体低
%newline / %n换行低
%thread / %t线程名中
%X{key}ThreadContext 属性低
%property{key}全局属性低
%exception异常堆栈中,仅异常时产生
%file / %F源文件名高,走调用栈
%line / %L行号高,走调用栈
%method / %M方法名高,走调用栈
%location / %l完整位置最高,等价于上三者之和

%L和%l是最常见的性能陷阱:为了在日志里看到行号,每条日志都要抓一次调用栈,高频路径下开销可能是普通输出的几倍。我的建议是默认不开,需要定位问题时临时打开某个 logger 的 appender 单独加,问题定位完就撤掉。

5. 排查清单:日志不写的五个真实原因

日志组件最让人抓狂的地方是它几乎不报错——配置错了就是静默不写,你只能靠猜。这一章列五条我实际踩过的,每条按“现象 → 原因 → 解决”走一遍。开启内部调试是排查的总闸门:在 app.config 的<appSettings>里加<add key="log4net.Internal.Debug" value="true"/>,或者在代码里设log4net.Util.LogLog.InternalDebugging = true,组件内部的异常和配置解析细节就会打到控制台。

5.1 现象:程序跑起来了,日志文件根本没生成

原因通常有三层。最外层是log4net.config没被复制到输出目录,程序读的是一个不存在的路径;中间层是初始化代码根本没执行,比如日志写在某个静态构造函数里而那个类没被用到;最内层是进程账户对目标目录没有写权限,尤其 IIS 应用池账户和 Windows 服务账户,默认对程序目录之外的路径是没有权限的。

解决顺序是自外向内:先确认输出目录里配置文件在不在,再在初始化处打断点或临时输出一行,最后检查目录权限。目录不存在时组件会尝试创建,但前提是父目录给了创建权限;更稳的做法是部署脚本里预先建好日志目录并授权。

// 临时自查:把关键信息打到控制台,比猜快得多 Console.WriteLine(AppDomain.CurrentDomain.BaseDirectory); Console.WriteLine(Environment.CurrentDirectory); Console.WriteLine(File.Exists(Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "log4net.config")));

5.2 现象:改了配置文件,运行时行为一点没变

原因有两类。一是Watch=true只监听已经成功加载的那个文件,如果第一次加载就失败了,后面改多少次都不会被捕捉到,必须重启进程。二是路径解析基准搞错:独立配置文件用的是相对路径时,基准可能是当前工作目录,计划任务或快捷方式启动的进程,工作目录和程序目录经常不是同一个。

解决办法是配置文件路径一律用AppDomain.CurrentDomain.BaseDirectory拼绝对路径,然后重新走一遍初始化。另外在生产环境慎用Watch=true:它会长期持有文件句柄,运维在服务器上直接编辑配置文件时会提示文件被占用,改完保存不了,这一条踩过的人不少。

5.3 现象:滚动之后历史日志少了一段,或者旧文件被反复覆盖

原因多半在staticLogFileName、countDirection、maxSizeRollBackups三个参数的组合上。比如staticLogFileName=false又配了rollingStyle=Size,文件名会随日期变,但序号规则没变,跨天之后新旧文件的序号可能撞车;再比如maxSizeRollBackups设得太小,滚动一次就把最早的文件删了,事后想追查前一天的问题已经没了。

解决方法是先用rollingStyle=Composite+staticLogFileName=true+countDirection=-1这个稳妥组合,把历史保留份数按“日均日志量 × 需要追溯的天数 ÷ 单文件大小上限”算出来,别拍脑袋。改完做一次验证:手动写几条超长日志触发滚动,然后ls -l看一眼文件名序列是否符合预期,五分钟的事,能省掉一次事故复盘时的尴尬。

5.4 现象:日志文件被占用,运维打不开、也删不掉

默认的ExclusiveLock会持有文件句柄直到进程退出,这在生产环境很常见。另一个来源是多进程或多应用池写同一个文件,滚动时 A 进程要重命名、B 进程还拿着句柄,重命名失败之后日志可能继续写进一个“本该被归档”的文件里,文件数量看着对,内容却是混的。

解决方法是单进程场景改成MinimalLock,用一点性能换可运维性;多进程场景拆文件,用进程 ID 或实例名做文件名后缀,比如app_%property{PID}.log,配合全局属性在启动时赋值。不要在 1.2.10 上指望InterProcessLock兜住多进程并发,这条路走不通。

5.5 现象:发布后报“无法加载程序集 log4net”或版本冲突

原因是有组件引用了不同版本的 log4net,运行时按清单去找指定版本,找不到就抛异常。解决办法是按 2.3 节做绑定重定向,把低版本请求统一拉到部署版本;同时清理输出目录里残留的旧 dll,很多人本地能跑、服务器不能跑,就是因为本地 bin 里恰好留着一份老版本。

排查这类问题有个笨但有效的办法:在异常捕获处把ex.FusionLog或ex.Message完整打出来,加载失败的详细信息里会写明它找的是哪个版本、在哪些探测路径找过。

6. 进阶用法:动态级别、上下文属性与上线前自检

线上出问题时最想要的能力,是不重启进程就把某个模块的日志级别调细。1.2.10 支持运行期改级别,前提是你拿到了正确的 Hierarchy 对象,并且注意别被配置热加载冲掉。

// 把某个 logger 临时调成 DEBUG,不用改配置文件 var hierarchy = (log4net.Repository.Hierarchy.Hierarchy)log4net.LogManager.GetRepository(); var logger = (log4net.Repository.Hierarchy.Logger)hierarchy.GetLogger("模拟项目X.Order"); logger.Level = hierarchy.LevelMap["DEBUG"]; hierarchy.Configured = true; // 标记为已配置,防止 Watch 重载时把手工设置覆盖回去

hierarchy.Configured = true这一行是关键。不设它的话,Watch=true监听到文件变动重新加载配置时,会用手工设置之前的级别覆盖回去,表现为“调了生效几秒又变回去了”,非常难查。

第二个技巧是给日志加业务上下文。排查问题时最痛苦的不是日志太少,而是几十个并发请求的日志交织在一起分不清谁是谁。用ThreadContext塞一个追踪号,配上%X{TraceId}就能把一次请求的所有日志串起来:

// 入口处赋值,注意 async/await 场景要用 LogicalThreadContext log4net.LogicalThreadContext.Properties["TraceId"] = traceId; try { // 业务代码 } finally { // 必须清理:线程池会复用线程,不清会把追踪号带到下一个请求上 log4net.LogicalThreadContext.Properties.Remove("TraceId"); }

在同步代码里ThreadContext够用,但只要用了async/await,线程会切换,ThreadContext的值就丢了,必须换成LogicalThreadContext。这个差异不会报错,只会表现为“日志里追踪号时有时无”,我当初查了半天才反应过来。

最后是上线前的自检,我一般固定跑三步:用第 2 章那段反射代码打印一次FullName和Location,确认加载的是预期那一份;打开log4net.Internal.Debug跑一遍启动流程,把内部调试输出扫一眼有没有配置节解析告警;再手工触发一次滚动,确认历史文件命名和保留份数符合预期。三步十分钟,能挡掉绝大多数“上线才发现没日志”的场面。

从那以后我每次接老项目,第一件事就是把程序集身份和配置文件路径写进交接文档,宁可多花十分钟,也不再靠猜。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询