开发 / ROBLOX
Roblox 请求限流:保护服务器提示,避免反复点击
为工坊文字提示制作教学限流器:允许四次立即尝试,再以每秒两个令牌的速度补充。解释玩家独立状态、服务器时间、按钮恢复和本地检查,不向 Roblox 发送真实测试流量。
先选择需要限制的具体动作 #
设想新手在工坊点击“工作站在哪里?”请求提示。快速连续点击可能是误操作、等待延迟回复,也可能是不希望出现的重复。如果每次请求都启动昂贵工作,小按钮也会造成不必要的负担。先写明点击之后运行哪项服务器操作,以及为什么需要限制频率。
我们选择文字提示,不涉及购买、奖励或档案保存。这是原创教学场景,不是已添加到网站作者游戏中的功能。四次尝试与每秒两个令牌,是为了说明算法选择的参数,不是 Roblox 平台配额,也不是所有游戏动作的通用建议。本教程没有进行真实多人负载测试。
客户端按钮不决定服务器规则 #
暂时禁用按钮并显示等待回复,可以帮助玩家理解请求已经发出。但服务器仍必须独立判断下一次调用是否允许。Roblox 文档明确提醒,不应只依赖客户端的频率限制。界面中的等待提示解决操作理解问题,并不能替代服务器判断。
不要接受客户端声称的剩余请求次数或上次点击时间作为许可依据。我们的模块在服务器保存余额与时间。客户端请求提示,服务器作出决定。频率控制不会把错误参数变成正确参数;类型、允许的动作和游戏状态,仍要分别验证。
令牌桶允许短时间连续尝试 #
想象每名玩家都有一个最多容纳四个令牌的桶。一次尝试消耗一个令牌,每秒恢复两个,但余额不超过容量。因此可以立即尝试四次,持续请求则要等待补充。这是 Roblox 文档介绍的 token bucket 思路。
它不同于“每个日历秒内四次调用”。短暂等待可能只补充部分令牌。没有一个完整令牌时,下一次尝试仍被拒绝。我们测试小数补充,让读者能够解释等待时间,而不是把它当作随机按钮故障。
| 时间 | 尝试 | 结果与余额 |
|---|---|---|
| 0 | 第一次至第四次 | 允许:4 → 0 |
| 0 | 第五次 | 拒绝:0 |
| 0.25 s | 新尝试 | 拒绝:0.5 |
| 0.5 s | 新尝试 | 允许:0 |
每名玩家保存独立状态 #
玩家 A 用完四个令牌,不应因此阻止 B 的下一次请求。桶表使用服务器 OnServerEvent 收到的 Player 作为键。不要用客户端额外发送的玩家名或身份编号替换这个对象,否则选择其他玩家的桶就会依赖不可信输入。
独立桶不会限制所有玩家的总流量。大量用户可能同时消耗各自允许的尝试。昂贵动作或后端请求,需要另外分析整体频率、队列和操作成本。这个模块没有共享服务器预算、并发任务上限或跨服务器状态。
理解教学 ModuleScript #
把 HintRequestLimiter 放入 ServerScriptService。new(capacity, refillPerSecond, clock) 设置容量、补充速度和服务器时钟函数。Allow(player) 返回是否允许与简短结果代码,Forget(player) 在离开后删除本地状态。模块不访问 DataStore、不创建 RemoteEvent,也不自行发送提示。
容量必须是 1 到 1000 的整数,补充速度必须为正的有限数字且不超过 1000。这些是本例的校验范围,不是平台配额,补充速度可以是小数。无效时钟或时钟报错会拒绝请求;时间倒退不会增加令牌。全部状态只属于当前服务器。
os.clock 没有指定绝对起点,应比较时间差,而不是日历日期。教学模块接受有限的负起点,单独的本地测试验证这种时钟下的补充。
-- Original teaching limiter for server-side hint requests.
-- Pass the Player supplied by OnServerEvent, never a client-supplied identity.
-- Frequency control does not validate permissions or grant rewards.
local Limiter = {}
local function finite(value)
return type(value) == "number" and value == value
and value > -math.huge and value < math.huge
end
function Limiter.new(capacity, refillPerSecond, clock)
assert(finite(capacity) and capacity == math.floor(capacity)
and capacity >= 1 and capacity <= 1000, "Invalid capacity")
assert(finite(refillPerSecond) and refillPerSecond > 0
and refillPerSecond <= 1000, "Invalid refill rate")
if clock == nil then clock = os.clock end
assert(type(clock) == "function", "Invalid clock")
local buckets = {}
local adapter = {}
function adapter:Allow(player)
if player == nil then return false, "MissingPlayer" end
local ok, now = pcall(clock)
if not ok or not finite(now) then
return false, "InvalidClock"
end
local bucket = buckets[player]
if not bucket then
bucket = {tokens = capacity, last = now}
buckets[player] = bucket
elseif now < bucket.last then
return false, "ClockWentBackwards"
else
bucket.tokens = math.min(capacity,
bucket.tokens + (now - bucket.last) * refillPerSecond)
bucket.last = now
end
if bucket.tokens < 1 then return false, "RateLimited" end
bucket.tokens -= 1
return true, "Allowed"
end
function adapter:Forget(player)
buckets[player] = nil
end
return adapter
end
return Limiter
明确接入服务器处理函数 #
在 ReplicatedStorage 创建名为 GetHint 的 RemoteEvent,并在模块旁放置服务器 Script。Script 获取服务、通过 require 加载模块,使用 4、2 与 os.clock 创建限流器。处理函数使用 Roblox 提供的 Player,检查请求,再只向这名玩家发送预先定义的文字。
示例要求现有服务器教学逻辑维护 TutorialStep=1 属性。它不会启动教学,也不会按客户端请求修改该属性。没有这个状态就不发送提示。示例还没有客户端界面;需要另外接入 GetHint 和回复处理。服务器 Script 展示集成方式,不代表已经测试完成的整款游戏。
-- Server Script; create ReplicatedStorage.GetHint as a RemoteEvent first.
-- TutorialStep must be maintained by your existing SERVER gameplay logic.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Limiter = require(
game:GetService("ServerScriptService"):WaitForChild("HintRequestLimiter")
)
local request = ReplicatedStorage:WaitForChild("GetHint")
assert(request:IsA("RemoteEvent"), "GetHint must be a RemoteEvent")
local limiter = Limiter.new(4, 2, os.clock)
request.OnServerEvent:Connect(function(player, hintName)
-- Each attempt consumes quota before further processing.
if not limiter:Allow(player) then return end
if type(hintName) ~= "string" or #hintName > 32 then return end
if hintName ~= "FindWorkshop" then return end
if player:GetAttribute("TutorialStep") ~= 1 then return end
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if not humanoid or humanoid.Health <= 0 then return end
request:FireClient(player, "Hint", "Look for the workshop sign.")
end)
Players.PlayerRemoving:Connect(function(player)
limiter:Forget(player)
end)
定义什么会消耗一次尝试 #
本例在字符串校验前调用 Allow,所以提示名称错误或教学阶段不合适,也会消耗一个令牌。这是有意选择的规则:限制的是进入处理函数的次数,而不只是成功发出提示的次数。错误输入没有无限免费通道进入后续处理。
不要每次拒绝后自动退还令牌,否则大量错误请求可能绕过所选规则。游戏意义仍要独立验证。有令牌不代表拥有奖励权限、物品所有权、已完成任务或可以修改他人对象。其他任务可能需要不同顺序和成本,应该按具体目的明确规定。
不要回复每个被拒绝的重复请求 #
教学处理函数会静默停止受限请求。如果每次拒绝都发送回复或写入详细日志,拒绝路径本身也可能产生新的工作流。不要让限流器变成无尽通知来源。为了观察,选择有边界的拒绝汇总方式,而不是保存所有输入包。
静默拒绝要求界面在合理的本地等待后恢复按钮,并说明可以重试。本例没有实现这个客户端计时器。不要永远等待服务器刻意不发送的回复,导致按钮一直禁用。界面便利与服务器许可决定是两项不同任务。
用受控时间检查 #
本地 Luau 测试向构造函数传入自己的时钟。在时间 0,A 可以尝试四次,第五次返回 RateLimited。到 0.25 只补充了半个令牌,所以仍拒绝。到 0.5 有一个令牌,一次尝试成功,下一次失败。这些数字描述本地测试,不是实际游戏访问。
测试还检查 B 的独立桶。长时间闲置最多恢复四次尝试,无效时间与倒退不会制造额外余额。配置校验、清理和小数恢复速度也被检查。全部测试使用替代对象和受控时间,没有真实 Roblox 远程事件、玩家或服务器性能测量。
| 检查 | 预期结果 |
|---|---|
| 立即第五次尝试 | RateLimited |
| 另一玩家请求 | 独立令牌桶 |
| 长时间闲置 | 最多 4 个令牌 |
| 时间倒退 | 不补充 |
| 无效时钟 | InvalidClock |
| 清理后的状态 | 新的满桶 |
玩家离开时清理 #
把 Players.PlayerRemoving 接到 limiter:Forget(player)。否则表会保留已离开玩家的状态,直到服务器结束。不要在普通拒绝后调用 Forget:下一次请求会得到新的满桶,取消频率限制的意义。清理属于玩家生命周期管理,不是恢复按钮许可的工具。
清理后重新进入会从新桶开始。这是预期的本地行为,不是防止反复重连或实现账号全局限流。状态不会跨服务器保存。涉及多个会话的要求需要单独设计;删除表项并不能证明这些保证。
限制频率不等于限制全部后续工作 #
允许的请求可能在其他函数中启动长任务。模块不等待任务结束,也不计算已经运行的任务数。即使符合所选频率,昂贵操作仍可能承受不了。检查许可之后发生什么:是否复制大型模型、调用 API 或影响其他玩家?
购买、数据保存和共享经济系统需要各自的规则,不能只有令牌。本例也没有检查到实体工作站的距离,因为文字提示刻意没有这种要求。适配物体交互时,应增加需要的服务器位置、权限与状态验证。我们没有修改作者现有游戏代码。
准备交接给开发者的材料 #
把受限动作、消耗尝试的含义、容量、补充速度和服务器校验一起保存。附上本地测试路径,明确标记真实网络与负载测试仍待完成。不要把四作为所有按钮或武器的统一设置;这个数字只属于当前练习。
请开发者检查两名玩家、长时间闲置、离开、错误请求,以及静默拒绝后按钮是否恢复。单独列出缺失功能:共享服务器预算、并行操作、跨会话状态和客户端界面。这样可以把思路用于下一款游戏,同时保留清楚的完成边界。
原始资料
Roblox Creator Hub — Client-server boundaryLuau — Standard library
Roblox Creator Hub — os