电商平台商品管理实战:SPU与SKU的设计与实现

发布时间:2026/6/12 7:41:04

电商平台商品管理实战:SPU与SKU的设计与实现 1. SPU与SKU电商商品管理的基石第一次接触电商后台开发时我被各种商品编码搞得晕头转向。直到某次大促活动因为商品属性配置错误导致用户买到的手机颜色和页面显示不符才真正理解SPU和SKU的重要性。简单来说SPU是商品的身份证而SKU是商品的指纹——就像iPhone 13是一个SPU而iPhone 13 128GB 星光色则是一个具体的SKU。在实际项目中SPUStandard Product Unit承载着商品的基础信息比如名称、品牌、分类这些不变的属性。我们团队曾用汽车模型来帮助新人理解汽车的品牌、车型就是SPU而具体到2023款 红色 2.0T自动挡这个配置就是SKU。SKUStock Keeping Unit则关系到库存和销售的最小单元包含价格、规格等会变动的属性。常见的设计误区是把所有属性都塞进SPU或SKU。有次我们接手过一个老系统商品表居然有200多个字段就是因为早期没做好规划。后来重构时我们按这样的原则拆分SPU表存商品公共描述如电子产品参数SKU表存具体规格如内存大小、颜色商品描述单独建表支持富文本规格参数用JSON格式动态扩展2. 数据库设计实战四张表搞定商品体系2.1 核心表结构设计经过多个电商项目迭代我总结出最稳定的四表结构。先看tb_goodsSPU表的关键字段CREATE TABLE tb_goods ( id bigint(20) NOT NULL AUTO_INCREMENT, goods_name varchar(100) NOT NULL COMMENT 商品名称, brand_id bigint(20) DEFAULT NULL COMMENT 品牌ID, category1_id bigint(20) DEFAULT NULL COMMENT 一级类目, category2_id bigint(20) DEFAULT NULL COMMENT 二级类目, category3_id bigint(20) DEFAULT NULL COMMENT 三级类目, is_enable_spec tinyint(1) DEFAULT 0 COMMENT 是否启用规格, audit_status varchar(2) DEFAULT 0 COMMENT 审核状态, price decimal(10,2) DEFAULT NULL COMMENT 基准价, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;tb_goods_desc表专门存储商品详情用JSON格式应对复杂场景CREATE TABLE tb_goods_desc ( goods_id bigint(20) NOT NULL COMMENT SPU_ID, item_images json DEFAULT NULL COMMENT 商品图片集, specification_items json DEFAULT NULL COMMENT 规格参数, custom_attribute_items json DEFAULT NULL COMMENT 自定义属性, package_list text COMMENT 包装清单, sale_service text COMMENT 售后服务, PRIMARY KEY (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.2 SKU表的特殊设计tb_item表的设计最有讲究我们踩过三个坑后才定型最初没加冗余字段每次查询都要多表关联后来过度冗余导致更新困难最终采用适度冗余方案CREATE TABLE tb_item ( id bigint(20) NOT NULL AUTO_INCREMENT, goods_id bigint(20) NOT NULL COMMENT SPU_ID, title varchar(100) NOT NULL COMMENT 商品标题, spec json DEFAULT NULL COMMENT 规格参数JSON, price decimal(10,2) NOT NULL COMMENT 价格, num int(10) NOT NULL COMMENT 库存, status varchar(1) DEFAULT 1 COMMENT 状态, is_default varchar(1) DEFAULT NULL COMMENT 是否默认, category_id bigint(20) DEFAULT NULL COMMENT 类目ID(冗余), category varchar(50) DEFAULT NULL COMMENT 类目名称(冗余), brand varchar(50) DEFAULT NULL COMMENT 品牌名称(冗余), spec_map varchar(500) DEFAULT NULL COMMENT 规格键值对(搜索用), PRIMARY KEY (id), KEY idx_goods_id (goods_id), KEY idx_category (category_id), FULLTEXT KEY ft_spec (spec_map) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;提示JSON字段在MySQL 5.7才能使用如果版本较低可以用TEXT代替但查询效率会降低3. 前后端交互的三种模式3.1 标准商品发布流程前端传参结构经历过三次重大调整最终形成的方案既能满足复杂商品需求又保持接口简洁。这是我们现在使用的数据结构{ goods: { isEnableSpec: 1, // 是否启用规格 category3Id: 3, // 叶子类目 goodsName: 华为P50, brandId: 1, price: 3988 // 基准价 }, goodsDesc: { itemImages: [ {color:黑色,url://img1.jpg}, {color:白色,url://img2.jpg} ], specificationItems: [ { attributeName: 内存, attributeValue: [8GB,12GB] } ] }, itemList: [ { spec: {内存:8GB,颜色:黑色}, price: 3988, num: 1000 }, { spec: {内存:12GB,颜色:白色}, price: 4288, num: 500 } ] }3.2 无规格商品处理很多开发者会忽略无规格商品的情况比如图书、虚拟商品。我们的解决方案是在service层做兼容处理// 在GoodsServiceImpl中 if (0.equals(goods.getGoods().getIsEnableSpec())) { TbItem item new TbItem(); item.setTitle(goods.getGoods().getGoodsName()); item.setPrice(goods.getGoods().getPrice()); item.setNum(99999); // 默认库存 item.setSpec({}); // 空JSON item.setIsDefault(1); // 唯一SKU即为默认 setItemValues(goods, item); itemMapper.insert(item); }3.3 批量操作优化大促前批量导入商品时原始方案会导致数据库连接爆满。我们最终采用两种优化方案使用MyBatis的批量插入语法对于超大批量数据改用文件导入异步处理!-- MyBatis批量插入配置 -- insert idbatchInsert parameterTypejava.util.List INSERT INTO tb_item (goods_id, title, spec, price, num) VALUES foreach collectionlist itemitem separator, (#{item.goodsId}, #{item.title}, #{item.spec}, #{item.price}, #{item.num}) /foreach /insert4. 业务逻辑中的六个坑点4.1 规格名称生成算法早期我们直接用字符串拼接生成SKU名称直到出现华为P508GB黑色这样的歧义名称。现在的方案是StringBuilder title new StringBuilder(goods.getGoodsName()); MapString,String specMap JSON.parseObject(item.getSpec(), Map.class); specMap.forEach((k,v) - title.append( ).append(v)); item.setTitle(title.toString()); // 同时生成搜索用的键值对字符串 item.setSpecMap(specMap.entrySet().stream() .map(e - e.getKey() : e.getValue()) .collect(Collectors.joining(,)));4.2 主图选择策略商品详情页的主图展示有这些规则默认取第一个SKU的图片用户选择不同规格时切换对应图片无规格商品直接使用SPU图片实现代码示例ListMap images JSON.parseArray(goodsDesc.getItemImages(), Map.class); if (!images.isEmpty()) { // 取规格对应的颜色图或默认第一张 String color specMap.get(颜色); OptionalMap matched images.stream() .filter(img - color.equals(img.get(color))) .findFirst(); item.setImage(matched.orElse(images.get(0)).get(url).toString()); }4.3 价格体系处理我们遇到过最复杂的价格场景包括会员等级价促销活动价阶梯价格组合优惠价最终方案是在SKU表只存基准价其他价格通过单独服务获取。核心逻辑public BigDecimal getFinalPrice(Long skuId, Integer userId) { // 1. 获取基准价 BigDecimal basePrice itemMapper.selectPriceById(skuId); // 2. 获取所有可用优惠 ListPromotion promotions promotionService .getValidPromotions(skuId, userId); // 3. 计算最优价格 return promotions.stream() .map(p - p.calculate(basePrice)) .min(BigDecimal::compareTo) .orElse(basePrice); }5. 性能优化实战记录5.1 商品详情页优化某次618大促期间商品详情页接口响应时间从800ms降到120ms主要优化点多级缓存策略本地缓存Guava Cache存储基础SPU信息有效期5分钟Redis缓存存储完整的商品详情有效期1小时异步刷新商品变更时通过消息队列更新缓存public GoodsDetail getDetail(Long spuId) { // 一级缓存检查 GoodsDetail detail localCache.get(spuId); if (detail ! null) return detail; // 二级缓存检查 String redisKey goods: spuId; detail redisTemplate.opsForValue().get(redisKey); if (detail ! null) { localCache.put(spuId, detail); return detail; } // 数据库查询 detail assembleDetail(spuId); redisTemplate.opsForValue().set(redisKey, detail, 1, HOURS); localCache.put(spuId, detail); return detail; }5.2 商品搜索优化Elasticsearch的索引设计直接影响搜索效果我们的商品索引包含这些关键字段{ mappings: { properties: { goodsId: {type: keyword}, title: { type: text, analyzer: ik_max_word }, specMap: { type: text, analyzer: whitespace }, price: {type: double}, brandId: {type: keyword}, categoryPath: { type: keyword, index: false } } } }特别要注意的是规格参数的索引方式我们采用key:value的格式存储便于做精确筛选// 构建索引文档时 document.addField(specMap, specMap.entrySet().stream() .map(e - e.getKey() : e.getValue()) .collect(Collectors.joining( )));6. 扩展性设计经验6.1 商品类型模板系统为支持不同类目的商品参数差异我们开发了类型模板系统后台维护各商品类目的参数模板前端根据模板动态生成参数表单参数以JSON格式存储在商品描述表模板表示例{ templateName: 手机模板, attributes: [ { name: 网络制式, type: multi_select, options: [5G, 4G, 3G] }, { name: 电池容量, type: number, unit: mAh } ] }6.2 商品审核流程自营和商家商品的审核流程完全不同我们通过状态机实现public enum GoodsAuditStatus { DRAFT(0, 草稿), WAITING(1, 待审核), APPROVED(2, 审核通过), REJECTED(3, 审核拒绝); // 状态流转规则 private static final MapString, ListString TRANSITIONS Map.of( 0, List.of(1), 1, List.of(2, 3), 3, List.of(1) ); public static boolean canTransfer(String from, String to) { return TRANSITIONS.getOrDefault(from, List.of()) .contains(to); } }在商品服务中调用public Result applyAudit(Long spuId) { TbGoods goods goodsMapper.selectById(spuId); if (!GoodsAuditStatus.canTransfer(goods.getAuditStatus(), 1)) { return Result.error(当前状态不能提交审核); } goods.setAuditStatus(1); goodsMapper.updateById(goods); return Result.success(); }

相关新闻