Redis Cluster con Ruby

August 22, 2013

Intro

En Comenta.Tv procesamos gran cantidad de tweets, que obtenemos a partir de la API de streaming de Twitter, y generamos estadísticas y reportes en tiempo real.

Para lograr escalar a cientos de mensajes por segundo y para poder generar los reportes con tiempos de respuesta rápidos, nos valemos principalmente de Redis, que demostró ser una herramienta ideal para logar nuestra meta.

El “problema” de Redis (y también su virtud) es que toda la base de datos tiene que caber en la memoria RAM, y rápidamente esto se convierte en un recurso escaso. La solución ideal sería disponer ya de Redis Cluster, pero como todavía no está disponible hubo que encarar una solución más casera para particionar el contenido en distintos servidores.

Redis::Distributed

Según la documentación de Redis, hay distintas opciones para particionar los datos en múltiples servidores y una de las opciones es utilizar directamente el cliente de Redis en Ruby utilizando la opción “distributed”.

La documentación para utilizar Redis::Distributed es casi nula y encontré pocos ejemplos y demasiado básicos como para tomarlos en serio. Luego de cometer algunos errores que me llevaron a andar moviendo millones de claves de un servidor a otro, decidí escribir este post para tratar de evitar que otros pasen por lo mismo.

¿Cómo funciona?

Normalmente para utilizar Redis desde Ruby con un sólo servidor hacemos algo así:

client = Redis.new('redis://127.0.0.1:6379/0')

client.set "clave1", "Hello!"        # "OK"
client.get "clave1"                  # "Hello!"
client.del "clave1"                  # 1

Cuando utilizamos Redis::Distributed el uso es similar, pero el cliente se inicializa con un array de servidores Redis en lugar de un único servidor:

REDIS_ARRAY = [ 'redis://127.0.0.1:6379/0', 'redis://127.0.0.1:6380/0' ]

client = Redis::Distributed.new(REDIS_ARRAY)
client.set "clave1", "Hello!"        # "OK"
client.get "clave1"                  # "Hello!"

Como vemos ahora tenemos múltiples server, pero el funcionamiento del los comandos es similar. Ahora el servidor de destino se elige en base al valor de la clave:

client.node_for "clave1"             # <Redis client v3.0.4 for redis://127.0.0.1:6379/0>
client.node_for "clave2"             # <Redis client v3.0.4 for redis://127.0.0.1:6380/0>

Limitaciones

Múltiples claves

El hecho de que cada clave se encuentre en un servidor distinto impone algunas limitaciones, por ejemplo: todas las operaciones que impliquen múltiples claves no son posibles:

client = Redis::Distributed.new(REDIS_ARRAY)

client.mset("clave1", "val1", "clave2", "val2")
  >> Redis::Distributed::CannotDistribute: Redis::Distributed::CannotDistribute

Esto no significó un problema para nosotros porque no utilizamos ninguna de dichas operaciones ya que todo lo realizamos en base a claves independientes.

Agregar servidores

Una vez que uno tiene el cluster de servidores funcionando y con contenido, no es posible agregar un nuevo servidor al array porque eso alteraría automáticamente la distribución de claves y las ya existentes quedaría inaccesibles.

Entonces, ¿cómo hacer cuando nos volvemos a quedar sin memoria? La primera opción sería agregar memoria a los servidores actuales, pero esto no siempre es posible.

La solución sugerida es que en el armado del cluster tengamos la previsión de tener muchos servidores Redis corriendo, aunque inicialmente todos los procesos pueden estar en el mismo servidor físico y a medida que necesitamos, vamos migrando el contenido a nuevos servidores físicos. La cantidad de servidores no debemos cambiarla nunca y no hay que tener miedo de tener muchos servidores Redis corriendo en el mismo server físico ya que la utilización de memoria y CPU es muy baja.

Armado del cluster

Identificar los servidores por ID

En los ejemplos que había visto con Redis::Distributed la inicialización se realizaba siempre especificando un array de urls de redis, tal como se ven en los ejemplos anteriores, pero para mi sorpresa la distribución de claves no se realiza en base a la posición del servidor dentro del array sino en base a su ID, que por defecto es la url del servidor. Por lo tanto, si cambiamos la IP o el puerto de un servidor redis, ¡la distribución de claves cambia!

La solución es especificar el array de servidores de la siguiente forma:

REDIS_ARRAY = [
  { id:  'redis_0',
    url: "redis://127.0.0.1:6379/0",
  },
  { id:  'redis_1',
    url: "redis://127.0.0.1:6380/0",
  },
  #
  # more servers
  #
  { id:  'redis_n',
    url: "redis://127.0.0.1:63xx/0",
  },
]

De esta forma, cuando agregamos un nuevo servidor físico para aumentar la capacidad del cluster, lo único que tenemos que hacer es replicar la base de datos de Redis y luego cambiar sólo la url correspondiente en el REDIS_ARRAY.

Script de arranque para Ubuntu 12.04

Para simplificar el arranque y detención de los múltiples servidores Redis que corren en el mismo servidor físico, modifiqué el script alojado en /etc/init.d/ para agregar fácilmente múltiples servidores en distintos puertos. En el siguiente gist se puede ver el archivo /etc/init.d/redis-__port__ que sirve de base para crear los servidores.

Una vez que tenemos dicho archivo en /etc/init.d/ y con los permisos de ejecución correspondientes, lo único que hace falta hacer es crear links simbólicos cuyo nombre tiene que contener el port en el que queremos que el servidor Redis esté escuchando:

cd /etc/init.d/

ln -s redis-__port__ redis-6380
ln -s redis-__port__ redis-6381
ln -s redis-__port__ redis-6382
ln -s redis-__port__ redis-6383

update-rc.d redis-6380 defaults
update-rc.d redis-6381 defaults
update-rc.d redis-6382 defaults
update-rc.d redis-6383 defaults

También es necesario tener el archivo /etc/redis/_common.conf que contiene toda la configuración común a todas las instancias. Opcionalmente también se pueden especificar configuraciones particulares a cada instancia creando el archivo correspondiente en /etc/redis/_\[port\].

Al arrancar un servidor, por ejemplo con:

/etc/init.d/redis-6380 start

el script se encarga de crear la configuración correspondiente en /etc/redis/redis-6380.conf y luego arrancar la instancia con dicha configuración, que como ejemplo va a tener el siguiente contenido:

port 6380
pidfile /var/run/redis/redis-6380.pid
logfile /var/log/redis/redis-6380.log
dbfilename dump-6380.rdb
include /etc/redis/_common.conf

Si además detecta que existe el archivo /etc/redis/_6380.conf, la configuración tendrá además incluido dicho archivo antes del _common.conf.

Migración de claves

Tweet
comments powered by Disqus