Transactions distribuées sans JTA : Best Efforts pattern

Comme expliqué précédemment, migrer vers Hibernate 4 nous aura fait prendre conscience de certains problèmes de conception de nos services. Le plus critique étant l’absence de synchronisation entre nos transactions.

Rappels

Il est très fréquent que, lors de l’appel d’un service, on ait besoin de faire appel à une transaction afin d’assurer la cohérence du système d’information.
Prenons un cas simple. Soit une base de données comprenant :

  • une table contenant des utilisateurs ;
  • une table contenant des rôles, chacun étant attribué à un utilisateur via une colonne userId.

On décide de faire un service permettant de supprimer un utilisateur et le rôle qui lui est associé. Le service devra donc :

  • trouver le rôle associé à l’utilisateur ;
  • supprimer le rôle ;
  • supprimer l’utilisateur.

Si tout se passe bien, l’utilisateur et le rôle sont tous les deux supprimés. Si en revanche une erreur se produit, on voudrait qu’aucune des deux suppressions n’ait lieu et que la base reste dans l’état initial. En particulier, si la suppression du rôle a lieu et que la suppression de l’utilisateur échoue, on souhaite annuler la suppression du rôle, sinon l’utilisateur se verra dépourvu de rôle.
Dans ces cas-là, on utilise une transaction. Cette dernière est crée au début du service puis met en attente tous les changements en base de données. Si tout s’est bien passé, la transaction tente d’appliquer les changements à la fin du service : on parle de « commit ». Si une exception est levée, l’exécution du service est interrompue et la transaction annule tous les changements : c’est un « rollback ».

Intégration Spring Hibernate, transaction simple

Spring & Hibernate proposent tous deux des outils pour gérer les transactions. Voici une configuration classique reprenant notre cas précédent.

Tout d’abord, on dispose de deux services, UserService & RoleService situés dans le package com.alinto.services. Le service UserService ressemble à quelque chose comme ça :

package com.alinto.services;

import com.alinto.services.RoleService; 
...

public class UserService
{
    public void deleteUser( User user )
    {
        Role role = this.roleService.getByUserId( user.getId() );
        this.roleService.removeRole( role );
        this.userDao.delete( user );
    }
    ...
}

On ne s’attardera pas sur RoleService dont les méthodes se contentent d’appeler des DAO et sont donc triviales.
Au-delà des services, il faut définir un DataSource, une SessionFactory et un TransactionManager :

<bean id="userDataSource" class="org.apache.commons.dbcp.BasicDataSource">
    ...
</bean>

<bean id="userSessionFactory" class="org.springframework.orm.hibernate4.LocalSessionFactoryBean">
    <property name="dataSource" ref="userDataSource" />
    ...
</bean>

<bean id="userTransactionManager" class="org.springframework.orm.hibernate4.HibernateTransactionManager">
    <property name="sessionFactory" ref="userSessionFactory" />
</bean>

Ensuite, vous devez ordonner au transaction manager d’ouvrir une transaction et de la « commiter » / « rollbacker » à la fin du service suivant que l’exécution se soit déroulée normalement ou pas. Pour ce faire, le plus pratique est de configurer un advice AOP ce qui peut se faire de deux façons :

  • soit on annote le service avec @Transactionnal ;
  • soit on configure l’AOP en XML :
<tx:advice id="userTxAdvice" transaction-manager="userTransactionManager">
    <tx:attributes>
        <tx:method name="*" propagation="REQUIRED" />
    </tx:attributes>
</tx:advice>

<aop:config>
    <aop:pointcut id="userServiceMethods" expression="execution(* com.alinto.services.UserService.*(..))" />
    <aop:advisor advice-ref="userTxAdvice" pointcut-ref="userServiceMethods" />
</aop:config>

 

DataSources séparés

Mettons maintenant que l’on souhaite stocker différemment les utilisateurs & les rôles, par exemple dans deux bases de données différentes. Pour séparer le stockage, il suffit de dupliquer la configuration ci-dessus. On définit donc un second DataSource, une seconde SessionFactory & un second TransactionManager qu’on lie à notre service RoleService. Il faut donc ajouter la configuration suivante  :

<bean id="roleDataSource" class="org.apache.commons.dbcp.BasicDataSource">
    ...
</bean>

<bean id="roleSessionFactory" class="org.springframework.orm.hibernate4.LocalSessionFactoryBean">
    <property name="dataSource" ref="roleDataSource" />
    ...
</bean>

<bean id="roleTransactionManager" class="org.springframework.orm.hibernate4.HibernateTransactionManager">
    <property name="sessionFactory" ref="roleSessionFactory" />
</bean>

<aop:config>
    <aop:pointcut id="roleServiceMethods" expression="execution(* com.alinto.services.RoleService.*(..))" />
    <aop:advisor advice-ref="roleTxAdvice" pointcut-ref="roleServiceMethods" />
</aop:config>

Et voilà ! Enfin presque… Si on teste le service dans un cas normal, tout se passe bien. Mais prenons le cas suivant :

  • on appelle UserService.deleteUser()
  • userTransactionManager débute une transaction qu’on appellera userTransaction ;
  • on va chercher le rôle en base (on ne détaille pas cette étape qui ne nous intéresse pas vraiment)  ;
  • on appelle RoleService.removeRole() ;
  • roleTransactionManager débute une transaction roleTransaction ;
  • removeRole() ordonne la suppression du rôle ;
  • removeRole() se termine et on fait un commit sur roleTransaction : la suppression est donc effective en base ;
  • deleteUser() tente de supprimer l’utilisateur mais une exception est levée parce que deleteUser() a été codé n’importe comment par un stagiaire alcoolique ;
  • on fait un rollback sur userTransaction.

Au final, le rôle a bien été supprimé mais pas l’utilisateur : on a donc un utilisateur sans rôle qui va bientôt se plaindre parce qu’il n’arrive plus à se connecter.
Certes, ici, ce n’est pas très grave : si on a voulu supprimer l’utilisateur, c’est probablement qu’on ne voulait plus jamais en entendre parler de toute façon. Mais si on prend le même exemple dans un contexte de transaction bancaire, on saisit aisément la criticité du problème. Par exemple, si votre compte est débité mais que l’automate ne vous donne pas vos billets…

Transactions distribuées

On parle de transaction distribuée quand une transaction fait intervenir au moins deux ressources dont la cohérence doit être garantie. Typiquement, deux bases de données ou encore une base de données et une queue de messages. Dans ces cas-là, on a besoin de synchroniser les transactions afin que le rollback de l’une entraîne le rollback des autres.
Pour ce faire, la solution classique est d’utiliser le protocole XA qui permet non seulement cette synchronisation mais garantit également la cohérence des données en cas de crash matériel (par exemple si votre serveur de bases de données tombe). Ceci peut être fait relativement simplement via JTA & Spring : il suffit d’utiliser le JtaTransactionManager correspondant à votre serveur d’application. Ce post expliquant la configuration Spring nécessaire afin d’utiliser JTA pourra probablement vous aider si besoin.

Si JTA est le plus sûr moyen de synchroniser les transactions & de garantir la cohérence des données, il comporte des inconvénients :

  • l’impact en termes de performance est notable ;
  • il dépend potentiellement de votre serveur d’application ; cependant, il existe des implémentations de JTA comme Atomikos ou Bitronix utilisables avec des conteneurs légers tels que Jetty ou Tomcat  ;
  • il nécessite d’utiliser des technologies compatibles avec le protocole XA ; c’est notamment un problème si vous utilisez MySQL dont le support des transactions XA est incomplet.

Ce dernier point fut totalement rédhibitoire pour nous. Dès lors, nous avons cherché des solutions alternatives. Et un pattern a répondu à nos attentes : le « Best Efforts 1 Phase Commmit ». Je vous conseille chaudement la lecture de cet article présentant les différents patterns pour gérer les transactions distribuées dont ce dernier.

Le pattern « Best Efforts »

Globalement, l’idée du pattern est de retarder au maximum la phase de commit des transactions afin que seul un problème d’infrastructure puisse mettre en péril l’intégrité de la transaction. Le pattern peut être découpé ainsi :

  • on démarre toutes les transactions nécessaires au service dans un ordre précis ;
  • on exécute le service ;
  • on commit ou on rollback les transactions dans l’ordre inverse de leur démarrage.

Pour notre exemple :

  1. on démarre une userTransaction ;
  2. on démarre une roleTransaction ;
  3. on exécute le service deleteUser() qui appelle lui-même deleteRole() ;
  4. on commit ou on rollback roleTransaction suivant qu’il y ait eu une exception ou pas ;
  5. de même pour userTransaction.

Par définition, le code métier a été exécuté avant les phases 4 et 5. Dans un contexte Hibernate, cela signifie notamment que les requêtes ont été « flushées » et qu’Hibernate a donc déjà pu lever une exception à ce stade en cas de non respect d’une contrainte de la couche modèle (comme une contrainte d’intégrité). Si on reprend notre cas, le service – codé par notre stagiaire rougeot – lèvera une exception lors de la phase 3 et les deux transactions seront correctement « rollbackées ».

Concernant l’ordre, il est conseillé de « commiter » / « rollback » en dernier la transaction du service qui pilote les autres : ici, c’est UserService qui appelle RoleService donc il est préférable de « commiter » userTransaction en dernier. Ceci permet de s’assurer que la totalité du code métier a été exécutée lorsqu’on arrive à l’étape 5.

Malgré tout, il reste un cas où la cohérence n’est pas garantie : si le commit de la dernière transaction (ici userTransaction) échoue alors que tous les précédents ont réussi. Ceci peut notamment arriver en cas d’indisponibilité du serveur de bases de données. Néanmoins, ces cas sont – je l’espère – suffisamment rares pour qu’on fasse l’économie de leur gestion. Au pire, rien n’empêche de les traiter à posteriori s’ils sont proprement inacceptables.

Intégration du pattern Best Efforts dans Spring

Malheureusement, ce pattern n’est pas géré nativement par Spring à l’heure où j’écris cet article. Si vous trouvez vous aussi que c’est regrettable, vous pouvez toujours voter pour le ticket associé. En attendant, voici notre solution.

Pour la gestion des ouvertures et commit/rollback des transactions, nous avons choisi de réutiliser une classe fournie par le projet Spring Neo4J, Neo4J étant un SGBD orienté graphe et appartenant à la mouvance NoSQL. Certes, ça n’a rien à voir avec notre problème mais il se trouve que org.springframework.data.neo4j.transaction.ChainedTransactionManager répond exactement à nos attentes. Cette classe se charge simplement, à partir d’une liste de PlatformTransactionManager (interface implémentée par HibernateTransactionManager), d’ouvrir les transactions dans l’ordre de la liste et de les « commiter » / « rollback » dans le sens inverse. Bien entendu, si un commit échoue, les transactions suivantes sont rollbackées.
Plus précisément, vous trouverez cette classe dans le .jar de spring-data-neo4j-tx. On pourrait implémenter nous-même cette fonctionnalité mais, à mon humble avis, il est plus sûr et plus évolutif d’utiliser une classe maintenue par un projet Spring.

Ce ChainedTransactionManager présente toutefois comme inconvénient de devoir connaître la liste des TransactionManagers dès sa construction. L’approche choisie a donc été de construire ce manager à la volée, avant l’exécution d’un service où l’on doit synchroniser des transactions. La liste des TransactionManagers doit être spécifiée de manière déclarative ce qui est fait grâce à une annotation dédiée nommée @ChainedTransaction.

L’astuce consiste à surcharger le fonctionnement du org.springframework.transaction.interceptor.TransactionInterceptor, à savoir la classe qui se charge d’ouvrir et de « commiter » / « rollback » les transactions autour d’un service lié à un advice AOP de transaction (que ce soit fait par annotation ou par XML). Le principe est simple : si on détecte notre annotation dédiée, on va parser la liste des TransactionManagers, construire un ChainedTransactionManager avec cette liste et lier ce TransactionManager au service courant. Sinon, on laisse le TransactionInterceptor se débrouiller comme d’habitude.
L’intégration est ainsi simplifiée : on ne modifie que le comportement des services annotés, sans même avoir à réorganiser la configuration des AOP de transactions. Si un service est noté @Transactionnal("monTxManager") mais qu’il possède l’annotation @ChainedTransaction, c’est le mécanisme de transactions chaînées qui prend le pas.

Trève de bla-bla, voici le code utile. Tout d’abord, on surcharge le TransactionInterceptor en implémentant l’interface org.springframework.beans.factory.config.BeanFactoryPostProcessor :

public class TransactionInterceptorModifier implements BeanFactoryPostProcessor
{
	@Override
	public void postProcessBeanFactory( ConfigurableListableBeanFactory beanFactory ) throws BeansException
	{
		String[] beanNames = beanFactory.getBeanNamesForType( TransactionInterceptor.class );
		for ( String beanName : beanNames )
		{
			BeanDefinition def = beanFactory.getBeanDefinition( beanName );
			def.setBeanClassName( "com.alinto.transactions.ChainedTransactionInterceptor" ); // pour pouvoir surcharger l'Interceptor
		}
	}
}

Il faut alors ajouter la ligne suivante à votre chargement de contexte Spring :

<bean class="com.alinto.transactions.TransactionInterceptorModifier" />

Voici maintenant notre Interceptor maison :

public class ChainedTransactionInterceptor extends TransactionInterceptor implements ApplicationContextAware
{	
	private ApplicationContext		applicationContext;
	// Pour stocker la liste des TransactionManagers
	private ThreadLocal<string[]>	chainedTransactionManagersNames; 

	@Override
	// Appelé juste avant le service
	public Object invoke( MethodInvocation invocation ) throws Throwable
	{
		if ( ( invocation != null ) && ( invocation.getMethod() != null ) )
		{
			ChainedTransaction chainedTransactionAnnotation = invocation.getMethod().getAnnotation( ChainedTransaction.class );
			if ( chainedTransactionAnnotation != null ) // Le service est annoté, on récupère la liste des TransactionManagers
			{
				if ( this.chainedTransactionManagersNames == null )
				{
					this.chainedTransactionManagersNames = new ThreadLocal<string[]>();
				}
				this.chainedTransactionManagersNames.set( chainedTransactionAnnotation.value() );
			}
		}
		// Cas classique
		return super.invoke( invocation );
	}

	@Override
	// Peu après l'invoke(), cette fonction est appelée pour déterminer le TransactionManager à utiliser.
	// Si le service est annoté, ce sera le ChainedTransactionManager
	protected PlatformTransactionManager determineTransactionManager( TransactionAttribute txAttr )
	{
		if ( this.chainedTransactionManagersNames != null ) // Le service est annoté
		{
			String[] chainedTxManagersNames = this.chainedTransactionManagersNames.get();
			this.chainedTransactionManagersNames.remove();
			if ( chainedTxManagersNames != null )
			{
				ChainedTransactionManager chainedTransactionManager = this.createTxManager( chainedTxManagersNames );
				return chainedTransactionManager;
			}
		}
		// Cas classique
		return super.determineTransactionManager( txAttr );
	}

	// Petite fonction privée pour créer le ChainedTransactionManager
	private ChainedTransactionManager createTxManager( String[] chainedTransactionManagersNames )
	{
		int managersCount = chainedTransactionManagersNames.length;
		Set transactionManagers = new HashSet( managersCount ); // pour prévenir les doublons
		for ( int i = 0 ; i < managersCount ; i++ )
		{
			// On récupère le TransactionManager dans le contexte Spring
			PlatformTransactionManager transactionManager = (PlatformTransactionManager)this.applicationContext.getBean( chainedTransactionManagersNames[i] ); 
			transactionManagers.add( transactionManager );
		}
		// On construit le ChainedTransactionManager à partir de la liste des TransactionManagers
		return new ChainedTransactionManager( transactionManagers.toArray( new PlatformTransactionManager[0] ) );
	}

	@Override
	public void setApplicationContext( ApplicationContext applicationContext ) throws BeansException
	{
		this.applicationContext = applicationContext;
	}
}

Vous noterez au passage l’utilisation de ThreadLocal afin de ne pas se mélanger les pinceaux dans un contexte multi-tâches.

Il ne reste alors plus qu’à annoter notre service :

// la liste des TransactionManagers pour ce service avec 
@ChainedTransaction({ "userTransactionManager", "roleTransactionManager" }) 
public void deleteUser( User user )
{
    ...
}

L’annotation étant définie comme suit :

@Target(ElementType.METHOD)
@Retention(value = RetentionPolicy.RUNTIME)
@Transactional
public @interface ChainedTransaction
{
	String[] value();
}

Et hop ! Le tour est joué. Bien entendu, cette version est probablement perfectible. Si vous avez des remarques ou votre propre version, n’hésitez pas à les partager !

Pour finir, notons que le nec plus ultra serait de pouvoir éviter une approche déclarative lors de la spécification de la liste des TransactionManagers. Pour ce faire, il faudrait parvenir à détecter quels services sont appelés dans un service, détecter s’il y a des TransactionManagers différents et le cas échéant construire le ChainedTransactionManager. On pourrait peut-être également se débrouiller pour que ce soient les TransactionManagers qui se déclarent eux-même avant le service… Tout ceci me paraît assez complexe mais j’imagine que tout est possible ?

Une réflexion sur “ Transactions distribuées sans JTA : Best Efforts pattern ”

  1. Ping : Spring : Constructor injection et Setter injection - Post-IT

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *