16. 高并发与高请求
高并发与高请求
上一章讲了灰度发布,解决的是"怎么安全地更新"。但更新只是手段,目的是让更多玩家来玩。当你的游戏从几百人同时在线变成几万人、几十万人同时在线时,服务器就会面临一个终极挑战:高并发。
本章用大白话讲清楚:什么是高并发、为什么它会让服务器崩溃、以及作为游戏开发者你应该怎么做。
什么是高并发
打个比方:你开了一家奶茶店。
- 低并发:店里同时来了 5 个客人,1 个店员做奶茶,大家排队等一下就好了
- 高并发:突然涌进来 500 个客人,1 个店员根本做不过来——队伍越排越长,客人等得不耐烦走了,甚至有人开始吵架
- 高并发解决方案:雇 10 个店员、开通线上小程序点单、提前准备好半成品原料、限制每人最多买 3 杯
在游戏世界里,"客人"就是玩家的请求,"店员"就是服务器处理能力。
| 术语 | 大白话解释 |
|---|---|
| 并发 | 同一时刻有多少人在同时使用你的服务 |
| QPS(每秒请求数) | 每秒钟有多少个请求打到服务器上 |
| 吞吐量 | 服务器每秒钟能处理多少个请求 |
| 瓶颈 | 系统中最慢的那个环节,决定了整体速度 |
| 雪崩 | 一个环节挂了,导致连锁反应,整个系统崩溃 |
哪些场景会出现高并发
对于游戏来说,高并发通常发生在以下时刻:
| 场景 | 流量特征 | 举例 |
|---|---|---|
| 游戏开服 | 所有玩家同一时间涌入 | 新游戏首发、新服务器开放 |
| 限时活动开始 | 瞬间流量飙升 | 春节活动 0 点准时开始,几万人同时点击"参加" |
| 排行榜结算 | 大量查询集中爆发 | 活动结束时的最终排名刷新 |
| 抽卡/开箱 | 短时间大量请求 | 限定角色上线,所有人疯狂抽卡 |
| 每日登录 | 固定时段的流量波峰 | 晚上 8-10 点是玩家活跃高峰 |
最危险的时刻
开服 + 活动开始是最高风险的时刻。两个流量高峰叠加,如果服务器扛不住,玩家看到的画面就是:登录页面转圈圈、活动按钮点了没反应、排行榜加载不出来。
这个时刻如果出问题,玩家会在社交媒体上疯狂吐槽,直接伤害游戏口碑。
为什么服务器会扛不住
瓶颈在哪
服务器的处理能力是有限的。一个请求从发出去到收到结果,中间要经过多个环节:
玩家手机 → 网络 → 负载均衡 → Web 服务器 → 应用服务器 → 数据库 → 返回结果每一个环节都可能成为瓶颈:
| 环节 | 瓶颈表现 | 原因 |
|---|---|---|
| 网络带宽 | 请求发不出去、响应回不来 | 带宽就像高速公路的宽度,车太多就堵了 |
| CPU | 服务器处理慢 | 每个请求都需要 CPU 计算(比如战斗判定) |
| 内存 | 服务器崩溃 | 同时在线的玩家太多,内存装不下所有数据 |
| 数据库 | 查询超时 | 数据库是整个系统中最慢的环节(磁盘读写比内存慢 1000 倍) |
| 连接数 | 新连接被拒绝 | 服务器能同时保持的网络连接数是有限的 |
数据库——最常见的瓶颈
数据库通常是整个系统中最慢的部分。来对比一下速度:
| 存储介质 | 读写速度 | 比喻 |
|---|---|---|
| CPU 缓存 | ~1 纳秒 | 你脑子里的记忆,瞬间就能想起来 |
| 内存(RAM) | ~100 纳秒 | 你桌子上的文件,伸手就能拿到 |
| 固态硬盘(SSD) | ~100 微秒 | 你书架上的书,要走几步去拿 |
| 机械硬盘(HDD) | ~10 毫秒 | 你仓库里的箱子,要找半天 |
| 网络请求(跨机房) | ~1-100 毫秒 | 你去图书馆借书,要出门走一趟 |
如果每个请求都要查一次数据库,那数据库就成了瓶颈。
解决方案——从易到难
方案一:缓存(性价比最高)
核心思路:把经常读取的数据放在内存里,下次直接从内存取,不用查数据库。
| 数据类型 | 缓存策略 | 说明 |
|---|---|---|
| 玩家基础信息 | 缓存 30 分钟 | 等级、昵称、头像,变化不频繁 |
| 活动配置 | 缓存 5 分钟 | 所有玩家看到的一样,全局共享 |
| 排行榜 | 缓存 1 分钟 | 不需要实时精确到秒 |
| 玩家背包 | 缓存 + 写穿 | 先读缓存,修改时同时更新缓存和数据库 |
// 简单的缓存管理器(客户端侧也可使用)
using Godot;
using System.Collections.Generic;
public partial class CacheManager : Node
{
// 缓存数据:key → (value, 过期时间)
private readonly Dictionary<string, (object data, double expireTime)> _cache = new();
// 写入缓存
public void Set(string key, object value, double ttlSeconds = 300.0)
{
double expireTime = Time.GetTicksMsec() / 1000.0 + ttlSeconds;
_cache[key] = (value, expireTime);
}
// 读取缓存(过期返回 null)
public T Get<T>(string key) where T : class
{
if (!_cache.TryGetValue(key, out var entry))
return null;
double now = Time.GetTicksMsec() / 1000.0;
if (now > entry.expireTime)
{
_cache.Remove(key);
return null; // 已过期
}
return entry.data as T;
}
// 带回源的读取:缓存有就用缓存,没有就去加载
public async System.Threading.Tasks.Task<T> GetOrLoad<T>(
string key,
System.Func<System.Threading.Tasks.Task<T>> loader,
double ttlSeconds = 300.0)
{
// 先查缓存
var cached = Get<T>(key);
if (cached != null)
return cached;
// 缓存没有,执行加载
var data = await loader();
if (data != null)
Set(key, data, ttlSeconds);
return data;
}
// 清理过期缓存
public void Cleanup()
{
double now = Time.GetTicksMsec() / 1000.0;
var expired = new List<string>();
foreach (var kv in _cache)
{
if (now > kv.Value.expireTime)
expired.Add(kv.Key);
}
foreach (var key in expired)
_cache.Remove(key);
}
}# 简单的缓存管理器(客户端侧也可使用)
extends Node
# 缓存数据:key → { value, expire_time }
var _cache: Dictionary = {}
## 写入缓存
func set(key: String, value, ttl_seconds: float = 300.0) -> void:
var expire_time = Time.get_ticks_msec() / 1000.0 + ttl_seconds
_cache[key] = {"value": value, "expire_time": expire_time}
## 读取缓存(过期返回 null)
func get(key: String):
if not _cache.has(key):
return null
var entry = _cache[key]
var now = Time.get_ticks_msec() / 1000.0
if now > entry["expire_time"]:
_cache.erase(key)
return null # 已过期
return entry["value"]
## 带回源的读取:缓存有就用缓存,没有就去加载
func get_or_load(key: String, loader: Callable, ttl_seconds: float = 300.0):
# 先查缓存
var cached = get(key)
if cached != null:
return cached
# 缓存没有,执行加载
var data = await loader.call()
if data != null:
set(key, data, ttl_seconds)
return data
## 清理过期缓存
func cleanup():
var now = Time.get_ticks_msec() / 1000.0
var expired = []
for key in _cache:
if now > _cache[key]["expire_time"]:
expired.append(key)
for key in expired:
_cache.erase(key)方案二:限流——保护服务器不被冲垮
当流量超出服务器处理能力时,与其让所有请求都卡住(最后全部超时),不如主动拒绝一部分请求,保证剩余请求能正常处理。
就像商场门口保安限流:人太多了,先在外面排队,分批放进去。
| 限流方式 | 原理 | 适用场景 |
|---|---|---|
| 固定窗口 | 每秒最多处理 N 个请求 | 简单场景 |
| 令牌桶 | 以固定速率产生"令牌",每个请求消耗一个令牌 | API 限流 |
| 漏桶 | 请求进入队列,以固定速率处理 | 消息处理 |
| 滑动窗口 | 更精确地统计任意时间窗口内的请求数 | 精细限流 |
// 令牌桶限流器
using Godot;
public partial class TokenBucketLimiter : Node
{
// 桶的最大容量
private int _maxTokens;
// 当前令牌数
private int _currentTokens;
// 令牌生成速率(每秒生成几个)
private double _refillRate;
// 上次补充令牌的时间
private double _lastRefillTime;
public TokenBucketLimiter(int maxTokens, double refillRate)
{
_maxTokens = maxTokens;
_currentTokens = maxTokens; // 一开始桶是满的
_refillRate = refillRate;
_lastRefillTime = Time.GetTicksMsec() / 1000.0;
}
// 尝试获取一个令牌(true=允许通过,false=被限流)
public bool TryAcquire()
{
RefillTokens();
if (_currentTokens > 0)
{
_currentTokens--;
return true;
}
return false;
}
// 补充令牌
private void RefillTokens()
{
double now = Time.GetTicksMsec() / 1000.0;
double elapsed = now - _lastRefillTime;
int newTokens = (int)(elapsed * _refillRate);
if (newTokens > 0)
{
_currentTokens = System.Math.Min(_currentTokens + newTokens, _maxTokens);
_lastRefillTime = now;
}
}
}
// 使用示例:限制玩家每秒最多发送 5 个请求
public partial class PlayerRequestHandler : Node
{
private TokenBucketLimiter _limiter;
public override void _Ready()
{
// 每秒最多 5 个请求,桶容量 10(允许短时突发)
_limiter = new TokenBucketLimiter(10, 5.0);
}
public void HandlePlayerRequest(string action)
{
if (!_limiter.TryAcquire())
{
GD.Print("请求过于频繁,请稍后再试");
return;
}
// 正常处理请求...
GD.Print($"处理请求:{action}");
}
}# 令牌桶限流器
class_name TokenBucketLimiter
extends RefCounted
# 桶的最大容量
var _max_tokens: int
# 当前令牌数
var _current_tokens: int
# 令牌生成速率(每秒生成几个)
var _refill_rate: float
# 上次补充令牌的时间
var _last_refill_time: float
func _init(max_tokens: int, refill_rate: float):
_max_tokens = max_tokens
_current_tokens = max_tokens # 一开始桶是满的
_refill_rate = refill_rate
_last_refill_time = Time.get_ticks_msec() / 1000.0
## 尝试获取一个令牌(true=允许通过,false=被限流)
func try_acquire() -> bool:
_refill_tokens()
if _current_tokens > 0:
_current_tokens -= 1
return true
return false
## 补充令牌
func _refill_tokens():
var now = Time.get_ticks_msec() / 1000.0
var elapsed = now - _last_refill_time
var new_tokens = int(elapsed * _refill_rate)
if new_tokens > 0:
_current_tokens = mini(_current_tokens + new_tokens, _max_tokens)
_last_refill_time = now
# 使用示例:限制玩家每秒最多发送 5 个请求
extends Node
var _limiter: TokenBucketLimiter
func _ready():
# 每秒最多 5 个请求,桶容量 10(允许短时突发)
_limiter = TokenBucketLimiter.new(10, 5.0)
func handle_player_request(action: String):
if not _limiter.try_acquire():
print("请求过于频繁,请稍后再试")
return
# 正常处理请求...
print("处理请求:%s" % action)方案三:异步处理——不要让玩家干等
有些操作不需要立刻返回结果。比如:
- 发送邮件:玩家点"发送",不需要等对方真的收到了
- 领取活动奖励:服务器记录下来就行,不需要等数据库写完
- 战斗结束结算:先给玩家看结果,后台慢慢算排名
核心思路:把"可以稍后处理"的事情放到队列里,慢慢处理,不要阻塞当前请求。
同步处理(慢):
玩家请求 → 数据库写入(等 50ms)→ 返回结果给玩家
总耗时:50ms,玩家等了 50ms
异步处理(快):
玩家请求 → 放入消息队列(1ms)→ 立刻返回"已受理"
后台:消息队列 → 数据库写入(不阻塞玩家)
总耗时:1ms,玩家几乎无感方案四:分库分表——数据太多怎么办
当一张表的数据量超过千万级别,查询会越来越慢。解决方案是把数据拆分到多个表或多个数据库。
| 策略 | 说明 | 举例 |
|---|---|---|
| 垂直分库 | 按业务拆分不同的数据库 | 用户库、战斗库、聊天库分开 |
| 水平分表 | 同一张表按规则拆成多张 | 玩家数据按 ID 取模分到 16 张表 |
| 读写分离 | 写操作走主库,读操作走从库 | 1 个主库 + 3 个从库 |
这部分主要是后端的工作
作为 Godot 客户端开发者,你不需要实现分库分表。但你应该了解这些概念,因为:
- 它会影响你的 API 设计(比如有些操作变成了异步)
- 和后端沟通时能听懂他们在说什么
- 如果你自己做全栈开发,迟早会遇到这些问题
方案五:CDN——让静态资源就近访问
游戏中的图片、音效、视频等静态资源不需要从你的服务器下载。使用 CDN(内容分发网络),把这些资源放到离玩家最近的服务器上。
没有 CDN:
北京玩家 → 请求图片 → 你的服务器在美国 → 跨越半个地球 → 慢!
有 CDN:
北京玩家 → 请求图片 → 北京的 CDN 节点 → 就在附近 → 快!客户端能做什么
虽然高并发主要是服务端的问题,但客户端也可以帮忙减轻压力:
| 客户端优化 | 说明 |
|---|---|
| 本地缓存 | 不变的数据(配置表、静态资源)缓存到本地,减少请求次数 |
| 请求合并 | 多个小请求合并成一个大请求 |
| 防抖和节流 | 玩家疯狂点击按钮时,只发送最后一次请求 |
| 离线模式 | 网络不好时先在本地操作,恢复网络后同步 |
| 指数退避重试 | 请求失败后,等 1 秒、2 秒、4 秒再重试,而不是立刻疯狂重试 |
防抖和节流
// 节流器——固定时间间隔内只执行一次
using Godot;
public partial class Throttler : Node
{
private double _interval;
private double _lastExecTime;
public Throttler(double intervalSeconds)
{
_interval = intervalSeconds;
_lastExecTime = -999;
}
// 返回 true 表示可以执行,false 表示被节流
public bool ShouldExecute()
{
double now = Time.GetTicksMsec() / 1000.0;
if (now - _lastExecTime >= _interval)
{
_lastExecTime = now;
return true;
}
return false;
}
}
// 防抖器——停止操作后过一段时间才执行
public partial class Debouncer : Node
{
private double _delay;
private double _lastActionTime;
private bool _pending;
public Debouncer(double delaySeconds)
{
_delay = delaySeconds;
}
public void RecordAction()
{
_lastActionTime = Time.GetTicksMsec() / 1000.0;
_pending = true;
}
public bool ShouldExecute()
{
if (!_pending) return false;
double now = Time.GetTicksMsec() / 1000.0;
if (now - _lastActionTime >= _delay)
{
_pending = false;
return true;
}
return false;
}
}
// 使用示例:玩家疯狂点击"攻击"按钮
public partial class BattleController : Node
{
private Throttler _attackThrottler;
public override void _Ready()
{
// 每 0.5 秒最多攻击一次
_attackThrottler = new Throttler(0.5);
}
public void OnAttackButtonPressed()
{
if (_attackThrottler.ShouldExecute())
{
PerformAttack();
}
else
{
GD.Print("攻击冷却中...");
}
}
}# 节流器——固定时间间隔内只执行一次
class_name Throttler
extends RefCounted
var _interval: float
var _last_exec_time: float = -999.0
func _init(interval_seconds: float):
_interval = interval_seconds
## 返回 true 表示可以执行,false 表示被节流
func should_execute() -> bool:
var now = Time.get_ticks_msec() / 1000.0
if now - _last_exec_time >= _interval:
_last_exec_time = now
return true
return false
# 防抖器——停止操作后过一段时间才执行
class_name Debouncer
extends RefCounted
var _delay: float
var _last_action_time: float = 0.0
var _pending: bool = false
func _init(delay_seconds: float):
_delay = delay_seconds
func record_action():
_last_action_time = Time.get_ticks_msec() / 1000.0
_pending = true
func should_execute() -> bool:
if not _pending:
return false
var now = Time.get_ticks_msec() / 1000.0
if now - _last_action_time >= _delay:
_pending = false
return true
return false
# 使用示例:玩家疯狂点击"攻击"按钮
extends Node
var _attack_throttler: Throttler
func _ready():
# 每 0.5 秒最多攻击一次
_attack_throttler = Throttler.new(0.5)
func _on_attack_button_pressed():
if _attack_throttler.should_execute():
_perform_attack()
else:
print("攻击冷却中...")高并发应对检查清单
上线前
上线时
上线后
下一章
高并发解决的是"流量太大"的问题。但流量大了之后,作弊的人也会变多。下一章讲一个同样重要的话题——怎么防止玩家作弊。
→ 17. 反作弊
