Indice secuencial con Table Storage en Azure


Azure Table Storage es un sistema NoSQL de almacenamiento de tipo clave/valor. Es un sistema pensado para grandes volúmenes de datos distribuidos en alta disponibilidad y algunas cosas que son triviales hoy en día en un motor SQL estándar, en un NoSQL es muy posible que ni siquiera estén contempladas. Esto nos obliga a pensar en otras técnicas o incluso otras formas de almacenar los datos y sus relaciones.
Numbers

Hace unos días necesité un contador secuencial para almacenar en tablas del Table Storage. Ya se que muchos me diréis que no es lo más adecuado para el Storage, pero la secuencia es para mostrar por pantalla y resulta que los humanos llevamos muy mal el retener números de más de seis cifras.

Lo primero que nos vamos a encontrar son las siguientes trabas/características:

  • El Table Storage nos permite 8 tipos de datos diferentes, son tipos básicos y no existe el tipo de dato auto-numérico
  • Sólo se indexan dos de los 255 campos disponibles: la clave de partición y la clave de fila, no existen índices secundarios
  • No hay relaciones ni integridad referencial
  • No tenemos procedimientos almacenados ni funciones. Para obtener el MAX de un campo es necesario recorrer toda la tabla, o una partición si sólo nos interesa la partición (recomendable), aún así son muchas operaciones de E/S y crecen por cada inserción.
  • No es posible bloquear registros o tablas
  • Sólo es posible realizar una “transacción” a nivel de partición en una sola tabla a la vez, sobre un máximo de 100 filas, sólo una operación por fila y el total de la operación de menos de 4MB

Visto lo que no podemos hacer, habrá que descubrir qué nos permite hacer la plataforma. He buscado bastante y hay información dispar de cómo resolverlo; desde un punto de entrada único para poder realizar bloqueos hasta una solución ingeniosa en la que generan previamente los índices mediante un worker role y los meten en una cola.
Como no me acababan de convencer los sistemas que he visto, os explico aquí la solución que me ha parecido más adecuada, aunque estoy pendiente de hacer unas pruebas de rendimiento razonables montando un entorno de test en Azure con múltiples clientes (el emulador es muy lento para hacer una prueba fiable), pues por ahora el rendimiento no me acaba de convencer del todo, aunque para mi solución concreta es suficiente y gestionando bien las particiones de datos el tiempo de respuesta es más que aceptable.

Operaciones con concurrencia optimista

Las tablas de Azure realizan las actualizaciones usando concurrencia optimista, es decir: sólo nos permite escribir un dato si el timestamp/etag se corresponden con el del dato que habíamos leído. Esto evita establecer un mecanismo de bloqueo, aunque nos impedirá realizar operaciones atómicas en múltiples tablas a la vez.

Este mecanismo nos permite crear el contador en una tabla auxiliar, sumarle uno y si nadie más le ha sumado uno antes que nosotros se grabará en el storage, en ese caso ya tendremos nuestro índice. En caso de conflicto volvemos a intentar todo el proceso de nuevo (leer, sumar, escribir) hasta que consigamos grabar. El problema que nos podemos encontrar es que se tenga que repetir esa operación demasiadas veces y la respuesta sea muy lenta para algunas peticiones.

Mi solución

En esta solución, crearé una tabla llamada Identities para mantener el índice secuencial que se ha creado. Sólo tiene una PartitionKey, una RowKey donde pondremos el nombre de la tabla sobre la que creamos un índice secuencial y un campo Value, que es el índice secuencial.

_storageAccount = CloudStorageAccount.Parse(
    CloudConfigurationManager.GetSetting("StorageConnectionString"));
// Create the table client.
CloudTableClient tableClient = _storageAccount.CreateCloudTableClient();


_identityTable = tableClient.GetTableReference("identities");
_identityTable.CreateIfNotExists();

En la tabla identities introduciremos objetos como este para almacenar el índice de cada tabla:

public class Identity : TableEntity
{
    public Identity() { }
    public Identity(string partitionKey, string tableName)
        : base(partitionKey, tableName)
    {
    }

    public int Value { get; set; }
}

Al realizar la actualización tendremos que comprobar si salta una excepción con un HttpStatusCode 412 (PreconditionFailed ) que nos indicará que la operación de merge no ha ido bien y se debe repetir, o bien un 409 (Conflict) que nos indicará que ha habido un conflicto, probablemente por intentar hacer dos inserts sobre el mismo campo simultáneamente. En ese caso vamos a repetir la secuencia de operaciones (read, increment, merge) hasta que consigamos actualizar… o hasta que lleguemos a un límite aceptable de repeticiones para que podamos indicar al usuario que algo está tardando más de la cuenta:

const int MAXRETRIES = 50;

private static int getIdentity(string partitionId, string tableId)
{
    var retrieve = TableOperation.Retrieve<Identity>(partitionId, tableId);
    bool tryAgain = false;
    int tryCount = 0;
    int value = -1;
    do
    {
        tryAgain = false;
        var result =  _identityTable.Execute(retrieve);
        try
        {
            Identity entity = null;
            TableOperation incrementOperation = null;

            entity = result.Result as Identity;

            if (entity == null)
            {
                entity = new Identity(partitionId, tableId) { Value = 1 };
                incrementOperation = TableOperation.Insert(entity);
            }
            else
            {
                entity.Value++;
                incrementOperation = TableOperation.Merge(entity);
            }
            var opResult= _identityTable.Execute(incrementOperation);
            value = entity.Value;
            System.Diagnostics.Trace.TraceInformation("Success after {1} tries: {0} ", entity.Value, tryCount);
        }
        catch (StorageException ex)
        {
            if (ex.RequestInformation != null &&
                (ex.RequestInformation.HttpStatusCode == (int)HttpStatusCode.PreconditionFailed ||
                    ex.RequestInformation.HttpStatusCode == (int)HttpStatusCode.Conflict))
            {
                tryAgain = true;
                if (tryCount++ > MAXRETRIES)
                {
                    tryAgain = false;
                    throw new Exception("Maximum retries reached");
                }
            }
            else
            {
                throw;
            }
        }

    } while (tryAgain);

    return value;
}

Ahora ya podemos utilizar este método getIdentity para insertar datos en secuencia dentro de otra tabla, aunque si queréis tener la tabla ordenada por ese valor, deberéis usarlo como PartitionKey o RowKey, pues las tablas sólo se ordenan sobre la combinación PartitionKey+RowKey, ah y recordad hacer un ToString(“0000000000”) o similar, pues las claves de las tablas son strings y el orden es alfanumérico :), algo así:

var id = await getIdentity("App1", "sorteditem");
//el id se cifra dentro de un string con 10 dígitos para tener un orden
var sorted = new SortedItem("App1", id.ToString("0000000000"));
var op = TableOperation.Insert(sorted);
_sortedTable.Execute(op);

Aunque no siempre nos valdrá así, pues si queremos consultar habitualmente los últimos ids insertados, tendremos que crear un orden inverso en el RowKey de la tabla para poder hacer una consulta de los top N, por ejemplo:

var id = getIdentity("App1", "sorteditem");
//el id se cifra dentro de un string con 10 dígitos para tener un orden
var sorted = new SortedItem("App1", (int.MaxValue - id).ToString("0000000000"));
var op = TableOperation.Insert(sorted);
_sortedTable.Execute(op);

Otra cosa que debéis recordar es que puede fallar si tiene que intentar el write múltiples veces. Yo he puesto un throw, así que desde el método de llamada deberíamos recogerlo y avisar al usuario de que la operación está tardando demasiado.

Más sobre Azure Table Storage

Aquí os dejo algunos artículos interesantes sobre el tema:

    Anuncios

    Responder

    Introduce tus datos o haz clic en un icono para iniciar sesión:

    Logo de WordPress.com

    Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

    Imagen de Twitter

    Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

    Foto de Facebook

    Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

    Google+ photo

    Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

    Conectando a %s