
商品分类功能听起来是所有后台模块里最好糊弄的一个加几个输入框、放一个树形控件、再配个下拉选择看起来半天就能交差。但我把商品分类功能从“能用”做到“扛得住千万级商品、大促流量、运营天天改结构”这三件事前后重构了三轮踩过的坑能写满一张表。这篇东西我想从头到尾梳理一遍分类模型怎么选、数据库表怎么设计、缓存怎么接、管理后台和前台页面如何配合以及那些不做到上线永远不会暴露的翻车现场。如果你正准备给商城系统做分类或者你们现在的分类已经出现“改一个父分类、整棵商品树错乱”的苗头这篇应该能省你不少时间。1. 商品分类功能看着简单但它先逼你回答三个问题很多团队把商品分类当成“树形菜单”来做需求一确认就开始建表、写接口、套前端组件。我一般不会急着写代码因为分类这个功能真正难的地方不是树怎么渲染而是你压根没想清楚它在业务里的位置。以下三个问题越早回答后面返工越少。1.1 一个分类能往下分几层这决定了整棵树的复杂度分类层级不是拍脑袋定的。三级分类和五级分类数据库设计、前端导航、搜索聚合、大数据报表完全是两种代价。举个实际例子服装类目如果只有两级“男装”下面直接挂“衬衫”那衬衫的尺码、版型、袖长这些属性就必须贴在“衬衫”这个节点上。如果后面运营想加一个“上衣”层级所有商品属性模板都得跟着迁移一遍。我在大多数项目里会采用“允许四级推荐用前三级”的规则。为什么因为层级越深面包屑导航占的横向空间越多搜索聚合对类目的归类也越复杂更重要的是移动端筛选页面的体验会变得很笨重。你可以自己在导航里点两下感受一下一个用户想买一件“男士修身牛仔外套”如果必须点四次才能到最终分类大部分人已经切到搜索框了。更深一层的问题是分类层级是否要限制得看商品属性的一致性。同一父类下的子类如果共用一套属性模板三级即可如果子类之间属性差异极大比如“家具”下面既有“桌类”又有“床垫”那就得允许更深的分类层级让每个末端分类都能绑定独立属性模板。这个判断直接决定你后面要不要做复杂的“类目属性继承”。1.2 一个商品到底属于一个分类还是可以属于多个分类这是分类功能里面最容易吵起来的需求。运营会说“一条牛仔裤既能放到男装也能放到裤装”产品经理会纠结“分类树只有一个父亲才好看”后端则会考虑“商品分类是多对多我的统计报表要完蛋了”。我的建议是商品和分类的关系用多对多设计但业务上强制指定一个“主分类”。商品可以挂在多个分类下用于展示和搜索但库存统计、销售报表、佣金结算、属性模板一律以主分类为准。原因很简单如果你只用单一分类前台个性化推荐和专题页想把“秋季外套”同时挂到“女装/外套”和“秋季新品”下面就只能复制商品数据。复制不是不行但库存同步和价格同步会变成另一场灾难。多对多加主分类的设计既保住了树形结构的整洁又给了运营足够的灵活性。我在表结构上一般这么处理分类表是单向的树商品表和分类表之间加一张关系表带is_primary字段。所有默认查询走主分类搜索索引里存全部关联分类 ID 列表用于召回。1.3 分类和属性、品牌、标签的关系必须在第一天清楚很多系统的分类表越做越臃肿就是因为把属性、品牌、标签全塞进了分类里。实际上分类是一棵树属性是挂在节点上的模板品牌是另一个独立维度标签是运营临时打的记号。四者混在一起等商品量起来以后维护成本会指数级上升。举一个具体的反例有人为了省事在分类表里加了attr_group_ids字段直接把属性组 ID 以逗号分隔存进去。刚开始看不出问题等运营想给某个属性组改名或者想给多个分类共用一套属性模板的时候你就得遍历所有分类去做字符串替换还得担心历史数据的脏值。正确的做法是单独建一张category_attribute_template表分类和属性模板多对多关联关系可以随意复用、继承。分类只管“它是谁”属性模板决定“这个分类下的商品长什么样”。2. 数据库怎么选型我从邻接表踩到闭包表最后还是回到组合方案分类功能的数据库设计是网上争论最多的话题。邻接表、路径枚举、嵌套集、闭包表每个方案都有拥趸也都有骂声。这里我不想吵架只讲我在实际项目里的取舍逻辑。2.1 先看四个常见方案的性能差异我把常用的四类存储方案放到一张表里对比大家可以根据自己的团队能力去选方案查询子树查询直接父级插入成本移动节点实现难度邻接表parent_id递归多次查询或递归 CTE极快极快容易改 parent_id但子树处理麻烦低路径枚举path 字段用 LIKE 匹配路径较快较快快要批量更新所有子节点路径中嵌套集left/right一次查询就是整棵子树较慢需要重整所有节点编号非常痛苦高闭包表祖先表一次 join 查全子树一次查询较快需要维护大量祖先关系行较高嵌套集和闭包表看起来很专业但它们在“分类移动”这种低频操作上付出的代价太大。商城分类功能有一个很现实的特点读多写多但这里的“写多”指的是日常增删改多不是并发写多。运营会非常频繁地调整分类顺序、改名、移动、禁用如果每次移动都要重建几百行祖先关系或左右值编号后台体验就会特别卡。2.2 我最常用的组合方案邻接表 冗余路径字段我最终选的是邻接表加冗余路径字段。表结构大致长这样CREATE TABLE category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT NOT NULL DEFAULT 0, name VARCHAR(64) NOT NULL, level TINYINT NOT NULL DEFAULT 1, path VARCHAR(512) NOT NULL DEFAULT , sort INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, icon VARCHAR(255) DEFAULT , seo_title VARCHAR(255) DEFAULT , seo_keywords VARCHAR(255) DEFAULT , goods_count INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_parent_sort (parent_id, sort), KEY idx_path (path) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的核心是path字段它存取从根节点到自身的一整条路径比如/1/12/36/。有了这个字段两个最重要的查询就变得非常简单查询某个分类的所有子分类包含后代WHERE path LIKE /1/12/%查询某个分类的所有祖先分类按path拆开以后批量取 ID或者直接WHERE id IN (1, 12, 36)而parent_id还是保留因为插入、修改单个节点时按父节点 ID 直接操作最顺手。等于把两种结构的优点拼在了一起。2.3 为什么我坚持要冗余商品数量、SEO 字段这些“非分类属性”很多刚入门的同学会把分类表设计得特别“干净”只留 id、parent_id、name。但上了生产以后你会发现分类查询太频繁了几乎每个页面都要显示分类树和每个分类下的商品数量。如果你每次都需要去商品表 COUNT 一遍数据库根本顶不住。所以我会在分类表里冗余一个goods_count它不代表实时精确值而是通过异步任务或者商品上下架时累加得到的近似值。大促期间显示“本分类共 1234 件商品”差几件用户根本感知不到但对数据库的压力却从天量查询降成了读一个字段。SEO 字段、icon、排序字段也是一样的道理。分类页的标题、关键词、描述就是跟着分类走的如果你非要另建一张表来存查询时多一次关联管理后台还要多维护一个界面不值得。3. 接口和缓存分类查询是典型的“读多写少”但写的时候最吓人分类的数据量不大几千条顶天了但它是所有前台页面的基础数据。导航栏要读、面包屑要读、商品详情页要读、后台列表要读。流量一大你就能看到数据库连接被打满而罪魁祸首往往是那个“简单”的分类树接口。3.1 一个能扛住大促的分类查询接口长什么样我建议把分类树构建成两种形态的缓存一是完整的树形结构供导航和后台树形控件使用二是扁平列表供下拉选择和搜索过滤使用。缓存可以放在 Redis 里也可以放在本地进程内存里。考虑到数据量小、更新不频繁我用的是“Redis 缓存完整树 JSON 进程内缓存二次兜底”。TTL 通常设置 30 分钟到 1 小时但更重要的是版本号String treeKey category:tree:v categoryVersion : siteId; String json redis.get(treeKey); if (json null) { // 加分布式锁防止缓存击穿 // 从数据库加载全量分类构建树形和扁平结构 // 写回 Redis设置过期时间 }版本号categoryVersion是每次分类变更后自增的全局计数。这样我不需要逐个删除大量分类相关的 key只需要让旧版本的 key 失去引用让新的请求自然落到新版本上。categoryVersion本身可以在每次变更事务提交时同步更新。这个方案的要点是不要在查询接口里做任何数据库操作。即使 Redis 挂了也要有进程内缓存兜底或者由网关层降级返回一份静态 JSON。我见过很多系统在 Redis 抖动时直接雪崩就是因为查询链路里还带着数据库查询。3.2 分类增删改之后缓存怎么失效才干净分类的增删改操作本身不复杂真正容易翻车的是缓存失效的时序。我曾经在一个项目里见过这样的代码先删除 Redis 里的分类树 key再去更新数据库。结果数据库更新失败缓存已经被删了用户瞬间看到空分类树。这个顺序是明显有问题的但真到代码里就有人会写错。正确顺序是先更新数据库提交事务再删缓存。如果担心删缓存失败可以用延迟双删的策略或者直接依赖版本号机制让旧缓存自然过期。总之不能让一次数据库操作失败导致整棵分类树不可用。还有一点容易被忽略分类移动时不仅parent_id要改path字段也要同步更新。假设你要把/1/12/36/这个分类移动到/1/5/下面那所有path LIKE /1/12/%的节点都要一起改。这条路如果没走对轻则面包屑错乱重则商品列表查不到数据。UPDATE category SET path CONCAT(/1/5/, SUBSTRING(path, LENGTH(/1/12/))) WHERE path LIKE /1/12/%;我建议把“移动分类”封装成一个专门的服务方法里面强制做三件事校验目标父节点不是自己的子节点、更新父节点子结构、更新所有受影响节点的 path。三步必须在一个数据库事务里。3.3 分类和商品的关系查询时怎么优化分类页要展示商品列表最常见的错误写法是每页都实时 JOIN 两张表。商品一多JOIN 就走不动了。我的建议是把商品和分类的关系在写入时落到一张冗余表里而不是查询时去临时关联。具体来说就是product_category表里面存product_id、category_id、is_primary、status、sort并在这个表上建(category_id, status, sort, product_id)联合索引。浏览分类页的时候直接按这个表分页查商品 ID再回商品主表查完整信息。SELECT pc.product_id FROM product_category pc WHERE pc.category_id 123 AND pc.status 1 ORDER BY pc.sort ASC, pc.product_id DESC LIMIT 20;这种做法在商品数万、数十万量级下都非常稳。分类页的过滤条件如果要叠加品牌、价格区间再把筛选条件同步到 ES 或者搜索服务里去处理分类关系表只负责最基础的范围界定。4. 管理后台和前台展示同一个分类两种完全不同的实现思路分类功能的后台和前台虽然都用树形结构但实现逻辑完全不同。后台要的是操作效率前台要的是响应速度。把两者混在一起做是很多项目后期维护痛苦的原因。4.1 树形控件背后到底该提交什么数据后台管理分类通常离不开树形拖拽。但很多前端工程师会把整棵分类树组装成数组提交到后端后端再全量替换。这个做法在小数据量下确实能用问题是一旦分类树超过几百个节点全量替换非常容易产生并发覆盖两个人同时打开后台后保存的人会把先保存的人的操作全部覆盖掉。我建议后台树形控件只提交操作变更而不是提交全量快照。比如前端可以这样传变更包{ operator: move, nodeId: 123, newParentId: 45, prevSiblingId: 67 }后端根据变更包去更新parent_id、sort和path。这样每一次操作都可控、可审计也不会出现全量覆盖的问题。拖拽只是操作入口真正的落库逻辑还是应当对应到add、delete、move、rename、disable这些基础动作。4.2 前台导航的“懒加载”要怎么做才不闪屏前台的分类导航一般有两种方案一次性加载全量分类树或者按层级懒加载。中小型商城用一次性加载没毛病因为几千条 JSON 也就几十 KB压缩传输后更小。但如果分类数量非常多、层级又深一次性加载会让首屏渲染变慢移动端更是明显。懒加载的核心不是“用户点击时才请求”而是“只加载当前可见层级扩展下一层时按需请求”。前端的实现要有三点记住展开状态、缓存已加载的子级、避免重复请求同一个父级分类的子级。在实践中我会用一个childrenLoadedMap记录哪些父级已经加载过用expandedMap记录哪些节点处于展开状态。用户切换到别的页面再切回来时分类展开状态不会丢。这比每次重新请求、重新渲染要顺滑得多。4.3 运营手动排序和批量操作要留的兜底运营是分类功能的使用主力她们的日常操作包括把一个分类往前挪、批量给一批子分类改状态、统一替换某个分类下的商品推荐位。这里我吃过一次亏一开始我用的排序字段是sort INT新增分类时赋一个越来越大整数运营要调整顺序就只能“把一个分类的 sort 改成负数”或者“每次加减 1 再保存”。其实更优雅的做法是支持“在两个相邻分类之间插入”的排序值算法。比如有sort 10和sort 20两个相邻分类现在要往中间插一个新分类的sort可以设为 15再往中间插设为 12.5。理论上精确值会不断变小但只要设置一个最小精度比如保留 4 位小数等到精度不够的时候做一次全表重排就行。这个做法让我少跟运营吵了很多架。5. 分类功能最容易翻车的五个场景我全都替你踩过了这里我挑选几个印象最深的生产事故每一个都不是特别复杂的错但影响范围都非常大。5.1 删了分类商品变成“无头苍蝇”第一版分类删除功能我图省事直接物理删除分类记录。理论上商品挂在分类下的关系也应该一起删但我还天真地判断“应该不会有商品挂在分类上”结果一夜之间几千个商品在后台列表中查不到分类信息详情页直接打不开。解决方案是双重兜底删除分类前先把该分类下所有商品主分类迁移到父分类或默认分类删除行为改成软删除status改成 0而不是物理删除。这样即使有漏网的商品也能通过默认分类兜底不至于整站报错。5.2 分类改名后搜索和缓存没同步分类改名看着简单改完以后前端导航更新了、后台显示更新了但商品搜索里聚合出来的分类名还是旧的。我们的搜索服务会定时从分类表同步数据而同步任务只识别变更后的updated_at。有一次运营半夜把“女包”改成“女士箱包”第二天早上搜索聚合页还是“女包”用户点进去却到了新分类页白白流失了一批点击。后来我加了一个统一的“分类变更事件”任何分类字段变更除了更新数据库还要发一条 MQ 消息搜索服务、缓存服务、静态页服务各自订阅去做自己的同步。宁可多同步一次也不要让两边不一致。5.3 父分类被移动后子分类的 SEO 地址全部失效这是个非常隐蔽的坑。分类 URL 一般用path拼出来比如https://xxx/category/1/12/36.html。移动父分类后子分类的path变了URL 就变了。但搜索引擎、用户收藏夹、外部链接都还指向老 URL结果就是大量的 404。移动分类之前一定要生成一份“旧 URL - 新 URL”的映射发布到网关层做 301 跳转。如果是大促前临时调整层级最好克制一下等大促结束后再动。分类这种底层数据改一次波及的页面是几何级数增长的。5.4 历史脏数据parent_id 循环引用表结构有parent_id就有可能出现 A 的父级是 B、B 的父级是 A 这种循环引用。一旦出现递归遍历分类树就会死循环轻则接口超时重则进程堆栈溢出。我的兜底方案是每天凌晨跑一个完整性检查任务用path字段验证每个节点的祖先链路是否合法。同时在移动分类的接口里做防环校验目标父节点不能是当前节点的后代。校验逻辑很简单查一下目标节点 ID 是否在WHERE path LIKE 当前节点path%的结果集里如果在直接拒绝操作。5.5 热门分类并发更新商品数量导致锁等待goods_count冗余字段在给用户带来性能提升的同时也会带来并发更新问题。一个分类下的商品爆了几万件后台批量上下架时多条商品记录在同一分类上频繁更新计数很容易出现行锁等待。我把计数更新从实时改成异步商品上下架只更新关系表的状态字段计数统计交给定时的汇总任务去算。前台显示的goods_count允许最多延迟几小时但换来的是数据库不再出现无意义的锁竞争。这个取舍只要不是做“实时库存”的场景基本都成立。6. 重构三次之后我现在会提前埋的一些设计点如果你现在还在设计阶段下面几个点我建议提前预留否则后期想加会非常费劲。6.1 分类表上一定要预留扩展字段运营需求的变动性在分类功能上体现得淋漓尽致。今天要显示图标明天要加营销角标后天要标识“新品区”。如果你每次都要加一列去满足新需求数据库迁移会越来越频繁。我一般会在分类表里预留一个config_json字段所有展示类、营销类的临时配置都往里面放真正稳定且高频查询的字段才单独建列。这个习惯可能会被 DBA 吐槽但在业务快速迭代期非常实用。等业务稳定了再把高频查询的 JSON 字段拆出来建列、建索引完全来得及。6.2 分类操作审计日志越早加越好分类操作的频率不高但每一次都影响巨大。运营改错一个父分类可能导致几百个商品出现异常。没有操作日志时出了问题只能靠猜。我后来加了分类操作审计日志记录操作人、操作时间、变更前后快照、操作原因备注排查问题的效率直接提升了十倍。日志表很简单操作人、操作类型、分类 ID、旧值、新值、备注、IP、创建时间。后端在做任何分类写操作时同步记录即可。这个字段占不了多少空间但它是整个分类功能“最后一根救命稻草”。6.3 我个人目前在项目里保留的习惯最后分享一个经过实战验证的习惯分类变更的发布订阅机制一定要在一开始就设计进去即使初期只有缓存和搜索两个订阅方。因为分类功能天生连锁反应巨大后面接商品数统计、接静态化页面、接个性化推荐全都需要感知分类变化。有一张变更事件表或者一个 MQ topic所有下游服务都能体面地接入而不是像我们早期一样四处打补丁。商品分类功能的复杂度从来不在树形控件也不在增删改查而在它和商品、属性、搜索、缓存、SEO 之间的耦合关系。把分类当成一个独立模块来设计把它的变更当作一次事件来广播大部分问题都能在早期规避掉。希望这篇梳理能让你在动手前多想一轮少走点我走过的弯路。