☰
Asp.Net三层架构无限极分类增删改查实战
2026/10/6 3:58:34 网站建设 项目流程

简介:这份Asp.Net三层架构版无限极分类项目,面向初中级.NET开发者,演示如何用分层设计实现商品分类、部门结构等无限层级树形数据的增删改查。项目以hyclass模块为主线,完整覆盖表现层(aspx页面)、业务逻辑层(BLL的cs文件)、数据访问层(DAL、DBUtility),并附数据库脚本与配置文件,可直接在Visual Studio中打开运行。具体包含13个cs源码、4个aspx页面、24个dll动态库及配套pdb调试符号,数据库mdf/ldf文件一并打包,共94个文件,压缩包仅757KB,目录结构清晰,适合快速定位各层代码。目前已有177人浏览学习。通过该项目可掌握无限极分类的自关联表设计、递归获取全部子分类、级联删除分类、分类名称唯一性校验等核心技巧,同时理解三层架构如何实现高内聚低耦合,提升系统的可维护性与可扩展性,是一份实用性很强的Asp.Net实战参考。

1. 无限极分类与三层架构:先搞清楚这资源到底治什么病

做后台管理系统,分类表一深就头痛。一级分类、二级分类、三级分类……写递归写吐了不说,增删改查里还埋着各种雷:删除父分类子分类变孤儿、修改父节点产生循环引用、递归层级太深直接栈溢出。这些场景做电商、CMS、OA 的从业者基本都撞过。这份 Asp.Net 三层架构版无限极分类资源,解决的就是这个问题:把无限极分类的增删改查完整实现,按 UI 层、业务层、数据层拆开,从数据库表设计到前端 Tree 联动全都覆盖。适合刚接手 .NET 维护项目的新手,也适合想看看别人怎么组织分层逻辑、想直接抄一套分类模块的熟手。不是什么高深算法,但能把分类管理做得不翻车,本身就是本事。

2. 先把三层架构拆开:UI 层、业务层、数据层各自管什么

2.1 三层不是三个文件夹:职责边界才是关键

很多项目标着三层架构,实际上就是一个 Web 项目里塞了三个文件夹。UI 层直接 new SqlConnection,业务层里写 SQL,数据层反而啥也不干。这种架构形同虚设,改一个字段要翻遍全项目。

真正的三层架构,每一层只有一个职责。UI 层只负责接收请求和展示结果,不碰数据库。业务层做逻辑判断、事务控制、递归组装,不写 SQL。数据层只做最基础的增删改查,把 DataTable 转成实体集合,把参数传给存储过程或 ORM。这个资源的分层方式就是标准做法:UI 层调用 BLL(业务逻辑层),BLL 调用 DAL(数据访问层),DAL 访问数据库,实体类在各层之间流动。

用这张图理解:

  • UI 层:接收用户点击的“新增分类”请求,把表单数据封装成实体,调用 BLL 的新增方法。
  • BLL 层:检查分类名是否重复、父节点是否存在、层级有没有超限,然后调用 DAL。
  • DAL 层:执行 INSERT 语句,返回受影响行数。

2.2 实体类与数据库表设计:分类表只需要四个核心字段

无限极分类最常见的表结构是邻接表,也就是表里只存一个父节点 ID。这个资源里的分类表设计,核心字段就是四个:分类 ID、父分类 ID、分类名称、排序号。SQL Server 下的建表语句大致是这样:

CREATE TABLE Category ( Id INT IDENTITY(1,1) PRIMARY KEY, -- 分类ID,自增主键 ParentId INT NOT NULL DEFAULT 0, -- 父分类ID,0表示顶级分类 Name NVARCHAR(50) NOT NULL, -- 分类名称 SortNo INT NOT NULL DEFAULT 0, -- 排序号,同层级内按此排序 CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); GO CREATE INDEX IX_Category_ParentId ON Category(ParentId);

这里有一个值得注意的设计细节:ParentId默认值设置为 0,而不是 NULL。顶级分类的父 ID 是 0,这样查询顶级分类只需要WHERE ParentId = 0,代码里也不用判断空值,在 C# 里直接用 int 类型接收,省掉很多空值分支。排序号SortNo是为了在同一父节点下控制分类的顺序,没有这个字段,后加的分类只能按主键排序,想调整顺序只能改 ID,非常被动。

实体类对应关系也很直接:

public class Category { public int Id { get; set; } public int ParentId { get; set; } public string Name { get; set; } public int SortNo { get; set; } public List<Category> Children { get; set; } // 子分类集合 }

Children属性不是数据库字段,是给递归组装树用的。数据层从表里查出平铺的记录,业务层负责把它们组装成树形结构。这个分离是三层架构的优势所在,树组装逻辑放在 BLL 层,DAL 层永远只做简单的查询和增删改。

2.3 一个标准请求的完整流转:从 Page 到 DAL 的调用链

以新增分类为例,走一遍完整的调用链。UI 层是 Asp.Net WebForms 的页面,用户填写分类名称、选择父分类、填写排序号,点击保存按钮后触发服务端事件:

// UI 层:CategoryAdd.aspx.cs protected void btnSave_Click(object sender, EventArgs e) { Category entity = new Category(); entity.Name = txtName.Text.Trim(); entity.ParentId = int.Parse(ddlParent.SelectedValue); entity.SortNo = int.Parse(txtSortNo.Text.Trim()); CategoryBLL bll = new CategoryBLL(); bool result = bll.AddCategory(entity); if (result) { Response.Redirect("CategoryList.aspx"); } else { lblMsg.Text = "新增失败,请检查分类名称是否重复"; } }

BLL 层拿到实体后,先做业务校验,再调用 DAL:

// BLL 层:CategoryBLL.cs public bool AddCategory(Category entity) { // 检查同父节点下分类名是否重复 CategoryDAL dal = new CategoryDAL(); int count = dal.ExistsSameName(entity.ParentId, entity.Name); if (count > 0) return false; // 检查父节点是否存在且有效 if (entity.ParentId > 0 && !dal.ExistsCategory(entity.ParentId)) return false; // 通过校验,调用数据层新增 return dal.Insert(entity) > 0; }

最后是 DAL 层:

// DAL 层:CategoryDAL.cs public int Insert(Category entity) { string sql = @"INSERT INTO Category (ParentId, Name, SortNo) VALUES (@ParentId, @Name, @SortNo); SELECT CAST(SCOPE_IDENTITY() AS int);"; SqlParameter[] parameters = { new SqlParameter("@ParentId", entity.ParentId), new SqlParameter("@Name", entity.Name), new SqlParameter("@SortNo", entity.SortNo) }; return (int)SqlHelper.ExecuteScalar(sql, parameters); }

这里的逻辑说明和参数注意点有三个方面。第一,DAL 层返回的是受影响的记录数或新插入的 ID,不返回业务结果,这样数据层保持纯碎。第二,SCOPE_IDENTITY()拿的是当前会话最后插入的自增 ID,比@@IDENTITY安全,后者在多触发器场景下可能拿到别的表生成的 ID,这是 SQL Server 的老坑。第三,三层之间用实体类传参,不推荐用 DataTable 传,因为 DataTable 太灵活,字段改了编译期根本发现不了,实体类有强类型校验,字段写错直接编译报错。

3. 无限极分类的数据结构与递归:为什么邻接表这么能打

3.1 三种树存储方案选型:邻接表、路径枚举、嵌套集

无限极分类的数据库存储方案有几种,项目里常见的是这三种:

方案存储思路查询子树难度增删改难度适用场景
邻接表表里存 ParentId需要递归简单分类层级浅、量不大
路径枚举存一个 path 字段如 "0,1,5"一条 LIKE 查询修改路径时要更新全子树层级深、读多写少
嵌套集存左右值(lft/rgt)一条查询插入删除要重算所有节点几乎不动态修改的树

这份资源用的是邻接表,原因很实在:多数管理系统的分类层级在 2 到 5 层之间,数据量在几千条以内,邻接表在增删改方面操作直观,代码好理解,而且不需要维护额外的冗余字段。路径枚举和嵌套集性能好,但写 MR 时同事看不懂,维护成本反而上去了。对这个场景,邻接表是正确的选择,但不是唯一的答案。

3.2 递归加载分类树:C# 里怎么写递归才算合格

有了邻接表数据,接下来就是把平铺数据组装成树。常见的做法是在 C# 里写递归方法。DAL 层一次性查出所有分类,BLL 层做内存递归组装,不用反复查库:

// BLL 层:CategoryBLL.cs public List<Category> GetCategoryTree() { CategoryDAL dal = new CategoryDAL(); List<Category> all = dal.GetAll(); // 一次性查全表 List<Category> tree = new List<Category>(); foreach (Category item in all) { if (item.ParentId == 0) { tree.Add(item); } } foreach (Category root in tree) { BuildChildren(root, all); } return tree; } private void BuildChildren(Category parent, List<Category> all) { parent.Children = all.Where(c => c.ParentId == parent.Id) .OrderBy(c => c.SortNo) .ToList(); foreach (Category child in parent.Children) { BuildChildren(child, all); } }

这个方法的逻辑是:先从数据库把全表数据捞出来,然后在内存中遍历两遍,第一遍找出顶级节点,第二遍递归挂载子节点。只查一次数据库,性能上没有隐患。

参数和细节上,排序字段SortNo在组装时生效,而不是在 SQL 查询时整体排序,这样每一层的子节点都能按自己层级的排序号排列。递归的终止条件天然存在,因为每个节点只挂载到自己的父节点下,没有父节点的节点就是顶级节点。但这里隐藏着一个必须处理的问题:如果数据里存在循环引用,比如 A 的父节点是 B,B 的父节点是 A,递归会死循环,栈溢出。处理办法是在递归方法里加一个深度参数,超过 50 层直接终止,然后记日志人工排查数据。

前端展示时,用 Repeater 嵌套或者递归绑定都行,注意缩进样式就行:

<!-- UI 层:分类树展示片段 --> <ul> <asp:Repeater ID="rptTree" runat="server" OnItemDataBound="rptTree_ItemDataBound"> <ItemTemplate> <li> <%# Eval("Name") %> <asp:Repeater ID="rptChild" runat="server" /> </li> </ItemTemplate> </asp:Repeater> </ul>

后端在绑定子节点时递归处理:

protected void rptTree_ItemDataBound(object sender, RepeaterItemEventArgs e) { if (e.Item.ItemType == ListItemType.Item || e.Item.ItemType == ListItemType.AlternatingItem) { Category current = e.Item.DataItem as Category; Repeater rptChild = e.Item.FindControl("rptChild") as Repeater; rptChild.DataSource = current.Children; rptChild.DataBind(); } }

这个嵌套 Repeater 的写法是 WebForms 时代的经典套路,核心在于每绑定一层数据源时,同时把下一层的子集合挂到内层 Repeater 上,形成自动递归。

3.3 递归之外的选择:什么时候该放弃递归

介绍完整套递归方案,必须说清楚它的边界。如果分类表的层级很深,比如超过 20 层,或者单次查询的数据量超过几万条,C# 内存递归虽然不查库,但每层递归都要创建 List 和进行 LINQ 遍历,整体时间复杂度是 O(n²),数据量一大就卡顿。

这时候有两个改进方向。第一个是在 SQL 侧用公共表表达式递归,SQL Server 2005 以上都支持:

WITH CategoryTree AS ( SELECT Id, ParentId, Name, SortNo, 0 AS Level FROM Category WHERE ParentId = 0 UNION ALL SELECT c.Id, c.ParentId, c.Name, c.SortNo, ct.Level + 1 FROM Category c INNER JOIN CategoryTree ct ON c.ParentId = ct.Id ) SELECT * FROM CategoryTree

这段 SQL 会返回带层级的平铺结果集,每行数据带上Level字段,前端展示缩进时直接用 Level 值控制。注意如果分类表存在环形引用,这个 CTE 会无限递归,查询超时,所以在 SQL 层递归时需要限制递归次数:

OPTION (MAXRECURSION 50)

第二个方向是把路径枚举和邻接表结合,在表里加一个Path字段,比如0,1,5,9,查询某个节点的所有子孙时直接WHERE Path LIKE '0,1,%',查询效率非常高,代价是移动节点时需要更新整个子树的 Path 值。这个方案在本章第 3.1 节的表格中提到过,第 6 章会细讲怎么落地。

4. 增删改查落地:四个操作里藏得最深的三个坑

4.1 新增分类:排序值、层级控制、重名校验缺一不可

新增分类看起来最简单,就是插入一条记录,但实际落地时至少有三个细节要处理好。

第一个是层级控制。业务上一般不允许无限深层级,比如限制最多 5 层,那么新增时就需要先算出当前父节点的层级:

public int GetLevel(int categoryId) { int level = 0; CategoryDAL dal = new CategoryDAL(); int currentId = categoryId; while (currentId > 0) { Category c = dal.GetById(currentId); if (c == null) break; level++; currentId = c.ParentId; } return level; }

这个循环方式通过反复查库获取父节点链,如果分类层级普遍在 5 层以内,性能完全够用。每层循环会执行一次按主键查询,有主键索引撑着,没什么压力。

第二个是重名校验。同一个父节点下不允许重名,但不同父节点下可以有重名分类,常见操作是「运动」和「户外」下面各有一个「鞋子」。校验 SQL 不能只查WHERE Name = @Name,必须带上父节点条件。

第三个是排序号。新分类默认排到最后,给一个大数字就行:

int maxSortNo = dal.GetMaxSortNo(entity.ParentId) + 10; entity.SortNo = maxSortNo == 0 ? 10 : maxSortNo;

每次步进 10 而不是 1,是为了给后续手动调整留空间。用户想把某个分类插到中间,直接在两个分类之间取一个中间值就行,比如步进 10 的情况下,第 1 和第 2 个分类之间可以插入排序号为 5 的值,不用重排后面所有节点。

4.2 修改分类:父节点变更时最容易出事

修改分类名称和排序号都比较简单,难的是修改父节点,分两种情况。

第一种情况,分类没有子节点,直接改 ParentId 就行。第二种情况,分类有子节点,子节点跟着一起移动。比如把「电子产品」从顶级分类移动到「家用电器」下面,它原来下面所有的子分类仍然挂在它下面,这个过程中需要更新的是「电子产品」自身和祖先链上节点与子节点之间的路径关系。

修改父节点有一个重要的业务限制,不能把节点移动到自己的子孙节点下面。如果「电脑」的父节点是「电子产品」,现在要把「电脑」作为「笔记本」的父节点,这在数据上会形成循环:电脑的父节点变成了笔记本,笔记本的父节点变成了电脑。验证代码如下:

public bool IsChild(int targetParentId, int currentId) { CategoryDAL dal = new CategoryDAL(); int current = targetParentId; while (current > 0) { Category c = dal.GetById(current); if (c == null) break; if (c.Id == currentId) return true; current = c.ParentId; } return false; }

这段代码从目标父节点向祖先方向逐层上溯,如果在链条上遇到了当前节点 ID,说明目标父节点是当前节点的子孙,那就不能执行移动。循环上溯会重复查库,但分类层级浅,每次修改操作最多执行几次查询,问题不大。

执行移动时用事务包住:

public bool MoveCategory(int categoryId, int newParentId) { if (categoryId == newParentId) return false; if (IsChild(newParentId, categoryId)) return false; string sql = "UPDATE Category SET ParentId = @NewParentId WHERE Id = @CategoryId"; SqlParameter[] parameters = { new SqlParameter("@NewParentId", newParentId), new SqlParameter("@CategoryId", categoryId) }; int rows = SqlHelper.ExecuteNonQuery(sql, parameters); return rows > 0; }

categoryId == newParentId这个判断容易漏。不判断的话,自己的父节点指向自己,树一旦刷新,这个节点就从任何父节点的子集合里消失了,隐形成孤儿。

4.3 删除分类:有子节点时只有两种体面做法

删除分类是增删改查里最容易出事的场景。直接执行DELETE FROM Category WHERE Id = @Id会留下孤儿数据,子分类的 ParentId 指向一个不存在的分类,前端渲染树时空引用,后台查询递归时找不到父节点直接断裂。

项目里常见的处理办法只有两种。第一种是限制删除,有子节点就提示用户先处理子节点再删除父节点:

public bool HasChildren(int categoryId) { string sql = "SELECT COUNT(1) FROM Category WHERE ParentId = @CategoryId"; SqlParameter[] parameters = { new SqlParameter("@CategoryId", categoryId) }; return (int)SqlHelper.ExecuteScalar(sql, parameters) > 0; }

第二种是级联删除,用事务把所有子孙节点连同本节点一起删掉。要在 SQL Server 里完成递归删除,常见用法是先用 CTE 查出所有要删的节点:

WITH CategoryToDelete AS ( SELECT Id FROM Category WHERE Id = @RootId UNION ALL SELECT c.Id FROM Category c INNER JOIN CategoryToDelete ct ON c.ParentId = ct.Id ) DELETE FROM Category WHERE Id IN (SELECT Id FROM CategoryToDelete);

这段代码要着重说明一个隐患:如果分类层级太深,超过 SQL Server CTE 默认的递归上限 100 次,会报递归错误。解决方式是指定OPTION (MAXRECURSION 0),去掉递归限制,但代价是如果数据中意外存在循环引用,查询会跑到超时,所以更安全的做法是用 100 或者 50 作为限制,超出时返回明确提示而不是让数据库跑死。

我一般会限制删除方案优先:先判断有没有子节点,有的话返回提示信息给用户。因为级联删除不可逆,手一抖删了一整棵子树,数据救不回来,尤其是分类下面还关联着商品、文章这类业务数据时,删除操作必须更保守。

4.4 查询分类:全量加载还是按需加载

分类查询有两种路线。第一种是全量加载,一次性查出全部数据,内存递归组装成树。这个方案在前面第 3.2 节已经完整实现,适合分类总量不超过几千条的场景。

第二种是按需加载懒加载,每次只加载某个父节点下的直接子节点。这个方案适合分类特别多、树特别大的场景,前端每次展开节点时再去查数据库:

public List<Category> GetChildren(int parentId) { string sql = "SELECT Id, ParentId, Name, SortNo FROM Category WHERE ParentId = @ParentId ORDER BY SortNo"; SqlParameter[] parameters = { new SqlParameter("@ParentId", parentId) }; DataTable dt = SqlHelper.ExecuteDataTable(sql, parameters); List<Category> list = new List<Category>(); foreach (DataRow row in dt.Rows) { Category c = new Category { Id = (int)row["Id"], ParentId = (int)row["ParentId"], Name = row["Name"].ToString(), SortNo = (int)row["SortNo"] }; list.Add(c); } return list; }

这个懒加载方案只有一个注意点:拿到子节点数据后,前端需要知道子节点下还有没有孙子节点,否则没法决定是否显示展开箭头。常见做法是查子节点时额外带一个HasChildren前缀:

public List<Category> GetChildrenWithHasChildrenFlag(int parentId) { string sql = @"SELECT c.Id, c.ParentId, c.Name, c.SortNo, CASE WHEN EXISTS (SELECT 1 FROM Category WHERE ParentId = c.Id) THEN 1 ELSE 0 END AS HasChildren FROM Category c WHERE c.ParentId = @ParentId ORDER BY c.SortNo"; SqlParameter[] parameters = { new SqlParameter("@ParentId", parentId) }; DataTable dt = SqlHelper.ExecuteDataTable(sql, parameters); List<Category> list = new List<Category>(); foreach (DataRow row in dt.Rows) { Category c = new Category { Id = (int)row["Id"], ParentId = (int)row["ParentId"], Name = row["Name"].ToString(), SortNo = (int)row["SortNo"], HasChildren = (int)row["HasChildren"] == 1 }; list.Add(c); } return list; }

子查询EXISTS的用法是所有 SQL Server 开发都应该掌握的,它比LEFT JOIN + COUNT的方式快得多,存在即返回,不用扫描所有子记录算数量。这个方案在你需要做树形下拉框、树形权限选择器时非常实用。

5. 避坑:无限极分类与三层架构的五个典型翻车现场

5.1 删除父分类后子分类全部失踪

现象:后台删除了「电子产品」这个分类,结果「手机」「电脑」「笔记本」全在列表里消失了,数据库里其实还有记录。

原因:删除时没做子节点判断,直接执行DELETE FROM Category WHERE Id = @Id。子分类的 ParentId 还指向已删除的 ID,前端递归时找不到父节点,整个分支从树里断了。

解决:删除前先查HasChildren,有子节点就拦截并提示。确认要级联删除的话,用第 4.3 节的 CTE 递归删除方案,且必须包在事务里执行,删除出错直接回滚。

5.2 修改父节点后树刷新报堆栈溢出

现象:把「笔记本」分类移动到了「电脑」下面,之后加载分类树直接报System.StackOverflowException,进程直接崩。

原因:移动前没做循环引用校验。「电脑」的父节点是「笔记本」,而「笔记本」的父节点是「电脑」。递归组装树时,A 挂到 B,B 挂到 A,C# 递归没有终止条件,栈溢出。

解决:修改父节点前必须跑一遍循环引用检测,从目标父节点沿祖先链向上找,遇到当前节点 ID 就拒绝移动。同时递归组装函数里加深度保护,超过 50 层强制终止,宁可报错也不要让进程崩溃。

5.3 递归查询没问题,但前端渲染少了某层数据

现象:SQL 查询结果集数量对,递归组装出来的树节点数也对,但是页面层级里看不到某些分类,感觉树是断的。

原因:多半是 UI 层的 Repeater 嵌套绑定事件没设置对,内层 Repeater 的OnItemDataBound事件没有绑定,导致只有第一层子节点被渲染,更深层的节点在绑定数据源后没触发递归绑定。

解决:检查嵌套 Repeater 的结构,外层 ItemTemplate 里内层 Repeater 必须绑定OnItemDataBound="rptTree_ItemDataBound",并且ItemDataBound事件里要做FindControl递归处理。另一个常见原因是通过(Category)e.Item.DataItem取实体时,如果实体里的Children集合为空,递归就断了。

5.4 修改分类排序时整个表被锁

现象:每次调整排序后系统卡顿,数据库管理工具里看到大量阻塞进程。

原因:排序功能实现方式太粗暴,调整时把同一父节点下的所有分类全部 UPDATE 一遍:

UPDATE Category SET SortNo = CASE Id WHEN @Id1 THEN 1 WHEN @Id2 THEN 2 ... END

分类多的时候这没事,但要是每次拖动排序都会触发全量 UPDATE,事务一长,行锁升级成表锁就卡了。

解决:用间隔排序法,初始化 SortNo 时步进 100,调整某个分类的排序时,只需修改那一条记录的 SortNo,比如从 100 改成 150,插入到 100 和 200 之间。顺带把树的加载和展示逻辑改成ORDER BY SortNo,不要依赖 ID 或创建时间排序。

5.5 三层写成了两层,SQL 满天飞

现象:代码里 UI 层页面后端直接写SqlConnection、SqlCommand、SELECT * FROM Category,BLL 层形同虚设,后来需求变更想加统一权限过滤,要改七八个页面。

原因:项目规模小的时候觉得三层架构繁琐,数据访问直接一把梭。遇到分类树这种操作,UI 层既要拼递归又要处理异常,写多了分层的念头就更淡,慢慢退化成两层乃至一层。

解决:把数据访问收敛到 DAL 层,UI 层只做参数接收和结果展示。受影响的代码量大时,按模块重构,先拿分类管理练手,把增删改查全部挪到 BLL 和 DAL 层,改完后再扩展其他模块。资源里自带的完整三层代码结构可以直接对照,照着抄就行,不用自己重新设计接口。

5.6 订单数据和分类关联,删分类删掉了历史记录的口径

现象:运营人员删除一个旧分类后,报表里的销售数据明细变少了。

原因:分类表和业务表之间使用的是直接外键关联,删除分类时业务表里的关联记录跟着被级联删除,或者因为外键约束删不掉,最终影响数据统计口径。

解决:业务表不要存分类 ID 做带约束的外键,而是通过CategoryId+CategoryName冗余存储分类信息,或者使用逻辑删除方案,分类表加IsDeleted字段,删除时只做软删除。切换视图时,用WHERE IsDeleted = 0过滤掉已删除的分类。历史表保留完整数据路径。

6. 进阶:用物化路径替换递归查询,一条 LIKE 拿回整棵子树

想要彻底绕开递归,把无限极分类的查询改成一条 SQL,方案是在邻接表基础上增加一个Path字段,物化路径方案。表结构调整成:

ALTER TABLE Category ADD Path VARCHAR(500) NOT NULL DEFAULT '';

顶级分类的 Path 是0,1,二级分类的 Path 是0,1,5,三级是0,1,5,9。Path 存的是从根到当前节点的完整 ID 链。新增分类时,先查父节点的 Path,拼上自己的 Id 即可:

UPDATE Category SET Path = (SELECT Path FROM Category WHERE Id = @ParentId) + ',' + CAST(@NewId AS VARCHAR(10)) WHERE Id = @NewId;

查询某个分类下的所有子孙,不再需要 CTE 递归,一条 LIKE:

SELECT * FROM Category WHERE Path LIKE '0,1,5,%';

查询某个分类的直接子节点:

SELECT * FROM Category WHERE Path LIKE '0,1,5,%' AND LEN(Path) = LEN('0,1,5') + 4;

后者这个写法依赖 Path 的格式规范。每个节点 ID 的位数不固定,上面这个LEN比较法在 ID 超过 9 时会失效,安全做法是用分段函数或者固定 ID 位数,实操里更稳妥的是直接把 Path 的长度也冗余,或者用Path LIKE '0,1,5,%' AND Path NOT LIKE '0,1,5,%,%'这种模式匹配方式。

这个方案的核心收益是查询不再递归,数据量上万时优势明显,读取性能稳定。代价是移动节点时整个子树的 Path 都要更新:

UPDATE Category SET Path = REPLACE(Path, '0,1,5', '0,1,8') WHERE Path LIKE '0,1,5,%';

有一点必须注意,如果某个分类 ID 的字符串长度不同,靠原生REPLACE替换 Path 里的子串就可能误伤同路径前缀的其他节点,比如0,1,5可能会错误匹配到0,1,50。所以字符串拼接前,各个 ID 统一加分隔符包裹,例如,1,5,9,而不是0,1,5,配合前后逗号能规避这种情况。

从那以后我每次做分类模块,都会先问自己一个问题:这个树的读多还是写多?写多就老老实实邻接表 + 递归,读多、分类层级又深,直接上 Path 物化路径,两条路想清楚再动手,而不是上来就写递归。希望帮到你。

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

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

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

立即咨询