CodeGym /Cours /Hibernate deep-dive /@DynamicUpdate et la largeur de UPDATE

@DynamicUpdate et la largeur de UPDATE

Hibernate deep-dive
Niveau 16, Leçon 3
Disponible

1. Largeur de UPDATE : pourquoi c'est important

Quand on commence à travailler avec un ORM, on se réjouit d'abord du simple fait que UPDATE « se produit d'une manière ou d'une autre ». Mais en se rapprochant de la réalité, on découvre qu'un même UPDATE peut être différent. Il peut modifier une seule colonne ou « réécrire la moitié de la table », même si vous n'avez changé qu'un seul drapeau. Et c'est là que le cerveau commence à poser des questions.

Imaginez que vous allez au bureau des passeports pour changer une lettre dans votre nom de famille, et l'employé dit : « Super ! Recommençons tout le passeport du début, y compris le numéro, la date de naissance et le lieu d'enregistrement. » Formellement, le résultat est correct. Mais on sent qu'on fait plus de travail que nécessaire. Voilà, la « largeur de UPDATE » parle à peu près de cela : combien de colonnes se retrouvent réellement dans SET et combien de valeurs traînons-nous à chaque fois dans le SQL.

Après avoir choisi une forme de modèle, il est naturel de se demander quel SQL il génère lors de la mise à jour. Ici, il est plus pratique de regarder non pas PromotionCampaign, mais Product : ses modifications étroites sont visibles immédiatement — renommage, changement de drapeau d'activité, ajustement de prix. C'est précisément sur ces scénarios qu'on voit bien quand le UPDATE reste large, et quand Hibernate peut le réduire aux champs dirty.

C'est important ici non pas parce que nous rêvons « d'optimiser chaque ligne », mais parce que nous apprenons à prédire le comportement de l'ORM. @DynamicUpdate change la forme du SQL. Et la forme du SQL est ce que vous verrez dans les logs, ce que la base de données planifiera, et ce que vous expliquerez lors des revues de code (parfois à vous-même à 3 heures du matin).

Pour voir le problème en direct, imaginez un SQL « ordinaire » sans aucune magie :

-- UPDATE « large » : dans SET, il y a plus de colonnes que ce qui a réellement changé
update product
set sku = ?, name = ?, status = ?, price_amount = ?, price_currency = ?, active = ?
where id = ?;

Si vous n'avez modifié que name, la question se pose : pourquoi Hibernate envoie-t-il les valeurs pour sku, status, price_* , active ? Et c'est là qu'apparaît justement le sujet de @DynamicUpdate.

2. UPDATE par défaut : souvent large

Si vous entendez aujourd'hui pour la première fois la phrase « Hibernate met à jour toutes les colonnes », ne vous inquiétez pas : cela ne signifie pas qu'il « ne sait pas faire » le dirty checking. Il le sait. Simplement, en mode par défaut, le dirty checking répond souvent à la question « mettre à jour ou ne pas mettre à jour du tout », et pas nécessairement à la question « quelles colonnes inclure dans le SQL ».

Du point de vue du modèle interne (que nous avons déjà abordé dans les jours sur le dirty checking et le flush), le scénario typique est le suivant : dans une transaction, vous chargez un Product, modifiez un champ, et lors du flush, Hibernate décide : « Oui, l'entité est dirty, donc un UPDATE est nécessaire ». Ensuite, il prend le modèle SQL de mise à jour préalablement préparé pour cette entité et y substitue simplement les valeurs actuelles de toutes les colonnes.

On peut le représenter très schématiquement ainsi :

flowchart TD
    A["em.find(Product, id)"] --> B[Product devient managed]
    B --> C["Modification du champ : product.setName(...)"]
    C --> D[Flush/Commit]
    D --> E[Dirty checking : y a-t-il des changements ?]
    E -->|oui| F[Hibernate exécute UPDATE]
    E -->|non| G[UPDATE non nécessaire]

Notez une idée importante : par défaut, Hibernate peut savoir qu'« un seul champ » a changé, mais envoyer quand même un « UPDATE large ». La raison est généralement banale et pragmatique : un SQL stable est bien mis en cache, bien réutilisé en tant que prepared statement, et la base de données passe moins de temps à analyser/planifier de multiples variantes.

Voici un mini-exemple de notre projet (dans le style « Commerce Persistence Lab »), où nous modifions uniquement le nom du produit. Le code est d'une banalité absolue — comme un lundi matin, mais au moins il est honnête :

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductAdminService {
    private final EntityManager em;

    public ProductAdminService(EntityManager em) {
        // EntityManager — entrée dans le persistence context (là où vivent les entités managed)
        this.em = em;
    }

    @Transactional
    public void renameProduct(Long id, String newName) {
        // Dans la transaction, l'entité devient managed, Hibernate commence à suivre les modifications
        Product p = em.find(Product.class, id);

        // On modifie un champ : le dirty checking verra le changement et enverra un UPDATE au flush
        p.setName(newName);
    }
}

Si Product est sans @DynamicUpdate, vous verrez typiquement dans le SQL trace quelque chose comme « mets à jour tout ce qui peut être mis à jour », même si un seul champ a réellement changé. Ce n'est pas une « erreur d'Hibernate », c'est son mode de fonctionnement normal (et d'une certaine manière très pratique).

3. @DynamicUpdate : UPDATE selon les champs dirty

C'est ici qu'apparaît @DynamicUpdate. Et il faut commencer à en parler non pas par le mot « optimisation », mais par le mot « forme du SQL ». Cette annotation demande à Hibernate de ne pas utiliser un UPDATE fixe préétabli pour l'entité, mais plutôt de construire le SQL à la volée, en fonction des propriétés identifiées comme dirty.

Le sens est donc : le dirty checking sait déjà ce qui a changé. @DynamicUpdate lui dit : « OK, puisque tu le sais de toute façon, incluons dans SET uniquement les colonnes qui ont réellement changé ». Important : c'est une annotation Hibernate, pas du « JPA pur », donc l'import viendra du package Hibernate.

Un fragment minimal (juste un fragment, pas une classe complète) ressemble à ceci :

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import org.hibernate.annotations.DynamicUpdate;

@Entity
@DynamicUpdate // Demandons à Hibernate de construire un UPDATE pour les champs réellement modifiés (dirty properties)
public class Product {
    @Id
    @GeneratedValue // L'identifiant est généré par la BD/le fournisseur, on ne le touche pas dans l'UPDATE
    private Long id;

    // Si on modifie seulement name, alors avec @DynamicUpdate seule cette colonne se retrouve généralement dans SET
    private String name;
}

Après cela, si vous modifiez seulement name, le SQL peut devenir plus étroit — du genre :

update product set name = ? where id = ?;

Et si vous modifiez deux champs, le SQL s'élargira exactement à ces deux champs, mais pas à tous les autres.

Pour éviter l'impression que nous parlons « à vue de nez », voici un petit tableau d'attentes (en réalité, les noms des colonnes dépendent de votre mapping, mais l'idée est la même) :

Scénario de modification Sans @DynamicUpdate (souvent) Avec @DynamicUpdate (idée)
Modification uniquement de name SET sku=?, name=?, status=?, ... SET name=?
Modification de name et active SET sku=?, name=?, status=?, ... SET name=?, active=?
Aucune modification UPDATE ne sera pas effectué UPDATE ne sera pas effectué

Et ici, il est important de préciser « ce qui ne change pas », pour ne pas tomber dans le piège des attentes :

@DynamicUpdate n'annule pas le dirty checking. Il ne rend pas le travail d'Hibernate « magiquement bon marché ». Hibernate doit toujours comprendre ce qui a changé.

@DynamicUpdate ne réduit pas le nombre de requêtes en lui-même. Si vous avez un N+1 en lecture ou des SELECT superflus à cause d'un mauvais plan de fetch, cette annotation concerne tout autre chose.

@DynamicUpdate n'est pas un « remède contre un mauvais modèle ». Si l'entité est trop large, la bonne solution n'est parfois pas de « mettre à jour dynamiquement une table très large », mais de rendre le modèle plus propre (c'est exactement ce que nous avons appris avec ProductDetails, les embeddables et la composition).

4. Expérience dans Commerce Persistence Lab

Maintenant, faisons l'étape la plus utile pour la compréhension : transformons @DynamicUpdate d'une « annotation abstraite trouvée sur Internet » en un effet observable dans la trace SQL. C'est particulièrement important pour les débutants : tant que vous n'avez pas vu la différence de vos propres yeux, votre cerveau pensera que @DynamicUpdate est « quelque chose à propos de l'accélération ». Alors que nous voulons une mécanique précise.

Nous allons exécuter le scénario sur Product, car c'est l'objet central du projet et il a des modifications « étroites » naturelles : renommer un produit, masquer un produit, changer un prix. Pour voir l'effet, activez le profil de trace SQL (dans notre projet, il est déjà supposé l'être). Il est important non seulement de voir le SQL, mais aussi de ne pas oublier : le UPDATE est généralement envoyé lors du flush/commit.

Scénario A : modification uniquement de name

Méthode de service (pas nécessairement nommée exactement ainsi chez vous — le sens est plus important) :

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogWriteService {
    private final EntityManager em;

    public CatalogWriteService(EntityManager em) {
        // On garde EntityManager comme dépendance du service
        this.em = em;
    }

    @Transactional
    public void rename(Long id, String newName) {
        // On charge l'entité managed dans le cadre de la transaction
        Product p = em.find(Product.class, id);

        // On modifie un seul champ — c'est exactement ce que nous voulons voir dans un UPDATE étroit avec @DynamicUpdate
        p.setName(newName);
    }
}

Maintenant, expérience mentale sur le SQL :

Si Product est sans @DynamicUpdate, le log ressemblera souvent à un large update où toutes les colonnes updatables de Product sont mises à jour (y compris les colonnes de Money, status, active, etc.).

Si Product est avec @DynamicUpdate, alors lors de la modification de seulement name, Hibernate s'efforcera d'envoyer un UPDATE étroit où SET ne contiendra que name.

Oui, cela semble « évident », mais c'est ainsi que se crée l'habitude : nous ne croyons pas les mots, nous croyons la trace SQL.

Scénario B : modification de name et active

Maintenant, modifions deux champs — pour voir comment le SQL s'élargit exactement pour eux :

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogVisibilityService {
    private final EntityManager em;

    public CatalogVisibilityService(EntityManager em) {
        // EntityManager est nécessaire pour obtenir un Product managed et attendre le flush
        this.em = em;
    }

    @Transactional
    public void renameAndHide(Long id, String newName) {
        // On trouve l'entité : ensuite les modifications seront suivies automatiquement
        Product p = em.find(Product.class, id);

        // On fait deux modifications — avec @DynamicUpdate on s'attend à deux champs dans SET
        p.setName(newName);
        p.setActive(false);
    }
}

Le sens est simple : avec @DynamicUpdate, le UPDATE sera plus large que dans le scénario A, mais il n'est toujours pas obligé d'inclure les autres colonnes.

Et c'est un bon endroit pour faire le lien avec le sujet du flush. Si vous voulez « attraper le moment » où le SQL est envoyé, vous pouvez ajouter temporairement un flush forcé (à des fins pédagogiques, c'est tout à fait normal) :

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogFlushProbeService {
    private final EntityManager em;

    public CatalogFlushProbeService(EntityManager em) {
        // Ce service n'est nécessaire que comme « sonde » du moment du flush
        this.em = em;
    }

    @Transactional
    public void renameAndFlush(Long id, String newName) {
        // On charge l'entité managed
        Product p = em.find(Product.class, id);

        // On fait une modification...
        p.setName(newName);

        // ...et on force la sortie des modifications vers la BD pour voir le SQL ici même
        em.flush(); // ici, l'UPDATE est envoyé
    }
}

Et c'est là que généralement l'étudiant a un « déclic » : l'annotation n'affecte pas le setter, ni find(), mais précisément quel SQL est construit lors du flush.

Scénario C : modification de Money.amount dans un @Embeddable

Dans notre projet, le prix est un Money (@Embeddable). C'est un excellent exemple pour démontrer que la « largeur de mise à jour » ne concerne pas seulement les champs « plats ».

Supposons que Money soit stocké comme price_amount et price_currency. Nous modifions seulement amount :

import jakarta.persistence.EntityManager;
import java.math.BigDecimal;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductPricingService {
    private final EntityManager em;

    public ProductPricingService(EntityManager em) {
        // EntityManager est nécessaire pour travailler avec le Product managed et ses parties embeddable
        this.em = em;
    }

    @Transactional
    public void changePrice(Long id, BigDecimal newAmount) {
        // On trouve le produit dans le persistence context
        Product p = em.find(Product.class, id);

        // On modifie seulement amount dans le Money embeddable
        // Avec @DynamicUpdate, on s'attend à ce que price_amount soit dans SET, mais pas nécessairement price_currency
        p.getPrice().setAmount(newAmount);
    }
}

Si @DynamicUpdate est activé, Hibernate peut potentiellement mettre à jour uniquement la colonne amount, sans toucher à currency (si currency n'a pas changé). Sans @DynamicUpdate, on obtient à nouveau souvent « mets à jour tout ce que l'entité a ».

Et c'est d'ailleurs une bonne raison de garder Money soit immuable (comme nous l'avons discuté dans le thème des value objects), soit de le modifier avec précaution : si vous touchez accidentellement un champ supplémentaire dans un @Embeddable, la mise à jour dynamique l'inclura avec joie dans le SQL. Hibernate est très honnête à cet endroit : « tu as modifié — j'ai enregistré ».

5. Coût et bénéfice de @DynamicUpdate : où on gagne, où on paye

Il est facile de déclencher une guerre de religion autour de @DynamicUpdate : certains disent « activez-le toujours — moins de données », d'autres disent « jamais — ça tuera le cache et les performances ». Comme d'habitude, la réalité est plus ennuyeuse et plus utile : c'est un outil qui aide dans certains scénarios et gêne dans d'autres. Il faut comprendre les deux côtés.

Le bénéfice de @DynamicUpdate est simple à comprendre : si l'entité est large et que vos cas d'écriture modifient généralement 1–2 champs, alors un UPDATE étroit semble plus logique. Cela peut réduire le « bruit » dans le journal SQL et diminuer le volume de paramètres que vous échangez. Parfois, cela peut être particulièrement visible si la table comporte des champs volumineux ou des « morceaux rarement modifiés » que l'on ne veut pas toucher inutilement.

Le coût n'est pas magique non plus, mais technique : lorsque le SQL devient dynamique, vous avez de nombreuses variantes de UPDATE. Cela peut dégrader la réutilisation des prepared statements (selon l'environnement), compliquer le diagnostic et parfois dégrader soudainement les performances globales si les requêtes commencent à « sauter d'une forme à l'autre » trop souvent.

Ci-dessous, un tableau simple qui aide à maintenir l'équilibre :

Axe de comparaison Mode normal (souvent UPDATE large) @DynamicUpdate
Forme du SQL Généralement stable, « un modèle par entité » Peut varier en fonction des champs modifiés
« Largeur » de SET Souvent maximale Généralement minimale selon les champs dirty
Lisibilité de la trace SQL Parfois bruyant : on a modifié un seul, mis à jour tout Parfois plus clair : on voit ce qui a réellement été modifié
Comportement des caches SQL/plans Plus souvent facile à réutiliser Peut devenir plus complexe à cause des multiples formes
Sauve d'un mauvais mapping Non Non plus (mais crée l'illusion de sauver)

Et c'est ici un point pédagogique très important : @DynamicUpdate n'est pas « rendre tout plus rapide », mais changer la forme du SQL. Parfois ça aide. Parfois non. Parfois même ça gêne. Si on considère l'annotation comme un « outil pour embellir le SQL », on peut aussi se tromper : un SQL joli n'est pas toujours rapide, et un SQL rapide n'est pas toujours joli. Notre objectif est la prévisibilité et la contrôlabilité.

Comment prendre une décision : un scalpel ciblé, pas une « case à cocher par défaut »

Lorsqu'un développeur débutant découvre @DynamicUpdate, la main le démange de faire « correctement » et de l'appliquer à toutes les entités. C'est un désir compréhensible : il semble que « mettre à jour uniquement ce qui a changé » est évidemment mieux. Mais Hibernate est un système de compromis, et « évidemment mieux » se transforme souvent en « difficile de prédire pourquoi c'est devenu pire en production ».

Il est préférable de prendre une décision non pas sur le principe « j'aime » mais à travers trois questions. Première : à quel point l'entité est-elle large et à quel point le problème de l'UPDATE large est-il réellement visible. Deuxième : quels sont nos cas d'écriture. Si nous modifions presque toujours tout (par exemple, mise à jour de toute la fiche produit en bloc) — la mise à jour dynamique n'apportera pas grand-chose. Troisième : pouvons-nous résoudre le problème par le modèle plutôt que par un indicateur. Souvent, au lieu de @DynamicUpdate, il est plus correct de conserver les données rarement modifiées dans une entité séparée (ProductDetails) ou dans une table séparée, afin que la nature même des données devienne plus claire.

Pour que ce soit vraiment pratique, voici une « liaison adulte normale » de symptômes et de solutions (sans héroïsme) :

Observation dans le projet Que essayer en premier Où @DynamicUpdate peut trouver sa place
La table est très large à cause du modèle Simplifier le modèle, extraire les champs rarement modifiés Si on ne peut pas extraire, et que les mises à jour sont étroites et fréquentes
Dans les logs, un UPDATE irritant large S'assurer que vous modifiez vraiment seulement 1–2 champs Si c'est le cas, et que le large update gêne
L'entité « se salit » à cause de modifications accidentelles Corriger la mise à jour accidentelle (setters, mappers, embeddable) @DynamicUpdate n'est pas un traitement ici, mais simplement un autre type de symptômes

Dans notre Commerce Persistence Lab, une bonne position pour @DynamicUpdate est une entité qui a vraiment des modifications étroites et fréquentes (par exemple, « activer/désactiver » et « renommer »), et dont la table est sensiblement large. Mais si l'entité est déjà compacte, l'application de @DynamicUpdate n'est le plus souvent qu'un ajout de complexité sans gain.

Nuance importante : @DynamicUpdate ne corrige pas les « UPDATE accidentels »

Il est très facile de confondre deux problèmes différents. Le premier — « Hibernate met à jour trop de colonnes alors que j'en ai modifié une seule ». Le second — « Hibernate met à jour alors que je ne voulais pas mettre à jour ». @DynamicUpdate ne concerne que le premier. Si l'entité devient dirty à cause d'une modification accidentelle (par exemple, quelqu'un touche à Address, un mapper copie inconsidérément des champs, la journalisation provoque des effets de bord), alors @DynamicUpdate ne rend pas la situation correcte — il la rend simplement plus sélectivement incorrecte.

Il y a même un effet de bord amusant : quand le UPDATE est large, vous le remarquez parfois plus tôt (dans le journal, on voit clairement « tout a été envoyé »). Alors que quand le UPDATE est étroit, il semble « inoffensif », et vous pouvez ne pas remarquer pendant longtemps que le code métier fait des écritures superflues. Par conséquent, la règle reste la même : d'abord la discipline du modèle et du scénario transactionnel, ensuite les réglages ponctuels.

Et encore une chose : @DynamicUpdate n'est pas un outil pour « gérer des scénarios de merge complexes » ni un moyen de rendre le flux detached plus sûr. C'est un tout autre sujet. Ici, nous ne traitons pas detach/merge, nous ne traitons pas N+1, nous ne traitons pas les limites transactionnelles. Nous modifions simplement la forme d'un UPDATE spécifique à l'intérieur du cycle de flush.

6. Erreurs typiques lors de l'utilisation de @DynamicUpdate

Erreur n°1 : appliquer @DynamicUpdate à tout, parce que « c'est plus logique ».
Quand vous mettez l'annotation sur chaque entité, vous transformez le comportement SQL en « flottant dans sa forme » sans raison explicite. Parfois, cela ressemble à une amélioration (« oh, il y a moins de colonnes »), mais le coût apparaît plus tard : il est plus difficile d'expliquer le SQL, plus difficile de comparer les logs, plus difficile de prédire le comportement. Il est bien plus sain de considérer @DynamicUpdate comme un réglage ciblé pour une entité large spécifique et des mises à jour étroites spécifiques.

Erreur n°2 : s'attendre à ce que @DynamicUpdate réduise le nombre de requêtes.
Cette annotation ne concerne pas le nombre de SELECT ou UPDATE. Elle concerne le contenu de SET dans UPDATE. Si vous avez un problème de fetching (SELECT superflus, N+1, initialisation excessive des relations), @DynamicUpdate ne regarde même pas dans cette direction. Au mieux, vous « mettrez joliment à jour » des données que vous avez déjà lues à un coût trop élevé.

Erreur n°3 : utiliser @DynamicUpdate comme substitut à une conception de mapping normale.
Parfois, une entité devient large non pas parce que « c'est nécessaire », mais parce qu'on y a mis tout et n'importe quoi : la fiche, les détails, les champs administratifs, et de rares gros morceaux de texte. Dans ce cas, la bonne solution est le plus souvent compositionnelle : extraire une partie dans une entité ou une table séparée (ProductDetails), plutôt que de demander à Hibernate de construire à chaque fois un « UPDATE intelligent » pour le monstre.

Erreur n°4 : ne pas vérifier l'effet dans la trace SQL et se fier au nom de l'annotation.
Le nom DynamicUpdate donne l'impression que « ça va forcément marcher ». Mais l'ORM est un système de conditions : quels champs sont dirty, comment est structuré @Embeddable, ce qui change exactement dans l'objet, quand le flush a eu lieu. Si vous n'avez pas activé la trace SQL et comparé les requêtes réelles, vous avez en fait activé un indicateur « au hasard ». En ingénierie, cela se termine généralement mal.

Erreur n°5 : confondre « UPDATE étroit » avec « UPDATE sûr ».
Même si vous mettez à jour une seule colonne au lieu de dix, cela ne rend pas la mise à jour automatiquement « correcte » du point de vue de la logique métier. Si la modification est accidentelle, ou si vous mettez à jour le mauvais objet, ou si vous le faites dans un mauvais scénario — @DynamicUpdate rendra simplement le SQL plus court. Mais l'erreur restera. Un ORM (comme la vie) ne se soigne pas en réduisant le nombre de lignes de code.

1
Mission
Hibernate deep-dive, niveau 16, leçon 3
Bloqué
UPDATE étroit lors du renommage d'un produit
UPDATE étroit lors du renommage d'un produit
1
Mission
Hibernate deep-dive, niveau 16, leçon 3
Bloqué
UPDATE uniquement sur deux colonnes modifiées
UPDATE uniquement sur deux colonnes modifiées
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION