Il y a quelques semaines, en investiguant de possibles améliorations de performance pour le backend JDBC de Kestra, j’ai remarqué qu’une méthode qu’on utilisait pour mapper une entité à persister en base de données dans sa représentation JSONB
prenait beaucoup de temps dans nos profiles CPU.
Dans le flame graph suivant, on peut voir que la méthode JdbcQueue.map()
compte pour plus de 21% des échantillons et la méthode Repository.map()
pour 3.2% des échantillons d’un profile CPU utilisant async-profiler.
Ces deux méthodes permettent de mapper une colonne de type JSONB
dans le type cible de l’entité. Pour cela, on utilise un object mapper de Jackson. Le code peut se simplifier en :
MAPPER.readValue(record.get("value", String.class), MyEntity.class);
Instinctivement, je me dis qu’il faudrait plutôt lire le record en JSONB
qu’en String
, je réalise donc le changement suivant :
MAPPER.readValue(record.get("value", JSONB.class).data(), MyEntity.class);
Et le résultat est immédiat, une nette amélioration des performances !
JdbcQueue.map()
passe de plus de 21% des échantillons à un petit 4.6% et la méthode Repository.map()
de 3.2% des échantillons à 2.7%. Dans le test que je réalise, la queue de Kestra est fortement sollicitée ce qui explique que l’impact soit beaucoup plus fort sur ce composant.
Content par l’amélioration, j’ouvre une PR que je merge assez rapidement : https://github.com/kestra-io/kestra/pull/4899/files.
Mais, après réflexion je me dis que j’aimerais bien savoir le pourquoi ?
J’ouvre donc mon IDE favoris et parcourt le code :
Record.get(String, Class)
est implémenté parAbstractRecord.get(String, Class)
.AbstractRecord.get(String, Class)
mène àAbstractRecord.get(int, Class)
où une méthodeconverterOrFail()
est appelée. Ah!, une conversion, ça peut expliquer l’impact en performance !- La conversion se fait via un
ConvertedProvider
qui fournit unConverter
. - Après une recherche rapide, les convertisseurs utilisent la classe utilitaire
Convert
pour la conversion de type, on s’approche ! Convert.from()
, en ligne 716, convertit n’importe quel type en String via appel de sa méthodetoString()
.
// All types can be converted into String else if (toClass == String.class) { if (from instanceof EnumType e) return (U) e.getLiteral(); } return (U) from.toString(); }
On arrive donc à la méthode JSONB.toString()
qui utilise JSONValue.toJSONString(parsed())
pour normaliser la représentation JSON et permettre l’égalité entre deux JSON de structure différente, mais avec les mêmes attributs.
Cette méthode est clairement documentée comme étant une méthode à éviter dans un contexte sensible à la performance et c’est clairement précisé dans la JavaDoc.
uses a normalised representation of the JSON content, meaning that two equivalent JSON documents are considered equal. This impacts both behaviour and performance!
Je connais donc maintenant le pourquoi !
Par acquit de conscience, je me suis penché sur notre implémentation MySQL qui utilise le type JSON
, celui-ci n’effectue pas de normalisation et retourne directement le JSON sous-jasent via String.valueOf(data)
, elle ne souffre en conséquence pas du même souci de performance.
Voilà toute l’histoire, j’espère que cette plongée dans le code de jOOQ vous as intéressé autant que moi
Mise à jour : après publication de cet article sur les réseaux sociaux, jOOQ a répondu pointant vers deux issues GitHub à ce propos :
La deuxième issue vaut la peine d’être lue, elle pose le problème de la normalisation, évoque des pistes d’amélioration et les limites du parser JSON actuel. Un problème qui pourrait sembler simple au premier abord est souvent beaucoup plus compliqué qu’il n’y parait.
Pour participer à la discussion, j’ai ouvert une issue Switch JSONB record convertion for String to use the data() method qui propose de fixer ce cas précis de la conversion sans devoir attendre de fixer le problème plus générale de la performance de la méthode toString()
.