294 lines
12 KiB
Markdown
294 lines
12 KiB
Markdown
# 游戏性能与 QPS 分析(Dice 项目)
|
||
|
||
> 本文基于当前骰子游戏(dice)项目的实际代码结构,对后端接口的性能瓶颈、QPS 能力和优化方向做说明,便于后续压测、扩容和排查问题。
|
||
|
||
---
|
||
|
||
## 1. 运行环境与架构概览
|
||
|
||
| 项目 | 说明 |
|
||
|----------------|----------------------------------------------------------------------|
|
||
| 应用框架 | Webman(基于 Workerman 的常驻内存 PHP 框架) |
|
||
| 业务模块 | `app/dice`(玩家、抽奖记录、奖励配置、奖池配置、看板等) |
|
||
| HTTP 监听地址 | 通常为 `http://0.0.0.0:6688` |
|
||
| Worker 数量 | 推荐:`cpu_count() * 4`(例如 8 核 CPU → 32 个 Worker 进程) |
|
||
| 数据库 | MySQL + ThinkORM,使用连接池(例如 max=20 / min=2) |
|
||
| 缓存 | Redis + think-cache,使用连接池(例如 max=20 / min=2) |
|
||
|
||
**生产环境配置建议:**
|
||
|
||
- 在 `.env` 中统一使用 Redis 作为默认缓存:`CACHE_MODE=redis`
|
||
- 调整 `DB_POOL_MAX` / `REDIS_POOL_MAX`,使其与 Worker 数量和 MySQL `max_connections` 匹配
|
||
|
||
---
|
||
|
||
## 2. 核心接口与负载特征
|
||
|
||
从当前项目代码来看,游戏相关接口大致可以分为三类:
|
||
|
||
- **高负载型**:强依赖 DB + Redis,多次写入或复杂逻辑(QPS 瓶颈主要在这里)
|
||
- **中负载型**:部分写 DB 或复杂读 DB
|
||
- **轻量型**:读缓存或简单 DB 查询
|
||
|
||
### 2.1 主要接口梳理
|
||
|
||
| 接口 | 用途 | 负载级别 | 说明 / 风险点 |
|
||
|-------------------------------------|--------------------|----------|--------------------------------------------------------|
|
||
| `POST /api/game/playStart` | 开始一局游戏 / 抽奖 | 高 | 多次 DB + Redis 操作,是整体 QPS 的核心瓶颈 |
|
||
| `POST /api/game/buyLotteryTickets` | 购买门票 / 次数 | 中-高 | 多表写入:门票记录、钱包记录等 |
|
||
| `GET /api/game/config` | 获取游戏配置 | 中 | 依赖 `DiceConfig`,若无缓存会有全表扫描风险 |
|
||
| `GET /api/game/lotteryPool` | 获取奖池 / 奖励配置 | 中 | 依赖 `DiceLotteryPoolConfig` / `DiceRewardConfig` |
|
||
| `POST /api/v1/getGameUrl` | 获取游戏 URL | 中 | JWT + Redis 鉴权,DB 较少,Redis 压力较大 |
|
||
| `POST /api/v1/getPlayerInfo` | 获取玩家信息 | 中 | 需要 `dice_player.username` 索引,否则易产生慢查询 |
|
||
| `POST /api/v1/getPlayerGameRecord` | 获取玩家历史记录 | 中-高 | 若未优化,容易出现 N+1 查询问题 |
|
||
| `POST /api/v1/setPlayerWallet` | 调整玩家钱包余额 | 中 | 涉及余额变更与流水写入,需处理好并发与幂等 |
|
||
|
||
在进行 QPS 能力评估或性能优化时,优先关注:
|
||
|
||
- `playStart` 接口;
|
||
- 门票 / 钱包 / 流水相关接口。
|
||
|
||
---
|
||
|
||
## 3. `playStart` 调用链与资源消耗
|
||
|
||
以下是典型的一次 `playStart` 请求在后端的大致流程(抽象描述):
|
||
|
||
### 3.1 数据库访问路径(典型情况)
|
||
|
||
1. **加载玩家信息**
|
||
- 调用 `DicePlayer::find($playerId)`
|
||
- 建议:每个请求只查询一次,并在后续逻辑中复用该对象。
|
||
|
||
2. **加载奖励 EV / 最小实际 EV**
|
||
- 调用 `DiceRewardConfig::getCachedMinRealEv()`
|
||
- 首次会访问 DB 并写入 Redis;后续命中 Redis 即可。
|
||
|
||
3. **获取或创建奖池(LotteryService)**
|
||
- 调用 `LotteryService::getOrCreate()`:
|
||
- 优先从 Redis 中读取奖池信息;
|
||
- 若缓存不存在,则从 DB 中加载:如 `DiceLotteryPoolConfig::where('type', 0/1)->find()`。
|
||
|
||
4. **根据本次玩法加载奖池配置**
|
||
- 调用 `DiceLotteryPoolConfig::find($configId)`。
|
||
|
||
5. **核心业务落库(可能在事务中执行)**
|
||
- `DicePlayRecord::create()`:写入本次抽奖记录;
|
||
- `DicePlayer::save()`:更新玩家余额、状态等;
|
||
- `DicePlayerTicketRecord::create()`:写入门票/次数流水;
|
||
- `DiceLotteryPoolConfig::where('id')->update(['ev' => Db::raw(...)]`:更新 EV 统计;
|
||
- `DicePlayerWalletRecord::create()`:写入钱包流水。
|
||
|
||
6. **刷新玩家缓存**
|
||
- 再次使用(或重新加载)玩家信息,调用 `UserCache::setUser($player)` 同步到 Redis。
|
||
|
||
**DB / Redis 调用次数粗略估算:**
|
||
|
||
- 在写法合理、缓存命中良好的情况下,每次 `playStart` 大致会产生:
|
||
- 3 ~ 6 次 DB 访问(读 + 写);
|
||
- 3 ~ 6 次 Redis 操作。
|
||
- 若在同一请求中多次重复调用 `DicePlayer::find($playerId)`,DB 调用次数和耗时都会显著上升。
|
||
|
||
### 3.2 Redis 使用路径
|
||
|
||
在当前设计中,Redis 主要用于:
|
||
|
||
- 奖励 / 奖池配置缓存:`DiceRewardConfig::getCachedInstance()`、`getCachedByTier()`、`LotteryService::getOrCreate()` 等;
|
||
- 玩家缓存:`UserCache::getUser()`、`UserCache::setUser()`;
|
||
- 统计计数:可以使用 Redis `INCRBY` 等命令维护 EV 或其他统计数据。
|
||
|
||
**目标:** 热路径尽量命中 Redis,DB 主要负责落库和冷数据读取。
|
||
|
||
---
|
||
|
||
## 4. 性能瓶颈与优化方向
|
||
|
||
### 4.1 数据库连接与 SQL
|
||
|
||
1. **连接池大小**
|
||
- 在 32 个 Worker 且 `DB_POOL_MAX=20` 的情况下,高并发时容易出现连接耗尽和等待。
|
||
- 建议:根据 CPU 核心数和目标 QPS,将 `DB_POOL_MAX` 调整到 **32 ~ 64** 左右,并保证 MySQL `max_connections` 足够大。
|
||
|
||
2. **避免重复查询**
|
||
- 在 `playStart` 中,玩家信息应只查询一次:`$player = DicePlayer::find($playerId)`,后续逻辑统一使用 `$player`,避免重复 `find`。
|
||
|
||
3. **EV 更新策略**
|
||
- 频繁在在线请求中执行 `UPDATE dice_lottery_pool_config SET ev = ev - ?`,会造成该行热点锁竞争;
|
||
- 建议:
|
||
- 在线请求仅将 EV 变动累加到 Redis 计数器;
|
||
- 通过定时任务批量同步 Redis 中的统计数据回 MySQL。
|
||
|
||
4. **索引覆盖**
|
||
- 高频查询条件(如 `username`、`player_id`、`create_time`、`status` 等)必须有合适索引(见第 6 节)。
|
||
|
||
### 4.2 缓存与序列化开销
|
||
|
||
1. **统一使用 Redis 缓存驱动**
|
||
- `.env` 中设置:`CACHE_MODE=redis`;
|
||
- `config/cache.php` 中,生产环境默认缓存驱动应为 Redis。
|
||
|
||
2. **UserCache 加解密成本**
|
||
- 若 `UserCache` 中对玩家信息做 AES 加解密,在高 QPS 场景下会占用较多 CPU;
|
||
- 可以:
|
||
- 精简缓存字段,只缓存必要信息;
|
||
- 控制 `setUser` 调用频率,避免无意义重复写入;
|
||
- 评估哪些字段确实需要加密,非敏感字段可不加密。
|
||
|
||
### 4.3 N+1 查询问题
|
||
|
||
历史记录、钱包流水等接口非常容易出现 N+1 查询,例如:
|
||
|
||
- 循环按玩家逐条查询记录;
|
||
- 循环对每条记录再单独查询玩家信息。
|
||
|
||
建议:
|
||
|
||
- 使用 `whereIn('player_id', $playerIds)` 做批量查询;
|
||
- 使用 `with(['dicePlayer'])` 或 join 预加载关联的玩家信息;
|
||
- 控制单页 `limit`,例如不超过 100 条。
|
||
|
||
### 4.4 Redis 与 DB 的整体协同
|
||
|
||
- Worker 数、DB 连接池、Redis 连接池三者要相互匹配:
|
||
- 出现 CPU 未打满但 DB/Redis 已经“顶满”的情况,多半是配置不平衡。
|
||
- 对 Redis 的监控要关注:
|
||
- 慢日志(慢命令);
|
||
- 大 Key 或热点 Key(必要时做拆分或分桶)。
|
||
|
||
---
|
||
|
||
## 5. 单机 QPS 能力(估算)
|
||
|
||
> 以下为在常见生产环境(如 8C32G 单机、MySQL/Redis 与应用在同一内网)且做好上述优化后的大致量级,仅用于评估和对比,实际仍需通过压测确认。
|
||
|
||
### 5.1 单接口预估
|
||
|
||
- `playStart`
|
||
- 单次耗时:约 50 ~ 150 ms(受 DB/Redis 延迟和业务分支影响);
|
||
- 单机 QPS:约 **200 ~ 300**。
|
||
|
||
- `buyLotteryTickets`
|
||
- 单次耗时:约 20 ~ 60 ms;
|
||
- 单机 QPS:约 **500 ~ 1000+**。
|
||
|
||
- `getGameUrl` / 登录类接口
|
||
- 单次耗时:约 30 ~ 80 ms;
|
||
- 单机 QPS:约 **400 ~ 800**。
|
||
|
||
### 5.2 混合业务场景
|
||
|
||
假设:
|
||
|
||
- 70% 请求是 `playStart`;
|
||
- 30% 请求为 `getGameUrl`、`getPlayerInfo` 等轻 / 中量接口;
|
||
|
||
在 8 核 32 Worker、索引和缓存配置合理的前提下:
|
||
|
||
- 单机综合 QPS 约可达到 **250 ~ 400 QPS**;
|
||
- 若需要显著更高的整体 QPS,应采用多节点水平扩展 + DB/Redis 扩容(读写分离、分库分表等)。
|
||
|
||
---
|
||
|
||
## 6. 索引与表结构建议
|
||
|
||
### 6.1 必备索引
|
||
|
||
| 表名 | 索引 | 用途说明 |
|
||
|------------------------------|-----------------------------------|--------------------------------|
|
||
| `dice_player` | 唯一索引 `unique(username)` | 通过用户名快速定位玩家 |
|
||
| `dice_play_record` | 普通索引 `(player_id, create_time)` | 玩家历史记录分页 + 时间排序 |
|
||
| `dice_player_wallet_record` | 普通索引 `(player_id, create_time)` | 钱包流水查询 |
|
||
| `dice_player_ticket_record` | 普通索引 `(player_id, create_time)` | 门票流水查询 |
|
||
|
||
示例 SQL:
|
||
|
||
```sql
|
||
ALTER TABLE dice_player ADD UNIQUE INDEX uk_username (username);
|
||
ALTER TABLE dice_play_record ADD INDEX idx_player_create (player_id, create_time);
|
||
ALTER TABLE dice_player_wallet_record ADD INDEX idx_player_create (player_id, create_time);
|
||
ALTER TABLE dice_player_ticket_record ADD INDEX idx_player_create (player_id, create_time);
|
||
```
|
||
|
||
### 6.2 大表策略
|
||
|
||
对于体量较大的历史表(抽奖记录、钱包流水、门票流水等),建议提前规划:
|
||
|
||
- 分区或分表策略(按时间或按玩家 ID);
|
||
- 归档 / 删除过旧数据,例如只保留最近 N 个月的在线查询。
|
||
|
||
---
|
||
|
||
## 7. 压测步骤与指标观测
|
||
|
||
### 7.1 压测 `playStart`
|
||
|
||
1. 准备数据:
|
||
- 预先创建足够多的测试玩家及初始余额;
|
||
- 获取一批可用的 JWT token。
|
||
|
||
2. 使用 `ab` 压测示例:
|
||
|
||
```bash
|
||
ab -n 2000 -c 32 \
|
||
-p post_body.json \
|
||
-T application/json \
|
||
-H "token: YOUR_JWT" \
|
||
http://127.0.0.1:6688/api/game/playStart
|
||
```
|
||
|
||
3. 使用 `wrk` 压测示例:
|
||
|
||
```bash
|
||
wrk -t4 -c32 -d60s -s play_start.lua http://127.0.0.1:6688/api/game/playStart
|
||
```
|
||
|
||
4. 重点关注指标:
|
||
- QPS(Requests per second);
|
||
- P95 / P99 延迟;
|
||
- 错误率(HTTP 5xx、超时、业务失败);
|
||
- MySQL / Redis 的 CPU 使用率与连接数。
|
||
|
||
### 7.2 常见问题排查思路
|
||
|
||
1. **QPS 上不去,CPU 却不高**
|
||
- 检查 DB / Redis 连接池是否经常“打满”;
|
||
- 是否存在慢 SQL(全表扫描、缺索引);
|
||
- 是否有外部依赖(如第三方接口)拖慢整体。
|
||
|
||
2. **P99 延迟偶尔飙高**
|
||
- 查看是否有大事务 / 大批量更新与压测同时进行;
|
||
- 观察磁盘 I/O 和 GC 情况;
|
||
- 检查 Redis 是否有大 Key 或阻塞命令(如 `KEYS`)。
|
||
|
||
---
|
||
|
||
## 8. 配置与代码自查清单
|
||
|
||
> 建议在压测前和上线前都按此清单进行一次自查。
|
||
|
||
### 8.1 配置层面
|
||
|
||
- `.env`:
|
||
- [ ] `CACHE_MODE=redis` 已开启;
|
||
- [ ] `DB_POOL_MAX` / `DB_POOL_MIN` 已根据 CPU 与 MySQL `max_connections` 调整;
|
||
- [ ] `REDIS_POOL_MAX` 能够支撑预期并发量。
|
||
- MySQL:
|
||
- [ ] `max_connections` 不小于所有应用实例 DB 连接池上限之和;
|
||
- [ ] 已开启慢查询日志,并设置了合理阈值。
|
||
|
||
### 8.2 代码层面
|
||
|
||
- [ ] `playStart` 在一次请求中只加载一次玩家信息并全程复用;
|
||
- [ ] 奖励/奖池配置统一通过缓存访问,修改后有刷新缓存逻辑(如 `refreshCache()`);
|
||
- [ ] 历史记录、钱包流水接口避免 N+1 查询,使用批量查询或预加载;
|
||
- [ ] EV 等高频统计字段不直接在在线请求中频繁 `UPDATE`,而是通过 Redis 聚合后批量回写。
|
||
|
||
---
|
||
|
||
## 9. 总结
|
||
|
||
1. 对于当前 dice 项目,`playStart` 以及与之相关的奖池、钱包、门票逻辑,是后端 QPS 能力的关键瓶颈所在。
|
||
2. 通过合理配置 DB/Redis 连接池、充分利用缓存、补齐必要索引、减少重复查询和热点 UPDATE,可以大幅提升单机可承载的 QPS,并降低 P99 延迟。
|
||
3. 真正的容量边界必须通过 `ab` / `wrk` / `k6` 等工具在接近真实数据和流量模型的情况下进行压测验证,本文档主要为定位瓶颈和指导优化提供一个工程实践层面的参考。
|
||
|