ITPub博客

首页 > 数据库 > 数据库开发技术 > 追溯父子码关系怎么存?边表设计与递归反查SQL

追溯父子码关系怎么存?边表设计与递归反查SQL

原创 数据库开发技术 作者:制造业数字化观察 时间:2026-11-03 12:21:22 0 删除 编辑


条码质量追溯里最常见的建模烦恼,是父子码关系怎么存、反查怎么写。产线上一盒拆成若干瓶,一瓶拆成若干支,扫描到的永远是当前包装的码,但质量问题需要向上追溯原料、向下定位受影响成品。关系数据库拿手的是一对多,天然不适合直接落一棵会变深的树——一旦层级变化或者同一码在不同产线承担不同父级,单列 parent_code 立刻就不够用了。

一、为什么把父子码关系拆成一张边表

把“一个父码到子码的一步关系”当成独立的实体落盘,就是图论里的边。边表的好处不依赖任何数据库厂商:它把可变深度的树变成稳定的关系,用标准 SQL 就能反查。

与在 master 表里挂一列 parent_code(adjacency_list)相比,边表有三个实际收益:

  • 同一个子码在不同场景可以对应不同父码(同一瓶在 A 线属于 X 盒,在 B 线属于 Y 盒),adjacency_list 做不到。
  • 天然支持多叉、跨批次合并等“一度像图不像树”的现场。
  • 祖先/后代反查用 SQL:1999 的 WITH ... RECURSIVE 就能写,不依赖 Oracle CONNECT BY、MySQL 5.7 之前的存储过程等非标准能力。

先给出与厂商无关的 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';

字段设计里的取舍:

  • parent_code + child_code 联合主键:同一父-子步关系不重复落盘。
  • child_code 唯一约束:在“一个码只能有一个直接父码”的业务里保留 uk_child;业务允许多父时去掉,靠 context 隔离。
  • context 是实战里最常被省掉、也最常后悔的字段。不建 context,同一码在不同产线/工单的父子关系会互相污染;建了 context,反查时必须携带 context 过滤,否则结果偏大。
  • edge_type 记录“拆箱/分装/合并”的语义,给审计和逆向还原提供依据。

二、边表里一条记录到底承载什么

边表不承载“整棵树”,只承载“当前这一步是谁生出了谁”。以一条产线为例,扫码拆箱 A→{B1,B2,B3} 时,写入三行:(A,B1), (A,B2), (A,B3);后续 B1 再拆成 {C1,C2},写入 (B1,C1), (B1,C2)。树在查询时由这些单步边拼出来,而不是在写入时预先计算好。

写边表时的两条纪律:

  • 写入原子性:一次拆/合动作产生的多条边要么全入要么不入,事务控制在写入侧,而不是在反查侧去猜“写到一半的树”。
  • 禁止手写闭包:不要把 (A,C1) 这类跨级祖先-后代也写入边表。边表只记录一步关系,跨级关系交给递归 CTE 在查询时计算,否则删除/变更父码时的维护成本会指数级上升。

三、递归反查:给定子码,找所有祖先(自底向上)

质量问题最常见的问法是“这支笔的原料是什么”,即给定一个叶子码,向上爬到所有祖先。用 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;

几个工程细节:

  • 循环保护:真实的扫码数据可能出现 A→B→A 的录入失误。递归 CTE 在数据有环时会无限循环或报错。上面写法靠树形语义(child 永远不等于祖先)自然终止,但在脏数据环境下建议用 path NOT LIKE 自身或传一个 visited 列表做剪枝。
  • depth 用于分页和展示层级;path 是给排查用的可读路径,生产环境数据量大时建议只保留 depth,path 用应用层拼装,避免字符串拼接吃掉缓冲。
  • 锚点的 WHERE child_code = :start_code 命中 uk_child / 索引时,递归的入口是 O(1) 的,整条反查性能取决于树的宽度和边表索引。

四、和其他存法的对比:什么时候边表不是最优

边表 + 递归 CTE 是通用解,但不同场景有更省力的写法。工程选型看的是“查询模式 + 变更频率”的组合,不是看哪种写法更酷。

4.1 adjacency_list(master 表挂 parent_code)

树形层级稳定、每个子码只有一个父码、且几乎不做“给定叶子找所有祖先”的跨级查询时,adjacency_list 最省存储。一旦需要跨级反查,MySQL 8.0+/PostgreSQL/Oracle 可以用递归 CTE/CONNECT BY 在 adjacency_list 上跑,语义和边表等价,但写入侧无法表达“同子码多父”。

4.2 closure_table(闭包表)

把每一对 (ancestor, descendant, depth) 全部物化。读极快:给定祖先找所有后代是单表等值查询,无需递归;但写很重:插入/删除一个节点要重写以其为祖先/后代的所有行。条码追溯写入高频、反查低频,闭包表的写入放大通常不划算。

4.3 materialized_path(路径字符串)

用类似 A/B1/C1 的路径串存在一列里,靠 LIKE 前缀做后代查询。实现轻、不依赖递归 CTE,但路径变更时要重写子树,且“给定子码找祖先”要做字符串拆分,语义也弱于边表。

总结成一句话:变更频繁、反查按需计算的树,边表 + 递归 CTE 是默认解;读多写少、且祖先-后代关系基本不变的目录型树,闭包表或物化路径更合适。语义等价,代价分布不同,按业务取舍。

四(续)、递归反查的两条防御:循环保护与遍历顺序

边表模型在脏数据下的两条常见事故,值得在设计阶段就防住。

4.5 循环保护:别让 A→B→A 把数据库拖死

扫码录入失误或回退重打可能产生环。递归 CTE 在环上不会自己停。上面自底向上的写法靠“树形语义(子不可能是祖先的父)”自然终止,但这只是语义假设,不是硬约束。工程上的做法有两个:

  • 写入侧约束:在 barcode_edge 上加校验逻辑/触发器,拒绝 parent_code = child_code 的自环,并检查“若插入 (A,B) 会导致 B 可达 A”的跨级环。校验成本不低,但质量追溯场景里这是最值得投入的地方。
  • 查询侧剪枝:在递归步里用 path 列做 NOT LIKE 自身,或传一个 visited 列表。代价是把循环检测从存储过程推到了每行拼接,适合读多写少且环概率极低的环境。

4.6 遍历顺序:BFS vs DFS 由你决定

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/ ,如需转载,请注明出处,否则将追究法律责任。

下一篇: 没有了~
请登录后发表评论 登录
全部评论
关注离散制造MES、WMS、条码追溯与设备数据采集的实施经验分享。

注册时间:2026-10-24

  • 博文量
    7
  • 访问量
    4262