Utilisation des collections fiables
Service Fabric propose un modèle de programmation avec état disponible pour les développeurs .NET via les collections fiables. Plus précisément, Service Fabric fournit un dictionnaire fiable et des classes de file d’attente fiables. Lorsque vous utilisez ces classes, votre état est partitionné (pour l’évolutivité) répliqué (pour la disponibilité) et traité dans une partition (pour la sémantique ACID). Examinons l'utilisation type d'un objet de dictionnaire fiable afin de découvrir ses fonctionnalités réelles.
try
{
// Create a new Transaction object for this partition
using (ITransaction tx = base.StateManager.CreateTransaction())
{
// AddAsync takes key's write lock; if >4 secs, TimeoutException
// Key & value put in temp dictionary (read your own writes),
// serialized, redo/undo record is logged & sent to secondary replicas
await m_dic.AddAsync(tx, key, value, cancellationToken);
// CommitAsync sends Commit record to log & secondary replicas
// After quorum responds, all locks released
await tx.CommitAsync();
}
// If CommitAsync isn't called, Dispose sends Abort
// record to log & all locks released
}
catch (TimeoutException)
{
// choose how to handle the situation where you couldn't get a lock on the file because it was
// already in use. You might delay and retry the operation
await Task.Delay(100);
}
Toutes les opérations relatives aux objets de dictionnaires fiables (à l'exception de ClearAsync qui n'est pas annulable) nécessitent un objet ITransaction. Cet objet est associé à toutes les modifications que vous tentez d'apporter à un dictionnaire fiable et/ou à des objets de file d'attente fiable au sein d'une même partition. Vous obtenez un objet ITransaction en appelant la méthode CreateTransaction du StateManager de la partition.
Dans le code ci-dessus, l'objet ITransaction est transféré vers la méthode AddAsync d'un dictionnaire fiable. En interne, les méthodes de dictionnaire qui acceptent une clé prennent un verrou de lecture/écriture associé à la clé. Si la méthode modifie la valeur de la clé, elle accepte un verrou d'écriture sur la clé, et si elle ne lit qu'à partir de la valeur de la clé, un verrou de lecture est appliqué sur la clé. Puisque AddAsync modifie la valeur de la clé en la remplaçant par la nouvelle valeur transmise, le verrou d'écriture de la clé est appliqué. Par conséquent, si 2 threads (ou plus) tentent d'ajouter des valeurs à la même clé au même moment, un thread acquerra le verrou d'écriture et les autres threads se bloqueront. Par défaut, les méthodes se bloquent pendant 4 secondes maximum pour acquérir le verrou. Après 4 secondes, les méthodes lèvent une exception TimeoutException. Il existe des surcharges de méthode qui vous permettent de transmettre une valeur de délai d'attente explicite si vous le souhaitez.
En règle générale, vous écrivez votre code de manière à réagir à une exception TimeoutException en l’interceptant et en recommençant toute l’opération (comme indiqué dans le code ci-dessus). Dans ce code simple, nous appellons simplement Task.Delay en transmettant 100 millisecondes à chaque fois. Mais, en réalité, vous pouvez juger préférable d’utiliser un type de délai de temporisation exponentiel.
Une fois le verrou acquis, AddAsync ajoute les références d’objet de clé et de valeur à un dictionnaire temporaire interne associé à l’objet ITransaction. De cette manière, vous obtenez une sémantique de type « lecture de vos propres écritures ». Autrement dit, après avoir appelé AddAsync, un appel ultérieur à TryGetValueAsync à l’aide du même objet ITransaction renverra la valeur même si vous n’avez pas encore validé la transaction.
Notes
L’appel de TryGetValueAsync avec une nouvelle transaction retourne une référence à la dernière valeur validée. Ne modifiez pas directement cette référence, car cela a pour effet de contourner le mécanisme de persistance et de réplication des modifications. Nous vous recommandons de faire en sorte que les valeurs soient en lecture seule, de sorte que la seule façon de modifier la valeur d’une clé soit d’utiliser des API de dictionnaire fiables.
Ensuite, AddAsync sérialise vos objets de clé et de valeur dans des tableaux d’octets et ajoute ces tableaux d’octets à un fichier journal situé sur le nœud local. Pour finir, AddAsync envoie les tableaux d’octets à tous les réplicas secondaires de manière à leur fournir les mêmes informations de clé/valeur. Même si les informations de clé/valeur ont été écrites dans un fichier journal, les informations ne sont pas considérées comme intégrées au dictionnaire tant que la transaction qui leur est associée n’a pas été validée.
Dans le code ci-dessus, l'appel à CommitAsync permet de valider toutes les opérations de la transaction. Plus précisément, il ajoute des informations de validation au fichier journal situé sur le nœud local et envoie également l’enregistrement de validation à tous les réplicas secondaires. Dès lors qu’un quorum (une majorité) de réplicas a répondu, toutes les modifications apportées aux données sont considérées comme permanentes et tous les verrous associés aux clés qui ont été manipulées à l’aide de l’objet ITransaction sont libérés afin que d’autres threads/transactions puissent manipuler les mêmes clés et les valeurs qui leur sont associées.
Si CommitAsync n’est pas appelée (généralement en raison de la levée d’une exception), l’objet ITransaction est détruit. Lors de la suppression d'un objet ITransaction non validé, Service Fabric ajoute les informations d'abandon au fichier journal situé sur le nœud local et rien n'est envoyé aux réplicas secondaires. Dès lors, tous les verrous associés aux clés qui ont été manipulées à l’aide de la transaction sont libérés.
Collections fiables volatiles
Dans certaines charges de travail, comme un cache répliqué, une perte de données occasionnelle peut être tolérée. En évitant la persistance des données sur le disque, vous pouvez améliorer les latences et les débits lors de l'écriture dans les dictionnaires fiables. Mais l'absence de persistance a un inconvénient : en cas de perte du quorum, toutes les données sont également perdues. Cela dit, l'amélioration des performances est telle et les pertes de quorum tellement rares que le risque en vaut souvent la chandelle pour ces charges de travail.
Actuellement, la prise en charge volatile est uniquement disponible pour les dictionnaires fiables et les files d'attente fiables, et non pour ReliableConcurrentQueues. Pour déterminer si vous pouvez utiliser des collections volatiles, consultez la liste des Mises en garde.
Pour activer la prise en charge volatile dans votre service, définissez l'indicateur HasPersistedState
de la déclaration de type de service sur false
, comme suit :
<StatefulServiceType ServiceTypeName="MyServiceType" HasPersistedState="false" />
Notes
Les services persistants existants ne peuvent pas devenir volatils, et vice versa. Pour bénéficier d'un service volatil, vous devez supprimer le service existant, puis déployer le service avec l'indicateur mis à jour. Autrement dit, si vous souhaitez modifier l'indicateur HasPersistedState
, vous devez être prêt à subir une perte totale de données.
Pièges courants et comment les éviter
Maintenant que vous avez compris le fonctionnement interne des collections fiables, examinons quelques cas d'erreurs d'utilisation courants. Observez le code ci-dessous :
using (ITransaction tx = StateManager.CreateTransaction())
{
// AddAsync serializes the name/user, logs the bytes,
// & sends the bytes to the secondary replicas.
await m_dic.AddAsync(tx, name, user);
// The line below updates the property's value in memory only; the
// new value is NOT serialized, logged, & sent to secondary replicas.
user.LastLogin = DateTime.UtcNow; // Corruption!
await tx.CommitAsync();
}
Lorsque vous travaillez avec un dictionnaire .NET standard, vous pouvez ajouter une clé/valeur au dictionnaire puis modifier la valeur d’une propriété (par exemple, LastLogin). Toutefois, ce code ne fonctionnera pas correctement avec un dictionnaire fiable. N’oubliez pas que, dans la discussion précédente, l’appel à AddAsync sérialise les objets de clé/valeur dans des tableaux d’octets, puis enregistre ces tableaux dans un fichier local et les envoie également aux réplicas secondaires. Si vous modifiez ultérieurement une propriété, seule la valeur stockée en mémoire est modifiée. La modification n'a aucun impact sur le fichier local ou sur les données envoyées aux réplicas. Si le processus se bloque, le contenu de la mémoire est immédiatement nettoyé. Au démarrage d’un nouveau processus ou si un autre réplica prend le rôle de réplica principal, vous obtiendrez l’ancienne valeur de propriété.
Il est extrêmement facile de commettre le type d’erreur décrit ci-dessus. De plus, vous ne vous rendrez compte que vous avez commis une erreur que si/lorsque le processus se bloque. Pour écrire correctement le code, il suffit d’inverser les deux lignes :
using (ITransaction tx = StateManager.CreateTransaction())
{
user.LastLogin = DateTime.UtcNow; // Do this BEFORE calling AddAsync
await m_dic.AddAsync(tx, name, user);
await tx.CommitAsync();
}
Voici un autre exemple d’erreur courante :
using (ITransaction tx = StateManager.CreateTransaction())
{
// Use the user's name to look up their data
ConditionalValue<User> user = await m_dic.TryGetValueAsync(tx, name);
// The user exists in the dictionary, update one of their properties.
if (user.HasValue)
{
// The line below updates the property's value in memory only; the
// new value is NOT serialized, logged, & sent to secondary replicas.
user.Value.LastLogin = DateTime.UtcNow; // Corruption!
await tx.CommitAsync();
}
}
Là encore, avec les dictionnaires .NET standard, le code ci-dessus fonctionne correctement et représente un modèle courant : le développeur utilise une clé pour rechercher une valeur. Si la valeur existe, le développeur modifie la valeur d'une propriété. Toutefois, avec les collections fiables, ce code présente le même problème que celui indiqué précédemment : vous ne devez pas modifier un objet une fois que vous l’avez attribué à une collection fiable.
Pour mettre à jour une valeur dans une collection fiable, le mieux est d’obtenir une référence à la valeur existante et de considérer comme immuable l’objet référencé par cette référence. Créez ensuite un nouvel objet qui sera la copie exacte de l'objet d'origine. À présent, vous pouvez modifier l’état de ce nouvel objet et écrire le nouvel objet dans la collection afin qu’il soit sérialisé dans des tableaux d’octets, ajouté au fichier local et envoyé aux réplicas. Après avoir validé la ou les modifications, les objets en mémoire, le fichier local et tous les réplicas présenteront exactement le même état. Tout est parfait !
Le code ci-dessous montre comment mettre à jour une valeur dans une collection fiable :
using (ITransaction tx = StateManager.CreateTransaction())
{
// Use the user's name to look up their data
ConditionalValue<User> currentUser = await m_dic.TryGetValueAsync(tx, name);
// The user exists in the dictionary, update one of their properties.
if (currentUser.HasValue)
{
// Create new user object with the same state as the current user object.
// NOTE: This must be a deep copy; not a shallow copy. Specifically, only
// immutable state can be shared by currentUser & updatedUser object graphs.
User updatedUser = new User(currentUser);
// In the new object, modify any properties you desire
updatedUser.LastLogin = DateTime.UtcNow;
// Update the key's value to the updateUser info
await m_dic.SetValue(tx, name, updatedUser);
await tx.CommitAsync();
}
}
Définir des types de données immuables pour éviter les erreurs de programmation
Dans l'idéal, nous aimerions que le compilateur signale les erreurs lorsque vous produisez accidentellement du code qui modifie l'état d'un objet que vous êtes censé considérer comme immuable. Mais le compilateur C# ne permet pas de le faire. Par conséquent, pour éviter les éventuels bogues de programmation, il est vivement recommandé de définir les types vous utilisez avec les collections fiables comme des types immuables. Plus précisément, cela signifie que vous allez vous en tenir aux types de valeur de base (par exemple, des nombres [Int32, UInt64, etc.], DateTime, Guid, TimeSpan, etc.). Vous pouvez aussi utiliser String. Il est préférable d'éviter les propriétés de collection car la sérialisation et la désérialisation de ces propriétés nuisent souvent aux performances. Toutefois, si vous souhaitez utiliser des propriétés de collection, nous vous recommandons d'utiliser la bibliothèque de collections immuables de .NET (System.Collections.Immutable). Cette bibliothèque est disponible au téléchargement sur https://nuget.org. Nous vous recommandons également de sceller vos classes et de définir les champs en lecture seule chaque fois que cela est possible.
Le type UserInfo ci-dessous montre comment définir un type immuable, en tirant parti des recommandations mentionnées précédemment.
[DataContract]
// If you don't seal, you must ensure that any derived classes are also immutable
public sealed class UserInfo
{
private static readonly IEnumerable<ItemId> NoBids = ImmutableList<ItemId>.Empty;
public UserInfo(String email, IEnumerable<ItemId> itemsBidding = null)
{
Email = email;
ItemsBidding = (itemsBidding == null) ? NoBids : itemsBidding.ToImmutableList();
}
[OnDeserialized]
private void OnDeserialized(StreamingContext context)
{
// Convert the deserialized collection to an immutable collection
ItemsBidding = ItemsBidding.ToImmutableList();
}
[DataMember]
public readonly String Email;
// Ideally, this would be a readonly field but it can't be because OnDeserialized
// has to set it. So instead, the getter is public and the setter is private.
[DataMember]
public IEnumerable<ItemId> ItemsBidding { get; private set; }
// Since each UserInfo object is immutable, we add a new ItemId to the ItemsBidding
// collection by creating a new immutable UserInfo object with the added ItemId.
public UserInfo AddItemBidding(ItemId itemId)
{
return new UserInfo(Email, ((ImmutableList<ItemId>)ItemsBidding).Add(itemId));
}
}
Le type ItemId est également un type immuable comme indiqué ici :
[DataContract]
public struct ItemId
{
[DataMember] public readonly String Seller;
[DataMember] public readonly String ItemName;
public ItemId(String seller, String itemName)
{
Seller = seller;
ItemName = itemName;
}
}
Contrôle de version du schéma (mises à niveau)
En interne, les collections fiables sérialisent vos objets à l'aide du DataContractSerializer de .NET. Les objets sérialisés sont conservés sur le disque local du réplica principal et sont également transmis aux réplicas secondaires. À mesure que votre service évolue, il est probable que vous souhaitiez modifier le type de données (schéma) dont votre service a besoin. Abordez la question du contrôle de version de vos données avec une extrême prudence. Tout d’abord, vous devez toujours être en mesure de désérialiser les anciennes données. Plus précisément, cela signifie que votre code de désérialisation doit avoir une compatibilité descendante à l’infini : la version 333 de votre code de service doit être en mesure de fonctionner sur les données placées dans une collection fiable par la version 1 de votre code de service créé 5 ans plus tôt.
En outre, le code de service est mis à niveau à raison d’un domaine de mise à niveau à la fois. Par conséquent, pendant une mise à niveau, deux versions différentes de votre code de service s’exécutent simultanément. Vous devez éviter que la nouvelle version de votre code de service utilise le nouveau schéma, étant donné que les anciennes versions de votre code de service ne seront peut-être pas en mesure de gérer le nouveau schéma. Dans la mesure du possible, vous devez concevoir chaque version de votre service de manière à ce qu'elle soit compatible avec les versions ultérieures. Plus précisément, cela signifie que la V1 de votre code de service doit pouvoir ignorer tous les éléments de schéma qu'elle ne gère pas explicitement. Toutefois, elle doit être en mesure d'enregistrer toutes les données qu'elle ne connaît pas explicitement et de les réécrire lors de la mise à jour d'une valeur ou d'une clé de dictionnaire.
Avertissement
Bien que vous puissiez modifier le schéma d'une clé, vous devez vous assurer de la stabilité des algorithmes d’égalité et de comparaison de votre clé. Le comportement des collections fiables après une modification de l’un de ces algorithmes n’est pas défini et peut entraîner une altération des données, des pertes et des blocages de service. Les chaînes .NET peuvent être utilisées comme clés, mais utilisez la chaîne elle-même en tant que clé et n’utilisez pas le résultat de String.GetHashCode comme clé.
Vous pouvez également effectuer une mise à niveau en plusieurs phases.
- Mettez à niveau le service vers une nouvelle version qui
- dispose à la fois la version V1 d’origine et la nouvelle version V2 des contrats de données inclus dans le package de code de service ;
- inscrit des sérialiseurs d’état V2 personnalisés, si nécessaire ;
- effectue toutes les opérations sur la collection V1 d’origine à l’aide des contrats de données V1.
- Mettez à niveau le service vers une nouvelle version qui
- crée une collection V2 ;
- effectue chaque opération d’ajout, de mise à jour et de suppression sur les collections V1 d’abord, puis sur les collections V2 dans une transaction unique ;
- effectue des opérations de lecture sur la collection V1 uniquement.
- Copiez toutes les données de la collection V1 vers la collection V2.
- Cela peut être effectué dans un processus en arrière-plan par la version du service déployée à l’étape 2.
- Récupérez toutes les clés de la collection V1. L’énumération est effectuée avec IsolationLevel.Snapshot par défaut pour éviter de verrouiller la collection pendant la durée de l’opération.
- Pour chaque clé, utilisez une transaction distincte pour
- TryGetValueAsync de la collection V1.
- Si la valeur a déjà été supprimée de la collection V1 depuis le début du processus de copie, la clé doit être ignorée et non ressuscitée dans la collection V2.
- TryAddAsync la valeur de la collection V2.
- Si la valeur a déjà été ajoutée à la collection V2 depuis le début du processus de copie, la clé doit être ignorée.
- La transaction ne doit être validée que si le
TryAddAsync
retournetrue
. - Les API d’accès aux valeurs utilisent isolationLevel.ReadRepeatable par défaut et s’appuient sur le verrouillage pour garantir que les valeurs ne sont pas modifiées par un autre appelant tant que la transaction n’est pas validée ou abandonnée.
- Mettez à niveau le service vers une nouvelle version qui
- effectue des opérations de lecture sur la collection V2 uniquement ;
- effectue toujours chaque opération d’ajout, de mise à jour et de suppression sur les collections V1 d’abord, puis sur les collections V2 pour conserver l’option de restauration vers V1.
- Testez complètement le service et vérifiez qu’il fonctionne comme prévu.
- Si vous avez manqué une opération d’accès à des valeurs qui n’a pas été mise à jour pour fonctionner à la fois sur la collection V1 et V2, vous pouvez remarquer des données manquantes.
- S’il manque des données, revenez à l’étape 1, supprimez la collection V2 et répétez le processus.
- Mettez à niveau le service vers une nouvelle version qui
- effectue toutes les opérations sur la collection V2 uniquement ;
- il n’est plus possible de revenir à la version V1 avec une restauration du service. Cela nécessiterait une restauration avec les étapes 2 à 4 inversées.
- Mettez à niveau le service vers une nouvelle version qui
- Attendez la troncation du journal.
- Par défaut, cela se produit tous les 50 Mo d’écritures (ajouts, mises à jour et suppressions) dans des collections fiables.
- Mettez à niveau le service vers une nouvelle version qui
- ne comprend plus les contrats de données V1 dans le package de code de service.
Étapes suivantes
Pour en savoir plus sur la création de contrats de données à compatibilité ascendante, consultez Contrats de données à compatibilité ascendante.
Pour découvrir les meilleures pratiques relatives au contrôle de version des contrats de données, consultez Contrôle de version des contrats de données.
Pour savoir comment implémenter des contrats de données à tolérance de version, consultez Rappels de sérialisation avec tolérance de version.
Pour savoir comment fournir une structure de données capable d'interagir entre plusieurs versions, consultez IExtensibleDataObject.
Pour savoir comment configurer des collections fiables, consultez Configuration du réplicateur