大概在半年前,我接了一个电商后台系统的重构需求。需求清单里躺着一条最不起眼但后来让我连续加了三个通宵的条目:商品添加功能。当时觉得这不就是一张表单、一个提交按钮、一张数据库表的事吗?等真正把“商品添加”四个字拆开揉碎,才发现这是整个电商系统里最考验设计功底的地方——光是一个SKU和SPU的关系就能让新手直接懵掉,更别提图片上传、库存校验、价格区间、属性继承这些细节了。
这篇文章就把我做商品添加功能的全过程整理出来。不聊高大上的架构,只说从需求理解、数据建模、表单设计、前后端联调,到幂等防重、性能优化、异常排查这一路踩过的坑和沉淀下来的方案。无论你是刚入行的后端开发、全栈工程师,还是正在规划电商后台的产品经理,这份总结应该能让你少走几段弯路。
1. 商品添加功能整体拆解:它到底在解决什么问题
1.1 从“一张表单”到“一套状态机”的需求认知转变
大多数人对商品添加功能的第一印象来自早年做管理系统时的经历:写个页面,标题、价格、库存、描述,点保存,往数据库插一条记录。只要不报500,就算交付了。
但放在真实电商场景里,商品添加面临的是完全不同的复杂度。我接手的那个项目,商品来自多个供应商,有的卖服装,颜色尺码都要拆开管理;有的卖数码产品,型号参数各不相同;还有的是粮油日化,一个商品对应一个条码。同样的“添加商品”,不同类目下的字段完全不一样,校验规则也千差万别。
这背后的本质问题是:商品添加功能不是在“往表里插一条数据”,而是在完成一个状态建模的过程。一个完整的商品,至少要经历草稿、待审核、已上架、已下架这几个状态。商品添加只是状态机的一个入口,但它决定了后面所有环节的数据质量和用户体验。
我当时的做法是先把需求拆成了五个子问题:
- 商品的基础字段有哪些,哪些是全局共有,哪些是类目私有
- 多规格商品(比如一个T恤有3个颜色、4个尺码)怎么在表单里表达
- 商品添加时如何校验价格、库存、编码等数据的合法性
- 用户操作过程中如何避免重复提交、并发覆盖
- 添加完成后如何保证数据最终一致,比如库存、日志、索引同步
这五个问题,每一个都能单独写一篇技术文档。但真正做的时候,它们会纠缠在一起。最典型的例子就是:用户在前端选好“鞋服类”后,才出现“颜色”“尺码”的输入区域,提交时后端必须根据类目动态调整校验逻辑——这就不是一张静态表单能搞定的事情了。
1.2 典型应用场景与底层通用能力
商品添加功能并不只存在于电商后台。进销存系统里要添加货品,外卖后台要添加菜品,二手交易平台要发布闲置,本质上都是商品添加功能的变种。它们共享一套底层能力:
- 结构化数据组装:用户零散输入的信息,最终要组织成符合数据库范式结构的数据形态
- 合法性校验:包括必填校验、格式校验、范围校验、唯一性校验
- 依赖检查:比如类目必须存在并且处于启用状态,品牌必须已经通过审核
- 事务的一致性保证:当商品基础信息、SKU明细、图片资源需要同时写入多张表时,任何一个环节失败都不能留下半截数据
- 操作可追溯:谁在什么时间添加了哪个商品,必须留痕
把这套底层能力摸清楚之后,商品添加功能的轮廓就出来了:前端是动态渲染的表单,后端是一个聚合了校验、落库、状态流转、日志记录的服务接口。
2. 核心细节解析与实操要点:数据建模是根基
2.1 SPU与SKU建模:多规格商品的数据骨架
如果说商品添加功能只能讲一个知识点,我一定选SPU与SKU的建模。当初我在这个点上的理解偏差,直接导致第一次提交的方案被资深架构师当场打回。
简单说,SPU是标准化产品单元,比如“某品牌圆领白T恤”——它描述的是一个商品,不具体到颜色尺码。SKU是库存量单位,比如“某品牌圆领白T恤-白色-M码”——这是可以定价、下单、扣库存的最小颗粒度。
商品添加时,前端可能只让用户填一次“名称”“品牌”“类目”,然后填写多组规格值。后端要把这些信息拆分成:一个SPU记录,加多个SKU记录。以一件有三色两码的T恤为例,一个SPU能拆出3乘2等于6个SKU。如果设计时只建了一张商品表,把颜色尺码塞进一个JSON字段里,后面做库存管理、订单拆分的时候就会痛苦到怀疑人生。
我落地时的表结构大致是这样:
- 商品主表:存SPU级别的信息,包括商品名称、类目ID、品牌ID、状态、创建时间
- SKU表:存SKU级别的信息,包括SKU编码、商品ID、规格组合(颜色/尺码)、价格、库存、条形码
- 属性表:存类目相关的扩展属性,比如手机的“屏幕尺寸”“电池容量”
- 商品属性关系表:关联商品ID和属性ID,存具体值
字段上有一个地方要特别提醒:价格和库存必须放在SKU表,不能只放在商品主表。因为不同颜色、不同尺码的T恤,价格和库存可以完全不同。很多新手做第一版时图省事,把价格库存都挂在SPU上,结果一到下单环节就发现没法处理“白色M码没货但白色L码有货”这种极常见的场景。
2.2 表单字段分组与动态校验规则
商品添加页面不能是几十个字段一窝蜂铺开。我见过很多后台这样干,用户打开页面瞄一眼就关掉了,操作负担太重。正确的姿势是按业务逻辑分组,让用户依次完成:
- 基本信息:商品名称、副标题、类目、品牌
- 销售信息:价格、市场价、库存、起购数量
- 规格属性:规格名(颜色、尺码、容量等)、规格值、SKU列表
- 媒体资源:主图、详情图、视频
- 其他设置:上架时间、是否推荐、排序权重
分组不是目的,分组是为了让校验规则跟随上下文。用户填到“销售信息”这一步时,系统才校验价格范围;填到“规格属性”这一步时,才校验规格组合是否有遗漏。这种渐进式校验的体验,远好于最后点提交时一次性报出一堆红字错误。
关于校验,我有三条实战经验:
- 后端校验必须兜底,不能只依赖前端。前端校验是为了体验,后端校验才是保命。接口被curl直接调用时,什么正则都能绕过。
- 唯一性校验要考虑并发。商品条码或商品编码往往要求唯一,但先查后插的方式在高并发下会破功。最稳妥的方案是对唯一字段建数据库唯一索引,同时把捕获到的DuplicateKeyException翻译成友好的中文提示。
- 异常信息要具体。别返回“参数错误”这种废话。用户不知道是自己没填库存,还是库存格式不对。至少给出类似“库存必须为大于0的整数”的说法。
2.3 参数校验的具体写法参考
后端接口我使用的是Spring Boot框架,参数校验通过Validator注解完成。一个商品添加DTO的核心字段大致长这样:
public class ProductAddDTO { @NotBlank(message = "商品名称不能为空") @Size(max = 100, message = "商品名称长度不能超过100字符") private String name; @NotNull(message = "类目ID不能为空") private Long categoryId; @NotNull(message = "品牌ID不能为空") private Long brandId; @NotNull(message = "平台价不能为空") @DecimalMin(value = "0.01", message = "平台价必须大于0") private BigDecimal price; @NotNull(message = "库存不能为空") @Min(value = 0, message = "库存不能为负数") private Integer stock; @NotEmpty(message = "至少需要一条SKU数据") @Valid private List<SkuAddDTO> skuList; }在业务层做校验时,我还习惯做一次手动校验。为什么?因为注解校验只能解决字段级别的合法性问题,解决不了业务规则层面的问题。比如商品类目必须处于启用状态,这一类检查就需要查数据库。我把这层手动校验看作二次门禁,技术上不复杂,但能挡住大量脏数据。
3. 实操过程与核心环节实现:一步一步把功能落地
3.1 商品编码生成的实战方案
商品编码是商品添加时绕不开的字段。它不是一个随便生成的随机数——在后续的订单、物流、对账环节中,商品编码都要作为稳定的业务标识存在。
我项目里的编码规则是供应商编码加类目编码加日期加序号,呈现出来类似SUP001-CAT003-20250611-0001。给用户展示这种编码,能直接看出商品归属和类目,不用去翻数据库。
生成编码最怕两件事:一是重复,二是性能差。用数据库的SELECT MAX(serial)再+1的方式,在并发场景下很容易拿到同一个号。我最终的方案是在应用层生成,利用Redis的INCR命令保证同一日期下的流水号自增,然后再拼上其他前缀字段。流程在伪代码里是这样:
public String generateProductCode(String supplierCode, String categoryCode) { String datePart = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); String key = "product:code:" + supplierCode + ":" + categoryCode + ":" + datePart; long seq = redisTemplate.opsForValue().increment(key); // 为这个key设置过期时间,防止老数据一直堆积 redisTemplate.expire(key, 48, TimeUnit.HOURS); return supplierCode + "-" + categoryCode + "-" + datePart + "-" + String.format("%04d", seq); }如果团队没有Redis,也可以用数据库的乐观锁或者针对唯一编码表做插入冲突检测,核心思想都是利用某种原子操作确保生成过程的并发安全性。并发不高的小团队直接从一张编码序列表拿号也没问题,但当并发量过了某个阈值,还是Redis最省心。
3.2 事务控制:保证两张表同时成功
商品添加的落库会涉及商品主表、SKU表、属性表等多张表,我必须让这些张表的数据要么全部成功,要么全部回滚。
在Spring Boot中,这是一个标准的@Transactional应用场景。但“加个注解”和“真正安全”之间的距离,比很多人想象中要大。加注解之后有三件事必须检查:
- 事务是否真的生效:同类的内部调用
this.addProduct()不会经过Spring代理,事务可能失效。放在不同Service中互相调用,或者使用TransactionTemplate,可以规避这个坑。 - 事务范围是否合理:不要把图片上传这类重I/O操作包进事务里。上传文件动辄几百毫秒,长时间占用数据库连接会造成连接池耗尽。我当时的做法是:先上传拿到图片URL,再把URL作为字符串入库。
- 异常是否被静默吞掉:很多人会在事务方法里写
try-catch,然后返回一个错误提示。一旦异常被捕获,框架就无法触发回滚。正确的做法是让异常直接抛出去,或者捕获后显式调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
落库的核心代码,我简化之后大致是这个样子:
@Override @Transactional(rollbackFor = Exception.class) public Long addProduct(ProductAddDTO dto) { // 1. 校验类目与品牌 // 2. 生成商品编码 // 3. 保存SPU主表,得到productId // 4. 循环保存SKU列表 // 5. 保存商品属性关系 // 6. 写入操作日志,记录来源与操作人 return productId; }这里值得多说一句:步骤6的操作日志很多人会漏掉。商品添加看起来只是一个写操作,但一旦出现数据纠纷或需要审计,日志就是唯一的追溯证据。日志表设计和主流程放在一个事务里没有问题,因为它本身也是本地数据库写入,不会拖累外部服务。
3.3 接口幂等防重:连续点击提交按钮的灾难
商品添加最经典的事故就是用户双击提交按钮,同一个商品被保存了两遍。如果在正常业务里,用户就是手快点了两下,产生的后果不算特别严重,顶多后台多一条重复数据。但如果你碰上一个写了定时脚本误调接口的第三方系统,那后果就可能是几千条重复商品瞬间涌入。
防重方案我用的组合拳是“前端置灰+后端Token幂等”:
前端提交按钮在点击后立即置灰,并进入loading状态。但这只防君子不防小人,接口被绕过页面直接调用时依然可能重复。所以后端必须做一道防重。
具体做法是:后端提供一个幂等Token接口,前端进入商品添加页时先请求一个幂等Token,提交时带着Token一起发送。后端收到数据后,先查询Redis中是否有这个Token:
- 不存在,说明可能被重复提交或Token过期,直接拒绝
- 存在,则先删除Token,再执行后续业务逻辑
删除和业务执行的顺序很关键。如果先执行业务再删除Token,极端情况下两个并发请求同时通过了Token检查,还是会重复插入。所以要用SETNX这类原子操作,只有抢到Token的请求才被允许继续。
String idempotentKey = "idempotent:product:" + token; boolean success = redisTemplate.opsForValue().setIfAbsent(idempotentKey, "1"); if (!success) { throw new BizException("请勿重复提交"); } // 设置到期时间,防止key永久残留 redisTemplate.expire(idempotentKey, 5, TimeUnit.MINUTES);这套方案的好处是不需要额外建数据库表,只要Redis不宕机,防重效果就非常稳定。如果团队没有Redis,退而求其次的方案是在商品编码上做数据库唯一索引,发生冲突时提示用户“该商品已存在”。
3.4 库存与价格的边界处理
库存和价格是商品添加中最容易出低级错误的两个字段,因为它们的规则并不是“填个数字就行”。
价格方面,商城通常涉及多个价格概念:平台价、市场价、会员价、促销价。商品添加时,至少得保证平台价和会员价的关系是正确的。我当时写了针对价格的三条铁律:
- 平台价不能为0或负数
- 市场价不能低于平台价,否则展示时会显得折扣毫无意义
- 会员价不能高于平台价,否则会员反而买贵了
这三条规则听着简单,却是我见过最多的线上数据问题来源。很多运营填价格时全凭手感,后台没有约束,结果前端促销模块的折扣率算出来是负的。
库存方面,规则更加微妙。库存为0的商品允许添加吗?我认为要允许。因为很多商品是预售款,尚未到货时可以允许用户先创建商品,等库存入库后再上架。但库存为负数必须禁止,负库存会直接影响下单逻辑的扣减判断,甚至导致超卖。
为了防止并发把库存扣成负数,我建议商品添加接口落库后,所有库存变更操作都使用条件更新:
UPDATE sku SET stock = stock - #{quantity} WHERE id = #{skuId} AND stock >= #{quantity}这条SQL执行后返回0,说明库存不足,直接回滚。这个方案比先查库存再判断是否大于下单量要靠谱得多,因为后者的“查”和“改”之间存在时间窗口,并发一高就会超卖。
3.5 图片上传与资源处理:用户体验的重灾区
商品添加里用户体验最直接的就是图片上传。用户上传一张商品主图,如果等了三秒还在转圈,他大概率会直接关掉页面。图片又是商品信息里最重的数据,不做处理就裸传到服务器的做法,在当前阶段几乎是不可接受的。
我当时选的是七牛云对象存储加自定义上传凭证的方案。流程是:用户选择图片,前端请求后端获取上传凭证,拿到凭证后直接上传到对象存储,上传完成后把返回的URL回填到表单里。这样做的原因是将上传流量走CDN,不占用应用服务器带宽,用户传大图也不至于把后端拖垮。
图片在传给存储之前,前端先做一个本地压缩,尤其是手机拍摄的图片,动不动就是三五MB,直接上传体验太差。我用的思路是:超过200KB的图片在前端canvas缩到最长边不超过1200px,再转成JPEG提交。这样既能保留足够清晰度,又能大幅减少上传耗时。
后端也要加一道大小和格式的校验,不能完全信任前端的压缩结果。Java侧收到URL后,如果是通过后端转发的方式,还需要再校验一下文件后缀名和Content-Type。至于那些不怀好意的用户上传一张带脚本的图片之类的问题,对象存储本身会处理好访问权限,后端不在响应文件时把它当作HTML渲染即可。
3.6 前端动态表单的实现路径
前端方面,我基于Vue实现了动态表单。核心思路是:后端提供一个类目属性配置接口,返回该类目需要渲染哪些表单控件。前端拿到配置后,循环渲染出对应的输入框、下拉选择框、单选按钮等控件,并把用户填入的值收集到表单对象中。
商品添加页通常会有“基础信息”和“规格信息”两块区域。规格信息是动态表单里最复杂的部分。比如用户选择了“颜色”“尺码”两个规格维度,界面需要让用户分别输入颜色名和尺码名,然后前端自动做笛卡尔积,生成SKU列表。每次用户修改规格值,SKU列表都要重新生成。
这个逻辑听起来不复杂,但细节很多。比如某件T恤只有白色L码和黑色M码两个可售组合,其他组合处于停用状态,前端就要允许用户对自动生成的SKU逐行修改状态、库存和条码。做的时候不要把所有SKU一股脑渲染成密密麻麻的表格,建议分组显示,按规格值折叠,不然用户看到几十行表格就直接劝退了。
3.7 性能细节:添加商品接口的耗时优化
商品添加接口本身的耗时往往不高,但把图片处理、数据校验、落库、日志全部加起来,就可能让用户等待超过两秒。当时我们做了一轮性能排查,发现主要瓶颈有三个:
- 校验类目品牌时,多次调用查询数据库,可合并成一次批量查询
- 保存SKU时循环逐条插入数据库,可改为批量插入
- 商品属性和SKU的关联数据,可以一次组装后批量保存
批量插入是优化效果最明显的。比如一个商品有10个SKU,原来要执行10次INSERT,网络往返就占了大头。用MyBatis的foreach拼成一个批量INSERT,一次网络往返就能完成全部插入。配合Spring事务,整个操作还是原子的。
批量插入的SQL大致如下:
INSERT INTO sku (product_id, sku_code, spec, price, stock) VALUES (1, 'SKU001', '白色-M', 99.00, 100), (1, 'SKU002', '白色-L', 99.00, 100)再补充一个小优化:商品添加成功之后,不要去同步刷Redis缓存。正确做法是直接删除该商品相关的缓存key,等下一次读取时再回源数据库加载。同步刷缓存存在数据和缓存不一致的时间窗口,而且刚添加的商品短时间内被频繁读取的概率并不高,没必要在写入路径上增加负担。
4. 常见问题与排查技巧实录:把踩过的坑都摆出来
4.1 五大高频问题速查表
这一节直接给干活的人看。我在开发商品添加功能的这段时间里,遇到过的问题可以分为下面五类,每一类都给排查思路和解决方案。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 点击保存提示系统繁忙,但数据库里多了一条商品记录 | 接口内部异常导致事务未回滚,或事务方法内吞掉异常 | 查看应用日志中是否有未抛出的catch块,检查事务方法是否有try-catch | 把业务异常直接抛出,配置rollbackFor = Exception.class;必要时加TransactionTemplate |
| 一个商品在后台重复出现多条 | 前端重复提交或接口被重复调用 | 查看操作日志中的UUID,检查请求时间间隔 | 使用幂等Token+RedisSETNX防重 |
| 库存明明填了,但下单时提示库存不足 | 库存误放到SPU表,没有随SKU保存 | 查询SKU表是否存在该商品的库存记录 | 在数据建模阶段明确库存挂在SKU,或做数据迁移 |
| 商品编码生成重复 | 数据库MAX+1方式在并发下产生相同序号 | 查看编码号的连续性和重复时间点 | 改为RedisINCR生成编码,数据库唯一索引兜底 |
| 添加一个大字段的商品时接口响应慢 | 校验查询多次访问数据库,SKU循环插入 | 查看SQL日志,分析执行次数与耗时 | 批量查询、批量插入,必要时加缓存 |
4.2 一次真实的重复提交事故排查
有一次联调阶段,测试环境出现了一个诡异的现象:商品列表里出现了两条几乎一样的手机商品,创建时间只差1秒。查操作日志,确实有两个不同的请求都成功了。
回看代码发现,当时防重的幂等Token逻辑是先查Redis是否存在Token,然后再删除Token。两个并发请求A和B先后到达,A查到了Token,还没来得删除时,B也查到了Token,两个都认为自己是合法的,就双双通过了防重检查。
这恰好印证了前面提到的:防重的“查”和“删”必须是一个原子操作。换成setIfAbsent之后,同一个瞬间只有一个请求能成功插入,问题随即消失。后来我在代码评审中养成了一个习惯,凡是涉及先查后改的逻辑,都要先问一句:如果两个请求同时到了,会不会出事?
4.3 图片上传慢的根因分析
上线之后有运营反馈:上传商品主图时,转圈5秒才显示成功。开始我以为是服务器带宽不行,后来排查发现,本地开发时把图片压缩逻辑放在后端,而生产环境图片直接传到对象存储,按说不该这么慢。
进一步看浏览器开发者工具才发现,用户选择的是手机相册原图,一张图8MB左右,前端直传对象存储,耗时受用户上行带宽影响很大。加了前端canvas压缩之后,图片压到200KB以内,上传耗时从5秒降到600毫秒以内。从那之后我就坚持一个原则:上传类功能的性能优化,优先在前端解决,而不是指望后端扩容。
4.4 规格属性错乱的诡异Bug
还有一个有意思的Bug:运营添加一个多规格商品后,在商品详情页看到的规格值顺序和添加时不一致。颜色“黑色”突然变成了“深黑”,尺码“XL”跑到了“S”后面。
排查后发现问题出在属性值排序。数据库查询时没有指定ORDER BY,MySQL的返回顺序不是插入顺序,而是受索引和存储结构影响。前端拿到无序数据后直接渲染,于是出现了乱序和赋值错乱的感觉。
解决方案很朴素:在规格值表里增设sort_order字段,查询时用ORDER BY sort_order ASC固定顺序。这类问题在单条数据时完全看不出来,只有真实运营反复填写多规格商品时才会暴露。所以做商品属性设计时,凡是涉及展示顺序的字段,都要有显式的排序字段,不能依赖数据库的默认返回顺序。
4.5 前后端字段命名不统一的团队协作坑
最后这个是流程层面的问题,但它带来的返工比技术问题更让人抓狂。当时前端写的是product_name,后端定义的是name,联调时前端一直报字段不存在。两个开发各改了一轮,来回浪费了一整天的开发时间。
后来我们用团队的接口文档平台强制管理:后端提供接口时,同时维护一份字段定义文档,字段命名以文档为准。前端和后端都以文档为唯一事实来源,而不是靠口头沟通。从那以后,因为字段名引发的联调问题基本绝迹。
如果团队里还没有接口文档规范,我强烈建议先建一份字段清单,哪怕用Excel维护都好。商品添加这种涉及几十个字段的接口,一旦命名混乱,排查成本极高。
5. 一点关于扩展的思考:商品添加之后的路
商品添加功能做完之后,紧接着要考虑的是商品编辑、商品审核、商品上架下架、商品分销等等环节。其中商品编辑是最容易被“复用添加逻辑”的念头坑惨的地方——编辑是回填数据再提交,添加是全新创建,两者的幂等语义、状态约束、并发冲突处理完全不同。试图复用一套代码,很可能出现改一个字段把另一个的创建时间覆盖掉之类的问题。
还有一点值得提:商品添加时的数据质量直接决定了后续搜索和推荐的效果。比如商品名称里是否包含关键属性,图片是否白底无文字,类目是否精确到叶子节点,这些“看着不紧急”的细节会在之后对接智能搜索引擎时反馈出来。我见过一个项目因为类目层级混乱,导致搜索“连衣裙”时漏掉了其实归属准确的商品,流量损失非常明显。
“商品添加功能”这个标题看起来毫不起眼,但它身后挂着的是一条长长的链路。作为开发者,不要在写完页面和接口后就觉得任务结束,多往前看一步——你录入的每一个字段,最终都支撑着用户下单时的判断,也支撑着运营做决策时的依据。数据从这里进来,质量就从这里开始被决定。