viernes, 31 de mayo de 2013

Mantener actualizados los índices del Sphinx

   Una vez instalado Sphinx, y configurados los índices, me he encontrado con un problema habitual. ¿Cómo mantener actualizada la información de dichos índices en "tiempo real" (o lo más parecido posible)?.
   Hay entornos en los que una demora de minutos u horas no tiene mayor importancia. Pero en otros, supongamos una aplicación de archivo, es imprescindible que una vez creada una entrada, ésta salga en las búsquedas.
   La primera aproximación "lógica", sería hacer que mi aplicación (PHP) usara los métodos que proporciona el API de Sphinx para ir actualizando los contenidos.
   Ya tenemos nuestro primer problema de consistencia. A largo plazo es imposible que todo sea coherente. Supongamos que mi aplicación tiene ya un volumen importante y no puedo garantizar que siempre se notifique. Y aunque se hiciera, no puedo garantizar que dicha notificación ha cumplido correctamente con su cometido. Imaginemos una aplicación en la que hay varios sistemas interactuando entre si (incluso varios servicios o demonios independientes). No puedo obligar a todos,(y confiar en que así sea) a que mantengan los índices actualizados.

   Está claro que dicha actualización debe ser transparente y asumida por un único actor. Si conseguimos trasladar todo el trabajo a la base de datos, podemos despreocuparnos, y seguir trabajando como hasta ahora.
   Resulta casi obvio, llegados a este punto, que tenemos que hacer uso de los Triggers para UPDATE, INSERT y DELETE en las tablas que contienen la información que debe estar actualizada en los índices.

   Para un entorno como el que estoy proponiendo, lo normal será querer lanzar una página php que se encargue de actualizar los índices cada vez que salte uno de éstos eventos.
Para poder lanzar dicha página, tenemos que meternos en los UDF, o funciones definidas por el usuario. MySQL permite que programemos en C o C++ funciones, y que luego hagamos uso de ellas en las querys.
   Por suerte, ya hay unas funciones hechas para lanzar aplicaciones del sistema (cuidado con los potenciales peligros que puede conllevar esto). Se trata de la función sys_exec. Está incluída en la librería lib_mysqludf_sys
Se puede descargar y compilar, pero si estamos en windows y buscamos algo más sencillo, también puede descargarse la dll aquí. Debemos copiar dicha librería en la carpeta lib/plugin/ de nuestro servidor mysql (si tenemos la configuración por defecto).
A continuación debemos ejecutar el siguiente script mysql
 
DROP FUNCTION IF EXISTS sys_exec;
CREATE FUNCTION sys_exec RETURNS STRING SONAME 'lib_mysqludf_sys.dll';


Una vez que tenemos el UDF necesario para lanzar el PHP, podemos hacer una prueba:
DELIMITER @@
CREATE TRIGGER Test_Trigger
AFTER INSERT ON mitabla
FOR EACH ROW
BEGIN
 DECLARE cmd CHAR(255);
 DECLARE result int(10);
 SET cmd=CONCAT('echo ', NEW.id, ' ', NEW.texto, ' > c:\\out.txt');
 SET result = sys_exec(cmd);
END;
@@
DELIMITER ;

Veremos que al hacer un insert en mitabla, se genera un txt en c: (windows) con el contenido de los campos id y texto de dicha tabla.

Con esta misma función podríamos lanzar el PHP con algo parecido a esto
sys_exec(CONCAT('php /var/www/test/actualizar.php ', NEW.id));
(Póngase la ruta correspondiente del ejecutable php y del script)
 

Pero aquí viene un gran problema. El trigger que lanza MSQL es síncrono, y debe ser así para poder garantizar que la operación es atómica, esto es, que nadie se me va a colar a mitad de inserción. Es importante entender esto, porque si ahora desde actualizar.php recojo el id, y trato de hacer un select sobre la tabla, no encontraré el registro (porque el insert aún no ha acabado, el registro está bloqueado, y estoy tratando de acceder desde otra instancia del cliente Mysql).

Una vez planteado el reto, estas son las soluciones:

  • La mejor opción, es usar una herramienta como gearman (hay muchas otras). Esta solución está propuesta en http://sphinxsearch.com/forum/view.html?id=9823
    Gearman básicamente es una herramienta que se encarga de realizar tareas. Se le piden las cosas y él se encarga de encolarlas e ir lanzándolas (permite además hacerlo de forma distribuida). Si alguna tarea falla se encargará de volver a intentarlo...
    Si nuestro trigger encola la tarea, podrá acabar la operación de INSERT y cuando el proceso php llegue lanzado por Gearman encontrará el registro y podrá leerlo.
    No voy a hacer incapié en ella, a pesar de ser la mejor porque ya hay mucha documentación para desarrollarlo.
  • Si buscamos algo más sencillo y no queremos entrar en instalar herramientas de este tipo, lo ideal sería pasar al PHP como argumentos todos los campos del registro y poder actualizar los índices del Sphinx sin tener que hacer un SELECT previo.
    Esto plantea algunos problemas:
    • Es incómodo pasar todos los campos (y cada uno puede ser de un tipo diferente).
    • Tenemos el INSERT esperando a que finalice la ejecución del PHP, por lo que notaremos un decremento de rendimiento de la Base de datos (habrá que evaluar el impacto en cada escenario) .
  • Pero habría una tercera solución intermedia, ni tan buena como la primera ni tan mala como la segunda, y puede ser suficiente para muchos.
    La idea consiste en que el trigger genere una entrada en una tabla "actualizaciones_pendientes". Y que después un evento de Mysql se encargue de leer las actualizaciones pendientes y vaya lanzando scripts PHP con el ID, para que vayan actualizando los índices. Veamos cómo hacerlo:

Creamos la tabla actualizaciones_pendientes:
CREATE TABLE actualizaciones_pendientes(
   id int not null auto_increment PRIMARY KEY,
   id_actualizar int not null,
   tipo_actualizacion ENUM('INSERT', 'UPDATE', 'DELETE') not null
);

Generamos el trigger, con algo parecido a esto:
DELIMITER @@
CREATE TRIGGER Insert_mitabla_Trigger
AFTER INSERT ON mitabla
FOR EACH ROW
BEGIN
 INSERT INTO actualizaciones_pendientes(id_actualizar, tipo_actualizacion) VALUES (NEW.id, 'INSERT');
END;
@@
DELIMITER ;
Y generaríamos triggers equivalentes para los UPDATES y DELETES.

Por último generaremos eventos de Mysql (es equivalente a un cron), para que vayan lanzado los PHP pendientes. Podemos hacer que el evento salte cada más o menos tiempo en función de nuestras necesidades. Lo ideal sería escoger el tiempo más alto posible que cumpla nuestras necesidades, ya que los procesos PHP que va a lanzar pueden ser algo pesados.
Para darlos de alta recomiendo visitar la documentación oficial
Pero para probar podemos ejecutar directamente (luego habrá que configurar el MySQL para que la global esté fija).
set global event_scheduler = ON;
y al hacer
show processlist;
Veremos el proceso event_scheduler lanzado.

Nos creamos un evento de este estilo (sintaxis aquí):

CREATE EVENT Actualizar_Pendiente_Event
   ON SCHEDULE EVERY 10 SECOND
   COMMENT 'Lee de actualizaciones_pendientes y lanza los PHP correspondientes'
   DO DELETE FROM actualizaciones_pendientes;

Y lanzamos un trigger al eliminar dichas entradas:
CREATE TRIGGER Eliminar_Pendientes_Trigger
BEFORE DELETE ON actualizaciones_pendientes
FOR EACH ROW
BEGIN
 DECLARE cmd CHAR(255);
 DECLARE result int(10);
 SET cmd= CONCAT('php /var/www/test/actualizar.php ', OLD.id_actualizar, ' ', OLD.tipo_actualizacion);
 SET result = sys_exec(cmd);
END;

Ya tenemos el proceso completo. De esta forma podemos trabajar directamente en nuestra aplicación, como si no tuvieramos Sphinx y este se mantendrá actualizado. Además hemos llevado el proceso "lento" a unos triggers que se lanzan en otras instancias y en background.
  • Se hace una modificación en un registro que debe actualizarse en el Sphinx
  • Salta un trigger que hace una inserción en una tabla de pendientes
  • Ciclicamente se recorre dicha tabla eliminando sus registros
  • Al eliminar dichos registros se lanza un PHP que actualiza los índices.

No hay comentarios:

Publicar un comentario