做B端业务的同学大概率会遇到这样的需求:代理商是树形层级结构(层级不限),需要快速查询某个代理商及其所有下级代理商的订单数据。
这看似简单的需求,藏着一个典型的层级数据查询优化问题——如何平衡表结构的简洁性和查询的高效性?在开始讲方案前,先给大家通俗解释下后续会用到的三个核心名词,方便理解:
1. 邻接表:最基础的树形结构存储方式,核心是用“parent_id”字段记录每个节点的直接上级,就像每个员工只记自己的直属领导,结构简单、维护方便,但查询所有下级时需要层层递归。
2. 路径枚举:给每个节点存储从根节点到自身的完整路径(比如“/根ID/父ID/当前ID/”),相当于给每个节点贴了一个“全链路地址”,查询所有下级时直接通过路径模糊匹配,无需递归。
3. 闭包表:专门存储树形结构中所有节点的“祖先-后代”关系(包括直接和间接层级),比如根节点与所有子节点、孙节点的关系都直接记录,查询时无需递归或模糊匹配,直接关联即可,代价是需要额外维护这张表。
清楚了基础概念,下面就给大家分享一套实战性极强的解决方案,兼顾易用性和性能,直接落地可用!
一、核心方案:邻接表 + 路径枚举(优先选)
对于大多数业务场景,最推荐的是「邻接表 + 路径枚举」的组合方案。既能轻松维护代理商树形结构,又能快速查询层级订单,无需额外复杂操作。
1. 两张核心表:代理商表 + 订单表
(1)代理商表(agent):存储层级关系核心
核心是两个字段:parent_id(维护直接层级)和agent_path(优化查询效率),表结构如下:
CREATETABLE`agent`(`id`BIGINTNOTNULLAUTO_INCREMENTCOMMENT'代理商唯一ID',`agent_name`VARCHAR(100)NOTNULLCOMMENT'代理商名称',`parent_id`BIGINTNULLDEFAULTNULLCOMMENT'上级代理商ID,根节点为NULL',`agent_path`VARCHAR(1000)NOTNULLCOMMENT'路径枚举,格式:/根ID/父ID/当前ID/,如/1/5/12/',`create_time`DATETIMENOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',`update_time`DATETIMENOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT'更新时间',PRIMARYKEY(`id`),-- 索引优化:parent_id用于层级遍历,agent_path用于快速模糊查询子节点KEY`idx_parent_id`(`parent_id`),KEY`idx_agent_path`(`agent_path`))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4COMMENT='代理商表(树形结构)';关键字段说明:
parent_id:邻接表的核心,记录直接上级ID。比如根代理商的parent_id是NULL,它的子代理商parent_id就是根ID,维护树形结构超简单。
agent_path:路径枚举是查询优化的关键!存储从根节点到当前节点的完整路径,格式统一为「/ID/ID/…/」。举个例子:根代理商ID=1,子代理商ID=5,5的子代理商ID=12,那么12的agent_path就是
/1/5/12/。
(2)订单表(order_info):关联代理商,优化索引
订单表核心是关联代理商ID,同时通过索引优化查询效率,表结构如下:
CREATETABLE`order_info`(`id`BIGINTNOTNULLAUTO_INCREMENTCOMMENT'订单唯一ID',`order_no`VARCHAR(32)NOTNULLCOMMENT'订单编号',`agent_id`BIGINTNOTNULLCOMMENT'归属代理商ID',`order_amount`DECIMAL(10,2)NOTNULLCOMMENT'订单金额',`order_status`TINYINTNOTNULLCOMMENT'订单状态:1-待支付,2-已支付,3-已取消',`create_time`DATETIMENOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',PRIMARYKEY(`id`),-- 索引优化:agent_id用于关联代理商,复合索引提升查询效率UNIQUEKEY`uk_order_no`(`order_no`),KEY`idx_agent_id_create_time`(`agent_id`,`create_time`))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4COMMENT='订单表';索引说明:idx_agent_id_create_time是复合索引,能大幅提升「按代理商查询特定时间范围订单」的效率,避免全表扫描。
2. 极致性能备选:闭包表(高频查询首选)
如果你的业务是「高频查询层级订单」(比如每天上千次查询),或者代理商层级极深(比如10层以上),可以补充一张「闭包表」。用少量存储成本,换取极致的查询效率。
闭包表的核心作用:存储所有代理商的「祖先-后代」关系(包括直接和间接层级),比如根代理商与所有子代理商、孙代理商的关系都直接存在表中。
CREATETABLE`agent_closure`(`ancestor_id`BIGINTNOTNULLCOMMENT'祖先代理商ID(包括自身)',`descendant_id`BIGINTNOTNULLCOMMENT'后代代理商ID(包括自身)',`level`INTNOTNULLCOMMENT'层级差:0表示自身,1表示直接子节点,2表示孙子节点...',PRIMARYKEY(`ancestor_id`,`descendant_id`),KEY`idx_descendant_id`(`descendant_id`))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4COMMENT='代理商闭包表(存储所有层级关系)';注意:使用闭包表需要额外维护——新增、删除或修改代理商层级时,要同步更新闭包表。比如新增一个子代理商,需要把它的父节点、祖父节点等所有祖先,都和这个新节点建立关联。
四、实战查询:两行SQL搞定层级订单
重点来了!不管是哪种方案,查询逻辑都很简单,直接套用即可。以「查询ID=5的代理商及其所有子代理商订单」为例:
1. 路径枚举方案(无需额外维护,推荐)
核心思路:通过agent_path的模糊匹配,快速锁定所有子节点。
-- 步骤1:先获取目标代理商的路径(比如ID=5的agent_path是/1/5/)SELECTagent_pathFROMagentWHEREid=5;-- 步骤2:模糊匹配路径,查询所有子节点订单SELECTo.*FROMorder_info oINNERJOINagent aONo.agent_id=a.idWHEREa.agent_pathLIKE'/1/5/%';-- 匹配所有以/1/5/开头的路径(包含ID=5本身)这里的关键是idx_agent_path索引——有了它,模糊匹配LIKE '/1/5/%'不会触发全表扫描,效率很高。
2. 闭包表方案(性能最优)
核心思路:直接通过闭包表的ancestor_id(祖先ID),锁定所有后代代理商,无需模糊匹配。
SELECTo.*FROMorder_info oINNERJOINagent_closure acONo.agent_id=ac.descendant_idWHEREac.ancestor_id=5;-- 直接匹配所有后代节点(包含5本身)这种方式的查询速度几乎是最快的,因为只需要通过主键索引匹配ancestor_id,再关联订单表即可,适合高频场景。
三、维护注意事项:避免踩坑
- agent_path自动维护:新增代理商时,要先查询父节点的agent_path,再拼接当前ID生成自己的路径。比如新增父ID=5的子代理商(ID=12):
INSERTINTOagent(agent_name,parent_id,agent_path)SELECT'子代理商12',5,CONCAT((SELECTagent_pathFROMagentWHEREid=5),'12/')FROMDUAL;层级修改要同步:如果需要修改代理商的父节点(比如把A从B的下级移到C的下级),必须同步更新A及其所有子节点的agent_path(可以用递归SQL或在应用程序中处理)。
索引不能少:agent表的agent_path、parent_id,订单表的idx_agent_id_create_time,这几个索引一定要建,否则查询效率会大打折扣。
四、4种订单表查询方案:从易用到最优
先说明测试前提:订单表有100万条数据,agent_id字段已建索引;目标代理商及子代理商ID数量分两种情况(≤1000条、>1000条)。
方案1:基础款——IN + 预获取ID列表(最常用)
这是最直观的方案,步骤分两步:
预处理:从代理商表(或缓存)查询出目标代理商ID,以及所有层级子代理商的ID,整理成列表(比如:5,12,20,31);
单表查询:用IN将ID列表代入,筛选订单表。
实操SQL示例:
-- 假设已提前获取ID列表:5(自身)、12、20、31(所有子节点)SELECT*FROMorder_infoWHEREagent_idIN(5,12,20,31)-- 叠加业务条件(时间、状态等)ANDcreate_timeBETWEEN'2025-01-01'AND'2025-01-31'ANDorder_status=2;-- 2=已支付✅ 优点:简单易上手,无需调整表结构,适合快速落地;ID列表≤1000条时,性能几乎无损耗(依赖agent_id索引)。
❌ 缺点:当ID列表>1000条时,问题会暴露——SQL语句过长,数据库解析耗时增加;极端情况(比如ID列表1万条),IN的执行计划可能变差,甚至走全表扫描。
📌 适用场景:子代理商数量少(≤1000)、查询频率低,比如后台运营偶尔导出数据。
方案2:进阶款——临时表+JOIN(解决长列表IN痛点)
如果子代理商ID数量极多(比如上千、上万条),用IN会导致SQL臃肿、解析慢,这时候可以用“临时表”过渡,用JOIN替代IN。
核心思路:先把超长ID列表存入临时表,再让订单表与临时表JOIN(临时表仅存ID,本质还是订单表单表查询的间接实现)。
实操SQL示例:
-- 步骤1:创建临时表并插入目标代理商ID(应用层或数据库执行)CREATETEMPORARYTABLEIFNOTEXISTStemp_agent_ids(agent_idBIGINTPRIMARYKEY-- 主键自动建索引,加速JOIN);-- 批量插入ID列表(可通过应用层批量拼接,避免单条插入)INSERTINTOtemp_agent_ids(agent_id)VALUES(5),(12),(20),...,(10000);-- 步骤2:订单表JOIN临时表查询SELECTo.*FROMorder_info oJOINtemp_agent_ids tONo.agent_id=t.agent_idWHEREo.order_status=2;-- 业务条件✅ 优点:解决了超长ID列表导致的SQL解析问题;临时表的主键索引能加速JOIN,性能优于超长IN列表;无需修改正式表结构。
❌ 缺点:多了“创建临时表+插入数据”的步骤,易用性略低;临时表仅当前会话有效,每次查询需重新创建(可通过应用层封装简化)。
📌 适用场景:子代理商数量多(>1000)、无法修改订单表结构,比如高频的批量数据查询。
方案3:最优款——订单表冗余agent_path字段(彻底摆脱ID列表)
如果允许对订单表做轻微调整(仅新增1个字段),这是单表查询的最优解——把代理商的层级信息“提前沉淀”到订单表,彻底不用依赖ID列表。
核心思路来自之前提到的“路径枚举”方案:在代理商表中用agent_path存储层级路径(比如/1/5/12/,代表根→5→12的层级),然后在订单表中冗余这个agent_path字段,查询时直接通过路径前缀匹配筛选。
第一步:调整订单表结构(仅新增1个字段)
-- 给订单表新增agent_path字段,同步代理商表的路径ALTERTABLEorder_infoADDCOLUMNagent_pathVARCHAR(1000)NOTNULLCOMMENT'归属代理商的层级路径,同步agent表'AFTERagent_id;-- 给新增字段建索引(核心优化,确保前缀匹配能走索引)CREATEINDEXidx_agent_pathONorder_info(agent_path);第二步:维护agent_path字段
在创建/修改订单时,同步更新agent_path:
创建订单:根据订单归属的agent_id,从代理商表查询对应的agent_path,存入订单表;
代理商层级变更:如果某代理商的父节点修改,需同步更新其所有子代理商的agent_path,以及这些子代理商名下所有订单的agent_path(可通过触发器或应用层批量更新)。
第三步:单表查询(一步到位,无需ID列表)
-- 查询ID=5的代理商及其所有子代理商的订单-- 只需匹配agent_path以“/1/5/”开头(/1/5/是ID=5的代理商路径)SELECT*FROMorder_infoWHEREagent_pathLIKE'/1/5/%'ANDorder_status=2;-- 业务条件✅ 优点:完全单表查询,一步到位;依赖agent_path索引,前缀模糊匹配(LIKE ‘xxx%’)能走索引,性能最优;彻底摆脱ID列表依赖,不用预处理ID。
❌ 缺点:需要调整订单表结构;需维护agent_path的一致性(代理商层级变更时要同步订单表)。
📌 适用场景:可微调订单表、高频查询(比如核心业务报表、用户端订单筛选),这是长期最优的方案。
方案4:避坑款——FIND_IN_SET(绝对不推荐大数据量)
有些同学会用FIND_IN_SET函数,把ID列表拼成字符串代入,比如:
-- 慎用!大数据量下全表扫描,性能极差SELECT*FROMorder_infoWHEREFIND_IN_SET(agent_id,'5,12,20,31');✅ 优点:仅需拼接字符串,易用性高。
❌ 缺点:FIND_IN_SET无法使用agent_id索引,大数据量下必走全表扫描,查询耗时呈指数级增长。
📌 适用场景:仅测试环境、小数据量(比如几百条订单),生产环境绝对避免!
五、方案对比与选择建议
| 方案 | 性能 | 易用性 | 维护成本 | 适用场景 |
|---|---|---|---|---|
| IN+短ID列表(≤1000) | 优 | 高 | 低 | 子代理商少、查询频率低 |
| 临时表+JOIN | 优 | 中 | 中 | 子代理商多、无法改订单表 |
| 订单表冗余agent_path | 最优 | 中 | 中 | 可微调订单表、高频查询(推荐) |
| FIND_IN_SET | 差 | 高 | 低 | 仅测试/小数据量(不推荐) |
六、核心优化要点
无论选哪种方案,以下2个优化点能让性能再上一个台阶:
确保索引生效:这是基础中的基础!agent_id或agent_path字段必须建索引,否则再优的方案也会全表扫描;
避免返回超大结果集:如果查询结果可能超过1万条,一定要用LIMIT分页,或先统计数量再分批次查询(比如导出数据时,分10批查询每批1万条);
ID列表预处理优化:用IN或临时表时,尽量通过应用层批量拼接ID列表/插入数据,避免单条操作导致的性能损耗。
总结:怎么选最适合?
大多数场景:选「邻接表 + 路径枚举」,维护简单,查询效率足够,无需额外操作;
高频查询/层级极深:选「邻接表 + 路径枚举 + 闭包表」,用闭包表承载核心查询,兼顾维护性和性能;
这套方案的核心优势就是「平衡」——既不像纯邻接表那样查询慢,也不像纯闭包表那样维护复杂,完全适配层级不限的代理商场景,落地后能轻松支撑层级订单查询需求。
如果你的业务有特殊场景(比如代理商数量超10万、订单日均10万+),可以在评论区留言,再给你针对性优化索引和查询语句!