La Diferencia Arquitectónica Que Importa

Las diferencias arquitectónicas fundamentales son el punto de partida, ya que todas las características de rendimiento se derivan de ellas.

Apache Kafka es una aplicación JVM. Está escrito en Scala y Java, corre en el JVM con su garbage collector asociado, y hasta hace poco requería ZooKeeper como servicio de coordinación separado. El modo KRaft (el consenso Raft built-in de Kafka) está production-ready desde Kafka 3.5, y ZooKeeper está oficialmente deprecado desde la 3.7.

Redpanda es una aplicación C++ construida sobre el framework Seastar, el mismo framework de I/O asíncrono que utiliza ScyllaDB. Sin JVM, sin garbage collector, sin ZooKeeper. Usa Raft para consenso nativamente, con cada partición teniendo su propio grupo Raft.

Esto no es solo una diferencia de lenguaje. Es una diferencia fundamental en cómo interactúan con el hardware.

Thread-Per-Core vs Thread Pool

El modelo thread-per-core de Seastar significa que cada core de CPU corre un único thread que es dueño de su memoria, sus conexiones de red y su I/O de disco. Sin locks, sin context switches entre threads, sin memoria compartida entre cores.

Redpanda (Seastar/thread-per-core):
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Core 0 │ │ Core 1 │ │ Core 2 │ │ Core 3 │
│ Shard 0 │ │ Shard 1 │ │ Shard 2 │ │ Shard 3 │
│ Mem prop │ │ Mem prop │ │ Mem prop │ │ Mem prop │
│ I/O prop │ │ I/O prop │ │ I/O prop │ │ I/O prop │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
 ↕ message passing (sin estado compartido)

Kafka (JVM/thread pool):
┌───────────────────────────────────────────────────┐
│ JVM Heap (compartido) │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │Thread 1│ │Thread 2│ │Thread 3│ │Thread N│ │
│ └────┬───┘ └────┬───┘ └────┬───┘ └────┬───┘ │
│ └──────────┴──────────┴──────────┘ │
│ Acceso sincronizado │
│ Las pausas de GC afectan a TODOS │
└───────────────────────────────────────────────────┘

Los threads de Kafka comparten memoria del heap y coordinan a través de locks y el modelo de memoria del JVM. Cuando el collector G1 o ZGC arranca, todos los threads pausan (o al menos se ralentizan durante las fases concurrentes). Redpanda simplemente no tiene este problema, no hay GC porque no hay managed heap.

Los Benchmarks

Las siguientes pruebas se corrieron en hardware idéntico: clusters de 3 nodos, cada nodo con 16 cores (AMD EPYC 7313), 64GB RAM, 2x NVMe SSDs en RAID 0, red de 25Gbps. Esto representa un setup "serio pero no extravagante".

Test de Throughput

Configuración de producer: mensajes de 1KB, batching habilitado (linger.ms=5, batch.size=65536), acks=all, 6 particiones, factor de replicación 3.

# Benchmark Kafka
bin/kafka-producer-perf-test.sh \
 --topic bench-topic \
 --num-records 10000000 \
 --record-size 1024 \
 --throughput -1 \
 --producer-props \
 bootstrap.servers=kafka1:9092,kafka2:9092,kafka3:9092 \
 acks=all \
 linger.ms=5 \
 batch.size=65536

# Benchmark Redpanda (la misma herramienta funciona, API compatible)
bin/kafka-producer-perf-test.sh \
 --topic bench-topic \
 --num-records 10000000 \
 --record-size 1024 \
 --throughput -1 \
 --producer-props \
 bootstrap.servers=redpanda1:9092,redpanda2:9092,redpanda3:9092 \
 acks=all \
 linger.ms=5 \
 batch.size=65536

Resultados (10M mensajes, 1KB cada uno):

Métrica Kafka 3.7 (KRaft) Redpanda 24.3
Throughput (MB/s) 850 920
Latencia avg 3.2ms 1.8ms
Latencia p50 2.1ms 1.4ms
Latencia p99 28ms 4.2ms
Latencia p99.9 145ms 8.1ms

Notemos el número de p99.9. 145ms vs 8.1ms. Eso no es un error de redondeo, es el G1GC del JVM haciendo una mixed collection. Y esto fue después de un extenso tuning de flags del JVM:

# Tuning de JVM para Kafka (KAFKA_HEAP_OPTS)
-Xms12g -Xmx12g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=20
-XX:G1HeapRegionSize=16M
-XX:InitiatingHeapOccupancyPercent=35
-XX:G1ReservePercent=25
-XX:+ParallelRefProcEnabled
-XX:+ExplicitGCInvokesConcurrent

Sin ese tuning, el p99.9 de Kafka pasaba los 400ms. El JVM es poderoso, pero lo pagás en la cola.

Throughput de Consumer

Para consumo, se testeó un grupo de 3 consumers leyendo del mismo topic:

Métrica Kafka 3.7 Redpanda 24.3
Throughput consumer (MB/s) 1,100 1,250
Tiempo de rebalanceo (3→4 consumers) 12s 3s
Latencia end-to-end (produce→consume) 8ms avg 3ms avg

La diferencia en tiempo de rebalanceo es significativa en la práctica. Cada rebalanceo significa un período donde las particiones no están asignadas y no se procesan mensajes. El cooperative sticky assignor de Kafka mejoró las cosas, pero la asignación de particiones de Redpanda es directamente más rápida porque no hay coordinación con un servicio de metadata separado.

La Migración: Un Caso de Estudio

Consideremos un cluster de 5 nodos de Kafka manejando unos 2TB de eventos por día, analytics de usuarios, eventos de órdenes, updates de inventario, todo el paquete. La decisión de probar Redpanda vino de dos cosas: el equipo de on-call pasaba el 30% del tiempo de incidentes en problemas de ZooKeeper o tuning de JVM, y el SLO de latencia p99 se estaba poniendo cada vez más difícil de cumplir.

Paso 1: Cluster Shadow

Primero se levantó un cluster Redpanda de 3 nodos y usamos MirrorMaker 2 para replicar todos los topics:

# mm2.properties
clusters = kafka, redpanda
kafka.bootstrap.servers = kafka1:9092,kafka2:9092,kafka3:9092
redpanda.bootstrap.servers = rp1:9092,rp2:9092,rp3:9092

kafka->redpanda.enabled = true
kafka->redpanda.topics = .*

sync.topic.configs.enabled = true
sync.topic.acls.enabled = false
replication.factor = 3

# Crítico: configurar esto para evitar que manee los nombres de topic
replication.policy.class = org.apache.kafka.connect.mirror.IdentityReplicationPolicy

Esto funcionó out of the box. MirrorMaker 2 no sabe ni le importa que el target es Redpanda, simplemente habla el protocolo Kafka.

Paso 2: Validación de Consumers

Se corrieron consumers shadow en el cluster Redpanda y comparamos conteos de mensajes, orden y contenido contra los consumers de Kafka. Esto detectó dos problemas:

  1. Manejo de timestamps: El LogAppendTime de Redpanda tenía diferencias de precisión en microsegundos respecto a Kafka. La lógica de deduplicación que usaba comparaciones de timestamp se rompió.

  2. Marcadores de transacción: Algunos producers estaban usando transacciones (enable.idempotence=true + transactional.id). Los marcadores de commit de transacción aparecían distinto en la representación interna de Redpanda. Los consumers usando aislamiento read_committed funcionaban bien, pero una herramienta custom de tracking de offsets que parseaba los datos crudos de la partición se rompió.

Paso 3: El Switchover

Se realizó una migración rolling, moviendo un consumer group a la vez:

# Script de migración simplificado
import confluent_kafka

def migrate_consumer_group(group_id, kafka_brokers, redpanda_brokers, topics):
 # 1. Obtener offsets committed actuales de Kafka
 admin = confluent_kafka.admin.AdminClient({
 'bootstrap.servers': kafka_brokers
 })

 committed = admin.list_consumer_group_offsets([
 confluent_kafka.admin.ConsumerGroupTopicPartitions(group_id)
 ])

 # 2. Mapear offsets a Redpanda (MM2 preserva offsets con IdentityReplicationPolicy)
 rp_admin = confluent_kafka.admin.AdminClient({
 'bootstrap.servers': redpanda_brokers
 })

 # 3. Pre-commitear offsets a Redpanda antes de arrancar consumers
 offsets = []
 for tp, offset_meta in committed[group_id].result().topic_partitions:
 offsets.append(confluent_kafka.TopicPartition(
 tp.topic, tp.partition, offset_meta.offset
 ))

 rp_admin.alter_consumer_group_offsets([
 confluent_kafka.admin.ConsumerGroupTopicPartitions(group_id, offsets)
 ])

 print(f"Migrado {group_id}: {len(offsets)} offsets de partición committeados a Redpanda")

Paso 4: Switchover de Producers

Se cambiaron los bootstrap servers de los producers durante una ventana de bajo tráfico:

# Antes
kafka:
 bootstrap-servers: kafka1:9092,kafka2:9092,kafka3:9092

# Después
kafka: # mantuvimos el nombre del config key para minimizar cambios de código
 bootstrap-servers: rp1:9092,rp2:9092,rp3:9092

Funcionó sin problemas. Sin cambios de código necesarios. La librería cliente de Kafka se conectó a Redpanda y empezó a producir. Producers idempotentes, producers transaccionales, producers con particiones, todos.

Schema Registry: La Historia de Compatibilidad

Redpanda incluye su propio schema registry que es API-compatible con el de Confluent. La migración cubrió ~200 schemas Avro:

# Exportar schemas del Confluent Schema Registry
for subject in $(curl -s http://kafka-sr:8081/subjects | jq -r '.[]'); do
 schema=$(curl -s "http://kafka-sr:8081/subjects/${subject}/versions/latest")
 echo "$schema" > "schemas/${subject}.json"
done

# Importar en el schema registry de Redpanda
for file in schemas/*.json; do
 subject=$(basename "$file" .json)
 schema=$(cat "$file" | jq -c '{schema: .schema, schemaType: .schemaType}')
 curl -X POST "http://rp1:8081/subjects/${subject}/versions" \
 -H "Content-Type: application/vnd.schemaregistry.v1+json" \
 -d "$schema"
done

Los modos de compatibilidad (BACKWARD, FORWARD, FULL) funcionaron todos. El único gotcha fue que el schema registry de Redpanda guarda datos internamente en un topic _schemas, mientras que Confluent usa _schemas también, pero el formato de los mensajes difiere. No es posible simplemente replicar el topic, teners que re-registrar.

Diferencias Operacionales Que Importan Día a Día

Monitoreo

Redpanda expone métricas Prometheus en el puerto 9644 por defecto. Sin JMX, sin exporters JMX-to-Prometheus, sin andar peleando con autenticación de JMX remoto:

# prometheus.yml
scrape_configs:
 - job_name: redpanda
 static_configs:
 - targets:
 - rp1:9644
 - rp2:9644
 - rp3:9644
 metrics_path: /public_metrics

Con Kafka, obtener buenas métricas requiere un sidecar de JMX exporter, que en sí consume recurse es y agrega complejidad. Ese setup no se extraña.

Uso de Disco

Redpanda usa alrededor de 20-30% menos de disco para los mismos datos, principalmente porque no tiene el overhead de archivos de índice y timeindex que Kafka crea por segmento. Redpanda mantiene su índice en memoria usando su propio motor de storage.

Memoria

Acá resulta interesante. Kafka necesita tuning del heap del JVM, muy poco y las pausas de GC te matan, demasiado y las pausas de GC también te matan (pero de otra forma). Con Redpanda, le decís cuánta memoria usar y la maneja solo:

rpk redpanda config set --set redpanda.memory=48G
rpk redpanda config set --set redpanda.reserve_memory=4G

Sin tuning de heap, sin tuning de GC, sin flags -XX:. El allocator de Seastar maneja todo.

Donde Kafka todavía gana

Kafka sigue siendo una opción sólida, y existen razones sustanciales para continuar utilizándolo:

  1. Kafka Streams y ksqlDB: El ecosistema de procesamiento de streams no tiene rival. Redpanda no tiene equivalente, lo combinarías con Flink o tus propios consumers.
  2. Kafka Connect: Si bien Redpanda puede correr Kafka Connect (es solo el protocolo Kafka), el ecosistema de conectores testeados en producción es más profundo con Kafka.
  3. Madurez de KRaft: El modo KRaft elimina ZooKeeper, y está estable desde la 3.5. La brecha operacional se está achicando.
  4. Comunidad y contratación: Encontrar ingenieros de Kafka es más fácil. La experiencia en Redpanda es más rara.
  5. Tiered storage: El tiered storage de Kafka (escribir segmentos fríos a S3) está más maduro que la implementación de Redpanda.

Los Números Después de 8 Meses

Después de migrar a Redpanda:

  • Latencia p99 de producción: Bajó de 28ms a 4ms (consistente, no solo al principio)
  • Incidentes on-call relacionados a streaming: Bajaron 70%
  • Costo de infraestructura: Bajó 40% (3 nodos Redpanda reemplazaron 5 Kafka + 3 ZooKeeper)
  • Tiempo dedicado a tuning de JVM: Cero, porque no hay JVM

La migración valió la pena para este caso de uso. Sin embargo, no es universalmente recomendable. Si un cluster de Kafka está estable, la latencia es aceptable, y se usa Kafka Streams a fondo, quedarse con Kafka puede ser la mejor opción. La migración tiene costos reales y riesgos reales.

Para proyectos greenfield, sin embargo, Redpanda merece fuerte consideración. La simplicidad operacional sola justifica la elección.