Cómo crear un ranking web en tiempo real con cache para MU Online
Monta un ranking web de personajes y guilds para MU Online que parece actualizarse en tiempo real, usando cache en capas, un endpoint JSON y actualización incremental en el front-end sin tumbar el SQL Server.
Un ranking que traba la página por tres segundos, muestra datos de hace diez minutos y encima deja el juego lagueado es la carta de presentación equivocada para cualquier servidor de MU Online. En este tutorial vas a montar un ranking web que da la sensación de actualizarse solo, protege el SQL Serv
Un ranking que traba la página por tres segundos, muestra datos de hace diez minutos y encima deja el juego lagueado es la carta de presentación equivocada para cualquier servidor de MU Online. En este tutorial vas a montar un ranking web que da la sensación de actualizarse solo, protege el SQL Server con cache en capas y sirve los datos por un endpoint JSON que el front-end consume de forma incremental. La arquitectura funciona igual en servidores Season 6 clásicos y en versiones modernas; lo que cambia es el nombre de algunas columnas, y eso varía por versión. El foco aquí es la ingeniería web: cómo transformar una query cara en una experiencia ligera y fluida.
La idea central es separar tres responsabilidades que normalmente quedan pegadas en el mismo ranking.php: la lectura de la base de datos, el almacenamiento temporal del resultado y la entrega al navegador. Cuando esas capas están desacopladas, puedes cambiar la fuente de datos, ajustar el tiempo de vida del cache y cambiar el visual sin reescribir todo. Es esa separación la que permite que el ranking parezca en tiempo real sin cobrar ese costo a la base en cada clic.
Requisitos previos
Antes de escribir cualquier línea, garantiza que el entorno base está en pie. Si todavía no tienes el servidor corriendo, empieza por la guía de cómo crear un servidor de MU Online y vuelve aquí después.
- Servidor MU Online funcional con SQL Server accesible (2008, 2014, 2017 o 2019) y la base
MuOnlinecon las tablasCharacter,MEMB_INFO,GuildyGuildMember/G_UserList. - Servidor web con PHP 7.4+ (idealmente 8.1), con la extensión
sqlsrvopdo_sqlsrvinstalada y habilitada. - Usuario SQL de solo lectura dedicado al sitio, sin permiso de escritura en las tablas del juego.
- Permiso de escritura en una carpeta de cache fuera del webroot, por ejemplo
../cache/. - Nociones de HTML, CSS y JavaScript moderno (
fetch,async/await). - Opcional: Redis instalado, en caso de que ya sepas que vas a escalar a múltiples nodos web.
Confirma la extensión de PHP con un archivo temporal que contenga <?php phpinfo(); y busca sqlsrv. Si no aparece, instala el driver de Microsoft correspondiente a tu versión de PHP antes de continuar. Sin ese driver nada de lo de abajo funciona.
Arquitectura en capas del ranking
Piensa en el flujo de una solicitud de ranking como una pila. En la cima está el navegador del jugador. Debajo, el endpoint PHP que responde JSON. Debajo de él, la capa de cache. Y solo en el fondo está el SQL Server. La regla de oro es: cuanto más profundo tenga que bajar la solicitud, más cara es. El objetivo es hacer que el 95% de las solicitudes se detengan en la capa de cache y nunca toquen la base.
| Capa | Responsabilidad | Costo | Frecuencia ideal de acceso |
|---|---|---|---|
| Navegador | Renderizar y hacer polling | Bajísimo | Cada 30s por jugador |
| Endpoint JSON | Validar parámetros y formatear | Bajo | Cada solicitud |
| Cache (archivo/Redis) | Guardar el resultado listo | Bajo | Cada solicitud |
| SQL Server | Ejecutar ORDER BY pesado | Alto | 1x por TTL |
Con un TTL de 60 segundos, aunque 500 jugadores actualicen el ranking en ese minuto, la base se consulta una sola vez. Las otras 499 solicitudes se sirven del cache en microsegundos. Esa es la diferencia entre un ranking que aguanta un lanzamiento con pico de accesos y uno que tumba el servidor el primer día.
Conexión y capa de acceso a la base de datos
Aísla la conexión en un único archivo que todo lo demás incluye. Nunca esparzas credenciales por el código.
<?php
// config/db.php
define('DB_HOST', 'localhost');
define('DB_NAME', 'MuOnline');
define('DB_USER', 'site_readonly'); // usuario de solo lectura
define('DB_PASS', 'senha_forte_aqui');
function getDB(): mixed {
static $conn = null;
if ($conn !== null) return $conn;
$conn = sqlsrv_connect(DB_HOST, [
'Database' => DB_NAME,
'UID' => DB_USER,
'PWD' => DB_PASS,
'CharacterSet' => 'UTF-8',
'ConnectionPooling' => 1, // reaprovecha conexiones
'LoginTimeout' => 5,
]);
if ($conn === false) {
http_response_code(503);
exit(json_encode(['erro' => 'Banco indisponível']));
}
return $conn;
}
El uso de static garantiza que dentro de la misma solicitud la conexión se abra una única vez. El ConnectionPooling deja que el driver reutilice conexiones entre solicitudes, lo que reduce el costo de handshake. El LoginTimeout corto evita que una caída momentánea del SQL trabe la página por 30 segundos.
La query de ranking bien escrita
La query es el punto más sensible para el rendimiento. Los nombres de columnas varían por versión: en muchas Seasons el campo de resets es Resets, en otras es ResetCount o está en una tabla separada como CharacterReset. Ajusta conforme a tu schema.
SELECT TOP 100
ROW_NUMBER() OVER (ORDER BY c.Resets DESC, c.cLevel DESC) AS Posicao,
c.Name AS nome,
c.Class AS classe,
c.cLevel AS level,
c.Resets AS resets,
c.ConnectStat AS online,
g.G_Name AS guild
FROM Character c
INNER JOIN MEMB_INFO m ON m.memb___id = c.AccountID
LEFT JOIN GuildMember g ON g.Name = c.Name
WHERE c.CtlCode = 0 -- excluye GM/admin
AND m.bloc_code = 0 -- excluye cuentas baneadas
ORDER BY c.Resets DESC, c.cLevel DESC;
Dos cuidados marcan la diferencia aquí. Primero, el filtro de CtlCode y bloc_code ocurre en la base: los datos de GMs y baneados nunca llegan al PHP. Segundo, para que ese ORDER BY no haga un barrido completo cada vez, crea un índice de apoyo:
CREATE INDEX IX_Character_Ranking
ON Character (Resets DESC, cLevel DESC)
INCLUDE (Name, Class, ConnectStat, AccountID);
El índice INCLUDE transforma la consulta en un index-only scan en la mayoría de los casos, o sea, el SQL responde sin tocar la tabla base. En bases con decenas de miles de personajes, ese índice por sí solo puede reducir el tiempo de la query de segundos a pocos milisegundos.
Implementando el cache en archivo
El cache en archivo es simple, confiable y no necesita un servicio extra. La función de abajo encapsula todo el patrón de "intenta el cache, si expiró recalcula".
<?php
// lib/cache.php
function cacheRemember(string $chave, int $ttl, callable $callback): array {
$dir = __DIR__ . '/../cache';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$arquivo = "$dir/" . preg_replace('/[^a-z0-9_]/i', '_', $chave) . '.json';
// 1) ¿Cache válido?
if (is_file($arquivo) && (time() - filemtime($arquivo)) < $ttl) {
$dados = json_decode(file_get_contents($arquivo), true);
if (is_array($dados)) return $dados;
}
// 2) Recalcula
$dados = $callback();
// 3) Graba de forma atómica (evita cache corrompido en concurrencia)
$tmp = $arquivo . '.' . uniqid('', true) . '.tmp';
file_put_contents($tmp, json_encode($dados));
rename($tmp, $arquivo); // rename es atómico en el mismo filesystem
return $dados;
}
El detalle del rename atómico es importante. Bajo concurrencia, dos procesos pueden intentar reescribir el mismo archivo de cache al mismo tiempo. Escribiendo primero en un archivo temporal y luego renombrando, garantizas que ningún lector toma un JSON a medias. Es un error clásico que genera bugs intermitentes difíciles de reproducir.
El endpoint JSON
Ahora amarra todo en un endpoint que el front-end va a consumir. Valida la pestaña pedida, aplica el cache y devuelve JSON puro.
<?php
// api/ranking.php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../lib/cache.php';
$abasValidas = ['resets', 'level', 'guild', 'online'];
$aba = in_array($_GET['aba'] ?? '', $abasValidas, true) ? $_GET['aba'] : 'resets';
$ttl = ['resets' => 60, 'level' => 60, 'guild' => 120, 'online' => 20][$aba];
$resultado = cacheRemember("ranking_$aba", $ttl, function () use ($aba) {
$conn = getDB();
$ordem = match ($aba) {
'level' => 'c.cLevel DESC',
'online' => 'c.ConnectStat DESC, c.cLevel DESC',
default => 'c.Resets DESC, c.cLevel DESC',
};
$sql = "SELECT TOP 100
ROW_NUMBER() OVER (ORDER BY $ordem) AS pos,
c.Name AS nome, c.Class AS classe,
c.cLevel AS level, c.Resets AS resets, c.ConnectStat AS online
FROM Character c
INNER JOIN MEMB_INFO m ON m.memb___id = c.AccountID
WHERE c.CtlCode = 0 AND m.bloc_code = 0
ORDER BY $ordem";
$stmt = sqlsrv_query($conn, $sql);
$linhas = [];
while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) {
$linhas[] = [
'pos' => (int)$row['pos'],
'nome' => $row['nome'],
'classe' => nomeClasse((int)$row['classe']),
'level' => (int)$row['level'],
'resets' => (int)$row['resets'],
'online' => (bool)$row['online'],
];
}
return ['gerado_em' => date('c'), 'itens' => $linhas];
});
echo json_encode($resultado);
Fíjate en que la variable $aba solo asume uno de los valores de la lista blanca antes de entrar en la string SQL. Eso es lo que impide la inyección de SQL por la query string: como el valor nunca llega crudo del usuario al ORDER BY, no hay vector de ataque. Nunca concatenes $_GET directamente en SQL; usa lista blanca para nombres de columna y parámetros vinculados para valores.
La función nomeClasse() traduce el código numérico de la clase a texto. El mapa exacto varía por versión, pero sigue la lógica de código base más evolución:
<?php
function nomeClasse(int $c): string {
$mapa = [
0 => 'Dark Wizard', 1 => 'Soul Master', 2 => 'Grand Master',
16 => 'Dark Knight', 17 => 'Blade Knight', 18 => 'Blade Master',
32 => 'Fairy Elf', 33 => 'Muse Elf', 34 => 'High Elf',
48 => 'Magic Gladiator', 64 => 'Dark Lord',
];
return $mapa[$c] ?? 'Desconhecido';
}
Actualización incremental en el front-end
Del lado del navegador, el truco para parecer tiempo real es hacer polling del endpoint y actualizar solo lo que cambió, sin recargar la página. Así la lista se reorganiza con una animación suave en vez de parpadear.
<table id="rank"><tbody></tbody></table>
<script>
const corpo = document.querySelector('#rank tbody');
let aba = 'resets';
async function atualizar() {
try {
const r = await fetch(`/api/ranking.php?aba=${aba}`, { cache: 'no-store' });
const { itens } = await r.json();
renderiza(itens);
} catch (e) {
console.warn('Falha ao atualizar ranking', e);
}
}
function renderiza(itens) {
const html = itens.map(i => `
<tr class="${i.online ? 'online' : ''}">
<td>${i.pos}</td>
<td>${i.nome}</td>
<td>${i.classe}</td>
<td>${i.level}</td>
<td>${i.resets}</td>
</tr>`).join('');
corpo.innerHTML = html;
}
atualizar(); // primera carga
setInterval(atualizar, 30000); // repolling cada 30s
</script>
Como el endpoint ya responde del cache la mayoría de las veces, ese polling de 30 segundos por jugador casi no le cuesta nada al servidor: cada solicitud toca solo el archivo de cache. El jugador, por otro lado, ve el ranking cambiar solo mientras tiene la pestaña abierta, y esa es la percepción de tiempo real que querías entregar.
Para una transición visual más refinada, puedes comparar la lista antigua con la nueva y aplicar clases CSS de "subió" o "bajó" en las filas que cambiaron de posición, creando ese efecto de marcador en vivo. Eso es pulido opcional, pero barato de implementar sobre la base que ya tenemos.
Precalentamiento de cache y el problema de la estampida
Existe una trampa sutil en el cache con TTL: cuando la clave expira justo en el pico, varias solicitudes simultáneas encuentran el cache vencido al mismo tiempo y todas corren a la base de una vez. Esto se llama cache stampede y puede tumbar el SQL justamente en el momento de mayor movimiento.
La solución más robusta es precalentar el cache por fuera, con una tarea programada que regenera el ranking a intervalo fijo, independientemente de las visitas. El sitio entonces siempre lee un cache ya listo.
<?php
// cron/aquecer.php — corre vía Programador de Tareas de Windows cada 60s
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../lib/cache.php';
foreach (['resets', 'level', 'guild', 'online'] as $aba) {
cacheRemember("ranking_$aba", 0, function () use ($aba) {
// TTL 0 fuerza la regeneración; misma lógica del endpoint
// ... consulta a la base de datos ...
return gerarRanking($aba);
});
}
Con el precalentamiento externo, el TTL del endpoint puede ser generoso porque el cron mantiene todo fresco. Las visitas nunca disparan la query pesada; a lo sumo leen un cache con pocos segundos de antigüedad. Esa es la arquitectura que sostiene rankings de servidores grandes sin sobresaltos.
Seguridad de la capa de ranking
El ranking es público, pero eso no significa descuido. Tres principios: el usuario SQL del sitio solo tiene SELECT, así que incluso una falla de código no permite alterar el juego; ningún campo sensible como contraseña, correo o serial sale en las queries; y todo parámetro proveniente del navegador pasa por lista blanca o binding. Además, coloca la carpeta cache/ fuera del webroot o protégela con .htaccess/regla de IIS negando el acceso HTTP directo a los archivos .json de cache, para que nadie descargue el dump serializado.
Vale también limitar la tasa de solicitudes al endpoint. Un jugador legítimo pide el ranking cada 30 segundos; un script abusivo puede pedir 100 veces por segundo. Un rate limit simple por IP, contado en un archivo o en el propio Redis, evita que alguien use el endpoint para presionar tu servidor.
Errores comunes y soluciones
| Síntoma | Causa probable | Solución |
|---|---|---|
| La página se traba por segundos al abrir | Query sin índice y sin cache | Crea el índice IX_Character_Ranking y activa el cache en archivo |
| El ranking muestra datos demasiado viejos | TTL muy alto o cache no precalentado | Reduce el TTL y agrega el cron de precalentamiento |
| JSON roto a veces | Escritura de cache no atómica | Escribe en .tmp y usa rename() |
| Los GMs aparecen en el tope | Falta el filtro CtlCode | Agrega WHERE c.CtlCode = 0 a la query |
El error sqlsrv_connect devuelve false | Driver ausente o credencial errada | Instala el driver sqlsrv y verifica usuario/contraseña |
| Lag en el juego en hora pico | El sitio consulta la base sin cache | Confirma que el 95% de las solicitudes se detienen en el cache |
| Los acentos aparecen como caracteres extraños | Charset divergente | Fuerza CharacterSet => UTF-8 en la conexión y charset=utf-8 en el header |
Lista de verificación de lanzamiento
- Extensión
sqlsrvhabilitada y probada conphpinfo() - Usuario SQL del sitio con permiso solo de
SELECT - Índice
IX_Character_Rankingcreado y verificado en el plan de ejecución - Filtros
CtlCode = 0ybloc_code = 0presentes en todas las queries - Cache en archivo funcionando con escritura atómica vía
rename() - Carpeta
cache/inaccesible por HTTP directo - Endpoint JSON validando la pestaña por lista blanca
- Front-end haciendo polling cada 30 segundos con
cache: no-store - Cron de precalentamiento corriendo cada 60 segundos
- Rate limit por IP configurado en el endpoint
- Ningún campo sensible (contraseña, correo, serial) expuesto en el JSON
- Prueba de carga simulando pico sin lag perceptible en el juego
Con esta estructura, tu ranking entrega la experiencia fluida de un marcador en vivo mientras protege el SQL Server del peso de las consultas. La clave está siempre en la misma idea: calcula el resultado caro rara vez, sirve el resultado listo siempre.
Preguntas frecuentes
¿El ranking realmente se actualiza en tiempo real?
No existe tiempo real absoluto en un ranking web; lo que se hace es reducir la latencia percibida. Con cache de 30 a 60 segundos y polling del front-end vía fetch, el jugador ve la lista cambiar sola sin recargar la página, lo que da la sensación de tiempo real sin sobrecargar la base de datos.
¿Por qué no consultar la base de datos en cada solicitud?
Porque la query de ranking hace ordenación sobre la tabla Character entera, y en hora pico puedes tener cientos de accesos por minuto. Sin cache, cada visita dispara un ORDER BY costoso que compite con el GameServer por el mismo SQL Server, causando lag en el juego.
¿Qué TTL de cache debo usar?
Depende del movimiento. Para el ranking de resets usa 60 a 300 segundos; para la lista de online, 15 a 30 segundos. El intervalo exacto varía por versión y por cantidad de jugadores, pero nunca lo dejes por debajo de 10 segundos para el ranking pesado.
¿Necesito Redis o el cache en archivo resuelve?
El cache en archivo resuelve para la mayoría de los servidores privados de MU. Redis solo compensa cuando tienes múltiples servidores web detrás de un balanceador o cuando el número de claves de cache crece mucho. Empieza simple y migra cuando midas la necesidad.
¿Cómo evito que el ranking muestre GMs y cuentas baneadas?
Filtra siempre por CtlCode = 0 en la tabla Character y por bloc_code = 0 en MEMB_INFO dentro de la query. Nunca confíes en el front-end para esconder registros; el filtrado tiene que ocurrir en el SQL para que el dato sensible ni siquiera salga de la base.