订单号查出了三笔,我以为是数据脏了,其实是自己写错了
前几天翻一个批量对账脚本,里面有一条查订单的 SQL,条件是 where order_no = 123,没加引号。当时扫了一眼没在意,order_no 看着就是个数字,谁没事会给数字加引号。直到后来跑起来发现同一个订单号查出了三条记录,才回头去看这条 SQL 到底出了什么问题。
顺手把这个坑和另一个坑放一起记一下,都是那种语法完全没错、跑起来不报错、结果却是错的类型。环境是 KES V009R001C010:
bash
ksql -h 127.0.0.1 -p 54321 -U app_user -d app_db
订单号不加引号,多查出两条不相干的数据
先照着原来那张表复现一下。订单号存成 varchar,这很常见,因为它可能带前导零、可能带字母前缀,不是纯数字:
sql
create table app_schema.t_trap_order (
id integer primary key,
order_no varchar(20) not null,
amount numeric(10,2) not null
);
insert into app_schema.t_trap_order(id, order_no, amount) values
(1, '00123', 100.00),
(2, '123', 200.00),
(3, '0123', 300.00),
(4, '9999', 400.00);
create index idx_trap_order_no on app_schema.t_trap_order(order_no);
00123、123、0123 这三个订单号是我故意凑的,看着都像"123",实际是三笔完全独立的订单,字符串层面一个字符都不一样。
带引号查一次,先看正常情况:
sql
explain analyze
select * from app_schema.t_trap_order where order_no = '123';

Filter: ((order_no)::text = '123'::text),过滤掉 3 行,剩 1 行,就是 id=2。执行计划是 Seq Scan,不是走索引------这张表就 4 行数据,优化器觉得直接扫一遍比查索引还省事,跟这个坑本身没关系,先别搞混了。这一步没什么意外,字符串比字符串,123 就是 123,00123 不是。
去掉引号,就是我最开始踩的那条:
sql
explain analyze
select * from app_schema.t_trap_order where order_no = 123;

能跑,不报错,但 Filter 变成了 ((order_no)::integer = 123)。KES 把 order_no 这一列转成整数再去跟 123 比,'00123' 转成整数是 123,'123' 是 123,'0123' 还是 123------仨订单号转完全撞一块了。执行计划里写着 rows=3,跟我实际看到的结果对上了:这条 SQL 把三笔订单一起捞出来了。
这种问题比直接报错麻烦。报错至少会被日志和监控揪出来,"多查出两笔看着有点关系的订单"反而没什么动静,尤其是嵌在批量脚本里,多出来的数据可能就跟着一起处理掉了,等发现的时候已经过了好几个环节。
这次实验表小,两条查询都是全表扫描,没法从索引角度对比。但有一点值得记一下:转换函数一旦包住了索引列本身(这里是 (order_no)::integer),普通 B-tree 索引就用不上了,表一大,这种写法不只是数据查错,索引也会跟着报废,变成一次完整的全表扫描。
改起来不难,字段是什么类型就按什么类型写,order_no 是字符串就老老实实加引号。真要按数值比较也不是不行,前提是清楚这么写就意味着 '00123' 和 '123' 要被当成一回事,这是业务决定,不该是手滑漏了个引号带出来的副作用。
查"没下过单的客户",NOT IN 给我整了个空集
另一个坑是查"没有下过订单的客户",用 NOT IN 加子查询,写法上没什么特别的:
sql
create table app_schema.t_trap_customer (
cust_id integer primary key,
cust_name varchar(50) not null
);
create table app_schema.t_trap_order_ref (
order_id integer primary key,
cust_id integer,
order_no varchar(20) not null
);
insert into app_schema.t_trap_customer(cust_id, cust_name) values
(1, 'customer-a'),
(2, 'customer-b'),
(3, 'customer-c');
insert into app_schema.t_trap_order_ref(order_id, cust_id, order_no) values
(1, 1, 'ORD-001'),
(2, null, 'ORD-002-异常订单,客户号缺失');
第二条订单记录的 cust_id 是 NULL,模拟的是那种数据导入漏映射、或者匿名下单没关联上客户号的脏数据,实际项目里这种记录不算罕见。customer-b、customer-c 都没下过单,按理说应该被查出来。
子查询里先把 NULL 过滤掉,没问题:
sql
select cust_id, cust_name
from app_schema.t_trap_customer
where cust_id not in (
select cust_id from app_schema.t_trap_order_ref where cust_id is not null
);

customer-b、customer-c,两行,对的。
但没人会没事往子查询里加个 is not null,尤其是压根不知道订单表里还有这种缺客户号的记录。去掉这个过滤:
sql
select cust_id, cust_name
from app_schema.t_trap_customer
where cust_id not in (
select cust_id from app_schema.t_trap_order_ref
);

0 行。 customer-b、customer-c 全部没了,跟那条异常订单八竿子打不着,也一起被牵连了。
这个我第一次看到是懵的:子查询里就多了一条不相干的记录,怎么就把两个正经客户也带没了。刨根问底是 NOT IN 展开之后的样子------cust_id not in (1, null) 相当于 cust_id <> 1 and cust_id <> null,<> null 这个比较的结果不是真也不是假,是"不知道"。and 链条里只要有一项"不知道",又没有别的项判定为假,整个表达式就是"不知道",WHERE 对"不知道"和"假"一视同仁,全部扔掉。所以子查询里只要混进一个 NULL,NOT IN 对外层每一行的判断都跟着遭殃,不是漏判几行,是全部判定失败。
换成 NOT EXISTS 就没这问题:
sql
select c.cust_id, c.cust_name
from app_schema.t_trap_customer c
where not exists (
select 1 from app_schema.t_trap_order_ref o
where o.cust_id = c.cust_id
);

customer-b、customer-c 又回来了,那条异常订单还在表里,不影响结果。NOT EXISTS 只关心"有没有匹配的行",一条 cust_id 是 NULL 的记录,天然跟谁都匹配不上,也就没法拖累别的判断。我以前一直以为"排除类查询用 NOT EXISTS 而不是 NOT IN"是个写法偏好,这回算是搞明白了,这不是偏好,是子查询里但凡可能出现 NULL,NOT IN 就真的不安全。
写完想到的
这两个坑隔得挺远,一个跟类型有关,一个跟 NULL 的三值逻辑有关,但我踩坑的过程挺像的:都是在干净的测试数据上跑得好好的,换了一批带点"瑕疵"的真实数据------带前导零的订单号、混进一条脏记录的子查询------就翻车了。以后写完 SQL,我打算多问自己一句:这批数据要是不那么干净,这条语句还会按我想的那样跑吗?想不明白就拿几行边界数据实际跑一遍,比等上线以后出问题省心多了。