Bases de datos: SQL y oxmysql
De lo básico de oxmysql a SQL avanzado: JOINs, índices, transacciones, funciones, stored procedures, triggers y migraciones para guardar dinero, vehículos e inventario sin perder un byte.
Cuando un jugador se desconecta de tu servidor, todo lo que vive solo en memoria desaparece: su dinero, su inventario, sus coches en el garaje, su identidad. Si quieres que esos datos sigan ahí la próxima vez que entre, necesitas guardarlos en algún sitio permanente. Ese sitio es una base de datos.
Una base de datos es como una hoja de cálculo gigante y muy ordenada: tienes tablas (users, vehicles, inventory) con filas (cada jugador) y columnas (cada dato). Persistir significa exactamente eso: que el dato sobrevive al reinicio del servidor y al cierre del juego.
El stack: MySQL/MariaDB + oxmysql
FiveM no trae base de datos propia. El estándar de facto es MySQL (o su gemela libre MariaDB, casi idéntica) como motor donde viven los datos, y un recurso llamado oxmysql como «driver»: el puente que tus scripts de Lua usan para hablar con la base de datos sin pelearte con conexiones a bajo nivel.
- MySQL / MariaDB: el servidor de base de datos. Guarda y devuelve los datos.
- oxmysql: el recurso de FiveM que conecta tus scripts con ese servidor.
- Tus recursos (economía, garaje, inventario) piden datos a oxmysql, no a MySQL directamente.
Instalar y configurar oxmysql
Descarga la última release de oxmysql desde su repositorio oficial y descomprímela dentro de tu carpeta resources/. Después configura la cadena de conexión en tu server.cfg y asegúrate de arrancar oxmysql ANTES que cualquier recurso que lo use.
# server.cfg
# Cadena de conexión: usuario:contraseña@host/base_de_datos
set mysql_connection_string "mysql://root:miPassword@localhost/fivem?charset=utf8mb4"
# oxmysql DEBE arrancar antes que los recursos que lo necesitan
ensure oxmysql
ensure mi_economia
ensure mi_garajeConfiguración en server.cfg
Para que un recurso pueda usar la API de oxmysql, declara su librería en el fxmanifest.lua del recurso como server_script (la base de datos siempre se toca desde el servidor, nunca desde el cliente):
fx_version 'cerulean'
game 'gta5'
-- Importa la API de oxmysql en este recurso
server_script '@oxmysql/lib/MySQL.lua'
server_scripts {
'server.lua'
}fxmanifest.lua del recurso
La base de datos es la autoridad: consúltala y modifícala SOLO desde server_scripts. El cliente no es de confianza y jamás debe tener acceso directo a SQL.
La API de oxmysql
oxmysql ofrece varios métodos según lo que esperes recibir. La versión `.await` espera el resultado de forma síncrona dentro de un hilo (debe ejecutarse dentro de una función o callback de servidor), lo que hace el código mucho más legible que con callbacks anidados.
local identifier = 'license:abc123'
-- query: devuelve TODAS las filas (una tabla de filas)
local vehiculos = MySQL.query.await('SELECT * FROM vehicles WHERE owner = ?', { identifier })
for _, v in ipairs(vehiculos) do print(v.plate) end
-- single: devuelve UNA sola fila (o nil)
local user = MySQL.single.await('SELECT * FROM users WHERE identifier = ?', { identifier })
print(user.name, user.money)
-- scalar: devuelve UN solo valor (la primera columna de la primera fila)
local dinero = MySQL.scalar.await('SELECT money FROM users WHERE identifier = ?', { identifier })
print('Saldo:', dinero)Leer datos: query, single y scalar
-- insert: crea una fila y devuelve el id autogenerado (insertId)
local nuevoId = MySQL.insert.await(
'INSERT INTO vehicles (owner, plate, model) VALUES (?, ?, ?)',
{ identifier, 'CRX 4321', 'adder' }
)
print('Vehículo guardado con id', nuevoId)
-- update: modifica filas y devuelve cuántas cambiaron (affectedRows)
local filas = MySQL.update.await(
'UPDATE users SET money = ? WHERE identifier = ?',
{ 5000, identifier }
)
print('Filas actualizadas:', filas)Escribir datos: insert y update
Consultas parametrizadas: tu escudo contra la inyección SQL
Fíjate en el símbolo `?` de los ejemplos anteriores. Cada `?` es un hueco que oxmysql rellena con los valores de la tabla `{ ... }`, escapándolos de forma segura. Esto NO es opcional: es la única forma correcta de meter datos en una consulta. Nunca, jamás, construyas SQL pegando texto del jugador con `..`.
-- ☠️ ANTI-PATRÓN: concatenar valores del usuario. INYECCIÓN SQL.
local nombre = source_input -- p. ej. "x'; DROP TABLE users; --"
local mal = MySQL.query.await("SELECT * FROM users WHERE name = '" .. nombre .. "'")
-- ✅ CORRECTO: el valor viaja como parámetro, nunca como código.
local bien = MySQL.query.await('SELECT * FROM users WHERE name = ?', { nombre })Antipatrón vs. consulta parametrizada
En el ejemplo malo, un jugador podría escribir como nombre algo que cierre tu consulta y ejecute la suya propia, borrando tablas enteras o robando datos. Con el `?`, ese texto se trata siempre como un dato literal, nunca como instrucciones.
Diseñar una tabla
Antes de guardar nada, define la tabla. Usa CREATE TABLE IF NOT EXISTS para que el recurso pueda crearla automáticamente al arrancar si no existe. Cada tabla necesita una clave primaria (id), columnas tipadas según lo que guardan, y un índice en la columna por la que sueles filtrar (normalmente identifier o citizenid).
CREATE TABLE IF NOT EXISTS `vehicles` (
`id` INT NOT NULL AUTO_INCREMENT,
`owner` VARCHAR(60) NOT NULL, -- identifier del dueño
`plate` VARCHAR(8) NOT NULL, -- matrícula
`model` VARCHAR(60) NOT NULL, -- spawn name del coche
`stored` TINYINT(1) DEFAULT 1, -- 1 = en garaje, 0 = fuera
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `plate` (`plate`), -- no se repiten matrículas
INDEX `idx_owner` (`owner`) -- filtramos por dueño: lo indexamos
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;Tabla vehicles con clave primaria e índice
El índice `idx_owner` es la diferencia entre una consulta instantánea y una que recorre fila por fila toda la tabla. Si filtras por `owner`, indexa `owner`. Es la optimización más barata y de mayor impacto que harás.
Consultas avanzadas: filtrar, ordenar y limitar
SELECT * está bien para empezar, pero en producción rara vez quieres toda la tabla. Las cláusulas WHERE (qué filas), ORDER BY (en qué orden) y LIMIT (cuántas) te dejan pedir exactamente lo que necesitas. Pedir solo lo justo es la primera regla de rendimiento: menos datos viajando, menos memoria, respuestas más rápidas.
-- Los 10 jugadores más ricos, del que más al que menos
SELECT identifier, name, money
FROM users
WHERE money > 0
ORDER BY money DESC
LIMIT 10;
-- Vehículos de un jugador que están guardados, más recientes primero
SELECT plate, model, created_at
FROM vehicles
WHERE owner = ? AND stored = 1
ORDER BY created_at DESC
LIMIT 50;WHERE + ORDER BY + LIMIT
También pide solo las columnas que vas a usar (identifier, name, money) en vez de `*`. Si tu tabla users tiene un blob JSON enorme con el inventario, traerlo cuando solo quieres el saldo es desperdicio puro.
JOINs: cruzar tablas relacionadas
Los datos de un servidor RP están repartidos en varias tablas que se relacionan por una clave común: el `identifier` (ESX) o el `citizenid` (QBCore). Un JOIN une dos tablas por esa clave para devolver una vista combinada en una sola consulta, en vez de hacer una consulta a users y luego otra a vehicles.
-- INNER JOIN: solo jugadores que TIENEN al menos un vehículo
SELECT u.name, u.identifier, v.plate, v.model
FROM users u
INNER JOIN vehicles v ON v.owner = u.identifier
WHERE u.identifier = ?;INNER JOIN: users + vehicles por identifier
-- LEFT JOIN: TODOS los jugadores, tengan coches o no.
-- Los que no tienen devuelven NULL en las columnas de vehicles.
SELECT u.name, u.identifier, v.plate
FROM users u
LEFT JOIN vehicles v ON v.owner = u.identifier
ORDER BY u.name;LEFT JOIN: incluye jugadores sin vehículos
La diferencia clave: INNER JOIN descarta las filas sin pareja (un jugador sin coches no aparece); LEFT JOIN conserva todas las filas de la tabla izquierda y rellena con NULL las columnas que no encuentran pareja. Para que un JOIN sea rápido, la columna por la que unes (`v.owner`) debe estar indexada.
Agregados: COUNT, SUM y GROUP BY
A veces no quieres las filas, quieres un resumen: ¿cuántos coches tiene cada jugador?, ¿cuánto dinero hay en total en el servidor? Para eso están las funciones de agregación, normalmente acompañadas de GROUP BY, que agrupa las filas por un valor antes de contarlas o sumarlas.
-- ¿Cuántos coches tiene CADA dueño? (ranking de garajes)
SELECT owner, COUNT(*) AS total_coches
FROM vehicles
GROUP BY owner
ORDER BY total_coches DESC;
-- Dinero total y media por jugador en todo el servidor
SELECT COUNT(*) AS jugadores,
SUM(money) AS dinero_total,
AVG(money) AS media
FROM users;
-- Solo dueños con MÁS de 3 coches (HAVING filtra sobre el agregado)
SELECT owner, COUNT(*) AS total
FROM vehicles
GROUP BY owner
HAVING total > 3;COUNT, SUM, AVG y GROUP BY / HAVING
WHERE filtra filas ANTES de agrupar; HAVING filtra grupos DESPUÉS de agregar. Si quieres «coches creados este mes», eso es WHERE; si quieres «dueños con más de 3 coches», eso es HAVING.
Índices a fondo: por qué tu servidor vuela o se arrastra
Sin índice, MySQL hace un «full table scan»: lee fila por fila toda la tabla para encontrar las que coinciden. Con 50 filas no lo notas; con 200.000 vehículos, cada consulta sin índice puede tardar cientos de milisegundos y bloquear el hilo. Un índice es como el índice alfabético de un libro: en vez de leerlo entero, va directo a la página.
- Índice simple: sobre una columna. Acelera filtros y JOINs por esa columna (idx_owner en vehicles).
- Índice compuesto: sobre varias columnas en orden. Sirve para consultas que filtran por ese prefijo. Un índice (owner, stored) ayuda a WHERE owner = ? AND stored = ?, y también a WHERE owner = ? solo, pero NO a WHERE stored = ? solo.
- Clave única (UNIQUE): además de indexar, impide duplicados (matrículas, identifiers).
- Coste: cada índice ocupa espacio y ralentiza un poco los INSERT/UPDATE (hay que mantenerlo). Indexa lo que filtras de verdad, no todo «por si acaso».
-- Índice compuesto para la consulta del garaje (owner + stored)
CREATE INDEX `idx_owner_stored` ON `vehicles` (`owner`, `stored`);
-- ¿Está usando mi índice? EXPLAIN te lo dice.
EXPLAIN SELECT plate FROM vehicles WHERE owner = 'license:abc' AND stored = 1;Índice compuesto y EXPLAIN
EXPLAIN delante de cualquier SELECT te muestra el plan de ejecución sin ejecutarlo de verdad. Mira la columna `type`: «ref» o «eq_ref» significan que usa un índice (bien); «ALL» significa full table scan (mal, falta índice). La columna `key` te dice qué índice eligió, y `rows` cuántas filas estima examinar. Menos es mejor.
Transacciones: todo o nada
Imagina transferir 1.000 € de un jugador a otro: restas 1.000 al primero y sumas 1.000 al segundo. Son dos UPDATE. ¿Qué pasa si el servidor crashea justo entre el primero y el segundo? El primer jugador pierde su dinero y el segundo nunca lo recibe: dinero evaporado. Una transacción agrupa varias consultas en una unidad atómica: o se aplican TODAS, o no se aplica NINGUNA.
-- Transferencia atómica entre dos jugadores con oxmysql
local function transferir(deOrigen, aDestino, cantidad)
local ok = MySQL.transaction.await({
{ 'UPDATE users SET money = money - ? WHERE identifier = ? AND money >= ?',
{ cantidad, deOrigen, cantidad } },
{ 'UPDATE users SET money = money + ? WHERE identifier = ?',
{ cantidad, aDestino } },
})
-- transaction.await devuelve true solo si TODAS las queries fueron bien.
-- Si alguna falla, oxmysql hace ROLLBACK: nadie pierde ni gana dinero.
if ok then
print('Transferencia completada')
else
print('Transferencia revertida (rollback)')
end
return ok
endMySQL.transaction.await para transferir dinero
Para que el `money >= ?` proteja de saldos negativos hace falta que la transacción sea de verdad atómica: con InnoDB (no MyISAM) y transaction.await, si el segundo UPDATE falla el primero se deshace. Úsalo siempre que dos o más escrituras tengan que cuadrar entre sí.
Funciones SQL (stored functions)
Una función almacenada es un trozo de lógica que vive DENTRO de la base de datos, recibe parámetros y devuelve un único valor. Sirve para centralizar un cálculo que usas en muchas consultas. Se crean con DELIMITER para que MySQL no confunda los `;` internos con el final de la sentencia, y se marcan DETERMINISTIC si con las mismas entradas siempre devuelven lo mismo.
DELIMITER //
-- Calcula el nivel de un jugador a partir de su XP
CREATE FUNCTION nivel_por_xp(xp INT)
RETURNS INT
DETERMINISTIC
BEGIN
-- 1000 XP por nivel, mínimo nivel 1
RETURN GREATEST(1, FLOOR(xp / 1000) + 1);
END //
DELIMITER ;
-- Se usa como cualquier función nativa, dentro de un SELECT:
SELECT name, xp, nivel_por_xp(xp) AS nivel
FROM users
ORDER BY nivel DESC;CREATE FUNCTION: nivel a partir de XP
La ventaja es que la regla «1000 XP por nivel» vive en un solo sitio. Si mañana cambia, la tocas una vez y todas las consultas que usan nivel_por_xp() quedan actualizadas. El inconveniente: la lógica de juego repartida entre Lua y SQL es más difícil de seguir, así que reserva las funciones para cálculos puramente de datos.
Stored procedures: rutinas con varios pasos
Un procedimiento es como una función, pero puede ejecutar varias sentencias, no devuelve necesariamente un valor (usa parámetros OUT) y puede modificar datos. Se usa cuando una operación de datos tiene varios pasos que siempre van juntos y quieres ejecutarla con una sola llamada (CALL).
DELIMITER //
CREATE PROCEDURE comprar_vehiculo(
IN p_owner VARCHAR(60),
IN p_plate VARCHAR(8),
IN p_model VARCHAR(60),
IN p_precio INT,
OUT p_ok TINYINT -- 1 = comprado, 0 = sin saldo
)
BEGIN
DECLARE saldo INT;
SELECT money INTO saldo FROM users WHERE identifier = p_owner;
IF saldo >= p_precio THEN
UPDATE users SET money = money - p_precio WHERE identifier = p_owner;
INSERT INTO vehicles (owner, plate, model) VALUES (p_owner, p_plate, p_model);
SET p_ok = 1;
ELSE
SET p_ok = 0;
END IF;
END //
DELIMITER ;
-- Llamada (desde otro SQL o un cliente):
CALL comprar_vehiculo('license:abc', 'CRX 0001', 'adder', 100000, @resultado);
SELECT @resultado;CREATE PROCEDURE con IN/OUT
¿Procedimiento o lógica en Lua? En FiveM, casi siempre prefieres Lua: es donde vive el resto de tu lógica de juego, lo depuras con prints y lo versionas con Git fácilmente. Reserva los procedures para operaciones intensivas de datos (limpiezas masivas, mantenimiento, migraciones) donde mover esa lógica al servidor SQL evita traer miles de filas a Lua solo para reescribirlas.
Triggers: reacciones automáticas a los cambios
Un trigger es código SQL que se dispara solo cuando ocurre un INSERT, UPDATE o DELETE en una tabla, sin que nadie lo llame. Son ideales para auditoría (registrar quién cambió qué), mantener contadores derivados o poner timestamps automáticos, porque la regla se cumple SIEMPRE, venga el cambio de tu recurso de economía, de otro script o de un admin tocando la base de datos a mano.
-- Tabla de auditoría de movimientos de dinero
CREATE TABLE IF NOT EXISTS `money_log` (
`id` INT NOT NULL AUTO_INCREMENT,
`identifier` VARCHAR(60) NOT NULL,
`old_money` INT NOT NULL,
`new_money` INT NOT NULL,
`diff` INT NOT NULL,
`changed_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_identifier` (`identifier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
DELIMITER //
-- Cada vez que cambia el dinero de un jugador, lo registramos
CREATE TRIGGER trg_money_after_update
AFTER UPDATE ON users
FOR EACH ROW
BEGIN
IF NEW.money <> OLD.money THEN
INSERT INTO money_log (identifier, old_money, new_money, diff)
VALUES (NEW.identifier, OLD.money, NEW.money, NEW.money - OLD.money);
END IF;
END //
DELIMITER ;TRIGGER AFTER UPDATE para auditar el dinero
Dentro del trigger, OLD se refiere a la fila antes del cambio y NEW a la fila después. Aquí solo registramos si el dinero cambió de verdad (NEW.money <> OLD.money), evitando ruido. Con esto detectas duplicación de dinero (un diff gigante e inexplicable) revisando money_log.
Usa los triggers con criterio. Se ejecutan en CADA fila afectada y dentro de la misma transacción que la escritura original: un trigger pesado (cálculos, varias inserciones) en una tabla que se actualiza cada segundo puede meter lag al servidor. Mantenlos ligeros y para tareas que de verdad deban ser automáticas e inviolables.
Migraciones y versionado del esquema
Tu esquema (las tablas y sus columnas) evoluciona: hoy añades una columna `level`, mañana una tabla `factions`. Una migración es un fichero .sql con esos cambios, numerado y guardado en Git, para que cualquiera pueda reconstruir la base de datos paso a paso y nunca tengas cambios «en la cabeza» que se pierden.
- Numera los ficheros: 0001_init.sql, 0002_add_vehicles.sql, 0003_add_level.sql. El orden importa.
- Idempotencia: usa CREATE TABLE IF NOT EXISTS y, para columnas, comprueba antes de añadir. Así re-ejecutar una migración no rompe nada.
- Nunca edites una migración ya aplicada en producción: crea una nueva. El historial es inmutable.
- Cambios destructivos (DROP COLUMN, DROP TABLE) requieren cuidado extra: una vez aplicados, los datos no vuelven.
- BACKUP antes de migrar producción. Siempre. Un mysqldump de 30 segundos te ahorra perder la economía entera del servidor.
-- 0003_add_level.sql — añadir columna sin romper datos existentes
ALTER TABLE `users`
ADD COLUMN IF NOT EXISTS `level` INT NOT NULL DEFAULT 1;
-- Backups antes de tocar producción (en la terminal, no en SQL):
-- mysqldump -u root -p fivem > backup_2026_06_29.sqlMigración aditiva segura + backup
Buenas prácticas y rendimiento
- Parametriza SIEMPRE con ?. No hay excepciones: cualquier dato que venga del jugador es hostil hasta que se demuestre lo contrario.
- No consultes dentro de bucles ni cada frame. En lugar de un SELECT por cada elemento, lee todo de una vez con un WHERE ... IN (?) o un JOIN y procesa el resultado en Lua.
- Inserta en lote (batch): si vas a guardar 100 ítems, usa una transacción o un único INSERT con varias filas en vez de 100 inserts sueltos. Reduce el ida y vuelta a la base de datos.
- Usa .await sin miedo: no bloquea todo el servidor, solo pausa el hilo actual mientras el resto del servidor sigue funcionando.
- Indexa las columnas por las que filtras y unes (identifier, citizenid, owner). Sin índice, las consultas se vuelven lentísimas a medida que crece la tabla.
- No guardes un JSON gigante si necesitas filtrar por dentro: si buscas «todos los que tienen el ítem X», eso debe ser una columna o tabla propia, no un blob que tengas que recorrer en Lua.
- No guardes secretos en claro: contraseñas, tokens o claves de API jamás se almacenan tal cual; hashéalos o ni siquiera los metas en la base de datos.
- Escribe en la base de datos solo cuando haga falta (al desconectar, al comprar), no cada frame. Guardar de más castiga el rendimiento.
Regla maestra: la lógica de juego va en Lua; la integridad (transacciones, claves únicas) y los agregados pesados van en SQL; y cuando dudes de si una consulta es lenta, no adivines: mídelo con EXPLAIN.
¿Una duda sobre esto? El chat de la IA lo sabe todo y te responde con código.
Pregunta a la IA