智能问数生成一份经营简报,往往不是只执行一条 SQL:先查总额,再查分组,最后提取异常明细。如果这些查询之间发生更新,拼接后的答案可能从未对应过数据库中的任何一个完整状态。解决办法不能只是给所有图表写上相同的生成时间;必须让计算边界和发布边界真正对齐。
先排除口径错误,再识别读到了不同版本
可靠的企业 Text-to-SQL要求指标、粒度、权限与时间口径明确。本篇假设这些条件已经一致,只处理另一类故障:多条查询本身正确,但读取的数据库版本不同。它与多表关联造成的重复计数不同,也与分页扫描漏记录不同;验收对象是最终拼成的一整份结果。
下面使用构造的库存表 report_stock:east 行为 100,west 行为 0。一次转移在同一写事务中把 east 改成 0、west 改成 100,再提交。总库存始终为 100。报表先查 east 得到 100,写事务随后提交,报表再查 west 得到 100,拼出的区域合计却为 200。
写入没有暴露半完成状态,也没有把总库存真的变成 200。错误来自报表把旧版 east 与新版 west 混用。若先查总库存得到 100,再从两次分开查询得到两个区域值,页面就会出现“总计 100、明细合计 200”。这些数单独看都可能来自正确执行。
必须先确认报表是否真的要求同一数据视图。有些实时看板允许各卡片独立刷新,但应显示各自的数据边界,不应强求严格对账。经营简报、导出文件或需要总分核对的回答,通常更适合成套固定视图。这里的“通常”是业务设计建议,不是数据库强制规则。
BEGIN 不自动把所有 SELECT 固定在同一快照
PostgreSQL 18 的 Read Committed 是默认隔离级别,普通 SELECT 按语句开始时的已提交状态读取;同一事务内连续两条 SELECT 可以看到不同提交结果。Repeatable Read 则让后续查询使用由首个非事务控制语句建立的同一快照。[1] 因此,只添加 BEGIN 与 COMMIT,并不足以修复前述例子。
对同一数据库的普通表读取,可以由受控执行层在专用连接上使用 BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;,然后依次执行所有相关查询,收齐结果后 COMMIT。事务模式应在查询开始前确定。[2] 这里的 SQL 是流程示意,不能让模型任意插入事务控制、权限变更或未经批准的函数。
连接池是常见的边界破坏点。若第一条查询借用连接 A、第二条又借用连接 B,两个独立事务并不会因为同属一个 HTTP 请求而共享快照。ORM 的事务对象也必须传到每个实际查询,不能只有外层函数声明事务,而内部辅助函数仍使用默认连接。
还要检查自动提交、异常重连与读写分离路由。连接中断后创建的新事务是新边界;不能保留旧总额,只在新事务里补查明细。把多项普通表聚合合并成一条 SQL 可以减少边界数量,但不能把任意外部函数调用或多个数据源都视为同一数据库快照。
并行查询需要共享快照,而不只是同时开始
多个工作者并行读取同一数据库时,分别开启 Repeatable Read 事务仍可能拿到不同快照。PostgreSQL 的快照同步接口允许协调事务调用 pg_export_snapshot(),将返回标识传给其他连接;接收方在符合要求的新事务开始处导入它。[3] “几乎同时开始”只是时间接近,不是共享版本的证据。
协调流程可以是:连接 A 开启只读 Repeatable Read 事务并导出快照;连接 B、C 分别开启同级只读事务,在执行数据查询前用 SET TRANSACTION SNAPSHOT 导入 A 返回的标识;确认导入成功后再并行查询。标识必须使用真实返回值,不能复制文档中的示例值。[2]
导出方至少要保持事务开放,直到所有需要它的工作者完成导入;导出事务结束后,该标识不能再用于新的导入。[3] 工作者已经导入后应在自身事务中完成读取。若一个工作者失联、导入失败或需要新连接,应按整组策略处理,而不是悄悄使用当前快照补齐缺失的一块。
这个接口不是跨数据库、跨集群或跨第三方 API 的通用时间机器,也不把未提交的修改共享给其他事务。普通报表若单连接顺序执行已能满足时限,增加协调者与工作者反而引入连接占用、失败恢复和标识生命周期管理成本。先证明并行有必要,再引入更复杂的方案。
稳定视图不保证数据新鲜,也不保证业务完整
一个稳定快照可能已经落后于当前业务。若报表从只读副本读取,复制进度会影响可见数据;若上游订单、退款分别异步入仓,数据库快照可能稳定地包含已到订单和未到退款。提高本地事务隔离级别不能让缺失的上游数据自动出现。
建议分别记录业务期间、源端同步进度、数据批次及报表计算版本。若要求多个来源共同完成某个批次,应由数据发布机制确认批次完整,再让报表读取已发布版本。给不同来源加上相同的墙上时钟时间,不等于它们具有相同的业务截止范围。
PostgreSQL 文档还区分稳定快照与可串行化语义;Repeatable Read 并不消除所有序列化异常。[1] 对依赖复杂并发业务约束的报表,应与写入事务设计一起评估,不能认为“只读”就无需讨论业务一致性。SERIALIZABLE READ ONLY DEFERRABLE 可在获取合适快照时等待,官方文档将其列为长报表等场景的选项。[2]
这也不是所有报表的默认升级路径。等待成本、写入端的隔离策略、查询涉及的对象和业务不变量都应确认。本文的库存示例把转移放在一个原子写事务中,因此用同一稳定视图即可避免跨版本拼接;若业务本来分两笔提交,中间状态本身可见,读端快照不会修复写端的流程设计。
快照要短,结果要成套发布
长时间持有快照可能让数据库继续保留仍可能被事务看到的旧行版本。PostgreSQL 的 VACUUM 文档说明,这些版本在仍可能可见时不能清除。[4] 因而不应在数据库事务内等待模型撰写长文、用户点击或外部服务长时间响应。
可先在受控事务中完成必要读取,保存一组经过校验的结果及元数据,结束事务,再让模型基于这份固定结果生成说明。保存时按权限与保留规则控制数据量。固定查询结果可以支持事后核对,但只保存 SQL、时间戳或一个已经失效的导出快照标识,并不能保证将来重现原数据。
用同一 report_run_id 关联全部卡片、明细、指标版本和授权范围,并先写入未公开的暂存版本。只有查询全部完成、关键合计通过核对且解释引用正确后,才把整份结果切换为可见。这里的发布切换是应用层设计,数据库快照不会自动替应用完成。
若某个查询超时,应返回明确的不完整状态,或废弃未交付版本后按同一任务参数重算整组。不能把旧版总额与新查明细拼接后继续沿用原版本号。若业务允许部分交付,应列出缺失项及各自数据边界,让读者知道哪些项目不能相互核对。
用两个会话复现,再检查真实连接路径
验收可在隔离测试库建立上述两行库存。会话 R 开启 Read Committed 事务,查询 east=100;会话 W 在一个事务里完成 east=0、west=100 并提交;R 再查 west,应能观察到 100。将 R 改为 Repeatable Read,并确保第一次读在 W 提交前建立快照,第二次读取 west 应仍为 0,区域合计为 100。
另一组测试让 W 在 R 建立快照前已经提交,则 R 应得到 east=0、west=100。两种结果均可正确,关键是不能跨版本组合。这是基于文档的可复现实验设计与预期,本文未声称已在读者的数据库或生产系统运行。
随后故意让两个查询走不同连接,或在第二个查询前断线重连,检查应用能否识别整组边界已失效。并行方案增加导出事务提前关闭、工作者导入失败及部分结果已保存的场景。验证不只是值相等,还要记录实际连接归属、隔离级别、查询顺序和失败处置。
最后测试上游迟到、查询口径变化、缓存返回旧结果与解释生成失败。应用应拒绝跨 report_run_id 或授权范围混用结果,缓存键也必须包含有效的数据与口径版本。快照一致、权限正确、批次完整、解释忠实是四项独立检查;只通过其中一项,不能宣称整份经营报告已经可信。
