在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

数据模型设计

本章将详细介绍项目的数据模型设计。我选择了 Prisma ORM 配合 PostgreSQL 作为持久化方案,所有业务实体统一在 prisma/schema.prisma 中声明,并通过 prisma generate 自动生成 TypeScript 类型——这意味着在编写业务代码时,数据库操作会获得完整的类型提示和编译时检查。

简单说:模型即代码——表结构变了,类型自动跟着变,避免了手写 SQL 时常见的字段名拼写错误。

在这里插入图片描述
在这里插入图片描述


1. 介绍之前

在开始逐个介绍模型之前,不妨先思考一个问题:一个典型的游戏服务器需要记录哪些数据?

  • 谁在玩?(用户账户)
  • 玩家长什么样?(昵称、等级、头像等公开信息)
  • 玩到哪了?(当前的游戏状态,用于断线重连)
  • 游戏里有哪些角色/势力?(由策划配置)
  • 玩家之间有哪些关系?(联盟、帮会)
  • 做了哪些值得记录的事?(成就、战斗日志)

基于这些问题,抽象出 9 个核心模型,它们之间的关系可以用下图表示:

has

has

unlocks

unlocked_by

belongs_to

produces

activates

participates

User

Profile

GameState

UserAchievement

Achievement

Alliance

CombatLog

SeasonEvent

Policy

Character


2. 核心模型详解

2.1 玩家账户与档案

Model 主要字段 说明
User id, username, email, createdAt, updatedAt 玩家账户信息,关联 ProfileGameState
Profile id, userId, nickname, avatarUrl, level, experience 玩家公开资料,展示在 UI 中

把账户和档案分开

User 存储的是认证相关的敏感信息(邮箱、密码哈希等),而 Profile 存储的是展示相关的公开信息。分开有两个好处:

  1. 查询排行榜时,不需要触碰 User 表,减少敏感数据暴露风险。
  2. 昵称、头像等频繁修改的字段不会影响认证表的主键索引。

展示域

认证域

1:1

User
id, email, password_hash

Profile
nickname, avatar, level, exp

2.2 游戏状态与日志

Model 主要字段 说明
GameState id, userId, currentTurn, lastSavedAt 保存玩家当前的游戏状态快照(JSON 列),用于断线恢复
CombatLog id, gameStateId, payload (JSON), createdAt 每回合的战斗日志,供回放与审计使用

断线恢复的工作原理:

PostgreSQL 服务器 客户端 PostgreSQL 服务器 客户端 正常游戏流程 断线重连 GameState 存的是整个游戏 的压缩快照,不是增量。 行动指令 计算新状态 更新 GameState (JSON) 插入 CombatLog 返回结果 重连请求 + userId SELECT * FROM GameState WHERE userId = ? 最新快照 (JSON) 恢复现场

** CombatLog 单独存**

  • GameState 只保留当前状态,体积小、读得快。
  • CombatLog 保留完整历史,用于:
    • 战斗回放功能
    • 玩家争议审计(判定是否作弊)
    • 数据分析(哪些技能使用频率高)

2.3 配置类数据

Model 主要字段 说明
Character id, name, faction, attributes (JSON) 游戏中的角色/势力定义,属性可扩展
SeasonEvent id, name, startAt, endAt, config (JSON) 每季活动配置
Policy id, name, effect (JSON) 游戏内政策/决策,即时生效或延迟生效

表共同点

它们都是由策划配置、玩家不可写的数据。设计成数据库表而非代码常量的原因是:

  • 热更新:修改活动时间无需重新部署服务
  • 多环境共享:开发、测试、生产可以用不同配置
  • 非技术人员可编辑:配合后台管理系统,策划可以直接修改

2.4 成就与联盟

Model 主要字段 说明
Alliance id, name, members (relation) 联盟/帮会实体,关联多名 User
Achievement id, code, title, description, reward 成就系统定义
UserAchievement id, userId, achievementId, unlockedAt 玩家已解锁的成就记录(联结表)

多对多关系的处理:

User

UserAchievement

int

id

PK

int

userId

FK

int

achievementId

FK

datetime

unlockedAt

Achievement

UserAchievement 是一个联结表,它不仅仅记录"谁完成了什么成就",还带有一个额外的 unlockedAt 字段——这很常见,因为多对多关系本身往往带有关系属性(完成时间、完成时的快照等)。

联盟关系

成员

成员

成员

User A

Alliance 青龙帮

User B

User C


3. 关联关系速查

关系类型 涉及模型 实现方式
一对一 User ↔ Profile profile.userId 外键
一对一 User ↔ GameState gamestate.userId 外键
多对多 User ↔ Alliance AllianceMember 中间表
多对多 User ↔ Achievement UserAchievement 联结表
一对多 GameState → CombatLog combatLog.gameStateId 外键

4. 数据库索引与查询优化

索引是性能的生命线,但不是越多越好——每个额外的索引都会拖慢写入速度。我们根据实际查询场景,有针对性地建立索引:

4.1 唯一索引(用于快速查找用户)

-- 登录时根据邮箱或用户名查找
CREATE UNIQUE INDEX idx_user_email ON "User"(email);
CREATE UNIQUE INDEX idx_user_username ON "User"(username);

4.2 复合索引(用于范围查询)

-- 查找最近活跃的玩家(用于运营活动推送)
CREATE INDEX idx_gamestate_turn_saved ON "GameState"(currentTurn, lastSavedAt);

这个复合索引的字段顺序很重要:

  • currentTurn 在前:如果你经常查询"回合数大于 N 的玩家"
  • lastSavedAt 在后:在筛选完回合数后,再按时间排序

4.3 外键索引(用于关联查询)

-- 回放战斗日志时,根据 gameStateId 快速过滤
CREATE INDEX idx_combatlog_gameStateId ON "CombatLog"(gameStateId);

4.4 JSON 字段索引(PostgreSQL 特有的 jsonb 索引)

-- 假设我们需要查询"生命值大于 100"的角色
CREATE INDEX idx_character_attributes ON "Character" USING GIN (attributes);
-- 配合查询:SELECT * FROM "Character" WHERE attributes->>'health' > '100';

💡 jsonb 的 GIN 索引可以加速任意键值查询,但会占用额外存储空间。仅在确实需要按 JSON 内部字段过滤时使用。

索引决策流程图

有查询慢的问题?

暂不建索引

该列是否经常作为 WHERE 条件?

是否多个列一起作为条件?

建复合索引
注意列顺序

建单列索引

写入频率高吗?

平衡读写,
评估是否值得

放心建索引


5. 迁移与种子数据

5.1 迁移流程

代码即架构。当模型发生变化时,遵循以下流程:

修改 schema.prisma

npx prisma migrate dev --name 描述

自动生成 SQL 迁移文件

应用到开发数据库

重新生成 Prisma Client

提交 migration 到 Git

关键原则:

  • 迁移文件必须提交到版本控制,保证团队和环境一致
  • 生产环境的迁移由 CI/CD 流程执行,不要在服务器上手动改表

5.2 种子数据

prisma/seed.ts 负责初始化两类数据:

类型 内容 目的
基础配置 Character、Policy、SeasonEvent 让新环境能直接运行游戏逻辑
测试数据 演示用的 User、GameState 本地开发时能看到非空界面

执行方式:

npx prisma db seed

6. 兼容性与演进

6.1 命名映射

使用 @@map@map 将 Prisma 模型名映射到实际的数据库表名:

model User {
  id        Int    @id @map("user_id")
  username  String @map("user_name")
  
  @@map("t_user")  // 表名统一加前缀
}

注意

  • PostgreSQL 默认大小写敏感,Useruser 是不同的表
  • 不同语言的命名习惯不同(TypeScript 喜欢 PascalCase,SQL 喜欢 snake_case)

6.2 类型安全的业务代码

// ✅ 直接从 prisma/client 导入类型
import { User, GameState } from '@prisma/client';

async function saveGame(userId: number, state: GameState) {
  // state 参数有完整的类型提示
  return prisma.gameState.upsert({
    where: { userId },
    update: { ...state, lastSavedAt: new Date() },
    create: { userId, ...state },
  });
}

6.3 添加新字段的注意事项

model User {
  id        Int      @id @default(autoincrement())
  username  String
  // 新加字段:必须提供默认值或标记为可选
  vipLevel  Int      @default(0)      // ✅ 有默认值,旧数据自动补0
  inviteCode String?                   // ✅ 可选字段,旧数据为 null
  // inviteCode String                   // ❌ 不行!旧数据没有这个字段
}

当执行 migrate dev 时,Prisma 会生成 ALTER TABLE ... ADD COLUMN 语句。如果新字段没有默认值且不为空,数据库不知道如何填充已有的 100 万行记录,迁移就会失败。


7. 本章小结

要点 说明
模型即代码 Prisma 模型 → TypeScript 类型 + SQL 表,三者同步
索引按需创建 根据实际查询场景选择单列、复合或 JSON 索引
种子数据分离 配置数据与测试数据分开,方便不同环境使用
迁移要可回放 所有迁移文件提交 Git,保证环境一致性
新增字段要兜底 提供 @default 或标记为可选,避免旧数据迁移失败

后续章节将基于本章的数据模型,展开业务逻辑的实现细节。理解这些模型之间的关系和设计意图,是阅读后续内容的基础。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐