AI时代的程序员修养:数据库不是存 JSON 的地方

📅 2026/7/16 11:22:57
AI时代的程序员修养:数据库不是存 JSON 的地方
《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(statuspending_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(pagepage) 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)这就是典型 N1 查询。页面 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 了高基数字段但没有合适索引。是否 N1 查询。是否把聚合放在高频请求里。是否 where 条件用了函数导致索引用不上。是否需要分区、归档或汇总表。让 AI 优化慢查询时可以要求它不要只改 SQL请先分析这个页面的访问模式 - 每次请求需要展示哪些字段。 - 每个字段来自哪张表。 - 哪些字段可以异步预聚合。 - 哪些查询必须实时。 - 是否存在 N1 查询。 - 给出 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 代码时要把查询行为说清楚请避免 N1 查询。 需要预加载 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. 说明慢查询风险、N1 风险、是否需要汇总表或分区。 9. 给出线上迁移方案expand、backfill、contract、验证和回滚。 10. 最后再写 ORM model、repository 和测试。这段 prompt 很长但数据库值得这么长。因为数据结构错了后面所有代码都会围着它打补丁。写表前先问几句话动手写数据层之前至少问自己这个字段以后会不会作为查询条件这个状态能不能用 check 约束限制这个唯一性靠代码查还是靠数据库约束这个 JSON 字段里有没有核心统计维度这个查询一年后数据量是多少这个索引服务哪几个查询会拖慢哪些写入这个事务里有没有慢操作两个请求同时更新同一行时会怎样这个 migration 会不会锁大表ORM 生成的 SQL 我看过没有AI 可以快速写出表、索引、ORM 和 migration。程序员要做的是判断这些东西是否真的表达了业务规则是否能在并发、增长和线上变更里站得住。数据库不会因为代码是 AI 写的就变宽容。它只相信约束、事务、索引和真实的数据量。