Como criar um ranking web em tempo real com cache para MU Online
Monte um ranking web de personagens e guilds para MU Online que parece atualizar em tempo real, usando cache em camadas, um endpoint JSON e atualização incremental no front-end sem derrubar o SQL Server.
Um ranking que trava a página por três segundos, mostra dados de dez minutos atrás e ainda deixa o jogo lagado é o cartão de visita errado para qualquer servidor de MU Online. Neste tutorial você vai montar um ranking web que dá a sensação de atualizar sozinho, protege o SQL Server com cache em cama
Um ranking que trava a página por três segundos, mostra dados de dez minutos atrás e ainda deixa o jogo lagado é o cartão de visita errado para qualquer servidor de MU Online. Neste tutorial você vai montar um ranking web que dá a sensação de atualizar sozinho, protege o SQL Server com cache em camadas e serve os dados por um endpoint JSON que o front-end consome de forma incremental. A arquitetura funciona igual em servidores Season 6 clássicos e em versões modernas; o que muda é o nome de algumas colunas, e isso varia por versão. O foco aqui é a engenharia web: como transformar uma query cara em uma experiência leve e fluida.
A ideia central é separar três responsabilidades que normalmente ficam grudadas no mesmo ranking.php: a leitura do banco, o armazenamento temporário do resultado e a entrega ao navegador. Quando essas camadas estão desacopladas, você consegue trocar a fonte de dados, ajustar o tempo de vida do cache e mudar o visual sem reescrever tudo. É essa separação que permite o ranking parecer em tempo real sem cobrar esse custo do banco a cada clique.
Pré-requisitos
Antes de escrever qualquer linha, garanta que o ambiente base está de pé. Se você ainda não tem o servidor rodando, comece pelo guia de como criar servidor de MU Online e volte aqui depois.
- Servidor MU Online funcional com SQL Server acessível (2008, 2014, 2017 ou 2019) e o banco
MuOnlinecom as tabelasCharacter,MEMB_INFO,GuildeGuildMember/G_UserList. - Servidor web com PHP 7.4+ (idealmente 8.1), com a extensão
sqlsrvoupdo_sqlsrvinstalada e habilitada. - Usuário SQL somente-leitura dedicado ao site, sem permissão de escrita nas tabelas do jogo.
- Permissão de escrita em uma pasta de cache fora do webroot, por exemplo
../cache/. - Noções de HTML, CSS e JavaScript moderno (
fetch,async/await). - Opcional: Redis instalado, caso você já saiba que vai escalar para múltiplos nós web.
Confirme a extensão do PHP com um arquivo temporário contendo <?php phpinfo(); e procure por sqlsrv. Se não aparecer, instale o driver da Microsoft correspondente à sua versão do PHP antes de continuar. Sem esse driver nada abaixo funciona.
Arquitetura em camadas do ranking
Pense no fluxo de uma requisição de ranking como uma pilha. No topo está o navegador do jogador. Abaixo, o endpoint PHP que responde JSON. Abaixo dele, a camada de cache. E só no fundo está o SQL Server. A regra de ouro é: quanto mais fundo a requisição precisa descer, mais cara ela é. O objetivo é fazer 95% das requisições pararem na camada de cache e nunca tocarem o banco.
| Camada | Responsabilidade | Custo | Frequência ideal de acesso |
|---|---|---|---|
| Navegador | Renderizar e fazer polling | Baixíssimo | A cada 30s por jogador |
| Endpoint JSON | Validar parâmetros e formatar | Baixo | Toda requisição |
| Cache (arquivo/Redis) | Guardar resultado pronto | Baixo | Toda requisição |
| SQL Server | Executar ORDER BY pesado | Alto | 1x por TTL |
Com um TTL de 60 segundos, mesmo que 500 jogadores atualizem o ranking nesse minuto, o banco é consultado apenas uma vez. As outras 499 requisições são servidas do cache em microssegundos. Essa é a diferença entre um ranking que aguenta um lançamento com pico de acessos e um que derruba o servidor no primeiro dia.
Conexão e camada de acesso ao banco
Isole a conexão em um arquivo único que todo o resto inclui. Nunca espalhe credenciais pelo código.
<?php
// config/db.php
define('DB_HOST', 'localhost');
define('DB_NAME', 'MuOnline');
define('DB_USER', 'site_readonly'); // usuário somente-leitura
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, // reaproveita conexões
'LoginTimeout' => 5,
]);
if ($conn === false) {
http_response_code(503);
exit(json_encode(['erro' => 'Banco indisponível']));
}
return $conn;
}
O uso de static garante que dentro da mesma requisição a conexão seja aberta uma única vez. O ConnectionPooling deixa o driver reutilizar conexões entre requisições, o que reduz o custo de handshake. O LoginTimeout curto evita que uma queda momentânea do SQL trave a página por 30 segundos.
A query de ranking bem escrita
A query é o ponto mais sensível para performance. Nomes de colunas variam por versão: em muitas Seasons o campo de resets é Resets, em outras é ResetCount ou fica em uma tabela separada como CharacterReset. Ajuste conforme o seu 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 -- exclui GM/admin
AND m.bloc_code = 0 -- exclui contas banidas
ORDER BY c.Resets DESC, c.cLevel DESC;
Dois cuidados fazem diferença aqui. Primeiro, o filtro de CtlCode e bloc_code acontece no banco: dados de GMs e banidos nunca chegam ao PHP. Segundo, para que esse ORDER BY não faça varredura completa toda vez, crie um índice de apoio:
CREATE INDEX IX_Character_Ranking
ON Character (Resets DESC, cLevel DESC)
INCLUDE (Name, Class, ConnectStat, AccountID);
O índice INCLUDE transforma a consulta em um index-only scan na maioria dos casos, ou seja, o SQL responde sem tocar na tabela base. Em bancos com dezenas de milhares de personagens, esse índice sozinho pode reduzir o tempo da query de segundos para poucos milissegundos.
Implementando o cache em arquivo
O cache em arquivo é simples, confiável e não precisa de serviço extra. A função abaixo encapsula todo o padrão de "tenta o cache, se expirou 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) Grava de forma atômica (evita cache corrompido em concorrência)
$tmp = $arquivo . '.' . uniqid('', true) . '.tmp';
file_put_contents($tmp, json_encode($dados));
rename($tmp, $arquivo); // rename é atômico no mesmo filesystem
return $dados;
}
O detalhe do rename atômico é importante. Sob concorrência, dois processos podem tentar reescrever o mesmo arquivo de cache ao mesmo tempo. Escrevendo primeiro em um arquivo temporário e depois renomeando, você garante que nenhum leitor pega um JSON pela metade. É um erro clássico que gera bugs intermitentes difíceis de reproduzir.
O endpoint JSON
Agora amarre tudo em um endpoint que o front-end vai consumir. Ele valida a aba pedida, aplica o cache e devolve 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);
Repare que a variável $aba só assume um dos valores da lista branca antes de entrar na string SQL. Isso é o que impede injeção de SQL pela query string: como o valor nunca vem cru do usuário para dentro do ORDER BY, não há vetor de ataque. Nunca concatene $_GET diretamente em SQL; use lista branca para nomes de coluna e parâmetros vinculados para valores.
A função nomeClasse() traduz o código numérico da classe para texto. O mapa exato varia por versão, mas segue a lógica de código base mais evolução:
<?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';
}
Atualização incremental no front-end
Do lado do navegador, o truque para parecer tempo real é fazer polling do endpoint e atualizar só o que mudou, sem recarregar a página. Assim a lista se reorganiza com uma animação suave em vez de piscar.
<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(); // primeira carga
setInterval(atualizar, 30000); // repolling a cada 30s
</script>
Como o endpoint já responde do cache na maioria das vezes, esse polling de 30 segundos por jogador custa quase nada ao servidor: cada requisição toca só o arquivo de cache. O jogador, por outro lado, vê o ranking mudar sozinho enquanto está com a aba aberta, e essa é a percepção de tempo real que você queria entregar.
Para uma transição visual mais refinada, você pode comparar a lista antiga com a nova e aplicar classes CSS de "subiu" ou "desceu" nas linhas que mudaram de posição, criando aquele efeito de placar ao vivo. Isso é polimento opcional, mas barato de implementar sobre a base que já temos.
Aquecimento de cache e o problema da estampida
Existe uma armadilha sutil no cache com TTL: quando a chave expira exatamente no pico, várias requisições simultâneas encontram o cache vencido ao mesmo tempo e todas correm para o banco de uma vez. Isso se chama cache stampede e pode derrubar o SQL justamente no momento de maior movimento.
A solução mais robusta é aquecer o cache por fora, com uma tarefa agendada que regenera o ranking em intervalo fixo, independentemente das visitas. O site então sempre lê um cache já pronto.
<?php
// cron/aquecer.php — rode via Agendador de Tarefas do Windows a 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 força regeneração; mesma lógica do endpoint
// ... consulta ao banco ...
return gerarRanking($aba);
});
}
Com o aquecimento externo, o TTL do endpoint pode ser generoso porque o cron mantém tudo fresco. As visitas nunca disparam a query pesada; no máximo leem um cache com poucos segundos de idade. Essa é a arquitetura que sustenta rankings de servidores grandes sem sustos.
Segurança da camada de ranking
O ranking é público, mas isso não significa descuido. Três princípios: o usuário SQL do site só tem SELECT, então mesmo uma falha de código não permite alterar o jogo; nenhum campo sensível como senha, e-mail ou serial sai nas queries; e todo parâmetro vindo do navegador passa por lista branca ou binding. Além disso, coloque a pasta cache/ fora do webroot ou proteja com .htaccess/regra do IIS negando acesso HTTP direto aos arquivos .json de cache, para que ninguém baixe o dump serializado.
Vale também limitar a taxa de requisições ao endpoint. Um jogador legítimo pede o ranking a cada 30 segundos; um script abusivo pode pedir 100 vezes por segundo. Um rate limit simples por IP, contado em um arquivo ou no próprio Redis, evita que alguém use o endpoint para pressionar seu servidor.
Erros comuns e soluções
| Sintoma | Causa provável | Solução |
|---|---|---|
| Página trava por segundos ao abrir | Query sem índice e sem cache | Crie o índice IX_Character_Ranking e ative o cache em arquivo |
| Ranking mostra dados antigos demais | TTL muito alto ou cache não aquecido | Reduza o TTL e adicione o cron de aquecimento |
| JSON quebrado às vezes | Escrita de cache não atômica | Escreva em .tmp e use rename() |
| GMs aparecem no topo | Falta filtro CtlCode | Adicione WHERE c.CtlCode = 0 na query |
Erro sqlsrv_connect retorna false | Driver ausente ou credencial errada | Instale o driver sqlsrv e confira usuário/senha |
| Lag no jogo em horário de pico | Site consultando o banco sem cache | Confirme que 95% das requisições param no cache |
| Acentos aparecem como caracteres estranhos | Charset divergente | Force CharacterSet => UTF-8 na conexão e charset=utf-8 no header |
Checklist de lançamento
- Extensão
sqlsrvhabilitada e testada comphpinfo() - Usuário SQL do site com permissão apenas de
SELECT - Índice
IX_Character_Rankingcriado e verificado no plano de execução - Filtros
CtlCode = 0ebloc_code = 0presentes em todas as queries - Cache em arquivo funcionando com escrita atômica via
rename() - Pasta
cache/inacessível por HTTP direto - Endpoint JSON validando a aba por lista branca
- Front-end fazendo polling a cada 30 segundos com
cache: no-store - Cron de aquecimento rodando a cada 60 segundos
- Rate limit por IP configurado no endpoint
- Nenhum campo sensível (senha, e-mail, serial) exposto no JSON
- Teste de carga simulando pico sem lag perceptível no jogo
Com essa estrutura, seu ranking entrega a experiência fluida de um placar ao vivo enquanto protege o SQL Server do peso das consultas. A chave está sempre na mesma ideia: calcule o resultado caro raramente, sirva o resultado pronto sempre.
Perguntas frequentes
O ranking realmente atualiza em tempo real?
Não existe tempo real absoluto em um ranking web; o que se faz é reduzir a latência percebida. Com cache de 30 a 60 segundos e polling do front-end via fetch, o jogador vê a lista mudar sozinha sem recarregar a página, o que dá a sensação de tempo real sem sobrecarregar o banco.
Por que não consultar o banco a cada requisição?
Porque a query de ranking faz ordenação sobre a tabela Character inteira, e em horário de pico você pode ter centenas de acessos por minuto. Sem cache, cada visita dispara um ORDER BY custoso que compete com o GameServer pelo mesmo SQL Server, causando lag no jogo.
Qual TTL de cache devo usar?
Depende do movimento. Para ranking de resets use 60 a 300 segundos; para lista de online use 15 a 30 segundos. O intervalo exato varia por versão e por quantidade de jogadores, mas nunca deixe abaixo de 10 segundos para o ranking pesado.
Preciso de Redis ou o cache em arquivo resolve?
O cache em arquivo resolve para a maioria dos servidores privados de MU. Redis só compensa quando você tem múltiplos servidores web atrás de um balanceador ou quando o número de chaves de cache cresce muito. Comece simples e migre quando medir necessidade.
Como evito que o ranking mostre GMs e contas banidas?
Filtre sempre por CtlCode = 0 na tabela Character e por bloc_code = 0 na MEMB_INFO dentro da query. Nunca confie no front-end para esconder registros; a filtragem tem que acontecer no SQL para que o dado sensível nem saia do banco.