14. Applications concurrentes

La notion de transaction est classique dans le domaine des bases de données, et très liée à celle de contrôle de concurrence. Tous les SGBD implantent un système de contrôle, et la première section de ce chapitre propose quelques rappels et un exercice sur les niveaux d’isolation pour un rafraichissement (probablement utile) de vos connaissances.

Hibernate ne ré-implante pas un protocole de contrôle transactionnel qui serait redondant avec celui du SGBD. En première approche, une application Hibernate est donc simplement une application standard qui communique avec la base de données, éventuellement en concurrence avec d’autres applications, et lui soumet des transactions. Là où Hibernate mérite une développement spécifique, c’est dans le traitement de transactions « longues », particulièrement utiles dans le cadre d’une application Web.

S1: Rappels sur la concurrence d’accès

Supports complémentaires :

La première chose que je vous invite à faire est de lire soigneusement le chapitre d”introduction à la concurrence d’accès de mon cours de bases de données. Pendant la séance de cours, je donne une démonstration des principales caractéristiques pratiques de la concurrence d’accès et des niveaux d’isolation. Vous devriez être capable de reproduire cette démonstration (en comprenant ce qui se passe), et donc d’effectuer l’exercice suivant.

Exercice: simuler des transactions en ligne de commande

Pour cet exercice, vous allez étendre la base de données Films en ajoutant des séances diffusant des films, et en autorisant un internaute à effectuer la réservation d’un certain nombre de places pour une séance. Les tables Seance et Reservation existent déjà dans la base. Je vous laisse faire le mapping JPA pour ces deux entités.

Pour l’instant, on n’utilise pas Hibernate, mais deux terminaux connectés à MySQL, représentant deux sessions concurrentes. La commande prompt sous l’interpréteur MySQL permet de caractériser chaque session.

Sur la base de ma démonstration (pour ceux qui suivent le cours) ou du chapitre cité ci-dessus, reproduisez les situations suivantes:

  • en mode read uncommitted: lecture sale, menant une des transactions à connaître une mise à jour de l’autre transaction, alors que cette dernière fait un rollback;
  • en mode read committed: des mises à jour perdues, résultant en un état de la base incohérent;
  • faire la même exécution concurrente en repeatable read: peut-on s’attendre à un changement?
  • en mode serializable: un interblocage.

S2: Gestion de la concurrence avec Hibernate

Supports complémentaires :

Comme expliqué au début du chapitre, les transactions concurrentes sont une source vicieuse d’anomalies, et il est important d’en être pleinement conscient dans l’écriture de l’application.

Gestion des niveaux d’isolation

Hibernate s’appuie sur le niveau d’isolation fourni par le SGBD, et ne tente pas de ré-implanter des protocoles de concurrence. Le mode par défaut dans la plupart des systèmes est read committed ou repeatable read. La première partie de ce chapitre doit vous avoir convaincu que ce niveau d’isolation (par défaut) n’offre pas toutes les garanties et qu’il est indipensable de se poser sérieusement la question des risques liés à la concurrence d’accès. Dans une application transactionnelle, le mode read uncommitted ne devrait même pas être envisagé. Il nous reste donc comme possibilités:

  • d’accepter le mode par défaut, après évaluation des risques;
  • ou de passer en mode serializable, en acceptant les conséquences (performances moindres, risques de deadlock);
  • ou, enfin, d’introduire une dose de gestion « manuelle » de la concurrence en effectuant des verrouillages préventifs (dits, parfois, « verrouillage pessimiste »).

La configuration Hibernate permet de spécifier le mode d’isolation choisi pour une application:

hibernate.connection.isolation = <val>

val est un des codes suivants:

  • 1 pour read uncommitted
  • 2 pour read committed
  • 3 pour repeatable read
  • 4 pour serializable

Vous pouvez donc spécifier un niveau d’isolation, qui s’applique à toutes les sessions, même celles qui ne présentent pas de risque transactionnel. Dans ces conditions, choisir le niveau maximal (serializable) systématiquement représente une pénalité certaine. Il semble préférable d’utiliser le niveau d’isolation par défaut du SGBD, et de changer le mode d’isolation à serializable ponctuellement pour les transactions sensibles.

Il ne semble malheureusement pas possible, avec Hibernate, d’affecter simplement le niveau d’isolation pour une session. On en est donc réduit à passer par l’objet Connexion de JDBC. Le code est le suivant (ça ne s’invente pas…).

session.doWork(
    new Work() {
            @Override
            public void execute(Connection connection) throws SQLException {
                connection.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
            }
        });

Le mode serializable, malgré ses inconvénients (apparition d’interblocages) est le plus sûr pour garantir l’apparition d’incohérences dans la base, dont la cause est très difficile à identifier. Une autre solution, décrite ci-dessous, consiste à verrouiller explicitement, au moment de leur mise en cache, les objets que l’on va modifier.

Verrouillage avec Hibernate

Tout d’abord, précisons qu’Hibernate se contente de transmettre les requêtes de verrouillage au SGBD: aucun verrou en mémoire n’est posé (cela n’a pas de sens car chaque application ayant son propre cache, un verrou n’a aucun effet concurrentiel).

La principale utilité des verrous est de gérer le cas de la lecture d’une ligne/objet, suivie de l’écriture de cette ligne. Par défaut, la lecture pose un verrou partagé qui n’empêche pas d’autres transactions de lire à leur tour. Reportez-vous au cours sur la concurrence (début du chapitre) et à l’exemple-prototype des mises à jour perdues pour comprendre comment ces lectures posent problème quand une écriture survient ensuite.

Le principe du verrouillage explicite est donc d’effectuer une lecture qui anticipe l’écriture qui va suivre. C’est l’effet de la clause for update.

select * from xxx where .... for update

Le for update déclare que les lignes sélectionnées vont être modifiées ensuite. Pour éviter qu’une autre transaction n’accède à la ligne entre la lecture et l’écriture, le système va alors poser un verrou exclusif. Le risque de mise à jour perdue disparaît.

Avec Hibernate, l’équivalent du for update est la pose d’un verrou au moment de la lecture.

Transaction tx = session.beginTransaction();
Film film = session.get (Film.class, filmId, LockMode.UPGRADE);

film.titre = ... ; // Mises à jour

tx.commit();

L’énumération LockMode implique l’envoi d’une requête au SGBD avec une demande de verrouillage. Les valeurs possibles sont:

  • LockMode.NONE. Pas d’accès à la base pour verrouiller (mode par défaut).
  • LockMode.READ. Lecture pour vérifier que l’objet en cache est synchronisé avec la base.
  • LockMode.UPGRADE. Pose d’un verrou exclusif sur la ligne.
  • LockMode.WRITE. Utilisé en interne.

Gérer la concurrence dans le code d’une application est une opération lourde et peu fiable. Je vous conseille de vous limiter au principe simple suivant: quand vous lisez une ligne que vous allez modifier ensuite, placer un verrou avec LockMode.UPGRADE. Pour tous les autres cas, n’utilisez pas les autres modes et laissez le SGBD appliquer son protocole de concurrence.

Exercice: réserver des séances de cinéma

Cet exercice consiste à implanter une fonction de réservation de places pour une séance qui soit préservée des mauvais effets de la concurrence d’accès. Vous devez implanter deux actions:

  • la première affiche les séances, en montrant la capacité de chaque salle et le nombre de places restantes; un petit menu déroulant doit permettre à l’utilisateur de choisir de réserver de 1 à 10 places; un bouton « Réservation » permet de demander la réservation de ces places;
  • la seconde action doit effectuer la réservation.

Bien entendu il est essentiel de garantir l’intégrité de la base: on ne doit pas accepter plus de réservations que la capacité de la salle ne le permet. À vous de voir quelle technique de gestion de la concurrence vous devez appliquer (il doit être clair maintenant que le mode par défaut présente des risques).

S3: Transactions applicatives

Supports complémentaires :

Dans le cadre d’une application Web (ou d’une application interactive en général), la notion de « transaction » pour un utilisateur prend souvent la forme d’une séquence d’actions: affichage d’un formulaire, saisie, soumission, nouvelle saisie, confirmation, etc. On dépasse donc bien souvent le simple cadre d’un ensemble de mises à jour soumises à un moment donné par une action pour voir une « transaction » comme un séquence d’interactions (typiquement une séquence de requêtes HTTP dans le cadre d’une application Web).

Un exemple typique (que je vous laisserai implanter à titre d’exercice à la fin de cette section) est l’affichage des valeurs d’un objet, avec possibilité pour l’utilisateur d’effectuer des modifications en fonction de l’état courant de l’objet. Par exemple, on affiche un film et on laisse l’utilisateur éditer son résumé, ou les commentaires, etc.

Les transactions vues jusqu’à présent (que nous appellerons transactions en base) s’exécutent dans le cadre d’une unique action, sans intervention de l’utilisateur. Nous allons maintenant voir comment gérer des transactions applicatives couvrant plusieurs requêtes HTTP. Disons tout de suite qu’il est exclu de verrouiller des objets en attente d’une action utilisateur qui peut ne jamais venir. La stratégie à appliquer (pour laquelle Hibernate fournit un certain support) est une stratégie dite de contrôle optimiste: on vérifie, au moment des mises à jour, qu’il y n’a pas eu d’interaction concurrentielle potentiellement dangereuse.

Note

Pour ceux qui ont suivi le cours de base de données dans son intégralité, l’algorithme de concurrence correspondant est dit « contrôle multiversions ».

Les transactions applicatives sont appelées également transactions longues, transactions métier, ou transactions utilisateurs. Elles supposent une mise en place au niveau du code de l’application, ce qui peut être assez lourd. La méthode et ses différentes variantes sont de toute façon bonnes à connaître, donc allons-y.

Les stratégies possibles

Reprenons l’exemple de notre édition du résumé d’un film: l’utilisateur consulte le résumé courant, le complète ou le modifie, et valide. Dans une situation concurrentielle, deux utilisateurs A et B éditent simultanément le film dans sa version f. Le premier valide une nouvelle version f_1 et le second une version f_2. Les trois phases de l’édition sont illustrées par la figure Mécanisme d’une transaction applicative: qui l’emporte?.

_images/longtransac.png

Figure 1: Mécanisme d’une transaction applicative: qui l’emporte?

La question est quelle mise à jour l’emporte? On peut envisager trois possibilités:

  • Le dernier commit l’emporte. Si B valide après A, la mise à jour de A (f_1) est perdue, et ce même si A a reçu un message de confirmation…
  • Le premier commit l’emporte. Si B valide après A, il reçoit une message indiquant que la version f a déjà été modifiée, et qu’il faut resoumettre sa modification sur la base de f_1.

La seconde solution admet plusieurs variantes. On peut par exemple imaginer une tentative automatique de fusion des deux mises à jour. Toujours est-il qu’elle paraît préférable à la première solution qui efface sans prévenir une action validée. Vous aurez peut-être reconnu dans la seconde stratégie le principe de base du contrôle de concurrence multiversions.

Pour l’implanter, on peut s’appuyer sur une gestion automatique de versions incrémentées fournie par Hibernate.

Versionnement avec Hibernate

Hibernate (JPA) permet la maintenance automatique d’un numéro de version associé à chaque ligne. Il faut ajouter une colonne à la table à laquelle s’applique le contrôle multiversions, et la déclarer dans l’entité de la manière suivante:

@Version
public long version;

Ici, la colonne s’appelle version (même nom que la propriété) mais on est libre de lui donner n’importe quel nom. On a choisi d’utiliser un compteur séquentiel, mais Hibernate propose également la gestion d’estampilles temporelles (marquage par le moment de la mise à jour).

L’interprétation de cette annotation est la suivante: Hibernate détecte toute mise à jour d’un objet le rendant « dirty ». Cela inclut la modification d’une propriété ou d’une collection. La requête update engendrée prend alors la forme suivante, illustrée ici sur notre classe Film, et en supposant que la version courante (celle de l’objet dans le cache) est 3.

update Film set [...], version=4 where id=:idFilm and version=3

Hibernate sait (par l’objet présent dans le cache) que la version courante est 3. La requête SQL se base sur l’hypothèse que le cache est bien synchrone avec la base de données, et ajoute donc une clause version=3 pour avoir la garantie que la ligne modifiée est bien celle lue initialement pour instancier l’objet du cache.

Si c’est le cas, cette ligne est trouvée, la mise à jour s’effectue et le numéro de version est incrémenté.

Si ce n’est pas le cas, c’est qu’une autre transaction a effectué une mise à jour concurrente et incrémenté le numéro de version de son côté. L’objet dans le cache n’est pas synchrone avec la base, et la mise à jour doit être rejetée. Hibernate engendre alors une exception de type StaleObjectStateException. On se retrouve dans la stratégie 2 ci-dessus (le premier commit l’emporte), avec nécessité d’informer l’utilisateur qu’un mise à jour concurrente est passée avant la sienne.

Important

Vous aurez noté que le mécanisme n’est sûr que si toutes les mises à jour de la table Film passent par le même code Hibernate… C’est la limite (forte) d’une gestion de la concurrence au niveau de l’application.

Doté de ce mécanisme natif Hibernate, voyons comment l’utiliser dans le cadre de transactions applicatives. Auparavant, vérifions que, si nous faisons rien de plus, c’est la première solution, le dernier commit l’emporte, qui s’applique.

Par défaut, le dernier commit l’emporte!

Nous implantons simplement la fonction de mise à jour d’un film. La première action affiche le formulaire. Voici son code:

if (action.equals("editer")) {
     // Action + vue test de connexion
     Transactions trans = new Transactions();
     Film film = trans.chercheFilmParTitre("Gravity");
     request.setAttribute("film", film);
     maVue = "/vues/transactions/editer.jsp";
    }

Je vous laisse implanter la méthode chercheFilmParTitre() avec la requête HQL appropriée. Voici la page JSP (restreinte à la balise body).

<body>

      <h2>Edition d'un film: le formulaire</h2>

      <p>Vous éditez le film ${film.titre}, dont la version courante est ${film.version}</p>

      <form action="${pageContext.request.contextPath}/transactions" method="get">
      <input type="hidden" name="idFilm" value="${film.id}"/>
      <input type="hidden" name="action" value="modifier"/>
      <textarea name="resume" cols="80" rows="7">${film.resume }</textarea>
      <input type="submit">
      </form>

</body>

Nous sommes dans le contexte d’un contrôleur Transactions. Vous voyez que sur validation de ce formulaire, on appelle une autre action du même contrôleur, modifier. Voici son code:

if (action.equals("modifier")) {
  // Initialisation du modèle
  Transactions trans = new Transactions();

  // Recherche du film
  String idFilm = request.getParameter("idFilm");
  Film film = trans.chercheFilmParId(Integer.parseInt(idFilm));

  // On conserve la version avant modif, pour l'afficher
  request.setAttribute("versionAvantModif", film.getVersion());

  // Modification et validation
  film.setResume (request.getParameter("resume"));
  trans.updateFilm(film);

  // Affichage
  request.setAttribute("film", film);
  maVue = "/vues/transactions/modifier.jsp";
}

Je vous laisse implanter les méthodes chercheFilmParId() et updateFilm(), cette dernière selon le modèle de transaction présenté précédemment. Le code de la JSP est le suivant:

<h2>Mise à jour du film</h2>

<p>Vous avez demandé la mise à jour du film ${film.titre}, dont
la version courante est ${versionAvantModif }
   </p>

<p>
   Le résumé que vous avez saisi est: ${film.resume }
<p>
<p>
La version <i>après modification</i> est ${film.version}
 </p>
<a href="${pageContext.request.contextPath}/transactions?action=editer">Retour au
formulaire d'édition.</a>

Et voilà. Il est très facile de vérifier, en ouvrant deux navigateurs sur la fonction d’édition du film, que le dernier utilisateur qui clique sur Valider l’emporte: la mise à jour du premier est perdue.

Exercice: vérifier que le dernier l’emporte

Implantez les actions précédentes et vérifiez par vous-mêmes que deux utilisateurs qui éditent de manière concurrente un film voient l’un des deux (le dernier qui valide) l’emporter.

Granularité des sessions

Dans l’implantation qui précède, le numéro de version ne nous sert à rien, car nous relisons à nouveau le film dans la méthode modifier(), et il n’y a donc aucune vérification que la version du film que nous modifions est celle que nous avons éditée.

Jusqu’à présent, la portée d’une session coincide avec celle d’une requête HTTP. On ouvre la session quand une requête HTPP arrive, on la ferme avant de transmettre la vue en retour. C’est une méthode dite session per request, et la plus courante.

On peut gérer une transaction applicative en gardant la même granularité (une session pour chaque requête) mais en exploitant la capacité d’un objet persistant à être détaché de la session courante. Dans ce cas on procède comme suit:

  • un objet persistant est placé dans le cache de la première session;
  • quand on ferme la première session, on détache l’objet et on le conserve bien au chaud (par exemple dans l’objet HttpSession) en attendant la requête HTTP suivante;
  • quand cette dernière arrive, on ré-attache l’objet persistant à la nouvelle session.

La méthode est dite session-per-request-with-detached-objects. Elle est illustrée par la figure Mécanisme de la session avec objets détachés.

_images/session-detached.png

Figure 2: Mécanisme de la session avec objets détachés

Enfin il existe une méthode plus radicale, consistant à placer la session toute entière dans le cache applicatif survivant entre deux requêtes HTTP (typiquement l’objet HttpSession). Dans ce cas on ferme la connexion JDBC avec la méthode disconnect() et on en ouvre une nouvelle avec la méthode reconnect() quand la session est ré-activée. La méthode est dite session-per-application-transaction ou tout simplement session longue.

Méthode des objets détachés

Voici quelques détails sur la méthode session-per-request-with-detached-objects. Tout d’abord nous devons placer dans la session HTTP l’objet à gérer par transaction applicative. Le code est très simple:

HttpSession httpSession = request.getSession();
httpSession.setAttribute("object", obj)

La séquence est donc la suivante: (i) on ouvre une session Hibernate, et on cherche les objets que l’on souhaite éditer, (ii) on place ces objets dans la session HTTP pour les préserver sur plusieurs requêtes HTTP, (iii) on ferme la session Hibernate et on affiche la vue. Voici le code complet pour l’édition du film.

// Version avec session applicative
Session hibernateSession = sessionFactory.openSession();

// Recherche du film
Film film = (Film) hibernateSession
                                .createQuery("from Film where titre like :titre")
                                .setString("titre", "Gravity").uniqueResult();
// On le place dans la session HTTP
HttpSession httpSession = request.getSession();
httpSession.setAttribute("filmModif", film);

// On ferme la session Hibernate
hibernateSession.close();

// Et on affiche le film dans le formulaire
request.setAttribute("film", film);
maVue = "/vues/transactions/editer2.jsp";

À l’issue de cette action, l’objet film devient donc détaché. Dans l’action de mise à jour (quand l’utilisateur à soumis le formulaire), il faut rattacher à une nouvelle session Hibernate l’objet présent dans la session HTTP. Ce rattachement s’effectue avec la méthode saveOrUpdate() ou simplement update(): c’est notamment utile si on soupçonne que l’objet a déjà été modifié, et se trouve donc dirty avant même d’être réaffecté à la session.

Voici le code complet montrant la transaction.

Session hibernateSession = sessionFactory.openSession();
Transaction tx = null;
try {
        tx = hibernateSession.beginTransaction();

        // On prend le film dans la session HTTP
        HttpSession httpSession = request.getSession();
        Film film = (Film) httpSession.getAttribute("filmModif");

        // On le réinjecte dans la session Hibernate
        hibernateSession.saveOrUpdate(film);
        tx.commit();

        // Affichage
        request.setAttribute("film", film);
        maVue = "/vues/transactions/modifier2.jsp";
} catch (RuntimeException e) {
        if (tx != null)
                tx.rollback();
                throw e; // or display error message
        } finally {
                hibernateSession.close();
}

La différence essentielle avec la version précédente est donc qu’on ne va pas chercher le film dans la base mais dans la session HTTP, et qu’on obtient donc l’objet avec le numéro de version v que l’on a vraiment édité. Si vous avez mis en place le versionnement Hibernate, la requête SQL comprendra une clause where version=v.

update Film set resume=?, version=? where id=? and version=?

L’absence de cette version indiquerait qu’une mise à jour est intervenue entre temps: . Hibernate lève une exception StaleObjectStateException avec le message suivant:

Row was updated or deleted by another transaction
(or unsaved-value mapping was incorrect) : [modeles.webscope.Film#79]

À vous de faire l’expérience avec le code qui précède: éditez un même film avec deux navigateurs différents, et vérifiez que le premier commit doit gagner, le second étant rejeté avec une exception StaleObjectStateException.

Méthode des sessions longues

Si on veut éviter de détacher/attacher des objets, on peut préserver l’ensemble de l’état d’une session entre deux requêtes HTTP. Attention: l’état d’une session comprend la connexion JDBC qu’il est impératif de relâcher avec disconnect() si on ne veut pas « enterrer » des connexions et se retrouver face au nombre maximal de connexions autorisé.

Quand on reprend une action déclenchée par une nouvelle requête HTTP, il faut récupérer l’objet session Hibernate dans la session HTTP, et appeler reconnect(). On récupère alors l’ensemble des instances persistantes placées dans le cache.

Important

Il faut même penser à fermer la session quand la transaction applicative est terminée, ce qui suppose de savoir identifier une action « finale », ou au contraire de fermer/ouvrir la session pour toute action qui n’est pas intégrée à la séquence des actions d’une transaction applicative.

Exercice: une transaction applicative

Modifiez vos deux actions editer() et modifier() pour être sûr de modifier la version du film que vous avez éditée. Vérifiez qu’Hibernate applique bien le protocole de gestion de concurrence dans ce cas.

Résumé: savoir et retenir

La question des transactions est délicate car elle implique des processus concurrents et une synchronisation temporelle qui sont difficiles à conceptualiser. Voici les informations essentielles que vous devez voir en tête, à défaut de mémoriser le détail des solutions.

  • Hibernate se comporte comme une application standard, la gestion de la concurrence étant réservée au SGBD.
  • Le niveau d’isolation transactionnel par défaut des SGBD est permissif et autorise potentiellement (même si c’est rare) l’apparition d’anomalies dues à des transactions concurrentes. Il faut parfois savoir choisir le niveau d’isolation maximal pour s’en prémunir.
  • Du point de vue de l’utilisateur, certaines transactions couvrent plusieurs sessions Hibernate, et la gestion de concurrence du SGBD devient alors ineffective. Hibernate fournit un support pour implanter un contrôle de concurrence multiversion dans ces cas là.