☰
Serilog 消息模板解析性能实测:从 183ns 到 2.6µs 的解析成本剖析
2026/10/15 16:51:31 网站建设 项目流程
  • 后端
  • 可观测性

【免费下载链接】serilog

Simple .NET logging with fully-structured events

项目地址:https://gitcode.com/gh_mirrors/se/serilog
点击查看免费下载

本篇技术指南以 results/net4.5.2/MessageTemplateParsingBenchmark-report-github.md 这份 BenchmarkDotNet 基准报告为主体,结合 Serilog 仓库中MessageTemplateParser的源码实现与对应测试,剖析消息模板解析(Message Template Parsing)的真实耗时构成。读完本文,你将掌握:该基准在什么环境下测得、两个用例相差约 14 倍的原因、解析器内部如何用快速路径与容错逻辑控制成本,以及如何在你的 .NET 应用中复现该基准并写出低解析开销的日志模板。

一、基准报告全景:一次真实运行的完整记录

这份报告是 BenchmarkDotNet 在运行MessageTemplateParsingBenchmark后自动生成的 GitHub 格式输出,包含两部分核心信息:宿主环境与测量结果。

1.1 宿主环境信息

Host Process Environment Information: BenchmarkDotNet.Core=v0.9.9.0 OS=Microsoft Windows NT 6.2.9200.0 Processor=Intel(R) Core(TM) i7-3720QM CPU 2.60GHz, ProcessorCount=8 Frequency=2533306 ticks, Resolution=394.7411 ns, Timer=TSC CLR=MS.NET 4.0.30319.42000, Arch=64-bit RELEASE [RyuJIT] GC=Concurrent Workstation JitModules=clrjit-v4.6.1080.0 Type=MessageTemplateParsingBenchmark Mode=Throughput

解读这份环境信息时需要注意:该数据来自 .NET Framework 4.5.2 时代的历史运行记录(BenchmarkDotNet.Core v0.9.9.0,CLR 4.0,RyuJIT 64 位 Release,并发工作站 GC)。它说明了两点:

  1. 基准模式为Mode=Throughput,即吞吐量模式,衡量单位时间内能完成多少次解析,报告中的中位数(Median)与标准差(StdDev)以纳秒为单位;
  2. 性能数字与 CPU 频率、运行时版本强相关,这些绝对值不能直接横向移植到其他机器或运行时,其核心价值在于同一环境下不同用例的相对比例(Scaled)。

1.2 测量结果

MethodMedianStdDevScaledScaled-SD
EmptyTemplate183.3209 ns3.9928 ns1.000.00
DefaultConsoleOutputTemplate2,636.2172 ns77.3304 ns14.400.50

两个关键结论:

  • EmptyTemplate(空模板)是基线,单次解析约 183ns;
  • DefaultConsoleOutputTemplate(默认控制台输出模板)约 2636ns,是空模板的14.40 倍。

仓库中还保留了一份更新环境下(BenchmarkDotNet v0.10.6,.NET Core 2.0,Mac OS X,i7-7567U)的同类报告 results/netcoreapp2.0/MessageTemplateParsingBenchmark-report-github.md,它还附带分配数据:

MethodMeanErrorStdDevScaledScaledSDGen 0Allocated
EmptyTemplate371.0 ns7.570 ns14.03 ns1.000.000.1259264 B
DefaultConsoleOutputTemplate2,912.1 ns66.661 ns190.19 ns7.860.581.09482296 B

对比两份报告可以观察到:虽然绝对耗时因机器与运行时差异而不同(空模板 183ns vs 371ns),但复杂模板相对空模板总是贵一个数量级这一规律是稳定的(14.40x vs 7.86x)。同时第二份报告揭示了一个重要事实:每次解析默认控制台模板会产生约2.3KB 的托管堆分配,这解释了为什么 Serilog 要在解析层之上再加缓存(见下文第五节)。

二、被测对象:三个基准用例与对应源码

报告对应的基准类位于 test/Serilog.PerformanceTests/MessageTemplateParsingBenchmark.cs,标注了[MemoryDiagnoser]以同时采集分配数据:

[MemoryDiagnoser] public class MessageTemplateParsingBenchmark { MessageTemplateParser _parser = new(); [Benchmark(Baseline = true)] public void EmptyTemplate() { _parser.Parse(""); } [Benchmark] public void DefaultConsoleOutputTemplate() { _parser.Parse("{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level}] {Message}{NewLine}{Exception}"); } [Benchmark] public void LiteralTextOnly() { _parser.Parse("Token validated for claims principal with identifier `HJdfshjka678-HJK68SFHJKhjkfsdhjsd456`"); } }

三个用例代表了模板解析的三种典型形态:

  1. EmptyTemplate:空字符串,最极端的快速路径;
  2. DefaultConsoleOutputTemplate:Serilog 默认控制台输出模板({Timestamp:yyyy-MM-dd HH:mm:ss} [{Level}] {Message}{NewLine}{Exception}),包含带格式的属性、纯属性、文本与转义序列,是真实世界中每条控制台日志都会用到的模板;
  3. LiteralTextOnly:无任何属性的纯文本模板(报告正文未单独列出其数据,但源码中保留了该用例,用于测纯文本场景的成本)。

三、模板解析在 Serilog 中的位置:调用链全景

要理解这份基准的意义,先要看解析器在 Serilog 中的角色。消息模板解析发生在两个层面:

3.1 日志消息模板的解析(热路径)

每条日志调用最终都会把模板字符串解析成MessageTemplate。这条路径由 src/Serilog/Capturing/MessageTemplateProcessor.cs 承接,它内部组合了带缓存的解析器:

readonly MessageTemplateCache _parser = new(new MessageTemplateParser());

也就是说,Logger层并不直接触碰MessageTemplateParser,而是经由MessageTemplateCache包一层——这层缓存的存在正是为了把基准中测到的 2.6µs 级解析成本在重复日志调用中摊薄到近乎为零。

3.2 输出模板的解析(低频路径)

控制台、文件等 Sink 的输出模板(outputTemplate)由 src/Serilog/Formatting/Display/MessageTemplateTextFormatter.cs 解析:

_outputTemplate = new MessageTemplateParser().Parse(outputTemplate);

注意这里直接使用裸的MessageTemplateParser,没有缓存——但输出模板通常在 Logger 构建期只解析一次,因此其成本可忽略。

3.3 解析入口的定义

MessageTemplateParser实现自IMessageTemplateParser接口(src/Serilog/Core/IMessageTemplateParser.cs),其Parse(string)方法返回MessageTemplate。入口实现位于 src/Serilog/Parsing/MessageTemplateParser.cs:

public MessageTemplate Parse(string messageTemplate) { Guard.AgainstNull(messageTemplate); return new(messageTemplate, Tokenize(messageTemplate)); }

MessageTemplate(src/Serilog/Events/MessageTemplate.cs)在构造时会一次性把 token 序列化为数组,并扫描出位置参数(PositionalProperties)与命名属性(NamedProperties),供后续属性绑定使用。

四、解析成本从何而来:源码级剖析

MessageTemplateParser的核心是一个迭代式扫描器Tokenize(src/Serilog/Parsing/MessageTemplateParser.cs),它交替识别文本 token(TextToken)与属性 token(PropertyToken):

IEnumerable<MessageTemplateToken> Tokenize(string messageTemplate) { if (messageTemplate.Length == 0) { yield return EmptyTextToken; yield break; } var nextIndex = 0; while (true) { var beforeText = nextIndex; var tt = ParseTextToken(nextIndex, messageTemplate, out nextIndex); if (nextIndex > beforeText) yield return tt; // ...识别属性 token 并继续 } }

4.1 空模板为何只要约 183ns

Tokenize的第一行就对空字符串做了专门处理:直接返回一个静态共享的EmptyTextToken(static readonly TextToken EmptyTextToken = new("")),不分配新对象、不进入主循环。整个Parse("")的成本几乎只剩方法调用与一次MessageTemplate构造。这正是它成为 1.00 基线的底层原因。

4.2 文本扫描的快速路径设计

ParseTextToken(src/Serilog/Parsing/MessageTemplateParser.cs)是解析器中最讲究性能的部分,其设计原则是:大多数模板不含转义序列,应尽量走单次Substring的最快路径;只有真正遇到{{/}}转义时才退化为 StringBuilder 慢路径。

// 如果遇到转义序列 {{ 或 }},结果就不是模板的严格子串。 // 但这需要分配 StringBuilder,所以先尽可能远地按快速路径解析, // 只在确实需要时才分配 StringBuilder 并落入慢速字符串构建路径。 var i = messageTemplate.IndexOfAny(CurlyBraceChars, startAt); if (i == -1) { // 模板中不再有感兴趣字符,剩余全是文本。 next = messageTemplate.Length; return new(messageTemplate[startAt..]); }
  • 快速路径:IndexOfAny找到{/}后,若命中的是属性开始符{且非转义,则直接Substring切出文本 token,全程零额外分配(除 token 对象本身);
  • 慢速路径:仅当检测到{{、}}转义时,才创建StringBuilder逐字符累积。

结合基准数据可以推断:DefaultConsoleOutputTemplate中的{NewLine}与{Exception}之间、{Level}前的[等都是普通文本,走快速路径;整个模板只有一次Substring级分配,成本集中在属性 token 的处理上。

4.3 属性 token 的校验链:复杂模板的 14 倍差距来源

ParsePropertyToken(src/Serilog/Parsing/MessageTemplateParser.cs)负责把{...}切分为属性名、解构提示、格式与对齐宽度,需要依次执行:

  1. 定位}并切出rawText与tagContent;
  2. TrySplitTagContent按:与,分离 format 与 alignment;
  3. 校验属性名合法性:TryContinuePropertyName要求首字符为字母或_,后续允许字母数字、_、.(.后需重新进入标识符状态),纯数字则按位置参数校验(src/Serilog/Parsing/MessageTemplateParser.cs);
  4. 校验格式串(IsValidInFormat:格式中不允许裸});
  5. 解析对齐宽度:允许-前缀表示左对齐,+前缀与int.MinValue均判为非法并回退为文本;
  6. 最终构造PropertyToken,并附带把属性名解析为位置索引(src/Serilog/Parsing/PropertyToken.cs)。

整个解析过程的容错哲学是:任何一步不合法,就把整段{...}当作普通文本TextToken输出,绝不抛异常。这一点由 test/Serilog.Tests/Parsing/MessageTemplateParserTests.cs 中大量测试锁定,例如:

  • AMalformedPropertyTagIsParsedAsText:{0 space}、{0_space、{.testname}等 13 种畸形标签全部回退为文本;
  • AlignmentOfIntMinValueIsParsedAsText:{Hello,-2147483648}因int.MinValue无正数对应而被判为文本;
  • DestructureWithInvalidHintsIsParsedAsText:{@$}、{$@}回退为文本。

默认控制台输出模板之所以比空模板贵约 14 倍,从源码可以归纳出三个因素:

  • token 数量多:{Timestamp:...}、[{Level}]、{Message}、{NewLine}、{Exception}共 5 个属性 token 加若干文本 token,每个都要走完整的属性校验链;
  • 每个属性 token 都要构造PropertyToken对象,并在MessageTemplate构造时被拷贝进 token 数组、扫描分类;
  • 模板带 format 与多个位置参数,TrySplitTagContent的分隔符扫描与int.TryParse都是额外 CPU 与少量分配。

五、缓存层:为什么生产环境感受不到 2.6µs

MessageTemplateCache(src/Serilog/Core/Pipeline/MessageTemplateCache.cs)是解析基准与真实日志热路径之间的桥梁。它的设计细节解释了基准数字如何被摊薄:

const int MaxCacheItems = 1000; const int MaxCachedTemplateLength = 1024; public MessageTemplate Parse(string messageTemplate) { Guard.AgainstNull(messageTemplate); if (messageTemplate.Length > MaxCachedTemplateLength) return _innerParser.Parse(messageTemplate); var result = (MessageTemplate?)_templates[messageTemplate]; if (result != null) return result; result = _innerParser.Parse(messageTemplate); lock (_templatesLock) { if (_templates.Count == MaxCacheItems) _templates.Clear(); _templates[messageTemplate] = result; } return result; }

关键实现事实:

  • 底层用Hashtable而非Dictionary<TKey,TValue>,注释明确说明是出于线程安全考量——Hashtable允许单写者多读者无需加锁(src/Serilog/Core/Pipeline/MessageTemplateCache.cs),并配合ByReferenceStringComparer做按引用比较以加速命中;
  • 超过1024 字符的模板直接绕过缓存交给裸解析器,防止超长模板撑爆缓存;
  • 缓存条目达到1000时整体清空(_templates.Clear()),注释说明这是在"库被误用(直接传动态字符串而非常量模板)"时防止 OOM 的兜底,正确用法下几乎不会触发。

因此在实际应用中,只要日志模板是编译期常量(如Log.Information("User {UserId} logged in", id)),首次调用付出一次解析成本后,后续调用全部命中缓存,2.6µs 的解析开销不复存在。仓库中的 MessageTemplateCacheBenchmark_Cached.cs 正是专门验证缓存命中路径性能的配套基准。

六、如何复现与运行这份基准

当前仓库的性能测试工程为 test/Serilog.PerformanceTests/Serilog.PerformanceTests.csproj,目标框架为net10.0;net9.0;net8.0,引用 BenchmarkDotNet 0.15.2 与 xUnit。运行方式有两种:

方式一:通过 xUnit 测试宿主(推荐)

test/Serilog.PerformanceTests/Harness.cs 提供了把各基准包装为 xUnit 测试的入口,其中MessageTemplateParsing()方法即运行本基准:

dotnet test -c Release --filter "FullyQualifiedName=Serilog.PerformanceTests.Harness.MessageTemplateParsing"

如需指定目标框架,可追加-f net8.0(或 net9.0 / net10.0)。

方式二:仓库脚本一键运行全部基准

仓库根目录的 RunPerfTests.ps1 会先执行Build.ps1编译,再对每个test/*.PerformanceTests工程执行dotnet test -c Release,输出即落在results/<框架>/目录下,格式与本文剖析的报告一致。

复现时的注意事项:

  • 性能基准对运行环境敏感,应使用 Release 配置、尽量关闭后台负载;
  • 同一环境多次运行取稳定值后再对比,重点关注Scaled相对比例而非绝对 ns;
  • 报告中的历史数据(net4.5.2、netcoreapp2.0)仅代表当时机器与运行时,不要与当前 .NET 8/9/10 的实测值直接比较绝对值。

七、从基准到实践:降低模板解析开销的工程建议

基于上述源码证据与基准数据,可以得出几条可操作的实践结论:

  1. 日志模板坚持使用编译期常量。MessageTemplateCache对重复模板零成本命中;若传入每次拼接的动态字符串(如$"User {id}..."),既绕过常量模板分析器的提示,又可能触发缓存失效与高频解析。

  2. 尽量控制模板中的属性数量与格式复杂度。每个属性 token 都对应完整的校验链与对象分配(netcoreapp2.0 报告显示默认控制台模板单次解析分配约 2296B),属性越少、格式越简单,首次解析成本越低。

  3. 输出模板(outputTemplate)在构建期只解析一次,其解析成本不会进入每条日志的热路径,可放心使用丰富的排版(对齐、多段格式),不必为此过度优化。

  4. 注意转义序列的慢路径。模板中的{{/}}会迫使ParseTextToken走 StringBuilder 累积路径(src/Serilog/Parsing/MessageTemplateParser.cs),属于相对罕见的成本分支,日常模板应避免无谓的转义。

  5. 不要放大畸形模板的异常成本。解析器对畸形标签的既定策略是回退为文本、绝不抛异常(由 MessageTemplateParserTests.cs 全量锁定),因此即使模板书写有误,最坏情况也只是多几个文本 token,不会拖垮日志路径。

结语

这份基准报告的价值不在于 183ns 或 2636ns 这两个绝对数字,而在于它揭示了 Serilog 模板解析的成本结构:空模板走静态共享 token 的极速路径,复杂属性模板则要付出约一个数量级的校验与分配代价。而源码中的MessageTemplateCache、ParseTextToken快速路径设计,正是把这份成本在日常使用中摊薄为零的关键。理解这一层,既能帮你写出更高效的日志代码,也能在阅读性能报告时准确判断哪些优化值得做、哪些不必做。

相关参考文件:

  • 基准报告(.NET Framework 4.5.2):results/net4.5.2/MessageTemplateParsingBenchmark-report-github.md
  • 基准报告(.NET Core 2.0,含分配数据):results/netcoreapp2.0/MessageTemplateParsingBenchmark-report-github.md
  • 基准源码:test/Serilog.PerformanceTests/MessageTemplateParsingBenchmark.cs
  • 解析器实现:src/Serilog/Parsing/MessageTemplateParser.cs
  • 解析缓存:src/Serilog/Core/Pipeline/MessageTemplateCache.cs
  • 解析行为测试:test/Serilog.Tests/Parsing/MessageTemplateParserTests.cs
  • 后端
  • 可观测性

【免费下载链接】serilog

Simple .NET logging with fully-structured events

项目地址:https://gitcode.com/gh_mirrors/se/serilog
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询