场景:有待处理订单,补偿任务却扫不到

补偿任务从订单表里找出“尚未有成功回执”的订单。原先使用 NOT IN (SELECT order_id ...),导入一条未关联订单的回执后,任务突然返回零行,接口没有报错,查询耗时也正常。

下面是隔离复现,不代表真实生产事故。SQL 按 PostgreSQL 17 编写,使用会话临时表,连接关闭后消失。可在测试库的同一个 psql 会话中逐段执行。本文不讨论把查询结果直接用于扣款;涉及外部副作用还需要幂等和并发控制。

1. 先复现,再检查执行计划

CREATE TEMP TABLE order_candidates (
    tenant_id integer NOT NULL,
    order_id bigint NOT NULL,
    PRIMARY KEY (tenant_id, order_id)
);
CREATE TEMP TABLE delivery_receipts (
    receipt_id bigint PRIMARY KEY,
    tenant_id integer NOT NULL,
    order_id bigint,
    status text NOT NULL
);

INSERT INTO order_candidates VALUES (10, 101), (10, 102), (10, 103);
INSERT INTO delivery_receipts VALUES
    (1, 10, 101, 'succeeded'),
    (2, 10, NULL, 'succeeded'),
    (3, 10, 102, 'failed'),
    (4, 20, 103, 'succeeded');

SELECT o.order_id
FROM order_candidates AS o
WHERE o.tenant_id = 10
  AND o.order_id NOT IN (
      SELECT r.order_id
      FROM delivery_receipts AS r
      WHERE r.tenant_id = 10 AND r.status = 'succeeded'
  )
ORDER BY o.order_id;

预期业务结果是 102、103:102 只有失败回执,103 的成功回执属于另一个租户。实际原查询返回零行。此时先查筛选条件和输入数据,扩大连接池、刷新统计信息、增加索引都不能修复判断逻辑。

2. 把“未知”直接显示出来

SELECT
    101 NOT IN (101, NULL) AS matched_result,
    102 NOT IN (101, NULL) AS unmatched_result,
    (102 NOT IN (101, NULL)) IS UNKNOWN AS is_unknown;

三个字段依次是 false、NULL、true。不要把 psql 默认显示的空白当成空字符串;它可能是 NULL,可用 IS UNKNOWN 明确验证。

没有匹配值时,右侧 NULL 会让 NOT IN 得到未知;WHERE 只保留 true,因此这些行也被过滤。已经匹配到 101 的行仍然是 false。PostgreSQL 子查询表达式文档说明了这一边界。

生产定位时,检查的必须是相同过滤条件下的子查询结果,不能只统计整张表:

SELECT
    count(*) AS receipt_count,
    count(order_id) AS linked_count,
    count(*) FILTER (WHERE order_id IS NULL) AS unlinked_count
FROM delivery_receipts
WHERE tenant_id = 10 AND status = 'succeeded';

复现数据应得到 2、1、1。若线上结果为零个 NULL,再检查左侧是否可空、子查询是否通过外连接或表达式产生 NULL,以及应用绑定参数是否包含 NULL。多租户筛选、状态筛选遗漏是另一类独立缺陷,不能只替换一个 SQL 关键字就结束排查。

3. 按业务关系改成 NOT EXISTS

目标是“当前租户的当前订单不存在成功回执”,直接表达这个关系:

SELECT o.order_id
FROM order_candidates AS o
WHERE o.tenant_id = 10
  AND NOT EXISTS (
      SELECT 1
      FROM delivery_receipts AS r
      WHERE r.tenant_id = o.tenant_id
        AND r.order_id = o.order_id
        AND r.status = 'succeeded'
  )
ORDER BY o.order_id;

此查询应返回 102、103。没有关联订单的回执无法满足等值条件;失败回执也不匹配;其他租户相同订单号不会影响当前租户。SELECT 1 表达这里只关心有没有匹配行。

也可以在原子查询中加上 AND r.order_id IS NOT NULL。在本例左侧非空、业务把未关联回执视为无效匹配的前提下,它同样得到 102、103。若选择保留 NOT IN,应把这两个前提写进模型和测试,避免未来字段可空后再次出现遗漏。

不要使用 COALESCE(order_id, -1) 临时替换 NULL:哨兵值可能与合法数据冲突,也掩盖了字段语义。未关联回执可以合法存在于导入暂存阶段,不能未经业务确认就删行或强行加非空约束。

4. 修复前确认左侧 NULL 的处理规则

本例主键保证订单号非空。如果原查询左侧是可空字段,NOT EXISTS 与 NOT IN 不是无条件等价替换:

WITH candidates(order_id) AS (VALUES (101::bigint), (NULL::bigint)),
     receipts(order_id) AS (VALUES (101::bigint))
SELECT c.order_id
FROM candidates AS c
WHERE NOT EXISTS (
    SELECT 1 FROM receipts AS r WHERE r.order_id = c.order_id
);

该查询会保留 NULL 候选,因为普通等值比较不能匹配它。若业务要求仅处理有订单号的候选,显式加上 c.order_id IS NOT NULL;若业务真的把两个 NULL 视为同一键,PostgreSQL 可使用 IS NOT DISTINCT FROM,但这应由业务定义决定,不能为了得到想要的数量而修改。

5. 数据量大时,再验证计划和索引

临时表上可运行以下演练:

CREATE INDEX delivery_receipts_success_lookup
ON delivery_receipts (tenant_id, order_id)
WHERE status = 'succeeded';

ANALYZE order_candidates;
ANALYZE delivery_receipts;

EXPLAIN (ANALYZE, BUFFERS)
SELECT o.order_id
FROM order_candidates AS o
WHERE o.tenant_id = 10
  AND NOT EXISTS (
      SELECT 1 FROM delivery_receipts AS r
      WHERE r.tenant_id = o.tenant_id
        AND r.order_id = o.order_id
        AND r.status = 'succeeded'
  );

这里的索引只包含成功回执,键对应租户和订单关联条件。小表选择顺序扫描很正常;不要看到 Seq Scan 就认定索引失效。真实数据上关注实际行数、loops、缓冲区读取量和总耗时,比较相同数据范围的修复前后结果。可能出现 Hash Anti Join 或 Nested Loop Anti Join,不能承诺某一种计划。

EXPLAIN ANALYZE 会执行查询,相关行为见官方 EXPLAIN 文档。线上先用不带 ANALYZE 的 EXPLAIN,再在可控数据范围验证;建生产索引需评估现有索引、写入成本和上线窗口,不能照搬临时表的普通 CREATE INDEX 到忙碌大表。

6. 把遗漏场景留在回归测试里

建议至少固定下面的输入和预期,断言订单集合,而不是只比较数量:

场景 预期
成功回执为 101、NULL 返回 102、103
成功回执只有 101 返回 102、103
没有成功回执 返回 101、102、103
成功回执只有 NULL 返回 101、102、103
101 有重复成功回执 仍只返回 102、103
102 只有失败回执 102 不被排除
103 的成功回执属于其他租户 103 不被排除
候选键可空 按明确业务规则保留或排除 NULL

上线后,监控候选扫描数量、实际处理数量和未关联成功回执数量。扫描数量骤降为零需要结合业务流量基线告警,不能认为“无异常日志”就意味着任务正常。

回滚时保留修复前 SQL 和结果对照,必要时暂停补偿任务;不要把恢复有缺陷的查询作为默认措施。查询恢复后还要核对故障窗口中漏处理的订单,使用原有幂等机制补跑,避免重复产生副作用。

总结

排除查询返回零行时,先检查子查询里有没有 NULL,再明确租户、状态和可空键的业务含义。用关联 NOT EXISTS 表达“不存在匹配记录”,验证结果集合后再优化执行计划,才能同时解决遗漏与性能问题。

验证范围:关键语义已依据 PostgreSQL 17 官方文档核对;基础查询另用本地 SQLite 做了 NULL、空集合、重复值和租户隔离的结果回归。未连接 PostgreSQL 实例运行上述会话脚本或执行计划,SQLite 验证不代表 PostgreSQL 计划与性能验证。