Contexto
Roblox proporciona un conjunto de APIs para interactuar con almacenes de datos a través de DataStoreService. El caso de uso más común para estas APIs es guardar, cargar y replicar datos de jugadores. Es decir, datos asociados con el progreso del jugador, compras y otras características de sesión que persisten entre sesiones de juego individuales.
La mayoría de los juegos en Roblox utilizan estas APIs para implementar alguna forma de sistema de datos de jugadores. Estas implementaciones difieren en su enfoque, pero generalmente buscan resolver el mismo conjunto de problemas.
Problemas comunes
A continuación se presentan algunos de los problemas más comunes que los sistemas de datos de jugadores intentan resolver:
Acceso en Memoria: Las solicitudes de DataStoreService realizan solicitudes web que operan de manera asincrónica y están sujetas a límites de tarifa. Esto es apropiado para una carga inicial al comienzo de la sesión, pero no para operaciones de lectura y escritura de alta frecuencia durante el curso normal del juego. La mayoría de los sistemas de datos de jugadores de los desarrolladores almacenan estos datos en memoria en el servidor de Roblox, limitando las solicitudes de DataStoreService a los siguientes escenarios:
- Lectura inicial al comienzo de una sesión
- Escritura final al final de la sesión
- Escrituras periódicas a intervalos para mitigar el escenario donde la escritura final falla
- Escrituras para asegurar que los datos se guarden mientras se procesa una compra
Almacenamiento Eficiente: Almacenar todos los datos de sesión de un jugador en una sola tabla permite actualizar múltiples valores de manera atómica y manejar la misma cantidad de datos en menos solicitudes. También elimina el riesgo de desincronización entre valores y hace que las reversas sean más fáciles de entender.
Algunos desarrolladores también implementan serialización personalizada para comprimir grandes estructuras de datos (típicamente para guardar contenido generado por los usuarios en el juego).
Replicación: El cliente necesita acceso regular a los datos de un jugador (por ejemplo, para actualizar la interfaz de usuario). Un enfoque genérico para replicar los datos del jugador al cliente permite transmitir esta información sin tener que crear sistemas de replicación específicos para cada componente de datos. Los desarrolladores a menudo quieren la opción de ser selectivos sobre lo que se replica y lo que no se replica al cliente.
Manejo de Errores: Cuando no se pueden acceder a los DataStores, la mayoría de las soluciones implementarán un mecanismo de reintentos y un respaldo a datos "por defecto". Se necesita un cuidado especial para asegurar que los datos de respaldo no sobrescriban más tarde los datos "reales", y que esto se comunique al jugador de manera apropiada.
Reintentos: Cuando los almacenes de datos son inaccesibles, la mayoría de las soluciones implementan un mecanismo de reintentos y un respaldo a datos por defecto. Tenga especial cuidado en asegurar que los datos de respaldo no sobrescriban más tarde los datos "reales", y comunique la situación al jugador de manera apropiada.
Bloqueo de Sesión: Si los datos de un solo jugador se cargan y se mantienen en memoria en múltiples servidores, pueden ocurrir problemas en los que un servidor guarda información desactualizada. Esto puede llevar a la pérdida de datos y a comunes vacíos de duplicación de ítems.
Manejo Atómico de Compras: Verifique, otorgue y registre compras de manera atómica para evitar que los ítems se pierdan o se otorguen múltiples veces.
Código de ejemplo
Roblox tiene código de referencia para ayudarle a diseñar y construir sistemas de datos de jugadores. El resto de esta página examina el contexto, los detalles de implementación y las advertencias generales.
Después de importar el modelo en Studio, debería ver la siguiente estructura de carpetas:

Arquitectura
Este diagrama de alto nivel ilustra los sistemas clave en el ejemplo y cómo interactúan con el código en el resto del juego.

Reintentos
Clase: DataStoreWrapper
Contexto
Dado que DataStoreService realiza solicitudes web en segundo plano, no se garantiza que sus solicitudes tengan éxito. Cuando esto sucede, los métodos de DataStore generan errores, lo que permite manejarlos.
Un "problemilla" común puede ocurrir si intenta manejar fallos en el almacén de datos de esta manera:
local function retrySetAsync(dataStore, key, value)
for _ = 1, MAX_ATTEMPTS do
local success, result = pcall(dataStore.SetAsync, dataStore, key, value)
if success then
break
end
task.wait(TIME_BETWEEN_ATTEMPTS)
end
end
Si bien este es un mecanismo de reintento perfectamente válido para una función genérica, no es adecuado para las solicitudes de DataStoreService porque no garantiza el orden en el que se realizan las solicitudes. Preservar el orden de las solicitudes es importante para las solicitudes de DataStoreService porque interactúan con el estado. Considere el siguiente escenario:
- Se realiza la Solicitud A para establecer el valor de la clave K en 1.
- La solicitud falla, por lo que se programa un reintento para ejecutarse en 2 segundos.
- Antes de que ocurra el reintento, la Solicitud B establece el valor de K en 2, pero el reintento de la Solicitud A sobrescribe inmediatamente este valor y establece K en 1.
A pesar de que UpdateAsync opera sobre la última versión del valor de la clave, las solicitudes UpdateAsync deben procesarse en orden para evitar estados transitorios inválidos (por ejemplo, una compra resta monedas antes de que se procese una adición de monedas, resultando en monedas negativas).
Nuestro sistema de datos de jugadores utiliza una nueva clase, DataStoreWrapper, que proporciona reintentos que garantizan que se procesen en orden por clave.
Enfoque

DataStoreWrapper proporciona métodos correspondientes a los métodos de DataStore: DataStore:GetAsync(), DataStore:SetAsync(), DataStore:UpdateAsync() y DataStore:RemoveAsync().
Estos métodos, cuando se llaman:
Añaden la solicitud a una cola. Cada clave tiene su propia cola, donde las solicitudes se procesan en orden y en serie. El hilo solicitante cede hasta que la solicitud se haya completado.
Esta funcionalidad se basa en la clase ThreadQueue, que es un planificador de tareas basado en corutinas y limitador de tasa. En lugar de devolver una promesa, ThreadQueue cede el hilo actual hasta que la operación esté completa y lanza un error si falla. Esto es más consistente con los patrones asincrónicos idiomáticos de Luau.
Si una solicitud falla, se reintenta con una retroalimentación exponencial configurable. Estos reintentos forman parte del callback enviado a ThreadQueue, por lo que se garantiza su finalización antes de que comience la siguiente solicitud en la cola para esta clave.
Cuando se completa una solicitud, el método de solicitud devuelve el patrón success, result.
DataStoreWrapper también expone métodos para obtener la longitud de la cola para una clave determinada y limpiar solicitudes obsoletas. Esta última opción es particularmente útil en escenarios cuando el servidor está apagándose y no hay tiempo para procesar solicitudes que no sean las más recientes.
Advertencias
DataStoreWrapper sigue el principio de que, fuera de escenarios extremos, cada solicitud al almacén de datos debe permitirse completar (con éxito o no), incluso si una solicitud más reciente la hace redundante. Cuando se realiza una nueva solicitud, las solicitudes obsoletas no se eliminan de la cola, sino que se permite que se completen antes de que se inicie la nueva solicitud. La razón de esto se basa en la aplicabilidad de este módulo como una utilidad genérica de almacén de datos en lugar de una herramienta específica para datos de jugadores, y es la siguiente:
Es difícil decidir un conjunto intuitivo de reglas para cuándo es seguro eliminar una solicitud de la cola. Considere la siguiente cola:
Value=0, SetAsync(1), GetAsync(), SetAsync(2)
El comportamiento esperado es que GetAsync() devolvería 1, pero si eliminamos la solicitud SetAsync() de la cola debido a que fue redundante por la más reciente, devolvería 0.
La progresión lógica es que cuando se añade una nueva solicitud de escritura, solo se eliminan las solicitudes obsoletas hasta la más reciente solicitud de lectura. UpdateAsync, de lejos la operación más común (y la única utilizada por este sistema), puede tanto leer como escribir, por lo que sería difícil reconciliar esto dentro de este diseño sin agregar complejidad adicional.
DataStoreWrapper podría requerir que especifique si una solicitud UpdateAsync() estaba autorizada a leer y/o escribir, pero no tendría aplicabilidad a nuestro sistema de datos de jugadores, donde esto no se puede determinar de antemano debido al mecanismo de bloqueo de sesión (cubierto con más detalle más adelante).
Una vez eliminadas de la cola, es difícil decidir sobre una regla intuitiva para cómo debe manejarse esto. Cuando se realiza una solicitud DataStoreWrapper, el hilo actual se cede hasta que se complete. Si eliminamos solicitudes obsoletas de la cola, tendríamos que decidir si devolver false, "Eliminado de la cola" o nunca devolver y descartar el hilo activo. Ambos enfoques tienen sus propios inconvenientes y trasladan complejidad adicional al consumidor.
En última instancia, nuestra opinión es que el enfoque simple (procesar cada solicitud) es preferible aquí y crea un entorno más claro para navegar cuando se enfrentan problemas complejos como el bloqueo de sesiones. La única excepción a esto es durante DataModel:BindToClose(), donde limpiar la cola se vuelve necesario para guardar los datos de todos los usuarios a tiempo y el valor que devuelven las llamadas a funciones individuales ya no es una preocupación continua. Para más contexto, consulte Datos del Jugador.
Bloqueo de sesión
Clase: SessionLockedDataStoreWrapper
Contexto
Los datos del jugador se almacenan en memoria en el servidor y solo se leen y escriben en los almacenes de datos subyacentes cuando es necesario. Puede leer y actualizar instantáneamente los datos del jugador en memoria sin necesidad de solicitudes web y evitar exceder los límites de DataStoreService.
Para que este modelo funcione como se pretende, es imperativo que no más de un servidor pueda cargar los datos de un jugador en memoria desde DataStore al mismo tiempo.
Por ejemplo, si el servidor A carga los datos de un jugador, el servidor B no puede cargar esos datos hasta que el servidor A libere su bloqueo sobre ellos durante una última guardia. Sin un mecanismo de bloqueo, el servidor B podría cargar datos de jugador desactualizados del almacén de datos antes de que el servidor A tenga la oportunidad de guardar la versión más reciente que tiene en memoria. Luego, si el servidor A guarda sus datos más nuevos después de que el servidor B carga los datos desactualizados, el servidor B sobrescribiría esos datos más nuevos durante su próxima guardia.
A pesar de que Roblox solo permite que un cliente esté conectado a un servidor a la vez, no puede asumir que los datos de una sesión se guardan siempre antes de que comience la siguiente sesión. Considere los siguientes escenarios que pueden ocurrir cuando un jugador deja el servidor A:
- El servidor A realiza una solicitud de DataStore para guardar sus datos, pero la solicitud falla y requiere varios reintentos para completarse con éxito. Durante el período de reintentos, el jugador se une al servidor B.
- El servidor A realiza demasiadas llamadas UpdateAsync() a la misma clave y se ve ralentizado. La última solicitud de guardia se coloca en una cola. Mientras la solicitud está en la cola, el jugador se une al servidor B.
- En el servidor A, algún código conectado al evento PlayerRemoving cede antes de que los datos del jugador se guarden. Antes de que se complete esta operación, el jugador se une al servidor B.
- El rendimiento del servidor A ha disminuido hasta el punto en que la última guardia se retrasa hasta después de que el jugador se une al servidor B.
Estos escenarios deberían ser raros, pero ocurren, particularmente en situaciones donde un jugador se desconecta de un servidor y se conecta a otro en rápida sucesión (por ejemplo, mientras se teletransporta). Algunos usuarios malintencionados podrían incluso intentar abusar de este comportamiento para completar acciones sin que persistan. Esto puede ser particularmente impactante en juegos que permiten a los jugadores comerciar y es una fuente común de exploits de duplicación de ítems.
El bloqueo de sesión aborda esta vulnerabilidad al asegurar que cuando la clave de DataStore de un jugador se lee por primera vez en el servidor, el servidor escribe de manera atómica un bloqueo en los metadatos de la clave dentro de la misma llamada de UpdateAsync(). Si este valor de bloqueo está presente cuando cualquier otro servidor intenta leer o escribir la clave, el servidor no procede.
Enfoque

SessionLockedDataStoreWrapper es un meta-wrapper alrededor de la clase DataStoreWrapper. DataStoreWrapper proporciona funcionalidades de cola y reintento, las cuales SessionLockedDataStoreWrapper complementa con bloqueo de sesión.
SessionLockedDataStoreWrapper pasa cada solicitud DataStore—independientemente de si es GetAsync, SetAsync o UpdateAsync—a través de UpdateAsync. Esto se debe a que UpdateAsync permite que una clave se lea y escriba de manera atómica. También es posible abandonar la escritura basada en el valor leído devolviendo nil en el callback de transformación.
La función de transformación pasada a UpdateAsync para cada solicitud realiza las siguientes operaciones:
Verifica que la clave sea segura para acceder, abandonando la operación si no lo es. "Segura para acceder" significa:
El objeto de metadatos de la clave no incluye un valor LockId no reconocido que fue actualizado por última vez hace menos de la duración del tiempo de expiración del bloqueo. Esto toma en cuenta el respeto por un bloqueo colocado por otro servidor y por ignorar ese bloqueo si ha expirado.
Si este servidor ha colocado su propio valor LockId en los metadatos de la clave anteriormente, entonces este valor todavía está en los metadatos de la clave. Esto toma en cuenta la situación donde otro servidor ha tomado el bloqueo de este servidor (por expiración o por fuerza) y luego lo liberó. Alternativamente fraseado, incluso si LockId es nil, otro servidor aún podría haber reemplazado y eliminado un bloqueo en el tiempo desde que usted bloqueó la clave.
UpdateAsync realiza la operación DataStore que el consumidor de SessionLockedDataStoreWrapper solicitó. Por ejemplo, GetAsync() se traduce en function(value) return value end.
Dependiendo de los parámetros pasados a la solicitud, UpdateAsync bloquea o desbloquea la clave:
Si la clave debe ser bloqueada, UpdateAsync establece el LockId en los metadatos de la clave en un GUID. Este GUID se almacena en memoria en el servidor para que pueda ser verificado la próxima vez que acceda a la clave. Si el servidor ya tiene un bloqueo sobre esta clave, no realiza cambios. También programa una tarea para advertirle si no vuelve a acceder a la clave para mantener el bloqueo dentro del tiempo de expiración del bloqueo.
Si la clave debe ser desbloqueada, UpdateAsync elimina el LockId en los metadatos de la clave.
Un manejador de reintentos personalizado se pasa al DataStoreWrapper subyacente para que la operación se reintente si fue abortada en el paso 1 debido a que la sesión está bloqueada.
También se devuelve un mensaje de error personalizado al consumidor, lo que permite que el sistema de datos del jugador informe un error alternativo en caso de bloqueo de sesión al cliente.
Advertencias
El régimen de bloqueo de sesión se basa en que un servidor siempre libere su bloqueo sobre una clave cuando haya terminado con ella. Esto debería suceder siempre a través de una instrucción para desbloquear la clave como parte de la última escritura en PlayerRemoving o BindToClose().
Sin embargo, el desbloqueo puede fallar en ciertas situaciones. Por ejemplo:
- El servidor se bloqueó o DataStoreService estaba inoperable para todos los intentos de acceder a la clave.
- Debido a un error de lógica o un bug similar, no se realizón la instrucción para desbloquear la clave.
Para mantener el bloqueo en una clave, debe acceder a ella regularmente mientras esté cargada en memoria. Esto normalmente se haría como parte del bucle de guardia automática que se ejecuta en segundo plano en la mayoría de los sistemas de datos de jugadores, pero este sistema también expone un método refreshLockAsync si necesita hacerlo manualmente.
Si el tiempo de expiración del bloqueo ha sido superado sin haberse actualizado, entonces cualquier servidor es libre de tomar el bloqueo. Si un servidor diferente toma el bloqueo, los intentos del servidor actual de leer o escribir la clave fallan a menos que establezca un nuevo bloqueo.
Procesamiento de productos de desarrollador
Singleton: ReceiptHandler
Contexto
El callback ProcessReceipt realiza la tarea crítica de determinar cuándo finalizar una compra. ProcessReceipt se llama en escenarios muy específicos. Para su conjunto de garantías, consulte MarketplaceService.ProcessReceipt.
Aunque la definición de "manejar" una compra puede diferir entre juegos, utilizamos los siguientes criterios
La compra no ha sido manejada previamente.
La compra se refleja en la sesión actual.
Esto requiere llevar a cabo las siguientes operaciones antes de devolver PurchaseGranted:
- Verifique que el PurchaseId no haya sido ya registrado como manejado.
- Otorgue la compra en los datos del jugador en memoria.
- Registre el PurchaseId como manejado en los datos del jugador en memoria.
- Escriba los datos del jugador en memoria en el DataStore.
El bloqueo de sesión simplifica este flujo, ya que ya no tiene que preocuparse por los siguientes escenarios:
- Los datos del jugador en memoria en el servidor actual pueden estar desactualizados, lo que requeriría que obtuviera el valor más reciente del DataStore antes de verificar el historial del PurchaseId
- El callback para la misma compra se ejecuta en otro servidor, lo que requiere que lea y escriba tanto el historial del PurchaseId y guarde los datos del jugador actualizados con la compra reflejada de manera atómica para prevenir las condiciones de carrera
El bloqueo de sesión garantiza que, si un intento de escribir en el DataStore del jugador es exitoso, ningún otro servidor ha leído o escrito con éxito en el DataStore del jugador entre la carga y el guardado de datos en este servidor. En resumen, los datos del jugador en memoria en este servidor son la versión más actualizada disponible. Hay algunas advertencias, pero no afectan este comportamiento.
Enfoque
Los comentarios en ReceiptProcessor delinean el enfoque:
Verifique que los datos del jugador estén actualmente cargados en este servidor y que se hayan cargado sin errores.
Debido a que este sistema utiliza bloqueo de sesión, esta verificación también asegura que los datos en memoria sean la versión más actualizada.
Si los datos del jugador no se han cargado aún (lo que se espera cuando un jugador se une a un juego), espere a que se carguen los datos del jugador. El sistema también escucha para que el jugador salga del juego antes de que sus datos se carguen, ya que no debería ceder indefinidamente y bloquear este callback de ser invocado nuevamente en este servidor para esta compra si el jugador vuelve a unirse.
Verifique que el PurchaseId no esté ya registrado como procesado en los datos del jugador.
Debido al bloqueo de sesión, el array de PurchaseIds que el sistema tiene en memoria es la versión más actualizada. Si el PurchaseId está registrado como procesado y reflejado en un valor que se ha cargado o guardado en el DataStore, devuelva PurchaseGranted. Si está registrado como procesado, pero no reflejado en el DataStore, devuelva NotProcessedYet.
Actualice los Datos del Jugador localmente en este servidor para "otorgar" la compra.
ReceiptProcessor adopta un enfoque de callback genérico y asigna un callback diferente para cada DeveloperProductId.
Actualice los datos del jugador localmente en este servidor para almacenar el PurchaseId.
Envíe una solicitud para guardar los datos en memoria en el DataStore, devolviendo PurchaseGranted si la solicitud es exitosa. Si no, devuelva NotProcessedYet.
Si esta solicitud de guardia no es exitosa, una solicitud posterior para guardar los datos de sesión en memoria del jugador aún podría tener éxito. Durante la siguiente llamada a ProcessReceipt, el paso 2 maneja esta situación y devuelve PurchaseGranted.
Datos del Jugador
Singletons: PlayerData.Server, PlayerData.Client
Contexto
Los módulos que proporcionan una interfaz para que el código lea y escriba de manera sincrónica los datos de sesión del jugador son comunes en los juegos de Roblox. Esta sección cubre PlayerData.Server y PlayerData.Client.
Enfoque
PlayerData.Server y PlayerData.Client manejan lo siguiente:
- Cargar los datos del jugador en memoria, incluyendo manejar casos en los que falla la carga
- Proporcionar una interfaz para que el código del servidor consulte y cambie los datos del jugador
- Replicar cambios en los datos del jugador al cliente para que el código del cliente pueda acceder a ellos
- Replicar errores de carga y/o guardia al cliente para que pueda mostrar diálogos de error
- Guardar los datos del jugador periódicamente, cuando el jugador se va y cuando el servidor se apaga
Cargar datos del jugador

SessionLockedDataStoreWrapper realiza una solicitud getAsync al almacén de datos.
Si esta solicitud falla, se utilizan los datos por defecto y el perfil se marca como "con error" para asegurarse de que no se escriba en el almacén de datos más tarde.
Una opción alternativa es expulsar al jugador, pero recomendamos permitir que el jugador juegue con datos por defecto y con una comunicación clara sobre lo que ocurrió en lugar de sacarlos del juego.
Se envía una carga inicial a PlayerDataClient que contiene los datos cargados y el estado de error (si lo hay).
Se reanudan los hilos cedidos utilizando waitForDataLoadAsync para el jugador.
Proporcionar una interfaz para el código del servidor
- PlayerDataServer es un singleton que puede ser requerido y accesible por cualquier código del servidor que se ejecute en el mismo entorno.
- Los datos del jugador se organizan en un diccionario de claves y valores. Puede manipular estos valores en el servidor utilizando los métodos setValue, getValue, updateValue y removeValue. Todos estos métodos operan de manera sincrónica sin ceder.
- Los métodos hasLoaded y waitForDataLoadAsync están disponibles para asegurar que los datos se hayan cargado antes de acceder a ellos. Recomendamos hacer esto una vez durante una pantalla de carga antes de que otros sistemas se inicien para evitar tener que verificar errores de carga antes de cada interacción con los datos en el cliente.
- Un método hasErrored puede consultar si la carga inicial del jugador falló, lo que les haría utilizar datos por defecto. Verifique este método antes de permitir que el jugador realice cualquier compra, ya que las compras no se pueden guardar en los datos sin una carga exitosa.
- Una señal playerDataUpdated se activa con el player, key y value cada vez que se cambian los datos de un jugador. Los sistemas individuales pueden suscribirse a esto.
Replicar cambios al cliente
- Cualquier cambio en los datos del jugador en PlayerDataServer se replica a PlayerDataClient, a menos que esa clave haya sido marcada como privada utilizando setValueAsPrivate
- setValueAsPrivate se utiliza para denotar claves que no deben enviarse al cliente
- PlayerDataClient incluye un método para obtener el valor de una clave (get) y una señal que se activa cuando se actualiza (updated). También se incluye un método hasLoaded y una señal loaded, por lo que el cliente puede esperar a que los datos se carguen y se repliquen antes de iniciar sus sistemas
- PlayerDataClient es un singleton que puede ser requerido y accesible por cualquier código de cliente que se ejecute en el mismo entorno
Replicar errores al cliente
- Los estados de error encontrados al guardar o cargar datos del jugador se replican a PlayerDataClient.
- Acceda a esta información con los métodos getLoadError y getSaveError, junto con las señales loaded y saved.
- Hay dos tipos de errores: DataStoreError (la solicitud de DataStoreService falló) y SessionLocked (ver Bloqueo de Sesión).
- Utilice estos eventos para deshabilitar los mensajes de compra del cliente e implementar diálogos de advertencia. Esta imagen muestra un ejemplo de diálogo:

Guardar datos del jugador

Cuando el jugador deja el juego, el sistema lleva a cabo los siguientes pasos:
- Verifique si es seguro escribir los datos del jugador en el almacén de datos. Los escenarios donde sería inseguro incluyen que los datos del jugador no se hayan cargado o que todavía estén en proceso de carga.
- Haga una solicitud a través de SessionLockedDataStoreWrapper para escribir el valor actual en memoria al almacén de datos y eliminar el bloqueo de sesión una vez completo.
- Limpie los datos del jugador (y otras variables como metadatos y estados de error) de la memoria del servidor.
En un ciclo periódico, el servidor escribe los datos de cada jugador en el almacén de datos (siempre que sea seguro guardarlos). Esta bienvenida redundancia mitiga la pérdida en caso de un fallo del servidor y también es necesaria para mantener el bloqueo de sesión.
Cuando se recibe una solicitud para apagar el servidor, ocurre lo siguiente en un callback de BindToClose:
- Se realiza una solicitud para guardar los datos de cada jugador en el servidor, siguiendo el proceso normalmente implementado cuando un jugador deja el servidor. Estas solicitudes se realizan en paralelo, ya que los callbacks de BindToClose solo tienen 30 segundos para completarse.
- Para agilizar las guardias, se eliminan todas las demás solicitudes en la cola de cada clave del DataStoreWrapper subyacente (ver Reintentos).
- El callback no devuelve hasta que todas las solicitudes se hayan completado.