Escalando SignalR: Estado en Memoria Detras de un Load Balancer
Servidores extra detras de un load balancer particionan el estado de SignalR. La cura: backplane de Redis y estado compartido.
Hace unos años yo era el arquitecto de una herramienta de colaboración en vivo. Varios usuarios entraban a la misma sesión, trabajaban durante horas sobre un estado mutable compartido, y todo viajaba por SignalR. El producto creció a más de 10.000 usuarios simultáneos, y decidimos poner más instancias detrás del load balancer.
Ahí empezaron los tickets. Usuarios dentro de la misma sesión no podían verse entre sí. En todas las reuniones aparecían reportes de usuarios aislados o en grupos más chicos de lo que correspondía. Nada crasheaba y nada logueaba un error. El sistema estaba sano según todas las métricas que teníamos.
Puestos uno al lado del otro, los reportes tenían un patrón: los grupos que veían los usuarios eran siempre subconjuntos de su sesión real, nunca grupos equivocados. Ese patrón apuntaba a un solo lugar: nuestro tracking de presencia vivía en un diccionario en memoria, y después del scale-out había quedado particionado en diferentes servidores.
Cada servidor tenía su propia realidad
El diseño era razonable cuando teníamos un solo servidor. Tener un diccionario que mapeaba conexiones a usuarios era razonable.
Escalar horizontalmente lo rompió:
El estado se particionó. Cada instancia mantenía su propio diccionario privado. Un usuario que caía en el servidor dos quedaba escrito en la memoria del servidor dos y en ningún otro lado. Preguntar “quién está en esta sesión” devolvía a los que casualmente compartían tu servidor. Los broadcasts se particionaron. Clients.All en SignalR significa todos los clientes conectados a este proceso. Sin un backplane, una actualización de presencia originada en el servidor dos jamás llegaba a los navegadores conectados a los demás servidores.
Un usuario no recibía una lista incompleta una vez y después se recuperaba. La lista quedaba mal, siempre igual de mal, mientras viviera la conexión, y reconectarse no ayudaba, porque el balanceador te fijaba de vuelta al mismo servidor.
Las sticky sessions resuelven otro problema
Sí, teníamos sticky sessions. Las conexiones nuevas se repartían entre los servidores y después quedaban fijadas, así que cada usuario se quedaba en un servidor mientras durara su conexión. En gran parte por eso el scale-out parecía seguro. Pero la afinidad ata un cliente a un servidor, y aquello de lo que trataba nuestro estado no era un cliente. Era una sesión: un grupo de personas trabajando juntas durante horas. Nada fijaba a los miembros de una misma sesión al mismo servidor, así que el balanceador los desparramó por todo el pool, y cada diccionario guardaba solo el fragmento del grupo que había caído ahí. La afinidad era real y estaba haciendo su trabajo. Lo estaba haciendo en la granularidad equivocada.
Por qué el ambiente de desarrollo no detectó el problema
No lo detectamos en desarrollo porque ahí no se podía detectar. Teníamos un solo servidor de dev. Cada cliente en cada prueba caía en el mismo proceso y compartía el mismo diccionario. Staging era topológicamente idéntico a producción: varias instancias detrás del mismo balanceador, con la misma afinidad. Un tester en una sola computadora es un solo cliente para que el balanceador lo fije, y por lo tanto un solo servidor, así que cada usuario simulado de la prueba compartía proceso exactamente igual que en el servidor único de dev. Lo que faltaba en las pruebas de staging eran más clientes. La suite de tests pasaba porque el código, en una sola instancia, era genuinamente correcto, y nosotros no logramos reproducir las condiciones que los usuarios reales tenían en producción. Este fue un punto serio de nuestro post-mortem, y de ahí salió un checklist nuevo que usamos desde entonces para evaluar cada cambio de infraestructura antes del deploy. El incidente también terminó en una charla: se lo conté a todo el equipo de ingeniería como una lección de sistemas distribuidos, para que el aprendizaje no quedara encerrado en los que lo debuggeamos.
El bug no existió hasta que levantamos las instancias nuevas en producción. Lo creó la topología del deployment, retroactivamente, en código que nadie tocaba hacía meses.
Esa es la parte que vale la pena generalizar. Cada pedazo de estado que guardás en la memoria del proceso arrastra una suposición invisible: todo el tráfico ve esta memoria. Nada en el sistema de tipos registra esa suposición, y los tests no fallan necesariamente cuando deja de valer. El cambio que nos rompió no tocó código de aplicación en absoluto. Era trabajo de capacidad, agregar instancias, el tipo de cambio que se lee como un ticket de ops.
La parte que fue mi responsabilidad
Yo le di luz verde a ese cambio. Los tests de dev pasaban, el plan de rollout se veía limpio, y no hice la única pregunta que importaba: ¿cómo afecta el cambio de infraestructura al código que esa infraestructura sostiene?
Revisar el scale-out como un cambio de diseño, no como un cambio de capacidad, era exactamente mi trabajo, y lo traté como rutina. Los desarrolladores habían implementado el tracker correctamente para el sistema en el que nació, y los tests reportaban con honestidad que funcionaba. El proceso no tenía ningún paso donde alguien fuera a preguntar “¿qué significa este código en N máquinas?”.
Reconstruyendo el bug para que lo puedas correr
No puedo mostrarte el sistema original, así que reconstruí la falla en miniatura: signalr-redis-backplane-demo, un stack de docker-compose con tres instancias de ASP.NET Core detrás de un nginx round-robin, un hub de presencia, y una página que muestra dos cosas: en qué instancia cayó tu pestaña, y a quiénes cree que tiene online. La infraestructura tiene sus propios walkthroughs en el repo (la configuración de nginx y la topología del compose) para que este artículo pueda concentrarse en el bug.
Hay dos diferencias con producción que son deliberadas, y las dos salen de la misma restricción. Producción tenía afinidad y miles de clientes separados para que el balanceador los desparramara. Una laptop tiene un solo cliente, y todas las pestañas de un navegador se ven iguales para el balanceador, así que la afinidad en la repro las fijaría a todas a la misma instancia y escondería el bug. Por eso la repro elimina la afinidad, y como consecuencia tiene que saltear el paso de negotiate de SignalR, porque el handshake por defecto es justamente el que necesita la afinidad; una pestaña queda entonces como un request que cae en una instancia. También reduce la sesión colaborativa a una sola sala global; el fix de producción se quedó en la granularidad de la sesión, con presencia por sesión y actualizaciones enviadas al grupo de esa sesión y no a cada cliente conectado. La invariante que demuestra es la misma: la lista de presencia vive adentro de un proceso, y hay más de un proceso.
La lección entera vive en una interfaz con dos implementaciones:
public interface IPresenceTracker
{
Task AddAsync(string connectionId, string name);
Task RemoveAsync(string connectionId);
Task<IEnumerable<string>> GetAllAsync();
}
La implementación rota es la que nosotros pusimos en producción, reducida a su esencia:
// THE BUG: this dictionary lives inside one process. On a single instance it is
// perfectly correct. Behind a round-robin load balancer each of the three
// instances keeps its own private copy, so a user only ever sees the users who
// happened to land on the same instance. Nothing crashes and nothing logs an
// error -- the app just quietly shows everyone a different reality.
public class InMemoryPresenceTracker : IPresenceTracker
{
private readonly ConcurrentDictionary<string, string> _users = new();
public Task AddAsync(string connectionId, string name)
{
_users[connectionId] = name;
return Task.CompletedTask;
}
public Task RemoveAsync(string connectionId)
{
_users.TryRemove(connectionId, out _);
return Task.CompletedTask;
}
public Task<IEnumerable<string>> GetAllAsync()
{
return Task.FromResult<IEnumerable<string>>(_users.Values.ToList());
}
}
Corré el modo roto y abrí varias pestañas:
docker compose up --build
# open http://localhost:8080 in six tabs, join with different names
Cada pestaña te dice en qué instancia cayó. Pestañas en instancias distintas muestran listas de usuarios distintas, en vivo, sin un solo error en ningún lado. Las pestañas hacen visible el bug; el directorio test/ del repo lo vuelve objetivo: conecta seis clientes de SignalR por script a través de nginx y reporta qué vio cada uno. Esta es la salida de una corrida:
"instanceSpread": {
"app2": ["user1", "user4"],
"app3": ["user2", "user5"],
"app1": ["user3", "user6"]
},
"distinctFinalLists": [
["user1", "user4"],
["user2", "user5"],
["user3", "user6"]
],
"allAgree": false
Seis usuarios, tres respuestas disjuntas. Esto fue lo que nos golpeó en producción, con más de diez mil usuarios concurrentes y sin logs de error. Esa tarde fue un caos.
El fix
Lo resolvimos moviendo el estado fuera del proceso, a Redis. Hay dos problemas separados, y cada uno necesita su propio arreglo:
Los broadcasts necesitan un backplane. AddStackExchangeRedis hace que cada instancia pase sus envíos de Clients.All por el pub/sub de Redis, así una actualización de presencia originada en un servidor llega a los navegadores conectados a todos.
El estado se tiene que ir del proceso. El backplane no hace nada por el diccionario. Si los datos siguen viviendo por proceso, cada instancia sigue respondiendo “quién está online” desde su copia privada. Solo que ahora transmite su respuesta equivocada a más gente. El tracker tiene que leer y escribir almacenamiento compartido.
En la repro, los dos arreglos juntos son esto:
if (mode == "redis")
{
// With abortConnect=false the multiplexer starts even if Redis is not up
// yet and connects in the background.
IConnectionMultiplexer redis =
await ConnectionMultiplexer.ConnectAsync(redisConnection + ",abortConnect=false");
builder.Services.AddSingleton(redis);
// The backplane relays Clients.All broadcasts between instances...
signalR.AddStackExchangeRedis(options => options.ConnectionFactory = _ => Task.FromResult(redis));
// ...and the shared tracker makes every instance agree on who is online.
builder.Services.AddSingleton<IPresenceTracker, RedisPresenceTracker>();
}
public class RedisPresenceTracker : IPresenceTracker
{
private const string Key = "presence";
private readonly IDatabase _db;
public RedisPresenceTracker(IConnectionMultiplexer redis)
{
_db = redis.GetDatabase();
}
public Task AddAsync(string connectionId, string name)
{
return _db.HashSetAsync(Key, connectionId, name);
}
public Task RemoveAsync(string connectionId)
{
return _db.HashDeleteAsync(Key, connectionId);
}
public async Task<IEnumerable<string>> GetAllAsync()
{
var entries = await _db.HashGetAllAsync(Key);
return entries.Select(e => e.Value.ToString()).ToList();
}
}
Mismo stack, mismo nginx, mismas tres instancias:
docker compose down
PRESENCE_MODE=redis docker compose up
Abrí las mismas pestañas otra vez. Ahora todas muestran a todos los usuarios, sin importar en qué instancia cayó cada una.
Dónde te deja esto
El diccionario era la decisión correcta cuando había un solo servidor. La regla que rompió recién entró en vigencia más tarde: un proceso puede escalar horizontalmente solo mientras el estado que guarda sea una copia descartable de una verdad que vive en otro lado, o datos limitados a una sola conexión. Todo lo demás convierte al proceso en la autoridad de esos datos. Nuestro diccionario era la autoridad sobre “quién está online”, así que agregar instancias creó en silencio muchas autoridades donde el diseño suponía una, particionadas por servidor cuando aquello sobre lo que eran autoridad era una sesión. A nada en el sistema se le había pedido nunca que notara la diferencia.
El fix tiene costos reales, y vale más nombrarlos que presentar a Redis como un final feliz. Ahora, cada vez que alguien entra o sale se paga una ida y vuelta por la red donde antes había una lectura de diccionario. Redis se vuelve una dependencia que hay que dimensionar, monitorear y hacer redundante, porque centralizar la verdad también centraliza el dominio de falla. Los broadcasts cruzan la red dos veces, una hacia el backplane y otra hacia cada instancia. Las entradas de presencia compartidas sobreviven a una instancia que muere de golpe salvo que algo las limpie, y por eso los sistemas reales acompañan este patrón con un TTL o un heartbeat. Y el backplane mismo es pub/sub con entrega at-most-once, así que un mensaje que se pierde deja a un cliente con una lista vieja hasta el próximo evento; el fix no elimina la divergencia silenciosa, la mueve detrás de una dependencia que podés monitorear. Si preferís comprar en vez de operar, eso es lo que te da un servicio administrado como Azure SignalR Service para la mitad del problema, la de conexiones y broadcasts. El estado igual tiene que vivir en algún lugar compartido.
Las sticky sessions no son el atajo para esquivar nada de esto; ya las teníamos y las mantuvimos después. Además, todas las asignaciones se vuelven a sortear en el próximo deploy o failover, así que incluso la estabilidad por cliente que te dan dura lo que dura la topología.
Te dejo una sola pregunta. Para cada static, singleton y caché en un sistema que corre en más de una instancia: ¿esta memoria es la autoridad de algo, o una copia?