.NET性能优化-是时候换个序列化协议了
计算机单机性能一直受到摩尔定律的约束,随着移动互联网的兴趣,单机性能不足的瓶颈越来越明显,制约着整个行业的发展。不过我们虽然不能无止境的纵向扩容系统,但是我们可以分布式、横向的扩容系统,这听起来非常的美好,不过也带来了今天要说明的问题,分布式的节点越多,通信产生的成本就越大。[*]网络传输带宽变得越来越紧缺,我们服务器的标配上了 10Gbps 的网卡
[*]HTTPx.x 时代 TCP/IP 协议通讯低效,我们即将用上 QUIC HTTP 3.0
[*]同机器走 Socket 协议栈太慢,我们用起了 eBPF
[*]....
现在我们的应用程序花在网络通讯上的时间太多了,其中花在序列化上的时间也非常的多。我们和大家一样,在内部微服务通讯序列化协议中,绝大的部分都是用 JSON。JSON 的好处很多,首先就是它对人非常友好,我们能直接读懂它的含义,但是它也有着致命的缺点,那就是它序列化太慢、序列化以后的字符串太大了。
之前笔者做一个项目时,就遇到了一个选型的问题,我们有数亿行数据需要缓存到 Redis 中,每行数据有数百个字段,如果用 Json 序列化存储的话它的内存消耗是数 TB级别的(部署个集群再做个主从、多中心 需要成倍的内存、太贵了,用不起)。于是我们就在找有没有除了 JSON 其它更好的序列化方式?
看看都有哪些
目前市面上序列化协议有很多比如 XML、JSON、Thrift、Kryo 等等,我们选取了在.NET 平台上比较常用的序列化协议来做比较:
[*]JSON:JSON 是一种轻量级的数据交换格式。采用完全独立于编程语言的文本格式来存储和表示数据。简洁和清晰的层次结构使得 JSON 成为理想的数据交换语言。
[*]Protobuf:Protocol Buffers 是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于(数据)通信协议、数据存储等,它类似 XML,但比它更小、更快、更简单。
[*]MessagePack:是一种高效的二进制序列化格式。它可以让你像 JSON 一样在多种语言之间交换数据。但它更快、更小。小的整数被编码成一个字节,典型的短字符串除了字符串本身之外,只需要一个额外的字节。
[*]MemoryPack:是 Yoshifumi Kawai 大佬专为 C#设计的一个高效的二进制序列化格式,它有着.NET 平台很多新的特性,并且它是 Code First 开箱即用,非常简单;同时它还有着非常好的性能。
我们选择的都是.NET 平台上比较常用的,特别是后面的三种都宣称自己是非常小,非常快的,那么我们就来看看到底是谁最快,谁序列化后的结果最小。
C#基础 .NET6/WPF/Winform零基础到各类实战!
卖课是在卖,免费资料还不是免费送!完整还附源码...
快来领取吧
百闻不如一见,代码最真切,视频最明了。这里我已经把全部的视频和源码都打包放在百度云了,可以直接下载随时随地看!
资料免费自取:
由于内容过多不便呈现,需要视频教程和配套源码的小伙伴,可点击下方链接
也可直接点击下方卡片:点击后可自动复制威芯号,并跳转到威芯。得辛苦大家自行搜索威芯号添加。内容已做打包,添加后直接发送注意查收!
准备工作
我们准备了一个 DemoClass 类,里面简单的设置了几个不同类型的属性,然后依赖了一个子类数组。暂时忽略上面的一些头标记。
public partial class DemoClass
{
public int P1 { get; set; }
public bool P2 { get; set; }
public string P3 { get; set; } = null!;
public double P4 { get; set; }
public long P5 { get; set; }
public DemoSubClass[] Subs { get; set; } = null!;
}
public partial class DemoSubClass
{
public int P1 { get; set; }
public bool P2 { get; set; }
public string P3 { get; set; } = null!;
public double P4 { get; set; }
public long P5 { get; set; }
}
System.Text.Json
选用它的原因很简单,这应该是.NET 目前最快的 JSON 序列化框架之一了,它的使用非常简单,已经内置在.NET BCL 中,只需要引用System.Text.Json命名空间,访问它的静态方法即可完成序列化和反序列化。
using System.Text.Json;
var obj = ....;
// Serialize
var json = JsonSerializer.Serialize(obj);
// Deserialize
var newObj = JsonSerializer.Deserialize<T>(json)
Google Protobuf
.NET 上最常用的一个 Protobuf 序列化框架,它其实是一个工具包,通过工具包+*.proto文件可以生成 GRPC Service 或者对应实体的序列化代码,不过它使用起来有点麻烦。
使用它我们需要两个 Nuget 包,如下所示:
<!--Google.Protobuf 序列化和反序列化帮助类-->
<PackageReference Include=&#34;Google.Protobuf&#34; Version=&#34;3.21.9&#34; />
<!--Grpc.Tools 用于生成protobuf的序列化反序列化类 和 GRPC服务-->
<PackageReference Include=&#34;Grpc.Tools&#34; Version=&#34;2.50.0&#34;>
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
由于它不能直接使用 C#对象,所以我们还需要创建一个*.proto文件,布局和上面的 C#类一致,加入了一个DemoClassArrayProto方便后面测试:
syntax=&#34;proto3&#34;;
option csharp_namespace=&#34;DemoClassProto&#34;;
package DemoClassProto;
message DemoClassArrayProto
{
repeated DemoClassProto DemoClass = 1;
}
message DemoClassProto
{
int32 P1=1;
bool P2=2;
string P3=3;
double P4=4;
int64 P5=5;
repeated DemoSubClassProto Subs=6;
}
message DemoSubClassProto
{
int32 P1=1;
bool P2=2;
string P3=3;
double P4=4;
int64 P5=5;
}
做完这一些后,还需要在项目文件中加入如下的配置,让Grpc.Tools在编译时生成对应的 C#类:
<ItemGroup>
<Protobuf Include=&#34;*.proto&#34; GrpcServices=&#34;Server&#34; />
</ItemGroup>
然后 Build 当前项目的话就会在obj目录生成 C#类:
最后我们可以用下面的方法来实现序列化和反序列化,泛型类型T是需要继承IMessage<T>从*.proto生成的实体(用起来还是挺麻烦的):
using Google.Protobuf;
// Serialize
public static byte[] GoogleProtobufSerialize<T>(T origin) where T : IMessage<T>
{
return origin.ToByteArray();
}
// Deserialize
public DemoClassArrayProto GoogleProtobufDeserialize(byte[] bytes)
{
return DemoClassArrayProto.Parser.ParseFrom(bytes);
}
http://Protobuf.Net
那么在.NET 平台 protobuf 有没有更简单的使用方式呢?答案当然是有的,我们只需要依赖下面的 Nuget 包:
<PackageReference Include=&#34;protobuf-net&#34; Version=&#34;3.1.22&#34; />
然后给我们需要进行序列化的 C#类打上ProtoContract特性,另外将所需要序列化的属性打上ProtoMember特性,如下所示:
public class DemoClass
{
public int P1 { get; set; }
public bool P2 { get; set; }
public string P3 { get; set; } = null!;
public double P4 { get; set; }
public long P5 { get; set; }
}
然后就可以直接使用框架提供的静态类进行序列化和反序列化,遗憾的是它没有提供直接返回byte[]的方法,不得不使用一个MemoryStrem:
using ProtoBuf;
// Serialize
public static void ProtoBufDotNet<T>(T origin, Stream stream)
{
Serializer.Serialize(stream, origin);
}
// Deserialize
public T ProtobufDotNet(byte[] bytes)
{
using var stream = new MemoryStream(bytes);
return Serializer.Deserialize<T>(stream);
}
MessagePack
这里我们使用的是 Yoshifumi Kawai 实现的MessagePack-CSharp,同样也是引入一个 Nuget 包:
<PackageReference Include=&#34;MessagePack&#34; Version=&#34;2.4.35&#34; />
然后在类上只需要打一个MessagePackObject的特性,然后在需要序列化的属性打上Key特性:
public partial class DemoClass
{
public int P1 { get; set; }
public bool P2 { get; set; }
public string P3 { get; set; } = null!;
public double P4 { get; set; }
public long P5 { get; set; }
}
使用起来也非常简单,直接调用MessagePack提供的静态类即可:
using MessagePack;
// Serialize
public static byte[] MessagePack<T>(T origin)
{
return global::MessagePack.MessagePackSerializer.Serialize(origin);
}
// Deserialize
public T MessagePack<T>(byte[] bytes)
{
return global::MessagePack.MessagePackSerializer.Deserialize<T>(bytes);
}
另外它提供了 Lz4 算法的压缩程序,我们只需要配置 Option,即可使用 Lz4 压缩,压缩有两种方式,Lz4Block和Lz4BlockArray,我们试试:
public static readonly MessagePackSerializerOptions MpLz4BOptions = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4Block);
// Serialize
public static byte[] MessagePackLz4Block<T>(T origin)
{
return global::MessagePack.MessagePackSerializer.Serialize(origin, MpLz4BOptions);
}
// Deserialize
public T MessagePackLz4Block<T>(byte[] bytes)
{
return global::MessagePack.MessagePackSerializer.Deserialize<T>(bytes, MpLz4BOptions);
}
MemoryPack
这里也是 Yoshifumi Kawai 大佬实现的MemoryPack,同样也是引入一个 Nuget 包,不过需要注意的是,目前需要安装 VS 2022 17.3 以上版本和.NET7 SDK,因为MemoryPack代码生成依赖了它:
<PackageReference Include=&#34;MemoryPack&#34; Version=&#34;1.4.4&#34; />
使用起来应该是这几个二进制序列化协议最简单的了,只需要给对应的类加上partial关键字,另外打上MemoryPackable特性即可:
public partial class DemoClass
{
public int P1 { get; set; }
public bool P2 { get; set; }
public string P3 { get; set; } = null!;
public double P4 { get; set; }
public long P5 { get; set; }
}
序列化和反序列化也是调用静态方法:
// Serialize
public static byte[] MemoryPack<T>(T origin)
{
return global::MemoryPack.MemoryPackSerializer.Serialize(origin);
}
// Deserialize
public T MemoryPack<T>(byte[] bytes)
{
return global::MemoryPack.MemoryPackSerializer.Deserialize<T>(bytes)!;
}
它原生支持 Brotli 压缩算法,使用如下所示:
// Serialize
public static byte[] MemoryPackBrotli<T>(T origin)
{
using var compressor = new BrotliCompressor();
global::MemoryPack.MemoryPackSerializer.Serialize(compressor, origin);
return compressor.ToArray();
}
// Deserialize
public T MemoryPackBrotli<T>(byte[] bytes)
{
using var decompressor = new BrotliDecompressor();
var decompressedBuffer = decompressor.Decompress(bytes);
return MemoryPackSerializer.Deserialize<T>(decompressedBuffer)!;
}
跑个分吧
我使用BenchmarkDotNet构建了一个 10 万个对象序列化和反序列化的测试,源码在末尾的 Github 链接可见,比较了序列化、反序列化的性能,还有序列化以后占用的空间大小。
public static class TestData
{
//
public static readonly DemoClass[] Origin = Enumerable.Range(0, 10000).Select(i =>
{
return new DemoClass
{
P1 = i,
P2 = i % 2 == 0,
P3 = $&#34;Hello World {i}&#34;,
P4 = i,
P5 = i,
Subs = new DemoSubClass[]
{
new() {P1 = i, P2 = i % 2 == 0, P3 = $&#34;Hello World {i}&#34;, P4 = i, P5 = i,},
new() {P1 = i, P2 = i % 2 == 0, P3 = $&#34;Hello World {i}&#34;, P4 = i, P5 = i,},
new() {P1 = i, P2 = i % 2 == 0, P3 = $&#34;Hello World {i}&#34;, P4 = i, P5 = i,},
new() {P1 = i, P2 = i % 2 == 0, P3 = $&#34;Hello World {i}&#34;, P4 = i, P5 = i,},
}
};
}).ToArray();
public static readonly DemoClassProto.DemoClassArrayProto OriginProto;
static TestData()
{
OriginProto = new DemoClassArrayProto();
for (int i = 0; i < Origin.Length; i++)
{
OriginProto.DemoClass.Add(
DemoClassProto.DemoClassProto.Parser.ParseJson(JsonSerializer.Serialize(Origin)));
}
}
}
序列化
序列化的 Bemchmark 的结果如下所示:
从序列化速度来看MemoryPack遥遥领先,比 JSON 要快 88%,甚至比 Protobuf 快 15%。
从序列化占用的内存来看,MemoryPackBrotli是王者,它比 JSON 占用少 98%,甚至比Protobuf占用少 25%。其中ProtoBufDotNet内存占用大主要还是吃了没有byte[]返回方法的亏,只能先创建一个MemoryStream。
序列化结果大小
这里我们可以看到MemoryPackBrotli赢麻了,比不压缩的MemoryPack和Protobuf有着 10 多倍的差异。
反序列化
反序列化的 Benchmark 结果如下所示,反序列化整体开销是比序列化大的,毕竟需要创建大量的对象:
从反序列化的速度来看,不出意外MemoryPack还是遥遥领先,比 JSON 快 80%,比Protobuf快 14%。
从内存占用来看ProtobufDotNet是最小的,这个结果听让人意外的,其余的都表现的差不多:
总结
总的相关数据如下表所示,原始数据可以在文末的 Github 项目地址获取:
从图表来看,如果要兼顾序列化后大小和性能的话我们应该要选择MemoryPackBrotli,它序列化以后的结果最小,而且兼顾了性能:
不过由于MemoryPack目前需要.NET7 版本,所以现阶段最稳妥的选择还是使用MessagePack+Lz4压缩算法,它有着不俗的性能表现和突出的序列化大小。
回到文首的技术选型问题,笔者那个项目最终选用的是Google Protobuf这个序列化协议和框架,因为当时考虑到需要和其它语言交互,然后也需要有较小空间占用,目前看已经占用了111GB的 Redis 空间占用。
如果后续进一步增大,可以换成MessagePack+Lz4方式,应该还能节省95GB的左右空间。那可都是白花花的银子。
当然其它协议也是可以进一步通过Gzip、Lz4、Brotli算法进行压缩,不过鉴于时间和篇幅关系,没有进一步做测试,有兴趣的同学可以试试。
附录c
代码链接: https://github.com/InCerryGit/WhoIsFastest-Serialization原文链接:.NET性能优化-是时候换个序列化协议了。
页:
[1]