210 lines
7.8 KiB
Markdown
210 lines
7.8 KiB
Markdown
# 数据库设计详设文档
|
||
|
||
> **文档版本**: v1.0
|
||
> **撰写人**: 数据库架构师
|
||
> **创建日期**: 2024年9月11日
|
||
|
||
## 1. 技术选型
|
||
|
||
- **主数据库**: **MongoDB 6.0+**
|
||
- **原因**: 采用面向文档的存储模型,非常适合存储游戏会话、用户配置等半结构化数据。其灵活的Schema设计能快速迭代,内建的复制和分片功能为高可用和高扩展性提供了有力支持。
|
||
- **缓存/消息队列**: **Redis 7.0+**
|
||
- **原因**: 基于内存的高性能键值存储,用于缓存热点数据(如用户信息、排行榜)、管理WebSocket会话、实现分布式锁及作为多节点部署时的消息总线(Pub/Sub)。
|
||
- **ORM/ODM**: **Mongoose 7.x**
|
||
- **原因**: 为MongoDB提供强大的对象数据建模(ODM)能力,支持Schema定义、数据校验、中间件、查询构建等功能,能显著提升开发效率和代码健壮性。
|
||
|
||
## 2. MongoDB 数据模型设计
|
||
|
||
### 2.1 `users` 集合
|
||
|
||
存储用户信息。
|
||
|
||
- **Schema 定义**:
|
||
```typescript
|
||
import { Schema, model } from 'mongoose';
|
||
|
||
const userSchema = new Schema({
|
||
_id: { type: String, required: true }, // 使用微信的 openid 作为主键
|
||
nickname: { type: String, required: true },
|
||
avatarUrl: { type: String, required: true },
|
||
|
||
stats: {
|
||
gamesPlayed: { type: Number, default: 0 },
|
||
gamesWon: { type: Number, default: 0 },
|
||
winRate: { type: Number, default: 0.0 },
|
||
totalShots: { type: Number, default: 0 },
|
||
totalHits: { type: Number, default: 0 },
|
||
accuracy: { type: Number, default: 0.0 },
|
||
eloRating: { type: Number, default: 1200 } // Elo积分系统
|
||
},
|
||
|
||
lastLoginAt: { type: Date, default: Date.now },
|
||
createdAt: { type: Date, default: Date.now, immutable: true },
|
||
updatedAt: { type: Date, default: Date.now }
|
||
}, {
|
||
timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }
|
||
});
|
||
|
||
// Mongoose 中间件,用于在胜率和准确率变化时自动更新
|
||
userSchema.pre('save', function(next) {
|
||
if (this.isModified('stats.gamesPlayed') || this.isModified('stats.gamesWon')) {
|
||
this.stats.winRate = this.stats.gamesPlayed > 0 ? (this.stats.gamesWon / this.stats.gamesPlayed) * 100 : 0;
|
||
}
|
||
if (this.isModified('stats.totalShots') || this.isModified('stats.totalHits')) {
|
||
this.stats.accuracy = this.stats.totalShots > 0 ? (this.stats.totalHits / this.stats.totalShots) * 100 : 0;
|
||
}
|
||
next();
|
||
});
|
||
|
||
export const UserModel = model('User', userSchema);
|
||
```
|
||
|
||
- **索引 (Indexes)**:
|
||
- `_id`: (主键) 唯一索引,用于快速查找用户。
|
||
- `{ "stats.eloRating": -1 }`: 降序索引,用于实现排行榜。
|
||
|
||
### 2.2 `game_sessions` 集合
|
||
|
||
存储完整的游戏对局信息,用于复盘、数据分析和问题排查。
|
||
|
||
- **Schema 定义**:
|
||
```typescript
|
||
import { Schema, model } from 'mongoose';
|
||
|
||
const positionSchema = new Schema({ x: Number, y: Number }, { _id: false });
|
||
const planeSchema = new Schema({
|
||
center: positionSchema,
|
||
direction: String,
|
||
positions: [positionSchema]
|
||
}, { _id: false });
|
||
|
||
const playerStateSchema = new Schema({
|
||
userId: { type: String, ref: 'User', required: true },
|
||
board: {
|
||
planes: [planeSchema],
|
||
attackHistory: [{
|
||
position: positionSchema,
|
||
result: String, // 'miss', 'hit', 'destroy'
|
||
timestamp: Date
|
||
}]
|
||
},
|
||
stats: {
|
||
shots: Number,
|
||
hits: Number,
|
||
planesDestroyed: Number
|
||
}
|
||
}, { _id: false });
|
||
|
||
const gameSessionSchema = new Schema({
|
||
_id: { type: String, required: true }, // 游戏会话ID
|
||
roomCode: { type: String, required: true, index: true },
|
||
status: { type: String, required: true, enum: ['placing', 'battling', 'finished'], default: 'placing' },
|
||
|
||
players: [{ type: String, ref: 'User' }],
|
||
playerStates: [playerStateSchema],
|
||
|
||
winnerId: { type: String, ref: 'User' },
|
||
winReason: { type: String, enum: ['ALL_PLANES_DESTROYED', 'SURRENDER', 'TIMEOUT'] },
|
||
|
||
gameEvents: [{
|
||
type: String,
|
||
playerId: String,
|
||
data: Schema.Types.Mixed,
|
||
timestamp: Date
|
||
}],
|
||
|
||
startedAt: { type: Date, default: Date.now },
|
||
finishedAt: { type: Date }
|
||
});
|
||
|
||
export const GameSessionModel = model('GameSession', gameSessionSchema);
|
||
```
|
||
|
||
- **索引 (Indexes)**:
|
||
- `_id`: (主键) 唯一索引。
|
||
- `{ roomCode: 1 }`: 用于通过房间码快速查找游戏。
|
||
- `{ "players": 1 }`: 多键索引,用于查询某玩家参与的所有对局。
|
||
- `{ status: 1, startedAt: -1 }`: 复合索引,用于查找特定状态的游戏并按时间排序。
|
||
|
||
## 3. Redis 数据结构设计
|
||
|
||
Redis 用于存储高频访问、易失性或需要原子操作的数据。
|
||
|
||
### 3.1 用户会话 (User Session)
|
||
|
||
- **用途**: 存储用户登录状态和WebSocket连接信息。
|
||
- **数据结构**: `HASH`
|
||
- **Key**: `session:{userId}`
|
||
- **Value**:
|
||
- `token`: `string` (JWT)
|
||
- `connectionId`: `string` (当前WebSocket连接ID)
|
||
- `status`: `'online' | 'offline' | 'in-game'`
|
||
- `gameId`: `string` (如果`status`为`in-game`)
|
||
- **TTL**: 24小时 (每次访问刷新)
|
||
|
||
### 3.2 游戏房间 (Game Rooms)
|
||
|
||
- **用途**: 管理游戏房间列表和房间内玩家状态。
|
||
- **数据结构**: `HASH`
|
||
- **Key**: `room:{roomCode}`
|
||
- **Value**:
|
||
- `name`: `string` (房间名)
|
||
- `ownerId`: `string` (房主用户ID)
|
||
- `status`: `'waiting' | 'full' | 'in-game'`
|
||
- `player1Id`: `string`
|
||
- `player2Id`: `string`
|
||
- `player1Ready`: `'0' | '1'`
|
||
- `player2Ready`: `'0' | '1'`
|
||
- **TTL**: 2小时 (从最后一次活动开始计算)
|
||
|
||
### 3.3 游戏状态 (Live Game State)
|
||
|
||
- **用途**: 缓存进行中游戏的核心状态,减少对MongoDB的读写压力。
|
||
- **数据结构**: `STRING` (存储序列化后的`GameState`对象)
|
||
- **Key**: `game:state:{gameId}`
|
||
- **Value**: `JSON.stringify(GameState)`
|
||
- **TTL**: 1小时 (游戏结束后删除)
|
||
|
||
### 3.4 排行榜 (Leaderboard)
|
||
|
||
- **用途**: 实时更新和查询玩家排名。
|
||
- **数据结构**: `SORTED SET` (ZSET)
|
||
- **Key**: `leaderboard:elo`
|
||
- **Value**:
|
||
- `member`: `userId`
|
||
- `score`: `eloRating` (整数)
|
||
|
||
- **操作**:
|
||
- **更新排名**: `ZADD leaderboard:elo <eloRating> <userId>`
|
||
- **查询Top 100**: `ZREVRANGE leaderboard:elo 0 99 WITHSCORES`
|
||
- **查询玩家排名**: `ZREVRANK leaderboard:elo <userId>`
|
||
|
||
### 3.5 分布式锁 (Distributed Lock)
|
||
|
||
- **用途**: 在关键操作(如匹配玩家、创建游戏)中防止并发冲突。
|
||
- **数据结构**: `STRING`
|
||
- **Key**: `lock:{resource_name}:{resource_id}` (e.g., `lock:room:join:{roomCode}`)
|
||
- **Value**: `unique_lock_id` (e.g., a UUID)
|
||
- **操作**: 使用`SET key value NX PX milliseconds`命令实现原子性的加锁操作。
|
||
- `NX`: 只在键不存在时设置。
|
||
- `PX`: 设置过期时间(毫秒),防止死锁。
|
||
|
||
## 4. 数据一致性策略
|
||
|
||
- **写操作**:
|
||
1. **关键操作** (如`EXECUTE_ATTACK`):
|
||
- 开启**分布式锁**。
|
||
- 更新Redis中的**实时游戏状态** (`game:state:{gameId}`)。
|
||
- 将操作事件**异步写入**一个队列(如Redis Stream或RabbitMQ)。
|
||
- **释放锁**。
|
||
- 立即向客户端返回成功响应。
|
||
2. 一个独立的**后台Worker**消费队列中的事件,批量将游戏会话数据持久化到MongoDB (`game_sessions` 集合)。
|
||
|
||
- **读操作**:
|
||
- **进行中的游戏**: 优先从**Redis**读取实时状态。
|
||
- **历史游戏/玩家统计**: 从**MongoDB**读取。
|
||
|
||
- **优势**:
|
||
- **低延迟**: 游戏核心逻辑的读写都在内存中完成,响应迅速。
|
||
- **高吞吐**: 将对DB的写操作异步化和批量化,减轻数据库压力。
|
||
- **数据最终一致性**: 即使后台Worker暂时失败,数据也保留在队列中,保证最终会持久化到MongoDB。 |