2

.NET性能优化-是时候换个序列化协议了

 1 year ago
source link: https://www.cnblogs.com/InCerry/p/Dotnet-Perf-Opt-Serialization-Protocol.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

.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平台上比较常用的,特别是后面的三种都宣称自己是非常小,非常快的,那么我们就来看看到底是谁最快,谁序列化后的结果最小。

我们准备了一个DemoClass类,里面简单的设置了几个不同类型的属性,然后依赖了一个子类数组。暂时忽略上面的一些头标记。

[MemoryPackable]  
[MessagePackObject]  
[ProtoContract]  
public partial class DemoClass  
{  
    [Key(0)] [ProtoMember(1)] public int P1 { get; set; }  
    [Key(1)] [ProtoMember(2)] public bool P2 { get; set; }  
    [Key(2)] [ProtoMember(3)] public string P3 { get; set; } = null!;  
    [Key(3)] [ProtoMember(4)] public double P4 { get; set; }  
    [Key(4)] [ProtoMember(5)] public long P5 { get; set; }  
    [Key(5)] [ProtoMember(6)] public DemoSubClass[] Subs { get; set; } = null!;  
}  
  
[MemoryPackable]  
[MessagePackObject]  
[ProtoContract]  
public partial class DemoSubClass  
{  
    [Key(0)] [ProtoMember(1)] public int P1 { get; set; }  
    [Key(1)] [ProtoMember(2)] public bool P2 { get; set; }  
    [Key(2)] [ProtoMember(3)] public string P3 { get; set; } = null!;  
    [Key(3)] [ProtoMember(4)] public double P4 { get; set; }  
    [Key(4)] [ProtoMember(5)] 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="Google.Protobuf" Version="3.21.9" />

<!--Grpc.Tools 用于生成protobuf的序列化反序列化类 和 GRPC服务-->
<PackageReference Include="Grpc.Tools" Version="2.50.0">  
  <PrivateAssets>all</PrivateAssets>  
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>  
</PackageReference>

由于它不能直接使用C#对象,所以我们还需要创建一个*.proto文件,布局和上面的C#类一致,加入了一个DemoClassArrayProto方便后面测试:

syntax="proto3";  
option csharp_namespace="DemoClassProto";  
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="*.proto" GrpcServices="Server" />  
</ItemGroup>

然后Build当前项目的话就会在obj目录生成C#类:

image-dotnet%e6%80%a7%e8%83%bd%e4%bc%98%e5%8c%96-%e6%98%af%e6%97%b6%e5%80%99%e6%8d%a2%e4%b8%aa%e5%ba%8f%e5%88%97%e5%8c%96%e5%8d%8f%e8%ae%ae%e4%ba%86-221105131838939.png
image-dotnet%e6%80%a7%e8%83%bd%e4%bc%98%e5%8c%96-%e6%98%af%e6%97%b6%e5%80%99%e6%8d%a2%e4%b8%aa%e5%ba%8f%e5%88%97%e5%8c%96%e5%8d%8f%e8%ae%ae%e4%ba%86-221105131923721.png

最后我们可以用下面的方法来实现序列化和反序列化,泛型类型T是需要继承IMessage<T>*.proto生成的实体(用起来还是挺麻烦的):

using Google.Protobuf;

// Serialize
[MethodImpl(MethodImplOptions.AggressiveInlining)]  
public static byte[] GoogleProtobufSerialize<T>(T origin) where T : IMessage<T>  
{  
    return origin.ToByteArray();  
}

// Deserialize
[MethodImpl(MethodImplOptions.AggressiveInlining)]  
public DemoClassArrayProto GoogleProtobufDeserialize(byte[] bytes)  
{  
    return DemoClassArrayProto.Parser.ParseFrom(bytes);  
}

Protobuf.Net

那么在.NET平台protobuf有没有更简单的使用方式呢?答案当然是有的,我们只需要依赖下面的Nuget包:

<PackageReference Include="protobuf-net" Version="3.1.22" />

然后给我们需要进行序列化的C#类打上ProtoContract特性,另外将所需要序列化的属性打上ProtoMember特性,如下所示:

[ProtoContract]  
public class DemoClass  
{  
    [ProtoMember(1)] public int P1 { get; set; }  
    [ProtoMember(2)] public bool P2 { get; set; }  
    [ProtoMember(3)] public string P3 { get; set; } = null!;  
    [ProtoMember(4)] public double P4 { get; set; }  
    [ProtoMember(5)] public long P5 { get; set; }  
}

然后就可以直接使用框架提供的静态类进行序列化和反序列化,遗憾的是它没有提供直接返回byte[]的方法,不得不使用一个MemoryStrem

using ProtoBuf;

// Serialize
[MethodImpl(MethodImplOptions.AggressiveInlining)]  
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="MessagePack" Version="2.4.35" />

然后在类上只需要打一个MessagePackObject的特性,然后在需要序列化的属性打上Key特性:

[MessagePackObject] 
public partial class DemoClass  
{  
    [Key(0)] public int P1 { get; set; }  
    [Key(1)] public bool P2 { get; set; }  
    [Key(2)] public string P3 { get; set; } = null!;  
    [Key(3)] public double P4 { get; set; }  
    [Key(4)] public long P5 { get; set; }
}

使用起来也非常简单,直接调用MessagePack提供的静态类即可:

using MessagePack;

// Serialize
[MethodImpl(MethodImplOptions.AggressiveInlining)]  
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压缩,压缩有两种方式,Lz4BlockLz4BlockArray,我们试试:

public static readonly MessagePackSerializerOptions MpLz4BOptions =   MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4Block);  

// Serialize
[MethodImpl(MethodImplOptions.AggressiveInlining)]  
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="MemoryPack" Version="1.4.4" />

使用起来应该是这几个二进制序列化协议最简单的了,只需要给对应的类加上partial关键字,另外打上MemoryPackable特性即可:

[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
[MethodImpl(MethodImplOptions.AggressiveInlining)]  
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
[MethodImpl(MethodImplOptions.AggressiveInlining)]  
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 = $"Hello World {i}",  
            P4 = i,  
            P5 = i,  
            Subs = new DemoSubClass[]  
            {  
                new() {P1 = i, P2 = i % 2 == 0, P3 = $"Hello World {i}", P4 = i, P5 = i,},  
                new() {P1 = i, P2 = i % 2 == 0, P3 = $"Hello World {i}", P4 = i, P5 = i,},  
                new() {P1 = i, P2 = i % 2 == 0, P3 = $"Hello World {i}", P4 = i, P5 = i,},  
                new() {P1 = i, P2 = i % 2 == 0, P3 = $"Hello World {i}", 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[i])));  
        }  
    }  
}

序列化的Bemchmark的结果如下所示:

image-dotnet%e6%80%a7%e8%83%bd%e4%bc%98%e5%8c%96-%e6%98%af%e6%97%b6%e5%80%99%e6%8d%a2%e4%b8%aa%e5%ba%8f%e5%88%97%e5%8c%96%e5%8d%8f%e8%ae%ae%e4%ba%86-221105140527855.png

从序列化速度来看MemoryPack遥遥领先,比JSON要快88%,甚至比Protobuf快15%。

image-dotnet%e6%80%a7%e8%83%bd%e4%bc%98%e5%8c%96-%e6%98%af%e6%97%b6%e5%80%99%e6%8d%a2%e4%b8%aa%e5%ba%8f%e5%88%97%e5%8c%96%e5%8d%8f%e8%ae%ae%e4%ba%86-221105142007441.png

从序列化占用的内存来看,MemoryPackBrotli是王者,它比JSON占用少98%,甚至比Protobuf占用少25%。其中ProtoBufDotNet内存占用大主要还是吃了没有byte[]返回方法的亏,只能先创建一个MemoryStream

image-dotnet%e6%80%a7%e8%83%bd%e4%bc%98%e5%8c%96-%e6%98%af%e6%97%b6%e5%80%99%e6%8d%a2%e4%b8%aa%e5%ba%8f%e5%88%97%e5%8c%96%e5%8d%8f%e8%ae%ae%e4%ba%86-221105142439510.png

序列化结果大小

这里我们可以看到MemoryPackBrotli赢麻了,比不压缩的MemoryPackProtobuf有着10多倍的差异。

image-dotnet%e6%80%a7%e8%83%bd%e4%bc%98%e5%8c%96-%e6%98%af%e6%97%b6%e5%80%99%e6%8d%a2%e4%b8%aa%e5%ba%8f%e5%88%97%e5%8c%96%e5%8d%8f%e8%ae%ae%e4%ba%86-221105143606388.png

反序列化的Benchmark结果如下所示,反序列化整体开销是比序列化大的,毕竟需要创建大量的对象:

image-dotnet%e6%80%a7%e8%83%bd%e4%bc%98%e5%8c%96-%e6%98%af%e6%97%b6%e5%80%99%e6%8d%a2%e4%b8%aa%e5%ba%8f%e5%88%97%e5%8c%96%e5%8d%8f%e8%ae%ae%e4%ba%86-221105144101347.png

从反序列化的速度来看,不出意外MemoryPack还是遥遥领先,比JSON快80%,比Protobuf快14%。

image-dotnet%e6%80%a7%e8%83%bd%e4%bc%98%e5%8c%96-%e6%98%af%e6%97%b6%e5%80%99%e6%8d%a2%e4%b8%aa%e5%ba%8f%e5%88%97%e5%8c%96%e5%8d%8f%e8%ae%ae%e4%ba%86-221105144742390.png

从内存占用来看ProtobufDotNet是最小的,这个结果听让人意外的,其余的都表现的差不多:

image-dotnet%e6%80%a7%e8%83%bd%e4%bc%98%e5%8c%96-%e6%98%af%e6%97%b6%e5%80%99%e6%8d%a2%e4%b8%aa%e5%ba%8f%e5%88%97%e5%8c%96%e5%8d%8f%e8%ae%ae%e4%ba%86-221105145244019.png

总的相关数据如下表所示,原始数据可以在文末的Github项目地址获取:

image-dotnet%e6%80%a7%e8%83%bd%e4%bc%98%e5%8c%96-%e6%98%af%e6%97%b6%e5%80%99%e6%8d%a2%e4%b8%aa%e5%ba%8f%e5%88%97%e5%8c%96%e5%8d%8f%e8%ae%ae%e4%ba%86-221105150847810.jpeg

从图表来看,如果要兼顾序列化后大小和性能的话我们应该要选择MemoryPackBrotli,它序列化以后的结果最小,而且兼顾了性能:

image-dotnet%e6%80%a7%e8%83%bd%e4%bc%98%e5%8c%96-%e6%98%af%e6%97%b6%e5%80%99%e6%8d%a2%e4%b8%aa%e5%ba%8f%e5%88%97%e5%8c%96%e5%8d%8f%e8%ae%ae%e4%ba%86-221105150713027.png

不过由于MemoryPack目前需要.NET7版本,所以现阶段最稳妥的选择还是使用MessagePack+Lz4压缩算法,它有着不俗的性能表现和突出的序列化大小。

回到文首的技术选型问题,笔者那个项目最终选用的是Google Protobuf这个序列化协议和框架,因为当时考虑到需要和其它语言交互,然后也需要有较小空间占用,目前看已经占用了111GB的Redis空间占用。

image-dotnet%e6%80%a7%e8%83%bd%e4%bc%98%e5%8c%96-%e6%98%af%e6%97%b6%e5%80%99%e6%8d%a2%e4%b8%aa%e5%ba%8f%e5%88%97%e5%8c%96%e5%8d%8f%e8%ae%ae%e4%ba%86-221105151648043.png

如果后续进一步增大,可以换成MessagePack+Lz4方式,应该还能节省95GB的左右空间。那可都是白花花的银子。

当然其它协议也是可以进一步通过GzipLz4Brotli算法进行压缩,不过鉴于时间和篇幅关系,没有进一步做测试,有兴趣的同学可以试试。

代码链接: https://github.com/InCerryGit/WhoIsFastest-Serialization


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK