Skip to content

6.3 如何操作数据库

本节目标:理解数据库操作的核心概念(CRUD、ORM、事务),学会审查 AI 生成的数据库代码。


小红的第一个需求

上一节,小红设计好了校园外卖平台的表结构。表有了,关系也理清了,但数据库现在还是空的——就像盖好了一栋大楼,但里面一个人都没住进去。

现在她面临一个实际问题:怎么往数据库里存数据、取数据?

她的第一个需求很简单:用户注册。一个学生打开 App,填了手机号和昵称,点"注册"——后端需要把这条用户信息存进 users 表。

这个操作叫插入(Insert),是数据库最基本的操作之一。听起来简单,但小红马上遇到了一个选择题:是直接写 SQL 语句,还是用别的方式?

她选择了用一个叫 ORM 的工具。


ORM:用代码操作数据库的翻译官

ORM(Object-Relational Mapping,对象关系映射) 是一个"翻译层",让你用编程语言(TypeScript/JavaScript)来操作数据库,而不需要手写 SQL。

你可以把 ORM 想象成一个翻译官:你说中文(TypeScript 代码),数据库只懂英文(SQL),ORM 负责在中间翻译。你写 db.insert(users).values({ phone: '138xxxx', name: '小红' }),ORM 翻译成 INSERT INTO users (phone, name) VALUES ('138xxxx', '小红') 发给数据库执行。

为什么需要这个翻译官?直接写 SQL 不行吗?

直接写 SQL 的问题。 SQL 是数据库的"母语",功能强大但有几个痛点:字符串拼接容易出错、没有类型检查(写错列名要到运行时才发现)、不同数据库的 SQL 语法有差异。对于 Vibe Coder 来说,最大的问题是——你让 AI 生成 SQL,很难一眼看出对不对。一条复杂的 SQL 可能有十几行,嵌套三四层,光是括号匹配就让人头晕。

ORM 的好处。 ORM 把数据库表映射成代码里的对象,把 SQL 操作映射成函数调用。列名有自动补全、类型有检查、写错了编辑器直接报红。而且 ORM 生成的代码更容易阅读和审查——你看到 db.select().from(users).where(eq(users.phone, '138xxxx')),即使不懂 SQL 也能猜到它在做什么。

本教程推荐 Drizzle ORM,原因是:

  • 语法贴近 SQL,学过 SQL 概念就能看懂
  • TypeScript 类型安全,AI 生成的代码更容易审查
  • 同时支持 PostgreSQL 和 SQLite,方便切换
  • 社区活跃,文档完善

另一个流行的选择是 Prisma,它用自己的 Schema 语言定义表结构,语法更简洁但离 SQL 更远。两者都是好工具,选一个坚持用就行。重要的不是选哪个,而是理解 ORM 背后的概念——因为概念是通用的,工具可以换。


CRUD:数据库操作的四个基本动作

不管你做的是外卖平台、社交应用、电商网站还是博客系统,所有数据库操作归根结底就四种,合称 CRUD

操作英文含义生活类比
Create创建往表里插入新数据新用户注册、下新订单
Read读取从表里查询数据查看订单列表、搜索商品
Update更新修改已有数据改收货地址、更新订单状态
Delete删除移除数据取消订单、注销账号

CRUD 这个缩写在开发圈子里非常常见。当有人说"这个功能就是个 CRUD",意思是"这个功能的核心就是对数据库做增删改查,没有复杂的业务逻辑"。事实上,大部分 Web 应用的 80% 功能都是 CRUD——真正复杂的业务逻辑只占很小一部分。

这四个操作覆盖了 99% 的业务场景。你在任何应用里看到的功能,拆到数据库层面都是 CRUD 的组合。

比如小红的外卖平台:

  • 用户注册 → Create 一条 users 记录
  • 浏览菜单 → Read dishes 表,按商家筛选
  • 下单 → Create 一条 orders 记录 + 多条 order_items 记录
  • 改地址 → Update users 表的 address 字段
  • 取消订单 → Update orders 的 status 为"已取消"(通常不真删)
  • 商家下架菜品 → DeleteUpdate dishes 的状态

注意最后一条:实际项目中很少真的删除数据。更常见的做法是**"软删除"**——加一个 deleted_at 字段,标记为已删除但数据还在。这样万一删错了还能恢复,也方便做数据分析。

为什么不直接删?想象一下:用户取消了订单,你把订单记录从数据库里彻底删掉了。一个月后用户来投诉说"我明明下过单,你们怎么没记录?"你拿什么证明?软删除就是给数据加一个"回收站"——标记为已删除,但数据还在,需要的时候随时可以恢复或查证。

id姓名手机地址状态
1小明138****1234北京市朝阳区正常
2小红139****5678上海市浦东新区正常
3小李137****9012深圳市南山区正常

审查 AI 生成的 CRUD 代码

作为 Vibe Coder,你不需要自己写 CRUD 代码——AI 会帮你写。但你需要知道怎么审查 AI 生成的代码。

这就像你请了一个装修工人。你不需要自己刷墙、铺地板,但你得知道墙刷得平不平、地板铺得正不正。如果你完全不懂装修,工人偷工减料你也看不出来。

AI 生成的数据库代码也是一样。它大部分时候是对的,但偶尔会犯一些"看起来能跑但其实有隐患"的错误。以下是每种操作的审查要点:

Create(插入)审查清单:

  • 必填字段都传了吗?(NOT NULL 的列不能漏)
  • 有没有做输入校验?(手机号格式、昵称长度)
  • 插入失败时有没有错误处理?(比如手机号重复,数据库会报 UNIQUE 约束冲突)

Read(查询)审查清单:

  • 查询条件对吗?(是按 user_id 查还是按 phone 查)
  • 需要关联查询吗?(查订单时要不要带上用户信息)
  • 有没有分页?(不加分页,数据量大了会一次返回几万条,直接把页面卡死)
  • 有没有排序?(通常按创建时间倒序,最新的在前面)
  • 有没有 N+1 问题?(见下方说明)

Update(更新)审查清单:

  • WHERE 条件对吗?(没有 WHERE 会更新整张表的所有行!)
  • 只更新了需要改的字段吗?(不要把其他字段也覆盖了)
  • 有没有更新 updated_at 时间戳?

Delete(删除)审查清单:

  • 是真删除还是软删除?(生产环境建议软删除)
  • WHERE 条件对吗?(没有 WHERE 会删掉整张表!)
  • 有没有考虑关联数据?(删用户前,他的订单怎么办?)

WHERE 条件是生命线

Update 和 Delete 操作如果忘了写 WHERE 条件,会影响表里的所有行。这是数据库操作中最常见也最致命的错误。

想象你想把小红的地址改成"3 号楼 201",结果 AI 生成的代码忘了加 WHERE 条件——整张 users 表里所有用户的地址都被改成了"3 号楼 201"。几千个用户的地址,一瞬间全变了。

审查 AI 代码时,看到 UPDATE 或 DELETE,第一件事就是检查有没有 WHERE。没有 WHERE 的 UPDATE/DELETE 几乎永远是 bug。


常见性能陷阱

代码能跑和跑得快是两回事。以下三个性能陷阱在实际项目中最常见——不管是人写的还是 AI 写的,数据量一大都可能踩到。好消息是,你只需要知道这些坑的名字,遇到性能问题时告诉 AI "检查一下有没有 N+1 查询"就行。

N+1 查询:最隐蔽的性能杀手

小红要做一个"商家后台"页面,展示所有用户和他们各自的订单数量。

一种常见的错误写法是:先查出所有用户(1 次查询),然后对每个用户分别查他的订单(N 次查询)。如果有 100 个用户,就是 101 次数据库查询——这就是 N+1 问题。

为什么叫 N+1?因为第一次查询返回了 N 条记录,然后对每条记录又发了 1 次查询,总共 N+1 次。

这个问题特别隐蔽,因为在开发环境里(数据少、网络延迟低)几乎感觉不到。但到了生产环境,每次数据库查询都有网络往返的开销(通常 1-5 毫秒)。100 次查询就是 100-500 毫秒,再加上数据库本身的处理时间,页面加载轻松突破好几秒。

正确的做法是用 JOIN 或批量查询,一次把所有数据取出来。审查时如果看到"循环里套着数据库查询",基本就是 N+1。

N+1 能让页面加载从 100ms 变成 10 秒。如果你发现某个页面莫名其妙地慢,第一个怀疑对象就是它。

用户数量10
👤
应用
N+1 方式
已发送: 0/11 次查询
JOIN 方式
等待中...
🗃
数据库

分页越翻越慢:OFFSET 的陷阱

小红的菜品列表有上千道菜,需要分页展示——每页 20 道,用户可以翻页浏览。AI 默认生成的分页通常是 OFFSET 方式——"跳过前 N 条,取接下来的 20 条"。

前几页很快,但翻到第 50 页时明显变慢了。为什么?

想象你在图书馆找第 1000 本书。OFFSET 的做法是:从第一本开始数,数到第 1000 本,然后取出后面 20 本。每次翻页都要从头数起,页数越深,数的越多,越慢。

数据库也是这样。OFFSET 1000 LIMIT 20 意味着数据库要先扫描前面 1000 条被跳过的行,然后才取出你要的 20 条。这些被跳过的行虽然不返回给你,但数据库还是要读取和处理它们。

更好的方式是游标分页(Cursor Pagination):记住上一页最后一条记录的 ID,下一页直接从那个 ID 之后开始取。就像在图书馆里放了一个书签,下次直接从书签位置开始,不需要从头数。无论翻到第几页,速度都一样快。

审查分页代码时,看到 OFFSET 就要留个心眼——数据量小无所谓,数据量大了一定要换成游标分页。

当前页码第 1 页
OFFSET 分页
扫描了 20 行,丢弃了 0 行,只返回 20
Cursor 游标分页
直接定位,只扫描 20

先查再改的并发陷阱

小红要做一个"用户设置"功能。用户修改主题颜色时,设置可能已经存在(需要更新),也可能是第一次设置(需要插入)。

AI 可能生成"先查有没有,有就更新,没有就插入"的逻辑。这在只有一个用户的时候没问题。但想象这个场景:用户快速连点了两次"保存"按钮,两个请求几乎同时到达服务器。

第一个请求查了一下:"设置不存在"。第二个请求也查了一下:"设置不存在"。然后两个请求都去执行插入操作——第二个就会因为主键或唯一约束冲突而报错。

这个问题叫竞态条件,在 6.1 节讲 JSON 并发写入时已经提过。数据库有一个专门解决这个问题的操作叫 UPSERT(INSERT ... ON CONFLICT DO UPDATE)——一条语句原子完成"有就更新、没有就插入",不会有并发冲突。

审查时看到"先 SELECT 再决定 INSERT 还是 UPDATE"的模式,就应该改成 UPSERT。


事务:要么全成功,要么全失败

小红的平台上,用户下单时需要同时做三件事:

  1. 创建一条订单记录
  2. 创建多条订单明细记录(点了哪些菜)
  3. 扣减对应菜品的库存

如果第 1 步成功了,第 2 步也成功了,但第 3 步扣库存时发现库存不足——怎么办?订单已经创建了,但菜品没库存了,这就是数据不一致。用户以为下单成功了,商家一看库存已经是负数了,两边都懵。

事务(Transaction) 就是解决这个问题的。事务把多个操作打包成一个"原子操作":要么全部成功,要么全部回滚(撤销),不会出现"做了一半"的中间状态。

"原子"这个词来自物理学——原子是不可再分的最小单位。事务也是一样,它是数据库操作的最小单位,不存在"执行了一半"的状态。

用一个更生活化的例子来理解:你去 ATM 转账,从 A 账户转 100 块到 B 账户。这个操作包含两步:A 扣 100、B 加 100。如果 A 扣了钱但 B 没加上(比如系统崩溃了),那 100 块就凭空消失了。事务保证这两步要么都完成,要么都不做——如果中途出错,A 扣的钱会自动退回来。

事务的四个特性(ACID):

特性含义小红的例子
Atomicity 原子性全成功或全失败下单三步要么都完成,要么都撤销
Consistency 一致性操作前后数据都合法库存不会变成负数
Isolation 隔离性并发操作互不干扰两人同时抢最后一份,只有一人成功
Durability 持久性成功后数据永久保存服务器重启,订单数据还在
场景

你不需要自己实现事务逻辑——AI 会用 ORM 的事务 API 帮你包装好。你只需要审查:哪些操作应该放在同一个事务里。

判断标准很简单:如果几个操作必须同时成功或同时失败,就放在一个事务里。

常见需要事务的场景:

  • 下单(创建订单 + 扣库存)
  • 转账(A 扣钱 + B 加钱)
  • 注册(创建用户 + 创建默认配置 + 发送欢迎消息)
  • 发帖(创建帖子 + 更新用户发帖计数 + 创建动态记录)

事务要短小精悍

事务执行期间会持有数据库锁,阻塞其他操作。如果事务里包含了调用外部 API、发送邮件等耗时操作,锁会被长时间占用,导致其他用户的请求排队等待。

想象你在银行柜台办业务,柜员帮你转完账后,又帮你打电话确认、发短信通知、打印回执……后面排队的人全在等。正确的做法是:柜员只做转账这一件事(数据库操作),转完账你就离开柜台,打电话、发短信这些事在旁边自己办(事务之外)。

原则:事务里只放数据库操作,外部调用放在事务之外。比如"创建订单 + 发通知",应该先在事务里创建订单,事务提交后再发通知。


Schema 定义:数据库的蓝图

在让 AI 写 CRUD 之前,你需要先定义好表结构(Schema)。Schema 就是数据库的"蓝图",描述了有哪些表、每张表有哪些列、列的类型和约束是什么。

你可以把 Schema 想象成建筑图纸。盖房子之前,建筑师要画好图纸:几层楼、每层几个房间、每个房间多大、门窗在哪里。数据库也一样——先画好"图纸"(Schema),再往里面"住人"(存数据)。

Schema 不是你手写的——AI 会根据你的业务需求自动生成。但你需要能看懂它,知道它设计得对不对。

AI 生成 Schema 后,你需要审查这些要点:

  • 主键设置正确吗? 每张表都应该有 id 主键
  • 外键方向对吗? posts.userId 应该指向 users.id,不能反了。外键永远在"多"的那一方——一个用户有多篇帖子,所以 userIdposts 表里,不在 users 表里
  • 该加的约束加了吗? 点赞表的 userId + postId 应该有联合唯一约束——"这两个字段的组合不能重复",也就是同一个用户只能给同一篇帖子点一次赞。没有这个约束,用户连点两次就会产生两条点赞记录
  • 字段类型合适吗? 帖子内容用 text(不限长度),不要用 varchar(255)——6.2 节讲过,PostgreSQL 里两者性能一样,varchar 只是多了个不必要的长度限制
  • 时间戳齐全吗? created_atupdated_at 是标配,几乎每张表都应该有
  • 索引考虑了吗? 经常用来查询的字段(如 userIdpostId)应该有索引——6.4 节会详细讲

数据库迁移:Schema 变更的版本管理

表结构不是一成不变的。随着功能迭代,你可能需要给 users 表加一个 avatar 字段,或者给 posts 表加一个 is_pinned 列。

直接改数据库?太危险了。万一改错了,线上数据就毁了。而且你怎么记得住自己改了什么?三个月后回头看,完全想不起来当初为什么加了这个列。

迁移(Migration) 是数据库的"版本管理",就像 Git 管理代码版本一样。每次修改表结构,都生成一个迁移文件,记录"做了什么改动"。这样可以:

  • 追踪历史:知道表结构是怎么一步步变成现在这样的,每次改动都有记录
  • 团队协作:队友拉代码后执行迁移,数据库结构自动同步,不需要手动对齐
  • 安全回滚:改错了可以回退到上一个版本,就像 Git 的 revert

Drizzle ORM 有内置的迁移工具。工作流程是这样的:你修改 Schema 代码(比如给 users 表加一个 avatar 列),然后运行迁移命令,工具会自动对比新旧 Schema 的差异,生成一个迁移 SQL 文件(比如 0001_add_avatar_to_users.sql),最后执行这个文件来更新数据库。

生产环境迁移要谨慎

开发环境随便改,但生产环境的迁移要小心。有些操作是不可逆的:删掉一个列,里面的数据就永远没了;把 text 改成 integer,不符合格式的数据会丢失。

AI 生成迁移后,一定要先在开发环境测试,确认无误再应用到生产。特别是涉及删列、改类型的操作,要格外谨慎。如果不确定,先备份。


工作流总结

作为 Vibe Coder,你和数据库打交道的工作流是这样的:

  1. 设计阶段:把你的业务需求描述清楚,AI 会生成 Schema
  2. 审查 Schema:检查表结构、关系、约束是否正确(用 6.2 学到的知识)
  3. 生成 CRUD:AI 根据 Schema 生成增删改查代码
  4. 审查 CRUD:重点检查 WHERE 条件、事务使用、错误处理(用本节学到的知识)
  5. 迭代修改:需求变了,AI 修改 Schema 并生成迁移
  6. 测试验证:在开发环境跑通,再部署到生产

注意你在这个流程里的角色:你不是"写数据库代码的人",而是"审查数据库代码的人"。 AI 负责写,你负责判断对不对。

这就是为什么前面两节花了大量篇幅讲概念——只有理解了主键、外键、约束、关系类型,你才能有效地审查 AI 的输出。不理解这些概念,你就只能"AI 说什么就是什么",遇到问题也不知道从哪里下手排查。


本节核心要点

  • ORM 是代码和数据库之间的翻译层,推荐 Drizzle
  • CRUD 是所有数据库操作的基础:创建、读取、更新、删除
  • 事务 保证多个操作要么全成功要么全失败
  • 审查重点:WHERE 条件、外键方向、唯一约束、事务边界
  • 迁移 是表结构的版本管理,改表结构必须走迁移流程