1. 折扣大促场景下,筛选接口为什么是最容易翻车的环节
1.1 从一次大促压测说起
我先交代一下背景。去年年中大促之前,我们负责的唯品会品牌特卖频道做全链路压测,结果在商品列表页的筛选接口上翻了大车。压测脚本模拟了用户进入折扣专区后连续点击品牌筛选、类目筛选、价格区间筛选的场景,单机QPS刚过800,接口的P99延迟直接从120ms飙升到3.2s,数据库连接池被打满,依赖的商品搜索服务开始大量超时。最要命的是,压测过程中有一瞬间,某个热门类目下的筛选结果直接返回了空数组,前端页面展示"该分类暂无商品",而这在真实大促中是绝对不允许出现的。
这个接口就是标题里说的"品牌类目筛选接口"。它做的事情说起来很简单:用户在折扣频道里勾选一个品牌,或者点进某个类目,接口返回符合条件的商品列表,同时返回可用的品牌列表和类目树,供用户继续联动筛选。但就是这么一个"看起来简单"的接口,在折扣场景下变得非常复杂,因为它至少要同时处理三个维度:品牌、类目、折扣状态。这三个维度不是独立的,品牌列表会随类目变化,类目树会随折扣范围变化,商品结果集又要被品牌和类目同时约束。任何一个维度的数据在缓存里过期或者被淘汰,接口就会返回错误结果。
1.2 品牌与类目筛选联动的业务本质
先说清楚"联动"到底是什么意思。在普通电商的列表页,品牌筛选和类目筛选通常是可以独立生效的,用户选了"运动鞋"类目,再选"耐克"品牌,后端拿两个条件去商品库过滤就行。但在品牌特卖场景下,逻辑要多一层约束:当前只有参与折扣活动的商品才需要展示,而每个品牌的折扣商品覆盖范围和覆盖类目是动态变化的。比如"美妆"类目下可能有200个品牌参与活动,但"食品"类目下只有80个品牌参与。另一个关键点是,用户在筛选器里看到的品牌列表,不能是静态的品牌全量库,必须是"当前类目下、当前折扣范围内、有实际在售商品"的品牌集合。
这就是联动的本质:筛选器的选项集合必须由商品结果集反向推导,而不是靠一张静态配置表。我见过不少团队在这个地方偷懒,直接用一个品牌维度表去渲染筛选器,结果用户选了品牌A和类目B,发现B类目下根本没有品牌A的商品,页面出现"该分类暂无商品"的空状态。这种错误在大促期间用户感知极强,而且会导致大量用户流失。折扣场景还叠加了一个"价格档位"的筛选维度,用户可能会在折扣专区里把价格区间从"100-200元"调整到"200-500元",这时候品牌列表和类目数都要随之变化。所以这个接口的背后,本质是一个多维度的动态聚合查询,而不是简单的KV查询。
在大促流量下,如果每次都实时去算品牌列表和类目树,数据库会直接被打死。我们压测翻车的原因就是这个:接口的SQL里用了三层嵌套的子查询,先按折扣状态圈定商品ID集合,再按类目和品牌聚合,最后还要统计每个品牌的商品数。这个聚合逻辑在数据量小的时候没问题,但唯品会大促时商品SPU量级在千万级别,参与折扣的SKU更多,一条实时聚合SQL跑一次要几百毫秒,QPS一上来数据库就扛不住了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 筛选参数设计与联动规则的数据建模
2.1 请求参数设计:筛选条件不是简单的字符串拼接
翻车之后我们做的第一件事,是把接口的请求参数和响应结构重新设计了一遍。这个环节很多团队容易忽视,觉得反正就是一个查询接口,参数随便传就行。但联动的核心问题在于,多个筛选条件之间存在依赖关系,如果参数结构设计得不好,后端根本没法判断这些条件的优先级和组合方式。
我们最终把筛选接口的请求体设计成下面这样:
json复制{
"scene": "discount",
"page": 1,
"pageSize": 20,
"sortType": "default",
"filters": {
"category": {
"level1": 1001,
"level2": 1024
},
"brandIds": [5000123, 5000789],
"discountRange": {
"min": 30,
"max": 70
},
"priceRange": {
"min": 100,
"max": 500
}
}
}
设计的关键点在于,所有筛选条件放在一个独立的 filters 对象里,后端可以根据这些条件动态生成组合查询。scene 字段用来区分当前是不是折扣场景,这直接决定要不要走折扣商品的白名单过滤逻辑。品牌筛选用数组而不是单个值,支持多选,因为用户经常想同时看几个品牌的折扣商品。
还有个容易踩坑的点是类目层级。唯品会的类目是三级结构:一级类目(如服饰)、二级类目(如男装)、三级类目(如T恤)。用户在筛选器里可能只选了一级类目,也可能一路选到三级。后端必须支持不同粒度的类目过滤,并且当用户选了二级类目时,品牌列表计算要基于这个二级类目下的所有三级类目商品来做聚合,而不是只算二级类目ID这个维度。这个规则的优先级我们写死在接口层,不允许前端传过来的 category 同时包含 level1 和 level2 时后端还傻乎乎地把两个条件都带上。
响应结构也做了调整,最重要的变化是,接口除了返回商品列表,还要返回"下一级可选的品牌列表"和"下一级可选的类目列表"。这两个列表不是随便给的,是跟当前 filters 组合条件实时匹配后的结果。响应里增加了一个 reference 字段:
json复制{
"code": 0,
"data": {
"items": [],
"total": 1024,
"reference": {
"brands": [
{"brandId": 5000123, "brandName": "某品牌", "count": 128},
{"brandId": 5000789, "brandName": "某品牌2", "count": 96}
],
"categories": [
{"level2Id": 1024, "name": "男装", "count": 512}
]
}
}
}
reference 里的品牌列表和类目列表,才是前端渲染联动筛选器的数据源。这样做的好处是,前端不用自己维护一份全量品牌表和类目树,也不用在用户点击筛选时去推测"这个品牌在这个类目下有没有商品"。后端一次性把商品列表和联动选项都返回了,前端只负责展示。从接口语义上讲,这也让"联动"这件事的后端职责更清晰。
2.2 品牌-类目-折扣三维关系的预计算与存储
数据建模是另外一个关键。如果每次都实时查商品表去聚合品牌、类目、折扣这三个维度,必然扛不住大促流量。我们的解法是引入一层预计算的"筛选维度索引",用商品ID关联表 + 维度统计表的方式,把实时聚合转化为查询预聚合结果。
具体来说,我们建了一张筛选维度表,结构大致如下:
sql复制CREATE TABLE `screen_dim_index` (
`id` bigint NOT NULL AUTO_INCREMENT,
`product_id` bigint NOT NULL COMMENT '商品ID',
`category_l1` int NOT NULL DEFAULT '0',
`category_l2` int NOT NULL DEFAULT '0',
`category_l3` int NOT NULL DEFAULT '0',
`brand_id` bigint NOT NULL DEFAULT '0',
`discount_status` tinyint NOT NULL DEFAULT '1' COMMENT '1有折扣 0无折扣',
`discount_rate` int NOT NULL DEFAULT '0' COMMENT '折扣力度百分比',
`price` decimal(10,2) NOT NULL DEFAULT '0.00',
`sale_status` tinyint NOT NULL DEFAULT '1' COMMENT '1可售 0下架',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_category_brand` (`category_l2`, `brand_id`, `discount_status`, `sale_status`),
KEY `idx_brand_category` (`brand_id`, `category_l2`, `discount_status`, `sale_status`),
KEY `idx_discount_cat` (`discount_status`, `category_l2`, `brand_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这张表本质上是一张"商品筛选维度快照表",每行是一个商品在品牌、类目、折扣、价格维度上的描述。商品上下架、折扣力度变化、类目调整的时候,会通过异步任务刷新这张表。查询的时候,商品列表主查询走商品搜索服务或者ES,而 reference 里的品牌列表和类目列表走这张维度表的聚合查询。维度表的行数虽然比商品主表大(一个商品可能对应多个类目),但它的字段少、索引固定,聚合查询走覆盖索引,性能远好于直接对商品主表做 group by。
同时,我们还把"品牌在某个二级类目下的在售商品数"这个聚合结果,单独做了一层统计缓存,key 的格式是 dim:brand:cat:{level2Id}:{brandId}:{discountStatus},value 是商品数。为什么要在品牌列表接口里返回商品数?因为前端要根据商品数来决定某个品牌在筛选项里是否置灰,没有这个值,用户选了品牌后才发现没商品,体验很差。
预计算还有一个好处,就是可以把大范围聚合搬到离线任务里做。像"折扣专区下每个二级类目有哪些品牌"这种数据,我们直接在离线数仓里算好,放进Redis的Hash结构,实时接口用 HGETALL 就能拿到。这样,实时接口绝大部分时候只做一次维度索引表的范围查询 + 一次Redis Hash读取即可,代价非常可控。
3. 接口链路的高可用改造:缓存、降级与限流
3.1 多级缓存的分工:本地缓存、Redis、CDN
参数和数据建模做完后,我们开始做高可用改造。这块如果只看单点,其实方案都大同小异,但真正做的时候最大的坑在于:不同层级的缓存应该存什么数据,必须分清楚,不然缓存之间的一致性会让你崩溃。
我们分了三级缓存。第一级是应用本地缓存,用Caffeine,缓存时间60秒,主要存两类数据:一是"品牌在类目下的商品统计"这类高频读、低实时性要求的数据;二是商品搜索服务的降级兜底结果,防止下游抖动时接口雪崩。本地缓存的特点是访问极快、无网络开销,但容量有限,不能存大的集合,所以只适合存小对象。
第二级是Redis分布式缓存,缓存时间5分钟,存的是接口汇总结果,比如 reference 中的品牌列表、类目树,以及商品ID列表。这里有个经验值:商品ID列表不直接存整个商品详情,只存商品ID集合,前端拿到ID后再批量调用商品详情接口获取价格、图片、标题等信息。把"筛选条件"和"商品详情"拆开缓存,是因为商品详情是另一个独立的服务,它的缓存淘汰节奏和筛选条件不一样,混在一个缓存里会导致某个字段变了就要把整个大对象刷新一遍,浪费资源。
第三级是CDN缓存,针对的是"折扣频道首页的默认筛选结果"。因为大促期间大量用户进入频道时没有做任何筛选操作,这时候返回的默认品牌列表和类目树是完全一样的,直接用CDN缓存可以扛住极大的流量。CDN缓存时间设置的30秒,因为大促期间品牌和类目变化不会特别频繁,30秒的延迟用户感知不强。
三级缓存的分工可以用一个表来说明:
| 层级 | 存储 | 缓存时间 | 主要数据 | 淘汰机制 |
|---|---|---|---|---|
| L1 | Caffeine本地缓存 | 60秒 | 品牌商品数统计、下游兜底结果 | 容量淘汰 + 定时刷新 |
| L2 | Redis | 5分钟 | 筛选聚合结果、商品ID列表 | 过期 + 主动删除 |
| L3 | CDN | 30秒 | 默认筛选结果 | URL带版本号 |
| 兜底 | 数据库/预计算维度表 | 实时 | 聚合查询 | 无 |
3.2 热点品牌key的击穿防护与缓存预热
缓存三件套里,最怕的就是缓存击穿。大促期间,某个热门品牌(比如某个知名运动品牌)的筛选结果会被大量用户同时点击,这时候如果这个key的缓存刚好过期,而大量的请求同时打到数据库去重建缓存,瞬间就可能把数据库压垮。我们用了一个组合方案来防击穿。
第一层是互斥锁。在重建缓存的时候,用Redis的 SETNX 加锁,只有拿到锁的请求才能去数据库加载数据并重建缓存,其他请求短暂sleep后重试,从缓存里拿。这个方案的缺点是会阻塞请求,所以锁的超时时间必须设置得合理,我们设置的3秒,超过3秒直接放行去数据库查询,宁可慢一点也不能让请求卡死。
第二层是热点key主动续期。我们做了个热点维度的统计,在应用层记录每个key的访问次数。如果一个key在10秒内被访问超过500次,就认为它是热点key,后台线程会提前刷新它的缓存,不在过期时才重建。这个方案实现起来不复杂,但效果很好,大促期间热门品牌key的缓存命中率能维持在99.5%以上。
第三层是空值缓存。压测那次出现的"返回空数组"事件,其实本质上就是一个缓存穿透问题:某个类目和品牌组合下确实没有折扣商品,但因为没有缓存,每次请求都去数据库查一次,大量请求穿透到数据库后,数据库压力增大,其他正常的查询也被拖慢。我们的解法很简单,即使是空结果也缓存起来,key的过期时间设置短一些,2分钟就行。这样,一个空结果在2分钟内不会反复穿透到数据库。
3.3 下游依赖不可用时的降级策略
筛选接口依赖了好几个下游服务:商品搜索服务、商品详情服务、品牌服务、类目服务。任何一个下游抖动,都会影响接口的可用性。我们给每个下游依赖都做了独立的降级开关,并且基于Fault Tolerance框架(如Resilience4j或Sentinel)配置了不同的降级阈值。
商品搜索服务的降级策略比较特殊。正常情况下,商品列表主查询走搜索服务,但搜索服务的延迟在大促期间往往是最不稳定的。我们的降级方案是:当搜索服务P99超过500ms或错误率超过5%时,自动把商品列表查询切换到"维度索引表 + Redis商品ID缓存"的路径。因为维度索引表里已经有商品ID、品牌、类目、折扣这些核心信息,虽然不能支持复杂的排序(比如按销量排序),但至少能保证用户看到商品列表。对于大促场景来说,返回不完全准确的结果比返回错误结果或者超时要好得多,这是高可用设计里很重要的一条原则。
品牌服务和类目服务的降级更简单。这两个服务主要影响的是 reference 里的联动列表。如果品牌服务不可用,我们就直接返回当前类目下预先离线算好的静态品牌列表,虽然可能包含一些"该品牌暂无折扣商品"的选项,但至少筛选器还能展示出来,用户点了之后后端会返回空列表,这种情况下我们再给前端一个"该品牌暂无折扣商品"的友好提示。虽然体验略差,但比整个筛选器都没法渲染好得多。
限流这块,我们用的Sentinel做的接口级限流,规则分为两层:第一层是按QPS限流,单机阈值500;第二层是按线程数限流,防止接口被慢请求拖垮线程池。限流后超出阈值的请求直接返回一个特殊的响应码,前端收到这个响应码后展示"活动太火爆,请稍后重试"的提示,而不是让用户一直等着转圈。
4. 线上真实故障复盘:一次类目筛选结果为空的事故
4.1 故障现象与快速止血
上线运行了两周后,我们遇到了一次真实的线上故障,这次复盘的价值我觉得比前面所有设计加起来都大。那天晚上8点左右,监控报警显示某个热门二级类目的筛选接口错误率突然升到12%,紧接着有客服反馈,用户在折扣频道选择"男装"类目后,筛选器里品牌列表只显示了不到10个品牌,而且再点进某个品牌后,页面提示"该分类暂无商品",但实际上那个品牌当时有300多个商品在参与折扣。
我们当时的快速止血动作是:先把缓存时间从5分钟改成1分钟,然后回滚了当天下午发布的规则引擎改动。为什么要回滚规则引擎?因为当天下午我们刚上线了一个"折扣商品智能排序"的规则,我们怀疑是它引入了问题。但回滚后问题并没有消失,错误率和空结果现象依然存在,只是持续时间短了。这件事告诉我们一个教训:出问题之后第一反应应该是看监控数据和日志,而不是凭直觉回滚代码。回滚动作本身没有错,但应该在确认根因之后再做,否则会浪费宝贵的排查时间。
止血的同时,我打开了筛选接口的详细日志,把请求参数和响应结果打出来对比。很快发现一个规律:空结果只出现在选择了"男装"类目、并且同时选择了"折扣力度在50%-70%"这个区间的时候。其他折扣区间都正常。
4.2 根因定位:折扣过滤条件与品牌库交集查询的顺序问题
定位到具体场景后,我们顺着代码排查,最终在"品牌列表聚合查询"的SQL里找到了问题。我们的品牌列表聚合逻辑是:先按折扣力度过滤商品,然后按品牌做 group by 统计商品数,最后按商品数排序返回品牌列表。但有个细节是,品牌列表的初始数据集不是全量品牌,而是一个预先算好的"类目品牌映射表"。
问题出在过滤顺序上。我们当时的代码逻辑是这样的:
python复制# 伪代码
def get_reference_brands(category_id, discount_range):
# 1. 从映射表取出该类目下的全量品牌
all_brands = get_category_all_brands(category_id)
# 2. 查出该折扣范围内的商品品牌集合
discounted_brands = query_discounted_brands(category_id, discount_range)
# 3. 取交集
result = all_brands & discounted_brands
return result
看起来没问题对吧?但问题是,get_category_all_brands 返回的品牌映射表里,一些品牌因为近期运营调整被移出了分类映射,但维度索引表里还保留着这些品牌的折扣商品记录。也就是说,映射表里没有品牌X,但商品数据里却有品牌X的折扣商品。取交集之后,品牌X被过滤掉了,但商品列表的查询并没有用同一个映射表约束,商品列表还是能查到品牌X的商品。这就导致了"品牌列表里没有X,但用户直接搜索品牌X却有商品"的矛盾现象。
而"男装50%-70%"这个区间为什么特别严重?因为这个区间下,有两个被移出映射表的大品牌(某休闲品牌和某快时尚品牌)恰好有大量折扣商品,它们被品牌列表过滤后,剩下的品牌商品总数大幅减少,再叠加用户点了某个具体品牌后商品列表为空的情况,体验就特别糟糕。
这是个典型的"数据不一致导致逻辑错误"的故障。过滤条件来自两个不同的数据源,一个来自静态映射表,一个来自实时索引表,两者没有做到强一致。
4.3 修复方案与回归验证
修复方案分两步。第一步是临时修复:把 get_category_all_brands 的逻辑改成从维度索引表动态生成品牌集合,不再依赖静态映射表。动态生成的SQL如下:
sql复制SELECT brand_id
FROM screen_dim_index
WHERE category_l2 = 1024
AND discount_status = 1
AND sale_status = 1
AND discount_rate BETWEEN 50 AND 70
GROUP BY brand_id
HAVING COUNT(*) > 0
这个SQL返回的就是当前折扣条件下真实有商品的品牌集合,天然和商品列表查询保持一致。但动态生成会导致每次请求都做一次聚合,性能压力会变大,所以这只是临时修复。真正的长期方案是加了一个"品牌变更事件"的消息通知机制:运营在后台调整品牌分类映射时,会发送一个MQ消息,我们的服务收到消息后主动刷新维度索引表,并且把该品牌对应的筛选缓存和品牌列表缓存全部删除。同时,给静态映射表增加了 update_time 字段,每天凌晨做一次全量对账,发现映射表和维度索引表不一致的品牌,自动触发一次数据订正任务。
回归验证做了两件事。第一,用测试数据构造了"映射表有品牌X但索引表没有"以及"映射表没有品牌X但索引表有"两种反例场景,验证新逻辑下品牌列表和商品列表结果永远一致。第二,在大促前做了三天的小流量灰度,灰度期间筛选接口的空结果率从0.3%降到了0.02%,基本达到了预期。
这次故障复盘给我们的最大收获是:联动筛选接口的数据一致性,不是靠代码逻辑保证的,而是靠数据变更机制的闭环保证的。代码里写了再多的条件判断,如果底层数据源在不同时间点不一致,最终结果一定会有问题。
5. 压测数据与性能对比:改造前后到底提升了什么
5.1 压测场景设计与核心指标对比
高可用方案上线后,我们又做了一轮完整的压测,这次的结果才真正让人放心。压测场景分为三类:第一类是"默认频道页",模拟用户进入折扣频道不做任何筛选;第二类是"单条件筛选",模拟用户选了品牌或类目进行筛选;第三类是"联动筛选",模拟用户连续操作,先选类目再选品牌再调价格区间。三类场景并行压测,总目标QPS是2000。
改造前后的核心指标对比如下:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 单机QPS峰值 | 800 | 1800 |
| P99延迟(联动场景) | 3.2s | 260ms |
| 数据库连接数占用 | 120 | 15 |
| 筛选维度表查询耗时 | 380ms | 12ms(覆盖索引) |
| 缓存命中率 | 82% | 99.1% |
| 空结果率 | 1.2% | 0.02% |
| 依赖服务超时导致接口失败率 | 4.5% | 0.1% |
这里面最让我意外的是数据库连接数从120降到了15。因为加了预计算维度表和多级缓存之后,数据库真正承担的查询只有两种:缓存未命中时的维度索引表聚合查询,以及后台任务定期刷新缓存时的查询。大促流量几乎全被缓存吸收掉了,数据库压力反而比日常还小。
5.2 还有哪些地方可以做得更好
压测通过后,我们又从性能和架构两个角度做了复盘,发现还有几个优化点值得后续继续做。
第一个是预计算维度的粒度还可以更细。现在我们只做了品牌-类目-折扣三个维度的预计算,但实际业务里还有价格区间、新品标记等维度。后面可以引入一个更通用的"筛选维度Cube",把价格区间也纳入预计算,这样用户拖动价格滑块时就不用实时聚合了。
第二个是我们把商品列表的ID集合缓存做得比较粗暴,直接按"所有筛选条件的组合"作为key。这导致组合数量膨胀得比较快,Redis的内存压力不小。一种更好的做法是"倒排索引缓存",也就是把每个维度的商品ID集合单独缓存,比如 cat:{level2}:{categoryId} 是一个Set,brand:{brandId}:{discountStatus} 是一个Set,查询的时候先 SINTERSTORE 对集合做交集,再做分页。这种方式虽然增加了Redis的计算量,但大幅降低了缓存key的数量,而且支持任意维度组合,灵活性更高。我们打算下个版本就切到这种方案。
第三个是关于接口的容错体验。虽然我们做了降级和限流,但降级后的数据准确性还有提升空间。比如品牌服务不可用时返回的静态品牌列表,可能会出现不少"该品牌暂无折扣商品"的选项。我们正在做一个"品牌有效性探测"的定时任务,每5分钟把静态品牌列表和当前商品索引表做一次比对,标出哪些品牌在当前折扣场景下是"无效"的,降级时直接只返回有效品牌。这样一个任务,能让降级场景的用户体验和正常场景几乎一致。
最后再分享一个我做这类接口总结出来的经验:联动筛选接口的稳定性,核心不在于某一个技术点的精妙,而在于数据链路的闭环。从商品数据变更,到维度索引表,到预计算缓存,到接口输出,整条链路必须保证一致性和可追踪性。任何一个环节脱节,都会表现为用户端的诡异现象,比如筛选器选项和商品列表对不上。所以,做这类接口时,投入精力去设计数据变更机制和数据校验机制,远比在接口层写一堆防御性逻辑更有价值。
如果你也在做类似的电商筛选接口,我建议动手前先把"品牌、类目、折扣、价格"这几个维度的数据变更场景全部梳理一遍,画清楚数据流向,再设计接口和缓存。别学我们一开始那样,直接拿SQL拼查询,等到大促压测翻车了才回头补功课。
