La gestión de grandes conjuntos de identificadores de usuario (UIDs) en sistemas de marketing o análisis a menudo requiere operaciones de conjuntos como uniones, intersecciones y diferencias. Una técnica común para representar estos conjuntos de UIDs es el uso de bitmaps, donde cada bit corresponde a la presencia o ausencia de un UID específico. Tradicionalmente, Redis, con su tipo de datos bitmap y comandos como BITOP, se ha utilizado para estas tareas.
Consideremos un escenario donde necesitamos consolidar listas de UIDs que cumplen diversas condiciones de negocio. Cada condición podría generar una lista de UIDs, que luego se almacenaría como un bitmap en Redis. Por ejemplo, si un grupo de usuarios incluye los UIDs 3 y 7, el bitmap podría representarse como 00010001. Posteriormente, los comandos BITOP AND, BITOP OR, o BITOP XOR se usarían para combinar estos bitmaps y obtener el conjunto final de usuarios.
Desafíos con Bitmaps en Redis a Gran Escala
Aunque Redis es excelente para muchas operaciones, el manejo de bitmaps extremadamente grandes directamente en la base de datos puede presentar serios problemas de rendimiento y estabilidad:
- Sobrecarga de comandos
SETBIT: Para grupos de usuarios numerosos, se requieren múltiples comandosSETBIT {uid} 1, lo que puede generar una latencia considerable. - Asignación de memoria inicial: Si el primer UID en un bitmap es un número muy grande (por ejemplo, mil millones), Redis puede intentar asignar una gran cantidad de memoria (aproximadamente 120MB para 10^9 bits) de una sola vez. Esto puede llevar a pausas o bloqueos en la instancia de Redis, especialmente en entornos de alta concurrencia.
- Consumo de CPU en
BITOP: Las operacionesBITOPsobre bitmaps extensos son computacionalmente intensivas y pueden consumir una cantidad significativa de ciclos de CPU. Ejecutar estas operaciones en una instancia de Redis que primariamente está optimizada para almacenamiento puede degradar el rendimineto general del sistema.
Para mitigar estos problemas, una estrategia eficaz es trasladar los cálculos intensivos de bitmaps desde Redis a la capa de aplicación. De esta forma, Redis solo se utiliza para almacenar el bitmap final y compacto, mientras que la aplicación maneja las operaciones intermedias. Además, para UIDs iniciales extremadamente grandes, la asignación de memoria puede gestionarse de forma incremental (por ejemplo, configurando bits en intervalos de 1K, 2K, 3K, etc.) para evitar bloqueos súbitos.
Implementación de Bitmaps en PHP: Primeros Intentos y Limitaciones
Dada la necesidad de implementar lógica de bitmaps en PHP, la primera aproximación lógica es utilizar operaciones a nivel de bit. Sin embargo, PHP impone ciertas restricciones:
- Las operaciones bit a bit en PHP solo funcionan con números enteros.
- Los enteros en PHP (en sistemas de 64 bits) tienen un tamaño máximo de 64 bits. Al ser firmados, el bit más significativo se reserva para el signo, limitando el valor positivo más grande a 2^63 - 1. Esto significa que un solo entero puede representar eficazmente UIDs hasta 63, lo cual es insuficiente para la mayoría de los escenarios reales.
Una solución aparente es utilizar un array de enteros, donde cada elemento represente un segmento de 63 UIDs. Por ejemplo, para almacenar 36 millones de UIDs (aproximadamente 63 * 1 millón), necesitaríamos un array con 1 millón de elementos. Intuitivamente, se podría esperar que esto consumiera alrededor de 7.6 MB de memoria (64 bits * 1 millón / 8 bits/byte / 1024 KB/MB / 1024 MB/GB). Sin embargo, la realidad en PHP es diferente debido a la implementación de los arrays.
PHP implementa sus arrays como HashTable, una estructura de datos compleja que conlleva una sobrecarga de memoria significativa más allá del espacio ocupado por los valores de los elementos en sí. Para ilustrar esto, consideremos el siguiente fragmento de código:
<?php
ini_set('memory_limit', '4G'); // Aseguramos suficiente memoria para la prueba
$uidCollection = [];
// Simula el almacenamiento de bits para un rango de UIDs muy grande
// Cada entero representa 64 bits (o 63 utilizables)
// Esto emula un array necesario para cubrir 64 millones de bits
for ($index = 0; $index < 1000000; $index++) {
$uidCollection[] = PHP_INT_MAX; // Cada elemento consume un entero completo
}
// En este punto, $uidCollection ha sido poblado.
// Observa el consumo de memoria del proceso PHP.
// Por ejemplo, usando 'ps aux | grep php' y verificando la columna RSS.
?>
Al ejecutar este código y monitorear el uso de memoria del proceso PHP (por ejemplo, con ps aux | grep php en Linux o similar), se observa que el consumo de memoria asciende a alrededor de 1.5 GB. Esto es considerablemente más alto que los 7.6 MB inicialmente estimados, lo que demuestra que la aproximación de un array de enteros no es eficiente para bitmaps de gran escala en PHP.
Optimización con la Extensión GMP de PHP
Para superar la limitación de memoria y la incapacidad de manipular números de longitud abritraria, la extensión PHP GMP (GNU Multiple Precision) emerge como una solución ideal. GMP permite a PHP manejar enteros de precisión arbitraria, lo que significa que un único número GMP puede representar un bitmap de longitud prácticamente ilimitada sin la sobrecarga de un HashTable para cada segmento de 64 bits.
Veamos cómo GMP resuelve el problema de la memoria:
<?php
// Inicializa un número GMP con valor 0.
// Este objeto GMP crecerá dinámicamente según sea necesario.
$largeBitmap = gmp_init(0);
// Establece el bit en la posición 64,000,000 (equivalente a un UID de 64 millones).
// GMP asigna la memoria solo hasta el bit más alto establecido.
gmp_setbit($largeBitmap, 64 * 1000000, true);
// En este punto, $largeBitmap representa un bitmap con un bit establecido en una posición muy alta.
// Si monitoreamos el consumo de memoria del proceso PHP, veremos una mejora drástica.
?>
Con esta implementación, el consumo de memoria para un bitmap que cubre hasta 64 millones de bits se reduce a aproximadamente 15 MB. Esto es una mejora significativa en comparación con los 1.5 GB requeridos por el array de enteros. Además, la extensión GMP proporciona funciones nativas para operaciones bit a bit, como gmp_and(), gmp_or() y gmp_xor(), facilitando enormemente las operaciones de conjuntos.
Mejora Adicional: Bitmaps Segmentados
Aunque GMP resuelve eficientemente el problema de la memoria para bitmaps densos, aún existe una oportunidad de optimización para bitmaps "dispersos", es decir, aquellos con muy pocos bits establecidos pero en posiciones muy altas. Por ejemplo, si un grupo de usuarios contiene solo el UID 64,000,000, el objeto GMP aún necesitaría asignar suficiente memoria para abarcar esa posición, aunque la mayoría de los bits anteriores no estén configurados. Esto podría resultar en un uso ineficiente de la memoria para colecciones de UIDs muy pequeñas pero con valores muy grandes.
Para optimizar aún más para casos dispersos, se puede adoptar un enfoque híbrido: dividir un bitmap grande en un array de pequeños bitmaps GMP. Cada elemento del array representaría un "segmento" del rango total de UIDs (por ejemplo, un segmento de 10 millones de bits). Si necesitamos almacenar el UID 64,000,000, simplemente estableceríamos el bit correspondiente en el sexto elemento del array (el segmento que cubre los UIDs desde 60,000,000 hasta 69,999,999), en lugar de tener un único objeto GMP gigantesco. Esta estrategia combina la eficiencia de memoria de GMP con la gestión de escasez de los arrays, logrando un equilibrio óptimo.