news 2026/7/16 11:22:48

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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_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;

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/16 11:21:24

CANN/asc-devkit:RegLayout寄存器布局

RegLayout 【免费下载链接】asc-devkit 本项目是CANN 推出的昇腾AI处理器专用的算子程序开发语言&#xff0c;原生支持C和C标准规范&#xff0c;主要由类库和语言扩展层构成&#xff0c;提供多层级API&#xff0c;满足多维场景算子开发诉求。 项目地址: https://gitcode.com/…

作者头像 李华
网站建设 2026/7/16 11:20:54

魔兽争霸3终极优化指南:让你的经典游戏在现代电脑上焕然新生

魔兽争霸3终极优化指南&#xff1a;让你的经典游戏在现代电脑上焕然新生 【免费下载链接】WarcraftHelper Warcraft III Helper , support 1.20e, 1.24e, 1.26a, 1.27a, 1.27b 项目地址: https://gitcode.com/gh_mirrors/wa/WarcraftHelper 魔兽争霸3作为一代经典RTS游戏…

作者头像 李华
网站建设 2026/7/16 11:19:56

AR桌面开发实战:Unity3D实现与性能优化

1. AR桌面(launcher)实现背景与技术选型2023年12月28日这个时间节点&#xff0c;AR技术在消费级设备上的应用已经进入爆发期。不同于传统手机launcher的二维平面交互&#xff0c;AR桌面需要解决三维空间中的界面布局、手势识别和虚实融合等核心问题。我在实际开发中发现&#x…

作者头像 李华
网站建设 2026/7/16 11:19:51

Codex + Git worktree:AI开发环境的配置即代码实践

1. 为什么“用顺”和“真正高手”之间隔着一个 worktree Codex App Windows 版本上线后&#xff0c;我看到太多人卡在同一个地方&#xff1a;装好了、能打开、能连上模型、写两行代码也跑得通——但只要项目一复杂&#xff0c;立刻陷入泥潭。改个配置要重启整个应用&#xff0c…

作者头像 李华
网站建设 2026/7/16 11:19:50

告别手动编写!openeuler/splitter gen命令自动生成SDF文件教程

告别手动编写&#xff01;openeuler/splitter gen命令自动生成SDF文件教程 【免费下载链接】splitter Splitting openEuler packages into multiple slices for building distroless images. 项目地址: https://gitcode.com/openeuler/splitter 前往项目官网免费下载&am…

作者头像 李华
网站建设 2026/7/16 11:19:35

WorkBuddy 终极教程(上):从安装到跑通第一个任务

本文配套录屏教程&#xff0c;建议边看文章边操作。每个步骤都标注了需要截图或录屏的位置&#xff0c;照着做就行。 写在前面 我花了半个月时间把 WorkBuddy 翻来覆去地用&#xff0c;从安装配置到自动化任务&#xff0c;踩了不少坑。这篇把我踩过的坑和摸出来的经验一次性整…

作者头像 李华