El Problema con la Arquitectura de Cassandra
Seamos honestos con lo que pasa dentro de un nodo de Cassandra. Teners un proceso JVM manejando decenas de gigas de heap, corriendo en una máquina con 16-32 cores. Cada lectura y escritura pasa por estructuras de datos compartidas protegidas por locks. El garbage collector del JVM periódicamente decide pausar todo, y "todo" significa que los 32 cores dejan de hacer trabajo útil simultáneamente.
Acá va una entrada típica del log del GC de Cassandra que ilustra el problema:
GC pause (G1 Evacuation Pause) 14.2GB->9.8GB 247.483ms
247 milisegundos. Eso no es un outlier de tail latency, es un cuarto de segundo donde tu base de datos simplemente no responde. Y esto era en un cluster tuneado con G1GC, 8GB de new gen, y regiones cuidadosamente dimensionadas.
El problema fundamental es que el modelo de threading de Cassandra se diseñó a mediados de los 2000. Usa un approach de thread-per-core con estado mutable compartido, y después le pone un runtime con garbage collection encima. Cada decisión arquitectural se acumula:
Thread pool executor (WRITE stage) → Memtable compartida → Contención de locks
Thread pool executor (READ stage) → Row cache compartido → Contención de locks
JVM GC → Stop-the-world → Todos los threads pausan → Pico de latencia
Seastar: El Framework Shard-Per-Core
ScyllaDB está construido sobre el framework Seastar, que originalmente fue desarrollado para networking de alto rendimiento (potenciaba aplicaciones basadas en DPDK antes de que ScyllaDB existiera). Seastar implementa una arquitectura shard-per-core que elimina el estado compartido por completo.
El modelo mental es así. En una máquina de 16 cores:
Core 0: [Shard 0] → memtable propia, cache propio, cola I/O propia, conexiones propias
Core 1: [Shard 1] → memtable propia, cache propio, cola I/O propia, conexiones propias
Core 2: [Shard 2] → memtable propia, cache propio, cola I/O propia, conexiones propias
...
Core 15: [Shard 15] → memtable propia, cache propio, cola I/O propia, conexiones propias
Cada shard es una mini-base-de-datos completa. No hay locks, porque no hay nada compartido que lockear. Cuando datos necesitan moverse entre shards (que pasa durante range scans o cuando un request llega al shard equivocado), Seastar usa message passing explícito, no memoria compartida.
El reactor loop de Seastar en cada core se ve más o menos así:
void reactor::run() {
while (true) {
// Pollear I/O completado (sin syscall si se utiliza io_uring o AIO)
poll_io();
// Correr tareas listas de la cola de este shard
run_tasks();
// Pollear red (epoll o DPDK, según config)
poll_network();
// Chequear timers
expire_timers();
}
}
Esto es multitasking cooperativo. No hay context switches, no hay migraciones de threads, no hay overhead del scheduler. Una tarea corre hasta que voluntariamente hace yield (típicamente en un boundary de I/O), y la siguiente tarea de ese shard arranca. El caché de CPU se mantiene caliente porque el mismo core siempre procesa los mismos datos.
Shard-Per-Core en Práctica: Qué Significa para las Queries
Cuando una query CQL llega a un nodo de ScyllaDB, el coordinador determina qué shard es dueño de la partition key usando un hash:
unsigned shard_of(const partition_key& pk) {
auto token = partitioner().get_token(pk);
return token.raw() % smp::count; // simplificado
}
Si el request cae en el core del shard correcto, el procesamiento es completamente local, sin comunicación cross-core, sin cache line bouncing, sin penalidades NUMA. Los drivers shard-aware que ScyllaDB provee para Java, Go, Python y Rust implementan este mismo hashing del lado del cliente:
# Driver shard-aware de ScyllaDB para Python
from cassandra.cluster import Cluster
from cassandra.policies import TokenAwarePolicy, DCAwareRoundRobinPolicy
cluster = Cluster(
['scylla-node-1', 'scylla-node-2', 'scylla-node-3'],
load_balancing_policy=TokenAwarePolicy(DCAwareRoundRobinPolicy()),
shard_aware_options=ShardAwareOptions()
)
Con ruteo shard-aware, el driver abre una conexión por shard por nodo. En un cluster de 3 nodos con 16 cores cada uno, son 48 conexiones, pero cada conexión va directo al shard correcto, eliminando el overhead del hop del coordinador.
Comparación Real de Latencia p99
Estos benchmarks corrieron en hardware idéntico, 3 nodos, cada uno con 16 vCPUs (c5d.4xlarge en AWS), 32GB RAM, NVMe local. Mismo schema, mismo dataset (800M particiones, ~2TB), mismo workload YCSB.
Workload B (95% lectura, 5% escritura), 50K ops/seg sostenido
p50 p95 p99 p99.9
Cassandra 4.1 1.2ms 4.8ms 28.3ms 312ms
ScyllaDB 5.4 0.4ms 0.9ms 1.8ms 4.2ms
Observar el p99.9. Ese es 312ms en Cassandra? Eso es GC. No hay nada que puedas tunear para que desaparezca, es fundamental a la arquitectura. Los 4.2ms de ScyllaDB en p99.9 es jitter del I/O scheduling, y es predecible.
El write path es aún más dramático porque la memtable de Cassandra es un ConcurrentSkipListMap compartido que se pone contendido bajo cargas write-heavy:
Workload A (50% lectura, 50% escritura), 50K ops/seg sostenido
p50 p95 p99 p99.9
Cassandra 4.1 1.8ms 8.2ms 45.1ms 580ms
ScyllaDB 5.4 0.3ms 0.7ms 1.4ms 3.8ms
La Historia de la Compactación
Cassandra viene con SizeTieredCompactionStrategy (STCS) y LeveledCompactionStrategy (LCS). Las dos tienen problemas conocidos: STCS causa amplificación de espacio temporal hasta 2x, LCS causa amplificación de escritura que mata el throughput.
ScyllaDB introdujo Incremental Compaction Strategy (ICS), que es su default y no tiene equivalente directo en Cassandra. Opera a una granularidad sub-SSTable, en vez de leer y reescribir SSTables enteras, trabaja sobre sorted runs dentro de las SSTables:
Compactación Tradicional (STCS):
SSTable_A (4GB) + SSTable_B (4GB) → SSTable_C (8GB)
Espacio temporal necesario: 16GB (input + output)
Compactación Incremental (ICS):
SSTable_A[run_1] + SSTable_B[run_1] → merged_run_1
SSTable_A[run_2] + SSTable_B[run_2] → merged_run_2
Espacio temporal necesario: ~1GB (un run a la vez)
Esto significa que es posible correr ScyllaDB al 50% de utilización de disco en vez de necesitar 50% libre para headroom de compactación. En un disco NVMe de 2TB, eso es un terabyte extra de almacenamiento usable. Un resultado óptimo.
Guía de Migración: Cassandra a ScyllaDB
Acá va un proceso de migración probado, validado en múltiples entornos de producción. Es menos doloroso de lo esperado.
Paso 1: Verificación de Compatibilidad del Schema
Exportá tu schema de Cassandra y validalo contra ScyllaDB:
cqlsh cassandra-node -e "DESCRIBE KEYSPACE my_keyspace" > schema.cql
# ScyllaDB soporta el 99% de CQL. Principales incompatibilidades:
# - Materialized Views: soportadas pero con garantías de consistencia diferentes
# - SASI indexes: no soportados, utilizar la implementación de secondary index de ScyllaDB
# - Custom types de Java: necesitan UDTs equivalentes
Paso 2: Streaming de SSTables
Para datasets menores a 2TB, la carga directa de SSTables es el camino más rápido:
# En cada nodo de Cassandra, tomá un snapshot
nodetool snapshot -t migration_snap my_keyspace
# Copiá SSTables a los nodos de ScyllaDB (respetando token ranges)
sstableloader -d scylla-node-1,scylla-node-2,scylla-node-3 \
/var/lib/cassandra/data/my_keyspace/my_table-<uuid>/snapshots/migration_snap/
Paso 3: Validación
# Comparar conteo de filas y checksums entre clusters
import hashlib
from cassandra.cluster import Cluster
def checksum_partition_range(session, table, token_start, token_end):
rows = session.execute(f"""
SELECT * FROM {table}
WHERE token(partition_key) >= {token_start}
AND token(partition_key) < {token_end}
""")
h = hashlib.sha256()
count = 0
for row in rows:
h.update(str(row).encode())
count += 1
return count, h.hexdigest()
Paso 4: Cutover de Tráfico
Se recomienda fuertemente una fase de shadow-traffic donde se envía reads a ambos clusters y se compara resultados:
async def dual_read(cassandra_session, scylla_session, query, params):
cass_future = cassandra_session.execute_async(query, params)
scylla_future = scylla_session.execute_async(query, params)
cass_result = cass_future.result()
scylla_result = scylla_future.result()
if cass_result != scylla_result:
log.error(f"Mismatch en query {query}: cassandra={cass_result}, scylla={scylla_result}")
metrics.increment("dual_read.mismatch")
return scylla_result # servir desde ScyllaDB cuando estés seguro
Diferencias Operacionales que Teners que Saber
Algunas diferencias operacionales importantes:
Manejo de memoria: ScyllaDB usa toda la RAM disponible por default para su row cache y no necesita tuning de heap. No pongas límites de memoria a menos que estés compartiendo la máquina (cosa que no es recomendable, ver el tip de arriba).
Priorización de workloads: ScyllaDB soporta service levels que te dejan priorizar workloads por usuario CQL. Esto es increíble para setups multi-tenant:
CREATE SERVICE LEVEL gold WITH timeout = 500ms AND workload_type = 'interactive';
CREATE SERVICE LEVEL batch WITH timeout = 30s AND workload_type = 'batch';
ALTER ROLE analytics_user WITH SERVICE LEVEL batch;
ALTER ROLE api_user WITH SERVICE LEVEL gold;
Repair: El row-level repair de ScyllaDB es significativamente más rápido que el de Cassandra. Lo que tardaba 8 horas en un cluster comparable de Cassandra termina en 45 minutos en ScyllaDB, porque solo streamea las filas que realmente difieren en vez de SSTables enteras.
Cuándo NO Usar ScyllaDB
ScyllaDB no es la elección correcta para:
- Datasets menores a 100GB donde PostgreSQL alcanza y sobra
- Workloads que son 99% full-table scans (utilizar ClickHouse o DuckDB)
- Equipos que necesitan el ecosistema de herramientas de Apache Cassandra sin modificación
Pero si se está corriendo Cassandra hoy y peleándote con tail latencies, pagando nodos extra para compensar pausas del GC, o gastando demasiado tiempo tuneando parámetros del JVM, ScyllaDB es la respuesta que aborda estos problemas directamente.
La migración no es gratis, pero los equipos consistentemente salen del otro lado corriendo menos nodos, con mejores latencias, y con significativamente menos trabajo operativo. Eso no es marketing, es la realidad de producción.