数据库不是存 JSON 的地方
原创 · 约 29 分钟阅读 · 阅读 --

数据库不是存 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_idprovider_idstatuscostsearch_idlatency_mserror_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_idtool_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 字段、加默认值、改字段类型、创建大索引、回填历史数据,都可能锁表、拖慢写入或造成长事务。

一个更稳的新增必填字段流程:

  1. 新增 nullable 字段。
  2. 新代码开始写入新字段。
  3. 后台分批回填老数据。
  4. 验证没有空值。
  5. 再加 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 写的就变宽容。它只相信约束、事务、索引和真实的数据量。