Escaneo de claves en un clúster Redis

0
2644

Índice de contenidos

1. Introducción

Redis es una base de datos en memoria que almacena tipos de datos: strings, listas, hashes, sets ordenados, streams,… Puede ser utilizado como sistema de caché, para lo que hay que tener en cuenta una serie de consideraciones como por ejemplo deshabilitar la persistencia en disco o limitar el uso de memoria máximo disponible para el servicio Redis a un 60% de la RAM total disponible.

Las soluciones clusterizadas de Redis tienen como objetivo conseguir un alto rendimiento y facilitar el escalado, así como asegurar disponibilidad, teniendo al menos una réplica por cada nodo maestro.

Cuando montamos un clúster Redis podemos vernos en la necesidad de monitorizar el uso de memoria por cada uno de los datos almacenado en Redis.

En este tutorial veremos cómo realizar dicha monitorización sin poner en peligro el servicio, ya que existe la posibilidad de que bloqueemos el acceso a los nodos del clúster por estar realizando una operación muy costosa.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

    • Hardware: Portátil MacBook Pro 15’ (2,5 GHz Intel Core i7, 16 GB 1600 MHz DDR3)
    • Sistema operativo: Mac OS X High Sierra 10.13.6
    • Entorno de desarrollo: IntelliJ IDEA CE 2018.3.4
    • Java 11.0.2
    • Apache Maven 3.5.3
    • Redis 5.0.3
    • Jedis 3.0.1

En cuanto al clúster Redis, se ha creado un clúster mínimo, es decir, 3 nodos maestros y sus correspondientes 3 réplicas, cada uno configurado con una política de desalojo LRU. También se han configurado para que no persistan en memoria y con el máximo de memoria establecido a 2.4 GB.

3. Inicializando el clúster

Para inicializar el clúster y realizar el escaneado de claves he creado un proyecto Java gestionado por Maven, por tanto lo primero que tenemos que hacer es incluir en nuestro pom la dependencia con la librería Jedis, un cliente de Redis para Java:

pom.xml
...

	<dependencies>
		<dependency>
			<groupId>redis.clients</groupId>
			<artifactId>jedis</artifactId>
			<version>3.0.1</version>
		</dependency>
		<dependency>
			<groupId>com.fasterxml.jackson.core</groupId>
			<artifactId>jackson-databind</artifactId>
			<version>2.9.8</version>
		</dependency>
	</dependencies>

...

También se incluye la librería Jackson para devolver la respuesta del programa en formato Json.

Podemos crearnos un método main encargado de recoger los parámetros de entrada del programa, inicializar el clúster (de manera opcional) con datos de prueba, realizar el escaneo y mostrar el resultado por la salida estándar.

package com.autentia.redis;

import com.autentia.redis.initializer.ClusterInitializer;
import com.autentia.redis.response.ScannerResponse;
import com.autentia.redis.scanner.KeyScanner;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisCluster;

import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class ScanRedisCluster {

    private static final ObjectMapper mapper = new ObjectMapper();

    public static void main(String[] args) {
        if (args.length  4) {
            System.err.println("Provide all master nodes in ip:port format (Optional -i to initialize cluster)");
            return;
        }

        Set nodes = new HashSet();
        String[] splittedNode = args[0].split(":");
        HostAndPort hostAndPort = new HostAndPort(splittedNode[0], Integer.valueOf(splittedNode[1]));
        nodes.add(hostAndPort);


        try (JedisCluster cluster = new JedisCluster(nodes)) {

            if (args.length == 4 && "-i".equals(args[3])) {
                ClusterInitializer.initialize(cluster);
            }

            final List response = KeyScanner.scan(cluster, Arrays.asList(args[0], args[1], args[2]));
            System.out.println(mapper.writeValueAsString(response));
        } catch (JsonProcessingException e) {
            System.err.println("Error parsing response");
        }

    }
}

Como se observa en el snippet de código anterior, para realizar la conexión con Redis utilizamos el primer parámetro de entrada, que será el host y el puerto a través del que da servicio uno de los nodos maestros de nuestro clúster. Jedis solo necesita la información de uno de los nodos, y lo usará para obtener la información necesaria de la topología del clúster.

Si incluimos como último parámetro de entrada al programa la opción -i inicializaremos el clúster con la clase ClusterInitilizer. Dicha clase tiene un método encargado de crear datos en Redis, en particular crea 10 claves de tipo zset, que agrupan a su vez 50000 claves de tipo string. Además también incluimos 100000 claves de tipo hash con 20 campos cada uno.

package com.autentia.redis.initializer;

import redis.clients.jedis.JedisCluster;

public class ClusterInitializer {

    public static void initialize(JedisCluster cluster) {
        initialiceZsets(cluster);
        initializeHashes(cluster);
    }

    private static void initialiceZsets(JedisCluster cluster) {
        for (int zsetKey = 0; zsetKey < 10; zsetKey++) {
            for (int memberKey = 0; memberKey < 50000; memberKey++) {
                String member = "key" + zsetKey + memberKey;
                String value = "value" + zsetKey + memberKey;
                cluster.set(member, value);
                String zset = "zset" + zsetKey;
                cluster.zadd(zset, 0, member);
            }
        }
    }

    private static void initializeHashes(JedisCluster cluster) {
        for (int hashKey = 0; hashKey < 100000; hashKey++) {
            String hash = "hash" + hashKey;
            for (int fieldKey = 0; fieldKey < 20; fieldKey++) {
                String field = "field" + fieldKey;
                String value = "hashValue" + fieldKey;
                cluster.hset(hash, field, value);
            }
        }
    }

}	

¿Por qué he inicializado el clúster con zsets y hashes? He querido enfocar la solución en situaciones en las que utilizamos Redis como sistema de caché distribuida. Si utilizamos Spring Data Redis en combinación con Jedis para la gestión de la caché, los métodos que hayamos marcado como @Cacheable crearán una nueva clave, en el nodo Redis que les corresponda, que será de tipo string. Esto se hará por cada una de las diferentes combinaciones de parámetros de entrada con las que se llame al método cacheado. Estas claves contendrán el valor cacheado, y a su vez dichas claves estarán agrupadas en un zset que identifican de manera genérica al método.

Por otro lado, también se almacena en el clúster Redis los hashes que hemos comentado anteriormente. Redis nos puede servir para almacenar las sesiones de nuestra aplicación web, de manera que cuando se necesite información asociada a una sesión, ésta se vaya a buscar al clúster Redis, evitando la necesidad de tener afinidad de sesión con los clientes de la aplicación. Para realizar dicha labor podemos usar Spring Session en particular la librería Spring Session Data Redis. Y como os podéis imaginar, la información asociadas de las sesiones se guarda en Redis como hashes.

4. Escaneado de claves

A la hora de hacer un recorrido por todas las claves que tenemos en un nodo Redis hay que tomar precauciones. Quizás nuestro primer impulso sea lanzar el comando KEYS con el patrón *. Y sí, efectivamente obtendremos todas las claves. ¿Pero qué ocurre si lo lanzamos en entornos productivos donde se han podido generar cientos de miles de claves?, pues que ponemos en peligro el rendimiento, es más, podemos bloquear durante mucho tiempo el acceso a Redis con lo que estaremos bloqueando a las aplicaciones clientes del sistema.

Como alternativa, Redis nos proporciona el comando SCAN. Con él podremos obtener las claves de una manera iterativa, es decir, nos va proporcionando las claves por lotes. Con un cursor que inicializaremos a cero, y del que Redis nos devolverá un valor actualizado con el que realizar la siguiente llamada, podremos ir recuperando todas las claves. El proceso finalizará cuando el cursor vuelva a ser cero. El comando SCAN no te puede asegurar que te acabe devolviendo todas las claves ni que todas las que te devuelva realmente estén, ya que durante las iteraciones se han podido crear o eliminar claves.

El escaneador de claves que os propongo es el siguiente:

package com.autentia.redis.scanner;

import com.autentia.redis.model.RedisZset;
import com.autentia.redis.response.ScannerResponse;
import com.autentia.redis.response.ZsetResponseBuilder;
import redis.clients.jedis.*;

import java.util.*;
import java.util.stream.Collectors;

public class KeyScanner {

    public static List scan(JedisCluster cluster, List masterNodes) {
        List zsets = new ArrayList();
        Map stringKeysWithMemoryUsage = new HashMap();
        Map hashesKeysWithMemoryUsage = new HashMap();

        Map clusterNodes = cluster.getClusterNodes();
        Set clusterNodesHostAndPort = clusterNodes.keySet();

        clusterNodesHostAndPort.stream().forEach(node -> {
            if (isMaster(node, masterNodes)) {
                JedisPool jedisPool = clusterNodes.get(node);
                try (Jedis connection = jedisPool.getResource()) {
                    String cursor = "0";
                    do {
                        ScanResult scanResult = connection.scan(cursor);
                        List keys = scanResult.getResult();
                        keys.stream().forEach(key -> {
                            long memoryUsage = getMemoryUsage(connection, key);
                            String type = connection.type(key);
                            switch (type) {
                                case "zset":
                                    List zsetKeys = getZsetKeys(key, connection);
                                    RedisZset zset = new RedisZset(key, zsetKeys, memoryUsage);
                                    zsets.add(zset);
                                    break;
                                case "string":
                                    stringKeysWithMemoryUsage.put(key, memoryUsage);
                                    break;
                                case "hash":
                                    hashesKeysWithMemoryUsage.put(key, memoryUsage);
                                    break;
                            }
                        });
                        cursor = scanResult.getCursor();
                    } while (!"0".equals(cursor));
                }
            }
        });

        return getResponse(zsets, stringKeysWithMemoryUsage, hashesKeysWithMemoryUsage);
    }

    private static boolean isMaster(String node, List masterNodes) {
        return masterNodes.contains(node);
    }

    private static long getMemoryUsage(Jedis connection, String key) {
        long memoryUsage = 0;
        String script = "return redis.call('memory', 'usage', '" + key + "')";
        Object memoryUsageResult = connection.eval(script);
        try {
            memoryUsage = Long.valueOf(String.valueOf(memoryUsageResult));
        } catch (NumberFormatException e) {
            System.err.println("Invalid memory usage in key: " + key);
        }
        return memoryUsage;
    }

    private static List getZsetKeys(String key, Jedis connection) {
        List zsetKeys = new ArrayList();
        String cursor = "0";
        do {
            ScanResult zscanResult = connection.zscan(key, cursor);
            cursor = zscanResult.getCursor();
            List keysResult = zscanResult.getResult();
            List keys = keysResult.stream().map(Tuple::getElement).collect(Collectors.toList());
            zsetKeys.addAll(keys);
        } while(!"0".equals(cursor));
        return zsetKeys;
    }

    private static List getResponse(List zsets, Map stringKeysWithMemoryUsage, Map hashesKeysWithMemoryUsage) {
        List responses = new ArrayList();
        List zsetResponses = new ZsetResponseBuilder().build(zsets, stringKeysWithMemoryUsage);
        long hashesMemoryUsage = hashesKeysWithMemoryUsage.values().stream().mapToLong(Long::longValue).sum();
        ScannerResponse hashesResponses = new ScannerResponse("Hashes for sessions", "hash", hashesMemoryUsage);
        responses.addAll(zsetResponses);
        responses.add(hashesResponses);
        return responses;
    }

}

Lo primero que nos puede llamar la atención es que lanzamos el comando SCAN por cada uno de los nodos maestros, utilizando una conexión directa a ellos en lugar de utilizar la conexión general con el clúster de tipo JedisCluster. Esto se debe a que Jedis no nos permite hacer un scan con el patrón * contra el clúster en general.

El algortimo, a medida que va obteniendo los lotes de claves recuperados por el comando SCAN, se encarga de a través del comando MEMORY USAGE, incluido a partir de Redis 4.0.0, de obtener el uso de memoria en bytes de cada elemento. Esto incluye tanto el valor o valores que almacena, como lo que implica almacenar su clave. Jedis, al menos en la versión que estoy utilizando a día de hoy, no incluye la operación MEMORY USAGE, como alternativa, utilizo la operación EVAL, a la que le podemos pasar un script LUA para ser ejecutado en el nodo Redis. En este caso enviamos un script que devuelve el resultado de ejecutar el comando MEMORY USAGE sobre una clave concreta. He concatenado el string que forma el script con la operación +, pero podríamos usar un StringBuilder como mejor práctica.

Una vez que tenemos el uso de memoria, obtenemos el tipo de la clave con el comando TYPE. Si el TYPE nos indica que se trata de un zset, obtenemos todas las claves que contiene. Igual que ocurre con el comando KEYS, debemos procurar no usar el comando ZRANGE, ya que si tenemos muchas claves agrupadas en dicho zset podremos volver a tener problemas de bloqueo. Como alternativa disponemos del comando ZSCAN, cuyo funcionamiento es el mismo que el del SCAN pero sobre un zset concreto. No obstante, si vemos que se están generando un número de claves excesivo asociadas a un zset, puede que tengamos que analizar lo que estamos cacheando y cómo, porque es posible que no lo hayamos planteado correctamente, ya que si hay muchas combinaciones de parámetros de entrada también es posible que los hit de cachés sean bajos o casi inexistentes, con lo que realmente no nos aporta la manera en que se está cacheando.

Las claves de tipo string serán las claves agrupadas por los zsets. En el ejemplo, estas claves son muchas, y devolverlas todas como respuesta del programa puede ser un problema, además, usando Redis como sistema de caché con Jedis, estas claves se almacenan como array de bytes si usamos el serializador por defecto, por lo que mostrarlas no nos aporta mucha información. Lo mejor es obtener lo que ocupan y luego acumular todos esos valores de uso de memoria en el zset al que pertenecen.

Finalmente, de las claves de tipo hash obtenemos al igual que con los string solo lo que ocupan, para luego acumular todos estos valores e indicar que es el espacio total que está ocupando en un momento dado la gestión de sesiones. Las sesiones caducan, por lo que desaparecen y aparecen en Redis constantemente, a parte de que pueden ser muchas, por lo que tampoco aporta mucho devolver las claves concretas.

Como vemos, para montar la respuesta del programa, por un lado sumamos todo el uso de memoria que implican los hashes y lo agrupamos en un mismo elemento, sin embargo, como la lógica de la construcción de la respuesta de los elementos zset es un poco más compleja, se ha implementado en la siguiente clase:

package com.autentia.redis.response;

import com.autentia.redis.model.RedisZset;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class ZsetResponseBuilder {

    public static List build(List zsets, Map stringKeysWithMemoryUsage) {
        List zsetResponse = new ArrayList();
        zsets.stream().forEach(zset -> {
            long memoryUsage = zset.getMemoryUsage();
            memoryUsage += zset.getZsetKeys().stream().mapToLong(key -> {
                Long zsetKeyMemoryUsage = stringKeysWithMemoryUsage.get(key);
                return zsetKeyMemoryUsage != null ? zsetKeyMemoryUsage : 0;
            }).sum();
            ScannerResponse response = new ScannerResponse(zset.getKey(), "zset", memoryUsage);
            zsetResponse.add(response);
        });

        return zsetResponse;
    }
}

El builder se encarga de acumular el espacio de memoria utilizado por el propio zset con el utilizado por cada una de sus claves en un mismo valor.

De cara a construir la respuesta utilizamos una lista de la siguiente clase:

package com.autentia.redis.response;

public class ScannerResponse {

    private final String key;

    private final String type;

    private final long memoryUsage;

    public ScannerResponse(String key, String type, long memoryUsage) {
        this.key = key;
        this.type = type;
        this.memoryUsage = memoryUsage;
    }

    public String getKey() {
        return key;
    }

    public String getType() {
        return type;
    }

    public long getMemoryUsage() {
        return memoryUsage;
    }
}

La clase de modelo utilizada para guardar la información de los zset (su clave, su uso de memoria sin incluir el usado por sus claves, y las claves de tipo string que agrupa) es la siguiente:

package com.autentia.redis.model;

import java.util.List;

public class RedisZset {

    private final String key;

    private final List zsetKeys;

    private final long memoryUsage;

    public RedisZset(String key, List zsetKeys, long memoryUsage) {
        this.key = key;
        this.zsetKeys = zsetKeys;
        this.memoryUsage = memoryUsage;
    }

    public String getKey() {
        return key;
    }

    public List getZsetKeys() {
        return zsetKeys;
    }

    public long getMemoryUsage() {
        return memoryUsage;
    }
}

Aquí os dejo un ejemplo de la salida del programa:

[
   {
      "key":"zset4",
      "type":"zset",
      "memoryUsage":7312131
   },
   {
      "key":"zset5",
      "type":"zset",
      "memoryUsage":7872131
   },
   {
      "key":"zset1",
      "type":"zset",
      "memoryUsage":7312131
   },
   {
      "key":"zset9",
      "type":"zset",
      "memoryUsage":7472131
   },
   {
      "key":"zset0",
      "type":"zset",
      "memoryUsage":7632131
   },
   {
      "key":"zset8",
      "type":"zset",
      "memoryUsage":7472131
   },
   {
      "key":"zset3",
      "type":"zset",
      "memoryUsage":7712131
   },
   {
      "key":"zset7",
      "type":"zset",
      "memoryUsage":7632131
   },
   {
      "key":"zset6",
      "type":"zset",
      "memoryUsage":7472131
   },
   {
      "key":"zset2",
      "type":"zset",
      "memoryUsage":7632131
   },
   {
      "key":"Hashes for sessions",
      "type":"hash",
      "memoryUsage":48188890
   }
]

5. Alternativas

Algunas alternativas para hacernos una idea de lo que están ocupando nuestras claves en Redis y detectar problemas pueden ser por ejemplo, el acceder a cada uno de los nodos del clúster a través del cliente de consola de Redis redis-cli con la opción –bigkeys. Esta opción realiza un escaneo de claves, también de una manera iterativa con el comando SCAN. Y nos proporciona información del número total de claves entontradas organizadas por tipos, el porcentaje del total de claves y la media de espacio que ocupan. También nos dicen que claves son las que más ocupan por tipo. No nos ofrece información de todas las claves pero nos puede indicar que por ejemplo existe un zset, el mayor, que contiene 600000 miembros. Quizás será una caché que debemos analizar.

Ya he comentado la posibilidad de lanzar un script LUA contra cada uno de los nodos del clúster con el comando EVAL. Yo he hecho un pequeño script:

function string.tohex(str)
    return (str:gsub('.', function (c)
        return string.format('%02X', string.byte(c))
    end))
end

local keys = {}; 
local done = false; 
local cursor = "0"; 

repeat 
	local result = redis.call("SCAN", cursor); 
	cursor = result[1]; 
	for i, key in ipairs(result[2]) do
		table.insert(keys, key); 
	end
until cursor == "0"; 

local memoryUsages = {}
local types = {}
for i, key in ipairs(keys) do
	local memoryUsage = redis.call("MEMORY", "USAGE", key);
	table.insert(memoryUsages, memoryUsage);
	local type = redis.call("TYPE", key).ok;
	table.insert(types, type); 
end

local responses = {}
for i = 1, #keys do
	if types[i] == "zset" then
		local response = types[i] .. ", " .. keys[i] .. ", " .. memoryUsages[i]
		local zsetKeys = redis.call("ZRANGE", keys[i], 0, -1)
		response = response .. ","
		for i, key in ipairs(zsetKeys) do
			response = response .. " " .. string.tohex(key)
		end
		table.insert(responses, response);
	elseif string.match(keys[i], "spring:session") then
		local response = types[i] .. ", " .. keys[i] .. ", " .. memoryUsages[i]
		table.insert(responses, response);
	else
		local response = types[i] .. ", " .. string.tohex(keys[i]) .. ", " .. memoryUsages[i]
		table.insert(responses, response);
	end
	
end

return responses

Os pido por favor que no lo ejecutéis en entornos productivos por dos motivos principales, el primero, como véis se utiliza el comando ZRANGE para obtener las claves asociadas a un zset, que como ya hemos dicho no es recomendable, y el segundo que ya de por sí es un script pesado y mientras se esté ejecutando bloquearemos el clúster. Si esto os ocurriese os recomiendo que lancéis el comando SCRIPT KILL sobre el nodo bloqueado. Simplemente lo comparto para que tengaís una referencia de como se podría hacer un escaneo de claves en un nodo con LUA.

6. Conclusiones

Lo que creo que debe quedar claro con este tutorial, es que debemos tener cuidado al intentar obtener información de todas las claves que se almacenan en nodos Redis. Las primeras opciones siempre deben ser las propuestas por la herramienta, como el comando INFO, en particular en la sección MEMORY donde encontraremos el uso de memoria por parte de las claves, el uso de memoria por parte del servicio Redis en general, los picos de memoria, niveles de fragmentación, etc, o la opción –big keys vista anteriormente.

Elastic también proporciona beats con métricas a explotar y visualizar en Kibana, este beat hace uso del comando INFO.

Si necesitamos obtener información más completa de las claves hay que procurar consumirlas siempre de manera iterativa con el comando SCAN y similares, tal y como se ha visto en este tutorial. Si vamos a usar este tipo de programas para automatizar la monitorización debemos tener cuidado con la frecuencia con la que se lanzarán.

¡Espero que esta información os haya resultado útil!

7. Referencias

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad