《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;不同数据库对limitin 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 写的就变宽容。它只相信约束、事务、索引和真实的数据量。