ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

电商后台商品添加实战:从SPU/SKU建模到幂等防重

电商后台商品添加实战:从SPU/SKU建模到幂等防重 大概在半年前我接了一个电商后台系统的重构需求。需求清单里躺着一条最不起眼但后来让我连续加了三个通宵的条目商品添加功能。当时觉得这不就是一张表单、一个提交按钮、一张数据库表的事吗等真正把“商品添加”四个字拆开揉碎才发现这是整个电商系统里最考验设计功底的地方——光是一个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 ListSkuAddDTO 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检查请求时间间隔使用幂等TokenRedisSETNX防重库存明明填了但下单时提示库存不足库存误放到SPU表没有随SKU保存查询SKU表是否存在该商品的库存记录在数据建模阶段明确库存挂在SKU或做数据迁移商品编码生成重复数据库MAX1方式在并发下产生相同序号查看编码号的连续性和重复时间点改为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 BYMySQL的返回顺序不是插入顺序而是受索引和存储结构影响。前端拿到无序数据后直接渲染于是出现了乱序和赋值错乱的感觉。解决方案很朴素在规格值表里增设sort_order字段查询时用ORDER BY sort_order ASC固定顺序。这类问题在单条数据时完全看不出来只有真实运营反复填写多规格商品时才会暴露。所以做商品属性设计时凡是涉及展示顺序的字段都要有显式的排序字段不能依赖数据库的默认返回顺序。4.5 前后端字段命名不统一的团队协作坑最后这个是流程层面的问题但它带来的返工比技术问题更让人抓狂。当时前端写的是product_name后端定义的是name联调时前端一直报字段不存在。两个开发各改了一轮来回浪费了一整天的开发时间。后来我们用团队的接口文档平台强制管理后端提供接口时同时维护一份字段定义文档字段命名以文档为准。前端和后端都以文档为唯一事实来源而不是靠口头沟通。从那以后因为字段名引发的联调问题基本绝迹。如果团队里还没有接口文档规范我强烈建议先建一份字段清单哪怕用Excel维护都好。商品添加这种涉及几十个字段的接口一旦命名混乱排查成本极高。5. 一点关于扩展的思考商品添加之后的路商品添加功能做完之后紧接着要考虑的是商品编辑、商品审核、商品上架下架、商品分销等等环节。其中商品编辑是最容易被“复用添加逻辑”的念头坑惨的地方——编辑是回填数据再提交添加是全新创建两者的幂等语义、状态约束、并发冲突处理完全不同。试图复用一套代码很可能出现改一个字段把另一个的创建时间覆盖掉之类的问题。还有一点值得提商品添加时的数据质量直接决定了后续搜索和推荐的效果。比如商品名称里是否包含关键属性图片是否白底无文字类目是否精确到叶子节点这些“看着不紧急”的细节会在之后对接智能搜索引擎时反馈出来。我见过一个项目因为类目层级混乱导致搜索“连衣裙”时漏掉了其实归属准确的商品流量损失非常明显。“商品添加功能”这个标题看起来毫不起眼但它身后挂着的是一条长长的链路。作为开发者不要在写完页面和接口后就觉得任务结束多往前看一步——你录入的每一个字段最终都支撑着用户下单时的判断也支撑着运营做决策时的依据。数据从这里进来质量就从这里开始被决定。
返回列表