Skip to content

最佳实践

本指南提供了使用 Sleet ORM 的最佳实践,帮助您构建高效、可维护的 FiveM 应用。

Schema 设计原则

合理的表结构设计

lua
-- ✅ 好的实践
local players = sl.table('players', {
    -- 使用有意义的主键
    id = sl.serial().primaryKey().comment('玩家唯一标识'),
    
    -- 唯一标识符应该有约束
    identifier = sl.varchar(64).notNull().unique().comment('Steam/Discord ID'),
    
    -- 金钱字段使用 decimal 确保精度
    money = sl.decimal(10, 2).default(1000.00).comment('现金金额'),
    bank = sl.decimal(12, 2).default(5000.00).comment('银行余额'),
    
    -- 布尔字段提供默认值
    is_active = sl.boolean().default(true).comment('账户是否激活'),
    
    -- 时间戳字段使用自动更新
    created_at = sl.timestamp().defaultNow().comment('创建时间'),
    updated_at = sl.timestamp().defaultNow().onUpdate(sl.sql('NOW()')).comment('更新时间'),
    
    -- 软删除支持
    deleted_at = sl.timestamp().softDelete().comment('删除时间戳')
})

-- ❌ 避免的做法
local bad_players = sl.table('players', {
    -- 没有注释,难以理解
    id = sl.int(),
    
    -- 金钱使用 int,可能丢失精度
    money = sl.int(),
    
    -- 缺少约束和默认值
    name = sl.varchar(255),
    active = sl.boolean()
})

数据类型选择

lua
-- 根据实际需求选择合适的数据类型
local items = sl.table('items', {
    id = sl.serial().primaryKey(),
    
    -- 字符串长度要合理
    name = sl.varchar(100).notNull().comment('物品名称'),          -- 短名称
    description = sl.text().comment('详细描述'),                  -- 长文本
    
    -- 数字类型要精确
    price = sl.decimal(8, 2).comment('价格(精确到分)'),         -- 货币
    weight = sl.float().comment('重量(公斤)'),                  -- 浮点数
    stack_size = sl.smallint().default(1).comment('堆叠数量'),    -- 小整数
    
    -- 合理使用 JSON
    metadata = sl.json().comment('扩展属性'),                     -- 复杂数据
    
    -- 时间字段
    expires_at = sl.timestamp().comment('过期时间'),
    created_at = sl.timestamp().defaultNow()
})

查询优化

使用索引友好的查询

lua
-- ✅ 索引友好的查询
local player = db.select()
    .from(s.players)
    .where(sl.eq(s.players.identifier, identifier))  -- identifier 有 unique 索引
    .limit(1)
    .execute()[1]

-- ✅ 复合条件查询
local activePlayers = db.select()
    .from(s.players)
    .where(sl.and_(
        sl.eq(s.players.is_active, true),
        sl.isNull(s.players.deleted_at)
    ))
    .execute()

-- ❌ 避免全表扫描
local badQuery = db.select()
    .from(s.players)
    .where(sl.like(s.players.metadata, '%某个值%'))  -- JSON 字段模糊查询
    .execute()

分页查询

lua
-- ✅ 高效的分页
function GetPlayersPaginated(page, pageSize)
    pageSize = pageSize or 20
    local offset = (page - 1) * pageSize
    
    return db.select()
        .from(s.players)
        .orderBy(s.players.id, 'asc')  -- 稳定的排序
        .limit(pageSize)
        .offset(offset)
        .execute()
end

-- ✅ 获取总数(如果需要)
function GetPlayersCount()
    ---@type { count: integer }[]
    local result = db.select({ sl.sql('COUNT(*) as count') })
        .from(s.players)
        .execute()
    return result[1].count
end

批量操作

lua
-- ✅ 批量插入
function CreateMultiplePlayers(playersData)
    return db.insert(s.players)
        .values(playersData)  -- 传入数组
        .execute()
end

-- ✅ 批量更新(使用 IN 查询)
function UpdatePlayersMoney(playerIds, amount)
    return db.update(s.players)
        .set({ money = sl.sql('`money` + ?', { amount }) })
        .where(sl.inArray(s.players.id, playerIds))
        .execute()
end

错误处理

安全的数据库操作

lua
-- ✅ 带错误处理的操作
function SafeUpdatePlayerMoney(playerId, amount)
    local success, result = pcall(function()
        return db.update(s.players)
            .set({ money = sl.sql('`money` + ?', { amount }) })
            .where(sl.and_(
                sl.eq(s.players.id, playerId),
                sl.gte(s.players.money, -amount)  -- 防止负数
            ))
            .execute()
    end)
    
    if not success then
        print("更新玩家金钱失败:", result)
        return false
    end
    
    return result > 0  -- 返回是否实际更新了记录
end

-- ✅ 验证数据存在性
function GetPlayerSafely(identifier)
    local players = db.select()
        .from(s.players)
        .where(sl.eq(s.players.identifier, identifier))
        .limit(1)
        .execute()
    
    if #players == 0 then
        return nil, "玩家不存在"
    end
    
    return players[1], nil
end

性能优化

连接管理

lua
-- ✅ 复用数据库连接
local db = sl.connect()  -- 在模块顶层创建一次

-- ✅ 避免在循环中重复创建连接
function ProcessPlayers(playerList)
    for _, playerId in ipairs(playerList) do
        -- 使用已有的 db 连接
        local player = db.select()
            .from(s.players)
            .where(sl.eq(s.players.id, playerId))
            .execute()[1]
        
        -- 处理玩家数据...
    end
end

查询缓存策略

lua
-- ✅ 简单的内存缓存
local playerCache = {}
local CACHE_TTL = 300000  -- 5分钟

function GetPlayerCached(identifier)
    local cached = playerCache[identifier]
    if cached and (GetGameTimer() - cached.timestamp) < CACHE_TTL then
        return cached.data
    end
    
    -- 从数据库获取
    local player = db.select()
        .from(s.players)
        .where(sl.eq(s.players.identifier, identifier))
        .execute()[1]
    
    if player then
        playerCache[identifier] = {
            data = player,
            timestamp = GetGameTimer()
        }
    end
    
    return player
end

减少数据传输

lua
-- ✅ 只查询需要的字段
function GetPlayerBasicInfo(identifier)
    return db.select({
        s.players.id,
        s.players.name,
        s.players.money,
        s.players.bank
    })
    .from(s.players)
    .where(sl.eq(s.players.identifier, identifier))
    .execute()[1]
end

-- ❌ 避免查询不必要的大字段
function BadGetPlayerInfo(identifier)
    -- 这会查询包括 metadata (JSON) 在内的所有字段
    return db.select()
        .from(s.players)
        .where(sl.eq(s.players.identifier, identifier))
        .execute()[1]
end

数据完整性

原子操作

lua
-- ✅ 使用 SQL 表达式确保原子性
function TransferMoneyAtomic(fromId, toId, amount)
    -- 扣除发送方
    local fromResult = db.update(s.players)
        .set({ money = sl.sql('`money` - ?', { amount }) })
        .where(sl.and_(
            sl.eq(s.players.id, fromId),
            sl.gte(s.players.money, amount)  -- 确保余额充足
        ))
        .execute()
    
    if fromResult == 0 then
        return false, "余额不足或玩家不存在"
    end
    
    -- 增加接收方
    local toResult = db.update(s.players)
        .set({ money = sl.sql('`money` + ?', { amount }) })
        .where(sl.eq(s.players.id, toId))
        .execute()
    
    if toResult == 0 then
        -- 回滚:如果接收方不存在,撤销扣款
        db.update(s.players)
            .set({ money = sl.sql('`money` + ?', { amount }) })
            .where(sl.eq(s.players.id, fromId))
            .execute()
        return false, "接收方不存在"
    end
    
    return true, "转账成功"
end

数据验证

lua
-- ✅ 在应用层验证数据
function ValidatePlayerData(data)
    if not data.identifier or type(data.identifier) ~= 'string' then
        return false, "标识符无效"
    end
    
    if not data.name or type(data.name) ~= 'string' or #data.name > 255 then
        return false, "名称无效"
    end
    
    if data.money and (type(data.money) ~= 'number' or data.money < 0) then
        return false, "金钱数额无效"
    end
    
    return true, nil
end

function CreatePlayer(data)
    local valid, error = ValidatePlayerData(data)
    if not valid then
        return nil, error
    end
    
    return db.insert(s.players)
        .values(data)
        .execute()
end

代码组织

模块化设计

lua
-- server/database/players.lua
local sl = Sleet
local s = require 'server.schema'
local db = sl.connect()

local Players = {}

function Players.GetByIdentifier(identifier)
    return db.select()
        .from(s.players)
        .where(sl.eq(s.players.identifier, identifier))
        .limit(1)
        .execute()[1]
end

function Players.Create(data)
    return db.insert(s.players)
        .values(data)
        .execute()
end

function Players.UpdateMoney(playerId, amount)
    return db.update(s.players)
        .set({ money = sl.sql('`money` + ?', { amount }) })
        .where(sl.eq(s.players.id, playerId))
        .execute()
end

return Players
lua
-- server/main.lua
local Players = require 'server.database.players'

RegisterNetEvent('sleet:getPlayer', function()
    local source = source
    local identifier = GetPlayerIdentifierByType(source, 'steam')
    
    local player = Players.GetByIdentifier(identifier)
    if not player then
        player = Players.Create({
            identifier = identifier,
            name = GetPlayerName(source)
        })
    end
    
    -- 使用玩家数据...
end)

类型安全

lua
-- ✅ 使用类型注解(如果支持 LuaLS)
---@param identifier string Steam 标识符
---@param name string 玩家名称
---@return integer|nil playerId 新创建的玩家 ID
function CreatePlayer(identifier, name)
    return db.insert(s.players)
        .values({
            identifier = identifier,
            name = name
        })
        .execute()
end

-- ✅ 为复杂查询添加返回类型注解
---@return { id: integer, name: string, money: number }[]
function GetRichPlayers()
    return db.select({ s.players.id, s.players.name, s.players.money })
        .from(s.players)
        .where(sl.gte(s.players.money, 100000))
        .execute()
end

调试技巧

查询调试

lua
-- ✅ 调试模式下记录 SQL
local DEBUG_MODE = true

function DebugQuery(queryBuilder)
    if DEBUG_MODE then
        local sql, params = queryBuilder.toSQL()
        print("SQL:", sql)
        print("Params:", json.encode(params))
    end
    return queryBuilder.execute()
end

-- 使用方式
local players = DebugQuery(
    db.select()
        .from(s.players)
        .where(sl.eq(s.players.is_active, true))
)

性能监控

lua
-- ✅ 监控慢查询
function MonitoredQuery(name, queryFn)
    local start = GetGameTimer()
    local result = queryFn()
    local duration = GetGameTimer() - start
    
    if duration > 100 then  -- 超过100ms记录警告
        print(("慢查询警告: %s 耗时 %dms"):format(name, duration))
    end
    
    return result
end

-- 使用方式
local players = MonitoredQuery("获取活跃玩家", function()
    return db.select()
        .from(s.players)
        .where(sl.eq(s.players.is_active, true))
        .execute()
end)

总结

遵循这些最佳实践将帮助您:

  • 构建高性能的数据库操作
  • 保证数据完整性和安全性
  • 编写可维护的代码
  • 避免常见的性能陷阱

记住,好的数据库设计和查询优化是构建稳定 FiveM 服务器的基础!

Released under the MIT License.