De MySql Master-Master a Percona XtraDB Cluster y un toque de optimizaciones

El 16 de Febrero del 2018 se hizo pública la rama de desarrollo de Ivoz Provider 2.0 (Artemis), la solución open source de telefonía VoIP para operadores de Irontec. Aproximadamente año y medio después de 1.0 (Oasis) y aún con mucho trabajo por delante, habíamos dado el primer paso de una release ambiciosa: teníamos luz verde para trabajar en un producto más estable, más seguro y más potente.

Oasis se desplegaba sobre una configuración master-master de MySQL que, más allá de algún caso aislado de replicaciones rotas, cubría nuestras necesidades razonablemente bien. Aún así, tratándose de un componente tan crítico para nuestra solución, decidimos que Artemis se ejecutaría sobre un cluster de tres nodos SQL. Buscábamos darle a la solución mayor rendimiento, más estabilidad, disponibilidad y minimizar problemas derivados de la caída de un nodo.

Este último punto es importante para nosotros, Ivoz Provider es una solución que da servicio 24×7. Una intervención técnica que pueda afectar el servicio requiere ser realizada de noche, cuando el número de potenciales afectados es menor, y ni siquiera así es fácil de negociar con el cliente. El cambio estaba justificado y la decisión tomada.

En este artículo busco compartir mi experiencia como developer. Lo aprendido por el camino trabajando por primera vez con Percona XtraDB Cluster en una solución con requisitos de disponibilidad altos, bases de datos grandes y ventanas de intervención pequeñas respecto a los proyectos en los que había trabajado previamente.

Índice:

  1. Elegir candidato
  2. Ready to go
  3. Selects masivos
  4. Deadlocks y lock wait timeouts: Batch inserts / Mass deletes & updates
  5. Rotado de datos
  6. Rotar no equivale a liberar espacio
  7. Índices
  8. Cambios de esquema (DDL) sin caída de servicio
  9. Bonus track: Flow control
  10. Conclusiones

 

Elegir candidato

No es mi intención hacer una comparativa de las distintas soluciones del mercado, simplemente diré que partiendo de MySQL los candidatos más obvios son MySQL Group Replication, Galera Cluster y Percona XtraDB Cluster. MySQL Group Replication es la «nueva», Galera la solución clásica para clustering en MySQL y XtraDB se construye sobre Galera. Todos ellos comparten los siguientes puntos:

  • Casos de uso:
    • Entornos con requisitos de disponibilidad altos.
    • Escrituras sobre múltiples nodos garantizando la consistencia de datos.
    • Tiempos de failover reducidos.
  • Funcionalidades:
    • Alta disponibilidad.
    • Replicación multi-master.
    • Consistencia (Cero perdida de datos).
    • Optimistic locking (El primer commit gana).
    • Quorum / Prevención de split brain.
    • Nodos: unirse/abandonar el cluster automáticamente.
    • Todos los nodos tienen todos los datos, las lecturas son locales, las escrituras globales.
  • Limitaciones:
    • Transacciones grandes:
      • Mayor probabilidad de fallo. Cuanto más grande sea la transacción más probable el conflicto entre escrituras.
      • Replicación poco efectiva, mayor overhead de red.
    • Actualizar los mismos registros al mismo tiempo no es posible
    • Aplicar todas las escrituras en el mismo nodo suele ser la mejor opción
    • Rendimiento sensible a latencias

Nosotros optamos por Percona XtraDB Cluster. Nos encajaba lo que ofrecía, teníamos buenas referencias de la empresa y era un producto maduro. Además, el departamento de sistemas de Irontec ya había trabajado con XtraDB. En caso de necesitarlo podíamos apoyarnos en ellos, eso daba tranquilidad y lo convertía en la opción más conveniente. Entre las funcionalidades y limitaciones especificas de XtraDB destacan:

  • Funcionalidades:
    • Escalabilidad alta de lecturas y escrituras respecto al resto de candidatos.
    • Modo mantenimiento del cluster a través de ProxySQL.
    • Provisión automática de nodos:
      • Incremental Snapshot Transfer: XtraDB guarda un log binario de las últimas transacciones, el tamaño es modificable vía configuración. Cuando un nodo se queda fuera del cluster y se reincorpora tiempo después, se sincronizará con el resto de nodos leyendo las transacciones pendientes del binlog.
      • State Snapshot Transfer: Cuando el binary log no cubre la brecha entre los últimos registros del nodo y el cluster, o simplemente se trata de un nuevo nodo, se transfiere un snapshot completo.
  • Limitaciones:
    • Replicación síncrona, aplicación asíncrona:
      • En galera y XtraDB todos los nodos tienen que dar el OK a todas las escrituras. Actualizar un millón de rows significa compartir dicha transacción con el resto de nodos, esperar a que la analicen y verifiquen que no hay conflictos para aplicarla. Una vez todos dan el OK, esta se aplica. Ya sea por que el load de un nodo es más alto que en el resto o cualquier otro motivo, un nodo tardar más que el resto en la aplicación del cambio.
    • Previene que un nodo se quede demasiado atrás respecto al cluster:
      • Cuando un nodo cruza el threshold (por defecto, 100 transacciones de escritura) comunica al resto de nodos que pausen las escrituras (flow control) hasta que vuelva a estar en rango.

Latencias y Rendimiento

Tanto intercambio de información entre nodos hace que unas malas latencias echen por tierra el rendimiento de todo el cluster. Hagamos un pequeño test de laboratorio que mide los tiempos de 1000 inserts en un cluster de tres nodos XtraDB:

nodo3# time for i in {1..1000}; do mysql -h 127.0.0.1 -e "insert into tp_cdrs values(null, uuid(), '*raw', '127.0.0.1', '',
uuid(), '*voice', '*postpaid', 'b0', 'call', 'c0', 'c0', '+34916708082', now(), now(), '3000000000', '{}', '', -1, 'null', '', now(), now(), null);"; done;  

real    0m20,663s
user    0m6,028s
sys     0m2,916s
nodo1# tc qdisc add dev ens19 root netem delay 14ms
nodo3# time for i in {1..1000}; do mysql -h 127.0.0.1 -e "insert into tp_cdrs values(null, uuid(), '*raw', '127.0.0.1', '', 
uuid(), '*voice', '*postpaid', 'b0', 'call', 'c0', 'c0', '+34916708082', now(), now(), '3000000000', '{}', '', -1, 'null', '', now(), now(), null);"; done;

real    0m36,477s
user    0m6,068s
sys     0m3,028s

Añadir 14 milisegundos de latencia casi duplica tiempos.

 

Ready to go

Listo, teníamos nuestra primera instancia con la nueva solución de base de datos y éramos felices. El esquema de datos no requería ningún cambio. La aplicación no requería ningún cambio. Los costes de hardware habían subido un poco pero era perfectamente asumible. Lo que viene siendo coser y cantar, ¿verdad? Not so easy… aún no lo sabíamos pero venían curvas, para variar. Siempre hay un trade off, ganas algo y pierdes algo. Vamos de menos a más.

 

Selects masivos

Este punto está relacionado con el tamaño del dataset y es aplicable a cualquier base de datos SQL, cluster o no. Por ponernos en contexto, algunos de nuestros clientes generan entre 10 y 15 GB de datos de llamadas al mes entre sus distintas marcas (Ivoz Provider es una solución multi-tenant de doble nivel, multi-marca y multi-cliente, donde cada marca es independiente al resto y tiene por tanto sus propios clientes y operadores). A nada que la instancia tenga un par de años te ves continuamente filtrando resultados entre cientos de gigas y, en función de la consulta, el proceso puede llegar a ser muy lento.

La solución cuenta con algunas funcionalidades que trabajan con conjuntos de datos razonablemente grandes, exportar las llamadas a CSV de manera programada por poner un ejemplo. Los criterios son configurables: ejecutar cada semana, a final de mes, para un tipo especifico de llamadas, filtrado por marca o cliente, etc. La funcionalidad debe ser útil porque no son pocos los clientes que hacen uso de ella.

En este caso, las consultas van contra la tabla más grande de aplicación aunque tratándose de una tarea programada y asíncrona también tiene sus ventajas; podemos ejecutarla por las noches cuando la carga del sistema es más baja. Aún y con todo, pronto nos dimos cuenta que el proceso había empezado a tardar horas. Obviamente lo primero fue revisar que los índices de la tabla eran correctos y que cubrían los escenarios más habituales. No fue suficiente.

Resulta que estábamos cometiendo un error de base. Para no traernos cientos de miles de llamadas a la vez en aplicación (mapearlas vía ORM, crear sus instancias y consumir varios gigas de memoria), estábamos solicitando los registros de forma paginada, de 5.000 en 5.000 por decir algo. No sé vosotros pero yo este diseño lo he aplicado toda la vida y jamás me ha dado problemas. ¿Por qué? Porque el dataset de partida era mucho más pequeño y la base de datos iba sobrada. ¿Entonces nos resignamos a que tarde horas? No, reflexionemos sobre la consulta en cuestión:

  • Criterios:
    • Filtrado: mes de enero, llamadas salientes de la marca X.
    • Orden y paginado: ordenado por fecha, 5.000 resultados a partir del 75.000.
  • Query behind the scenes:
    • La base de datos lee del índice que parece más conveniente como punto de partida; índice de fecha o de marca en este caso.
    • Selecciona los resultados del índice que nos interesan.
    • Filtra sobre los resultados previos: tipo de llamada saliente y fecha/marca (en función del índice de partida).
    • Ordena los resultados.
    • Nos devuelve el subconjunto solicitado.

El hecho de solicitar datos paginados, a pesar de que los vamos a terminar solicitando todos, implica para la base de datos aplicar la misma consulta cientos o miles de veces sobre decenas de gigas de datos. A medida que el dataset crecía de forma natural con el uso de la aplicación, los tiempos eran más y más sangrantes. Esto solo iba a empeorar. Borrar registros hubiese ayudado pero implicaba negociar uno por uno con los clientes y cruzar los dedos para que aceptasen. La solución pasaba por traernos todos los datos de una vez. ¿Y el consumo de memoria que mencionábamos antes? Fácil, while(nextRow) [ivoz-core/UnpaginatedResultGeneratorExtension.php], parece mentira lo rápido que nos olvidamos de los good old classics. Y se hizo el milagro y los procesos de exportar llamadas a CSV volvieron a tardar unos pocos minutos y fuimos felices.

Disclaimer para programadores PHP: El driver de MySQL usa «Buffered mode» por defecto. Esto significa que se traerá todo el dataset y lo alojará en la memoria del proceso PHP. Esto para nuestro caso de uso no vale y tendremos que desactivarlo: `$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);`. En caso de usar Doctrine ORM, se complica un pelín más porque la instancia está encapsulada dentro del modelo Connection de la librería y tendremos que recurrir a malas artes tipo Reflection para salir del paso. Aquí [ivoz-core/EntityManager.php] tenéis nuestra implementación por si os es de ayuda.

 

Deadlocks y lock wait timeouts: Batch inserts / Mass deletes & updates

Y llegamos a la primera complicación especifica del cambio a un cluster SQL, la probabilidad de que surjan problemas de locks aumenta y mucho. Conocíamos la recomendación de apuntar todas las escrituras al mismo nodo y lo estamos haciendo bien desde el día uno. Entonces, ¿por qué teníamos tantas transacciones fallidas? El problema era más real de lo que imaginábamos y nos estaba dando trabajo absurdo: generación de facturas que fallaban y tenían que ser relanzadas y supervisadas por nosotros, horas perdidas de manera sistemática a primeros de cada mes en tareas cero gratificantes. Hora de leer.

A lo largo de una transacción SQL, InnoDB aplica candados en los registros sobre los que estamos aplicando escrituras (Pessimistic locking). Esto garantiza que una segunda transacción no modifique esos registros hasta que la primera haya terminado, bien sea con un commit o un rollback. Si no iniciamos una transacción de manera explicita cada una de las queries se comporta como transacciones de un único statement. Este diseño garantiza la integridad de datos pero implica que si la query 1 tiene el lock sobre el id 100 de la tabla Companies y una segunda transacción intenta aplicar otra escritura sobre el mismo registro, tiene que optar por alguna de la siguientes opciones:

  • Esperar a que la transacción número 1 termine (esto puede acabar en timeout) y después, solo después, ver si su modificación es compatible con la primera.
  • En función del lock, desistir y lanzar una excepción (locks circulares y tal).

La alternativa a esto es «Optimistic locking«. Consiste en no aplicar locks y confiar que todo está bien hasta el commit. Será en este momento cuando se verifique que el registro no ha sido modificado desde el inicio de la transacción y no hay conflictos de ningún tipo. Si está premisa no se cumple, abortar misión.

Las escritura en Galera, y por extensión en XtraDB, hacen uso de «Pessimistic locking» en el nodo al que apuntamos la escritura y «Optimistic locking» a lo largo del cluster. Es decir, el comportamiento no cambia respecto a un único nodo MySQL hasta que se busca el OK del resto de cluster para aplicar la sentencia. La principal razón por las que esto es así es que compartir la información de todos los locks de todos los nodos por red, en tiempo real, sería costoso y haría las escrituras mucho más lentas.

Conociendo el diseño, apuntar todas las escrituras al mismo nodo tiene sentido. Pero eso ya lo estamos haciendo y seguimos con problemas… ¿Se debe a que por el hecho de que las transacciones se propagan por el cluster antes de ser aplicadas los tiempos de lock son mayores? Habría que analizarlo caso a caso pero la solución pasa por:

  1. Mantener las transacciones pequeñas:
    • Cuanto menos tarden en ejecutarse mejor (el tiempo entre start transaction y commit juega en tu contra).
    • Cuantos menos registros se modifiquen mejor (menos locks involucrados). En caso de mass updates nosotros optamos a veces por resolver los ids primero y lanzar el update después. También sería posible hacer pequeños chunks con esos ids y lanzar múltiples sentencias en lugar de una única. Si conoces otras estrategias me encantaría de leerlas en los comentarios ;). Tenéis un ejemplo aquí  pero se resumen en:
      • Select id from X where XX.
      • Update X where id in (XX).
  2. En caso de fallo, insistir:

WARNING: A estas alturas tal vez hayas caído en la cuenta, pero por si acaso, mucho cuidado con los ON DELETE CASCADE en tablas grandes.

 

Rotado de datos

Una llamada en Ivoz Provider implica escrituras en múltiples tablas (CDRs, sistema de tarificación…). Todas ellas son procesadas y sintetizadas después en su tabla final. Una vez finalizado este proceso, o como mínimo una vez la llamada ha sido emitida en una factura, estos registros temporales pueden ser eliminados (realmente nosotros los conservamos 4 meses, just in case).

Como habrás podido imaginar, si un update de 10.000 rows puede dar problemas, un delete de varios millones lo tiene casi garantizado. Recordemos que nosotros estamos obligados a tener mucho cuidado con no afectar el servicio más allá de lo estrictamente necesario y ahora el cluster nos exige ser aún más sutiles (no vaya a ser que un nodo se quede muy atrás y se bloqueen todas las escrituras, por decir algo…). No queda otra que ir poco a poco: borro 500 registros, espero un poco, borro un poco más… ¿Me hago un script? ¿Mejor a mano? Ni una ni otra, el script ya existe y funciona bien 🙂

Dentro del paquete percona toolkit se incluye la herramienta pt-archiver. Si me preguntas a mi, una maravilla. Es posible configurar la condición de borrado, retries, checks de lag, etc.

¡A rotar se ha dicho! Lo primero será hacer un backup de los registros que queremos eliminar no vaya a ser que la preparemos:

mysqldump --single-transaction=TRUE --no-create-info ${DATABASE} ${TABLE} --where "${WHERE}" > ${BACKUP_FILE}

¡Ojo cuidau! que la parte de –single-transaction es crítica. Por defecto mysqldump bloquea las tablas contra escritura. Tiene sentido, queremos un dataset consistente. De nada sirve que mientras hacemos el dump de tabla2 alguien haga una insert en las tablas 1 y 3 donde tabla3 tiene una foreign key a un nuevo registro de tabla1. Para cuando nuestro dump llegue a la tabla3, tabla1 ya no nos vale por inconsistencia de datos.

¿La alternativa? Jugar con transacciones. En InnoDB las transacciones son por defecto REPEATABLE READ, es decir, no vemos los cambios hechos por terceros hasta el cierre de transacción. Esto sirve para crear backups consistentes sin necesidad de recurrir a locks y es exactamente lo que hace el flag «–single-transaction».

Rotando con pt-archiver:

$ pt-archiver \
--purge \
--columns=id \
--progress=1000 \
--bulk-delete \
--limit 100 \
--run-time ${MAX_RUN_TIME} \
--why-quit \
--sentinel /opt/ivozprovider/storage/pt-archiver.sentinel \
--source h=data.ivozprovider.local,D=${DATABASE},t=${TABLE},i=${INDEX} \
--where "${WHERE}"

 

Rotar no equivale a liberar espacio

¡Ya tenemos nuestra base de datos libre de registros innecesarios! Éxito total 👌, espera… ah no no no ¿Por qué sigue ocupando 150 gigas en disco? Si amigo, InnoDB prácticamente no libera espacio en el DELETE (no es un comportamiento exclusivo de MySQL en el mundo de las bases de datos). ¿Entonces? Pues estamos obligados a hacer un OPTIMIZE TABLE. Y podría ser peor, al menos desde la versión 5.6 la setting `innodb_file_per_table` viene activada por defecto. Sin esto liberar espacio pasa por hacer un backup de la base de datos y reimportarla. Una maravilla.

Vale, calma, solo queda un paso, ya casi lo tenemos, un mero tramite ¿No? Bueno… OPTIMIZE TABLE bloquea las tablas a nivel de cluster y puede tardar un buen rato en función del tamaño de esta.

 


De momento con que sepas que esto ocurre es suficiente, ya lo abordaremos en Cambios de esquema (DDL) sin caída de servicio.

 

Índices

Hablemos de índices. Estas son algunas de la implicaciones que tienen los índices y que todos hemos leído o escuchado en algún momento de la vida o sencillamente son de sentido común:

  • Los índices penalizan el rendimiento de las escrituras.
  • Los índices duplicados no tienen ventaja alguna y son dañinos, especialmente en entornos de escrituras intensivas.
  • Los indices se utilizan para optimizar queries con sentencias WHERE, ORDER BY, GROUP BY y MIN/MAX.
  • Requieren espacio en disco.

Vale, requieren espacio en disco ¿Pero cuánto? En función de la tabla… mucho. Tanto o más que los datos.

SELECT table_name AS `Table`, round(((data_length + index_length) / 1024 / 1024), 2) `Total Size in MB`, round((data_length / 1024 / 1024), 2) AS `Data Size in MB`, round((index_length / 1024 / 1024), 2) AS `Index Size in MB` FROM information_schema.TABLES ORDER BY (data_length + index_length) DESC limit 10;
+----------------------+------------------+-----------------+------------------+
| Table                | Total Size in MB | Data Size in MB | Index Size in MB |
+----------------------+------------------+-----------------+------------------+
| tp_cdrs              | 22863.75         | 19909.00        | 2954.75          |
| BillableCalls        | 5914.78          | 2915.98         | 2998.80          |
| kam_users_cdrs       | 5348.17          | 2385.98         | 2962.19          |
| kam_trunks_cdrs      | 3209.55          | 1160.00         | 2049.55          |
| Changelog            | 3171.73          | 2320.84         | 850.89           |
| sm_costs             | 897.70           | 846.91          | 50.80            |
| tp_destination_rates | 684.47           | 350.81          | 333.66           |
| tp_destinations      | 665.56           | 175.70          | 489.86           |
| tp_rates             | 644.17           | 238.27          | 405.91           |
| Destinations         | 450.00           | 326.80          | 123.20           |
+----------------------+------------------+-----------------+------------------+
10 rows in set (0.11 sec)

Es bueno recordar que MySQL hace uso de un único índice por consulta y tabla. Es decir, podemos poner un índice a cada una de las columnas de la tabla si queremos pero solamente se utilizará uno en la consulta, el que MySQL considere más conveniente. La única excepción es Index Merge Optimization que se aplica a veces cuando hay una condición OR o filtros de rango involucrados.

Dicho esto, si sabemos que a una tabla se accede siempre aplicando una condición sobre tres columnas concretas, es preferible crear un índice combinado de tres columnas que tres índices independientes.

Los índices combinados no requieren que hagamos uso de todas sus columnas para ser utilizados pero el orden en el que estas se declaran dentro del índice importa: la condición solo es aplicable de izquierda a derecha. Pongamos un ejemplo:

  • Create index IDX(col1, col2, col3).
  • La condición «WHERE col1 = X AND col3 = Z» puede hacer uso del primer segmento del índice, col1.
  • La condición «WHERE col2 = Y AND col3 = Y» no hará uso del índice en absoluto.
  • La condición «WHERE col1 = X AND col2 = Y AND col3 = Z» puede hacer uso del índice al completo.
  • La condición «WHERE col1 = X AND col2 = Y ORDER BY col3» puede hacer uso del índice al completo.

Por lo tanto, un UNIQUE KEY uidx (col1,col2) hace, como mínimo, completamente innecesario otro índice mono-columna sobre col1.

Volviendo al ejemplo en el que accedemos a una tabla utilizando todas las columnas de un índice combinado, también es preferible ordenar las columnas por cardinalidad (porcentaje de valores únicos o poco repetidos) de izquierda a derecha. En un listado de llamadas lo más probable es que la fecha tenga mayor cardinalidad que el id de marca por ejemplo.

En cuanto al tipo de dato a indexar, no está de más recordar que los índices sobre columnas varchar ocupan la longitud máxima de estos: un índice en un varchar(255) utf8 ocupará 2×255 bytes por row.

Primary keys y terminamos: Todos los índices en InnoDB se componen del propio índice + primary key. La razón es que es necesario conocer la PK para acceder a los datos porque estos «viven» dentro de ella (clustered index). Como consecuencia, una búsqueda sobre primary key será siempre más rápida que sobre cualquier otro indice. Otra de las implicaciones de esto es que si la PK de una tabla ocupa 2 bytes, todos los índices creados sobre esa tabla ocuparán lo que requieran + 2 bytes. Mucho cuidado por tanto con crear primary keys varchar. Y si, UUID como PK tiene muchas ventajas pero rinde mal.

Disclaimer: Si no declaramos primary key, MySQL buscará el primer unique index cuyas columnas sean not null y lo usará como clustered index. En caso de no existir nada que cumpla los requisitos, creará un indice oculto donde almacenar los datos.

Ahora que sabemos como identificar índices que no aportan nada, podemos hacer un repaso manual a las tablas más grandes y delegar en una herramienta el resto (percona-toolkit one more time):

$ pt-duplicate-key-checker --databases ivozprovider

En nuestro caso, entre revisiones manuales y automatizadas, volaron más de 50 índices 🙂
Pruebas 1, 2 y 3.

 

Cambios de esquema (DDL) sin caída de servicio

Ya casi hemos terminado. De postre tenemos el punto más delicado de todos, cambios de DDL (Data Description Language), la madre del cordero. Si ya habíamos tenido que aprender a trabajar las escrituras de una determinada forma para no comprometer el servicio, un DDL no podía ser menos. Vamos con la teoría.

  • Los DDLs (ALTER TABLE para los amigos) cambian la estructura de la base de datos y no son transaccionales. Galera Cluster y XtraDB procesan los cambios de esquema de dos posibles maneras:
    • Total Order Isolation (TOI): Esta estrategia aplica los cambios en todos los nodos del cluster a la vez y es bloqueante. Los nodos esperan a que todas las transacciones previas sean ejecutadas y aplican después el DDL de forma sincronizada. No es posible hacer ningún COMMIT sobre el mismo recurso mientras un DDL está en ejecución, obtendrás un deadlock. En caso de que el commit vaya sobre una tabla distinta, la transacción esperará a que el DDL finalice para ser aplicada. WARNING: La sentencia es replicada en todos los nodos del cluster antes de ser ejecutada pero no hay forma de saber si un nodo ha fallado en la aplicación de la misma. ¿Significa esto que tendré que ir nodo a nodo verificando que el cambio se ha aplicado correctamente? Pues sí, a nosotros nos ha fallado un par de veces (de varios cientos). Algunos ORMs como doctrine hacen que este check resulte sencillo, basta con ejecutar `doctrine:schema:update –dump-sql` nodo por nodo y asegurarnos que no hay diferencias entre base de datos y modelos.
    • Rolling Schema Upgrade (RSU): Esta estrategia aplica los cambios solo en el nodo local y tendremos que ser nosotros lo que vayamos nodo a nodo aplicándola. Para ejecutar la sentencia, se entra en modo Donor/Desynced, aplica la query y vuelve a sincronizarse. Sobra decir que el cambio tiene que ser compatible con el esquema previo de base de datos: añadir un índice no es problema, añadir una nueva columna requerida sin valor por defecto sí.

Nosotros solo nos hemos salido de TOI (modo por defecto) para liberar espacio en disco vía OPTIMIZE TABLE , o más bien ALTER TABLE TableName ENGINE=InnoDB que viene a hacer lo mismo. Durante las actualizaciones de Ivoz Provider asumimos que puedan ocurrir pequeñas interrupciones de unos pocos segundos por la noche. El problema con OPTIMIZE es que el proceso tarda bastantes minutos (es proporcional al tamaño de la tabla) y eso ya no es aceptable.

Aproximación número 1: Sacar el nodo manualmente del cluster [FAIL]

La estrategia es poner el nodo en modo desync (evita el flow control) y desactivar la replicación de nuestra sesión. Leímos que este método era viable pero nosotros no fuimos capaces de hacerlo funcionar si había escrituras de por medio:

$ while [ true ]; do time mysql -h otro_nodo -e "insert into tp_cdrs values(null, uuid(), '*raw', '127.0.0.1', '', uuid(), '*voice', '*postpaid', 'b0', 'call', 'c0', 'c0', '+34916708082', now(), now(), '3000000000', '{}', '', -1, 'null', '', now(), now(), null);"; sleep 0.2; done;
mysql> SET GLOBAL wsrep_desync=1;
Query OK, 0 rows affected (0.01 sec)

mysql> SET wsrep_on=0;
Query OK, 0 rows affected (0.00 sec)

mysql> alter table `tp_cdrs` ENGINE=InnoDB;
ERROR 1317 (70100): Query execution was interrupted

mysql> SET wsrep_on=1;
Query OK, 0 rows affected (0.00 sec)

mysql> SET GLOBAL wsrep_desync=0;
Query OK, 0 rows affected (0.01 sec)

Aproximación número 2: OPTIMIZE LOCAL [FAIL]

La sentencia OPTIMIZE TABLE escribe al binlog y por tanto se propaga por el cluster. Si únicamente queremos aplicarlo al nodo local podemos recurrir al modificador NO_WRITE_TO_BINLOG o su alias LOCAL. Mismo resultado.

$ while [ true ]; do time mysql -h otro_nodo -e "insert into tp_cdrs values(null, uuid(), '*raw', '127.0.0.1', '', uuid(), '*voice', '*postpaid', 'b0', 'call', 'c0', 'c0', '+34916708082', now(), now(), '3000000000', '{}', '', -1, 'null', '', now(), now(), null);"; sleep 0.2; done;
mysql> optimize local table `tp_cdrs`;
+----------------------+----------+----------+----------------------------------------------------------
---------+                                                                                               
| Table                | Op       | Msg_type | Msg_text                                                 
         |                                                                                               
+----------------------+----------+----------+----------------------------------------------------------
---------+                                                                                               
| ivozprovider.tp_cdrs | optimize | note     | Table does not support optimize, doing recreate + analyze
 instead |                                                                                               
| ivozprovider.tp_cdrs | optimize | error    | Query execution was interrupted                          
         |                                                                                               
| ivozprovider.tp_cdrs | optimize | status   | Operation failed                                         
         |                                                                                               
+----------------------+----------+----------+----------------------------------------------------------
---------+

Nota: Combinar este punto y el anterior no mejora las cosas.

Aproximación número 3: RSU

¡Esta vez si! Funcionó a las mil maravillas. Recordemos que hay que ir nodo a nodo aplicándolo y que viejo y nuevo esquema tienen que ser compatibles.

$ while [ true ]; do time mysql -h otro_nodo -e "insert into tp_cdrs values(null, uuid(), '*raw', '127.0.0.1', '', uuid(), '*voice', '*postpaid', 'b0', 'call', 'c0', 'c0', '+34916708082', now(), now(), '3000000000', '{}', '', -1, 'null', '', now(), now(), null);"; sleep 0.2; done;
mysql> set wsrep_OSU_method=RSU;
Query OK, 0 rows affected (0.00 sec)

mysql> alter table `tp_cdrs` ENGINE=InnoDB;
Query OK, 0 rows affected (12 min 54.10 sec)
Records: 0 Duplicates: 0 Warnings: 0

mysql> set wsrep_OSU_method=TOI;

Si miramos el estado del nodo durante la aplicación del DDL nos encontraremos algunos valores de interés:

mysql> show status like 'wsrep_%';
+----------------------------------+------------------+
| Variable_name                    | Value            |
+----------------------------------+------------------+
| wsrep_local_recv_queue           | 164              |
| wsrep_flow_control_status        | OFF              |
| wsrep_local_state                | 2                |
| wsrep_local_state_comment        | Donor/Desynced   |
| wsrep_cluster_weight             | 3                |
| wsrep_desync_count               | 1                |
+----------------------------------+------------------+

`wsrep_local_recv_queue` hace referencia al número de sentencias de escritura recibidas que han sido encoladas. El estado Desynced evita que las escrituras del cluster se pausen porque un nodo se ha quedado excesivamente atrás mientras aplica el DDL.

 

Aproximación número 4: pt-online-schema-change

Percona-toolkit al rescate una vez más, pt-online-schema-change es probablemente la opción más potente y compleja de la lista. Tiene un millón de opciones de configuración con las que no nos vamos a entretener, únicamente decir que permite hacer algunos cambios no compatibles con el esquema de partida, sin bloqueos y aplicado sobre todos los nodos a la vez. La jugada es la siguiente:

  • Copiar la la estructura de la tabla a modificar en NombreTabla_new.
  • Aplicar el DDL en la nueva tabla.
  • Crear triggers para asegurarse de que cualquier cambio en la tabla original (INSERT, DELETE, UPDATE) llega a la nueva tabla.
  • Copiar poco a poco los datos de la tabla vieja a la nueva.
  • Una vez sincronizadas:
    • Eliminar la tabla original.
    • Renombrar la nueva tabla.
    • Eliminar triggers.

A nosotros el último punto nos producía cierto respeto y decidimos aplicarlo manualmente. Si algo salía mal era preferible estar presente para reaccionar rápido y mitigar daños. Ejemplo de OPTIMIZE sobre la tabla Invoices a «nuestra manera»:

$ time pt-online-schema-change \
--alter "ENGINE=InnoDB" D=ivozprovider,t=Invoices \
--execute \
--no-drop-new-table \
--no-drop-triggers \
--no-swap-tables \
--alter-foreign-keys-method none
mysql> ANALYZE TABLE `ivozprovider`.`_Invoices_new`;
mysql> SET foreign_key_checks=0;
mysql> DROP TABLE `Invoices`;
mysql> RENAME TABLE `ivozprovider`.`_Invoices_new` TO `ivozprovider`.`Invoices`;
mysql> SET foreign_key_checks=1;
mysql> DROP TRIGGER IF EXISTS `ivozprovider`.`pt_osc_ivozprovider_Invoices_del`;
mysql> DROP TRIGGER IF EXISTS `ivozprovider`.`pt_osc_ivozprovider_Invoices_upd`;
mysql> DROP TRIGGER IF EXISTS `ivozprovider`.`pt_osc_ivozprovider_Invoices_ins`;

En un intento por hacer la operación aún más conservadora, intentamos hacer un `RENAME TABLE ivozprovider.Invoices TO ivozprovider._Invoices_old` e inmediatamente después `RENAME TABLE ivozprovider._Invoices_new TO ivozprovider.Invoices`. No salió bien, todas las foreign keys a Invoices apuntaban ahora a _Invoices_old. Lo habíamos intentado 🙂

 

BONUS TRACK: FLOW CONTROL

Después de tanta mención a flow control, no podíamos despedirnos  sin comentar muy brevemente las variables de estado que nos indican la salud del cluster. Recordemos que cuando un nodo es incapaz de seguir el ritmo del resto, pone en cola las transacciones que le van llegando. Si el tamaño de la cola excede el límite configurado, se activa flow control indicando al resto de nodos que pausen las escrituras hasta que este vuelva a estar en rango.

Monitoring

mysql> show status like 'wsrep_%queue';
+------------------------+-------+
| Variable_name          | Value |
+------------------------+-------+
| wsrep_local_send_queue | 0     |
| wsrep_local_recv_queue | 0     |
+------------------------+-------+

`wsrep_local_send_queue` y `wsrep_local_recv_queue` son el tamaño de la cola de salida y entrada. Por lo general nos fijaremos en la segunda, pues es esta la que indica que el nodo se está quedando atrás.

mysql> show status like '%flow_control%';
+----------------------------------+----------------+
| Variable_name                    | Value          |
+----------------------------------+----------------+
| wsrep_flow_control_sent          | 0              |
| wsrep_flow_control_recv          | 6              |
| wsrep_flow_control_interval      | [ 173, 173 ]   |
| wsrep_flow_control_interval_low  | 173            |
| wsrep_flow_control_interval_high | 173            |
| wsrep_flow_control_status        | OFF            |
+----------------------------------+----------------+

`wsrep_flow_control_sent` y `wsrep_flow_control_recv` son contadores del número de veces que el nodo ha enviado/recibido una orden de flow control.

El threshold viene determinado por los valores `wsrep_flow_control_interval_high` y  `wsrep_flow_control_interval_low`. Cuando recv_queue cruza interval_high se entra en flow control y no se sale hasta que caiga por debajo de interval_low. `wsrep_flow_control_status` muestra si el nodo está actualmente fuera de rango o no, ‘OFF’ equivale a respirar tranquilo.

mysql> SHOW STATUS LIKE 'wsrep_flow_control_paused%';
+------------------------------+----------------+
| Variable_name                | Value          |
+------------------------------+----------------+
| wsrep_flow_control_paused_ns | 11621553462547 |
| wsrep_flow_control_paused    | 0.002766       |
+------------------------------+----------------+

`wsrep_flow_control_paused_ns` es el tiempo en nanosegundos que el nodo ha estado en flow control. En cuanto a `wsrep_flow_control_paused`, tiene un valor comprendido entre cero y uno y representa el tiempo que el nodo ha estado fuera de rango (1 equivale al 100% del tiempo).

NOTA: Estos valores se resetean al hacer FLUSH STATUS.

Parametrización

Pongamos que queremos modificar el threshold de flow control, ¿es posible?

mysql> set global wsrep_provider_options="gcs.fc_limit=100";
Query OK, 0 rows affected (0.00 sec)

mysql> show status like 'wsrep_flow_control_interval';
+-----------------------------+--------------+
| Variable_name               | Value        |
+-----------------------------+--------------+
| wsrep_flow_control_interval | [ 173, 173 ] |
+-----------------------------+--------------+

`gcs.fc_limit` es el valor base para el cálculo del threshold. Tiene una relación 1-1 con el valor de `wsrep_flow_control_interval_high` cuando hay un único nodo. A medida que añadimos nodos interval_high aumenta.

mysql> set global wsrep_provider_options="gcs.fc_factor=0.75";
Query OK, 0 rows affected (0.00 sec)

mysql> show status like 'wsrep_flow_control_interval';
+-----------------------------+--------------+
| Variable_name               | Value        |
+-----------------------------+--------------+
| wsrep_flow_control_interval | [ 130, 173 ] |
+-----------------------------+--------------+

El ejemplo anterior reduce el valor de interval_low a un 75% de interval_high. El threshold de entrada y salida de flow control yo no es idéntico.

Disclaimer: No olvides que aumentar el threshold aumenta la desincronización máxima que pueden llegar a alcanzar los nodos. Lee antes de tocar.

 

Conclusiones

Eso es todo amigos. Solamente decir que a pesar de estos «detallitos» estamos contentos con la solución. Lo que promete lo hace bien, muy bien, simplemente hay que ser conscientes de que el cambio no sale gratis y si algún día te ves en esta aventura ¡Ojalá este post te haga el camino un poco más fácil! A partir de aquí solo queda cruzar los dedos para que el futuro no depare nuevas sorpresas y darte la enhorabuena por llegar hasta el final, que el artículo es densito 🙂

P.D: Un agradecimiento especial a Kaian por haber compartido estas batallas conmigo y ser el padre de los memes del post. Thanks man!



¿Te gusta este post? Es solo un ejemplo de cómo podemos ayudar a tu empresa...
Sobre Mikel Madariaga

Desarrollador de aplicaciones, principalmente PHP y javascript orientado a web, desde el lanzamiento del pan Bimbo de corteza tierna blanca.

Queremos tu opinión :)