ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

数据库两表比对:NOT EXISTS、JOIN、EXCEPT与NULL陷阱

数据库两表比对:NOT EXISTS、JOIN、EXCEPT与NULL陷阱 两表数据比对这件事写起来简单真上手才知道坑不少。前阵子帮朋友收拾一个数据库课程设计的收尾工作两张结构完全一样的订单表——一张是源库导出的快照一张是同步工具写进来的目标表跑完对完总行数严丝合缝可抽查明细就是有几条对不上号折腾了两个多小时才发现是 NOT IN 撞上 NULL整个结果集被悄悄干成了空。这种场景在数据校验、异构库迁移、定时对账里实在太常见所以我把平时用得最多的三种写法整理出来把各自的执行逻辑、性能表现、适用边界还有最阴险的那几个坑全部摊开讲一遍。全文围绕“数据库两表数据差异”这条主线覆盖从动手前的边界确认、三种写法的逐层拆解、横向选型对比到一套可以直接抄作业的完整实操流程再到踩坑速查表。不管你是刚开始做数据库课程设计、第一次写对账脚本的学生还是天天跟 MySQL、Oracle、PostgreSQL、达梦打交道的老手都能从里面找到能直接落地的部分。三种写法本身都不复杂真正拉开差距的是对 NULL、索引和数据量的处理细节这也正是下面重点要讲清楚的地方。1. 动手之前先把“比对”这件事想清楚很多人拿到需求就急着敲 SQL结果写出来的语句要么慢得离谱要么结果看着对、实则漏。两表比对的本质是集合运算——把两张表看成两个集合我们要找的是它们的差集、交集或者元素内部属性的差异。数学上很干净但落到关系型数据库里NULL、重复行、排序规则这些东西会立刻把水搅浑。所以真正开写之前有三个前提必须先确认否则后面全是返工。1.1 三种典型场景决定了写法选型先别急着选写法先搞清楚你要解决的是哪一种需求不同场景对应的最优解差别很大。只想知道“哪几行对不上”典型场景是数据同步后的行级校验。你只需要一份差异行清单不关心具体哪个字段不同。这种需求用集合运算符或者差集查询最快。要定位“哪一行的哪个字段对不上”对账系统、数据修复场景常见。这时候光找行不够得把字段一个个拎出来比外连接加上逐字段判断更合适。两表结构不一致需要先做字段映射源库和目标库列名、类型都不完全一样得先用表达式把两边“拉平”再比。这种情况集合运算符基本用不了只能靠手工对齐的 JOIN。我自己判断的标准很土但好用如果两张表是用同一个建表语句出来的优先考虑 EXCEPT / MINUS 这类集合运算只要有一列名字或类型对不上就老老实实走 JOIN 系。1.2 三个必须先确认的前提条件第一主键或唯一键是什么。两表比对必须有一个能唯一标识一行的东西通常是主键或者业务上的联合唯一键。没有它你连“这一行是同一条记录”都定义不了只能整行比对。真遇到没有唯一键的表就用所有业务列拼一个联合键但要清楚这会导致“某行有差异”时无法定位到具体记录。第二字段的 NULL 语义。这是最容易翻车的地方。数据库里的 NULL 不是空字符串也不是 0任何与 NULL 的比较结果都是 UNKNOWN不是 TRUE 也不是 FALSE。这直接决定了 NOT IN 会不会返回空集也决定了两个字段比对时该不该用 COALESCE 包一层。第三数据类型、字符集和排序规则是否一致。一边是 VARCHAR2(50)、一边是 VARCHAR(50)一边 utf8mb4、一边 utf8排序规则一边区分大小写、一边不区分这些都会让你误判出成片的“假差异”。做之前先用SELECT COUNT(*)和抽样几行核对一下比事后 debug 省事得多。提示做任何正式比对之前先用两条SELECT COUNT(*)确认两边总行数。行数差得离谱说明同步根本没跑完这时候去研究写法的性能是浪费时间。1.3 一个容易被忽略的准备动作给比对列建索引不管最后选哪种写法只要数据量上万比对列上有没有索引执行时间可能是几十毫秒和几十秒的差别。尤其 JOIN 的关联键、NOT EXISTS 子查询里的关联条件都强烈建议有索引覆盖。这一步在测试环境经常被忽略因为数据量小感觉不出来一上线就原形毕露。2. 写法一NOT EXISTS 与 NOT IN 的差集查询这是最符合直觉的一种写法也是绝大多数人第一个想到的方案。核心思想很直接从 A 表里挑出那些“在 B 表里找不到对应记录”的行。看似只有一行 WHERE 条件但里面藏着的门道一点不少。2.1 最直观的那一版写法假设我们有两张订单表源表orders_src目标表orders_tgt都以order_id为主键。要找“源表有、目标表没有”的订单第一反应往往是这样-- 找出源表有但目标表没有的订单号 SELECT s.order_id FROM orders_src s WHERE s.order_id NOT IN (SELECT t.order_id FROM orders_tgt t);这个写法的可读性无可挑剔一眼就能看懂在干什么。但它的隐患也恰恰藏在 Subquery 里。很多人线上跑出“空结果”排查半天数据最后发现根本不是数据问题而是这一行 SQL 的 NULL 语义在作祟。2.2 NOT IN 的 NULL 陷阱坑过太多人NOT IN展开之后本质上等价于一连串的比较再取 AND。问题在于只要子查询返回的结果集里出现任何一个 NULL整个NOT IN的比较结果就永远是 UNKNOWNWHERE会把它过滤掉最终返回空集。换句话说你明明知道有几千条差异行SQL 却告诉你“一条差异都没有”。我见过太多次因为这个误判把“同步成功”当成结论报上去的案例。更气人的是如果子查询结果里没有 NULL这段 SQL 又跑得好好的于是它变成了一个“有时对有时错”的定时炸弹。-- 只要有这么一行上面的 NOT IN 就彻底失效 INSERT INTO orders_tgt (order_id) VALUES (NULL);规避方式有两种一是在子查询里加WHERE t.order_id IS NOT NULL把 NULL 显式过滤掉二是干脆换用 NOT EXISTS。前者能救急但要求你永远记得这个前提不如后者省心。2.3 NOT EXISTS 为什么更稳把上面那句改写一下-- 找出源表有但目标表没有的订单号 SELECT s.order_id FROM orders_src s WHERE NOT EXISTS ( SELECT 1 FROM orders_tgt t WHERE t.order_id s.order_id );这段写法对 NULL 是天生的免疫。EXISTS 只关心子查询里“能不能查出一行”返回的是布尔值不参与 NULL 的数值比较所以子查询里有没有 NULL 都不影响结果。这一点是它相对 NOT IN 最大的优势也是生产环境里我更推荐它的核心原因。从执行计划看现代数据库MySQL 8.0、PostgreSQL、Oracle 等通常会把 NOT EXISTS 优化成反连接anti-join也就是针对外表每一行去内表探测一次是否命中命中就丢弃、没命中就保留。这个过程中如果t.order_id上有索引探测是非常快的整体可以近似看成线性复杂度。2.4 双向差异怎么一次跑完上面的写法只能找出“源表多出来的行”。对账场景往往还需要知道“目标表多出来的行”也就是反方向。做法是把两个方向 UNION ALL 起来-- 双向差异左表独有 右表独有 SELECT src_only AS diff_type, s.order_id FROM orders_src s WHERE NOT EXISTS (SELECT 1 FROM orders_tgt t WHERE t.order_id s.order_id) UNION ALL SELECT tgt_only AS diff_type, t.order_id FROM orders_tgt t WHERE NOT EXISTS (SELECT 1 FROM orders_src s WHERE s.order_id t.order_id);用一个常量列diff_type把方向标出来后续不管是人工看还是程序处理都一目了然。代价是要扫两遍表但换来的是双向完整覆盖我认为很值。2.5 写法一的优缺点小结优点语义清晰、可读性好支持字段映射的灵活写法NOT EXISTS 对 NULL 免疫结果可靠几乎所有关系型数据库都支持包括达梦、openGauss 这类国产库能通过索引走反连接性能在多数场景可接受。缺点NOT IN 版本存在 NULL 陷阱容易静默返回空结果只能做行级判断无法定位到具体哪个字段不同双向比对需要写两段语句偏长当关联键上没有索引时嵌套循环代价很高。3. 写法二LEFT JOIN 加 IS NULL 的外连接比对如果说法一的强项是“找行”那法二的强项就是“找字段”。它用外连接把两张表在同一行上“拉齐”然后你想比哪列就比哪列定位精度直接提升一个档次。3.1 从“找差异行”升级到“找差异字段”同样是找“源表有、目标表没有”的行用左连接写出来是这样-- 左连接找源表独有的行连接不上就说明目标表缺这条 SELECT s.order_id FROM orders_src s LEFT JOIN orders_tgt t ON s.order_id t.order_id WHERE t.order_id IS NULL;逻辑上等价于法一但表达方式不同——先生成外连接结果集再用IS NULL筛掉匹配上的。这种写法的真正价值不在于替代 NOT EXISTS而在于它天然支持“把两边字段放到同一行上比对”这是集合运算符做不到的。3.2 字段级差异定位的完整写法假设两张表都有order_id、amount、status、updated_at四列我们想找出金额或状态对不上的行-- 字段级差异找出金额或状态不一致的订单 SELECT s.order_id, s.amount AS src_amount, t.amount AS tgt_amount, s.status AS src_status, t.status AS tgt_status FROM orders_src s JOIN orders_tgt t ON s.order_id t.order_id WHERE s.amount t.amount OR s.status t.status;这里用的是内连接因为我们只关心两边都存在的行。直接比字段看起来没问题但只要amount或status里出现 NULL那一行的比较结果就是 UNKNOWN差异会被漏掉。这是法二最需要警惕的地方。稳妥的写法是用COALESCE把 NULL 统一成一个哨兵值或者用数据库提供的空值安全比较-- 空值安全的字段比对MySQL 用 PostgreSQL/Oracle 用 IS DISTINCT FROM SELECT s.order_id, s.amount, t.amount FROM orders_src s JOIN orders_tgt t ON s.order_id t.order_id WHERE NOT (s.amount t.amount) OR NOT (s.status t.status);是 MySQL 的空值安全等于NULL 与 NULL 判定为相等PostgreSQL 和 Oracle 则用IS DISTINCT FROM语义完全一致只是写法不同。选哪个取决于你的数据库核心是别裸用。3.3 FULL OUTER JOIN 一次拿下双向差异如果想一次查出双向差异同时保留字段级定位能力FULL OUTER JOIN 是最省事的-- 一次拿下双向差异仅支持 FULL JOIN 的数据库 SELECT COALESCE(s.order_id, t.order_id) AS order_id, CASE WHEN s.order_id IS NULL THEN tgt_only WHEN t.order_id IS NULL THEN src_only ELSE field_diff END AS diff_type, s.amount AS src_amount, t.amount AS tgt_amount FROM orders_src s FULL OUTER JOIN orders_tgt t ON s.order_id t.order_id WHERE s.order_id IS NULL OR t.order_id IS NULL;这里要注意一个现实问题MySQL 直到今天也不支持 FULL OUTER JOIN只能靠LEFT JOIN UNION RIGHT JOIN来模拟。所以如果你的库是 MySQL这条写法得改造成两段 UNION或者干脆回退到法一的双向拼接。3.4 索引和性能的几个要点外连接比对的性能几乎完全取决于关联键上的索引。ON s.order_id t.order_id这一句如果两边都有主键索引数据库通常会走哈希连接或排序合并连接复杂度接近线性如果一边没索引就可能退化成嵌套循环数据量一大直接卡死。另外要留意WHERE条件的位置。像s.amount t.amount这种字段级条件放在ON里和放在WHERE里结果完全不同——放ON里只影响连接匹配、不影响左表全保留放WHERE里会把不满足的行直接滤掉。做字段差异定位时通常放WHERE做“保留全部左表再标注差异”时放ON这一点用之前一定要想清楚。3.5 写法二的优缺点小结优点能精确定位到具体字段适合对账和数据修复FULL JOIN 一次拿到双向差异结果集信息丰富便于人工核查配合空值安全比较可以完全规避 NULL 陷阱。缺点MySQL 不支持 FULL JOIN需要手工模拟裸用时 NULL 会漏判多表大字段比对时结果集可能很大内存压力明显对索引依赖较高缺索引时性能下降剧烈。4. 写法三EXCEPT 与 MINUS 集合运算符前两种写法都要手动表达“连接”和“判断”集合运算符则是让数据库直接帮你做集合减法写法短到极致短到你可能会怀疑它是不是少写了什么。4.1 各数据库方言对照这套运算符最让人头疼的地方是各家叫法不统一用之前先对号入座数据库差集运算符备注PostgreSQLEXCEPT标准 SQL 写法支持 ALL 修饰OracleMINUS同时支持 EXCEPT较新版本习惯用 MINUSSQL ServerEXCEPT标准写法MySQL8.0.31 起支持 EXCEPT早期版本不支持需用 JOIN 模拟达梦MINUS / EXCEPT兼容 Oracle 语法两种都认openGaussEXCEPT兼容 PostgreSQL 语法注意MySQL 8.0.31 之前的版本没有 EXCEPT如果你在生产上直接写会报语法错误。很多网上抄来的例子默认是 PostgreSQL 或 Oracle 语法照搬之前先确认自己的库版本。4.2 为什么它能一行搞定-- 源表有、目标表没有的整行PostgreSQL / SQL Server SELECT order_id, amount, status, updated_at FROM orders_src EXCEPT SELECT order_id, amount, status, updated_at FROM orders_tgt;这一句返回的是“在源表里存在、但在目标表里整行都找不到”的记录。数据库内部会分别对两个结果集做排序或哈希然后求差集过程对使用者完全透明。要双向差异就再补一段反方向注意用 UNION ALL 而不是 UNION-- 双向整行差异 (SELECT src_only AS diff_type, order_id, amount, status, updated_at FROM orders_src EXCEPT SELECT src_only, order_id, amount, status, updated_at FROM orders_tgt) UNION ALL (SELECT tgt_only, order_id, amount, status, updated_at FROM orders_tgt EXCEPT SELECT tgt_only, order_id, amount, status, updated_at FROM orders_src);4.3 用之前必须对齐的三件事这套写法简洁但代价是限制也硬。它要求参与运算的两个结果集列数相同、对应列的数据类型兼容、顺序一致。列名可以不同但类型必须能比较否则直接报错。这带来三个实际约束。第一两表结构必须完全一致或者你得手工把列裁剪、对齐到一模一样。列顺序错了会导致比对语义完全错乱而且不会报错只会给你一堆看不懂的结果。第二它只能判断“整行是否完全相同”无法告诉你“哪一列不同”。一旦某行金额有差异它会整行出现在结果里你还得自己再定位字段。第三它对 NULL 的处理是按“NULL 等于 NULL”来的两个 NULL 在集合运算里被认为相同。这跟的语义相反喜欢哪种见仁见智但心里得有数。4.4 写法三的优缺点小结优点写法最短一行搞定可读性极高整行比对语义清晰不用逐列写条件数据库内部做过优化结构一致时性能不错天然免疫 NULL 的陷阱。缺点要求列数、类型、顺序严格对齐结构微调就可能报错或出错结果无法定位到具体字段MySQL 低版本不支持结果集只能告诉你有差异不能告诉你哪边多哪边少双向需要自己拼。5. 三种写法横向对比与选型建议把三种写法放在一张表里对照选型时就不容易纠结了。对比维度法一 NOT EXISTS / NOT IN法二 LEFT JOIN法三 EXCEPT / MINUS语法通用性全部数据库全部数据库FULL JOIN 除外视方言和版本双向差异需写两段FULL JOIN 可一次完成需写两段字段级定位不支持支持不支持NULL 安全性NOT EXISTS 安全需 COALESCE 或空值安全比较安全结构不一致时可用可手工映射可手工映射基本不可用大表性能好有索引时中到好依赖索引好结构一致时结果信息量少只有键或整行多字段级明细中整行上手难度低中极低我的选型习惯可以归纳成几句话结构完全一致、只想快速看哪些行对不上直接上 EXCEPT / MINUS最省事需要定位到字段或者两表结构有些出入用 LEFT JOIN 系NOT EXISTS 则是我在写补数据脚本、需要精确控制筛选条件时的默认选项尤其是要批量删除或批量插入差异行的场景它和INSERT ... SELECT、DELETE ... WHERE NOT EXISTS的配合最顺。还有一个实际提醒如果数据库里有同步工具或背压机制比对 SQL 本身也会产生锁和 IO 压力线上执行前尽量放到从库或者挑业务低峰期别在高峰期拿整表做全量比对。6. 完整实操从造数据到跑出差异清单光看写法容易觉得都懂真跑一遍才会发现细节全在手上。下面这套流程我在好几个项目里复用你可以直接套。6.1 准备测试表和样本数据先建两张结构一致的表塞点有代表性的数据包括 NULL-- 建表以 PostgreSQL / 达梦通用语法为例MySQL 微调即可 CREATE TABLE orders_src ( order_id BIGINT PRIMARY KEY, amount NUMERIC(12,2), status VARCHAR(20), updated_at TIMESTAMP ); CREATE TABLE orders_tgt ( order_id BIGINT PRIMARY KEY, amount NUMERIC(12,2), status VARCHAR(20), updated_at TIMESTAMP ); -- 插入源表数据含一条 status 为 NULL 的记录 INSERT INTO orders_src VALUES (1, 100.00, PAID, 2024-01-01 10:00:00), (2, 200.00, PENDING, 2024-01-01 11:00:00), (3, 300.00, NULL, 2024-01-01 12:00:00), (4, 400.00, PAID, 2024-01-01 13:00:00); -- 插入目标表数据1/2 一致3 的 status 变了4 缺失5 是目标表独有 INSERT INTO orders_tgt VALUES (1, 100.00, PAID, 2024-01-01 10:00:00), (2, 200.00, PENDING, 2024-01-01 11:00:00), (3, 300.00, PAID, 2024-01-01 12:00:00), (5, 500.00, PAID, 2024-01-02 09:00:00);6.2 三种写法逐一执行先用 EXCEPT / MINUS 快速扫一遍行级差异-- 法三源表独有的整行预期返回 order_id 4 SELECT order_id, amount, status, updated_at FROM orders_src EXCEPT SELECT order_id, amount, status, updated_at FROM orders_tgt;预期结果里应该出现order_id 4源表有、目标表缺。至于order_id 3因为它 status 变了整行不同同样会出现在结果里——这正是 EXCEPT 的特点它不区分“缺失”还是“修改”只告诉你“这行两边不一样”。要区分“缺失”和“修改”换法二-- 法二先看目标表缺了谁预期 order_id 4 SELECT s.order_id FROM orders_src s LEFT JOIN orders_tgt t ON s.order_id t.order_id WHERE t.order_id IS NULL; -- 法二再看双方都有、但字段不一致的预期 order_id 3 SELECT s.order_id, s.status AS src_status, t.status AS tgt_status FROM orders_src s JOIN orders_tgt t ON s.order_id t.order_id WHERE NOT (s.status t.status);这里是空值安全比较能正确处理status里的 NULL。如果换 PostgreSQL 或 Oracle改写成s.status IS DISTINCT FROM t.status即可。再用法一验证目标表独有-- 法一目标表独有的行预期 order_id 5 SELECT t.order_id FROM orders_tgt t WHERE NOT EXISTS ( SELECT 1 FROM orders_src s WHERE s.order_id t.order_id );6.3 结果解读与交叉验证三套 SQL 都跑完之后把结果拼起来应该得到一份完整差异清单order_id 3是字段不一致修改order_id 4是源表独有缺失order_id 5是目标表独有多出。这三个类别基本覆盖了对账里九成以上的场景。我习惯做一次交叉验证把三种写法的结果行数对一遍。如果 EXCEPT 返回 2 行4 和 3而 LEFT JOIN 加起来也是 3 行2 1NOT EXISTS 返回 1 行那说明结果自洽。如果数字对不上八成是 NULL 或者结构问题回头查。6.4 大数据量下的分批比对策略数据量上千万时一次性全表比对可能拖垮数据库。实操里我会这么拆第一先比总行数再比主键最小值、最大值、求和校验用几秒的代价排除掉大部分“其实一致”的表。第二按主键区间分批比对比如每 50 万一行做一次WHERE order_id BETWEEN ? AND ?既能控制单次资源占用又方便断点续跑。第三对超大表考虑先算每行的哈希值比如MD5(CONCAT_WS(|, 列1, 列2, ...))把哈希写到临时表再比对哈希列。这样比对列从十几个降到一列索引和 IO 压力骤降。-- 哈希比对思路两边都算出同样规则的行哈希再比哈希和主键 SELECT order_id, MD5(CONCAT_WS(|, COALESCE(amount::text,), COALESCE(status,), COALESCE(updated_at::text,))) AS row_hash FROM orders_src;这里COALESCE的作用是把 NULL 统一成空串避免CONCAT_WS遇到 NULL 时结果变成 NULL。规则要保证两边完全一致否则哈希本来就该不同。7. 踩坑记录与常见问题速查写法和流程讲完剩下的是那些文档里不会写、只有踩过才知道的东西。7.1 常见问题速查表现象最可能的原因排查方向NOT IN 返回空结果子查询结果含 NULL加IS NOT NULL或改 NOT EXISTS明明有差异却查不出裸用遇到 NULL改用空值安全比较EXCEPT 报列数不匹配两表列数/类型不一致显式列出对应列别用SELECT *比对结果一片“假差异”字符集、排序规则不同核对两边列定义和编码字符型数字对不上一边 VARCHAR 一边 INT统一类型或显式转换时间戳差 1 秒时区或精度不同确认时区设置与列精度SQL 跑几十分钟不返回关联键无索引补索引或改分批比对结果行数忽多忽少有重复主键或唯一键失效先查重复键7.2 几条压箱底的经验第一永远别用SELECT *做 EXCEPT。哪怕现在结构一致哪天有人加了一列比对结果就可能全错或者直接报错。显式把列写出来虽然啰嗦但稳定。第二先小后大。拿几万行测试数据把三种写法都跑通确认结果自洽再上生产全量。我见过太多人直接拿千万级表跑卡到怀疑人生。第三别只信一种写法的结果。差异比对的正确性验证靠的是多种方法交叉印证。尤其涉及资金、订单这类敏感数据宁可多花点时间用两种写法互相校验。第四把比对脚本版本化。表结构调整时脚本要跟着改最好用 Git 管起来注明每列的处理逻辑不然过几个月自己都看不懂当初为什么加了那个 COALESCE。另外分享一个我最近用着挺顺的扩展思路如果两表差异只是偶发几条人工看就够了但如果每天都要跑可以把上面三种写法封装成一个参数化的校验脚本输入两张表名和主键自动生成双向差异 SQL再输出成差异明细表。字段映射的部分用配置化的方式维护这样换表、换库都不用重写逻辑。这套东西搭起来不超过半天但往后每次对账都能省下大量时间性价比非常高。
返回列表