Become a member!

Bloom Filters: La Memoria Aproximada que Revoluciona el Rendimiento

  • 👉 This article is available in english too.
  • 👉 Este artículo también está disponible en español.
  • 👉 Questo articolo è disponibile anche in italiano.
  • 👉 Este artigo também está disponível em português do Brasil.

El Problema: Cuando Verificar se Vuelve Costoso

En los últimos años, DelphiMVCFramework ha sido adoptado en sistemas críticos alrededor del mundo: desde APIs bancarias que procesan transacciones por millones de euros, hasta sistemas hospitalarios que gestionan datos vitales de pacientes, pasando por plataformas industriales que coordinan líneas de producción automatizadas. Estos sistemas comparten una característica común: deben manejar millones de llamadas API diariamente manteniendo excelente rendimiento y tiempos de respuesta predecibles.

En tales contextos, incluso las operaciones aparentemente más simples pueden convertirse en cuellos de botella críticos cuando se escalan a volúmenes empresariales. Un ejemplo perfecto es lo que podrías encontrar al desarrollar una API de registro de usuarios para una red social que ha alcanzado 100 millones de suscriptores. Cada vez que alguien intenta registrarse, necesitas verificar si el email ya existe en la base de datos. Una consulta aparentemente inocua como SELECT COUNT(*) FROM users WHERE email = ? puede parecer trivial, pero cuando se ejecuta miles de veces por minuto en una tabla con 100 millones de filas, incluso con los mejores índices, la base de datos comienza a sufrir.

La paradoja es que la gran mayoría de estas consultas devuelven cero resultados - la mayoría de los emails que se verifican son efectivamente nuevos. Literalmente estás desperdiciando recursos computacionales para confirmar algo que podrías saber de antemano con un método mucho más eficiente.

Este escenario se repite en innumerables contextos: verificación de URLs ya visitadas en un web crawler, verificación de claves en cachés distribuidas, filtros anti-spam, sistemas de deduplicación. Todas situaciones donde el costo de verificar si un elemento “podría estar presente” es enormemente inferior al costo de una verificación definitiva.

La Solución Tradicional y Sus Límites

El enfoque clásico implica usar estructuras de datos como HashSet o Dictionary para mantener en memoria todos los elementos previamente vistos. Para nuestro caso de emails, esto significaría cargar 100 millones de cadenas en memoria - estamos hablando de varios gigabytes de RAM solo para esta funcionalidad.

Incluso asumiendo que tengas la memoria disponible, el costo de mantener esta estructura sincronizada con la base de datos se vuelve prohibitivo. Cada inserción, modificación o eliminación requiere actualizaciones tanto en la base de datos como en la estructura en memoria.

Presentamos los Bloom Filters: La Memoria Aproximada

Un Bloom Filter es esencialmente una “memoria aproximada” que te permite responder a una pregunta fundamental: “¿He visto este elemento antes?” con dos posibles respuestas:

  • “Definitivamente NO” (respuesta garantizada al 100%)
  • “Probablemente SÍ” (con una probabilidad configurable de error)

Piensa en un Bloom Filter como ese amigo que tiene una memoria excepcional para las cosas que NUNCA ha visto, pero a veces confunde las cosas que ya ha encontrado. Si te dice “Nunca lo he visto”, puedes confiar en él ciegamente. Si te dice “Ya lo he visto”, podría estar equivocado, pero la mayoría de las veces tiene razón.

Cómo Funciona: La Elegancia de la Simplicidad

Un Bloom Filter consiste en un array de bits (inicialmente todos en cero) y un conjunto de funciones hash. Cuando quieres “recordar” un elemento:

  1. Calculas varias funciones hash del elemento
  2. Usas estos valores como índices en el array de bits
  3. Estableces todos los bits correspondientes en 1

Para verificar si un elemento ya está presente:

  1. Calculas las mismas funciones hash
  2. Verificas si todos los bits correspondientes están en 1
  3. Si incluso uno está en 0, el elemento definitivamente no está ahí
  4. Si todos están en 1, el elemento probablemente está ahí

La belleza radica en la compacidad: independientemente de cuántos elementos insertes, el tamaño del Bloom Filter permanece constante. Para nuestro caso de 100 millones de emails, en lugar de gigabytes de memoria, podríamos usar solo unos pocos megabytes manteniendo una probabilidad de falso positivo del 1%.

Resolviendo el Problema Original

Volviendo a nuestro escenario de registro de usuarios, el flujo se convierte en:

  1. Usuario intenta registro con email X
  2. Verificación rápida en el Bloom Filter (microsegundos)
  3. Si dice “Definitivamente NO”, procede directamente con la inserción
  4. Si dice “Probablemente SÍ”, hace la consulta a la base de datos para confirmación

Considerando que el 99% de los emails son efectivamente nuevos, eliminamos el 99% de las consultas a la base de datos. El 1% restante incluye tanto duplicados reales como falsos positivos, pero ambos requieren verificación final de todos modos.

Implementación Simple pero Eficiente en Delphi

La siguiente implementación es deliberadamente simple pero usa técnicas avanzadas para maximizar el rendimiento. En lugar de usar un TArray<Boolean> que desperdicia memoria, usamos un TArray<UInt32> para almacenamiento compacto de bits.

unit BloomFilter;

interface

uses
  System.SysUtils, System.Hash;

type
  TBloomFilter = class
  private
    fBitArray: TArray<UInt32>;    // Almacenamiento compacto con array de UInt32
    fBitCount: UInt32;            // Número total de bits en el filtro
    fHashFunctions: Integer;      // Número de funciones hash a usar
    
    function GetBitArraySize: Integer;
    procedure SetBit(aIndex: UInt32);
    function GetBit(aIndex: UInt32): Boolean;
    function ComputeHash(const aValue: string; aSeed: UInt32): UInt32;
    
  public
    constructor Create(aBitCount: UInt32; aHashFunctions: Integer = 3);
    destructor Destroy; override;
    
    procedure Add(const aValue: string);
    function MightContain(const aValue: string): Boolean;
    procedure Clear;
  end;

implementation

constructor TBloomFilter.Create(aBitCount: UInt32; aHashFunctions: Integer);
begin
  inherited Create;
  
  if aBitCount = 0 then
    raise EArgumentException.Create('Bit count must be greater than 0');
  if aHashFunctions <= 0 then
    raise EArgumentException.Create('Hash function count must be greater than 0');
    
  fBitCount := aBitCount;
  fHashFunctions := aHashFunctions;
  
  // Calcula el tamaño del array UInt32 requerido
  SetLength(fBitArray, GetBitArraySize);
  Clear;
end;

destructor TBloomFilter.Destroy;
begin
  SetLength(fBitArray, 0);
  inherited;
end;

function TBloomFilter.GetBitArraySize: Integer;
begin
  // División techo: (fBitCount + 31) div 32
  Result := (fBitCount + 31) div 32;
end;

procedure TBloomFilter.SetBit(aIndex: UInt32);
var
  lArrayIndex: Integer;
  lBitIndex: Integer;
begin
  lArrayIndex := aIndex div 32;
  lBitIndex := aIndex mod 32;
  fBitArray[lArrayIndex] := fBitArray[lArrayIndex] or (1 shl lBitIndex);
end;

function TBloomFilter.GetBit(aIndex: UInt32): Boolean;
var
  lArrayIndex: Integer;
  lBitIndex: Integer;
begin
  lArrayIndex := aIndex div 32;
  lBitIndex := aIndex mod 32;
  Result := (fBitArray[lArrayIndex] and (1 shl lBitIndex)) <> 0;
end;

function TBloomFilter.ComputeHash(const aValue: string; aSeed: UInt32): UInt32;
var
  lHash1Val, lHash2Val: Cardinal;
  lCombined: UInt64;  // UInt64 para prevenir overflow
begin
  // Doble hashing para simular funciones hash independientes
  lHash1Val := Cardinal(THashBobJenkins.GetHashValue(aValue));
  lHash2Val := Cardinal(THashFNV1a32.GetHashValue(aValue));
  
  // Combina con seed para crear diferentes funciones hash
  lCombined := UInt64(lHash1Val) + UInt64(aSeed) * UInt64(lHash2Val);
  
  // El módulo asegura que el resultado esté siempre en rango válido
  Result := lCombined mod fBitCount;
end;

procedure TBloomFilter.Add(const aValue: string);
var
  lHashValue: UInt32;
begin
  for var lI := 0 to fHashFunctions - 1 do
  begin
    lHashValue := ComputeHash(aValue, UInt32(lI));
    SetBit(lHashValue);
  end;
end;

function TBloomFilter.MightContain(const aValue: string): Boolean;
var
  lHashValue: UInt32;
begin
  Result := True;
  for var lI := 0 to fHashFunctions - 1 do
  begin
    lHashValue := ComputeHash(aValue, UInt32(lI));
    if not GetBit(lHashValue) then
    begin
      Result := False;
      Exit; // Optimización: salida temprana
    end;
  end;
end;

procedure TBloomFilter.Clear;
begin
  for var lI := 0 to Length(fBitArray) - 1 do
    fBitArray[lI] := 0;
end;

end.

La implementación usa varias técnicas para maximizar el rendimiento:

Almacenamiento Compacto: Usar TArray<UInt32> en lugar de TArray<Boolean> reduce el uso de memoria 32 veces. Cada UInt32 puede almacenar 32 bits, mientras que cada Boolean usaría uno entero.

Doble Hashing: La función ComputeHash combina dos algoritmos hash diferentes (Bob Jenkins y FNV1a) con una semilla para simular funciones hash independientes, evitando la necesidad de implementar múltiples funciones hash separadas.

Prevención de Overflow: Usar UInt64 en cálculos intermedios previene overflow durante operaciones aritméticas, asegurando resultados correctos incluso con valores grandes.

Salida Temprana: En MightContain, el bucle se detiene en el primer bit no establecido, optimizando el rendimiento en el caso más común (elementos no presentes).

Nomenclatura Moderna: La clase sigue las convenciones modernas de Delphi con miembros privados que comienzan con “f” y variables locales con “l”, junto con el uso de sintaxis inline para bucles.

El Secreto del Doble Hashing: ¿Por Qué Necesitamos Múltiples Funciones Hash?

Una pregunta fundamental en la implementación de Bloom Filters es: ¿por qué no usar solo una función hash? La respuesta radica en las matemáticas de la distribución probabilística y la necesidad de minimizar los falsos positivos.

El Problema de Una Sola Función Hash: Si usáramos solo una función hash, elementos diferentes podrían fácilmente “colisionar” en el mismo bit, reduciendo drásticamente la efectividad del filtro. Con múltiples funciones hash, la probabilidad de que dos elementos diferentes tengan todas las mismas posiciones hash se vuelve extremadamente baja.

La Técnica del Doble Hashing: En lugar de implementar k funciones hash completamente independientes (computacionalmente costoso), nuestra implementación usa una técnica elegante: simular k funciones hash usando solo dos funciones base.

La fórmula matemática es:

Gi(x) = (H1(x) + i * H2(x)) mod m

Donde:

  • Gi(x) es la i-ésima función hash simulada para el elemento x
  • H1(x) y H2(x) son las dos funciones hash base (Bob Jenkins y FNV1a)
  • i es el índice de la función hash (0, 1, 2…)
  • m es el tamaño del array de bits

Cómo Funciona en Nuestra Implementación:

function TBloomFilter.ComputeHash(const aValue: string; aSeed: UInt32): UInt32;
var
  lHash1Val, lHash2Val: Cardinal;
  lCombined: UInt64;
begin
  // Dos funciones hash base
  lHash1Val := Cardinal(THashBobJenkins.GetHashValue(aValue));  // H1(x)
  lHash2Val := Cardinal(THashFNV1a32.GetHashValue(aValue));     // H2(x)
  
  // Fórmula: G_i(x) = (H1(x) + i * H2(x)) mod m
  lCombined := UInt64(lHash1Val) + UInt64(aSeed) * UInt64(lHash2Val);
  Result := lCombined mod fBitCount;
end;

El parámetro aSeed representa el índice i de la función hash. Cuando llamamos el método con semillas 0, 1, 2… obtenemos funciones hash diferentes que están correlacionadas matemáticamente de manera óptima.

Ventajas de Este Enfoque:

  • Eficiencia: Calculamos solo dos hashes base en lugar de k hashes separados
  • Independencia: Las funciones resultantes son suficientemente independientes para minimizar falsos positivos
  • Simplicidad: Una fórmula en lugar de k implementaciones diferentes
  • Probado: Esta técnica ha sido matemáticamente probada casi equivalente a k funciones verdaderamente independientes

Esta es la razón por la que nuestro Bloom Filter puede ser tanto simple como extremadamente efectivo: aprovechamos las matemáticas para lograr el máximo resultado con el mínimo esfuerzo computacional.

Demo Rápida de Funcionalidad

Antes de ver uso práctico en producción, aquí hay una demo simple de consola que muestra la API en acción:

program BloomFilterDemo;
{$APPTYPE CONSOLE}

uses
  System.SysUtils,
  BloomFilter in 'BloomFilter.pas';

var
  lFilter: TBloomFilter;
begin
  // Crea un Bloom Filter con 10000 bits y 3 funciones hash
  lFilter := TBloomFilter.Create(10000, 3);
  try
    Writeln('=== DEMO BLOOM FILTER ===');
    Writeln;
    
    // Prueba emails aún no insertados
    Write('¿Está presente el email "mario@test.com"? ');
    if lFilter.MightContain('mario@test.com') then
      Writeln('PROBABLEMENTE SI')
    else  
      Writeln('DEFINITIVAMENTE NO');
      
    Write('¿Está presente el email "luigi@test.com"? ');
    if lFilter.MightContain('luigi@test.com') then
      Writeln('PROBABLEMENTE SI')
    else  
      Writeln('DEFINITIVAMENTE NO');
    
    Writeln;
    Writeln('Agregando "mario@test.com" al filtro...');
    lFilter.Add('mario@test.com');
    
    Writeln('Agregando "francesco@test.com" al filtro...');
    lFilter.Add('francesco@test.com');
    
    Writeln;
    Writeln('=== PRUEBA DESPUÉS DE INSERCIONES ===');
    
    // Prueba elementos insertados
    Write('¿Está presente el email "mario@test.com"? ');
    if lFilter.MightContain('mario@test.com') then
      Writeln('PROBABLEMENTE SI (¡correcto!)')
    else  
      Writeln('DEFINITIVAMENTE NO');
      
    Write('¿Está presente el email "francesco@test.com"? ');
    if lFilter.MightContain('francesco@test.com') then
      Writeln('PROBABLEMENTE SI (¡correcto!)')
    else  
      Writeln('DEFINITIVAMENTE NO');
    
    // Prueba elemento nunca insertado
    Write('¿Está presente el email "luigi@test.com"? ');
    if lFilter.MightContain('luigi@test.com') then
      Writeln('PROBABLEMENTE SI (falso positivo)')
    else  
      Writeln('DEFINITIVAMENTE NO (¡correcto!)');
      
    Writeln;
    Writeln('Nota: Los elementos insertados siempre son detectados (cero falsos negativos)');
    Writeln('Los elementos no insertados podrían ocasionalmente dar falsos positivos');
    
    Readln;
  finally
    lFilter.Free;
  end;
end.

La salida típica mostrará cómo el Bloom Filter responde “DEFINITIVAMENTE NO” para elementos nunca vistos, “PROBABLEMENTE SI” para elementos insertados, y ocasionalmente “PROBABLEMENTE SI” incluso para elementos no insertados (falsos positivos). El punto clave es que nunca tendremos falsos negativos: si un elemento ha sido insertado, siempre será detectado.

Uso Práctico

// En tu controlador de registro
var
  lBloomFilter: TBloomFilter;
  
// Inicialización (se hace solo una vez)
lBloomFilter := TBloomFilter.Create(1000000, 3); // 1M bits, 3 funciones hash

// Durante el registro
procedure RegisterUser(const aEmail: string);
begin
  if not lBloomFilter.MightContain(aEmail) then
  begin
    // Definitivamente email nuevo, insertar directamente
    InsertNewUser(aEmail);
    lBloomFilter.Add(aEmail);
  end
  else
  begin
    // Podría ser duplicado, verificar con base de datos
    if not EmailExistsInDB(aEmail) then
    begin
      InsertNewUser(aEmail);
      lBloomFilter.Add(aEmail);
    end
    else
      raise Exception.Create('Email ya registrado');
  end;
end;

Consideraciones Finales

Los Bloom Filters representan un ejemplo perfecto de cómo a veces la mejor solución no es la perfecta, sino la “suficientemente buena” que resuelve el problema real. ☝️ Como decía Voltaire “lo perfecto es enemigo de lo bueno”. Al aceptar un pequeño porcentaje de incertidumbre, obtenemos enormes ventajas en términos de memoria y rendimiento.

Cuándo Usar Bloom Filters

Los Bloom Filters son la elección ideal cuando:

  • Necesitas realizar muchas verificaciones de pertenencia en un conjunto muy grande
  • La memoria es un recurso limitado
  • Puedes tolerar un pequeño porcentaje de falsos positivos
  • No necesitas eliminar elementos del conjunto (una vez que un bit está en 1, no puede volver a 0)

Casos de Uso en el Mundo Real

Más allá de nuestro ejemplo de API de registro de usuarios, los Bloom Filters se usan en:

Navegadores Web: Chrome usa Bloom Filters para Safe Browsing - verifica rápidamente si una URL está en la lista de sitios maliciosos sin tener que consultar el servidor por cada enlace.

Bases de Datos Distribuidas: Sistemas como Cassandra y HBase los usan para evitar costosas lecturas de disco. Antes de buscar datos específicos, consultan el Bloom Filter para saber si vale la pena acceder al disco.

Sistemas de Caché: Para verificar rápidamente si un elemento ya está en caché antes de recuperarlo de una fuente más lenta.

Redes de Distribución de Contenido (CDN): Para determinar si el contenido ya está disponible en un servidor edge sin tener que hacer consultas costosas.

Detección de Duplicados: En aplicaciones donde es importante saber si un elemento ya ha sido visto (evitar spam, sugerir contenido no visto).

Compensaciones a Considerar

Ventajas:

  • Eficiencia de espacio extrema (unos pocos megabytes para millones de elementos)
  • Operaciones en tiempo casi constante
  • Garantía de cero falsos negativos

Desventajas:

  • Posibles falsos positivos (configurables)
  • Imposibilidad de eliminar elementos
  • Tamaño fijo del array de bits

En el mundo de las arquitecturas distribuidas y big data, los Bloom Filters se han convertido en una herramienta indispensable. Su simplicidad conceptual oculta un poder sorprendente en la optimización de sistemas reales, especialmente cuando se combinan con frameworks como DelphiMVCFramework para crear APIs de alto rendimiento.

Comments

comments powered by Disqus