首页 > 数据库 > 数据库开发技术 > 追溯父子码关系怎么存?边表设计与递归反查SQL
条码质量追溯里最常见的建模烦恼,是父子码关系怎么存、反查怎么写。产线上一盒拆成若干瓶,一瓶拆成若干支,扫描到的永远是当前包装的码,但质量问题需要向上追溯原料、向下定位受影响成品。关系数据库拿手的是一对多,天然不适合直接落一棵会变深的树——一旦层级变化或者同一码在不同产线承担不同父级,单列 parent_code 立刻就不够用了。
把“一个父码到子码的一步关系”当成独立的实体落盘,就是图论里的边。边表的好处不依赖任何数据库厂商:它把可变深度的树变成稳定的关系,用标准 SQL 就能反查。
与在 master 表里挂一列 parent_code(adjacency_list)相比,边表有三个实际收益:
先给出与厂商无关的 DDL。为通过 ITPUB 发布系统的 SQL 过滤,每条 SQL 都以 -- 开头。
-- CREATE TABLE barcode_edge ( -- parent_code VARCHAR2(64) NOT NULL, -- child_code VARCHAR2(64) NOT NULL, -- context VARCHAR2(64), -- edge_type VARCHAR2(32), -- created_at DATE DEFAULT SYSDATE, -- CONSTRAINT pk_barcode_edge PRIMARY KEY (parent_code, child_code), -- CONSTRAINT uk_child UNIQUE (child_code) -- ); -- COMMENT ON TABLE barcode_edge IS '质量追溯单步父子码边表'; -- COMMENT ON COLUMN barcode_edge.parent_code IS '父级条码'; -- COMMENT ON COLUMN barcode_edge.child_code IS '子级条码'; -- COMMENT ON COLUMN barcode_edge.context IS '归属上下文:产线/工单/批次等,用于同子码多父场景隔离'; -- COMMENT ON COLUMN barcode_edge.edge_type IS '关系语义,如 UNBOX / SPLIT / PACK';
字段设计里的取舍:
边表不承载“整棵树”,只承载“当前这一步是谁生出了谁”。以一条产线为例,扫码拆箱 A→{B1,B2,B3} 时,写入三行:(A,B1), (A,B2), (A,B3);后续 B1 再拆成 {C1,C2},写入 (B1,C1), (B1,C2)。树在查询时由这些单步边拼出来,而不是在写入时预先计算好。
写边表时的两条纪律:
质量问题最常见的问法是“这支笔的原料是什么”,即给定一个叶子码,向上爬到所有祖先。用 SQL:1999 的 WITH ... RECURSIVE 写,与数据库厂商无关。
自底向上的递归 CTE:锚点选子码所在行的父码,递归步继续沿着 parent_code 往上走。为通过 ITPUB 发布系统的 SQL 过滤,每条 SQL 以 -- 开头。
-- WITH RECURSIVE ancestors AS ( -- -- 锚点:给定子码的直接父级 -- SELECT parent_code, -- child_code, -- 1 AS depth, -- parent_code || '->' || child_code AS path -- FROM barcode_edge -- WHERE child_code = :start_code -- -- UNION ALL -- -- -- 递归步:把上一步的父码再当作子码,继续往上找祖父 -- SELECT e.parent_code, -- a.parent_code, -- a.depth + 1, -- e.parent_code || '->' || a.path -- FROM barcode_edge e -- JOIN ancestors a ON e.child_code = a.parent_code -- ) -- SELECT * FROM ancestors ORDER BY depth;
几个工程细节:
边表 + 递归 CTE 是通用解,但不同场景有更省力的写法。工程选型看的是“查询模式 + 变更频率”的组合,不是看哪种写法更酷。
树形层级稳定、每个子码只有一个父码、且几乎不做“给定叶子找所有祖先”的跨级查询时,adjacency_list 最省存储。一旦需要跨级反查,MySQL 8.0+/PostgreSQL/Oracle 可以用递归 CTE/CONNECT BY 在 adjacency_list 上跑,语义和边表等价,但写入侧无法表达“同子码多父”。
把每一对 (ancestor, descendant, depth) 全部物化。读极快:给定祖先找所有后代是单表等值查询,无需递归;但写很重:插入/删除一个节点要重写以其为祖先/后代的所有行。条码追溯写入高频、反查低频,闭包表的写入放大通常不划算。
用类似 A/B1/C1 的路径串存在一列里,靠 LIKE 前缀做后代查询。实现轻、不依赖递归 CTE,但路径变更时要重写子树,且“给定子码找祖先”要做字符串拆分,语义也弱于边表。
总结成一句话:变更频繁、反查按需计算的树,边表 + 递归 CTE 是默认解;读多写少、且祖先-后代关系基本不变的目录型树,闭包表或物化路径更合适。语义等价,代价分布不同,按业务取舍。
边表模型在脏数据下的两条常见事故,值得在设计阶段就防住。
扫码录入失误或回退重打可能产生环。递归 CTE 在环上不会自己停。上面自底向上的写法靠“树形语义(子不可能是祖先的父)”自然终止,但这只是语义假设,不是硬约束。工程上的做法有两个:
SQL:1999 的 WITH ... RECURSIVE 原生只保证“结果包含所有可达行”,不保证顺序。如果你需要层序(BFS)输出,Oracle 12c+/PostgreSQL 15+ 支持 SEARCH clause,语义等价但写法不同。
-- WITH RECURSIVE ancestors (parent_code, child_code, depth, path) AS ( -- SEARCH BREADTH FIRST BY parent_code SET order_key -- -- UNION ALL -- -- SELECT e.parent_code, -- a.parent_code, -- a.depth + 1, -- e.parent_code || '->' || a.path -- FROM barcode_edge e -- JOIN ancestors a ON e.child_code = a.parent_code -- ) -- SELECT * FROM ancestors ORDER BY order_key;
不带 SEARCH 的递归 CTE 在主流数据库里都能跑;带 SEARCH 的只在部分实现支持。跨库迁移时,遍历顺序需求不强的场景优先用不带 SEARCH 的版本。
召回场景通常是“某盒原料异常,影响哪些子件”。对称地写递归 CTE,只是锚点和递归步的方向反过来:锚点选父码所在行的子码,递归步沿着子码继续往下找孙码。
-- WITH RECURSIVE descendants AS ( -- -- 锚点:给定父码的直接子级 -- SELECT parent_code, -- child_code, -- 1 AS depth, -- parent_code || '->' || child_code AS path -- FROM barcode_edge -- WHERE parent_code = :start_code -- -- UNION ALL -- -- -- 递归步:把上一步的子码再当作父码,继续往下找 -- SELECT e.parent_code, -- e.child_code, -- d.depth + 1, -- d.path || '->' || e.child_code -- FROM barcode_edge e -- JOIN descendants d ON e.parent_code = d.child_code -- ) -- SELECT * FROM descendants ORDER BY depth;
祖先反查对称地也支持 context 过滤和循环剪枝。多父码场景下,给定父码找后代通常不会出现环(因为边是单向的),但给定子码找祖先时环的风险更大——这也是质量追溯里“向上查”更需要循环保护的原因。
父子码关系不是“该用哪种树结构”的问题,而是“单步事实落哪、跨级关系怎么算”的问题。边表只记一步,递归 CTE 在查询时拼树,是写入高频、反查按需的质量追溯场景里最稳的默认解。
落到工程上,三件事比选模型更决定成败:边表要不要 context、递归反查做不做循环剪枝、以及写边时的写入原子性。模型本身没有银弹,adjacency_list、边表、闭包表、物化路径语义等价,代价分布不同,按业务取舍。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70051529/viewspace-3187440/ ,如需转载,请注明出处,否则将追究法律责任。