现有表设计
先来看下目前商品模块的表设计,对照要实现的商品模块查询要求,看看这样的设计合不合理?
商品表中的分类id字段作为外键,指向了分类表的主键,但要注意,它只取三级分类,也就是分类表中维护的叶子节点的分类数据。这种设计在电商项目中也很常见。因为用户更倾向于用更具体的分类来检索和定位自己想要的商品嘛。
现在我们再来看商品前台和后台查询的几个场景:
- 后台管理员可以按任意一级的分类来查询商品
- 前台用户可以查看每个大类(一级分类)下的最新上架的商品列表(前6个)
- 前台用户按三级分类精确检索商品列表
主要看前两个查询,目前的设计,我们需要对外层分类尤其是一级分类进行多级遍历获取三级子分类,再去商品表里按三级分类来匹配查询。这里用父分类获取三级分类列表比较麻烦,除了要多层遍历外,还要做子类的去重处理,很麻烦。
调整表设计
现在我们思考怎么调整设计,来让基于分类的查询更容器实现。为了支持前台用户可以用大类检索商品,我们很容易想到在商品表中加一个一级分类的字段root_categoty_id
。这确实解决了按一级分类查询的问题,但还是没有解决按任意层级比如二级分类来查询商品,很显然冗余一级、二级分类id到商品表,增加了分类变动的维护开销,因为比起分类数据,商品的数据将会是几个数量级的,在有调整分类的需求时,这无疑会动大量的商品记录进行分类字段的更新。
那有没有什么更好的设计方案呢?
既然我们的分类考虑到复杂度和用户使用的习惯,只设计到三级,那我们可以对三级分类的记录存储时,除了pid
可以引用到二级分类,再加一个root_id
字段来引用一级分类。这样对于按照一级、二级分类查询三级分类的需求,实现起来就很简单了:传入一级按root_id
匹配,传入二级用pid
匹配即可。
调整分类模块实现
pdm新加字段
修改pdm模型,为分类表增加一个字段:
idea数据库工具连接本地h2数据库,右键新增column:
执行mybatis生成器
放开build.gradle中下面注解:
刷新下gradle配置,双击重新执行生成任务:
in查询代替递归查询
前面的小节商品分类接口功能完善(拖拽、redis整合),我们在实现分类层级的遍历和更新时,采用的是递归的处理方式,即获取到pid
对子分类进行操作,同样再以子分类的id
作为下一个层级的pid
进行递归操作,这种执行效率的性能问题在于当分类条目变多,发送的sql语句的次数相应也增多,网络请求也变多,能不能每个层级只执行一次查询,而不用对每个条目都进行sql查询,自然我们想到了使用in
条件查询,即,把每一层级的所有分类id都查出来,作为下一层级的pid
,通过in
查询来实现。因为相比于用户数据,后台分类数据量不会很大,且层级最多3级,也就是说真实场景下一个大类中的三级分类顶多几百条,用in
查询相比发送上百条sql语句的效率自然是有巨大的提升的。因此,我们对之前的查询进行改造:
java
package com.xiaojuan.boot.dao.mapper;
import ...
public interface CustomCategoryMapper {
...
@Update("<script>update TB_CATEGORY set LEVEL = #{level} + 1 where PID in <foreach collection='pids' item='pid' open='(' separator=',' close=')'>#{pid}</foreach></script>")
int updateChildrenLevel(@Param("pids") List<Long> pids, @Param("level") int level);
@Update("<script>update TB_CATEGORY set ROOT_ID = #{rootId} where ID in <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
int batchUpdateRootId(@Param("ids") List<Long> ids, @Param("rootId") long rootId);
@Select("<script>select id from TB_CATEGORY where PID in <foreach collection='pids' item='pid' open='(' separator=',' close=')'>#{pid}</foreach></script>")
List<Long> listChildIds(@Param("pids") List<Long> pids);
}
这里我们采用的注解的形式足以应对简单的查询了,只不过这里通过<script>
标签脚本的形式我们增加了<foreach
标签来支持in
查询传入列表参数的形式。看下以上三个方法的实现意图:
-
updateChildrenLevel
支持批量的更新某个分类或者某个层级下所有的子分类的
level
字段值,则在级联更新移动分类后的子分类层级会用到。 -
listChildIds
用于批量的查询某个分类或者某个层级下所有的子分类的id列表,在查看某个分类下面的层级深度以及需要按层级维度来进行批量操作时会用到。
-
batchUpdateRootId
在我们为分类表引入了冗余的一级分类字段后,在级联更新移动分类后的该字段时会用到。
再看通过in
语句改造后的CategoryServiceImpl
类中的级联操作方法:
java
public int cascadeCalcDepth(long pid) {
int length = 0;
List<Long> pids = Collections.singletonList(pid);
do {
pids = customCategoryMapper.listChildIds(pids);
length++;
} while (!CollectionUtils.isEmpty(pids));
return length - 1;
}
这样我们只需要每层执行一次查询,而先前的递归遍历效率实在是低下。
再看级联更新层级字段的方法改造后:
java
private void cascadeUpdateLevel(long pid, int level) {
List<Long> pids = Collections.singletonList(pid);
while (level < 3) {
customCategoryMapper.updateChildrenLevel(pids, level);
if (level == 2) break;
pids = customCategoryMapper.listChildIds(pids);
level++;
}
}
引入冗余root_id后的实现调整
在进行分类新增时,如果判断新增的是三级分类,就要设置上该字段:
java
public void addCategory(CategoryAddDTO categoryAddDTO) {
// 前置判断逻辑省略
...
Category category = new Category();
...
// 如果新增的是三级分类就查找下一级分类
if (level == 3) {
long rootId = customCategoryMapper.getPid(pid);
category.setRootId(rootId);
}
...
categoryMapper.insertSelective(category);
...
}
在移动分类方法实现的最后要级联更新root_id字段,这里多加一个判断逻辑:如果被移动的节点拖拽放置后,层级没有变且根分类没有变,则不进行root_id字段的级联更新:
java
@Transactional
@Override
public void moveCategories(CategoryMoveDTO moveCategoryDTO) {
// 前面逻辑省略
...
// 获取原来的层级
long level = customCategoryMapper.getLevel(dragId);
long oldRootId = getRootId(dragId);
categoryMapper.updateByPrimaryKeySelective(updateDrag);
// 级联处理被拖拽的子分类的层级
if (level != updateDrag.getLevel()) {
cascadeUpdateLevel(dragId, updateDrag.getLevel());
}
// 更新拖拽后的节点的三级节点的rootId
long rootId = getRootId(dragId);
if (level != updateDrag.getLevel() || oldRootId != rootId) {
cascadeUpdateRootId(Collections.singletonList(dragId), updateDrag.getLevel(), rootId);
}
stringRedisTemplate.delete(REDIS_CATEGORY_DATA_KEY);
}
private long getRootId(long id) {
long tempId;
do {
tempId = id;
id = customCategoryMapper.getPid(id);
} while (id != 0);
return tempId;
}
private void cascadeUpdateRootId(List<Long> ids, int level, long rootId) {
Long rid;
for (int i = level; i <= 3; i++) {
rid = i < 3 ? null : rootId;
customCategoryMapper.batchUpdateRootId(ids, rid);
if (i == 3) break;
ids = customCategoryMapper.listChildIds(ids);
}
}
在分类移动后,对拖拽的分类节点和子节点更新它们的root_id字段,如果level
为3,则更新为具体的值,否则更新为null
,因为我们希望一级和二级分类的记录该字段留空。