查询先于假设
功能完全失效
数据库迁移执行成功。所有验证查询都通过。文档记录完整。
第二天,所有 AI 生成功能停止工作。图片生成失败,视频生成失败,文本生成失败。用户看到的错误消息是:"服务暂时不可用。"
问题不在迁移的代码。问题在假设。
假设的结构
迁移 048 的目标是统一积分系统,从双系统(credits + video_credits)合并为单系统(credits)。这是一个合理的简化。
迁移脚本执行了这些操作:
- 删除
video_credits列 - 删除三个旧函数
- 修改
charge_credits_for_job函数,移除credit_type参数
文档写道:"旧函数已删除,验证查询显示 0 个旧函数残留。"
但真实情况是:charge_credits_for_job 函数有两个版本。Migration 006 创建的版本(4 个参数,返回 BOOLEAN),和 Migration 048 创建的新版本(3 个参数,返回 JSON)。
DROP FUNCTION charge_credits_for_job 没有指定函数签名,PostgreSQL 不知道要删除哪个版本。两个版本都保留了下来。
代码调用时使用 3 个参数,匹配到新版本。但新版本依赖的其他函数没有正确更新,导致整个调用链断裂。
这是假设制造的裂缝。假设删除命令会删除所有版本。假设验证查询能检测到所有问题。假设文档反映了真实状态。
PostgreSQL 允许同名函数有不同的参数签名。这是函数重载(overloading)。
对于开发者来说,这意味着:
DROP FUNCTION func_name可能失败(如果有多个版本)- 必须使用完整签名:
DROP FUNCTION func_name(param_types) - 或者使用
CASCADE强制删除所有版本
函数重载在设计时很方便,但在迁移时很危险。它让删除操作变得不明确。
为什么会假设
假设不是懒惰,是认知的捷径。
当你写完迁移脚本,运行验证查询,看到"0 个旧函数残留",大脑会说:"问题解决了。"这不是逻辑推理,是模式匹配。你见过这个模式很多次:写脚本 → 执行 → 验证 → 成功。
但这个模式有一个隐藏前提:验证查询能检测到所有相关问题。
如果验证查询只检查函数名,不检查函数签名,它会漏掉重载版本。如果验证查询只检查显式依赖,不检查调用链,它会漏掉间接依赖。
假设发生在这个缝隙里。你以为验证了所有问题,实际上只验证了部分问题。
这和 缺失值的传播 类似。一个假设会产生下一个假设,直到整个理解链条断裂。
查询的力量
真相只在一个地方:数据库本身。
不在文档里。文档可能过时,可能有误,可能基于旧的理解。
不在记忆里。人的记忆会重构,会简化,会遗忘细节。
不在代码里。代码表达意图,但数据库表达结果。意图和结果可能不一致。
唯一可靠的是查询:
-- 检查所有版本的 charge_credits_for_job 函数
SELECT
routine_name,
routine_type,
data_type,
array_agg(parameter_name || ' ' || data_type) as parameters
FROM information_schema.routines r
LEFT JOIN information_schema.parameters p
ON r.specific_name = p.specific_name
WHERE routine_name = 'charge_credits_for_job'
GROUP BY routine_name, routine_type, data_type;
这个查询会返回所有版本的函数,包括参数列表和返回类型。它不会说谎,不会遗忘,不会简化。
💡 Click the maximize icon to view in fullscreen
以查询为荣
"以认真查询为荣,以瞎猜接口为耻。"
这不是口号,是工程实践的原则。
每次迁移后,查询数据库的实际状态:
- 列是否真的删除了?
- 函数是否只保留了新版本?
- 依赖对象是否更新了?
- 约束是否仍然有效?
不要依赖脚本的执行日志。日志说"DROP FUNCTION 成功",不代表所有版本都删除了。
不要依赖验证脚本的输出。验证脚本可能有盲区。
唯一可靠的是直接查询。查询函数定义,查询依赖关系,查询调用链。
Model Context Protocol (MCP) 提供了查询数据库的标准接口。Supabase MCP 工具可以直接查询 PostgreSQL 的系统表。
关键查询命令:
1. 查询所有函数(包括签名)
// 通过 MCP 工具执行
mcp__supabase__query({
query: `
SELECT routines.routine_name,
routines.data_type as return_type,
string_agg(
parameters.parameter_name || ' ' ||
parameters.data_type, ', '
ORDER BY parameters.ordinal_position
) as parameters
FROM information_schema.routines
LEFT JOIN information_schema.parameters
ON routines.specific_name = parameters.specific_name
WHERE routines.routine_schema = 'public'
GROUP BY routines.routine_name, routines.data_type
ORDER BY routines.routine_name;
`
})
2. 检查函数定义(查看实际 SQL)
mcp__supabase__query({
query: `
SELECT pg_get_functiondef(oid) as definition
FROM pg_proc
WHERE proname = 'charge_credits_for_job';
`
})
3. 查询函数依赖关系
mcp__supabase__query({
query: `
SELECT DISTINCT dependent_ns.nspname as dependent_schema,
dependent_view.relname as dependent_view,
source_ns.nspname as source_schema,
source_table.relname as source_table
FROM pg_depend
JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid
JOIN pg_class as dependent_view ON pg_rewrite.ev_class = dependent_view.oid
JOIN pg_class as source_table ON pg_depend.refobjid = source_table.oid
JOIN pg_namespace dependent_ns ON dependent_ns.oid = dependent_view.relnamespace
JOIN pg_namespace source_ns ON source_ns.oid = source_table.relnamespace
WHERE source_table.relname = 'profiles';
`
})
这些查询揭示数据库的真实状态,不依赖假设或文档。
参考 let-errors-surface 了解如何在构建时暴露问题,而非掩盖。查询数据库是运行时版本的"暴露问题"。
验证的层次
验证不是一次性的检查,而是多层次的确认。
第一层:语法验证 迁移脚本能否执行?SQL 语法是否正确?
第二层:对象验证 目标对象是否存在?旧对象是否删除?
第三层:依赖验证 依赖对象是否更新?是否有残留引用?
第四层:功能验证 端到端流程是否工作?API 调用是否成功?
大多数迁移只做到第一层和第二层。Migration 048 做到了第二层,但在第三层失败了。
第三层需要查询依赖关系。哪些函数调用了 charge_credits_for_job?哪些表被 video_credits 列引用?哪些视图依赖这些对象?
这些查询不难写,但需要意识到它们的必要性。
迁移 049 的修复
修复的方法不是修补,而是重建。
-- 强制删除所有版本
DROP FUNCTION IF EXISTS charge_credits_for_job(uuid, uuid, integer, text) CASCADE;
DROP FUNCTION IF EXISTS charge_credits_for_job(uuid, uuid, integer) CASCADE;
-- 重建唯一版本
CREATE OR REPLACE FUNCTION charge_credits_for_job(
p_job_id uuid,
p_user_id uuid,
p_credit_cost integer
)
RETURNS json
LANGUAGE plpgsql
AS $$
BEGIN
-- 完整实现...
END;
$$;
关键是 CASCADE。它强制删除所有依赖关系,确保没有残留。然后重建干净的版本。
修复后,再次查询:
SELECT count(*) FROM pg_proc WHERE proname = 'charge_credits_for_job';
-- 结果:1(唯一版本)
这是验证的真相。不是假设有一个版本,而是查询确认只有一个版本。
Migration 048(2025-10-09)
- 目标:统一积分系统,删除
video_credits - 执行:删除列,删除旧函数,修改
charge_credits_for_job - 假设:所有旧函数已删除
- 结果:功能完全失效(2025-10-10 发现)
问题根源
DROP FUNCTION charge_credits_for_job未指定签名- Migration 006 的旧版本(4 参数)仍然存在
create_job_and_charge_credits函数未更新,仍引用video_credits- 验证查询不够全面,未检测到函数重载
Migration 049(2025-10-10)
- 修复:强制删除所有版本的函数(使用 CASCADE)
- 重建:统一函数定义,确保使用
credits字段 - 验证:通过 MCP 查询函数定义,确认不包含
video_credits - 结果:所有功能恢复
核心教训
- 函数重载需要显式签名删除
- 验证查询必须检查函数签名和定义
- 文档必须基于查询结果,非假设
- 迁移后立即进行端到端功能测试
详细的技术文档可以在项目的 migrations/ 目录中找到。
文档的限制
文档记录意图,查询记录现实。
Migration 048 的文档说:"旧函数已删除。"这是基于脚本的意图。脚本写了 DROP FUNCTION,所以假设函数删除了。
但 PostgreSQL 有自己的规则。DROP FUNCTION 不指定签名,在有函数重载时会失败或行为不明确。文档没有反映这个细节。
这不是文档的错。文档不可能预见所有边界情况。但文档的限制决定了它不能作为唯一的真相来源。
真相只在数据库中。文档是辅助,查询是验证。
参考 from-intent-to-architecture 了解意图和实现的鸿沟。查询数据库是弥合这个鸿沟的方法。
假设的代价
Migration 048 的假设导致所有 AI 生成功能失效。这不是小问题。
对用户来说,这意味着服务不可用。对团队来说,这意味着紧急修复。对系统来说,这暴露了验证流程的不足。
代价不只是停机时间。代价是信任。用户会想:"为什么测试没发现这个问题?"团队会想:"为什么迁移验证通过了?"
这些问题的答案是:测试和验证依赖假设。假设删除命令会成功。假设验证查询足够全面。假设文档反映现实。
假设的代价是隐形的,直到它爆发。
查询的文化
从假设到查询,不只是技术改进,是文化转变。
假设的文化说:"如果脚本没报错,应该就没问题。"
查询的文化说:"让我确认一下数据库的实际状态。"
假设的文化依赖信任:信任工具,信任流程,信任文档。
查询的文化依赖验证:不管工具说什么,查询数据库确认。
这不是不信任,是工程严谨性。飞行员在起飞前会检查所有仪表,不是因为不信任维护团队,而是因为检查是必要的安全措施。
查询数据库是同样的安全措施。
最后
数据库不会说谎。
它不会简化真相,不会隐藏细节,不会因为方便而调整事实。它只是如实呈现状态。
假设会说谎。不是故意的,是因为假设填补了我们不知道的空白。我们假设删除命令成功了,假设验证查询全面了,假设文档是最新的。
工程的质量,不在于避免所有假设,而在于用查询替代关键假设。
迁移数据库?查询结果。修改函数?查询签名。删除依赖?查询引用。
每次查询都是一次确认。每次确认都是一次防御。
从假设到查询,是从模糊到清晰,从脆弱到可靠,从侥幸到确定。
查询先于假设,因为真相先于期望。
Related Posts
Articles you might also find interesting
执行数据库迁移的三种路径
CLI、MCP 与线上 SQL——每种方法背后的权衡与适用场景。迁移不只是执行命令,更是选择控制权与便利性之间的平衡点。
数据库参数国际化:从 13 个迁移学到的设计原则
数据不该懂语言。当数据库参数嵌入中文标签时,系统的边界就被语言限制了。这篇文章从 13 个参数对齐迁移中提炼出设计原则——国际化不是功能,是系统设计的底层约束。
用 AI Agents 加速测试环境配置
测试环境的配置是重复的琐事。环境变量、测试数据库、配置文件——这些步骤消耗时间但不产生直接价值。AI agents 改变了这个等式。
在Claude Code中写单元测试:简单高效的实践
测试不是负担,是对话。Claude Code改变了测试的成本结构,让测试回归本质:验证行为,而非追求覆盖率。
CRUD 操作
四个字母背后,是数据的生命周期,是权限的边界,也是系统设计的基础逻辑
诊断 Supabase 连接失败:借助 MCP 工具链
连接失败不仅是配置问题,更是关于理解系统状态边界的过程。通过 Supabase MCP 与 Claude Code,让不可见的问题变得可观测。
Git Hooks 驱动的文档同步
文档不会自动更新,除非你让它自动更新。Git Hooks 是最接近代码变更的触发点,也是对抗文档腐烂最有效的位置。
单例模式管理 Redis 连接
连接不是技术细节,而是系统与外部世界的第一次握手,是可靠性的起点
使用Jest或Vitest作为测试框架有什么区别?
测试框架的选择不是功能列表的比较,而是关于工具哲学的选择。Jest代表完整性,Vitest代表原生性。
分层修复
生产问题没有银弹。P0 止血,P1 加固,P2 优化。优先级不是排序,而是在不确定性下的决策框架。
用 MCP 让 Claude Code 执行 Prisma 迁移
借助 Model Context Protocol,Claude Code 可以直接操作 Supabase 云数据库,完成 Prisma schema 的迁移和部署
缺少Jest依赖时的测试选择
测试不一定需要框架。有时候最简单的工具已经足够。Node.js内置的assert模块和基础测试能力,往往比庞大的测试框架更清晰。
PostgreSQL 原生不支持直接添加枚举值
当一个类型系统拒绝改变,它在保护什么?