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

  • 网络传输带宽变得越来越紧缺,我们服务器的标配上了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类,里面简单的设置了几个不同类型的属性,然后依赖了一个子类数组。暂时忽略上面的一些头标记。

  1. [MemoryPackable]
  2. [MessagePackObject]
  3. [ProtoContract]
  4. public partial class DemoClass
  5. {
  6. [Key(0)] [ProtoMember(1)] public int P1 { get; set; }
  7. [Key(1)] [ProtoMember(2)] public bool P2 { get; set; }
  8. [Key(2)] [ProtoMember(3)] public string P3 { get; set; } = null!;
  9. [Key(3)] [ProtoMember(4)] public double P4 { get; set; }
  10. [Key(4)] [ProtoMember(5)] public long P5 { get; set; }
  11. [Key(5)] [ProtoMember(6)] public DemoSubClass[] Subs { get; set; } = null!;
  12. }
  13. [MemoryPackable]
  14. [MessagePackObject]
  15. [ProtoContract]
  16. public partial class DemoSubClass
  17. {
  18. [Key(0)] [ProtoMember(1)] public int P1 { get; set; }
  19. [Key(1)] [ProtoMember(2)] public bool P2 { get; set; }
  20. [Key(2)] [ProtoMember(3)] public string P3 { get; set; } = null!;
  21. [Key(3)] [ProtoMember(4)] public double P4 { get; set; }
  22. [Key(4)] [ProtoMember(5)] public long P5 { get; set; }
  23. }

System.Text.Json

选用它的原因很简单,这应该是.NET目前最快的JSON序列化框架之一了,它的使用非常简单,已经内置在.NET BCL中,只需要引用System.Text.Json命名空间,访问它的静态方法即可完成序列化和反序列化。

  1. using System.Text.Json;
  2. var obj = ....;
  3. // Serialize
  4. var json = JsonSerializer.Serialize(obj);
  5. // Deserialize
  6. var newObj = JsonSerializer.Deserialize<T>(json)

Google Protobuf

.NET上最常用的一个Protobuf序列化框架,它其实是一个工具包,通过工具包+*.proto文件可以生成GRPC Service或者对应实体的序列化代码,不过它使用起来有点麻烦。

使用它我们需要两个Nuget包,如下所示:

  1. <!--Google.Protobuf 序列化和反序列化帮助类-->
  2. <PackageReference Include="Google.Protobuf" Version="3.21.9" />
  3. <!--Grpc.Tools 用于生成protobuf的序列化反序列化类 和 GRPC服务-->
  4. <PackageReference Include="Grpc.Tools" Version="2.50.0">
  5. <PrivateAssets>all</PrivateAssets>
  6. <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
  7. </PackageReference>

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

  1. syntax="proto3";
  2. option csharp_namespace="DemoClassProto";
  3. package DemoClassProto;
  4. message DemoClassArrayProto
  5. {
  6. repeated DemoClassProto DemoClass = 1;
  7. }
  8. message DemoClassProto
  9. {
  10. int32 P1=1;
  11. bool P2=2;
  12. string P3=3;
  13. double P4=4;
  14. int64 P5=5;
  15. repeated DemoSubClassProto Subs=6;
  16. }
  17. message DemoSubClassProto
  18. {
  19. int32 P1=1;
  20. bool P2=2;
  21. string P3=3;
  22. double P4=4;
  23. int64 P5=5;
  24. }

做完这一些后,还需要在项目文件中加入如下的配置,让Grpc.Tools在编译时生成对应的C#类:

  1. <ItemGroup>
  2. <Protobuf Include="*.proto" GrpcServices="Server" />
  3. </ItemGroup>

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



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

  1. using Google.Protobuf;
  2. // Serialize
  3. [MethodImpl(MethodImplOptions.AggressiveInlining)]
  4. public static byte[] GoogleProtobufSerialize<T>(T origin) where T : IMessage<T>
  5. {
  6. return origin.ToByteArray();
  7. }
  8. // Deserialize
  9. [MethodImpl(MethodImplOptions.AggressiveInlining)]
  10. public DemoClassArrayProto GoogleProtobufDeserialize(byte[] bytes)
  11. {
  12. return DemoClassArrayProto.Parser.ParseFrom(bytes);
  13. }

Protobuf.Net

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

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

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

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

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

  1. using ProtoBuf;
  2. // Serialize
  3. [MethodImpl(MethodImplOptions.AggressiveInlining)]
  4. public static void ProtoBufDotNet<T>(T origin, Stream stream)
  5. {
  6. Serializer.Serialize(stream, origin);
  7. }
  8. // Deserialize
  9. public T ProtobufDotNet(byte[] bytes)
  10. {
  11. using var stream = new MemoryStream(bytes);
  12. return Serializer.Deserialize<T>(stream);
  13. }

MessagePack

这里我们使用的是Yoshifumi Kawai实现的MessagePack-CSharp,同样也是引入一个Nuget包:

  1. <PackageReference Include="MessagePack" Version="2.4.35" />

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

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

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

  1. using MessagePack;
  2. // Serialize
  3. [MethodImpl(MethodImplOptions.AggressiveInlining)]
  4. public static byte[] MessagePack<T>(T origin)
  5. {
  6. return global::MessagePack.MessagePackSerializer.Serialize(origin);
  7. }
  8. // Deserialize
  9. public T MessagePack<T>(byte[] bytes)
  10. {
  11. return global::MessagePack.MessagePackSerializer.Deserialize<T>(bytes);
  12. }

另外它提供了Lz4算法的压缩程序,我们只需要配置Option,即可使用Lz4压缩,压缩有两种方式,Lz4BlockLz4BlockArray,我们试试:

  1. public static readonly MessagePackSerializerOptions MpLz4BOptions = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4Block);
  2. // Serialize
  3. [MethodImpl(MethodImplOptions.AggressiveInlining)]
  4. public static byte[] MessagePackLz4Block<T>(T origin)
  5. {
  6. return global::MessagePack.MessagePackSerializer.Serialize(origin, MpLz4BOptions);
  7. }
  8. // Deserialize
  9. public T MessagePackLz4Block<T>(byte[] bytes)
  10. {
  11. return global::MessagePack.MessagePackSerializer.Deserialize<T>(bytes, MpLz4BOptions);
  12. }

MemoryPack

这里也是Yoshifumi Kawai大佬实现的MemoryPack,同样也是引入一个Nuget包,不过需要注意的是,目前需要安装VS 2022 17.3以上版本和.NET7 SDK,因为MemoryPack代码生成依赖了它:

  1. <PackageReference Include="MemoryPack" Version="1.4.4" />

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

  1. [MemoryPackable]
  2. public partial class DemoClass
  3. {
  4. public int P1 { get; set; }
  5. public bool P2 { get; set; }
  6. public string P3 { get; set; } = null!;
  7. public double P4 { get; set; }
  8. public long P5 { get; set; }
  9. }

序列化和反序列化也是调用静态方法:

  1. // Serialize
  2. [MethodImpl(MethodImplOptions.AggressiveInlining)]
  3. public static byte[] MemoryPack<T>(T origin)
  4. {
  5. return global::MemoryPack.MemoryPackSerializer.Serialize(origin);
  6. }
  7. // Deserialize
  8. public T MemoryPack<T>(byte[] bytes)
  9. {
  10. return global::MemoryPack.MemoryPackSerializer.Deserialize<T>(bytes)!;
  11. }

它原生支持Brotli压缩算法,使用如下所示:

  1. // Serialize
  2. [MethodImpl(MethodImplOptions.AggressiveInlining)]
  3. public static byte[] MemoryPackBrotli<T>(T origin)
  4. {
  5. using var compressor = new BrotliCompressor();
  6. global::MemoryPack.MemoryPackSerializer.Serialize(compressor, origin);
  7. return compressor.ToArray();
  8. }
  9. // Deserialize
  10. public T MemoryPackBrotli<T>(byte[] bytes)
  11. {
  12. using var decompressor = new BrotliDecompressor();
  13. var decompressedBuffer = decompressor.Decompress(bytes);
  14. return MemoryPackSerializer.Deserialize<T>(decompressedBuffer)!;
  15. }

跑个分吧

我使用BenchmarkDotNet构建了一个10万个对象序列化和反序列化的测试,源码在末尾的Github链接可见,比较了序列化、反序列化的性能,还有序列化以后占用的空间大小。

  1. public static class TestData
  2. {
  3. //
  4. public static readonly DemoClass[] Origin = Enumerable.Range(0, 10000).Select(i =>
  5. {
  6. return new DemoClass
  7. {
  8. P1 = i,
  9. P2 = i % 2 == 0,
  10. P3 = $"Hello World {i}",
  11. P4 = i,
  12. P5 = i,
  13. Subs = new DemoSubClass[]
  14. {
  15. new() {P1 = i, P2 = i % 2 == 0, P3 = $"Hello World {i}", P4 = i, P5 = i,},
  16. new() {P1 = i, P2 = i % 2 == 0, P3 = $"Hello World {i}", P4 = i, P5 = i,},
  17. new() {P1 = i, P2 = i % 2 == 0, P3 = $"Hello World {i}", P4 = i, P5 = i,},
  18. new() {P1 = i, P2 = i % 2 == 0, P3 = $"Hello World {i}", P4 = i, P5 = i,},
  19. }
  20. };
  21. }).ToArray();
  22. public static readonly DemoClassProto.DemoClassArrayProto OriginProto;
  23. static TestData()
  24. {
  25. OriginProto = new DemoClassArrayProto();
  26. for (int i = 0; i < Origin.Length; i++)
  27. {
  28. OriginProto.DemoClass.Add(
  29. DemoClassProto.DemoClassProto.Parser.ParseJson(JsonSerializer.Serialize(Origin[i])));
  30. }
  31. }
  32. }

序列化

序列化的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性能优化-是时候换个序列化协议了的更多相关文章

  1. Spark性能优化(1)——序列化、内存、并行度、数据存储格式、Shuffle

    序列化 背景: 在以下过程中,需要对数据进行序列化: shuffling data时需要通过网络传输数据 RDD序列化到磁盘时 性能优化点: Spark默认的序列化类型是Java序列化.Java序列化 ...

  2. 【Java技术专题】「性能优化系列」针对Java对象压缩及序列化技术的探索之路

    序列化和反序列化 序列化就是指把对象转换为字节码: 对象传递和保存时,保证对象的完整性和可传递性.把对象转换为有字节码,以便在网络上传输或保存在本地文件中: 反序列化就是指把字节码恢复为对象: 根据字 ...

  3. ASP.NET性能优化小结(ASP.NET&C#)

    ASP.NET: 一.返回多个数据集 检查你的访问数据库的代码,看是否存在着要返回多次的请求.每次往返降低了你的应用程序的每秒能够响应请求的次数.通过在单个数据库请求中返回多个结果集,可以减少与数据库 ...

  4. ASP.NET性能优化小结

    一.返回多个数据集 检查你的访问数据库的代码,看是否存在着要返回多次的请求.每次往返降低了你的应用程序的每秒能够响应请求的次数.通过在单个数据库请求中返回多个结果集,可以减少与数据库通信的时间,使你的 ...

  5. .NET程序性能优化基本要领

    想了解更多关于新的编译器的信息,可以访问     .NET Compiler Platform ("Roslyn") 基本要领 在对.NET 进行性能调优以及开发具有良好响应性的应 ...

  6. 你不知道的Node.js性能优化,读了之后水平直线上升

    本文由云+社区发表 "当我第一次知道要这篇文章的时候,其实我是拒绝的,因为我觉得,你不能叫我写马上就写,我要有干货才行,写一些老生常谈的然后加上好多特技,那个 Node.js 性能啊好像 D ...

  7. [转]C#程序性能优化

    C#程序性能优化 1.显式注册的EvenHandler要显式注销以避免内存泄漏 将一个成员方法注册到某个对象的事件会造成后者持有前者的引用.在事件注销之前,前者不会被垃圾回收.   private v ...

  8. Mysql 性能优化教程

    Mysql 性能优化教程 目录 目录 1 背景及目标 2 Mysql 执行优化 2 认识数据索引 2 为什么使用数据索引能提高效率 2 如何理解数据索引的结构 2 优化实战范例 3 认识影响结果集 4 ...

  9. 【大数据】Spark性能优化和故障处理

    第一章 Spark 性能调优 1.1 常规性能调优 1.1.1 常规性能调优一:最优资源配置 Spark性能调优的第一步,就是为任务分配更多的资源,在一定范围内,增加资源的分配与性能的提升是成正比的, ...

随机推荐

  1. Java SE 16 新增特性

    Java SE 16 新增特性 作者:Grey 原文地址:Java SE 16 新增特性 源码 源仓库: Github:java_new_features 镜像仓库: GitCode:java_new ...

  2. Vue3中插槽(slot)用法汇总

    Vue中的插槽相信使用过Vue的小伙伴或多或少的都用过,但是你是否了解它全部用法呢?本篇文章就为大家带来Vue3中插槽的全部用法来帮助大家查漏补缺. 什么是插槽 简单来说就是子组件中的提供给父组件使用 ...

  3. HDU6623 Minimal Power of Prime (简单数论)

    题面 T ≤ 50   000 T\leq50\,000 T≤50000 组数据: 输入一个数 N N N ( 2 ≤ N ≤ 1 0 18 2\leq N\leq 10^{18} 2≤N≤1018) ...

  4. 【java】学习路径23-拆箱与装箱

    拿Integer类型和int类型来举例子. 装箱,基本给引用.下面的代码相当于Integer i_test = Integer.valueOf("100"); 注意!过程是自动的. ...

  5. MySQL半同步复制源码解析

    今天 DBA 同事问了一个问题,MySQL在半同步复制的场景下,当关闭从节点时使得从节点的数量 < rpl_semi_sync_master_wait_for_slave_count时,show ...

  6. Java SE final关键字

    final关键字 final可以修饰类.属性.方法和局部变量 如下情况,可以使用final 当不希望类被继承时,可以用final修饰 当不希望父类的某个方法被子类覆盖/重写(override)时,可以 ...

  7. java项目中VO、DTO以及Entity,各自是在什么情况下应用的

    按照标准来说: entity里的每一个字段,与数据库相对应 vo里的每一个字段,是和你前台页面相对应 dto,这是用来转换从entity到dto,或者从dto到entity的中间的东西 举个例子: h ...

  8. Prometheus 监控 Kubernetes Job 资源误报的坑

    转载自:https://www.qikqiak.com/post/prometheus-monitor-k8s-job-trap/ 昨天在 Prometheus 课程辅导群里面有同学提到一个问题,是关 ...

  9. 使用nginx-ingress-controller配置https,但是再同时配置使用http

    默认情况下,如果为该 Ingress 启用了 TLS,控制器会使用 308 永久重定向响应将 HTTP 客户端重定向到 HTTPS 端口 443.( Ingress 里配置了 https 证书的话,默 ...

  10. 存储类StorageClass

    存储类概述 StorageClass 存储类用于描述集群中可以提供的存储的类型.不同的存储类可能对应着不同的: 服务等级(quality-of-service level) 备份策略 集群管理员自定义 ...