1. 项目背景与需求本质
类目树形结构查询,听名字就知道是做什么的——把数据库里一张平铺的类目表,转换成前端能直接用的层级树。这个需求在实际业务中太常见了:电商的商品分类、后台管理系统的菜单权限、组织架构的部门层级,本质上都是同一类问题。
我最初接到这个需求,是给某电商后台系统做商品管理模块。客户提的需求原文很简单:进入商品管理页面,左侧要有完整的类目树,点哪个大类就展开哪个子类,商品列表跟着联动。但“类目树”三个字背后的复杂度,远不是写个递归就完事那么简单。
先说清楚这个问题的本质。数据库永远主张“扁平存储”——一张表,每条记录就是一行,常用字段记为两列:自身ID和父级ID。这种设计叫做邻接表模型,稳定、直白、易于增删改查。但真实业务中,类目的层级是不确定的:有的类目只有两级(大类→小类),有的类目要挂五六层(服装→男装→上衣→卫衣→加绒卫衣)。如果前端拿到的是一张平铺的列表,渲染逻辑就会非常痛苦——前端要自己反复嵌套循环,遇到深层数据还得写递归,性能和可维护性都会崩。
所以后端在返回数据时就应该直接把树形结构组装好:每个节点里挂着它的children数组,一层一层嵌套下去,前端拿到数据直接循环渲染即可,不用管层级到底有多深。这就是“类目树形结构查询”这个需求的核心目标。
我当年犯过的最大错误,就是一上来就写了一个经典递归——在Service层根据某个父ID去查子类目,查到了再递归查孙类目。功能确实实现了,但等到数据库里类目数据量涨到上万条,接口响应速度就变得让人难以接受,而且日志里全是SQL打印,一次树查询居然发出了几百条SQL。这次的教训换来了一个结论:树形结构的组装,核心矛盾不在“怎么递归”,而在“怎么少查数据库”。
这个项目适合谁参考?如果你正在做电商后台、内容管理系统、权限菜单模块,或者面试中被问到“怎么实现一个树形结构”,这篇文章里讲的思路、代码和踩坑记录,基本可以做到直接拿来用。我会把方案比对、完整代码、性能实测和典型问题都写出来,不讲虚的。
2. 整体设计方案与核心思路拆解
2.1 邻接表模型下,建表是第一步也是关键一步
在设计类目表结构之前,需要先厘清三个问题:表里存哪些字段、父子关系怎么表达、层级信息要不要冗余存储。
我见过很多新手的建表习惯,就是把业务字段一股脑全塞进去,id、name、parent_id就完事。这么做在一个全是内部数据的小系统里确实能跑,但一旦牵涉到多端展示、排序、状态控制,这种表结构会让人欲哭无泪。
实际项目中,我建议的核心字段至少包含以下几类:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增或雪花ID |
| parent_id | bigint | 父级ID,顶级节点的父ID为0或NULL |
| name | varchar | 类目名称 |
| level | int | 层级,从1开始,冗余字段,便于统计和限制深度 |
| sort | int | 同级排序权重,数值越小越靠前 |
| status | tinyint | 状态:1启用,0禁用 |
| leaf | tinyint | 是否叶子节点,1是0否,冗余字段 |
| create_time | datetime | 创建时间 |
很多教程会省略level和leaf这两个冗余字段,但真实业务里它们的作用很大。比如客户要求“只展示叶子类目”,如果没有leaf字段,就得上树以后逐个递归判断“这个节点下面还有没有子节点”,性能极差。而level字段则在“限制管理端只能建到三级类目”这类需求中非常有用,一条SQL就能查出层级异常的脏数据。
父子关系表达选择parent_id是公认的标准方案,这一点没有争议。真正的分歧点在于:要不要用类似“path”的物化路径字段来冗余全路径。比如某个类目的path存成“/1/20/35/”,查询某个节点下的所有后代,就能用一次LIKE查询解决。我的经验是,小系统没必要,层级控制在五级以内、数据量在一万以内时,parent_id的方案完全够用。path方案适合深度不固定、频繁需要“查某个节点下全部后代”做统计的场景,但它牺牲了移动节点时的便利性——一旦调整了层级,所有子节点的path都要连带更新。
2.2 三大查询方案对比:为什么最终选定了内存组装
树形查询的主流实现方案,我梳理下来大致有三种。
第一种是数据库递归查询方案。MySQL 8.0支持WITH RECURSIVE语法,一条SQL直接把某个节点下的所有层级数据都查出来。PostgreSQL、Oracle也都有对应的递归语法。这个方案的优点是代码极简、逻辑清晰,数据库返回的就是组装好的平铺结果,后续只需在代码里做层级装配。缺点是——不同数据库的递归语法不通用,团队如果打算换数据库,这一段SQL基本得重写;另外,复杂业务(比如递归时要过滤多个条件、要按sort多重排序)写在SQL里会非常难以调试。一个项目里如果核心系统是MySQL 8.0以下版本,直接放弃这个方案。
第二种是多次查询、逐层递归方案。在Service层先查所有顶级节点,然后遍历每个顶级节点再发一条SQL查它的子节点。这是新手最常写的树查询,代码结构一目了然,问题是性能极差——假设树有5层,每个节点平均10个子节点,一次全树查询就要发出1+N条SQL,N就是节点总数。这个方案我在前面的教训中已经提过,一旦数据量上来就是灾难,不建议在真实系统中使用。
第三种是全量查询加内存组装方案。一次性把整张类目表全查出来(只查需要的字段),然后在Java内存中通过Map索引和循环来完成父子关系的装配,全程只发一条SQL。这是目前我在实际项目里最终采用的方案,也是这篇文章要重点展开讲解的。
为什么最终选了内存组装?核心原因有三个。
第一,类目表的数据量是有天花板的。一个电商平台的类目撑死也就是几千几万条,这些数据全量加载到内存里,占用的空间可能还不到1MB,毫无内存压力。内存组装把IO次数降到一次,的代价不过是遍历一遍列表,收益和成本完全不成比例。
第二,树形组装本身适合在代码层面控制。业务里常有“只显示启用状态的类目”“叶子节点要额外标记”“按sort排序”这些逻辑,在Java中处理这些数据筛选比在SQL中写一堆条件直观得多。一次全量查询,把组装和过滤都放到Service层,代码可读性和可维护性都更好。
第三,配合缓存方案,效果拔群。全量查询的结果非常适合放进Redis缓存,因为数据本身相对稳定,且整棵树的变更通常只在后台维护时发生。如果用逐层递归方案,缓存什么、怎么失效都是问题——你没法确定某一层的缓存被哪些数据依赖。而全量内存组装方案只需有一个“类目树全量”的缓存key,更新类目时删掉这个key即可。
2.3 数据量边界:什么情况下内存组装方案会失效
内存组装不是银弹,它有一个明确的适用边界。我自己的判断标准是:当类目总数据量超过十万条、且层级普遍超过六级时,就该重新评估方案了。
十万条数据是什么概念?如果一个类目表的记录数达到这个量级,通常意味着业务已经从“电商类目”滑向了“组织架构”甚至“标签体系”——后者成员动辄百万级,就不适合用“树”来描述了。真要处理百万级层次数据,那不应该把整个表查出来,而是应该走路径枚举方案,通过范围查询或者前缀查询直接锁定子树,配合数据库索引和分页才能扛住。
在class="product-category"这种典型场景(几千条、最多五级),全量内存组装是综合最优解:代码简单、性能好、易维护、好缓存。
3. 核心实现:从SQL到Java的完整落地
3.1 基础实体与Mapper层设计
实体类定义不多说,直接看代码:
public class Category { private Long id; private Long parentId; private String name; private Integer level; private Integer sort; private Integer status; private Integer leaf; // 用于接收后端组装结果的子节点列表 private List<Category> children = new ArrayList<>(); }这里有一个很关键的细节:实体类里为什么会有一个children字段?因为MyBatis可以在一次查询中用嵌套结果映射自动组装树,但那种写法在N+1问题上难以把控,而且映射配置极其繁琐。我更推荐的做法是:实体类照常承载数据库字段,children字段仅作为返回VO使用,在Service层手动填充。
Mapper层的方法也很简单,就一条查询:
@Mapper public interface CategoryMapper { // 批量查询全部启用状态的类目,按父级ID和排序字段整体排序 @Select("SELECT id, parent_id, name, level, sort, status, leaf " + "FROM category " + "WHERE status = 1 " + "ORDER BY parent_id ASC, sort ASC") List<Category> selectAllEnabled(); }注意ORDER BY的设计逻辑。很多人在这一步会随手写个ORDER BY sort,实际使用时你会发现:如果不先按parent_id分组,那么在内存中遍历时就会遇到“子节点的父节点还没处理”的顺序问题。先按parent_id升序排,再按sort升序排,就能保证同一父级下的子节点连续出现,后续内存组装只需要一次遍历。
这段SQL还可以根据业务裁剪字段,比如管理端不需要leaf时就不查,省一点传输开销。但为了方便统一返回结构,通常都查全。
3.2 Service层组装:两种内存组装方式对比
内存组装的主流写法有两种:双层循环法和Map索引法。这里直接给出两种写法的代码,并分析各自的取舍。
双层循环法:思路是从列表中取每个节点,再遍历整个列表去找它的父节点。找到就把自己加进父节点的children里。代码大约是这样:
public List<Category> buildTree(List<Category> list) { List<Category> tree = new ArrayList<>(); for (Category node : list) { if (node.getParentId() == null || node.getParentId() == 0L) { tree.add(node); } for (Category possibleParent : list) { if (node.getParentId().equals(possibleParent.getId())) { possibleParent.getChildren().add(node); break; } } } return tree; }这段代码能跑,但时间复杂度是O(N²)。假数据量是1万条,双层循环意味着最多1亿次比较,Java处理这种量级也要几百毫秒,接口响应不可接受。而且这个代码还有隐藏问题:万一某个节点的父级被status过滤掉了(父级被禁用),这类节点就会丢失在最终的树里,如果业务上希望“父级禁用时子级也不显示”,这算符合预期,但如果你不希望这样——比如父级只是在非当前场景下被禁用——这里就会出Bug。
Map索引法:在一次遍历中,先建立每个id到节点的映射,第二次遍历时通过map找父节点,时间复杂度降为O(N)。这是我在实际项目中最终采用的写法:
public List<Category> buildTree(List<Category> list) { // 先建索引:id -> 节点对象 Map<Long, Category> indexMap = new HashMap<>(); for (Category category : list) { indexMap.put(category.getId(), category); } List<Category> tree = new ArrayList<>(); for (Category category : list) { Long parentId = category.getParentId(); Category parent = indexMap.get(parentId); if (parent != null) { // 父节点存在,把自己挂到父节点的children里 parent.getChildren().add(category); } else { // 父节点不存在(可能是顶级节点,也可能父级被过滤掉了) tree.add(category); } } return tree; }这个方案在数据量达到1万条时,两次遍历加上HashMap查询,单次耗时在个位数毫秒级,和双层循环方案完全不是一个量级。理解Map索引法的关键是:HashMap的get操作平均时间复杂度是O(1),所以整体从O(N²)降到了O(N)。
但这里有一个非常容易被忽视的细节:children的顺序问题。我们虽然SQL里已经ORDER BY sort了,但在遍历list挂载子节点时,由于Map索引法是按照list原有顺序依次追加到parent的children中,只要SQL中的排序正确,这里追加到children里的顺序也就是对的。反倒是如果SQL里没有排序,这里得到的children顺序就会随数据库返回顺序漂移,前端展示时会出现类目乱序的现象。所以SQL中的排序不是可选项,是必须项。
3.3 完整接口实现:Controller到Service的贯通
实际项目里,我不会在Controller里直接返回Map-tree这种结构,而是会包一层统一的响应格式。下面给出一个完整的接口实现供参考。
@RestController @RequestMapping("/api/category") public class CategoryController { @Resource private CategoryService categoryService; @GetMapping("/tree") public Result<List<Category>> tree() { return Result.success(categoryService.getCategoryTree()); } }Service实现类:
@Service public class CategoryServiceImpl implements CategoryService { @Resource private CategoryMapper categoryMapper; @Override public List<Category> getCategoryTree() { // 第一步,全量查询 List<Category> allCategories = categoryMapper.selectAllEnabled(); // 第二步,优先从Redis缓存取,缓存没有就组装后回填 String cacheKey = "category:tree:all"; String cachedJson = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.hasText(cachedJson)) { return JSON.parseArray(cachedJson, Category.class); } // 第三步,内存组装 List<Category> tree = buildTree(allCategories); // 第四步,写入缓存,过期时间根据业务更新频率来定 redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(tree), 30, TimeUnit.MINUTES); return tree; } private List<Category> buildTree(List<Category> list) { Map<Long, Category> indexMap = new HashMap<>(); for (Category category : list) { indexMap.put(category.getId(), category); } List<Category> tree = new ArrayList<>(); for (Category category : list) { Category parent = indexMap.get(category.getParentId()); if (parent != null) { parent.getChildren().add(category); } else { tree.add(category); } } return tree; } }这一步的缓存设计有个要点值得单独说:缓存内容应该是组装完成后的树,而不是原始平铺列表。如果缓存平铺列表,那么每次读取缓存后仍然要做一遍内存组装,虽然省了数据库查询,但CPU还是白白消耗。缓存树结构,就是让前端拿到的结果直接可用,同时服务端也省掉每次请求的组装开销。
另外,缓存失效策略不能用定时任务全量刷新,而是应该在新增、修改、删除类目时主动删除该缓存key。因为类目变更的频率很低(通常只有运营人员操作),主动删缓存要比定时任务干净得多,也不会出现“改了数据库缓存里还是旧数据”的尴尬。
关于JSON序列化Category对象,需要注意children字段是List类型,序列化没有任何问题。但要在Category类里针对children字段加一个@JsonInclude(JsonInclude.Include.NON_EMPTY)注解,避免叶子节点的children返回空数组,白白增加传输体积。
4. 实操过程中的关键细节与避坑经验
4.1 父子关系维护的根基:删除与移动的联动
树形结构的坑,往往不在查询,而在写入。当你的类目支持删除和移动时,查询方案的健壮性才会真正受到考验。
删除类目的经典场景是:运营删除了“服装”这个大类,那“男装”“女装”“卫衣”这些子类目怎么办?两种处理思路:第一种是逻辑删除——把status字段置为0,子类目保留,只是查询时不出现。第二种是物理删除——连同所有子类目一并删除,这时需要在Service层先查出所有子类目的id列表,再执行批量删除。
我在项目中用的是第一种逻辑删除为主、第二种仅用于手动清理。原因是逻辑删除最安全——运营误操作了还能恢复,而且子类目不会因为父类目被误删而永久丢失。但逻辑删除必须配合前面提到的leaf字段做一致性检查:如果一个父类目要禁用,你最好同时提示“该父类目下有N个子类目,确认一起禁用吗?”否则会出现“父类目看不到、子类目却一直存在”的心智混乱。
移动类目的场景也不少见:把“卫衣”从“男装”下挪到“运动服装”下。这里要注意的是,移动操作会连带改变所有子类目的level值。如果level字段是冗余存储的,就需要在移动时对该节点及其全部后代做一次level重算。这个重算逻辑也要在Service层通过递归来完成,同时记得删除缓存。如果你没有level字段,那就没有这个烦恼——所以冗余字段是一把双刃剑,用的时候必须清楚它要付出的维护成本。
4.2 排序的稳定性和动态调整
类目排序是另一个容易翻车的地方。前端树形组件通常支持拖拽排序,拖完提交时要带上每个节点的sort值。问题在于,同级的类目之间也许已经排好序了,跨级的移动却会打乱不同父节点下的相对顺序,如果你在SQL里只ORDER BY sort,不做任何处理,树的展示顺序就会随移动而变化,引发运营的投诉。
解决方案有两个:
第一,排序字段设计为“可调整的大间隔整数”。比如初始sort分别为100、200、300,而不是1、2、3。这样如果运营想把“男装”插到“女装”和“童装”之间,无需修改后面所有元素的sort值,只需要把“男装”的sort从200改成150即可——极大减少排序变更时涉及的更新行数。
第二,树排序时,在同级之间按sort排序,一旦sort值冲突,再按id排序兜底,保证展示顺序的确定性。SQL可以写成ORDER BY parent_id ASC, sort ASC, id ASC。这个细节很多人忽略,但如果sort是由前端拖拽提交的,很大概率会出现两个节点sort值相同的情况,没有id兜底排序,顺序就会随机。
4.3 组装过程中的循环引用与脏数据防御
内存组装“一条查询全部搞定”最怕什么?最怕数据表里有脏数据,比如循环引用——A的父节点是B,B的父节点是A。这种数据一旦出现,传统的递归组装会无限递归,直到栈溢出,整个接口挂掉。
Map索引法在一定程度上天然防御了循环引用:因为每个节点只往父节点的children里挂一次,不存在递归遍历,所以循环引用不会导致代码层面崩溃,但这会导致一个更隐蔽的问题——这个环里的所有节点都不会出现在最终树中。因为A在等B做父节点,B也在等A做父节点,两个都被谁挂载,都不在顶级节点列表里,整棵子树就莫名其妙消失了。
解决这个问题的办法是增加一层防御性校验:组装的最后,对树做一次节点数量统计,如果树中的节点数不等于全量节点数,说明有脏数据(丢失了部分节点),这时可以标记一场并记录日志,后续处理。这一段代码可以这样写:
int nodeCount = tree.stream() .mapToInt(node -> 1 + countChildren(node)) .sum(); if (nodeCount != list.size()) { log.warn("类目树组装异常:全量节点{}个,树中节点{}个,可能存在循环引用", list.size(), nodeCount); // 这里根据业务决定是抛异常还是降级返回 }当然,更根本的防御手段是在写入时校验——新增或修改类目时,判断新父节点是否是该节点的子节点,一旦是,直接拒绝操作。这种校验用递归在Service层写起来也简单,核心就是向上追溯parentId链,看是否走到了自己的id。
4.4 树过深的前端渲染问题
类目树确实可能出现七层、八层的场景,比如汽配行业的“品牌→车型→年款→配件系统→配件大类→配件小类”。前端组件对深层嵌套的DOM渲染性能通常很差,展开深层节点时页面卡顿明显。
面对这种场景,正确的策略是“懒加载”:顶层的接口只返回一级类目,用户点击展开某个父节点时,再请求该父节点下的子节点列表。这种方案下的后端接口,就是典型的“根据parentId查子节点列表”,完全不需要组装整棵树。这也是一种可行的类目查询方案,适合层级极深但单层数据量不大的场景。
懒加载方案的实现要点是:底层接口本身不需要缓存整棵树,只需要对每个父节点下的子节点列表做缓存,缓存key可以是category:children:{parentId}。但要注意,父节点的子节点列表一变,相关父节点和祖先节点的缓存全部要清掉。所以说,没有万能的方案,只有针对场景的取舍。如果层级深、单层数据量小、用户常按需展开,懒加载远比全量内存组装更合适。
5. 常见问题与排坑实录
5.1 查询变慢:一次树查询发出几百条SQL
症状:接口响应从几十毫秒飙升到几秒,日志里刷屏的是大量重复的“SELECT * FROM category WHERE parent_id = ?”记录,每次负载测试都把数据库连接池打满。
原因:有人用了逐层递归方案,类目总数五千条,查完整个树就要发五千多条SQL。即使每条SQL只有0.5毫秒,串行叠加也会把接口拖到不可接受。
解决:切换到全量查询加内存组装方案,只保留一条全量SQL,耗时立刻从秒级降到个位数毫秒。如果你的树形查询接口现在也扛着几百条SQL在跑,优先做这个替换。这里真正的优化目标是减少数据库往返次数,而不是“优化单条SQL”——单条SQL再快,乘以几千次也是灾难。
5.2 顶级节点丢失:父级节点被过滤了,子节点全没了
症状:类目树少了几个分支,数据表里明明有记录,但怎么查都查不出来。
原因:全量查询时加了WHERE status = 1的过滤。父节点被禁用后,子节点虽然状态是启用,但父节点在indexMap里不存在,于是子节点因为找不到父节点被归到了“顶级节点”列表,而前端预期中“父级禁用则整棵子树不展示”,所以表现是——子节点被错误地当成顶级节点展示,或者被业务代码过滤掉,最终看不到。
解决:先明确业务需求。如果“父级禁用则子级全部不显示”,那么在组装完毕后,对顶级节点做过滤时,要确保子节点不会因为父节点缺失而错误地出现在顶级列表中。解决思路是:组装时,把找不到父节点的节点单独记录,如果业务上要求“父禁用则子树隐藏”,就把这类节点直接丢弃;如果不希望隐藏,则说明不能用“父级禁用就查询不到”的SQL过滤方式,而是要在查询时把status的过滤放到组装完成之后。这个度需要结合具体业务来决定,不同系统不同处理,但逃不过一个核心认知:树形查询的过滤条件,要么全局统一,要么在组装的最后阶段才做,绝不能在中途搞双重标准。
5.3 缓存内容与数据库不一致
症状:后台改了类目名称,前台界面迟迟不变;清了应用缓存才好。
原因:更新类目后忘了删除缓存key;或者缓存设置过期时间过长。类目变更频率低,30分钟过期看起来合理,但运营改完名称等30分钟看效果,这体验太差了。
解决:在所有写入操作(新增、修改、删除、排序、移动)的事务方法内,统一调用一个“清空类目树缓存”的工具方法。清理动作应该和数据库更新在同一个事务里执行,或者至少保证“先更新数据库,再删缓存”,避免删除缓存后但数据库事务回滚,导致明明缓存删了但数据没改。这里可以进一步用延迟双删策略防极端情况,但类目场景一般不需要做到这个程度。主动清除缓存永远比定时刷新更可靠。
5.4 数据量可控却依赖全量查询,内存占用会不会有问题
有些同学对“全量查询”有天然抵触,总觉得一次性把表全查出来不符合“优雅”的直觉。这里算笔账就清楚了:一个类目表假设有2万条记录,每条记录包含id、parentId、name、level、sort、status、leaf这几个字段,name就算每个占50字节,单条记录整体估计在100字节,2万条也就2MB左右。2MB内存对现代JVM来说可以忽略不计,哪怕配上VO对象封装后膨胀到10MB,对于一次查询来说也完全在安全范围内。这个数量级下,没必要为了一两百KB的内存去设计分页或懒加载。
真正的风险点在于,全量查询会一直查那些已经废弃的历史类目——如果系统长时间积累了几十万条废弃类目却从不清理,全量查询的数据量就会失控。所以我会在侧边加一条定期清理机制:超过N个月未更新的禁用类目,进入归档流程,从主表移出。这个机制能保护全量查询方案在长期运行中可持续。
5.5 多级缓存下,树组装重复执行
我见过一种写法:先从缓存里查出平铺列表,然后每次请求都重新组装树。这样设计的初衷是缓存列表便于做单个节点的增删,但结果是每次请求都白白浪费CPU。正确做法是组装完成后再缓存,查询请求直接返回树的JSON序列化结果即可。
如果一定要缓存平铺列表(比如后台管理列表需要频繁单行操作),也请在Service内部加一层“本地内存缓存树结构”的二级缓存:列表变化时先把本地缓存清掉,下次请求再组装并回填。虽然多了一层复杂度,但对高频读取场景收益明显。
6. 项目复盘与后续扩展建议
类目树这个需求,看起来不过是“查一张表、拼一颗树”,但真正落地后你会发现,它牵扯到SQL设计、内存算法、缓存策略、数据一致性、异常防御等一系列环节。解决单点问题不难,难的是让整条链路在数据增长、业务变更、并发访问下还能保持稳定和高效。
我个人的体会是:凡是“树形结构”类需求,第一步永远是摸清两个边界——数据总量多大、层级最深能到多少。这两个数字直接决定你在“全量内存组装”“数据库递归”“懒加载”三种方案里选哪一个。绝大多数业务的类目、菜单、组织架构,数据量都在万级以内,层级不会超过五层,这时全量内存组装就是综合最优选。如果踩到了十万级、几十层的场景,才需要考虑更复杂的方案,但那时候你要解决的核心问题已经变成“树的存储模型”,而不是“树的查询写法”了。
在代码层面的后续扩展,我给几个明确建议。
第一,把组装逻辑下沉到一个独立的TreeBuilder工具类,泛型化处理。类目、菜单、部门都能复用同一个组装工具,不必每个模块写一套buildTree。泛型化的核心是传入一个“获取父ID、设置子列表”的接口,代码结构会干净很多。
第二,引入CompletableFuture做并发组装。只有当类目表被拆成多张分表、查询需要并发合并时才需要,普通场景没必要引入,过度设计同样是坑。
第三,对于需要跨服务获取树数据的场景(比如订单中心需要展示商品类目树,但类目数据在商品服务里),用缓存接口返回树JSON的“降级兜底”策略比RPC调用后二次组装更适合。因为树数据的强一致要求不高,只要缓存里有数据,哪怕背后服务挂了,前端照样能拿到树。
最后再送一个实用小技巧:给类目树接口增加一个debug参数,比如?includeDisabled=true。平时线上接口默认只返回启用类目,排查问题时可以带这个参数查看完整树。这个功能实现成本极低,就是在Service层多传一个过滤开关,但排障效率提升显著——很多时候你觉得“树不见了”,其实是数据被过滤了,而不是组装有bug。
这个项目做完,最大的收获不是掌握了某种查询技巧,而是学会了“先定位问题边界,再做方案选型”。数据量小的时候用最简单的方案,数据量大了自然知道该换哪一档。类目树如此,其他很多系统设计也是如此。