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

性能,NET,序列化,协议 · 浏览次数 : 5679

小编点评

**代码描述** * **MemoryPack**:一个用于内存序列化的协议,它支持Brotli压缩算法。 * **Brotli**:一个用于Brotli压缩的算法,它可以提升序列性能。 * **Lz4**:一个用于Lz4压缩的算法,它可以进一步提升序列性能。 * **MessagePack**:一个用于MessagePack的协议,它支持Lz4压缩算法。 **测试** * **TestData**:一个测试类,它包含了测试数据和序列化方法。 * **BenchmarkDotNet**:一个用于性能测试的框架,它可以用于测试序列化和反序列化的性能。 **结果** * **序列化速度**:MemoryPack比JSON和Protobuf快88%。 * **内存占用**:MemoryPackBrotli是最小的,它比JSON占用少98%。 * **反序列化速度**:MemoryPack比JSON和Protobuf快14%。 * **内存占用**:MemoryPackBrotli是最小的,它比JSON占用少98%。 **结论** * MemoryPack是一个性能优越的序列化协议,它支持Lz4和Brotli压缩算法。 * MemoryPackBrotli是MemoryPack的优化版本,它可以减少序列化后占用的空间大小。 * MemoryPack在性能和效率方面比JSON和Protobuf具有优势。

正文

计算机单机性能一直受到摩尔定律的约束,随着移动互联网的兴趣,单机性能不足的瓶颈越来越明显,制约着整个行业的发展。不过我们虽然不能无止境的纵向扩容系统,但是我们可以分布式、横向的扩容系统,这听起来非常的美好,不过也带来了今天要说明的问题,分布式的节点越多,通信产生的成本就越大

  • 网络传输带宽变得越来越紧缺,我们服务器的标配上了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#类:

最后我们可以用下面的方法来实现序列化和反序列化,泛型类型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的结果如下所示:

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

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

序列化结果大小

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

反序列化

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

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

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

总结

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

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

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

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

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

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

附录

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

与.NET性能优化-是时候换个序列化协议了相似的内容:

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

计算机单机性能一直受到摩尔定律的约束,随着移动互联网的兴趣,单机性能不足的瓶颈越来越明显,制约着整个行业的发展。不过我们虽然不能无止境的纵向扩容系统,但是我们可以分布式、横向的扩容系统,这听起来非常的美好,不过也带来了今天要说明的问题,分布式的节点越多,通信产生的成本就越大。 网络传输带宽变得越来越

.NET性能优化-复用StringBuilder

在之前的文章中,我们介绍了dotnet在字符串拼接时可以使用的一些性能优化技巧。比如: 为StringBuilder设置Buffer初始大小 使用ValueStringBuilder等等 不过这些都多多少少有一些局限性,比如StringBuilder还是会存在new StringBuilder()这

[转帖]基于Fuse的用户态文件系统性能优化几点建议

https://zhuanlan.zhihu.com/p/68085075 目前很多文件系统基于Fuse( http://fuse.sourceforge.net/ )开发,在较为深入钻研Fuse实现后,总结出开发此类文件系统时可考虑的优化方案,拿出来与大家讨论讨论,如有不准确的地方,还望大家不吝赐

C#/.NET/.NET Core优秀项目和框架精选(坑已挖,欢迎大家踊跃提交PR或者Issues中留言)

前言 注意:排名不分先后,都是十分优秀的开源项目和框架,每周定期更新分享(欢迎关注公众号:追逐时光者,第一时间获取每周精选分享资讯🔔)。 帮助开发者发现功能强大、性能优越、创新前沿、简单易用的C#/.NET/.NET Core优秀项目和框架,无论你是寻找灵感、学习新技术、改进代码质量,还是想拓展自

.NET性能优化-使用内存+磁盘混合缓存

我们回顾一下上一篇文章中的内容,有一个朋友问我这样一个问题: > 我的业务依赖一些数据,因为数据库访问慢,我把它放在Redis里面,不过还是太慢了,有什么其它的方案吗? 其实这个问题比较简单的是吧?Redis其实属于网络存储,我对照下面的这个表格,可以很容易的得出结论,既然网络存储的速度慢,那我们就

.NET性能优化-ArrayPool同时复用数组和对象

前两天在微信后台收到了读者的私信,问了一个这样的问题,由于私信回复有字数和篇幅限制,我在这里统一回复一下。读者的问题是这样的: 大佬您好,之前读了您的文章受益匪浅,我们有一个项目经常占用 7-8GB 的内存,使用了您推荐的ArrayPool以后降低到 4GB 左右,我还想着能不能继续优化,于是 du

.Net核心级的性能优化(GC篇)

1.前言 大部分人对于.Net性能优化,都停留在业务层面。或者简单的.Net框架配置层面。本篇来看下.Net核心部分GC垃圾回收配置:保留VM,大对象,独立GC,节省内存等.Net8里面有很多的各种GC配置,用以帮助你的程序进行最大程度性能提升和优化。 文章分为两部分,第一个是GC有哪些动作可以性能

【.NET8】访问私有成员新姿势UnsafeAccessor(上)

前言 前几天在.NET性能优化群里面,有群友聊到了.NET8新增的一个特性,这个类叫UnsafeAccessor,有很多群友都不知道这个特性是干嘛的,所以我就想写一篇文章来带大家了解一下这个特性。 其实在很早之前我就有关注到这个特殊的特性,但是当时.NET8还没有正式发布,所以我也没有写文章,现在.

【转帖】Linux性能优化(一)——stress压力测试工具

https://blog.csdn.net/a642960662/category_11641226.html 一、stress简介 1、stress简介 stress是Linux的一个压力测试工具,可以对CPU、Memory、IO、磁盘进行压力测试。 2、stress安装 安装: sudo yum

.Net性能测试工具BenchmarkDotNet学习

.Net性能测试工具BenchmarkDotNet学习 BenchmarkDotNet 是一个用于性能基准测试的开源框架。它可以让开发人员编写简单易懂的代码,并测量和分析这些代码的性能表现,从而帮助开发人员优化其代码,以达到更高的性能和更好的效率。 源码地址:https://github.com/d