做商城项目时,最容易被低估的一个需求,就是商品列表页的“过滤条件的筛选”。这个功能乍一看,就是把分类、品牌、价格区间这些条件做成控件,然后带上参数重新请求一遍列表而已。但实际动手之后你会发现,从筛选条件的编排、URL参数的状态同步,到后端动态SQL的拼接和索引优化,每一层都有隐藏的坑。而且这个模块往往直接长在核心交易链路上,页面流量大、查询条件组合多,一旦设计得不够稳,商品列表接口很容易变成整个系统的瓶颈。
这篇文章我想完整拆解一遍商城项目中“过滤条件筛选”从设计到落地的全过程,涵盖筛选维度分类、前端交互细节、URL状态同步、后端动态查询、索引优化和常见问题排查。如果你正在做商城类的商品中心、搜索列表页,或者准备重构一个筛选功能,这篇文章应该能帮你少走不少弯路。
1. 先想清楚:筛选条件到底有哪些形态
很多项目做筛选翻车,不是代码写得多烂,而是一开始就没有把筛选条件按类型分清楚。不同类型的筛选条件,决定了字段怎么传、SQL怎么写、索引怎么建,甚至决定了前端控件长什么样。所以第一步不是开IDE,而是坐下来把筛选条件分好类。
1.1 从业务角度看筛选维度的分类
以我做过的某电商商品中心为例,商品列表页常见的筛选维度大致可以分为五类:
第一类是精确匹配型,比如分类、品牌、商品状态、标签。这类条件的特点是“选或不选”,值通常是主键ID或枚举值,对应SQL里的等值匹配。第二种是范围匹配型,比如价格区间、上架时间、库存量,这类条件的值一般是一段连续区间,对应SQL里的大于等于、小于等于。第三种是模糊搜索型,比如商品名称关键字,这种条件和前面的精确匹配完全不同,用LIKE的时候要格外小心索引失效。第四类是排序型,比如默认排序、价格升序降序、销量、上架时间,排序条件虽然不是筛选,但它通常和筛选参数一起传递,混在一个查询里。第五类是地理相关型,如果商城支持多仓发货,还可能有区域、配送区域这类条件。这类条件看起来像精确匹配,但背后会联动库存表、运费模板表,复杂度明显更高。
把这五类分清楚之后,你会发现每一类的前后端设计其实都是独立的一套。比如精确匹配型适合用复选框或下拉框,范围匹配型适合用输入框或滑杆,模糊搜索型适合用文本输入加防抖,排序型适合用标签切换。混为一谈就会出现“价格区间和分类共用一套参数格式”“排序字段直接传字符串被当作表字段拼接”这类低级问题。
1.2 前端状态还是URL状态:一个关键的选择题
筛选条件的“状态存在哪里”,是我见过项目里分歧最大的设计点。最省事的做法是把所有筛选状态存在组件内部的data里,点击筛选时直接带着这些变量去请求接口。但这种方案有几个非常现实的问题:第一,用户刷新页面后筛选条件全部丢失,回到默认列表;第二,用户想复制某个筛选后的链接发给同事,发现复制出来的URL不带任何条件;第三,商品列表页如果嵌在某个运营活动位里,活动数据需要依赖筛选参数时,你根本没有办法从URL还原用户当前看到的是什么筛选结果。
所以我强烈建议把URL Query参数作为筛选条件的唯一数据源,前端组件的data只作为URL参数的映射。什么意思呢?用户点击“品牌A”,你做的第一件事不是更新本地变量,而是更新URL上的query参数,比如把brand改成id值,然后由路由变化统一触发列表重新加载。后端接口也只认URL上的query参数,不认组件内部状态。这样刷新、分享、回退、前进全部天然支持,而且每次路由变化都会留下历史记录,用户按浏览器返回键时也能回到上一个筛选组合。
以Vue单页应用为例,这个思路落地下来大概是这样的模式:点击筛选按钮时触发updateQueryParams,修改URL但不压栈;路由watch到query变化后重新拉列表;面包屑展示当前URL里的参数;清空按钮本质就是把query参数重置为空对象。这个架构一旦搭好,后面加任何筛选项都只是往parameters里塞字段而已,不会动整体逻辑。
2. 前端筛选交互的细节拆解
筛选功能的技术含量大部分不在请求代码里,而在交互细节上。尤其当筛选项多起来之后,组件怎么编排、选中态怎么管理、参数什么时候更新、边界条件怎么处理,每一个细节没处理好都会造成用户误操作或参数错乱。
2.1 筛选面板的组件化设计
我建议把每个筛选维度做成一个独立组件,比如分类筛选组件、品牌筛选组件、价格区间组件。每个组件只负责自己的数据获取和选中态展示,对外统一抛出一个filter-change事件,事件体是{ type, value }这样的对象。列表页统一监听这个事件,更新URL参数。这样做的好处很多:新增一个筛选项时只需要新增一个组件,不用动列表页逻辑;筛选组件可以被复用,比如B端管理后台的订单筛选、商品筛选和C端商品列表都可能是同一套组件;组件之间天然隔离,不会出现“改了价格组件的内部数据结果把品牌选中态带崩了”这种耦合故障。
组件的内部数据获取也有讲究。分类、品牌这类选项一般在页面加载时一次性拉取,然后缓存起来;但如果你有“销量TOP榜”“价格分档”这类动态聚合选项,建议按需加载或异步加载。方案上有一种常见做法是:筛选组件只显示静态选项,每个选项旁边显示的“可筛选商品数”由一个单独的统计接口异步返回,失败时静默降级,不阻塞主筛选流程。
2.2 几个必须处理好的交互细节
第一个细节是选中态管理。单选条件的选中态是当前值,多选条件的选中态是一个数组。比如品牌支持多选,UI上点了品牌A再点品牌B,两个都高亮;而分类是单选,选了子分类之后父分类的高亮状态要联动。如果分类树层级很深,还得注意选中的到底是最末级叶子节点还是可以选中父级。很多商城实际业务要求只能选叶子分类,但运营后台偶尔会挂商品到父分类,这块规则一定要和后端确认好,否则前端校验和后端数据对不上会非常难排查。
第二个细节是面包屑。当筛选条件比较多时,用户很难意识到自己已经选了哪些条件,所以页面顶部通常要展示当前已选条件的面包屑,并且支持单条删除。面包屑的删除操作和清空操作本质上是同一套逻辑:删除某个条件就是把这个条件从URL参数里移除,清空就是重置所有URL参数。这里要注意的是,删除分类条件时,如果URL里同时带着它的子分类参数,子分类参数也要一并清掉,否则会出现前端选了一个分类、URL里却残留着另一个子分类ID的情况。
第三个细节是价格输入的防抖和边界。价格区间输入框如果用户每敲一个数字就发一次请求,后端压力会非常大。我一般会做300到500毫秒的防抖:用户停止输入半秒之后,组件才把值提交上去。至于边界,前后端要约定清楚价格范围是左闭右闭还是左闭右开。推荐左闭右开,比如0到100表示包含0、不包含100,100到200表示包含100、不包含200,这样区间之间不会出现商品重复计数的问题。如果不想在前后端各写一套区间逻辑,可以让后端返回当前价格档位的边界值数组,前端只做展示和透传,这个方案更保险。
2.3 前端代码示例:以Vue单页应用为例
下面给出一个简化版本,把核心思路展示出来,只保留筛选状态同步和请求触发的骨架:
// 列表页筛选逻辑简化版 export default { data() { return { products: [], params: this.normalizeParams(this.$route.query), // normalizeParams 负责把字符串参数变成组件需要的类型 }; }, methods: { // 统一处理筛选组件抛出的变更事件 handleFilterChange({ type, value }) { if (value === null || (Array.isArray(value) && value.length === 0)) { delete this.params[type]; } else { this.params[type] = value; } this.$router.replace({ query: { ...this.params } }); // 注意用 replace 而不是 push,避免筛选操作在浏览器历史里堆栈 }, // 根据 URL query 加载商品列表 async loadProducts() { const query = this.$route.query; const normalized = this.normalizeParams(query); this.params = normalized; this.loading = true; try { const res = await fetchProducts(normalized); this.products = res.list; } finally { this.loading = false; } }, // 重置所有筛选条件 handleReset() { this.$router.replace({ query: {} }); }, }, watch: { // 路由变化即重新加载,保证浏览器前进后退也能刷新列表 "$route.query": function () { this.loadProducts(); }, }, mounted() { this.loadProducts(); }, };代码本身不复杂,但有一个细节值得强调:多选条件传参格式。比如品牌多选,我推荐传brand=1001,1002,1003,也就是逗号拼接的字符串。这个格式在URL里可读性强、调试方便,后端用split分割即可。如果你用brand=1001&brand=1002这种重复key的方式,大部分后端框架也能解析成数组,但某些网关会丢掉重复参数,线上遇到过几次之后我就统一改成逗号拼接了。
3. 后端查询条件的设计与动态SQL
前端把筛选参数理清楚之后,接力棒就到了后端手里。后端要解决的核心问题有三个:参数怎么接收、动态SQL怎么拼才安全、查询怎么建索引才扛得住。这一节是筛选模块最容易埋雷的地方。
3.1 请求参数规范与参数校验
筛选接口一般就是一个商品分页查询接口,建议参数尽量用GET加Query形式,语义上和筛选场景匹配,也方便做缓存。所有筛选字段收敛到一个统一的请求DTO里,不要为每个筛选场景单独建VO,否则接口数量会爆炸。
这个DTO里的字段要和前端约定的URL参数一一对应。拿我常用的例子来说:
public class ProductQueryDTO { // 分页 private Integer page = 1; private Integer size = 20; // 精确匹配型 private Long categoryId; private List<Long> brandIds; // 对应 brand=1001,1002,1003 private Integer status; // 范围匹配型 private BigDecimal minPrice; private BigDecimal maxPrice; private String keyword; // 商品名称模糊搜索 // 排序型 private String sort; // 白名单校验后映射到具体字段 }参数校验一定要做。分页参数要限上限,比如size最大100;价格区间要校验minPrice不能大于maxPrice,否则SQL里会出现永远查不出数据的条件,用户还以为是系统出bug了;keyword要做长度限制,超过长度直接截断或返回提示,避免用户输入超长文本拖慢查询。很多后端MCU框架自带参数校验注解,但复杂的业务校验还是要手写,这个不能偷懒。
还有一个常见坑:参数类型强转失败。比如brand=abc传过来时,如果框架抛类型转换异常,接口会直接500。好的做法是在入口做一个全局异常兜底,把类型转换错误包装成400返回,同时把参数校验错误统一格式。这样前端可以提前感知到“传入参数不合法”,而不是看到一个空洞的服务器错误。
3.2 动态SQL拼接:安全性和可维护性并重
后端ORM层我用得最多的是MyBatis,它的动态SQL能力非常适合筛选场景。动态SQL的要点只有一个:所有参数值都用预编译占位符写入,绝对不允许用拼字符串的方式把用户输入直接塞进SQL。尤其是排序字段,很多项目直接写法是order by ${sortField},这里如果sortFiled不是白名单校验过的值,SQL注入是跑不掉的。
下面给一个MyBatis XML里的动态查询示例:
<select id="selectProductPage" resultType="com.demo.product.ProductVO"> SELECT p.id, p.name, p.category_id, p.brand_id, p.price, p.status, p.created_at FROM product p <where> <if test="dto.status != null"> AND p.status = #{dto.status} </if> <if test="dto.categoryId != null"> AND p.category_id = #{dto.categoryId} </if> <if test="dto.brandIds != null and dto.brandIds.size() > 0"> AND p.brand_id IN <foreach collection="dto.brandIds" item="brandId" open="(" separator="," close=")"> #{brandId} </foreach> </if> <if test="dto.minPrice != null"> AND p.price >= #{dto.minPrice} </if> <if test="dto.maxPrice != null"> AND p.price < #{dto.maxPrice} </if> <if test="dto.keyword != null and dto.keyword != ''"> AND p.name LIKE CONCAT('%', #{dto.keyword}, '%') </if> </where> ORDER BY <choose> <when test="dto.sort == 'priceAsc'"> p.price ASC </when> <when test="dto.sort == 'priceDesc'"> p.price DESC </when> <when test="dto.sort == 'sales'"> p.sales_count DESC </when> <otherwise> p.sort_weight DESC, p.id DESC </otherwise> </choose> LIMIT #{offset}, #{size} </select>这个写法里有一个很容易被忽略的点:价格区间用的是左闭右开,maxPrice用的是小于而不是小于等于,这样和前端约定的边界规则保持一致,商品不会在相邻区间重复出现。categoryId是精确匹配,brandIds是多选IN匹配。keyword用的LIKE带有前置百分号,这种写法在数据量大时是索引杀手,后面性能优化部分我会专门展开。
多选条件还有一种情况是同一个表里多个ID同时满足,比如“所选分类必须全部命中的商品”。多数商城不需要这么复杂的逻辑,IN就够用了,不要一开始就上JOIN,JOIN之后去重、统计都会变得很麻烦。
3.3 索引设计:别让筛选条件拖垮数据库
筛选查询的性能大头在索引。一个常见的误区是给每个筛选字段单独建索引,但MySQL一般只在每张表上利用一个索引做过滤,多列筛选走多个单列索引时,最终往往是回表过滤,效果很差。正确做法是为高频筛选组合建联合索引。
以前面这个商品表为例,核心筛选条件是categoryId、brandId、price、status。那么一个比较合理的联合索引是(category_id, brand_id, price),如果status过滤很常见,就再往后加上,变成(category_id, brand_id, status, price)。这里有个原则:等值条件的字段放前面,范围条件的字段放后面。因为联合索引一旦遇到范围条件,后面的字段就无法利用索引排序或过滤了。如果把price这种范围字段放到category_id前面,分类筛选就废了。
验证索引是否被命中,最直接的办法是看执行计划。EXPLAIN语句里关注type字段,至少要达到ref或range级别,如果看到全表扫描的ALL,那说明索引没有建对或者SQL写法导致索引失效。常见原因有:对索引字段做了函数计算、隐式类型转换、LIKE前置百分号等。比如keyword用了LIKE '%关键词%',即使name字段有索引也走不了,数据量小的时候没事,当商品表到几十万上百万条,这个查询就能把数据库拖到报警。
我经历过一次真实案例,某次商品列表接口在筛选条件组合多时突然从500毫秒飙到5秒。查下来发现是brandIds为空时,MyBatis把IN条件整个去掉了,但另一个筛选条件的联合索引因为字段顺序问题没法走,MySQL优化器选择了全表扫描。有些人遇到这种情况直接改SQL,有些人改索引,但真正有效的办法是把联合索引的字段顺序调整成和实际筛选频次一致,同时引导开发用覆盖索引减少回表。
4. 筛选接口的查询实现与性能优化
参数和SQL都定了,接下来是查询实现层面的细节。这里面有两个问题经常被单独拎出来:分页和总数怎么查,筛选项旁边的商品数量统计怎么做。这两个问题不解决,接口可能能跑,但会在高并发或大数据量下暴露问题。
4.1 分页与总数统计
商品列表查询接口的返回值通常包含两个部分:当前页的商品列表和满足筛选条件的总条数。最粗暴的方案是一条SQL查列表、一条SQL查COUNT。数据量小的时候没毛病,但商品表数据量达到几百万之后,COUNT操作本身就非常昂贵,因为要对所有满足条件的记录做一遍计数。
优化方向有几种。第一种是给COUNT加缓存,比如同一组筛选条件下,把总数缓存60秒到120秒,没人会介意商品总数差几分钟的时效。第二种是做了分页查询之后,如果满足条件的记录数小于页容量,就不用再单独查COUNT了,直接用当前页数据长度判断。第三种是引入搜索引擎或OLAP组件来做聚合统计,比如用ES存商品的筛选字段,把列表查询和统计查询都走ES,MySQL只做商品主数据的存储和详情查询。这种方式在大型电商系统里是主流,但对中小团队来说成本较高,建议按量级选择。
分页本身还有一个深分页问题。举个例子:用户在“全部商品”里筛出来十万条数据,翻到第100页时,LIMIT 10000, 20会让数据库扫描前面的10000条记录然后丢弃,代价非常大。如果筛选条件没有排序需求,可以用“上一页最后一条商品ID”作为游标,SELECT ... WHERE id > 上一页最大id AND 筛选条件 LIMIT 20;如果需要排序,则要配合排序字段去构造游标条件。这个优化在筛选模块里很容易被忽略,因为筛选页一般不会真的有人翻到几百页,但某些运营场景里“销量排行”这种列表是可以翻很深的,提前做个兜底更稳妥。
4.2 筛选计数:让用户看到每个选项对应的商品数量
很多商城商品列表页会在分类、品牌旁边显示一个数字,比如“品牌A (320)”,表示当前筛选条件下该品牌还有320个商品。这个功能非常提升体验,但实现起来挺恶心。最直接的方案是用GROUP BY查一次聚合:在现有筛选条件下,按品牌分组统计数量,分类、价格区间同理。看起来只是一句SQL,但如果页面有10个筛选维度,每个维度都要单独聚合,就要执行10次查询,接口响应时间成倍增长。
我建议做个降级策略:默认情况下不加载这些数字,等主列表接口返回之后再异步加载筛选计数接口;计数接口的数据缓存下来,60秒内不重复查询。如果线上并发很高,可以把筛选计数直接禁掉,或者只在用户进入列表页第一次加载时拉一次。商品数量高亮是锦上添花,不是核心功能,别让它在高峰期拖垮数据库。
另一个思路是重建一个专门用于筛选的宽表或物化视图,把商品ID、分类、品牌、价格、标签拍平,聚合统计走宽表。这种方案适合筛选维度和数据量都很固定的场景,缺点是数据同步会有一段时间的延迟,而且建宽表本身需要额外开发成本,这个取舍要看团队资源。
4.3 排序和筛选的组合优化
排序字段对索引的影响非常大。商品列表默认排序是sort_weight加id,当用户按价格排序时,MySQL会先按筛选条件过滤,再对结果集做排序,如果满足条件的数据量很大,就会产生临时表和文件排序。对于筛选条件命中率高、结果集可控的场景,临时表排序问题不大;但要优化的话,可以把价格排序单独建索引(price, id),同时让条件字段能走等值过滤,尽可能减少排序行数。
排序字段白名单是必须做的。前端传sort=priceAsc、priceDesc、sales、new,后端映射成固定的排序表达式。如果后端直接拿sort字段的值拼ORDER BY,注入风险极高。我在代码里通常会维护一个Map,把所有允许排序的key映射到安全的字段和方向,然后从Map取值,取不到就走默认排序,这样即使前端传了恶意值也影响不了SQL结构。
5. 常见问题与排查实录
这个模块我踩过的坑不少,也排过不少同事的障。下面整理几个出现频率最高的问题,每个都描述一下现象、原因和解决思路,遇到类似情况可以直接对照排查。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 刷新后筛选条件丢失 | 筛选状态只存在组件内,没有同步到URL | 改为URL Query作为数据源 |
| 品牌多选后只生效最后一个 | 后端接收参数时被框架处理成单值 | 前端改用逗号拼接传参 |
| 0被当成空值导致筛选失效 | 后端判断条件用if(!param)而非if(param != null) | 数值类型校验改为非空判断 |
| 价格区间出现重复商品 | 区间边界左右开闭规则没约定 | 统一用左闭右开,maxPrice用小于 |
| 筛选接口执行计划走全表扫描 | 联合索引字段顺序不合理或LIKE前置百分号 | 用EXPLAIN分析,调整索引 |
| 排序字段注入风险 | 直接拼接前端传入的sort字段 | 排序字段白名单映射 |
| 筛选后商品总数不准确 | COUNT和列表查询不是同一时刻、条件漏了某个筛选 | 统一从同一查询DTO取条件 |
| 复制筛选后的URL给同事打不开 | 部分筛选参数用的是组件内部临时ID,URL不持久 | 所有参数都必须能从URL还原 |
这里特别想展开说一下整型判空的坑。Java后端里,如果你写的是if(status != null && status != 0),那当status等于0时条件也会被跳过。商品状态字段里0通常代表“下架”或“草稿”,当运营想筛下架商品时,接口返回的居然是全部商品,排查半天才发现是判断条件的问题。有一个很实用的排查技巧:后端在开发环境把MyBatis的SQL日志打出来,每次执行SELECT之前检查WHERE条件是否齐全。我把这个习惯保留到了现在,SQL日志不但是排查动态SQL的神器,也方便前后端对齐参数名。
再分享一个真实排障案例。某次商品列表页出现“筛选条件越多,返回商品反而越多”的诡异现象。查了SQL日志发现,MYBATIS的 判断里,前端传brand=【某个分类的ID】,但开发把brandIds初始化成了一个空数组,空数组不为null但size是0,逻辑没问题;真正的问题是另一个开发在写条件时,用了brandId != null连续判断,恰好前端传了字符串空值"",导致参数取出来是字符串,数据库隐式转换把字符串当成0,最后查出来的都是品牌ID为0的商品。这类问题靠日志比靠肉眼更靠谱,遇到筛选结果集异常,第一步永远是打开SQL日志看实际执行的语句长什么样。
6. 后续可扩展的方向
如果筛选模块想做得更深入,有几个方向值得提前规划。首先是属性筛选,也就是规格参数筛选,比如手机的颜色、内存、屏幕尺寸。属性筛选的难点在于属性是动态的,每个分类下的属性集合都不一样,存表结构通常需要设计成“商品-属性-值”的关联关系。这种情况下,用它筛选时往往不能简单地IN一个数组,而是需要做“同时满足所有属性条件”的复杂JOIN,SQL会变得很重,也需要在数据建模阶段就想清楚。
其次是筛选条件的可配置化。运营后台可以配置某个分类下展示哪些筛选项,每个筛选项的数据源是什么、控件类型是复选还是下拉还是输入框。这个方向做好的话,前端筛选面板就变成渲染引擎,后端只要输出筛选配置JSON即可。它带来的开发成本是一个配置中心和一套动态表单渲染,但对于SKU数量巨大、分类体系复杂的商城来说,这笔投入很值得。
第三个方向是聚合统计走缓存或搜索引擎。前面提过的筛选计数可以直接用Redis的Sorted Set按维度做预聚合,也可以把商品筛选数据同步到搜索组件中,让搜索组件承载筛选和统计查询,MySQL只保留一份权威数据。这个方案会引入额外的基础设施,团队要评估维护成本,但一旦数据量上来,它几乎是唯一能同时保证筛选功能和接口性能的办法。
回到个人经验上,我做这个模块最大的体会是:筛选不是一个纯前端问题,也不是一个纯后端问题,而是一条从交互约定、URL参数格式、动态SQL到索引设计的完整链路。这个链路里任何一个环节不一致,最终都会在线上以“筛选结果不对”的形式暴露出来。所以我现在的习惯是,动手写代码之前先和后端把URL参数协议对一遍,哪个参数是逗号分隔,哪个参数是JSON字符串,区间是左闭右开还是左闭右开,排序字段传什么值,全部确认清楚;然后前后端分别维护一份参数类型定义,哪怕是手写一份字段清单贴在接口文档里都好,省下来的联调时间远大于写这份文档的时间。
最后再分享一个小技巧:前端统一封装一个“解析和序列化筛选参数”的工具函数,专门负责把URL query变成组件需要的对象、以及把组件状态变回URL query。这个函数是筛选模块的地基,测试用例尽量写全,因为它会被所有筛选组件和列表页调用,一旦某个边界情况处理错了,影响面是整个商品列表页。我在项目里就因为这个函数漏掉了数组参数的反序列化,导致过一次品牌筛选在切换页面后丢失的线上问题,从那以后我的习惯是给这类工具函数单独建测试文件,穷举各种参数组合,再也不用担心调参调到一半发现状态对不上。