DataStore sem perder progresso: buffer em memória, jitter e UpdateAsync
As boas práticas oficiais de DataStore cabem em quatro decisões: poucas stores, uma chave por jogador, cópia em memória salva por intervalo e UpdateAsync no lugar de SetAsync. Este guia explica o porquê de cada uma e o que quebra sem elas.
Produzido com IA a partir das fontes citadas abaixo, e conferido por checagens automáticas contra essas mesmas fontes. Nenhuma pessoa leu este texto antes da publicação. Política Editorial

Quase todo jogo que perde progresso de jogador perde pelo mesmo motivo: o código salva do jeito mais direto possível. SetAsync a cada mudança, uma store por sistema, um laço que roda de minuto em minuto em todos os servidores ao mesmo tempo. Funciona com dez jogadores. Com mil, não.
A página oficial de boas práticas de DataStore é longa, mas o miolo dela cabe em quatro decisões de desenho. Este guia percorre as quatro, com o motivo de cada uma — porque a regra sem o motivo é a primeira coisa que se abandona sob pressão.
O problema
O primeiro ajuste de cabeça é entender o que um DataStore é. A documentação dá a analogia: DataStore se comporta de forma parecida com tabela de banco de dados. Não é uma pasta de arquivos, e não é um lugar onde criar um item novo é barato.
Daí sai a recomendação que contraria o instinto de organização: usar um conjunto pequeno e fixo de stores, organizando os registros por chave. A tentação de criar uma store por sistema — uma para moedas, uma para inventário, uma para conquistas — produz um jogo que faz três requisições onde cabia uma, e que não consegue salvar as três juntas.
O mesmo vale por jogador. Os dados persistentes de cada jogador devem caber em uma chave só, respeitado o limite de 4 MB por objeto. A documentação dá três motivos, e o terceiro é o que salva madrugada: esse padrão reduz requisições, permite atualizar valores relacionados de forma atômica, e facilita reverter. Quando o dado de um jogador está espalhado, não existe "voltar ao estado de ontem" — existe voltar seis pedaços e torcer.
A regra que decide quando dividir é uma frase só: o que precisa mudar junto de forma atômica fica na mesma chave. Moeda e inventário mudam juntos numa compra, então moram juntos. Estatística de telemetria não muda junto com nada, então pode sair.
Passo a passo
1. Nomeie a chave a partir de algo que não muda. O nome deve vir de identificador estável e padrão fixo, como User_{UserId}. Nome de exibição está proibido pelo motivo óbvio — ele muda, e a chave vira órfã.
Há dois ganhos menos óbvios. Padrão fixo torna a chave previsível entre servidores e ferramentas, o que é o que permite escrever um script de suporte que acha o dado de um jogador sem varrer nada. E ele também permite que o processamento automático de direito ao esquecimento identifique os dados daquele jogador — uma obrigação legal que passa a ser resolvida sozinha, ou não.
Para organizar dentro disso, prefixos servem para agrupar chaves relacionadas. Um jogo com vários perfis de personagem pode usar User_123456/Profile/Warrior e User_123456/Profile/Mage, e listar todos passando o prefixo comum. Existe ainda o scope, que prefixa uma string em toda chave daquela instância de store — o padrão é global.
2. Decida sobre biblioteca de terceiros com os olhos abertos. A documentação não proíbe: diz que em muitos casos pode ser preferível a construir do zero. Mas dá dois critérios antes de adotar — revisar dono, manutenção e recursos, e entender como acessar e migrar os dados sem a biblioteca. Esse segundo é o que separa dependência de armadilha.
E traz um nome específico: o DataStore2 é classificado como biblioteca de terceiros legada. Para quem já usa, existe uma ferramenta oficial em beta para migrar do padrão de uma store por jogador que ele adota.
3. Não fale com o DataStore durante a partida. Esta é a decisão que mais muda desempenho. A recomendação é manter uma cópia local no servidor durante o jogo, e atualizar essa cópia em vez de mandar uma requisição a cada mudança.
O salvamento acontece em quatro momentos, e a lista oficial é exatamente essa: periodicamente, na saída do jogador, no desligamento do servidor, e em pontos críticos como processamento de compra.
O intervalo periódico tem duas restrições. Precisa caber nos limites de requisição, e precisa ser menor que qualquer expiração de trava de sessão — senão a trava cai com o jogador ainda jogando. O exemplo oficial de dados e compra do jogador usa 180 segundos.
4. Espalhe as requisições no tempo. Um laço de frequência fixa começando junto em todos os servidores produz um pico a cada ciclo. A recomendação tem duas partes: antes de começar o laço, cada servidor ou jogador recebe um deslocamento inicial aleatório; e em laços que não exigem cadência exata, soma-se jitter aleatório limitado a cada intervalo. Juntas, distribuem as requisições no tempo e reduzem picos sincronizados de tráfego.
Artefato
O módulo abaixo junta as quatro decisões. Uma store, chave por padrão fixo, perfil vivo em memória, salvamento com deslocamento inicial e jitter, UpdateAsync com pcall e recuo exponencial.
Duas linhas dele merecem atenção. A primeira: quando a leitura falha, o código não cria um perfil padrão — ele desiste de persistir naquela sessão. Criar o padrão e salvar depois é como progresso desaparece, e o log fica limpo. A segunda: o callback do UpdateAsync devolve nil quando o que está gravado é mais novo que o que temos em mãos, o que aborta a escrita em vez de vencer a corrida.
Erro comum
O erro mais caro é usar SetAsync onde cabia UpdateAsync, e ele é caro porque não dá erro nenhum.
A regra oficial: prefira UpdateAsync() quando a escrita depende do valor atual ou quando vários servidores podem escrever a mesma chave. O motivo é mecânico — UpdateAsync() lê o valor mais recente para dentro do seu callback antes de escrever, o que reduz atualização perdida. Já o SetAsync() sobrescreve a chave sem ler antes, e pode causar inconsistência se dois servidores escreverem ao mesmo tempo.
O cenário que isso produz é conhecido de quem já viu: o jogador está em dois lugares — entrou, saiu, entrou de novo num servidor diferente antes do primeiro salvar. O servidor antigo salva por último, com dados velhos, e a compra dos últimos minutos evapora.
Repare que nenhuma das duas escritas falhou. As duas devolveram sucesso, as duas apareceram no log, e o resultado final é a perda. É o formato de bug mais difícil de acreditar quando um jogador reclama, porque tudo o que você tem para olhar diz que deu certo.
A regra prática que sai disso é curta: SetAsync só quando a escrita não depende de nada. Criar uma chave nova, gravar um valor que substitui integralmente o anterior sem consultá-lo. Todo o resto — moeda, inventário, progresso, qualquer coisa que some ou subtraia — é UpdateAsync. Se você precisa parar para pensar em qual usar, a resposta já é UpdateAsync.
O segundo erro é a nova tentativa ingênua. A documentação pede recuo exponencial com jitter, teto de atraso e de tentativas — mas pede também uma coisa que quase ninguém implementa: processar as tentativas em ordem para cada chave. Uma requisição antiga que tenta de novo depois de uma mais nova ter sucesso pode sobrescrever o dado mais novo. O retry vira o bug.
E há o caso que não tem solução limpa, só tratamento honesto: uma chamada que falhou pode ter sido concluída no servidor mesmo assim. "Falhou" quer dizer que a resposta não chegou, não que a escrita não aconteceu. Código que assume o contrário — e entrega o item de novo, ou reverte a moeda — cria o problema que achava estar evitando.
Próximo passo
Depois que o básico estiver de pé, existe um caso que exige medida, não palpite: a chave quente. Cada chave tem limites próprios de leitura e escrita, e um registro lógico que continua batendo nesses limites depois de você ter cortado as requisições desnecessárias precisa ser fatiado.
A fatia tem que ser determinística — a documentação sugere algo como User_{UserId}_Inventory_{ShardId}, saindo de um identificador estável, para que qualquer servidor calcule a mesma fatia sem combinar com ninguém.
A ordem importa e é fácil de inverter: fatiar antes de reduzir requisição é otimizar o desperdício. Primeiro o buffer em memória, depois o intervalo com jitter, e só então — se o limite continuar aparecendo — o fatiamento.
Vale também um exercício de cinco minutos que rende mais que qualquer refatoração: liste, num papel, toda chamada de DataStore que o seu jogo faz e em que momento ela acontece. A lista costuma ter surpresas — um GetAsync dentro de uma função de interface, um SetAsync disparado por um evento que acontece várias vezes por minuto, um laço que ninguém lembra de ter escrito.
Depois disso, o tópico natural é o de erros e limites, que a própria documentação indica como leitura seguinte. Ele diz quais falhas valem nova tentativa e quais não — porque insistir num erro de requisição inválida é gastar cota para receber a mesma recusa, e é assim que um jogo entra em estrangulamento sozinho, sem tráfego nenhum de verdade.
Fontes
Artefato pronto pra usar
-- ServerScriptService: as quatro práticas oficiais num arquivo só.
--
-- 1. UMA store, chave por padrão fixo -> PlayerData / User_{UserId}
-- 2. cópia em memória, salva por intervalo
-- 3. deslocamento inicial aleatório + jitter no intervalo
-- 4. UpdateAsync, com pcall e recuo exponencial
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local loja = DataStoreService:GetDataStore("PlayerData")
local INTERVALO = 180 -- o do exemplo oficial; menor que a trava de sessão
local JITTER = 30 -- some de 0 a 30s para os servidores não baterem juntos
local TENTATIVAS = 4
local PADRAO = { moedas = 0, nivel = 1, visitas = 0 }
-- perfil vivo: player -> tabela em memória. Nada lê o DataStore no meio da
-- partida; tudo lê e escreve AQUI.
local perfis = {}
local function chave(userId)
return "User_" .. userId
end
local function copiar(t)
local novo = {}
for k, v in t do
novo[k] = v
end
return novo
end
-- pcall + recuo exponencial com jitter, teto de tentativas.
local function tentar(fn)
local espera = 1
for i = 1, TENTATIVAS do
local ok, resultado = pcall(fn)
if ok then
return true, resultado
end
if i < TENTATIVAS then
task.wait(espera + math.random() * espera)
espera = math.min(espera * 2, 16)
else
return false, resultado
end
end
end
local function carregar(player)
local ok, dados = tentar(function()
return loja:GetAsync(chave(player.UserId))
end)
if not ok then
-- NÃO cria perfil padrão aqui: sem leitura confirmada, salvar depois
-- apagaria o progresso real. Melhor o jogador jogar sem persistir.
warn("leitura falhou para " .. player.Name .. ": nao vou salvar esta sessao")
return nil
end
local perfil = copiar(PADRAO)
if type(dados) == "table" then
for k, v in dados do
perfil[k] = v
end
end
perfil.visitas = perfil.visitas + 1
return perfil
end
-- UpdateAsync, não SetAsync: o callback recebe o valor mais recente, então
-- outro servidor que escreveu no meio não é apagado sem ser visto.
local function salvar(player)
local perfil = perfis[player]
if not perfil then
return false
end
local ok = tentar(function()
return loja:UpdateAsync(chave(player.UserId), function(atual)
if type(atual) == "table" and (atual.visitas or 0) > perfil.visitas then
-- o que está gravado é MAIS NOVO que o nosso: não sobrescreve
return nil
end
return perfil
end)
end)
if not ok then
warn("salvamento falhou para " .. player.Name)
end
return ok
end
Players.PlayerAdded:Connect(function(player)
local perfil = carregar(player)
if not perfil then
return
end
perfis[player] = perfil
-- deslocamento inicial aleatório: sem isso, todos os jogadores que
-- entraram juntos salvam juntos, para sempre.
task.spawn(function()
task.wait(math.random() * INTERVALO)
while perfis[player] do
salvar(player)
task.wait(INTERVALO + math.random() * JITTER)
end
end)
end)
Players.PlayerRemoving:Connect(function(player)
salvar(player)
perfis[player] = nil
end)
-- desligamento do servidor: o último ponto de salvamento da lista oficial
game:BindToClose(function()
if RunService:IsStudio() then
return
end
for player in perfis do
task.spawn(salvar, player)
end
task.wait(3)
end)


