拓冰建站拓冰建站
首页 / 资讯中心 / 正文

库存上下架模块设计:状态机、并发控制与防超卖实践

干这一行这么多年但凡做个电商类的项目库存上下架永远是被低估的一块。很多人觉得“不就是改个状态字段吗”真等到线上出了事才明白上下架是整个超市线上购物管理系统里牵连最广的业务动作之一。我这次要分享的就是基于Java技术栈实现的一套超市线上购物管理系统中的库存上下架模块从数据模型设计、接口实现、并发控制到压测复盘把完整链路拆开讲清楚。适合正在做毕业设计、准备Java面试或者第一次在产品里接触库存模块的同学参考。1. 为什么库存上下架会成为整个系统的命门1.1 从一次线上事故说起错把“下架”当“删除”之前我给一个商超客户维护系统时遇到过一件挺典型的事。运营人员在后台想把一款临期酸奶下架结果点了删除按键。数据库里的商品记录是真删掉了但历史订单、结算报表、库存流水全乱了用户端购物车里的商品也直接变成了“失效商品”在结算时疯狂报错。当时查了好久才定位到问题根因系统在设计初期把“下架”这个动作和“删除商品”混在了一起。从那之后我对于库存上下架这个模块的态度就变了。它表面上是商品状态的一次变更但实际影响的是搜索列表能不能搜到、商品详情能不能打开、购物车能否正常结算、订单创建时库存是否可扣减、库存盘点时数据是否一致。任何一个环节没考虑到位线上就会冒出一堆诡异的问题。所以这次做超市线上购物管理系统时我专门把库存上下架单独拆成一个模块去设计而不是简单地在商品表里加一个status字段。1.2 库存上下架到底管什么业务边界要划清楚先说一个观点库存上下架不是“商品表状态更新”这么简单它应该理解为“商品在售状态 库存可用性 业务约束”三者联动的一次变更。在我的理解里一个完整的上下架模块至少包含下面几个能力上架商品从“不可售”变为“可售”同时校验商品信息是否完整标题、图片、价格、库存量确保用户端可见。下架商品从“可售”变为“不可售”但保留商品基础信息、历史订单和库存记录用户端不可见、不可加购、不可下单。定时上下架比如生鲜类商品晚上8点后不再售卖第二天早上6点自动恢复上架。这个在超市场景里非常常见。批量上下架运营有时会一次性下架某个分类下的所有临期品或者按照供应商维度批量操作。库存联动下架不等于清空库存库存数量不动但可用状态变化上架后若库存为0也要提示“暂时缺货”而不是直接上架成功。所以我在设计时明确了一个原则商品上下架的操作目标是一份独立的“商品销售状态”而库存数量是另一个维度。只有状态是“可售”且库存0时用户端才展示“加入购物车”按钮。否则一律展示“已下架”或“缺货”。1.3 上下架参与的角色与状态流转超市线上购物管理系统里参与上下架的角色并不只有运营。运营人员在后台商品管理中执行上架、下架、定时上下架操作。系统调度定时任务触发定时上下架例如每日0点把鲜食类商品批量下架。用户端商城读取商品可售状态决定搜索列表、详情页、购物车是否正常展示商品。库存中心上下架状态变更后库存扣减逻辑必须同步感知否则会出现“商品已下架但用户还能付款成功”的事故。这几方参与者的状态流转我用一句话来概括就是商品销售状态变化必须以状态机方式约束不允许跳变不允许随意流转。 例如已删除的商品不允许直接上架定时下架中的商品不允许被手动重复下架审核中状态只能由运营触发变更为上架或驳回。这个状态机的设计是整个上下架模块的地基地基不稳后面所有东西都是空中楼阁。2. 数据模型与状态机先把上下架的地基画清楚2.1 商品、SKU、库存三张表的关系超市线上购物管理系统里的商品建模我建议遵循电商通用做法商品SPU和具体售卖单元SKU分开。SPU可以理解成“伊利纯牛奶250ml24盒”SKU则对应“伊利纯牛奶250ml24盒/箱”这样一个具体可下单的粒度。一张基础商品表维护SPU信息一张SKU表维护具体售卖维度信息比如规格、条码、重量、价格。库存则单独拆一张库存表跟SKU一对一或者多对一关联字段上至少要有总库存、锁定库存、可用库存、预警阈值、所在仓库ID。为什么要单独拆库存表因为库存的变化频率远高于商品基础信息。价格改了、标题改了都是低频操作但每次下单、取消订单、退货入库库存都在变。如果全塞在同一张表里行锁竞争会非常严重。拆开后库存表的热点行可以单独做优化甚至分库分表时也更灵活。2.2 上下架状态字段的设计要点很多人在设计上下架状态时会在商品表里放一个status字段0下架、1上架。这种做法不是不行但遇到超市这种业务场景会出现一些问题。比如商品有“审核中”“定时上架待生效”“人为下架”“系统自动下架”这些状态时一个字段就很难表达清楚。我这次采用的是“状态字段 生效时间字段”的组合方案sale_status当前销售状态取值为OFF_SHELF已下架、ON_SHELF在售、PENDING_REVIEW待审核可选。scheduled_status是否有定时上下架计划如果运营设置了定时任务该字段记录计划类型。scheduled_time定时上下架的计划生效时间。这样一来当用户端查询商品时可以一次性判断当前时间是否在计划生效期内当前状态是否为在售。还要强调一点状态字段尽量用TINYINT或者枚举映射不要用字符串直接存中文。数据库里存0/1/2这类数值Java里用枚举来约束避免脏数据。2.3 上下架变更记录表别等出问题才想起审计这个是我踩过坑才补上的设计。早年的系统里上下架操作只改商品状态不留记录。后来运营说某个商品被“莫名下架”了我们查半天根本不知道是谁在什么时间调用哪个接口改的。所以在这次的系统里我增加了一张product_shelf_log表字段包括id、product_id、sku_id、operator_id、operator_type人工/定时任务/系统、from_status、to_status、operation_type上架/下架/定时上架/定时下架、remark、create_time。这张表的价值在平时看不出来一旦线上出问题它是排查的第一手资料。而且做数据审计、跟运营对齐操作记录时这张表也能直接导出报表。代价只是多一次插入操作对整体性能影响微乎其微但收益非常大。2.4 状态机的流转约束状态机的约束我建议放在两个层面去实现。第一层是数据库约束最简单的做法是给sale_status字段加CHECK约束或者用枚举字段限制取值防止程序异常时写入非法值。不过MySQL 5.7之前对CHECK约束支持不友好很多人就直接在应用层控制。第二层是代码层面用状态机校验工具或者简单的if判断来约束流转合法性。例如我规定合法流转只允许以下路径OFF_SHELF - ON_SHELF手动上架需校验商品信息完整、库存可用ON_SHELF - OFF_SHELF手动下架或定时任务触发下架OFF_SHELF - OFF_SHELF重复下架动作幂等返回成功但记录remark为“重复操作”已删除状态禁止任何流转代码里最好把状态流转定义成枚举Map不要散落在一堆if else里否则后续加状态会非常痛苦。3. 上下架核心接口的实现方案与并发控制3.1 接口定义与参数校验上下架接口我拆成了两个核心接口一个给运营后台用一个给系统定时任务用。运营后台接口定义大致如下POST /admin/product/shelf入参productId、skuId可选、operationON/ON_SCHEDULE/OFF、scheduledTime可选、operatorId返回操作结果、受影响商品ID列表、失败原因定时任务内部接口则直接调用同一个Service层方法只不过operatorType传的是SYSTEM。这个接口看起来简单但参数校验一定要做全。我最开始只校验了productId和operation漏了校验“商品是否存在”“商品是否已删除”“操作人是否有权限”等结果测试时就出现下架一个不存在商品也返回成功的尴尬情况。后来我在Service入口加了一套统一的业务校验逻辑所有入口共用才把这个坑填上。3.2 为什么不能直接UPDATE product SET status ?新手最容易犯的错就是直接写一行SQLupdate product set sale_status 0 where id ?。这个写法单看没问题但一旦并发场景出现就会出事。举个例子运营A点了上架运营B同时点了下架两个请求同时进来。如果都是直接UPDATE最终状态取决于谁后执行而不是谁的操作更合理。更严重的场景是一个定时任务在0点自动下架某商品同时运营刚好在商品页编辑信息并点了保存保存逻辑里顺带把sale_status也带上了就会把下架状态覆盖回上架。所以要解决的是两个问题一是防止并发更新导致状态覆盖二是保证状态流转是按状态机合法推进的。于是我选择了乐观锁方案。3.3 乐观锁方案在上下架中的落地我在商品表里加了一个version字段每次更新时带上期望版本号。核心代码如下Service public class ProductShelfService { Autowired private ProductMapper productMapper; Transactional(rollbackFor Exception.class) public ShelfResult changeShelfStatus(ShelfCommand command) { // 1. 查询当前商品信息 Product product productMapper.selectById(command.getProductId()); if (product null) { throw new BizException(商品不存在); } if (product.getDeleted() 1) { throw new BizException(商品已删除无法操作上下架); } // 2. 状态机校验 Integer currentStatus product.getSaleStatus(); Integer targetStatus command.getOperation().getStatus(); if (!ShelfStateMachine.canTransit(currentStatus, targetStatus, command.getOperatorType())) { throw new BizException(非法的状态流转: currentStatus - targetStatus); } // 3. 乐观锁更新 int rows productMapper.updateSaleStatus( command.getProductId(), targetStatus, currentStatus, product.getVersion()); if (rows 0) { throw new BizException(操作冲突请刷新后重试); } // 4. 写操作日志 productShelfLogMapper.insert(buildLog(product, command, currentStatus, targetStatus)); return ShelfResult.success(); } }对应的Mapper SQLUPDATE product SET sale_status #{targetStatus}, version version 1 WHERE id #{id} AND sale_status #{currentStatus} AND version #{version}这个方案的好处是不需要显式加锁利用行锁冲突时的update影响行数为0来判断冲突代码简单且不会死锁。唯一要注意的是事务里先select再update中间有线程切走的时间窗口但最终因为update的where条件带上了原始状态和版本号并发覆盖问题可以杜绝。3.4 分布式场景下的Redis锁补充乐观锁能解决并发覆盖的问题但解决不了“多个操作叠加导致业务上不合法”的问题。举个例子运营A批量上架100个商品运营B批量下架这100个商品里的一部分。两批操作并发执行时就算单个商品的update都成功最终状态可能是“部分上架部分下架”可运营的真实意图是“先全量上架再调整”这中间需要有一个整体顺序。这种场景下我给上下架接口加了一个Redis分布式锁锁的粒度是“操作类型 商品范围”。比如按店铺维度加锁lock:product:shelf:{shopId}过期时间设3秒防止运营重复点击导致大量重复通知。这个锁只加在管理端接口上用户端只读接口不加锁避免影响读性能。用Redis锁要注意两点一是锁一定要设置过期时间防止进程挂了导致死锁二是释放锁时要判断是不是自己加的锁一般用UUID作为value释放前先比对再删除。4. 库存扣减与防超卖上下架之后真正的考验4.1 下单时的库存扣减链路上下架只是第一步库存真正的大考在下单扣减。超市线上购物系统的特点是单品价格不高但下单频率高生鲜类还有“小时达”这种时效要求所以下单链路不能太重。我的实现链路由四步组成用户提交订单请求先校验商品下是否有SKU处于上架状态。校验通过后尝试扣减库存把SKU可用库存减1同时锁定库存加1。如果库存扣减成功创建订单并把订单状态置为待支付。如果后续支付超时或用户取消再走库存释放流程。扣减库存的SQL是整个防超卖的核心我会在下一节详细讲。4.2 防超卖的三种常见方案对比做Java开发的对超卖场景应该都不陌生。我在设计时对比了三种方案最终选择了适合我们系统规模的方案。第一种是数据库悲观锁直接select * from inventory where sku_id ? for update锁住这一行库存记录然后再判断并扣减。优点是绝对不会超卖缺点是并发高时锁等待严重数据库压力很大。适合并发量不高的场景。第二种是乐观锁类似update inventory set available_stock available_stock - 1 where sku_id ? and available_stock 0。这里不需要加version直接把库存条件放到where里更新影响行数为0就说明库存不足或冲突。这种方案在低并发下很优雅不需要额外加锁。第三种是Redis预扣减先把库存预热到Redis扣减时用Lua脚本原子操作。优点是并发性能最强缺点是需要处理Redis与数据库的最终一致性。我们系统的日订单量峰值在万级以内最终选了乐观锁方案因为代码简单、可靠、不需要引入额外的Redis一致性逻辑。如果你要做的是真正的高并发秒杀系统才需要认真考虑Redis预扣减。核心扣减SQL是这样的UPDATE inventory SET available_stock available_stock - 1, locked_stock locked_stock 1 WHERE sku_id #{skuId} AND available_stock 0如果影响行数为1说明扣减成功否则扔出“库存不足”异常。这个SQL利用了MySQL行锁在available_stock 0条件不满足时不会扣减从机制上杜绝了超卖。4.3 订单取消/售后退回库存怎么恢复很多系统把扣减库存做了但库存恢复经常漏掉。我在做代码走查时发现一旦订单超时未支付状态变更为取消但库存没有回补用户看到的可用库存就越来越少最后出现“明明后台库存很多前台就是显示缺货”的问题。库存恢复的链路我做了两个触发点支付超时自动取消定时任务扫描超过30分钟未支付的订单把状态改为取消同时回补库存。用户主动取消调用取消接口时同步执行库存回补。售后完成用户申请退款售后审核通过后把商品库存加回来。回补库存的SQL要注意跟扣减方向保持对称UPDATE inventory SET available_stock available_stock 1, locked_stock locked_stock - 1 WHERE sku_id #{skuId} AND locked_stock 0为什么一定要加locked_stock 0这个条件因为如果重复回补或者订单数据错乱会导致锁定库存变成负数返回给运营的对账数据就乱了。加上这个条件后重复回补会被挡掉。库存恢复还有一个容易被忽视的点回补时要把SKU的上架状态也检查一遍。如果商品在下架状态下取消订单回补库存后不要自动上架否则会出现下架商品又“复活”的情况。我在代码里把库存回补和上下架状态变更做成了两个独立操作互不干扰由业务层去决定是否联动。4.4 缓存库存与数据库库存的一致性为了减轻数据库压力我确实在商品详情页做了Redis缓存缓存里存了SKU的可售状态和模糊库存区间。但这里有一个重要原则缓存只能用于展示不能作为扣减依据。所有扣减都必须回到数据库执行。那缓存怎么更新呢我用了两个策略主动失效上下架状态变更、库存扣减成功后主动删除对应的Redis key下次查询时回源数据库。延迟双删先删除缓存再更新数据库然后延迟几百毫秒再删除一次缓存防止并发读把旧数据回填进缓存。虽然这套方案做不到绝对强一致但超市线上购物场景下商品状态和库存数量允许有几秒钟的延迟只要不是长时间不一致用户体验基本不受影响。这也是很多电商系统采用的折中方案。5. 我踩过的坑与压测复盘5.1 下架漏了购物车校验导致用户无法结算这个坑是在一次联调时发现的。用户把某商品加入了购物车还没结算运营这边把商品下架了。用户点“去结算”时购物车不显示商品显示的是“商品已失效”。当时我们只是简单地把失效商品从购物车里隐藏掉结果用户结算单就空了用户根本不知道自己买过什么购物车变成空白一片。后来我调整了策略购物车里已失效的商品不能直接隐藏要展示出来但置灰显示“已下架”并给出“移出购物车”按钮同时结算时自动跳过失效商品。如果购物车中所有商品都失效则提示用户“部分商品已下架请重新选择商品”而不是直接让用户去购物车干瞪眼。所以这里要提醒一下下架接口不能光改商品状态还要触发购物车缓存的失效逻辑让购物车服务感知到商品状态变化。5.2 定时上架任务的时间轮问题定时上下架我最初用的是Spring自带的Scheduled每天凌晨用cron表达式跑一次。但问题来了运营要求的是“精确到分钟的定时上下架”比如某商品下午2点30分自动上架。如果用定时轮询扫表每秒钟扫一次全表数据库压力大每5分钟扫一次又不满足业务精度要求。后来我改成了一种轻量方案启动时把所有“待生效的定时上下架任务”加载到内存中的延迟队列每个任务设置了执行时间。到点后由单线程触发执行上下架逻辑。再加上一个兜底逻辑每隔5分钟扫一次数据库把漏执行的任务补上。这样既能按分钟精确执行也不至于把数据库打爆。这个方案看似简单但有个要注意的地方如果系统是多节点部署延迟队列在每个节点都会触发一次同一任务会重复执行。这时候幂等性就显得非常重要。我的做法是执行任务前先尝试获取Redis锁拿到锁的节点才执行其他节点直接跳过。5.3 压测数据与性能优化结论完成开发后我搭了一套测试环境做压测主要针对上下架操作和下单扣库存两个核心链路。测试环境配置不算高4核8G的单节点应用数据库是普通的MySQL 8.0。压测结果显示单线程循环执行上下架接口平均耗时在15ms左右主要耗时在事务提交和日志插入。开启乐观锁更新后并发50个线程同时操作同一个商品上下架接口成功率达到99%以上失败请求基本都走“操作冲突请刷新后重试”的分支没有出现状态错乱。下单扣减库存的接口在并发100个线程、库存只有50件的前提下实际销售数量不多不少正好50件没有一单超卖。加了Redis锁后上下架接口的平均响应时间从15ms涨到20ms左右增加的性能损耗可以接受。从压测结论来看乐观锁方案应对几千上万的并发完全够用。如果你想继续优化可以把上下架日志的插入改成异步方式或者把事务拆小进一步缩短锁的持有时间。5.4 给新手的落地建议最后给正在做类似项目的同学几点建议第一不要急着写代码。先把商品状态机、库存模型、上下架操作的边界条件想清楚画一张状态流转图哪怕手画都行。状态定义不清楚后面写出来的代码大概率是一堆烂if else。第二上下架操作一定要做幂等。运营手滑重复点击、定时任务重试都是很正常的事情接口幂等能帮你省掉很多麻烦。第三日志记录不能省。上下架操作的审计日志非常重要出问题的时候它比任何代码注释都管用。第四如果是在校学生做课程设计或者毕业设计不需要把系统搞得太复杂。Spring Boot MyBatis-Plus MySQL就足够了有精力的话可以加上Redis做缓存。重点是把业务逻辑讲清楚把状态机、并发控制、事务一致性这些点写明白这些才是面试官和老师真正看重的东西。如果你准备去面试Java岗位库存上下架相关的题目真的常出现比如“如何进行库存扣减才能防止超卖”“商品下架后购物车怎么处理”“订单取消如何恢复库存”。把这篇里的思路吃透再结合你自己系统的源码理解一遍比背八股文有用得多。做这种业务系统技术栈不难难的是把业务边界和异常场景想周全。希望这篇复盘能让你在写自己的库存模块时少走几个弯路。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门