Roblox · High

Economy service — session lock, fencing token e migração de schema

O que é

Módulo de economia e persistência com autoridade no servidor: session lock em MemoryStore com fencing token gravado no perfil, escritas atômicas via UpdateAsync, migração de schema, renda offline com teto, prestige e um gate de orçamento de requisições do DataStore. Escrito para um teste de estúdio; acompanha suíte de spec e spec de runtime.

Por baixo do capô

Meu papel: autor único. Escrito como teste técnico de estúdio para uma economia estilo simulator; os nomes de gerador e upgrade são placeholders.

O cliente nunca é fonte de valor nenhum — ele só pede, e todo número é calculado no servidor a partir da cópia do perfil que o servidor tem. Isso é o mínimo. As decisões que custaram pensamento de verdade são sobre o lock.

O design óbvio guarda o session lock dentro do perfil e o escreve no mesmo UpdateAsync dos dados: uma transação só, atraente. Mas cada refresh do lock vira uma escrita completa de perfil — um refresh de 15 s são 4 escritas por jogador por minuto, e como o orçamento do DataStore é por experiência e compartilhado com o Open Cloud (300 + 20 × CCU escritas/min), isso é ~20 % do orçamento de escrita da experiência inteira gasto em heartbeat, competindo com as ferramentas de admin. Mover o lock para o MemoryStore (que tem cota própria) reduz as escritas do DataStore só à cadência de autosave — cerca de 6× menos. O custo é que lock e dados deixam de ser uma transação, e isso é pago com um fencing token: o GUID da sessão vive no MemoryStore e é gravado no perfil, e nenhuma escrita acontece a menos que o token armazenado ainda seja o nosso. Uma sessão cujo lock expirou em silêncio não consegue sobrescrever quem o detém agora.

Mais duas regras que vêm de ver jogos reais perderem dados: um load que falha nunca cai num perfil padrão — ele expulsa o jogador, porque entregar um perfil novo e depois autosalvá-lo por cima dos dados reais é a causa mais comum de “perdi tudo”; e um save só escreve se ainda somos donos do lock — perdê-lo encerra a sessão em vez de atropelar o novo dono.

-- Aquisição do lock: UpdateAsync no mapa do MemoryStore. Se outra sessão
-- detém a chave, retorna nil para abortar sem escrever — nunca sobrescreve.
local function acquireLock(userId: number, token: string): (boolean, string?)
	local won = false
	local holder: string? = nil

	local ok, err = pcall(function()
		lockMap:UpdateAsync(keyFor(userId), function(current: string?): string?
			if current ~= nil and current ~= token then
				holder = current
				return nil -- outra sessão detém; aborta sem escrever
			end
			won = true
			return token
		end, Config.LOCK_TTL_SECONDS)
	end)

	if not ok then
		return false, "memorystore error: " .. tostring(err)
	end
	if not won then
		return false, "locked by another session (" .. tostring(holder) .. ")"
	end
	return true, nil
end

Também no módulo: versionamento de schema com migração para frente e uma trava contra schemas mais novos (um servidor antigo não pode “migrar” um perfil escrito por um build mais recente), serialização de escrita por sessão para dois saves nunca disputarem a mesma chave, escritas escalonadas por usuário para um servidor cheio não disparar tudo de uma vez, e todos os parâmetros numa única tabela de config com a nota de que, em jogo ao vivo, eles pertencem a um DataStore de config — uma economia que não dá para reajustar sem deploy é uma economia que não dá para consertar enquanto sangra.

Código-fonte

← Voltar para trabalhos