Pare de perder save no shutdown com BindToClose e flush paralelo
Seu jogador sai do jogo no momento do shutdown e volta com o progresso de dez minutos atrás. O conserto são três peças juntas: cache autoritativo em memória, session lock por UpdateAsync e um flush paralelo com barreira dentro do BindToClose.
Rascunho assistido por IA, revisado, testado e editado por um humano antes de publicar. Ver Política Editorial. · Revisado por Gustavo Cantino, 20/09/2026 Política Editorial

Seu DataStore não está perdendo o progresso no shutdown. Ele começa a escrever e o servidor fecha antes da requisição voltar — o valor que só existia na memória vira pó. Quando você aperta "Shut Down All Servers" (ou quando o Roblox derruba o servidor pra subir uma build nova), quem tem que segurar o fechamento é o seu código, e quase ninguém segura direito.
Por que o progresso desaparece exatamente no shutdown
Perder save no shutdown é sempre uma dessas cinco coisas, e normalmente três delas ao mesmo tempo.
1. Você salva em PlayerRemoving e confia que isso resolve. No shutdown o PlayerRemoving dispara, sim, mas o processo não espera a sua chamada assíncrona terminar. Quem segura o fechamento é game:BindToClose, e ele tem no máximo 30 segundos de janela. Passou disso, o servidor morre com os seus saves ainda na fila.
2. O servidor caiu, não fechou. Crash não chama BindToClose. Não tem callback, não tem 30 segundos, não tem nada. Contra crash existe uma única defesa: autosave periódico. Se a sua janela de autosave é 90 segundos, seu prejuízo máximo por crash é 90 segundos de jogo — e isso passa a ser uma decisão de produto, não um acidente.
3. Você estourou o orçamento de requisições. O DataStoreService trabalha com orçamento por minuto que escala com a quantidade de gente no servidor: a ordem de grandeza documentada para escrita é 60 mais 10 por jogador, por minuto. Requisição acima do orçamento não falha na hora, ela entra numa fila que drena no ritmo do orçamento — e fila cheia devolve erro. Num servidor de 40 que dispara 40 saves de uma vez, você fica esperando a fila dentro de uma janela de 30 segundos. Dá pra olhar o número antes de projetar:
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local budget = DataStoreService:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync)
print(budget, #Players:GetPlayers()) -- se budget < jogadores, parte do flush vai pra fila4. Rollback por migração. No shutdown pelo painel, o jogador é reconectado num servidor novo. Se esse servidor novo roda o GetAsync antes do servidor velho terminar a escrita, ele carrega o dado antigo — e quando o jogador sair dali, grava o dado antigo em cima do novo. O jogador vê o progresso voltar no tempo e ninguém acha erro nenhum no log, porque não houve erro: houve duas escritas fora de ordem. Sem carimbo de sessão você não tem como saber qual das duas é a mais recente.
5. A escrita falhou e ninguém viu. SetAsync sem pcall dentro de um for: o primeiro erro de rede encerra o loop e todo mundo depois daquele índice fica sem salvar. Sem log de falha, você descobre pelos prints do Discord dos jogadores três dias depois.
O passo a passo: cache, lock, autosave e flush paralelo
Passo 1 — memória é a fonte da verdade. DataStore é persistência, não banco de leitura. Um GetAsync/UpdateAsync na entrada, uma escrita na saída, e toda mutação de moeda, level e inventário acontece numa tabela em memória que você marca como suja. Se o seu código lê o DataStore pra saber quantas moedas o jogador tem, você já está gastando orçamento que vai faltar no shutdown.
Passo 2 — troque SetAsync por UpdateAsync em tudo. SetAsync sobrescreve cego. UpdateAsync te entrega o valor atual e deixa você decidir se escreve — e devolver nil na função de transformação cancela a escrita sem consumir uma tentativa de gravação inválida. É exatamente isso que permite recusar sobrescrever o dado de outro servidor.
Passo 3 — session lock no mesmo valor. Guarde na chave duas coisas: o dado e um carimbo de sessão com o game.JobId e um heartbeat em os.time(). Na entrada você só assume a sessão se não houver lock ou se o heartbeat estiver velho. 120 segundos funciona bem como limite de lock morto: é maior que qualquer janela de autosave razoável e menor que a paciência do jogador. Se houver lock vivo de outro servidor, espere e tente de novo — no artefato são 5 tentativas de 4 segundos — e só então recuse a entrada com um Kick explicando o motivo. Kick é menos pior que carregar save duplicado: duplicação de inventário é irreversível e quebra economia.
Passo 4 — autosave escalonado, um por tick. Não salve todo mundo junto a cada 60 segundos. Isso cria pico, derruba o orçamento e ainda faz o pico coincidir com o shutdown de vez em quando. Rode um loop de 1 segundo e escreva no máximo uma sessão por volta: a que está suja e com mais tempo desde o último save. Com 40 jogadores e janela de 90 segundos, seu pico é 1 escrita por segundo, folgado dentro do orçamento.
Passo 5 — reserve orçamento pro fechamento. O autosave só deve escrever se o orçamento de UpdateAsync estiver acima de uma reserva (8 no artefato). O shutdown é o momento mais caro do servidor inteiro; ele precisa encontrar caixa quando chegar.
Passo 6 — flush paralelo com barreira. Dentro do BindToClose, dispare um task.spawn por sessão e espere um contador chegar no total, em vez de percorrer um for sequencial. Com orçamento disponível, 40 escritas paralelas terminam em poucos segundos; em série, cada uma espera a latência da anterior e você não cabe nos 30 segundos. E pare de esperar em 25, não em 30: você quer logar o resultado antes de ser cortado, senão não sabe se o flush terminou.
Passo 7 — retry com backoff e liberação do lock. Duas ou três tentativas por escrita, com 1s, 2s e 4s entre elas. No save de saída e no flush, grave o lock como nil pra liberar a sessão. Se você não libera e o servidor cai, o jogador fica preso esperando o heartbeat vencer — e a sua tela de "não conseguimos abrir seu save" vira ticket de suporte.
O módulo SessionStore e como ligar ele
O artefato são duas peças. A primeira é um ModuleScript chamado SessionStore, direto em ServerScriptService: ele tem o cache, o lock, o autosave escalonado, o retry e o flush com barreira. A segunda é o Script que liga tudo: carrega na entrada, libera na saída, inicia o autosave e registra o BindToClose.
A API é curta de propósito. SessionStore.LoadAsync(player) devolve a tabela de dados ou nil se recusou a sessão. SessionStore.Get(userId) te dá a mesma tabela pra ler e mutar. SessionStore.MarkDirty(userId) é o que você chama depois de mudar qualquer coisa — sem isso o autosave ignora a sessão. SessionStore.ReleaseAsync(player) salva e solta o lock. SessionStore.FlushAll() é o que vai dentro do BindToClose.
Dois detalhes de operação. O nome da store tem sufixo de versão (PlayerData_v1) e o dado tem um campo version: quando o formato mudar, você lê a versão e migra na carga, em vez de descobrir que metade da base tem esquema antigo. E o código já trata o caso de a chave ter sido gravada no formato cru antigo (dado direto na chave, sem o envelope com lock), que é o estado da maioria dos jogos que estão migrando pra lock agora.
Pra testar: Stop no Studio não é teste válido, porque com um jogador você nunca reproduz estouro de orçamento (e o Studio precisa de "Enable Studio Access to API Services" ligado pra escrever). Publique, abra o jogo em dois clientes, gaste moeda nos dois, e derrube pelo Creator Hub com "Shut Down All Servers". O print [SessionStore] flush: 2/2 em 0.41s aparece no Server log do Developer Console (F9) antes do servidor morrer. Se aparecer 1/2, você tem uma escrita sendo cortada e o número do tempo te diz se foi fila ou retry.
O erro mais comum e mais caro: salvar em série no BindToClose
Esse é o código que quase todo jogo tem, e ele é a causa direta da maior parte dos rollbacks de shutdown:
game:BindToClose(function()
for _, player in Players:GetPlayers() do
store:SetAsync(player.UserId, cache[player.UserId]) -- um por vez, sem pcall
end
end)Três defeitos em cinco linhas. É série: com 40 jogadores, cada escrita espera a anterior, e basta a latência média ficar acima de 0,7 s pra você não caber nos 30 segundos — os últimos da lista simplesmente não salvam. Não tem pcall: o primeiro erro levanta e o for morre ali, então quem estava depois no loop perde tudo, inclusive os que teriam salvo sem problema. E é SetAsync: se o jogador já reconectou no servidor novo, essa linha grava o dado velho em cima do novo — você não só perdeu, você reverteu.
O conserto é o do artefato: task.spawn por sessão, contador como barreira, pcall em cada escrita, UpdateAsync recusando gravar quando o lock já é de outro servidor, e deadline interna de 25 segundos. O segundo erro caro mora exatamente aqui: retry longo dentro do flush. Backoff de 1s, 2s, 4s com 5 tentativas é 15 segundos de espera por jogador — bom no autosave, suicídio no shutdown. No flush, use 2 tentativas no máximo.
O que medir depois
Três números, e eles cabem num print e num contador.
Primeiro: done/pending e a duração do flush, em todo shutdown. Se a duração passar de 10 segundos com servidor cheio, seu orçamento está apertado antes do fechamento — provavelmente o autosave está escrevendo demais, ou algum sistema seu ainda chama DataStore em gameplay.
Segundo: quantas cargas bateram em lock vivo e quantas terminaram em Kick. Um pico de lock bloqueado depois de um shutdown é normal por alguns segundos; lock bloqueado constante significa que você não está liberando o lock em alguma saída de sessão.
Terceiro: falhas de escrita por sessão. Conte num hash de MemoryStore ou jogue no seu log externo, com o userId e a mensagem do erro. É a única forma de saber que o problema mudou de "não salva no shutdown" pra "não salva porque o payload passou de 4 MB por chave", que é o próximo teto que você encontra quando o inventário cresce.
E se você não quiser manter isso, a alternativa honesta é o ProfileStore (do loleris), open source e que já implementa lock de sessão e flush. O valor de escrever o seu é saber o que fazer quando o comportamento fugir — e agora você sabe onde olhar.
Artefato pronto pra usar
--!strict
-- SessionStore
-- Cache autoritativo em memoria + session lock por UpdateAsync + flush paralelo no shutdown.
-- Local: ModuleScript em ServerScriptService, nome exato "SessionStore".
local DataStoreService = game:GetService("DataStoreService")
local STORE_NAME = "PlayerData_v1"
local AUTOSAVE_WINDOW = 90 -- perda maxima de progresso em caso de crash, em segundos
local LOCK_STALE_AFTER = 120 -- lock de outro servidor considerado morto
local LOCK_RETRIES = 5
local LOCK_RETRY_WAIT = 4
local FLUSH_DEADLINE = 25 -- BindToClose corta em 30s; saimos antes pra conseguir logar
local BUDGET_RESERVE = 8 -- orcamento de UpdateAsync que o autosave nunca gasta
local store = DataStoreService:GetDataStore(STORE_NAME)
local jobId: string = if game.JobId ~= "" then game.JobId else "studio-" .. tostring(os.time())
export type Data = {
version: number,
coins: number,
level: number,
inventory: { string },
}
type Lock = { jobId: string, heartbeat: number }
type Record = { data: Data, lock: Lock? }
type Session = {
userId: number,
data: Data,
dirty: boolean,
saving: boolean,
lastSave: number,
}
local sessions: { [number]: Session } = {}
local closing = false
local SessionStore = {}
local function defaultData(): Data
return { version = 1, coins = 0, level = 1, inventory = {} }
end
local function keyFor(userId: number): string
return "u_" .. tostring(userId)
end
local function updateBudget(): number
return DataStoreService:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync)
end
-- Aceita a chave no formato novo (envelope com lock) e no formato antigo (dado cru).
local function toRecord(old: any, fallback: Data): Record
if old == nil then
return { data = fallback }
end
if old.data == nil then
return { data = old :: Data } -- migracao do formato cru
end
return old :: Record
end
local function lockIsForeign(lock: Lock?): boolean
if lock == nil then
return false
end
return lock.jobId ~= jobId and (os.time() - lock.heartbeat) < LOCK_STALE_AFTER
end
-- Tenta assumir a sessao. Retorna (registro, blocked).
local function acquire(userId: number): (Record?, boolean)
local blocked = false
local record: Record? = nil
local ok, err = pcall(function()
store:UpdateAsync(keyFor(userId), function(old: any): Record?
local rec = toRecord(old, defaultData())
if lockIsForeign(rec.lock) then
blocked = true
return nil -- cancela a escrita: nao roubamos sessao viva
end
rec.lock = { jobId = jobId, heartbeat = os.time() }
record = rec
return rec
end)
end)
if not ok then
warn("[SessionStore] acquire falhou para " .. userId .. ": " .. tostring(err))
return nil, false
end
return record, blocked
end
-- Escreve o dado da sessao. release=true solta o lock.
local function write(session: Session, release: boolean): boolean
local refused = false
local ok, err = pcall(function()
store:UpdateAsync(keyFor(session.userId), function(old: any): Record?
local rec = toRecord(old, session.data)
if lockIsForeign(rec.lock) then
refused = true
return nil -- outro servidor assumiu: nao sobrescreve dado mais novo
end
rec.data = session.data
rec.lock = if release then nil else { jobId = jobId, heartbeat = os.time() }
return rec
end)
end)
if not ok then
warn("[SessionStore] write falhou para " .. session.userId .. ": " .. tostring(err))
return false
end
if refused then
warn("[SessionStore] write recusado para " .. session.userId .. ": lock de outro servidor")
return true -- nao e erro de rede; retry nao ajuda
end
return true
end
local function writeWithRetry(session: Session, release: boolean, attempts: number): boolean
for i = 1, attempts do
if write(session, release) then
return true
end
if i < attempts then
task.wait(2 ^ (i - 1)) -- 1s, 2s, 4s
end
end
return false
end
function SessionStore.LoadAsync(player: Player): Data?
local userId = player.UserId
for _ = 1, LOCK_RETRIES do
local record, blocked = acquire(userId)
if record ~= nil then
local session: Session = {
userId = userId,
data = record.data,
dirty = false,
saving = false,
lastSave = os.clock(),
}
sessions[userId] = session
return session.data
end
task.wait(if blocked then LOCK_RETRY_WAIT else 2)
if player.Parent == nil then
return nil -- jogador desistiu antes de entrar
end
end
player:Kick("Nao conseguimos abrir seu save (sessao presa em outro servidor). Entre de novo em 1 minuto.")
return nil
end
function SessionStore.Get(userId: number): Data?
local session = sessions[userId]
return if session ~= nil then session.data else nil
end
function SessionStore.MarkDirty(userId: number)
local session = sessions[userId]
if session ~= nil then
session.dirty = true
end
end
function SessionStore.ReleaseAsync(player: Player)
local session = sessions[player.UserId]
if session == nil or session.saving then
return
end
session.saving = true
sessions[player.UserId] = nil
if not writeWithRetry(session, true, 3) then
warn("[SessionStore] PERDEU o save de " .. player.UserId .. " na saida")
end
end
function SessionStore.StartAutosave()
task.spawn(function()
while not closing do
task.wait(1)
if updateBudget() > BUDGET_RESERVE then
local now = os.clock()
for _, session in sessions do
if session.dirty and not session.saving and (now - session.lastSave) >= AUTOSAVE_WINDOW then
session.saving = true
session.dirty = false
session.lastSave = now
task.spawn(function()
if not writeWithRetry(session, false, 3) then
session.dirty = true -- tenta de novo na proxima janela
end
session.saving = false
end)
break -- uma escrita por tick: espalha o custo de orcamento
end
end
end
end
end)
end
-- Vai dentro do game:BindToClose. Paralelo + barreira + deadline de 25s.
function SessionStore.FlushAll()
closing = true
local pending = 0
local done = 0
for _, session in sessions do
if not session.saving then
session.saving = true
pending += 1
task.spawn(function()
writeWithRetry(session, true, 2) -- 2 tentativas: retry longo nao cabe aqui
done += 1
end)
end
end
local t0 = os.clock()
while done < pending and (os.clock() - t0) < FLUSH_DEADLINE do
task.wait(0.1)
end
print(("[SessionStore] flush: %d/%d em %.2fs"):format(done, pending, os.clock() - t0))
end
return SessionStore--!strict
-- Liga o SessionStore ao ciclo de vida do servidor.
-- Local: Script em ServerScriptService, ao lado do ModuleScript SessionStore.
local Players = game:GetService("Players")
local SessionStore = require(script.Parent:WaitForChild("SessionStore"))
local function onPlayerAdded(player: Player)
local data = SessionStore.LoadAsync(player)
if data == nil then
return -- LoadAsync ja kickou ou o jogador saiu
end
local stats = Instance.new("Folder")
stats.Name = "leaderstats"
local coins = Instance.new("IntValue")
coins.Name = "Coins"
coins.Value = data.coins
coins.Parent = stats
stats.Parent = player
end
-- Exemplo de mutacao: sempre mexe na memoria e marca sujo.
local function addCoins(player: Player, amount: number)
local data = SessionStore.Get(player.UserId)
if data == nil then
return
end
data.coins += amount
SessionStore.MarkDirty(player.UserId)
local stats = player:FindFirstChild("leaderstats")
local coins = if stats then stats:FindFirstChild("Coins") else nil
if coins and coins:IsA("IntValue") then
coins.Value = data.coins
end
end
-- Jogadores que entraram antes deste script rodar
for _, player in Players:GetPlayers() do
task.spawn(onPlayerAdded, player)
end
Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(function(player: Player)
SessionStore.ReleaseAsync(player)
end)
SessionStore.StartAutosave()
game:BindToClose(function()
SessionStore.FlushAll()
end)
return nil

