Gestion sécurisée de l'accès concurrentiel à une base de données SQLite sur Android

Les applications Android interagissent fréquemment avec les bases de données SQLite. Une problématique courante, surtout dans les applications multithreadées, est la gestion de l'accès concurrentiel à la base de données. Tenter d'accéder ou de modifier la base de données simultanément depuis plusieurs threads peut entraîner des erreurs inattendues, la plus fréquente étant le verrouillage de la base de données.

Le problème de l'accès concurrentiel direct

Considérons une approche simple où chaque thread qui souahite interagir avec la base de données crée sa propre instance de SQLiteOpenHelper, puis récupère une base de données modifiable (writable database), effectue une opération et ferme la connexion. Voici un exemple illustratif :

// Classe d'aide SQLite minimale pour l'exemple
class AideurBaseDonnees extends SQLiteOpenHelper {
    private static final String NOM_BASE = "mon_application.db";
    private static final int VERSION_BASE = 1;
    private static final String TABLE_DATA = "donnees_app";
    private static final String CREER_TABLE_SQL =
            "CREATE TABLE " + TABLE_DATA + " (_id INTEGER PRIMARY KEY AUTOINCREMENT, valeur TEXT)";

    public AideurBaseDonnees(Context contexte) {
        super(contexte, NOM_BASE, null, VERSION_BASE);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(CREER_TABLE_SQL);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        db.execSQL("DROP TABLE IF EXISTS " + TABLE_DATA);
        onCreate(db);
    }
}

// ... dans le code d'une activité ou d'un service Android
Context contexteApplication = getApplicationContext();

// Thread d'arrière-plan 1
new Thread(() -> {
    try {
        AideurBaseDonnees premierAideur = new AideurBaseDonnees(contexteApplication);
        SQLiteDatabase bd1 = premierAideur.getWritableDatabase();
        ContentValues valeurs1 = new ContentValues();
        valeurs1.put("valeur", "Donnée du Thread 1");
        bd1.insert(AideurBaseDonnees.TABLE_DATA, null, valeurs1);
        bd1.close(); // Fermeture immédiate de la connexion
        Log.d("SQLiteConcurrency", "Thread 1: Insertion réussie et BD fermée.");
    } catch (Exception e) {
        Log.e("SQLiteConcurrency", "Thread 1: Erreur d'accès à la BD", e);
    }
}).start();

// Thread d'arrière-plan 2, tentant un accès quasi simultané
new Thread(() -> {
    try {
        AideurBaseDonnees secondAideur = new AideurBaseDonnees(contexteApplication);
        SQLiteDatabase bd2 = secondAideur.getWritableDatabase();
        ContentValues valeurs2 = new ContentValues();
        valeurs2.put("valeur", "Donnée du Thread 2");
        bd2.insert(AideurBaseDonnees.TABLE_DATA, null, valeurs2);
        bd2.close(); // Fermeture immédiate de la connexion
        Log.d("SQLiteConcurrency", "Thread 2: Insertion réussie et BD fermée.");
    } catch (Exception e) {
        Log.e("SQLiteConcurrency", "Thread 2: Erreur d'accès à la BD", e);
    }
}).start();

L'exécution de ce code conduit souvent à une erreur telle que :

android.database.sqlite.SQLiteDatabaseLockedException: database is locked (code 5)

Cette erreur survient parce que chaque instance de SQLiteOpenHelper tente d'établir sa propre connexion physique à la base de données. SQLite, par défaut, ne permet qu'une seule connexion "écrivable" à la fois. Tenter d'ouvrir plusieurs connexions en écriture simultanément conduit à un verrouillage.

Première amélioration : L'instance unique de connexion

Pour résoudre le problème de multiples connexions, nous devons nous assurer que tous les accès à la base de données partagent la même connexion sous-jacente. Le pattern Singleton est idéal pour cela. Nous allons créer un gestoinnaire qui encapsule notre SQLiteOpenHelper et fournit une instance unique de SQLiteDatabase.

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;

public class GestionnaireConnexionBD {

    private static GestionnaireConnexionBD instanceUnique;
    private static SQLiteOpenHelper aideurBDPrincipal; // L'aideur de base de données partagé

    // Le constructeur est privé pour empêcher l'instanciation externe
    private GestionnaireConnexionBD() {}

    /**
     * Initialise l'instance unique du gestionnaire de connexion à la base de données.
     * Cette méthode doit être appelée une seule fois, typiquement au démarrage de l'application.
     *
     * @param aideur L'implémentation de SQLiteOpenHelper pour votre base de données.
     */
    public static synchronized void initialiser(SQLiteOpenHelper aideur) {
        if (instanceUnique == null) {
            instanceUnique = new GestionnaireConnexionBD();
            aideurBDPrincipal = aideur;
        }
    }

    /**
     * Retourne l'instance unique du gestionnaire de connexion.
     *
     * @return L'instance de GestionnaireConnexionBD.
     * @throws IllegalStateException si le gestionnaire n'a pas été initialisé.
     */
    public static synchronized GestionnaireConnexionBD obtenirInstance() {
        if (instanceUnique == null) {
            throw new IllegalStateException(GestionnaireConnexionBD.class.getSimpleName() +
                    " n'a pas été initialisé. Appelez initialiser(..) d'abord.");
        }
        return instanceUnique;
    }

    /**
     * Récupère une instance de SQLiteDatabase qui peut être utilisée pour l'écriture.
     *
     * @return L'instance de SQLiteDatabase.
     */
    public synchronized SQLiteDatabase recupererBaseModifiable() {
        // Cette approche ne gère pas la fermeture correcte, voir la prochaine section.
        return aideurBDPrincipal.getWritableDatabase();
    }
}

Avec ce gestionnaire, nos threads accèdent désormais à la même instance de base de données :

// Dans la classe Application ou au début de l'exécution
// GestionnaireConnexionBD.initialiser(new AideurBaseDonnees(contexteApplication));

// Thread d'arrière-plan 1
new Thread(() -> {
    GestionnaireConnexionBD gestionnaire = GestionnaireConnexionBD.obtenirInstance();
    SQLiteDatabase bd = gestionnaire.recupererBaseModifiable();
    try {
        ContentValues valeurs = new ContentValues();
        valeurs.put("valeur", "Singleton Thread 1");
        bd.insert(AideurBaseDonnees.TABLE_DATA, null, valeurs);
        Log.d("SQLiteConcurrency", "Thread 1: Insertion via Singleton.");
    } catch (Exception e) {
        Log.e("SQLiteConcurrency", "Thread 1: Erreur d'accès à la BD via Singleton", e);
    } finally {
        bd.close(); // Fermeture de la BD
    }
}).start();

// Thread d'arrière-plan 2
new Thread(() -> {
    GestionnaireConnexionBD gestionnaire = GestionnaireConnexionBD.obtenirInstance();
    SQLiteDatabase bd = gestionnaire.recupererBaseModifiable();
    try {
        ContentValues valeurs = new ContentValues();
        valeurs.put("valeur", "Singleton Thread 2");
        bd.insert(AideurBaseDonnees.TABLE_DATA, null, valeurs);
        Log.d("SQLiteConcurrency", "Thread 2: Insertion via Singleton.");
    } catch (Exception e) {
        Log.e("SQLiteConcurrency", "Thread 2: Erreur d'accès à la BD via Singleton", e);
    } finally {
        bd.close(); // Tentative de fermer la BD, qui pourrait déjà être fermée
    }
}).start();

Bien que cette solution prévienne l'erreur de verrouillage, elle introduit un nouveau problème : java.lang.IllegalStateException: attempt to re-open an already-closed object: SQLiteDatabase. Si le "Thread 1" termine son opération et appelle bd.close(), il ferme la connexion partagée. Lorsque le "Thread 2" tente ensuite d'utiliser cette même connexion (qui est désormais fermée), l'exception est levée.

La solution robuste : Gession du cycle de vie avec compteur de références

Pour résoudre le problème de fermeture prématurée, nous devons nous assurer que la base de données n'est fermée que lorsque aucun thread ne l'utilise activement. Cela peut être réalisé en utilisant un compteur de références. Chaque fois qu'un thread ouvre la base de données, il incrémente le compteur ; quand il a fini, il le décrémente. La base de données n'est réellement fermée que lorsque le compteur atteint zéro.

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import java.util.concurrent.atomic.AtomicInteger;

public class GestionnaireBaseDeDonnees {

    private final AtomicInteger compteurUtilisateurs = new AtomicInteger(); // Compteur d'accès
    private static GestionnaireBaseDeDonnees instanceUnique;
    private static SQLiteOpenHelper aideurPrincipale;
    private SQLiteDatabase baseDeDonneesActive; // La référence à la BD ouverte et partagée

    private GestionnaireBaseDeDonnees() {
        // Constructeur privé pour le pattern Singleton
    }

    /**
     * Initialise l'instance unique du gestionnaire de base de données.
     * Cette méthode doit être appelée une seule fois, par exemple dans Application.onCreate().
     *
     * @param helper Votre implémentation de SQLiteOpenHelper.
     */
    public static synchronized void initialiser(SQLiteOpenHelper helper) {
        if (instanceUnique == null) {
            instanceUnique = new GestionnaireBaseDeDonnees();
            aideurPrincipale = helper;
        }
    }

    /**
     * Retourne l'instance unique du gestionnaire.
     *
     * @return L'instance de GestionnaireBaseDeDonnees.
     * @throws IllegalStateException Si le gestionnaire n'a pas été initialisé.
     */
    public static synchronized GestionnaireBaseDeDonnees obtenirInstance() {
        if (instanceUnique == null) {
            throw new IllegalStateException(GestionnaireBaseDeDonnees.class.getSimpleName() +
                    " doit être initialisé via la méthode initialiser(..) avant utilisation.");
        }
        return instanceUnique;
    }

    /**
     * Ouvre la base de données pour une utilisation. Incrémente le compteur d'utilisateurs.
     * La base de données est réellement ouverte la première fois que cette méthode est appelée.
     *
     * @return L'instance de SQLiteDatabase prête à l'emploi.
     */
    public synchronized SQLiteDatabase ouvrirConnexion() {
        if (compteurUtilisateurs.incrementAndGet() == 1) {
            // C'est la première demande d'accès, ouvrir la base
            baseDeDonneesActive = aideurPrincipale.getWritableDatabase();
        }
        return baseDeDonneesActive;
    }

    /**
     * Ferme la connexion à la base de données. Décrémente le compteur d'utilisateurs.
     * La base de données est réellement fermée lorsque le compteur atteint zéro.
     */
    public synchronized void fermerConnexion() {
        if (compteurUtilisateurs.decrementAndGet() == 0) {
            // C'est le dernier utilisateur, fermer la base de données
            if (baseDeDonneesActive != null && baseDeDonneesActive.isOpen()) {
                baseDeDonneesActive.close();
            }
            baseDeDonneesActive = null; // Libérer la référence
        }
    }
}

L'utilisation de ce gestionnaire final est la suivante :

// Dans la classe Application ou un point d'entrée
// GestionnaireBaseDeDonnees.initialiser(new AideurBaseDonnees(contexteApplication));

// ... dans le code d'une activité ou d'un service ...

// Tâche asynchrone pour l'écriture dans la base de données
new Thread(() -> {
    GestionnaireBaseDeDonnees gestionnaire = GestionnaireBaseDeDonnees.obtenirInstance();
    SQLiteDatabase bd = null;
    try {
        bd = gestionnaire.ouvrirConnexion(); // Obtient la BD, incrémente le compteur
        // Effectuer les opérations de base de données
        ContentValues valeurs = new ContentValues();
        valeurs.put("valeur", "Donnée sécurisée par compteur");
        bd.insert(AideurBaseDonnees.TABLE_DATA, null, valeurs);
        Log.d("SQLiteConcurrency", "Thread BD: Opération réussie.");
    } catch (Exception e) {
        Log.e("SQLiteConcurrency", "Thread BD: Erreur d'accès à la BD", e);
    } finally {
        if (bd != null) {
            gestionnaire.fermerConnexion(); // Décrémente le compteur, ferme si zéro
        }
    }
}).start();

Avec cette approche, chaque appel à ouvrirConnexion() incrémente un compteur. La connexion à la base de données n'est établie qu'une seule fois, lors du premier appel (quand le compteur passe de 0 à 1). De même, chaque appel à fermerConnexion() décrémente le compteur. La base de données n'est physiquement fermée que lorsque le compteur atteint zéro, garantissant qu'elle reste ouverte tant qu'il y a des utilisateurs actifs. Cette stratégie assure un accès concurrentiel sûr et une gestion correcte du cycle de vie de la connexion SQLite.

Étiquettes: sqlite Android Concurrency multithreading Singleton

Publié le 20 juillet à 19h48