主题
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 为"已取消"(通常不真删)
- 商家下架菜品 → Delete 或 Update 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 秒。如果你发现某个页面莫名其妙地慢,第一个怀疑对象就是它。
应用
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 就要留个心眼——数据量小无所谓,数据量大了一定要换成游标分页。
OFFSET 分页
扫描了 20 行,丢弃了 0 行,只返回 20 行
Cursor 游标分页
直接定位,只扫描 20 行
先查再改的并发陷阱
小红要做一个"用户设置"功能。用户修改主题颜色时,设置可能已经存在(需要更新),也可能是第一次设置(需要插入)。
AI 可能生成"先查有没有,有就更新,没有就插入"的逻辑。这在只有一个用户的时候没问题。但想象这个场景:用户快速连点了两次"保存"按钮,两个请求几乎同时到达服务器。
第一个请求查了一下:"设置不存在"。第二个请求也查了一下:"设置不存在"。然后两个请求都去执行插入操作——第二个就会因为主键或唯一约束冲突而报错。
这个问题叫竞态条件,在 6.1 节讲 JSON 并发写入时已经提过。数据库有一个专门解决这个问题的操作叫 UPSERT(INSERT ... ON CONFLICT DO UPDATE)——一条语句原子完成"有就更新、没有就插入",不会有并发冲突。
审查时看到"先 SELECT 再决定 INSERT 还是 UPDATE"的模式,就应该改成 UPSERT。
事务:要么全成功,要么全失败
小红的平台上,用户下单时需要同时做三件事:
- 创建一条订单记录
- 创建多条订单明细记录(点了哪些菜)
- 扣减对应菜品的库存
如果第 1 步成功了,第 2 步也成功了,但第 3 步扣库存时发现库存不足——怎么办?订单已经创建了,但菜品没库存了,这就是数据不一致。用户以为下单成功了,商家一看库存已经是负数了,两边都懵。
事务(Transaction) 就是解决这个问题的。事务把多个操作打包成一个"原子操作":要么全部成功,要么全部回滚(撤销),不会出现"做了一半"的中间状态。
"原子"这个词来自物理学——原子是不可再分的最小单位。事务也是一样,它是数据库操作的最小单位,不存在"执行了一半"的状态。
用一个更生活化的例子来理解:你去 ATM 转账,从 A 账户转 100 块到 B 账户。这个操作包含两步:A 扣 100、B 加 100。如果 A 扣了钱但 B 没加上(比如系统崩溃了),那 100 块就凭空消失了。事务保证这两步要么都完成,要么都不做——如果中途出错,A 扣的钱会自动退回来。
事务的四个特性(ACID):
| 特性 | 含义 | 小红的例子 |
|---|---|---|
| Atomicity 原子性 | 全成功或全失败 | 下单三步要么都完成,要么都撤销 |
| Consistency 一致性 | 操作前后数据都合法 | 库存不会变成负数 |
| Isolation 隔离性 | 并发操作互不干扰 | 两人同时抢最后一份,只有一人成功 |
| Durability 持久性 | 成功后数据永久保存 | 服务器重启,订单数据还在 |
小明
¥1,000
小红
¥500
你不需要自己实现事务逻辑——AI 会用 ORM 的事务 API 帮你包装好。你只需要审查:哪些操作应该放在同一个事务里。
判断标准很简单:如果几个操作必须同时成功或同时失败,就放在一个事务里。
常见需要事务的场景:
- 下单(创建订单 + 扣库存)
- 转账(A 扣钱 + B 加钱)
- 注册(创建用户 + 创建默认配置 + 发送欢迎消息)
- 发帖(创建帖子 + 更新用户发帖计数 + 创建动态记录)
事务要短小精悍
事务执行期间会持有数据库锁,阻塞其他操作。如果事务里包含了调用外部 API、发送邮件等耗时操作,锁会被长时间占用,导致其他用户的请求排队等待。
想象你在银行柜台办业务,柜员帮你转完账后,又帮你打电话确认、发短信通知、打印回执……后面排队的人全在等。正确的做法是:柜员只做转账这一件事(数据库操作),转完账你就离开柜台,打电话、发短信这些事在旁边自己办(事务之外)。
原则:事务里只放数据库操作,外部调用放在事务之外。比如"创建订单 + 发通知",应该先在事务里创建订单,事务提交后再发通知。
Schema 定义:数据库的蓝图
在让 AI 写 CRUD 之前,你需要先定义好表结构(Schema)。Schema 就是数据库的"蓝图",描述了有哪些表、每张表有哪些列、列的类型和约束是什么。
你可以把 Schema 想象成建筑图纸。盖房子之前,建筑师要画好图纸:几层楼、每层几个房间、每个房间多大、门窗在哪里。数据库也一样——先画好"图纸"(Schema),再往里面"住人"(存数据)。
Schema 不是你手写的——AI 会根据你的业务需求自动生成。但你需要能看懂它,知道它设计得对不对。
AI 生成 Schema 后,你需要审查这些要点:
- 主键设置正确吗? 每张表都应该有
id主键 - 外键方向对吗?
posts.userId应该指向users.id,不能反了。外键永远在"多"的那一方——一个用户有多篇帖子,所以userId在posts表里,不在users表里 - 该加的约束加了吗? 点赞表的
userId + postId应该有联合唯一约束——"这两个字段的组合不能重复",也就是同一个用户只能给同一篇帖子点一次赞。没有这个约束,用户连点两次就会产生两条点赞记录 - 字段类型合适吗? 帖子内容用
text(不限长度),不要用varchar(255)——6.2 节讲过,PostgreSQL 里两者性能一样,varchar只是多了个不必要的长度限制 - 时间戳齐全吗?
created_at和updated_at是标配,几乎每张表都应该有 - 索引考虑了吗? 经常用来查询的字段(如
userId、postId)应该有索引——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,你和数据库打交道的工作流是这样的:
- 设计阶段:把你的业务需求描述清楚,AI 会生成 Schema
- 审查 Schema:检查表结构、关系、约束是否正确(用 6.2 学到的知识)
- 生成 CRUD:AI 根据 Schema 生成增删改查代码
- 审查 CRUD:重点检查 WHERE 条件、事务使用、错误处理(用本节学到的知识)
- 迭代修改:需求变了,AI 修改 Schema 并生成迁移
- 测试验证:在开发环境跑通,再部署到生产
注意你在这个流程里的角色:你不是"写数据库代码的人",而是"审查数据库代码的人"。 AI 负责写,你负责判断对不对。
这就是为什么前面两节花了大量篇幅讲概念——只有理解了主键、外键、约束、关系类型,你才能有效地审查 AI 的输出。不理解这些概念,你就只能"AI 说什么就是什么",遇到问题也不知道从哪里下手排查。
本节核心要点
- ORM 是代码和数据库之间的翻译层,推荐 Drizzle
- CRUD 是所有数据库操作的基础:创建、读取、更新、删除
- 事务 保证多个操作要么全成功要么全失败
- 审查重点:WHERE 条件、外键方向、唯一约束、事务边界
- 迁移 是表结构的版本管理,改表结构必须走迁移流程
