☰
电商全类目属性SQL建模与递归CTE查询实战
2026/10/9 19:11:37 网站建设 项目流程

简介:这是一份面向电商数据分析、数据库开发及平台运营人员的淘宝全类目属性SQL数据包。资源将淘宝平台各层级商品类目、属性及属性值整理为结构化SQL文件,适用于快速搭建类目字典、进行商品信息筛选或辅助市场分析场景。包体为单一sql文件,压缩后大小353KB,直接导入数据库即可查看和查询类目层级与属性关联结构。目前已有294人学习下载,适合需要了解淘宝商品数据模型、练习复杂SQL查询或构建本地测试环境的读者。通过该文件,可直观掌握电商后台中“类目—属性—属性值”的层级关系,并据此编写按类目或属性筛选商品的查询语句,也可作为数据分析、推荐系统或数据同步任务的参考数据源,对理解电商平台数据结构具有实用价值。

1. 全类目加属性SQL:这套查询体系到底在解决什么问题

电商后台里,类目和属性是两个绕不开的词:类目是一棵树,属性是挂在树上的字典,两者绑定后才有商品详情和筛选。标题里的“全类目加属性SQL”,我理解不是一条 SQL,而是一整套用 SQL 管理类目树、属性字典和绑定关系的方法。刚接手这类需求时最容易踩的坑,是有人直接拍脑袋建一张 50 个字段的大宽表,把类目路径和所有属性值都塞进去,结果每次查询都要 LIKE,跑一次报表慢 SQL 一堆。正确的做法反而是几张普通表加递归 CTE。这套方案能解决类目导购、选品后台、属性筛选、SKU 组合和报表统计五类场景,适合电商后端开发、数据仓库工程师和做商品中台的你。下面我从表结构开始,一步步把最常用且最不容易翻车的做法讲清楚。

2. 先立模型:类目、属性、绑定的四张表怎么建

2.1 类目表:血缘关系用 parent_id 还是 path 字段

先说类目表。全类目的第一感觉是一张“树”表,但关系数据库没有树类型,只能用 parent_id 表示父子关系。我一般这么建:

CREATE TABLE category ( category_id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '类目ID', parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父类目ID,0表示根节点', category_name VARCHAR(64) NOT NULL COMMENT '类目名称', level TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '层级,根为1', sort_order INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '同级排序', status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1启用 0停用', KEY idx_parent (parent_id), KEY idx_level (level) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='类目树';

parent_id 指向 category_id,根节点用 0 而不是 NULL,方便索引用整数比较。level 字段看起来冗余,但它能让查询“第几层”这类需求走索引,不用每次递归数深度。很多老系统喜欢用 path 字段(如“1/10/123”)直接查祖先,但 path 的维护成本很高,类目调整层级或删除中间节点时,要批量 UPDATE,很容易漏。我的取舍是:保留 parent_id 作为唯一血缘依据,递归查询交给 SQL 的 WITH RECURSIVE,path 只在极少数需要纯字符串匹配的报表里作为冗余,日常不参与业务写入。

还需要注意一个常见误用:有人会把 path 当主键的一部分,或者用逗号分隔的多级 ID 存到一个字段里。这会让统计和 join 变得极其痛苦,比如统计某个一级类目下所有二级类目数量,你得先 LIKE '1/%',再手工数逗号。血泪经验是,树表不要想着用一个字符串字段解决所有查询,老老实实 parent_id 就能配合绝大多数数据库的递归语法。

2.2 属性表和属性值表:全局字典还是类目私有

属性本身是字典。颜色、尺寸这种跨类目通用,材质、风格可能只属于某些类目。我建议建两张表:属性主表和属性值表。

CREATE TABLE attribute ( attr_id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '属性ID', attr_name VARCHAR(32) NOT NULL COMMENT '属性名', attr_type TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1单选 2多选 3输入', sort_order INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '全局排序', status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1启用 0停用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='属性字典'; CREATE TABLE attribute_value ( value_id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '属性值ID', attr_id BIGINT UNSIGNED NOT NULL COMMENT '所属属性ID', value_name VARCHAR(64) NOT NULL COMMENT '属性值名称', sort_order INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '同一属性内排序', KEY idx_attr (attr_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='属性值字典';

为什么拆开?因为一个属性有多个值,属性值是多的一条子表,不是两个字段。如果你把值用逗号存在 attribute 表的 attribute_values 字段里,之后要统计、筛选、联动都会变成噩梦。我见过一个项目把一个属性的可选值用 JSON 存在字段中,查询“哪些类目有可选值包含无线”时,全表 LIKE,慢且不可维护。拆成字典表后,属性值去重、排序、改名都直接 UPDATE 一行。

注意:这里属性表是全局的,同一个 attr_id 在所有类目共享。对“颜色”这种语义一致的全局属性没问题,但“尺码”在不同类目有不同取值(衣服有S/M/L,鞋有36/37/38),这时候光有全局值表会导致大量无关值。这个坑我放在第5章详细讲,正常处理可以在绑定表上再加一层“类目可用值集合”。

2.3 类目-属性绑定表:继承规则和排序放哪里

第三张表是绑定表,解决“哪个类目有哪些属性”的问题。

CREATE TABLE category_attribute ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, category_id BIGINT UNSIGNED NOT NULL COMMENT '类目ID', attr_id BIGINT UNSIGNED NOT NULL COMMENT '属性ID', is_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1必填 0选填', is_filter TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1允许筛选', inherit_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1可被子类继承 0不继承', sort_order INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '类目内排序', UNIQUE KEY uk_category_attr (category_id, attr_id), KEY idx_attr (attr_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='类目属性绑定';

这里最关键的是 inherit_enabled。因为全类目有层级,父类目有的属性子类目通常也有,比如“服装”下有“颜色”,“男装”也应该有。你可以在每个子类目的绑定表手动插入,但 update 时容易遗漏;也可以设置 inherit_enabled=1,让子类目查询时自动带上父类目的属性。is_filter 控制这个属性要不要出现在前台筛选列表。sort_order 放在绑定表,而不是属性表,因为同一个属性在不同类目下的排序不一样,全局排序只能作为兜底。这一张绑定表,就是标题里“加属性”三个字的具体落点。

还有一点容易被刚入门的人忽略:绑定表的唯一键应该是 category_id + attr_id,而不是单独 id。没有唯一键,同一个类目同一个属性可能被脚本重复插入,后面查询时 GROUP_CONCAT 里会出现两个一模一样的“颜色”,前端渲染时还要再做一次清洗。加上唯一键后,配合 INSERT ... ON DUPLICATE KEY UPDATE,重复执行脚本也不会产生脏数据。

3. 把数据灌进去:初始化与增量更新的SQL套路

3.1 一次性初始化:拆文件、过滤、插入的通用流程

类目和属性的来源一般是业务同学整理的配置文件,或者是老系统导出。常见做法是先落到临时表,再转换插入目标表,而不是直接一条 INSERT 拼大量 VALUES。因为数据量一旦超过几百行,手工拼 SQL 很难快速发现被截断的字段或重复项。SQL Server 里可以用 OPENROWSET,MySQL 用 LOAD DATA,我给出一个更容易调试的方式:

-- 假设已经通过工具把源表加载为 source_category(id, parent_id, name, level) INSERT INTO category (category_id, parent_id, category_name, level, sort_order, status) SELECT t.id, CASE WHEN t.parent_id IS NULL OR t.parent_id = 0 THEN 0 ELSE t.parent_id END, TRIM(t.name), COALESCE(t.level, 1), ROW_NUMBER() OVER (PARTITION BY t.parent_id ORDER BY t.name) - 1 AS sort_order, 1 AS status FROM source_category t LEFT JOIN category c ON c.category_id = t.id WHERE c.category_id IS NULL;

这段 SQL 做了什么:从源表整体读取,父ID为空时归到根;对每个父类目下的类目按名称排序生成 sort_order;最后的 LEFT JOIN 和 WHERE 保证只插入不存在的类目,重复执行不会产生重复。这里用了窗口函数 ROW_NUMBER,SQL 去重场景也能用同样的思路。

注意:一次性导入还要考虑自增列,如果源表的主键不能直接复用,需要先通过映射表记录老ID和新ID,否则父子关系会断。我一般会建立一个 category_id_map(old_id, new_id) 临时表,先把根层导完,再导子层,每层都查 map。类目树这种有自引用的数据,不要指望一条 INSERT 把所有层级全搞定。

3.2 增量更新:MERGE 还是先删后插

很多平台的类目不是只在初始化时导入一次,运营每周都会调整。属性可能改名、停用;绑定关系可能加属性或调整排序。增量更新我的习惯是“软删 + 幂等更新”,而不是物理 DELETE。

UPDATE category c JOIN source_update u ON c.category_id = u.id SET c.category_name = u.name, c.level = u.level, c.sort_order = COALESCE(u.sort_no, c.sort_order), c.status = CASE WHEN u.online = 1 THEN 1 ELSE 0 END WHERE c.category_id > 0;

如果是新增绑定关系,用 INSERT ... ON DUPLICATE KEY UPDATE,保证同一类目和属性不会出现两条:

INSERT INTO category_attribute (category_id, attr_id, is_required, is_filter, inherit_enabled, sort_order) SELECT t.category_id, t.attr_id, t.is_required, t.is_filter, t.inherit_enabled, t.sort_order FROM source_bind t ON DUPLICATE KEY UPDATE is_required = VALUES(is_required), is_filter = VALUES(is_filter), inherit_enabled = VALUES(inherit_enabled);

注意:VALUES() 在 MySQL 8.0.20 后已经不建议使用,可以写成别名形式:

INSERT INTO category_attribute (...) SELECT ... FROM source_bind AS t ON DUPLICATE KEY UPDATE is_required = COALESCE(t.is_required, category_attribute.is_required);

这段是幂等的,脚本可以反复执行,不会因为二次运行重复插入。如果你用的是 SQL Server,对应的写法是 MERGE,但 MySQL 没有内置 MERGE,所以 ON DUPLICATE KEY UPDATE 就是最顺手的方案。

3.3 批量导入的外键和事务注意点

如果表上加了外键,LOAD 数据时经常因为子类目还没插入触发外键失败。常规做法是先禁掉外键约束,导入完毕再检查并启用。MySQL 下面两条命令要包在事务里不能随便用:

SET FOREIGN_KEY_CHECKS = 0; -- 执行导入脚本 SET FOREIGN_KEY_CHECKS = 1;

或者按层级顺序导入:先根、再二级、再三级。我更推荐后者,因为类目树需要依赖 parent_id 真实存在,外键检查能拦截脏数据,而不是等发现下级孤儿时后悔。每次导入建议用事务封装,解析日志里打印受影响行数和错误码。

这类批量任务跑完后,务必检查三件事:类目总数和源表是否一致;parent_id 是否都能在 category 表找到;叶子类目是否有绑定属性。我习惯用下面这条 SQL 查孤儿节点:

SELECT c.category_id, c.category_name, c.parent_id FROM category c LEFT JOIN category p ON c.parent_id = p.category_id WHERE c.parent_id <> 0 AND p.category_id IS NULL;

只要这条查询返回多于 0 行,说明导入或增量更新有遗漏,需要把 parent_id 修好再发布。

4. 把“全类目加属性”查出来:四个核心SQL

4.1 递归查全量后代类目

标题的核心是“全类目”,第一步通常是给一个类目,找到它下面所有叶子或所有后代。MySQL 8 和 SQL Server 都支持 WITH RECURSIVE,写法如下:

WITH RECURSIVE category_cte AS ( SELECT category_id, parent_id, category_name, level, 1 AS depth FROM category WHERE category_id = 123 UNION ALL SELECT c.category_id, c.parent_id, c.category_name, c.level, cte.depth + 1 FROM category c INNER JOIN category_cte cte ON c.parent_id = cte.category_id ) SELECT category_id, category_name, level, depth FROM category_cte;

这里category_id=123是入口。UNION ALL 比 UNION 快,因为树不会有重复节点;如果担心环路,加一个 depth 上限避免死循环。depth 字段还能让你知道每个后代与入口相差几层。如果只想要叶子,最后加一个子查询,判断该节点没有子节点:

WITH RECURSIVE category_cte AS (...) SELECT c.* FROM category_cte c WHERE NOT EXISTS ( SELECT 1 FROM category child WHERE child.parent_id = c.category_id );

这样查出来的就是全量叶子类目,选品和报表都直接可用。

4.2 反向递归查祖先

做属性继承时经常要在一个子类目出发找它的父类目链。这段递归很关键:

WITH RECURSIVE parent_cte AS ( SELECT category_id, parent_id, category_name, 1 AS depth FROM category WHERE category_id = 4567 UNION ALL SELECT p.category_id, p.parent_id, p.category_name, pc.depth + 1 FROM category p INNER JOIN parent_cte pc ON p.category_id = pc.parent_id ) SELECT category_id, category_name, depth FROM parent_cte;

注意递归方向相反:入口是目标子类,递归时用 p.category_id = pc.parent_id,一路向上到 parent_id=0 结束。如果数据里 parent_id 不是自己期望的那个,这个查询会把问题暴露出来。比如你想往上找三级,结果只有一条结果,那就要检查这棵树的 parent_id 是不是断的。

4.3 聚合某个类目集合下的属性

拿到全部后代类目后,下一步要查这些类目绑定了哪些属性。最直接的是 JOIN 绑定表,然后按类目聚合:

SELECT c.category_id, c.category_name, GROUP_CONCAT(DISTINCT a.attr_name ORDER BY ca.sort_order SEPARATOR '|') AS attr_names FROM category c LEFT JOIN category_attribute ca ON c.category_id = ca.category_id LEFT JOIN attribute a ON ca.attr_id = a.attr_id WHERE c.category_id IN (123, 456, 789) GROUP BY c.category_id, c.category_name;

这里容易翻车:GROUP_CONCAT 默认最大长度是 1024,如果一个类目属性非常多,结果会被截断。可以在查询前执行SET SESSION group_concat_max_len = 65535;。更稳妥的方式是直接把子表查出来,由应用层组装,而不是指望一条 SQL 把所有属性值全拼进一个字段。如果只需要某个类目直接绑定的属性而不是后代,把 IN 换成等号即可,但要注意过滤掉继承属性,否则会重复。

4.4 属性继承:子类目如何拿到父类目属性

刚才说绑定表有一个 inherit_enabled 字段。查询某个类目最终生效的所有属性,需要先递归祖先链,再和绑定表做连接:

WITH RECURSIVE chain AS ( SELECT category_id, parent_id FROM category WHERE category_id = 999 UNION ALL SELECT p.category_id, p.parent_id FROM category p INNER JOIN chain ON p.category_id = chain.parent_id ) SELECT DISTINCT a.attr_id, a.attr_name, ca.is_required, ca.is_filter FROM chain INNER JOIN category_attribute ca ON ca.category_id = chain.category_id INNER JOIN attribute a ON ca.attr_id = a.attr_id WHERE ca.inherit_enabled = 1 ORDER BY ca.sort_order;

把 chain 和 category_attribute JOIN,等于把祖先链上的绑定规则全部汇总。如果你希望“离自己最近的层级优先覆盖祖先”,SQL 得改成保留最小 depth,这可以放在应用层去覆盖。就这么做,才能让“子类目自动继承父类目属性”从概念变成能跑的查询。实际接口中,我会把这段包成视图或存储过程,避免每来一个请求都拼一段递归。

5. 避坑与排查:全类目属性最容易翻车的五个场景

5.1 递归死循环:类目表有人插了 parent_id 等于自己的脏数据

现象:4.1 的递归查询执行一次要几十秒,或者直接报错 Recursive query aborted after ... 错误。

原因:运营手工导入类目数据时,某个节点的 parent_id 写成了自己的 category_id,出现环,递归无法终止。

解决:在递归 SQL 里加深度限制,比如WHERE cte.depth <= 15,这是最快速的保护;但要在应用层或写入层拦截脏数据,因为递归时过滤只能保证查询不死,不能让数据变干净。还要写一个校验 SQL 查环:

SELECT a.category_id, a.parent_id FROM category a WHERE EXISTS ( SELECT 1 FROM category b WHERE b.category_id = a.parent_id AND b.parent_id = a.category_id );

这是最简单的两节点环。多节点环要靠递归深度统计,SQL 不太方便,我一般只做单跳校验。真正根治的办法是在写入服务里检查“新 parent_id 不能是自身的后代”,这个逻辑可以复用 4.2 的祖先递归查询,如果目标节点已经出现在祖先链里就拒绝写入。

5.2 同一属性在不同类目下值域不同

现象:尺码属性下有 S、M、L、X/XL,也有 36、37、38、39,甚至“均码”,放在同一张 attribute_value 表里,类目筛选时会出现衣服类目也能选到 36 码。

原因:全局属性值表被所有类目共享,没有区分“这个属性能用哪些值”。颜色可以全局,尺码不能。

解决:常见做法是在绑定表 category_attribute 上再加一张 category_attr_value 表,记录某类目某属性允许的值集合:

CREATE TABLE category_attr_value ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, category_id BIGINT UNSIGNED NOT NULL, attr_id BIGINT UNSIGNED NOT NULL, value_id BIGINT UNSIGNED NOT NULL, sort_order INT UNSIGNED NOT NULL DEFAULT 0, UNIQUE KEY uk_category_attr_value (category_id, attr_id, value_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='类目属性值集合';

查询时先找 allowed value,再用 attribute_value 取名字,避免值污染。这个表看起来多了一层,但实际上它对前端筛选非常友好:按类目查allowed value_ids,再拼接属性名和属性值,不用每次在内存里过滤全局值表。

5.3 递归深度超过数据库默认上限

现象:层级有 800 多层,虽然电商类目很少超过 5 层,但某些非标准录入会让数据失去控制,MySQL 报错说明递归超过 1000 层,拒绝执行。

原因:MySQL 8 的 CTE 递归默认最多 1000 次迭代,当数据层级深或存在循环时触发。

解决:在递归 CTE 中显式加 depth <= 20,类目树一般 20 层足够;另外在查询中设置SET cte_max_recursion_depth = 1000;也可以,但我不建议只调高参数,而是同时限制数据入口。还要注意有些数据库对递归别名大小写敏感,写错报错信息会很迷,排查时可以先用最简单的两层递归跑通。

5.4 类目绑定的属性排序混乱

现象:前端筛选时属性顺序一会儿是颜色、尺码,一会儿是品牌、风格,看起来随机。

原因:开发时用 attribute 表的 sort_order 排序,但这个表是全局的,同一个属性在不同类目下的展示排序可能不一样;而绑定表里的 sort_order 被忽略了。

解决:统一用 category_attribute.sort_order 作为类目内排序。在 4.3 的聚合 SQL 里,GROUP_CONCAT 内部 ORDER BY ca.sort_order,而不是 a.sort_order。同时把 attribute.sort_order 降级为“默认排序”,只在绑定表没配置时兜底。如果发现排序仍然不对,检查绑定表的 sort_order 是不是都被写成了 0,那样就只能按 attr_id 排了。

5.5 慢SQL优化:不要每查一个类目就递归一次

现象:写接口时,对每个类目调用一次递归查询,然后再查属性,前端一个页面上百个类目,接口耗时超过三秒。

原因:业务层循环查询数据库,N+1 问题,且递归 CTE 没有索引可用。

解决:先一次递归出需要展示的全部分支,再把结果放到临时表或 IN 条件,一条 SQL JOIN 绑定表。另一个技巧是给 category.parent_id 和 category_attribute.category_id 建联合索引,减少回表。慢 SQL 优化第一原则从来不是调参数,而是减少查询次数。我做过一次优化,把一百多次循环查询改成一次递归加两次 JOIN,接口从 4 秒降到 200 毫秒,效果非常明显。

6. 进阶:用递归 CTE 生成 SKU 属性组合与去重技巧

标题里的“加属性”最终要落到商品 SKU。有了全类目属性和值,就可以自动生成候选 SKU 组合。举个例子:一款 T 恤有颜色(红、蓝)、尺码(S、M),候选就是红S、红M、蓝S、蓝M。SQL 用递归 CTE 做笛卡尔积比应用层循环更直观:

WITH RECURSIVE sku_cte AS ( SELECT attr_id, value_id, value_name, CAST(value_id AS CHAR) AS path, CONCAT(attr_name, ':', value_name) AS combo, 1 AS depth FROM ... WHERE ... UNION ALL ... ) SELECT * FROM sku_cte WHERE depth = 属性数量;

实际的复杂点在“属性数量不确定”,无法写死列数,所以 SQL 里通常保留 path 拼接结果,再由接口层拆分。另一种更实用的方式是先算组合数,防止 SKU 爆炸:

SELECT GROUP_CONCAT(CONCAT(attr_name, '(', value_count, ')') ORDER BY attr_id SEPARATOR ' × ') AS sku_schema, ROUND(EXP(SUM(LN(value_count))), 0) AS total_combinations FROM ( SELECT ca.attr_id, MAX(a.attr_name) AS attr_name, COUNT(cav.value_id) AS value_count FROM category_attribute ca LEFT JOIN category_attr_value cav ON cav.category_id = ca.category_id AND cav.attr_id = ca.attr_id LEFT JOIN attribute a ON ca.attr_id = a.attr_id WHERE ca.category_id = 123 AND ca.is_required = 1 GROUP BY ca.attr_id ) t;

这里用 EXP(SUM(LN(value_count))) 计算乘积,当某个属性没有可选值时 count=0,LN(0) 直接报错,所以要注意用 NULLIF。另一个常见需求是重复绑定记录去重,窗口函数可以这样:

WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY category_id, attr_id ORDER BY sort_order) AS rn FROM category_attribute ) DELETE FROM category_attribute WHERE (id) IN (SELECT id FROM ranked WHERE rn > 1);

这个技巧的数据变更场景很常用。验证 SQL 结果时,我会用两个角度:看总数是否等于 source 统计数,抽几个类目对比属性名字是否都在。最后养成一个习惯:任何递归 SQL 都加上深度上限,任何跟类目树有关的表都不允许物理 DELETE,只做 status 标记。这个习惯帮我少踩了很多数据维护的坑,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询