Lors du choix du format de sérialisation des messages qui seront écrits dans la file d'attente, le journal ou ailleurs, un certain nombre de questions se posent souvent qui affectent en quelque sorte le choix final. L'un de ces problèmes clés est la vitesse de sérialisation et la taille du message reçu. Comme il existe de nombreux formats à ces fins, j'ai décidé de tester certains d'entre eux et de partager les résultats.
Préparation au test
Les formats suivants seront testés:
- Sérialisation Java
- Json
- Avro
- Protobuf
- Thrift (binaire, compact)
- Msgpack
Scala a été choisi comme PL.
Le principal outil de test sera
Scalameter .
Les paramètres suivants seront mesurés et comparés: le temps nécessaire pour sérialiser et désérialiser, et la taille des fichiers résultants.
La facilité d'utilisation, la possibilité d'évolution du circuit et d'autres paramètres importants dans cette comparaison ne participeront pas.
Génération d'entrée
Pour la pureté des expériences, un ensemble de données doit d'abord être généré. Le format d'entrée est un fichier CSV. Les données sont générées à l'aide d'un simple `Random.next [...]` pour les valeurs numériques et `UUID.randomUUID ()` pour la chaîne. Les données générées sont écrites dans un fichier csv en utilisant
kantan . Au total, 3 ensembles de données de 100 000 enregistrements chacun ont été générés:
- Données mixtes - 28 Mo
Données mixtesfinal case class MixedData( f1: Option[String], f2: Option[Double], f3: Option[Long], f4: Option[Int], f5: Option[String], f6: Option[Double], f7: Option[Long], f8: Option[Int], f9: Option[Int], f10: Option[Long], f11: Option[Float], f12: Option[Double], f13: Option[String], f14: Option[String], f15: Option[Long], f16: Option[Int], f17: Option[Int], f18: Option[String], f19: Option[String], f20: Option[String], ) extends Data
- Seules les lignes - 71 Mo
Onlystrings final case class OnlyStrings( f1: Option[String], f2: Option[String], f3: Option[String], f4: Option[String], f5: Option[String], f6: Option[String], f7: Option[String], f8: Option[String], f9: Option[String], f10: Option[String], f11: Option[String], f12: Option[String], f13: Option[String], f14: Option[String], f15: Option[String], f16: Option[String], f17: Option[String], f18: Option[String], f19: Option[String], f20: Option[String], ) extends Data
- Seuls les chiffres (longs) - 20 Mo
Onlylongs final case class OnlyLongs( f1: Option[Long], f2: Option[Long], f3: Option[Long], f4: Option[Long], f5: Option[Long], f6: Option[Long], f7: Option[Long], f8: Option[Long], f9: Option[Long], f10: Option[Long], f11: Option[Long], f12: Option[Long], f13: Option[Long], f14: Option[Long], f15: Option[Long], f16: Option[Long], f17: Option[Long], f18: Option[Long], f19: Option[Long], f20: Option[Long], ) extends Data
Chaque entrée se compose de 20 champs. La valeur de chaque champ est facultative.
Test
Caractéristiques du PC sur lequel les tests ont eu lieu, version de scala et java:
PC: Intel Core i5-5350U 1,8 GHz (2 cœurs physiques), 8 Go 1600 MHz DDR3, SSD SM0128G
Version Java: 1.8.0_144-b01; Hotspot: build 25.144-b01
Version Scala: 2.12.8
Sérialisation Java
Json
Avro
Le circuit Avro a été généré en déplacement avant les tests directs. Pour cela, la bibliothèque
avro4s a été
utilisée .
Protobuf
Schéma de Protobuf syntax = "proto3"; package protoBenchmark; option java_package = "protobufBenchmark"; option java_outer_classname = "data"; message MixedData { string f1 = 1; double f2 = 2; sint64 f3 = 3; sint32 f4 = 4; string f5 = 5; double f6 = 6; sint64 f7 = 7; sint32 f8 = 8; sint32 f9 = 9; sint64 f10 = 10; double f11 = 11; double f12 = 12; string f13 = 13; string f14 = 14; sint64 f15 = 15; sint32 f16 = 16; sint32 f17 = 17; string f18 = 18; string f19 = 19; string f20 = 20; } message OnlyStrings { string f1 = 1; string f2 = 2; string f3 = 3; string f4 = 4; string f5 = 5; string f6 = 6; string f7 = 7; string f8 = 8; string f9 = 9; string f10 = 10; string f11 = 11; string f12 = 12; string f13 = 13; string f14 = 14; string f15 = 15; string f16 = 16; string f17 = 17; string f18 = 18; string f19 = 19; string f20 = 20; } message OnlyLongs { sint64 f1 = 1; sint64 f2 = 2; sint64 f3 = 3; sint64 f4 = 4; sint64 f5 = 5; sint64 f6 = 6; sint64 f7 = 7; sint64 f8 = 8; sint64 f9 = 9; sint64 f10 = 10; sint64 f11 = 11; sint64 f12 = 12; sint64 f13 = 13; sint64 f14 = 14; sint64 f15 = 15; sint64 f16 = 16; sint64 f17 = 17; sint64 f18 = 18; sint64 f19 = 19; sint64 f20 = 20; }
Pour générer des classes protobuf3, le plugin
ScalaPB a été
utilisé .
Thrift
Schéma d'épargne namespace java thriftBenchmark.java #@namespace scala thriftBenchmark.scala typedef i32 int typedef i64 long struct MixedData { 1:optional string f1, 2:optional double f2, 3:optional long f3, 4:optional int f4, 5:optional string f5, 6:optional double f6, 7:optional long f7, 8:optional int f8, 9:optional int f9, 10:optional long f10, 11:optional double f11, 12:optional double f12, 13:optional string f13, 14:optional string f14, 15:optional long f15, 16:optional int f16, 17:optional int f17, 18:optional string f18, 19:optional string f19, 20:optional string f20, } struct OnlyStrings { 1:optional string f1, 2:optional string f2, 3:optional string f3, 4:optional string f4, 5:optional string f5, 6:optional string f6, 7:optional string f7, 8:optional string f8, 9:optional string f9, 10:optional string f10, 11:optional string f11, 12:optional string f12, 13:optional string f13, 14:optional string f14, 15:optional string f15, 16:optional string f16, 17:optional string f17, 18:optional string f18, 19:optional string f19, 20:optional string f20, } struct OnlyLongs { 1:optional long f1, 2:optional long f2, 3:optional long f3, 4:optional long f4, 5:optional long f5, 6:optional long f6, 7:optional long f7, 8:optional long f8, 9:optional long f9, 10:optional long f10, 11:optional long f11, 12:optional long f12, 13:optional long f13, 14:optional long f14, 15:optional long f15, 16:optional long f16, 17:optional long f17, 18:optional long f18, 19:optional long f19, 20:optional long f20, }
Pour générer des classes d'épargne de type scala, le plugin
Scrooge a été utilisé.
Msgpack
Comparaison finale



Exactitude des résultatsImportant: les résultats de la sérialisation et de la vitesse de désérialisation ne sont pas précis à 100%. Il y a une grosse erreur. Malgré le fait que les tests ont été exécutés plusieurs fois avec un échauffement JVM supplémentaire, il est difficile d'appeler les résultats stables et précis. C'est pourquoi je déconseille fortement de tirer des conclusions définitives concernant un format de sérialisation particulier, en mettant l'accent sur les délais.
Étant donné que les résultats ne sont pas absolument exacts, certaines observations peuvent encore être faites sur leur base:
- Encore une fois, nous nous sommes assurés que la sérialisation java est lente et pas la plus économique en termes de sortie. L'une des principales raisons de la lenteur du travail est l'accès aux champs des objets par réflexion. Soit dit en passant, les champs sont accessibles et en outre enregistrés non pas dans l'ordre dans lequel vous les avez déclarés dans la classe, mais triés dans l'ordre lexicographique. Ceci est juste un fait intéressant;
- Json est le seul format de texte présenté dans cette comparaison. La raison pour laquelle les données sérialisées dans json prennent beaucoup de place est évidente - chaque enregistrement est écrit avec le circuit. Cela affecte également la vitesse d'écriture dans le fichier: plus vous devez écrire d'octets, plus cela prend de temps. N'oubliez pas non plus qu'un objet json est créé pour chaque enregistrement, ce qui ne réduit pas non plus le temps;
- Lors de la sérialisation d'un objet, Avro analyse le circuit afin de décider davantage comment traiter un champ particulier. Ce sont des coûts supplémentaires entraînant une augmentation du temps total de sérialisation;
- Thrift, en comparaison avec, par exemple, protobuf et msgpack, nécessite une plus grande quantité de mémoire pour écrire un champ, puisque ses méta-informations sont enregistrées avec la valeur du champ. De plus, si vous regardez les fichiers de sortie de Thrift, vous pouvez voir que pas une petite fraction du volume total n'est occupée par divers identifiants du début et de la fin de l'enregistrement et la taille de l'enregistrement entier comme séparateur. Tout cela ne fait certainement qu'augmenter le temps consacré à l'emballage;
- Protobuf, comme thrift, contient des méta-informations, mais les rend un peu plus optimisées. De plus, la différence dans l'algorithme d'emballage et de déballage permet à ce format dans certains cas de fonctionner plus rapidement que d'autres;
- Msgpack fonctionne assez rapidement. Une des raisons de la vitesse est le fait qu'aucune méta-information supplémentaire n'est sérialisée. C'est à la fois bon et mauvais: bon parce qu'il prend peu d'espace disque et ne nécessite pas de temps d'enregistrement supplémentaire, mauvais parce qu'en général on ne sait rien sur la structure d'enregistrement, donc déterminer comment emballer et décompresser ceci ou cela une valeur différente est exécutée pour chaque champ de chaque enregistrement.
Quant aux tailles des fichiers de sortie, les observations sont assez claires:
- Le plus petit fichier pour la numérotation numérique a été obtenu auprès de msgpack;
- Le plus petit fichier de l'ensemble de chaînes s'est avéré être dans le fichier source :) Sauf pour le fichier source, avro gagné par une petite marge de msgpack et protobuf;
- Le plus petit fichier pour l'ensemble mixte provient à nouveau de msgpack. Cependant, l'écart n'est pas si perceptible et avro et protobuf sont très proches;
- Les fichiers les plus volumineux provenaient de json. Cependant, une remarque importante doit être faite - le format de texte json et le comparer au volume binaire (et en termes de vitesse de sérialisation) n'est pas tout à fait correct;
- Le fichier le plus volumineux pour la numérotation numérique a été obtenu à partir de la sérialisation java standard;
- Le plus gros fichier de l'ensemble de chaînes était un binaire d'épargne;
- Le plus gros fichier de l'ensemble mixte était l'épargne binaire. Ensuite vient la sérialisation java standard.
Analyse de format
Essayons maintenant de comprendre les résultats par l'exemple de la sérialisation d'une chaîne de 36 caractères (UUID) sans prendre en compte les séparateurs entre les enregistrements, divers identifiants du début et de la fin d'un enregistrement - enregistrer uniquement 1 champ de chaîne, mais en tenant compte de paramètres tels que, par exemple, le type et le numéro de champ . La prise en compte de la sérialisation des chaînes couvre complètement plusieurs aspects à la fois:
- Sérialisation des nombres (dans ce cas, la longueur de la chaîne)
- Sérialisation de chaînes
Commençons par avro. Étant donné que tous les champs sont de type «Option», le schéma de ces champs sera le suivant: «union: [« null »,« string »]«. Sachant cela, vous pouvez obtenir le résultat suivant:
1 octet pour indiquer le type d'enregistrement (nul ou chaîne), 1 octet par longueur de ligne (1 octet car avro utilise
une longueur variable pour écrire des entiers) et 36 octets par ligne elle-même. Total: 38 octets.
Considérez maintenant msgpack. Msgpack utilise une approche similaire à la
longueur variable pour écrire des entiers:
spec . Essayons de calculer combien il faut réellement pour écrire un champ de chaîne: 2 octets par longueur de ligne (car une chaîne> 31 octets, puis 2 octets seront nécessaires), 36 octets par données. Total: 38 octets.
Protobuf utilise également
des longueurs variables pour coder les nombres. Cependant, en plus de la longueur de la chaîne, protobuf ajoute un autre octet avec le nombre et le type de champ. Total: 38 octets.
Thrift
binary n'utilise aucune optimisation pour écrire la longueur d'une chaîne, mais au lieu de 1 octet par numéro et type de champ, thrift prend 3. Par conséquent, le résultat suivant est obtenu: 1 octet par numéro de champ, 2 octets par type, 4 octets par longueur de ligne, 36 octets par chaîne. Total: 43 octets.
Thrift
compact , contrairement au binaire, utilise l'approche de
longueur variable pour écrire des entiers et, si possible, utilise en plus un enregistrement d'en-tête de champ abrégé. Sur cette base, nous obtenons: 1 octet pour le type et le numéro du champ, 1 octet pour la longueur, 36 octets pour les données. Total: 38 octets.
La sérialisation Java a pris 45 octets pour écrire une chaîne, dont 36 octets - une chaîne, 9 octets - 2 octets de long et 7 octets pour quelques informations supplémentaires, que je n'ai pas pu déchiffrer.
Seuls avro, msgpack, protobuf et thrift compact restent. Chacun de ces formats nécessitera 38 octets pour écrire des lignes utf-8 de 36 caractères. Pourquoi, alors, lors du compactage des enregistrements de chaîne de 100k, avro a-t-il obtenu une plus petite quantité, bien qu'un schéma non compressé ait été écrit avec les données? Avro a une petite marge par rapport aux autres formats et la raison de cet écart est le manque de 4 octets supplémentaires par longueur d'emballage de l'enregistrement entier. Le fait est que ni msgpack, ni protobuf, ni thrift n'ont de séparateur d'enregistrement spécial. Par conséquent, pour que je puisse décompresser correctement les enregistrements, j'avais besoin de connaître la taille exacte de chaque enregistrement. Sinon, alors, avec une forte probabilité, msgpack aurait un fichier plus petit.
Pour un ensemble de données numériques, la raison principale pour laquelle msgpack a gagné était le manque d'informations de schéma dans les données compressées et le fait que les données étaient rares. Thrift et protobuf prendront même plus d'un octet pour vider les valeurs en raison de la nécessité de regrouper des informations sur le type et le numéro du champ. Avro et msgpack nécessitent exactement 1 octet pour écrire une valeur vide, mais avro, comme déjà mentionné, enregistre le circuit avec les données.
Msgpack a également emballé dans un fichier plus petit et un ensemble mixte, qui était également rare. Les raisons en sont toutes les mêmes.
Ainsi, il s'avère que les données emballées dans msgpack prennent le moins d'espace. C'est une déclaration juste - ce n'est pas pour rien que msgpack a été choisi comme format de stockage des données pour Tarantool et Aerospike.
Conclusion
Après les tests, je peux tirer les conclusions suivantes:
- Il est difficile d'obtenir des résultats de référence stables;
- Le choix d'un format est un compromis entre la vitesse de sérialisation et la taille de sortie. Dans le même temps, n'oubliez pas des paramètres aussi importants que la commodité d'utilisation du format et la possibilité d'évolution du schéma (souvent ces paramètres jouent un rôle dominant).
Le code source peut être trouvé ici:
github