📑 本页目录(点开跳转)
06 · 关系数据库
⏱ 42 分钟 | ⭐ 「要存什么」比「用哪个数据库」重要一百倍
🎯 一句话
难点不在选型,在于「哪些东西必须在请求发生的那一刻就写下来」——错过了永远补不回来。 Postgres 还是 MySQL 五分钟能定;漏掉一列 token 数,三个月后你算不出这个功能挣不挣钱。
🧩 一、先把清单列全
| 存什么 | 干什么用 | 事后能补吗 |
|---|---|---|
| 用户 | 谁在用、配额多少 | 能 |
| 会话 conversations | 侧边栏那个列表 | 能 |
| 消息 messages | 对话正文,下一轮靠它拼上下文 | 能 |
| ⭐ 用量与成本 | 每条回复花了多少钱 | ❌ 不能 |
| 上传的文件 | 文件在对象存储,元数据在库里 | 半能 |
| 任务状态 | pending / running / done / failed | ❌ 不能 |
| ⭐ 审计日志 | 谁在什么时候调了哪个模型 | ❌ 不能 |
⚠️ 右边那一列才是重点。 会话标题忘了存,明天加一列就有了;token 数当时没记,那条请求的成本永远是未知数——原始响应体早就不在了。
⭐ 判据:可推导的能晚点存,不可推导的必须当场存。 用量、成本、状态变更、审计,都是「事件过去就消失」的东西。
🧩 二、对话为什么是两张表,不是一张 JSON
最省事的做法是把消息塞进一个 JSON 列。能跑,但三件事会打脸:⚠️ 追加一条要整读、改、整写回,两个请求同时追加时后写的会整段覆盖先写的;算不了账(这个用户这月花了多少,没法直接 SUM);查不了单条(「所有重试失败的 assistant 消息」在两张表里是一句 WHERE)。
-- 整段可以直接喂给 sqlite3(已实跑);Postgres 只需把类型换成 uuid / timestamptz
CREATE TABLE conversations (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL); -- ⭐ 列表页按它倒序,不是 created_at
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL REFERENCES conversations(id),
seq INTEGER NOT NULL, -- ⭐ 会话内序号,别拿时间戳当顺序
role TEXT NOT NULL, content TEXT NOT NULL,
model TEXT, -- ⭐ 哪个模型生成的,按条记
input_tokens INTEGER, output_tokens INTEGER,
cost_usd REAL, -- ⭐ 当场算好,别指望以后回补
created_at INTEGER NOT NULL,
UNIQUE (conversation_id, seq)); -- ⭐ 重试写两遍时,这行救你
表的形状一个字不用动,换库只换类型名。有了这两张表,「这个用户这个月花了多少」就是一句 SUM(cost_usd) 加一个 JOIN。
⭐ 三个容易漏的设计:用 seq 而不是靠时间戳排序(同一秒插两条,created_at 可能相同,顺序就随机了);model 记在消息上而不是会话上(用户中途换模型是常态,成本要按条算);UNIQUE (conversation_id, seq)——重试导致同一条写两遍是一定会发生的事,让数据库挡掉比在代码里判断可靠。
💡 JSON 列适合装「你不打算查询的东西」:原始响应体、工具调用参数快照。判据是你会不会
WHERE它、SUM它,会就单独开列。
🧩 三、索引:哪两个查询会先慢
读请求高度集中在两条路径:打开侧边栏(这个用户的会话,按最近更新倒序取 20 条)、点开会话(这个会话的消息,按 seq 升序)。不建索引就是全表扫。
实测(SQLite 内存库塞 40 万行消息、随机分给 2 万个会话,查的就是上面那条「点开会话」路径——SELECT role, content FROM messages WHERE conversation_id=? ORDER BY seq,重复 200 次;你的数值会不同,但量级差异是结构性的):
| 执行计划 | 200 次查询共 | |
|---|---|---|
| 无索引 | SCAN messages + USE TEMP B-TREE FOR ORDER BY |
3.802 秒 |
建 (conversation_id, seq) |
SEARCH … USING INDEX |
0.0019 秒 |
⭐ 索引要把 ORDER BY 的列也带上。只建 (conversation_id) 也能命中,但排序还得在内存里做——无索引那行计划里的 USE TEMP B-TREE FOR ORDER BY 就是它;两列一起建,数据库直接按顺序读出来,那句就没了。同理会话列表要的是 (user_id, updated_at DESC)。
💡
COVERING INDEX(连回表都省掉)是另一回事,别指望它:只有你要的列全在索引里时才会出现。上面这条查询要读content,索引里没有,所以仍要回表,计划是SEARCH … USING INDEX。把它换成SELECT seq FROM …(只取索引列)计划才变成SEARCH … USING COVERING INDEX,同样 200 次 0.0009 秒。⚠️ 别为了凑覆盖索引把content塞进索引——那等于把整张表再复制一遍。
⚠️ 看执行计划,别猜(SQLite EXPLAIN QUERY PLAN,Postgres EXPLAIN (ANALYZE, BUFFERS)),大表上出现 SCAN / Seq Scan 就是信号。也别把索引当免费——每个索引都让写入变慢、占空间,messages 这种写多的表两三个够了。
🧩 四、Schema 演进:迁移不是可选项 ⭐⭐
AI 产品的字段是天天加的:今天加 reasoning_tokens,明天加 cache_read_tokens。所以第一天就得回答:线上那个库怎么知道自己该长什么样? 答案是把每次结构变更写成一个带名字的脚本,把「跑过哪些」记在数据库自己里:
import sqlite3
MIGRATIONS = [("001_init", "CREATE TABLE m (id INTEGER PRIMARY KEY, body TEXT)"),
("002_add_cost", "ALTER TABLE m ADD COLUMN cost_usd REAL")] # ⭐ 只往末尾追加
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY)")
for _ in range(2): # ⭐ 连跑两遍:第二遍什么也不会发生,这就是幂等
done = {r[0] for r in db.execute("SELECT name FROM schema_migrations")}
for name, sql in MIGRATIONS:
if name in done: continue
db.execute(sql) # 改结构
db.execute("INSERT INTO schema_migrations VALUES (?)", (name,)) # ⭐ 同一步记账
db.commit(); print("applied", name)
print([r[1] for r in db.execute("PRAGMA table_info(m)")])
Alembic 之类的成品就是这个形状:有序脚本 + 一张记录表 + 幂等(部署脚本每次启动都调它,不幂等第二次就报错)。⚠️ 跑过的迁移永远不能回头改——别的库早就按旧版本执行完了。
💀 不用迁移的下场:有人本地手改了一列,没写进任何脚本。三个月后新同事按 README 建库,报
column does not exist;线上能跑只是因为那一台机器的库被手改过——此时谁也说不出线上库的真实结构。
⚠️ 上线中的表怎么加字段而不锁死
以下是 Postgres 行为(🗓️ 未实跑,需要真实 Postgres;细节随大版本变,动手前查你那版文档):
| 操作 | 危险在哪 | 怎么做 |
|---|---|---|
ADD COLUMN 可空 / 常量默认值 |
现代 Postgres 是元数据操作,不重写表 | ✅ 基本安全 |
| ⚠️ 锁排队 | 它仍要一瞬排他锁。前面卡着一个跑了 10 分钟的大查询,你的 ALTER 排队等,后来的读写又排在你后面,整张表不可用 | ⭐ SET lock_timeout='3s',抢不到就重试,别无限等 |
| 建索引 | 普通 CREATE INDEX 阻塞写入 |
CREATE INDEX CONCURRENTLY(⚠️ 不能在事务块里,失败会留下无效索引) |
| ⚠️ 删列 / 改列名 | 最危险:滚动发布期间旧代码还在跑,它引用的列没了 | ⭐ 扩展—收缩:①加新列 ②双写 ③回填 ④读切新列 ⑤几天后再删旧列 |
⭐ 通用规则:迁移和发布要能各自单独回滚,做法就是让每一步都同时兼容新旧两版代码——这是五步法存在的全部理由。
🧩 五、连接池:并发一上来就崩的那个配置
Postgres 每个连接是一个进程,几百个就能把内存吃光,所以应用侧要复用固定数量的连接。以 SQLAlchemy 异步引擎为例(🗓️ 未实跑,需要 Postgres + asyncpg),五个旋钮值得认识:pool_size 常驻连接数;max_overflow 峰值临时再开几个;⭐ pool_timeout 等不到就报错(无限等 = 请求全挂住,比报错难查得多);pool_recycle 定期回收,躲开中间层的空闲断连;⭐ pool_pre_ping 借出前 ping 一下,别拿到已经死掉的连接。
⭐ pool_size 是一道除法,不是拍脑袋:数据库能承受的连接总数(比如 100)÷ 应用进程数(4 worker × 2 台 = 8)÷ 还有谁在连同一个库(后台任务、定时脚本、你的 psql)→ 每进程 ~10 个。
⚠️ 两个真实翻车点:忘了乘进程数(单机测试 pool_size=20 很舒服,上线开 8 个 worker 就是 160 个连接,数据库直接拒连);以及 ⭐ 池小不是错,池小 + 占着不放才是错——真正的容量瓶颈几乎总是持有时长。
🧩 六、事务边界:⚠️ 别把 LLM 调用包在事务里
这是 AI 应用最常见也最贵的数据库错误。 写法非常自然:开事务 → 写用户消息 → 调 LLM → 写回复 → 提交,「要么都成功要么都失败,多干净」。问题是中间那步要三秒到三十秒,这整段时间里一个连接被占着什么也没干:
import asyncio, time
POOL, N, LLM, DB = 5, 40, 1.0, 0.005 # 真实世界一次 LLM 调用常是 3–30 秒
async def bad(sem):
async with sem: # ⚠️ 连接从头占到尾
await asyncio.sleep(DB + LLM + DB)
async def good(sem):
async with sem: await asyncio.sleep(DB) # ⭐ 读完立刻还回去
await asyncio.sleep(LLM) # ⭐ LLM 调用在池外
async with sem: await asyncio.sleep(DB) # ⭐ 写的时候再借一次
async def main():
for name, fn in (("包在池里", bad), ("池外调用", good)):
sem = asyncio.Semaphore(POOL); t = time.perf_counter() # 信号量当连接池
await asyncio.gather(*(fn(sem) for _ in range(N)))
print(f"{name}: {N} 个请求耗时 {time.perf_counter()-t:.2f}s")
asyncio.run(main())
实测输出:包在池里: 8.15s / 池外调用: 1.15s。池只有 5、并发 40,请求被迫排成 8 批,每批空等一次完整的 LLM 调用。池大小一个字没改,只是把外部调用挪出了持有区间。
⭐ 判据:事务里只放数据库自己的操作,而且必须是快操作。 任何网络调用(LLM、对象存储、第三方 API)都在事务之外。
那「一半成功了怎么办」?不是拉长事务,是换个模型:用户消息先单独提交;LLM 失败就给会话记一个 failed 状态,让用户看得见、能重试;真正需要原子性的只有最后一小段——写 assistant 消息 + 记账 + 更新 updated_at,一个短事务搞定。
⚠️ 同源的坑:别在 SELECT ... FOR UPDATE 之后调外部服务——那不只占着连接,是攥着一把行锁,别的请求会卡死在那些行上。
🌐 换个栈怎么对应
| 概念 | Python(本板块) | Node | Go |
|---|---|---|---|
| 迁移工具 | Alembic | Prisma Migrate / Drizzle Kit / node-pg-migrate | golang-migrate / goose / Atlas |
| 查询层 | SQLAlchemy 2.0(async) | Prisma / Drizzle / Knex | pgx(+ sqlc 生成代码)/ GORM |
| 连接池 | create_async_engine(pool_size=…) |
pg 的 Pool({ max }) |
pgxpool 的 MaxConns |
| 事务 | async with session.begin(): |
prisma.$transaction([...]) |
tx, _ := pool.Begin(ctx) |
| 池默认值 | 5(+ overflow 10) | 10 | max(4, CPU 核数) |
⭐ 执行计划三家都是同一句 EXPLAIN ANALYZE —— 这是数据库的能力,跟语言无关。
⭐ 三个生态的迁移工具都长成「有序脚本 + 一张记录表」,连接池都有个「最大连接数」旋钮。换栈要重学的只有语法。(🗓️ 包名会变,用前先查。)
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 07-向量检索落地.md | 下一章直接建在这一章的表上——向量不是另开一个系统,是给这张库再加一列。本章的 JOIN 和事务能力在那里会变成直接红利 |
| 11-长任务与队列.md | ⭐ 第一节清单里「任务状态」那一行到那一章才落成表:jobs 表 + 一条原子认领语句。那条语句能成立,靠的正是本章的事务 |
| 12-限流配额与成本护栏.md | ⭐⭐ 本章逼你把 model / token 数 / cost_usd 按条记下来,那一章就是花这笔数据:配额扣减是「一个账本 + 一条带条件的 UPDATE」,成本归因要 user / feature / model 三个维度——当时没记,那里就查不出来 |
| 15-可观测性.md | 成本看板不是新建一套系统,就是把本章记的这几列按三个维度切开看。⭐ 那一章的口径是「记钱,不要只记 token」——所以本章的 cost_usd 要当场算好 |
| ⭐ 06b · 列表接口与分页 | 本章建了 messages 表、加了 seq 列、建好了 (conversation_id, seq) 索引 —— 正好差最后一步:这张表怎么往外分页。⚠️ OFFSET 有两个必死的坑(深翻页越翻越慢、翻页途中有插入会重复和漏项),而 AI 应用恰恰是「你翻着历史,新消息还在往里写」 |
| ../数据这一关/19-隐私脱敏与留存.html | 你刚设计的 messages 存的是用户原话。存多久、能不能拿去训练、删除请求怎么落到行上——建表时就该定,不是出事后再补 |
| ../模型上线之后/17-版本回溯与可复现.html | 本章让你把 model 和 token 数记在每条消息上;那一章讲为什么这几个字段是事后复现一次线上行为的唯一凭据 |
✅ 检查点
- 七类必存数据里,哪三类「事后补不回来」?为什么?
- 为什么把整段对话塞进一个 JSON 列,会在并发追加时出错?
messages为什么要有seq列,而不是按created_at排序?- 索引把
ORDER BY的列一起带上好在哪?实测那两个数字是多少? - 迁移器为什么必须幂等?「跑过哪些」记在哪里?
- 线上要删掉一列,为什么不能直接
DROP COLUMN?五步法是哪五步? pool_size怎么算?单机测好的值搬上线为什么会炸?- 把 LLM 调用包在连接里,实测(池 5 / 并发 40 / LLM 1 秒)是多少对多少?
👀 答案
- 用量与成本、任务状态、审计日志。它们记的是事件发生那一瞬间的信息,事件过去后原始依据(那次请求的 token 数)就不存在了。
- 追加一条要整读 JSON → 改 → 整写回,两个请求读到同一份旧 JSON,后写的把先写的整段覆盖。另外也算不了
SUM(cost_usd)。 - 同一秒插入的两条
created_at可能相同,排序随机。seq是显式序号,配合UNIQUE (conversation_id, seq)还能挡住重试造成的重复写入。 - 数据库能直接按顺序读索引,不必再在内存里排序(无索引时计划里那句
USE TEMP B-TREE FOR ORDER BY就消失了)。实测 200 次查询:3.802 秒 → 0.0019 秒。⚠️ 注意它不是COVERING INDEX——这条查询要读content,索引里没有,仍要回表;只取索引列(SELECT seq)才会覆盖,200 次 0.0009 秒。 - 部署脚本每次启动都调它,不幂等第二次就重复执行 DDL 报错。记在数据库自己的
schema_migrations表里,否则回答不了「线上这个库跑到第几步」。 - 发布是滚动的,旧版本代码还在跑,它引用的列一消失就报错。五步:加新列 → 双写 → 回填 → 读切新列 → 几天后删,核心是每步都兼容新旧两版代码。
- 「数据库能承受的连接总数 ÷ 应用进程数 ÷ 还有谁在连这个库」。单机只有 1 个进程,上线 8 个 worker ×
pool_size=20= 160 个连接,直接拒连。 - 8.15 秒 vs 1.15 秒。池 5、并发 40,包在池里时请求排成 8 批,每批空等一次完整的 1 秒 LLM 调用;挪出去后连接只在毫秒级读写期间被持有。
🛑 可以停在这里
⚡ 走神救援
难点不在选型,在哪些东西必须在请求发生那一刻写下来。七类必存数据里,用量与成本、任务状态、审计日志补不回来——原始响应体早没了。判据:可推导的能晚存,不可推导的必须当场存。 对话拆
conversations+messages两张表不塞 JSON:JSON 追加要整读整写、并发时后写的整段覆盖先写的,还没法SUM(cost_usd)。三个细节——用seq排序(同一秒时间戳会撞)、model按条记(中途换模型是常态)、UNIQUE (conversation_id, seq)挡掉重试的重复写。 索引覆盖两条热路径:(user_id, updated_at DESC)和(conversation_id, seq),把ORDER BY的列一起带上才不用二次排序。实测(SQLite 内存库 40 万行,查的是「点开会话」那条路径)200 次查询:无索引 3.802 秒(SCAN+USE TEMP B-TREE FOR ORDER BY),有索引 0.0019 秒(SEARCH … USING INDEX;⚠️ 它要读content所以仍要回表,别指望覆盖索引——只取索引列才覆盖,0.0009 秒)。看执行计划别猜,也别乱加索引——写入会变慢。 字段天天加,所以迁移必须有:有序脚本 +schema_migrations记录表 + 幂等,十几行就能写,Alembic 只是成品;跑过的迁移永远不回头改。线上加字段的真风险是锁排队:ALTER 要一瞬排他锁,前面卡着长查询它就排队,后来的读写又排在它后面,整张表卡住,所以要SET lock_timeout抢不到就重试。删列改名走扩展—收缩五步:加新列 → 双写 → 回填 → 切读 → 几天后删。 连接池大小是道除法:数据库总连接数 ÷ 进程数 ÷ 其他连它的人;⚠️ 单机pool_size=20× 8 worker = 160 连接是最常见的上线事故。但瓶颈几乎总是持有时长:把 LLM 调用包在池里实测 8.15 秒(池 5、并发 40、LLM 1 秒),只把它挪出持有区间就是 1.15 秒。事务里只放数据库自己的快操作,网络调用一律在外;更别在SELECT ... FOR UPDATE之后调外部服务,那是攥着行锁在等。原子性靠拆:用户消息先单独提交,失败记failed状态,最后写回复 + 记账 + 更新时间用一个短事务。
下一节 👉 06b-列表接口与分页.md