Scripting Lua

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.

por Gustavo Cantino16 min de leituraAvançado

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

Placa tipográfica com a categoria Scripting Lua em destaque, o nível avançado abaixo, e um campo de barras verticais verde-limão sobre fundo escuro.

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:

Script em ServerScriptService
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 fila

4. 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:

Script em ServerScriptService
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

ModuleScript em ServerScriptService (nome exato: SessionStore)
lua
--!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
Script em ServerScriptService (irmão do módulo SessionStore)
lua
--!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
Neste artigo

Continue por aqui