数据库这一层,决定了整个翻译工具的稳定下限。任务、片段、仓库、安装之间的关系多,状态又一直在变,一开始我用原生 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,避免无历史变更。
发版时我按这三步走:
- 本地
prisma migrate dev生成并命名迁移文件; - 代码连同迁移文件一起进 Git、走一次 Code Review;
- 生产执行
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 修正。