Prisma 数据库设计与管理实操方法(GitHub 文档翻译工具项目实战)

数据库这一层,决定了整个翻译工具的稳定下限。任务、片段、仓库、安装之间的关系多,状态又一直在变,一开始我用原生 SQL 手写建表和查询,改一个字段要顺带改十几条语句,本地结构和生产环境对不上是常有的事。折腾了两周后,我把数据库层整体换成了 Prisma:用 schema 声明模型、自动生成迁移、再用类型安全的 client 访问数据。这套组合把”数据库是另一个项目”的割裂感彻底消掉了。本项目的核心实体是 User、Installation、Repository、TranslationJob、TranslationSegment、ApiKey,每张表都对应一个明确边界,避免把 GitHub API 原始结构塞进库。

一、为什么用 Prisma

换框架之前,我在 TypeORM 和 Sequelize 之间都试过。TypeORM 功能确实全,但装饰器写法啰嗦,联表查询的配置散落在实体和仓库两个文件里,出问题要两头找;Sequelize 社区大,可它的模型定义和 TypeScript 类型是两套东西,改一次模型往往要同步改类型声明,漏一处就等着运行时报错。Prisma 让我放心的是”单一事实来源”:schema 文件写完,client 和类型都由它生成,不存在两套定义慢慢漂移的问题。

下面从五个维度看它到底解决了我哪些具体的痛点:

能力 价值
Schema DSL 单一文件描述全部模型
类型安全 client 编译期捕获错误
迁移工具 自动生成 SQL,可回滚
多数据库 Postgres / SQLite / MySQL 自由切换
关联查询 include / select 表达力强

对翻译工具来说,迁移工具和类型安全 client 帮了我大忙:前者保证开发和生产的结构不会分叉,后者把一堆运行期才暴露的错误提前到编译期就拦下来。

二、核心实体与关系

建模之前,我先把 GitHub App 的工作方式捋了一遍。用户安装 App 之后得到一个 installation,installation 下能看到一批仓库,仓库里的文档按路径拆成翻译任务,任务再切成片段。顺着这条线往下建表,自然就是下面这张关系图:

User 1—* Installation *—1 GitHubApp
Installation 1—* Repository
Repository 1—* TranslationJob 1—* TranslationSegment
User 1—* ApiKey

这个结构参考了 GitHub 自己的 webhook 事件模型。我踩过的一个坑是:早期把 GitHub API 返回的仓库 JSON 整个塞进一张表,字段冗余不说,上游结构一调整,我的表就得跟着改。后来只保留业务真正需要的字段,API 的细节在内存里用完即弃。

关系图背后有几条必须守住的不变式:

  • 一个用户可能安装多个 GitHub App;
  • 一个安装对应一批仓库;
  • 一个仓库可发起多个翻译任务;
  • 一个任务拆分为多个片段,片段状态决定整体进度。

片段是整套设计的核心。长文档一次塞给模型会超出 token 上限,所以必须按路径切段;每段的 status 独立流转 PENDING → RUNNING → DONE / FAILED,任务级状态只是片段状态的汇总。这样重试时只重试失败的片段,不用整篇重译。

三、关键模型示例

下面这段 schema 是数据层的骨架,我挑几个容易忽略的细节说明:

model User {
  id            String        @id @default(cuid())
  githubId      BigInt        @unique
  login         String
  email         String?
  installations Installation[]
  apiKeys       ApiKey[]
  createdAt     DateTime      @default(now())
}

model Installation {
  id             String       @id @default(cuid())
  installationId BigInt       @unique
  user           User         @relation(fields: [userId], references: [id])
  userId         String
  accountLogin   String
  repositories   Repository[]
  createdAt      DateTime     @default(now())
}

model Repository {
  id           String   @id @default(cuid())
  fullName     String   @unique
  installation Installation @relation(fields: [installationId], references: [id])
  installationId String
  jobs         TranslationJob[]
}

model TranslationJob {
  id           String   @id @default(cuid())
  repository   Repository @relation(fields: [repositoryId], references: [id])
  repositoryId String
  sourceLang   String
  targetLang   String
  status       JobStatus @default(PENDING)
  segments     TranslationSegment[]
  createdAt    DateTime @default(now())
}

model TranslationSegment {
  id        String   @id @default(cuid())
  job       TranslationJob @relation(fields: [jobId], references: [id])
  jobId     String
  path      String
  sourceHash String
  status    SegmentStatus @default(PENDING)
  content   String?
  error     String?
}

model ApiKey {
  id        String   @id @default(cuid())
  user      User     @relation(fields: [userId], references: [id])
  userId    String
  provider  String
  cipher    String
  iv        String
  createdAt DateTime @default(now())
}

id 我用 cuid() 而不是自增数字。翻译工具的数据要在开发、测试、生产多套环境之间同步,自增 id 分分钟撞号,cuid 全局唯一还不可预测,也不用担心被外部猜出业务量。githubId 用 BigInt 是同一个道理:GitHub 的用户和安装 ID 早就超出了 JS 的安全整数范围,用 Int 会在第 16 位悄悄丢精度,这种 bug 特别难排查。另外我把 User 和 ApiKey 拆成两张表,没有把密钥塞在 User 身上,这样用户挂多个翻译服务商的 Key 时,不需要改表结构。

四、迁移与版本

迁移是数据库项目里最容易失控的一环。我早期贪图省事,开发环境改完 schema 直接 db push,跑得确实快,可生产环境没有对应的变更历史,等想回滚或者重建环境时,完全说不清结构是怎么一步步变成这样的。

后来我定下规矩:任何 schema 改动都必须走 prisma migrate dev 生成迁移文件:

  • 任何 schema 改动都通过 prisma migrate dev 生成迁移文件;
  • 提交迁移文件到 Git,让生产环境用 prisma migrate deploy;
  • 禁止在生产直接 db push,避免无历史变更。

发版时我按这三步走:

  1. 本地 prisma migrate dev 生成并命名迁移文件;
  2. 代码连同迁移文件一起进 Git、走一次 Code Review;
  3. 生产执行 prisma migrate deploy 并核对迁移记录。

db push 不是不能用,原型阶段和本地调试它都称职,但代码一旦进了 Git,就必须以迁移文件为准。每次发版前执行 prisma migrate deploy,数据库变更和代码变更同步可审查,出了问题也翻得到历史。

五、查询模式

5.1 列表查询

任务列表页要同时显示每个任务的失败片段。我最初写了两段查询:先查任务,再对每个任务查一次片段,任务一多,N+1 问题就冒出来了,页面查询次数随任务数线性增长。改成一次 findMany,用 include 把需要的片段一起带出来:

const jobs = await prisma.translationJob.findMany({
  where: { repositoryId, status: { in: ["PENDING", "RUNNING"] } },
  include: { segments: { where: { status: "FAILED" } } },
  orderBy: { createdAt: "desc" },
  take: 20,
});

include 里带 where,只把失败片段拉出来,不会把整批片段全传给前端;take 控制页大小,orderBy 保证列表顺序稳定。改完以后,任务列表的查询从”1 + N 次”降到 1 次,接口响应肉眼可见地变快。

5.2 原子更新

片段状态流转是多个 worker 并发的,同一个任务可能同时被两个 worker 取到。如果不做条件更新,两边都会把同一段改成 RUNNING,翻译重复执行,费用直接翻倍。用 updateMany 加条件把竞争挡掉:

await prisma.translationSegment.updateMany({
  where: { id, status: "PENDING" },
  data: { status: "RUNNING" },
});

关键在 where 里带了 status: “PENDING”。返回影响行数为 0 时,说明这段已经被别的 worker 抢走了,直接跳过。这不需要锁,也不用引入分布式锁,靠条件更新的原子性就解决了竞争问题。worker 重启丢掉的段,也会被别的 worker 自然接走,收敛得很好。

六、敏感字段

翻译工具要替用户调用各家大模型,API Key 必须存下来。我最初图省事存过明文,后来想想实在后怕:库一旦泄露,客户的所有 Key 等于一起交出去。改成加密存储后,ApiKey.cipher 只放加密后的密文,原文不入库。加密选的是 AES-GCM,它是认证加密,密文被篡改能被直接发现:

  • cipher = base64 密文
  • iv = 每次加密随机 12 字节
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();

iv 每次随机生成并随密文一起存储,同一段明文每次加密结果都不同,攻击者拿不到可复用的密文样本。主密钥放在环境变量或 KMS,Prisma schema 里只有密文字段,不碰密钥本身。即使有人拖库,拿到的也只是一堆解不开的密文。

七、性能与索引

翻译工具的查询量不算大,但有三个模式每天跑几千次,不建索引会拖住整个后台。我照着慢查询日志走了一遍,高频的基本是”按仓库加状态查任务””按用户查安装””按时间排序”三类,就给它们各建了索引:

@@index([repositoryId, status])
@@index([userId])
@@index([createdAt])

第一个是组合索引,任务列表永远带 repositoryId 和 status 两个条件,单列索引只能命中一半。另外我把翻译后的正文放在 TranslationSegment.content,主表不扛大字段,避免扫描和缓存膨胀;列表与统计用独立的 count 查询,不把大字段拉出来。这套组合落地后,后台接口没有再现过明显的卡顿。

八、事务

创建任务和写入片段是两步操作,但必须是一个原子动作。任务建出来了、片段却只插了一半,用户会看到任务卡在 0 个片段,重试还会产生重复片段。用事务包起来,要么全成,要么全败:

await prisma.$transaction(async (tx) => {
  const job = await tx.translationJob.create({ data: jobData });
  await tx.translationSegment.createMany({ data: segments });
  return job;
});

注意事务回调里必须用 tx,不能用全局的 prisma 对象,否则事务边界会被悄悄打破。片段是批量写入的,所以用 createMany 而不是循环 create,批量插入比逐条插入快得多(本机 SQLite 上的实测观察)。事务提交后任务和片段同时可见,后续 worker 才能安全地消费这些片段。

九、测试与种子

数据库相关的测试最容易互相污染。我在单测里用独立的 SQLite,每个用例跑在内存库上,跑完即弃;需要验证真实 Postgres 行为的集成测试,用 Docker 起一个一次性实例。这样测试互不干扰,也不碰开发数据。

种子数据也值得提前做。每次重装环境都要手动建账号、建仓库,太浪费时间,我写好了 prisma db seed 脚本:

  • 单测:使用独立 SQLite 或 Docker Postgres;
  • 种子:prisma db seed 写入一个开发者账号和一个 demo 仓库;
  • 回滚:测试结束 prisma migrate reset。

migrate reset 会把库重置到迁移文件的基线,再重新执行 seed,测试结束一键回到干净起点。这套组合让新同事第一天就能跑起完整环境,不用花半天折腾数据库。

十、运维

上线之后,真正麻烦的是那些”看不见的问题”——慢查询、迁移事故、静默的数据漂移。所以监控和备份我没有留到出事后才做:

  • 慢查询监控:开启 log: ["query", "warn", "error"] 收集慢语句;
  • 备份:生产数据库每日全量 + 增量;
  • 升级:测试环境先跑 Prisma 新版本,再上生产。

log 开启后我每天扫一遍慢语句,能提前发现该补的索引。备份做的是每日全量加增量,恢复演练也真跑过两次,确认能把数据恢复到一个小时内的状态。Prisma 版本升级我不敢直接上线上,先在测试环境跑一段时间,确认迁移和 client 行为没有变化,再动生产。

常见问题(FAQ)

Q1:SQLite 适合生产吗?

单机小流量可以。并发上升建议切换到 Postgres。

Q2:Prisma 怎么防 N+1?

用 include 一次拉取关联,避免循环查询。

Q3:迁移失败怎么回滚?

用 prisma migrate resolve --rolled-back 标记失败,再用反向 SQL 修正。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
小码农的头像小码农认证作者

相关推荐

返回顶部