多来买:商品详情数据模型与聚合实战
多来买:商品详情数据模型与聚合实战
事实来源:当前 product-service、product-api 的 Controller、Service、DTO、Mapper 与模型源码
证据状态:代码已确认,未构建、未启动、未连接数据库。
阅读目标:理解详情页为什么需要聚合 SPU、SKU、图片、分类和属性,而不是返回一张商品表。
1. 业务问题与边界
商品详情接口接收一个 skuId,最终却要返回价格、图片、分类面包屑、海报、销售属性、规格切换关系和平台属性。
这些数据分布在多张表中,且部分查询必须先拿到 SKU 才能确定 spuId 或三级分类 ID。
本文只回答三个问题:
- 商品详情读模型由哪些数据组成;
- Controller 到 Mapper 的聚合链路怎样工作;
- 当前查询结构有哪些明确边界。
Bloom、缓存和异步编排见下一篇,Elasticsearch 与上下架同步分别见本专题第 3、4 篇。
2. 模块职责
| 模块 | 本文职责 |
|---|---|
duolaimall-product/product-api | 定义详情 DTO 和跨服务商品 DTO |
duolaimall-product/product-service | 提供详情入口、聚合 Service 和 Mapper |
| MySQL 商品库 | 保存 SKU、SPU、图片、分类和属性事实数据 |
| MapStruct Converter | 在 PO、DTO 和详情读模型之间转换 |
详情链路没有跨到搜索服务:详情读取商品数据库,搜索服务维护的是另一套面向检索的读模型。
3. SPU、SKU 与两类属性
| 概念 | 示例 | 当前用途 |
|---|---|---|
| SPU | 某一款手机型号 | 聚合一组可切换 SKU |
| SKU | 某颜色和存储组合 | 展示、定价、上下架的最小单元 |
| 销售属性 | 颜色、版本 | 详情页规格选择和 SKU 切换 |
| 平台属性 | 屏幕尺寸、内存 | 规格展示与搜索筛选 |
主要数据表:
| 表 | 作用 |
|---|---|
sku_info | SKU 基础信息、SPU、价格、品牌、分类和上架状态 |
sku_image、spu_image | SKU 图片关联及图片内容 |
spu_poster | SPU 详情海报 |
spu_sale_attr_info、spu_sale_attr_value | SPU 可选销售属性和值 |
sku_sale_attr_value | SKU 实际选择的销售属性值 |
sku_platform_attr_value | SKU 关联的平台属性和值 |
platform_attr_info、platform_attr_value | 平台属性定义及候选值 |
| 一、二、三级分类表 | 分类面包屑 |
4. 端到端链路
图中只表达依赖关系;具体并发编排在本专题第 2 篇展开。
5. 详情读模型
源码:duolaimall-product/product-api/src/main/java/com/cskaoyan/mall/product/dto/ProductDetailDTO.java,ProductDetailDTO。
@Datapublic class ProductDetailDTO { SkuInfoDTO skuInfo; List<SpuSaleAttributeInfoDTO> spuSaleAttrList; String valuesSkuJson; BigDecimal price; CategoryHierarchyDTO categoryHierarchy; List<SpuPosterDTO> spuPosterList; List<SkuSpecification> skuAttrList;}它是面向详情页面的读模型,不对应单张表。
| DTO 字段 | 查询入口 | 根依赖 |
|---|---|---|
skuInfo | SkuService#getSkuInfo | skuId |
price | SkuService#getSkuPrice | skuId |
categoryHierarchy | CategoryService#getCategoryViewByCategoryId | SKU 的三级分类 ID |
spuPosterList | SpuService#findSpuPosterBySpuId | SKU 的 spuId |
spuSaleAttrList | SkuService#getSpuSaleAttrListCheckBySku | spuId 与 skuId |
valuesSkuJson | SpuService#getSkuValueIdsMap | spuId |
skuAttrList | SkuService#getPlatformAttrInfoBySku | skuId |
6. 当前 Controller 入口
源码:duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/controller/ProductDetailController.java,ProductDetailController#goodsDetail。
@GetMapping("/goods/{skuId}")public Result goodsDetail(@PathVariable Long skuId) { ProductDetailDTO productDetailDTO = productDetailService.getItemBySkuIdAsync(skuId); return Result.ok(productDetailDTO);}类中保留了 V1、V2 和其他异步版本,但当前 Controller 调用的是 getItemBySkuIdAsync。
判断当前行为时应沿这个方法继续阅读,不能把保留方法当成运行入口。
Controller 没有拆解子查询,也没有对 skuId 做业务校验;聚合和异常都交给后续层。
7. SKU 基础信息与图片
源码:duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/service/impl/SkuServiceImpl.java,SkuServiceImpl#getSkuInfo。
@Override@CsmallCache(prefix = "skuInfo:")public SkuInfoDTO getSkuInfo(Long skuId) { SkuInfo skuInfo = skuInfoMapper.selectById(skuId);
LambdaQueryWrapper<SkuImage> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(SkuImage::getSkuId, skuId); List<SkuImage> skuImageList = skuImageMapper.selectList(queryWrapper);
skuImageList.forEach(skuImage -> { SpuImage spuImage = spuImageMapper.selectById(skuImage.getSpuImgId()); skuImage.setImgName(spuImage.getImgName()); skuImage.setImgUrl(spuImage.getImgUrl()); });
skuInfo.setSkuImageList(skuImageList); return skuInfoConverter.skuInfoPO2DTO(skuInfo);}数据流:
sku_info -> sku_image 列表 -> 每条关联再查 spu_image -> 回填 SkuInfo -> MapStruct 转为 SkuInfoDTO当前代码具有逐图片查询特征。缓存命中可以减少重复回源,但首次查询仍可能出现一加 N 次访问。
若 selectById 返回 null,后续 setSkuImageList 无法正常执行;前置 Bloom 只能降低无效请求,不能替代空值处理。
8. 分类面包屑
源码:duolaimall-product/product-service/src/main/resources/com/cskaoyan/mall/product/mapper/ThirdLevelCategoryMapper.xml,selectCategoryHierarchyDTOById。
<select id="selectCategoryHierarchyDTOById" resultType="com.cskaoyan.mall.product.model.CategoryHierarchy"> select flc.id AS firstLevelCategoryId, flc.name AS firstLevelCategoryName, slc.id AS secondLevelCategoryId, slc.name AS secondLevelCategoryName, tlc.id AS thirdLevelCategoryId, tlc.name AS thirdLevelCategoryName from third_level_category tlc inner join second_level_category slc on tlc.second_level_category_id = slc.id inner join first_level_category flc on slc.first_level_category_id = flc.id where tlc.id = #{thirdLevelCategoryId} and tlc.is_deleted = 0 and slc.is_deleted = 0 and flc.is_deleted = 0</select>这里通过一次联表获得三级完整路径,而不是在 Java 中逐级查询三次。
查询还要求三个分类层级都未逻辑删除,因此任一上级无效都可能导致整个面包屑为空。
9. 销售属性与选中态
源码:duolaimall-product/product-service/src/main/resources/com/cskaoyan/mall/product/mapper/SpuSaleAttrInfoMapper.xml,selectSpuSaleAttrListCheckedBySku。
<select id="selectSpuSaleAttrListCheckedBySku" resultMap="spuSaleAttrMap"> select a.id, a.spu_id, a.sale_attr_id, a.sale_attr_name, b.id as bid, b.spu_sale_attr_value_name, IF(c.id is NULL, 0, 1) as is_checked from spu_sale_attr_info a inner join spu_sale_attr_value b on a.id = b.spu_sale_attr_id left join sku_sale_attr_value c on b.id = c.spu_sale_attr_value_id and c.sku_id = #{skuId} where a.spu_id = #{spuId} order by a.sale_attr_id, b.id</select>查询先返回 SPU 的全部销售属性和值,再通过 LEFT JOIN 标记当前 SKU 是否选中。
因此前端同时拥有:
- 可以展示的完整选项;
- 当前 SKU 的选中状态;
- 切换规格时需要拼接的候选值。
10. 规格组合到 SKU 的映射
源码:duolaimall-product/product-service/src/main/resources/com/cskaoyan/mall/product/mapper/SkuSaleAttrValueMapper.xml,selectSaleAttrValuesBySpu。
<select id="selectSaleAttrValuesBySpu" resultType="com.cskaoyan.mall.product.model.SkuSaleAttributeValuePermutation"> select a.sku_id as skuId, group_concat( a.spu_sale_attr_value_id order by c.sale_attr_id separator '|' ) as SkuSaleAttrValuePermutation from sku_sale_attr_value a inner join spu_sale_attr_value b on b.id = a.spu_sale_attr_value_id and a.is_deleted = 0 and b.is_deleted = 0 inner join spu_sale_attr_info c on c.id = b.spu_sale_attr_id and c.is_deleted = 0 inner join sku_info d on d.id = a.sku_id and d.is_sale = 1 and d.is_deleted = 0 where a.spu_id = #{spuId} group by a.sku_id</select>Service 把结果转换为:
"销售属性值ID|销售属性值ID" -> skuId属性值必须按稳定顺序拼接,否则相同选择可能生成不同 key。
SQL 只包含已上架、未删除 SKU;但查询结果会被缓存,上下架后是否立即更新取决于缓存失效,当前未看到统一失效链路。
11. 平台属性聚合
源码:duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/service/impl/SkuServiceImpl.java,SkuServiceImpl#getPlatformAttrInfoBySku。
List<SkuPlatformAttributeValue> relations = skuPlatformAttrValueMapper.selectList(queryWrapper);
List<PlatformAttributeInfo> infos = relations.stream() .map(relation -> { PlatformAttributeInfo info = platformAttrInfoMapper.selectById(relation.getAttrId()); PlatformAttributeValue value = platformAttrValueMapper.selectById(relation.getValueId()); info.setAttrValueList(Arrays.asList(value)); return info; }) .collect(Collectors.toList());该分支先查 SKU 属性关联,再为每个关联分别查询属性定义和值。
若 SKU 有 N 个平台属性,回源结构近似:
1 次关联查询 + N 次属性定义查询 + N 次属性值查询异步化只能与其他详情分支并行,不能消除本分支内部的 N+1 查询。
12. 数据依赖图
根节点不是 SPU,而是 skuId 对应的 SKU 基本信息;多个后续分支从中取得 spuId 和分类 ID。
13. 正常、空值与失败分支
| 场景 | 当前静态结果 |
|---|---|
| SKU 和关联数据完整 | 聚合为 ProductDetailDTO |
| 图片关联为空 | SKU 仍可返回,图片列表为空 |
| SKU 不存在 | getSkuInfo 存在空指针触发路径 |
| 分类任一级已删除 | 联表结果可能为空 |
| 属性关联存在但定义缺失 | 逐项查询可能返回空并影响回填 |
| 数据库某分支异常 | 是否影响整页取决于异步分支异常策略 |
这些结果来自代码结构,不是运行观测。
14. 已确认限制与候选风险
14.1 已确认限制
- 详情是多表聚合,不具备单表读取的原子快照。
- 图片和平台属性存在逐项查询结构。
- Controller 没有对非法或不存在的
skuId做显式校验。 - 多个历史详情实现仍在类中,当前入口只使用异步版本。
- 规格组合缓存没有看到与上下架联动的统一失效。
14.2 候选风险
| 风险 | 触发机制 | 缺失证据 |
|---|---|---|
| 无效 SKU 空指针 | Bloom 误判后数据库仍返回空 | 未运行接口复现 |
| 冷查询放大数据库访问 | 图片与平台属性 N+1 | 未做 SQL 统计或压测 |
| 聚合字段短暂不一致 | 多分支独立读取不同表 | 未构造并发写入场景 |
| 规格切换仍包含旧映射 | 缓存失效链路不明确 | 未连接 Redis 验证 |
15. 如果重做(非当前实现)
可评估方向:
- 对 SKU 不存在建立明确的业务结果,而不是依赖空对象继续聚合;
- 使用批量查询或联表减少图片、平台属性的 N+1;
- 明确详情缓存与上下架、属性更新之间的失效事件;
- 为聚合分支定义必需字段和可降级字段;
- 使用统一线程池和超时策略编排独立查询。
这些是演进建议,不代表当前项目已经实现。
16. 关键源码导航
| 阅读问题 | 项目相对路径 | 类/方法 |
|---|---|---|
| 详情 HTTP 入口 | duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/controller/ProductDetailController.java | goodsDetail |
| 详情返回模型 | duolaimall-product/product-api/src/main/java/com/cskaoyan/mall/product/dto/ProductDetailDTO.java | ProductDetailDTO |
| 当前聚合实现 | duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/service/impl/ProductDetailServiceImpl.java | getItemBySkuIdAsync |
| SKU 与图片 | duolaimall-product/product-service/src/main/java/com/cskaoyan/mall/product/service/impl/SkuServiceImpl.java | getSkuInfo |
| 分类联表 | duolaimall-product/product-service/src/main/resources/com/cskaoyan/mall/product/mapper/ThirdLevelCategoryMapper.xml | selectCategoryHierarchyDTOById |
| 销售属性选中态 | duolaimall-product/product-service/src/main/resources/com/cskaoyan/mall/product/mapper/SpuSaleAttrInfoMapper.xml | selectSpuSaleAttrListCheckedBySku |
| 规格组合 | duolaimall-product/product-service/src/main/resources/com/cskaoyan/mall/product/mapper/SkuSaleAttrValueMapper.xml | selectSaleAttrValuesBySpu |
17. 短复习点
- 详情页返回的是跨表读模型,
skuId是聚合根入口。 - SKU 先提供
spuId和三级分类 ID,再触发依赖查询。 - 销售属性负责规格选择,平台属性负责规格展示与搜索筛选。
- 图片和平台属性当前存在 N+1 回源结构。
- 本文只能确认代码链路,不能声称数据库与缓存当前可运行。
18. 一句话总结
多来买商品详情以 SKU 为根,把 SPU、图片、分类和两类属性聚合成页面读模型;实现能够串起完整数据链路,但空值处理、N+1 查询和缓存失效仍是必须如实说明的边界。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!