数据库不是存 JSON 的地方
古董级程序员,大厂出来后一直在创业公司,现在仍在一线写代码、做产品、和 AI 编程工具较劲。更完整的更新会放在微信公众号「字与码」:工作经历、新技术观察、AI 开发实践,以及这些年踩过的坑,都会陆续写在那里。
《AI时代的程序员修养》上一篇讲接口契约。接口是服务对外的承诺,数据库则是系统对自己的承诺。字段、索引、唯一约束、事务、锁和迁移脚本,决定了数据能不能保持一致,系统能不能撑住增长,故障后还能不能解释。
AI 很容易把数据库当成“存一下对象”的地方。需求里有个订单,它就建一张订单表;字段不确定,它就塞进 JSON;查询慢了,它就建议加索引;并发出问题了,它再补锁。这种顺序在 demo 里能跑,在线上会留下很硬的技术债。
数据库不是存 JSON 的地方。它是系统里最不容易被糊弄的部分。
表结构就是业务规则
很多人把表结构看成代码的附属品。先写对象,再让 ORM 生成表。这样很快,但容易把业务规则藏在代码里,数据库只剩下几个宽松字段。
比如一个任务表,AI 可能会生成:
create table jobs (
id uuid primary key,
user_id uuid not null,
status text not null,
payload jsonb not null,
result jsonb,
created_at timestamptz not null default now()
);
这张表能用,但它几乎没有表达规则:
status可以写任何字符串。- 同一个业务任务能不能重复创建,看不出来。
- 任务是否可以被 worker 抢占,看不出来。
- 重试次数、下次执行时间、错误信息都在不在
payload里,没人知道。 - 查询 pending 任务时需要什么索引,也没表达。
更像业务系统的表,至少应该把关键状态和访问路径露出来:
create table jobs (
id uuid primary key,
user_id uuid not null,
job_type text not null,
dedupe_key text not null,
status text not null check (
status in ('pending', 'running', 'success', 'failed', 'cancelled')
),
priority integer not null default 0,
run_at timestamptz not null default now(),
locked_by text,
locked_at timestamptz,
attempt integer not null default 0,
max_attempts integer not null default 3,
payload jsonb not null,
result jsonb,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (user_id, job_type, dedupe_key)
);
这张表仍然用了 JSON,但 JSON 不再承担所有职责。payload 可以放变化较快、查询较少的输入参数;状态、调度、重试、抢占和去重这些核心规则,必须变成明确字段。
这里的 unique (user_id, job_type, dedupe_key) 是业务规则,不是性能优化。它说明同一个用户、同一种任务、同一个去重键只能有一条任务。没有这条约束,代码里写多少“先查一下有没有”都挡不住并发插入。
让 AI 设计表时,可以先问:
请不要直接给 ORM model。
先列出这个实体的业务不变量:
- 哪些字段必须唯一?
- 哪些状态是枚举?
- 哪些字段参与查询、排序、抢占、过期和重试?
- 哪些内容可以放 JSON,哪些必须独立成列?
- 哪些规则必须由数据库约束保证,而不是只靠代码判断?
数据库约束不是束缚开发速度,而是把最重要的规则钉在系统底座上。
JSON 字段要有边界
JSON 字段很好用。它适合放不稳定的扩展信息、第三方原始返回、低频查看的配置、事件 payload 和审计快照。
但它不适合承载所有业务逻辑。
一个常见坏味道是:
create table tool_calls (
id uuid primary key,
user_id uuid not null,
data jsonb not null,
created_at timestamptz not null
);
data 里可能包含 tool_id、provider_id、status、cost、search_id、latency_ms、error_code。短期看省事,长期看会有几个问题:
- 查询要写
data->>'tool_id',索引复杂且容易漏。 - 字段类型不稳定,数字可能被写成字符串。
- 数据质量无法靠约束保证。
- 统计和排障都依赖 JSON 解析。
- 重构时不知道谁在用哪个 JSON path。
如果这些字段是核心查询条件或统计维度,就应该独立成列:
create table tool_calls (
id uuid primary key,
user_id uuid not null,
tool_id text not null,
provider_id text not null,
status text not null check (status in ('success', 'failed')),
latency_ms integer,
result_count integer,
search_id uuid,
error_code text,
request_payload jsonb not null,
response_summary jsonb,
created_at timestamptz not null
);
JSON 可以继续存在,但它退回到“扩展载荷”和“原始快照”的位置。
判断一个字段要不要独立成列,可以看五个问题:
| 问题 | 如果答案是是 |
|---|---|
| 会经常作为 where 条件吗? | 独立成列 |
| 会经常排序或分页吗? | 独立成列 |
| 会参与 group by 统计吗? | 独立成列 |
| 需要唯一约束或外键约束吗? | 独立成列 |
| 类型错误会造成业务事故吗? | 独立成列 |
让 AI 设计 JSON 字段时,可以明确:
请把字段分成三类:
1. 必须独立成列的字段。
2. 可以放入 jsonb 的扩展字段。
3. 不应该保存或需要脱敏的字段。
并解释每个独立列对应的查询、约束或统计需求。
只要把这一步做了,表结构就不会变成一个万能杂物箱。
索引要从查询模式倒推
AI 很喜欢在慢查询后说“加索引”。问题是,加什么索引,按什么字段顺序,是否覆盖排序,是否会拖慢写入,都需要从查询模式倒推。
比如工具调用历史页需要查某个用户、某个工具、最近一天的调用:
select id, tool_id, status, latency_ms, created_at
from tool_calls
where user_id = :user_id
and tool_id = :tool_id
and created_at >= :start
order by created_at desc
limit 100;
比较合理的索引是:
create index idx_tool_calls_user_tool_time
on tool_calls (user_id, tool_id, created_at desc);
为什么是这个顺序?user_id 和 tool_id 是等值过滤,created_at 负责范围和排序。数据库可以先定位用户和工具,再按时间倒序扫出 100 条。
如果另一个查询是按 provider 汇总昨天调用量:
select provider_id, count(*)
from tool_calls
where created_at >= :start
and created_at < :end
group by provider_id;
上面的索引就不一定有用。它以 user_id 开头,而这个查询没有用户条件。你可能需要:
create index idx_tool_calls_time_provider
on tool_calls (created_at, provider_id);
或者更实际一点,做每日汇总表:
create table provider_daily_stats (
stat_date date not null,
provider_id text not null,
call_count bigint not null,
success_count bigint not null,
primary key (stat_date, provider_id)
);
索引不是越多越好。每个索引都会增加写入成本、占用磁盘、增加维护开销。低选择性字段单独建索引也不一定有用,比如 status 只有几个值,单列索引经常效果有限。
让 AI 推荐索引时,不能只让它看表结构,要让它看查询:
请基于以下查询模式设计索引:
- 每个查询的 where 条件、order by、limit。
- 预估数据量和选择性。
- 每个索引服务哪些查询。
- 哪些查询不适合靠索引解决,需要汇总表或分区。
- 写入成本和磁盘成本。
能解释字段顺序的索引,才是真正想过的索引。
事务边界不要跨慢操作
事务用来保证一组数据库操作要么一起成功,要么一起失败。它很重要,也很容易被用坏。
一个危险写法:
async with transaction():
order = await create_order()
await reserve_inventory(order)
await call_payment_api(order)
await send_notification(order)
这段代码把外部支付和通知放进数据库事务里。外部调用慢,事务就一直开着;事务开着,锁可能一直持有;连接也一直占用。下游抖动会直接拖住数据库。
更合理的边界是:
async with transaction():
order = await create_order(status="pending_payment")
await reserve_inventory(order)
await outbox.add("payment.requested", {"order_id": order.id})
事务只包住必须一致的数据库写入。支付请求通过 outbox 交给后台任务。支付成功后,再用另一个短事务更新订单状态。
事务边界的原则很朴素:
- 事务里只放必须保持一致的数据库操作。
- 不在事务里做 HTTP/RPC 调用。
- 不在事务里做大文件读写。
- 不在事务里做长时间 CPU 计算。
- 不在事务里等待用户输入或外部任务。
- 事务尽量短,锁尽量少。
这和前面执行模型那篇是连着的。慢操作要离开请求链路,也要离开数据库事务。
让 AI 写涉及事务的代码时,可以要求:
请标出每个数据库事务的开始和结束。
事务内禁止外部 HTTP/RPC、文件 I/O、长时间计算。
说明事务保护的业务不变量是什么。
如果需要调用外部系统,请使用 outbox 或任务表。
如果 AI 不能说明“这个事务保护什么”,那这个事务多半只是为了让代码看起来安全。
锁不是补丁,是协议
并发更新同一份数据时,必须考虑锁或版本控制。锁不是出了 bug 才补的东西,而是多个执行者之间的协议。
比如扣库存。危险写法:
inventory = get_inventory(sku)
if inventory.available < quantity:
raise OutOfStock()
inventory.available -= quantity
save(inventory)
两个请求同时读到库存 10,各扣 8,最后可能超卖。
一种做法是单条原子更新:
update inventories
set available = available - :quantity
where sku = :sku
and available >= :quantity;
检查受影响行数。如果是 1,扣减成功;如果是 0,库存不足。
另一种做法是显式行锁:
select *
from inventories
where sku = :sku
for update;
拿到锁后再判断和更新。行锁适合需要读取多字段、做复杂判断的场景,但锁持有时间要短。
还有乐观锁:
update accounts
set balance = balance + :delta,
version = version + 1
where id = :account_id
and version = :expected_version;
更新失败说明版本变了,需要重读或重试。
几种方式的取舍:
| 方式 | 适合场景 | 风险 |
|---|---|---|
| 原子 update | 简单计数、库存扣减、额度扣减 | 业务逻辑不能太复杂 |
select for update | 需要锁住一行做复杂判断 | 锁等待、死锁、长事务 |
| 乐观锁 version | 冲突不高、读多写少 | 高冲突下重试变多 |
| 唯一约束 | 防重复创建、幂等记录 | 需要处理冲突错误 |
| advisory lock | 跨多行或业务 key 加锁 | 使用不当会变成隐式全局锁 |
让 AI 写并发更新时,可以直接要求:
不能用“先查再改”的非原子方式。
请给出并发安全方案:原子 update、行锁、乐观锁或唯一约束。
说明锁粒度、锁持有时间、死锁风险和重试策略。
请写两个并发请求同时扣减的测试。
并发正确性不能靠“概率上不碰上”。
慢查询是设计问题,不只是 SQL 问题
慢查询常常不是因为 SQL 写得丑,而是访问模式一开始没有设计好。
比如一个后台页面要展示用户列表,每行显示最近一次登录、最近一次购买、订单总数、未读消息数。如果 AI 直接写,很可能是:
users = get_users(page=page)
for user in users:
user.last_login = get_last_login(user.id)
user.order_count = count_orders(user.id)
user.unread_count = count_unread_messages(user.id)
这就是典型 N+1 查询。页面 50 个用户,后面可能跟着 150 次查询。
更合理的方式是批量查询或预聚合:
select
u.id,
u.name,
s.last_login_at,
s.order_count,
s.unread_count
from users u
left join user_summary s on s.user_id = u.id
order by u.created_at desc
limit 50;
user_summary 可以由异步任务维护,也可以由数据库 job 定时刷新。重点是别让列表页临时聚合一堆大表。
慢查询排查要看:
- 是否返回太多列。
- 是否缺索引或索引字段顺序不对。
- 是否排序需要额外 sort。
- 是否 join 了高基数字段但没有合适索引。
- 是否 N+1 查询。
- 是否把聚合放在高频请求里。
- 是否 where 条件用了函数导致索引用不上。
- 是否需要分区、归档或汇总表。
让 AI 优化慢查询时,可以要求它不要只改 SQL:
请先分析这个页面的访问模式:
- 每次请求需要展示哪些字段。
- 每个字段来自哪张表。
- 哪些字段可以异步预聚合。
- 哪些查询必须实时。
- 是否存在 N+1 查询。
- 给出 explain 预期、索引建议和数据量增长后的风险。
很多性能问题要靠数据模型解决,不是靠把 SQL 写得更花。
迁移脚本也要按线上系统写
AI 生成 migration 很快,但线上迁移不是简单 alter table add column。
新增 nullable 字段通常比较安全:
alter table users add column timezone text;
但新增 not null 字段、加默认值、改字段类型、创建大索引、回填历史数据,都可能锁表、拖慢写入或造成长事务。
一个更稳的新增必填字段流程:
- 新增 nullable 字段。
- 新代码开始写入新字段。
- 后台分批回填老数据。
- 验证没有空值。
- 再加
not null约束。
例如:
alter table users add column timezone text;
分批回填不要一口气更新全表:
update users
set timezone = 'Asia/Shanghai'
where timezone is null
and id > :last_id
order by id
limit 1000;
不同数据库对 limit in update 支持不一样,实际要按数据库方言写。关键是分批、小事务、可暂停、可重跑。
创建大索引也要谨慎。PostgreSQL 通常使用:
create index concurrently idx_tool_calls_user_time
on tool_calls (user_id, created_at desc);
concurrently 可以降低锁表风险,但不能放在普通事务块里。迁移工具需要支持这种特殊语义。
让 AI 写 migration 时,可以要求:
这是线上大表迁移,不能长时间锁表。
请给出 expand -> backfill -> contract 三阶段迁移方案。
回填必须分批、可重试、可观测。
创建索引用并发方式,并说明数据库方言限制。
给出回滚策略和验证 SQL。
迁移脚本不是一次性杂活。它是生产系统变更的一部分。
ORM 不能替你理解数据库
ORM 很方便,AI 也很喜欢用 ORM,因为对象关系清楚,代码生成顺手。
但 ORM 会遮住很多数据库事实:
- 一行代码可能触发多次查询。
- 访问一个属性可能 lazy load。
- 批量更新可能变成逐行更新。
- 事务范围可能比想象中更大。
- 默认隔离级别可能不符合业务。
- JSON 字段和复杂索引可能表达不完整。
- 迁移脚本可能和线上数据库方言不匹配。
比如:
orders = session.query(Order).limit(100).all()
for order in orders:
print(order.user.name)
如果 user 是 lazy load,这可能触发 101 次查询。AI 很容易写这种代码,因为它看起来像普通对象访问。
要求 AI 写 ORM 代码时,要把查询行为说清楚:
请避免 N+1 查询。
需要预加载 user,但只取 id/name 两个字段。
请说明生成的 SQL 大致是什么。
批量更新不能逐行 commit。
事务必须在 service 层显式控制。
ORM 是工具,不是数据库知识的替代品。越依赖 AI 生成 ORM 代码,越要让它解释背后的 SQL。
一份给 AI 的数据库设计提示词
写数据层之前,可以先用这段 prompt:
先不要写 ORM model 或 migration。
请为这个功能做数据库设计:
1. 列出核心实体、业务不变量和状态机。
2. 说明哪些字段必须独立成列,哪些可以放 jsonb,哪些不能保存。
3. 给出表结构、主键、唯一约束、check 约束、外键取舍。
4. 列出主要查询模式:where、order by、limit、group by、数据量预估。
5. 为每个查询设计索引,并解释字段顺序和写入成本。
6. 标出事务边界,说明每个事务保护的业务不变量。
7. 分析并发更新场景,选择原子 update、行锁、乐观锁或唯一约束。
8. 说明慢查询风险、N+1 风险、是否需要汇总表或分区。
9. 给出线上迁移方案:expand、backfill、contract、验证和回滚。
10. 最后再写 ORM model、repository 和测试。
这段 prompt 很长,但数据库值得这么长。因为数据结构错了,后面所有代码都会围着它打补丁。
写表前,先问几句话
动手写数据层之前,至少问自己:
- 这个字段以后会不会作为查询条件?
- 这个状态能不能用 check 约束限制?
- 这个唯一性靠代码查,还是靠数据库约束?
- 这个 JSON 字段里有没有核心统计维度?
- 这个查询一年后数据量是多少?
- 这个索引服务哪几个查询,会拖慢哪些写入?
- 这个事务里有没有慢操作?
- 两个请求同时更新同一行时会怎样?
- 这个 migration 会不会锁大表?
- ORM 生成的 SQL 我看过没有?
AI 可以快速写出表、索引、ORM 和 migration。程序员要做的是判断这些东西是否真的表达了业务规则,是否能在并发、增长和线上变更里站得住。
数据库不会因为代码是 AI 写的就变宽容。它只相信约束、事务、索引和真实的数据量。
微信公众号
欢迎关注「字与码」
如果这篇文章对你有用,也欢迎在微信里继续关注后续更新。
X / Twitter
关注 @ax2_zicode
更即时的技术观察、新文章提醒和一些短想法会发在 X 上。