- 后端
- 可观测性
【免费下载链接】serilog
Simple .NET logging with fully-structured events
本篇技术指南以 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)。它说明了两点:
- 基准模式为
Mode=Throughput,即吞吐量模式,衡量单位时间内能完成多少次解析,报告中的中位数(Median)与标准差(StdDev)以纳秒为单位; - 性能数字与 CPU 频率、运行时版本强相关,这些绝对值不能直接横向移植到其他机器或运行时,其核心价值在于同一环境下不同用例的相对比例(Scaled)。
1.2 测量结果
| Method | Median | StdDev | Scaled | Scaled-SD |
|---|---|---|---|---|
| EmptyTemplate | 183.3209 ns | 3.9928 ns | 1.00 | 0.00 |
| DefaultConsoleOutputTemplate | 2,636.2172 ns | 77.3304 ns | 14.40 | 0.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,它还附带分配数据:
| Method | Mean | Error | StdDev | Scaled | ScaledSD | Gen 0 | Allocated |
|---|---|---|---|---|---|---|---|
| EmptyTemplate | 371.0 ns | 7.570 ns | 14.03 ns | 1.00 | 0.00 | 0.1259 | 264 B |
| DefaultConsoleOutputTemplate | 2,912.1 ns | 66.661 ns | 190.19 ns | 7.86 | 0.58 | 1.0948 | 2296 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`"); } }三个用例代表了模板解析的三种典型形态:
- EmptyTemplate:空字符串,最极端的快速路径;
- DefaultConsoleOutputTemplate:Serilog 默认控制台输出模板(
{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level}] {Message}{NewLine}{Exception}),包含带格式的属性、纯属性、文本与转义序列,是真实世界中每条控制台日志都会用到的模板; - 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)负责把{...}切分为属性名、解构提示、格式与对齐宽度,需要依次执行:
- 定位
}并切出rawText与tagContent; TrySplitTagContent按:与,分离 format 与 alignment;- 校验属性名合法性:
TryContinuePropertyName要求首字符为字母或_,后续允许字母数字、_、.(.后需重新进入标识符状态),纯数字则按位置参数校验(src/Serilog/Parsing/MessageTemplateParser.cs); - 校验格式串(
IsValidInFormat:格式中不允许裸}); - 解析对齐宽度:允许
-前缀表示左对齐,+前缀与int.MinValue均判为非法并回退为文本; - 最终构造
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 的实测值直接比较绝对值。
七、从基准到实践:降低模板解析开销的工程建议
基于上述源码证据与基准数据,可以得出几条可操作的实践结论:
日志模板坚持使用编译期常量。
MessageTemplateCache对重复模板零成本命中;若传入每次拼接的动态字符串(如$"User {id}..."),既绕过常量模板分析器的提示,又可能触发缓存失效与高频解析。尽量控制模板中的属性数量与格式复杂度。每个属性 token 都对应完整的校验链与对象分配(netcoreapp2.0 报告显示默认控制台模板单次解析分配约 2296B),属性越少、格式越简单,首次解析成本越低。
输出模板(outputTemplate)在构建期只解析一次,其解析成本不会进入每条日志的热路径,可放心使用丰富的排版(对齐、多段格式),不必为此过度优化。
注意转义序列的慢路径。模板中的
{{/}}会迫使ParseTextToken走 StringBuilder 累积路径(src/Serilog/Parsing/MessageTemplateParser.cs),属于相对罕见的成本分支,日常模板应避免无谓的转义。不要放大畸形模板的异常成本。解析器对畸形标签的既定策略是回退为文本、绝不抛异常(由 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
相关推荐
NoneBot2 消息处理机制详解:从消息序列到消息模板
NoneBot2 消息处理机制详解:从消息序列到消息模板 你是否曾在开发聊天机器人时遇到过这样的困扰:不同平台的消息格式五花八门,有的支持纯文本,有的支持富文本
后端即时通讯Docker管理工具对比:gh_mirrors/do/docker-resources中的最佳选择
Docker管理工具对比:gh_mirrors/do/docker resources中的最佳选择 在容器化技术飞速发展的今天,Docker已成为开发者和运维人
Yii 2 日志框架完全指南:从消息记录、日志目标到性能剖析的源码级实战解析
Yii 2 日志框架完全指南:从消息记录、日志目标到性能剖析的源码级实战解析 Yii 2 内置了一套高度可定制、可扩展的日志框架,覆盖"记录消息 → 过滤消息
后端Web框架
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考