This commit is contained in:
2026-03-17 09:21:45 +08:00
parent 0b2f4a026e
commit e5543ae6e4
3 changed files with 545 additions and 158 deletions

View File

@@ -0,0 +1,293 @@
# 游戏性能与 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 或其他统计数据。
**目标:** 热路径尽量命中 RedisDB 主要负责落库和冷数据读取。
---
## 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_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. 重点关注指标:
- QPSRequests 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` 等工具在接近真实数据和流量模型的情况下进行压测验证,本文档主要为定位瓶颈和指导优化提供一个工程实践层面的参考。