MongoDB. Comprobando el rendimiento de usePowerOf2Sizes

Hace unos días escribí un artículo para GenbetaDev a propósito de la actualización de datos en MongoDB

En ese artículo además de hablar sobre como hacer actualizaciones de datos, comentaba cómo almacena MongoDB los documentos en la versión 2.4. Cuándo tenemos una colección vacía y empezamos a insertar documentos, MongoDB los almacena sin dejar espacio entre ellos. Es decir, que por cada documento almacenado, se reserva el espacio justo que se necesita. Si un documento se actualiza, habrá que moverlo para conseguir el espacio que necesita. Para evitar movimientos constantes -que penalizan el rendimiento- MongoDB calcula dinámicamente un factor de separación de manera que se reserva espacio extra en el documento. Si se vuelve a actualizar, como tiene espacio libre adicional, es posible que no sea necesario moverlo.

Por tanto cuándo no hemos realizado ninguna operación de actualización, el factor de separación (en inglés padding factor), es 1. Es decir que MongoDB no está dejando espacio entre documentos. Si se producen actualizaciones, habrá que mover los documentos, por lo que MongoDB aumentará el factor de separación. Un valor de 1.5, implica que se está añadiendo un 50% más al espacio que necesita un documento. Si el documento ocupa 1KB se reservará un espacio de 1,5KB.

Con la nueva versión 2.6 de MongoDB -ahora en release candidate-, este sistema se va a modificar para pasar a utilizar por defecto usePowerOf2Sizes.

¿Qué es usePowerOf2Sizes?

Este sistema se puede utilizar desde hace varias versiones de MongoDB. A partir de la versión 2.6, será el sistema utilizado por defecto, en lugar del actual cálculo dinámico del padding factor.

La idea es muy sencilla, y se trata de redondear el espacio que ocupa un documento a la potencia de 2 superior más cercana. Si nuestro documento tiene un tamaño de 3 bytes, se reservarán 4 bytes. Si su tamaño es de 90 bytes, se reservarán 128 bytes. Y si tiene un tamaño de 1800 bytes se guardará ocupando 2048 bytes.

Esto tiene una ventaja y un inconveniente. La ventaja es que MongoDB tendrá que mover menos documentos al realizar actualizaciones. Eso significa que el rendimiento mejorará. La desventaja es que, en teoría, el consumo de disco aumentará. Si por cada documento reservamos más espacio del necesario, está claro que necesitaremos más disco.

Las eliminaciones de documentos también influyen. Cuándo se borra un documento, el espacio que ocupa queda libre. Si se va a insertar un documento, y el espacio libre es suficiente, se utiliza para almacenarlo.

Ahora que hemos visto la teoría, vamos a realizar unos test para ver como se comportan ambos sistemas.

Componentes necesarios para los test

Necesitamos dos colecciones para hacer las pruebas

db.createCollection("noPowerOf2Sizes");
db.createCollection("powerOf2Sizes");
db.runCommand( {collMod: "powerOf2Sizes", usePowerOf2Sizes : true });

La primera colección, la creamos de forma normal. Con la segunda establecemos el parámetro usePowerOf2Sizes a true para usar potencias de 2 para el almacenamiento.

También creamos una función JavaScript que genere números aleatorios en un intervalo dado.

function getRandomInt (min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

Otro elemento que voy a utilizar, es un script para generar documentos e insertarlos en las dos colecciones.

//Definición de variables
var myArray;    
var startDate;
var noPowerOf2Milliseconds =0;
var powerOf2Milliseconds = 0;

//Insertamos 1.000.000 documentos
for(var i=0;i<=1000000;i++){

    myArray=new Array();

    for (var a=0;a< getRandomInt(1,10);a++)
    {
      myArray[a] = getRandomInt(1,10) * getRandomInt(1,50);
    }

    startDate= new Date();
    db.noPowerOf2Sizes.insert(
    {
        _id:i,
        foo:myArray
    }); 
    noPowerOf2Milliseconds += Math.abs(new Date() - startDate);  

    startDate= new Date();
    db.powerOf2Sizes.insert(
    {
        _id:i,
        foo:myArray
    }); 
    powerOf2Milliseconds += Math.abs(new Date() - startDate);  
}

print("noPowerOf2 " + noPowerOf2Milliseconds);
print("powerOf2 "+ powerOf2Milliseconds);
print("");

El script crea 1.000.000 de documentos, con un campo _id y un campo foo que contendrá un array de tamaño y contenido aleatorio. El script hace dos inserciones, una en cada colección, y va almacenando los tiempos que tardan esas inserciones. Posiblemente haya alguna forma mejor de medir los tiempos, pero yo creo que para los test que voy a realizar es más que suficiente. Aquí un ejemplo de documento insertado:

{
    "_id" : 142184,
    "foo" : [ 
        387, 
        153, 
        32, 
        230, 
        220, 
        26, 
        40,        
        126,        
        160
    ]
}

El último script, es el que ejecutaré varias veces para ver como se comportan los dos sistemas de almacenamiento. Es el siguiente:

//Definición de variables
var startDate; 
var noPowerOf2Milliseconds =0;
var powerOf2Milliseconds = 0;
var myArray;
//recuperamos el _id más alto que haya sido insertado
var lastElement=db.powerOf2Sizes.find().sort({_id:-1}).limit(1).toArray()[0]._id;


//Realizamos 100.000 actualizaciones aleatorias
for (i=0;i<=100000;i++){
    objectToUpdate= getRandomInt(0,lastElement);

    myArray = new Array();

    for (a=0;a< getRandomInt(1,10); a++){
        myArray[a] = getRandomInt(1,10) * getRandomInt(1,50);  
    }

    startDate= new Date();
    db.noPowerOf2Sizes.update(
        { _id:objectToUpdate},
        {$pushAll:{foo:myArray}}
    );   
    noPowerOf2Milliseconds += Math.abs(new Date() - startDate);

    startDate = new Date();
    db.powerOf2Sizes.update(
      { _id:objectToUpdate},
      {$pushAll:{foo:myArray}}
    );
    powerOf2Milliseconds += Math.abs(new Date() - startDate);            
}

//Realizamos 20.000 eliminaciones aleatorias
for (d=0;d<=20000;d++){
    objectToUpdate= getRandomInt(0,lastElement);

    startDate = new Date();
    db.noPowerOf2Sizes.remove({_id:objectToUpdate});
    noPowerOf2Milliseconds += Math.abs(new Date() - startDate);

    startDate = new Date();
    db.powerOf2Sizes.remove({_id:objectToUpdate});
    powerOf2Milliseconds += Math.abs(new Date() - startDate);
}

//Insertamos 20.000 documentos nuevos
for (j=lastElement + 1; j<= (lastElement + 20000); j++){

    myArray=new Array();    

    for (p=0;p< getRandomInt(1,10); p++)
    {
      myArray[p] = getRandomInt(1,10) * getRandomInt(1,50);
    }

    startDate= new Date();
    db.noPowerOf2Sizes.insert(
    {
        _id:j,
        foo:myArray
    }); 
    noPowerOf2Milliseconds += Math.abs(new Date() - startDate);  

    startDate= new Date();
    db.powerOf2Sizes.insert(
    {
        _id:j,
        foo:myArray
    }); 
    powerOf2Milliseconds += Math.abs(new Date() - startDate);  
}

//Mostramos los tiempos
print("noPowerOf2 " + noPowerOf2Milliseconds);
print("powerOf2 "+ powerOf2Milliseconds);
print("");

El funcionamiento es sencillo. Primero realizamos 100.000 actualizaciones filtrando por el campo _id. Luego borramos 20.000 documentos y finalmente insertamos otros 20.000. Tanto con las actualizaciones, como con las eliminaciones, es posible que no se realice ninguna operación. Esto es debido a que al ejecutar varias veces el script, es posible que el documento que se trata de borrar o actualizar aleatoriamente, ya no exista.

En el script he usado $pushAll por simplificar las consultas, pero en la versón 2.4 ya ha sido marcado como deprecated. Para cosas más serias es mejor utilizar $push junto con el operador $each.

En todas las operaciones, se realiza la misma acción sobre las dos colecciones.

Tomando medidas

Lo primero que hacemos es ejecutar el script de inserción. Una vez ha finalizado el proceso, se lanza el comando stats en cada colección. Por ejemplo, esto es lo que hay en las colecciones noPowerOf2Sizes y powerOf2Sizes después de la inserción de 1.000.001 documentos.

db.noPowerOf2Sizes.stats();
{
    "ns" : "test.noPowerOf2Sizes",
    "count" : 1000001,
    "size" : 71490776,
    "avgObjSize" : 71.4907045092955,
    "storageSize" : 123936768,
    "numExtents" : 11,
    "nindexes" : 1,
    "lastExtentSize" : 37625856,
    "paddingFactor" : 1,
    "systemFlags" : 1,
    "userFlags" : 0,
    "totalIndexSize" : 27921040,
    "indexSizes" : {
        "_id_" : 27921040
},
"ok" : 1
}

db.powerOf2Sizes.stats();
{
    "ns" : "test.powerOf2Sizes",
    "count" : 1000001,
    "size" : 107887632,
    "avgObjSize" : 107.8875241124759,
    "storageSize" : 123936768,
    "numExtents" : 11,
    "nindexes" : 1,
    "lastExtentSize" : 37625856,
    "paddingFactor" : 1,
    "systemFlags" : 1,
    "userFlags" : 1,
    "totalIndexSize" : 27921040,
    "indexSizes" : {
        "_id_" : 27921040
},
"ok" : 1  }

Para las mediciones, además de los tiempos que devuelven los scripts, nos vamos a fijar en el parámetro size que nos muestra el comando stats. Este parámetro indica el tamaño de los documentos almacenados (que no es lo mismo que el espacio que ocupan los datos en disco).

Una vez insertados los datos, realizamos varias pasadas del segundo script. Con cada pasada obtenemos el tiempo que tarda en realizarse la operación para cada script, y con stats el tamaño que ocupan los datos.

Resultados

Los datos que yo he obtenido son los siguientes:

ms noPowerOf2 ms PowerOf2 Espacio noPowerof2 Espacio PowerOf2
1 20970 16797 87763784 113968192
2 24109 19084 102019032 119836736
3 20690 18000 114495248 125442720
4 17660 20309 125581096 130835712
5 18284 18519 135444200 136044416
6 23637 30972 144322368 141061744
7 24374 23672 152318472 145963744
8 16919 17293 159640784 150733920
9 22379 18692 166309496 155405968
10 17199 15448 172489584 159979968
11 15915 25677 178227256 164397712
12 18456 17995 183622696 168715264
13 21171 19542 188779584 172981552
14 22439 33075 193657648 177145888
15 21426 30141 198368448 181273584
16 24909 26866 202882568 185336912
17 24919 22370 207172096 189340096
18 28614 20103 211301216 193269904
19 24280 33770 215432120 197177120
20 14813 17014 219394088 200983296
21 23832 17642 223309000 204765904

Para verlo más claro, unos gráficos.

Conclusiones

Lo primero que podemos ver es que con usePowerOf2Sizes se requiere más espacio de disco al inicio. Sin embargo, el consumo de disco con este sistema tiene un crecimiento más sostenido y predecieble. Por eso, llega un momento en el que los documentos ocupan menos espacio que el modelo de reserva de espacio tradicional. El modelo tradicional, que utiliza el padding factor, crece con saltos pronunciados. Es algo curioso, porque a priori podríamos pensar que el usePowerOf2Sizes utilizaría mayor espacio en disco. El problema yo creo que está en la fragmentación. El método que usa el factor de separación mueve los documentos, pero deja más huecos libres, dejando el espacio disponible fragmentado. Con useOfPowerOf2Sizes el espacio que deja un documento cuándo se mueve, es más fácil de identificar, y por tanto el hueco se puede reutilizar de manera más sencilla, reduciendo la fragmentación.

En cuánto a la velocidad de ejecución de las operaciones, podemos decir que utilizar usePowerOf2Sizes es más o menos igual que el sistema tradicional. Dependiendo de las operaciones realizadas, y de los documentos que haya que mover, el rendimiento variará bastante. Si puedo decir, que haciendo pruebas con menos documentos, el uso de usePowerfOfSizes mejora un poco el rendimiento.

Con este sencillo test, hemos podido comprobar por qué usePowerOf2Sizes será el modo de reserva de espacio que la nueva versión 2.6 de MongoDB utilizará por defecto. El espacio requerido por la base de datos será más previsible, y estable que con el método tradicional.

Eso sí, hay que tener en cuenta que el método actual de padding factor no se elimina. Simplemente pasará a ser opcional.



Recuerda que puedes ver el índice del tutorial y acceder a todos los artículos de la serie desde aquí.



¿Quiéres que te avisemos cuando se publiquen nuevas entradas en el blog?

Suscríbete por correo electrónico o por RSS