.. _chap-transactions: ######################### 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 : * `Diapos pour la session "S1 : Rappels sur la concurrence d'accès" `_ * Vidéo associée : https://mediaserver.cnam.fr/permalink/v125f35951341n951v7h/ .. * Vidéo associée : https://avc.cnam.fr/univ-r_av/avc/courseaccess?id=2049 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. .. _ex-basetrans: .. admonition:: 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 : * `Diapos pour la session "S2 : Gestion de la concurrence avec Hibernate" `_ * Vidéo associée : https://mediaserver.cnam.fr/permalink/v125f359513b2m7pz6xh/ .. * Vidéo associée : https://avc.cnam.fr/univ-r_av/avc/courseaccess?id=2050 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 = où ``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...). .. code-block:: java 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``. .. code-block:: sql 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. .. code-block:: java 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. .. _ex-reservation: .. admonition:: 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 : * `Diapos pour la session "S3 : Transactions applicatives" `_ * Vidéo associée : à venir 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 :math:`f`. Le premier valide une nouvelle version :math:`f_1` et le second une version :math:`f_2`. Les trois phases de l'édition sont illustrées par la figure :ref:`longtransac`. .. _longtransac: .. figure:: ../figures/longtransac.png :width: 60% :align: center 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* (:math:`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 :math:`f` a déjà été modifiée, et qu'il faut resoumettre sa modification sur la base de :math:`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: .. code-block:: java @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. .. code-block:: sql 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: .. code-block:: java 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``). .. code-block:: jsp

Edition d'un film: le formulaire

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

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: .. code-block:: java 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: .. code-block:: jsp

Mise à jour du film

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

Le résumé que vous avez saisi est: ${film.resume }

La version après modification est ${film.version}

Retour au formulaire d'édition. *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. .. _ex-dernieremporte: .. admonition:: 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 :ref:`session-detached`. .. _session-detached: .. figure:: ../figures/session-detached.png :width: 80% :align: center 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: .. code-block:: java 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. .. code-block:: java // 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. .. code-block:: java 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``. .. code-block:: sql 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. .. _ex-businesstrans: .. admonition:: 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à.