# 游戏性能与 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('name','default/killScore')->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` 以兼顾负载（默认 `20`，上限 `2000`；接口已固定为「最近 7 天」时间窗）。

### 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` 等工具在接近真实数据和流量模型的情况下进行压测验证，本文档主要为定位瓶颈和指导优化提供一个工程实践层面的参考。

