做食堂采购系统,真正难的从来不是页面,也不是流程。
而是两个字:库存。
很多团队一开始都觉得库存扣减很简单:
update inventory set quantity = quantity - 10;
上线一周后就开始出问题:
- 库存变负数
- 多人同时领料数据错乱
- 成本算不准
- 对账永远对不上
- 高峰期直接超卖
说句实在话:
只要库存算法没设计好,这套系统就一定跑不久。
食堂场景有个特点:
- 早上集中入库
- 中午集中出库
- 多窗口同时领料
- 并发非常高
这本质就是一个「高并发扣库存」系统。
下面我从实战角度,把一套能商用落地的库存扣减方案完整拆开讲清楚。
技术栈示例:
SpringBoot + MySQL + MyBatis + Redis
一、先搞清楚库存的本质模型
很多人一上来就写扣减逻辑,这是顺序错了。
库存正确模型应该是:
库存表 = 当前结果
流水表 = 真实依据
必须是:
有流水 → 才能变库存
而不是直接改库存。
推荐表结构
1 库存主表 inventory
CREATETABLEinventory(idBIGINTPRIMARYKEYAUTO_INCREMENT,goods_idBIGINTNOTNULL,warehouse_idBIGINTNOTNULL,quantityDECIMAL(10,2)DEFAULT0,amountDECIMAL(12,2)DEFAULT0,versionINTDEFAULT0,UNIQUEKEYuk_goods_wh(goods_id,warehouse_id));关键字段:
- quantity 当前库存
- amount 库存总成本
- version 乐观锁
2 库存流水表 inventory_log
CREATETABLEinventory_log(idBIGINTPRIMARYKEYAUTO_INCREMENT,goods_idBIGINT,warehouse_idBIGINT,typeVARCHAR(20),quantityDECIMAL(10,2),priceDECIMAL(10,2),amountDECIMAL(12,2),created_atDATETIME);所有变化都必须记录。
这是后期对账的唯一依据。
二、最容易踩坑的 3 种错误写法
错误写法一:直接扣减
updateinventorysetquantity=quantity-5;问题:
- 无并发保护
- 多线程同时扣 → 负数
直接淘汰。
错误写法二:先查再扣
Inventoryinv=select();if(inv.getQuantity()>=5){update();}问题:
并发时两个线程都读到 10,都能扣。
结果变 -5。
这叫:读写分离导致超卖
错误写法三:只加事务
很多人以为:
@Transactional 就安全了。
错。
事务只能保证单线程一致,不能解决并发竞争。
三、正确思路:三层并发控制模型
真正可商用方案一定是:
第一层:数据库乐观锁
第二层:条件扣减
第三层:Redis预扣减(高并发场景)
三层叠加,才稳。
四、核心方案一:MySQL乐观锁扣减(基础必备)
这是所有系统的底线方案。
扣减SQL(核心)
UPDATEinventorySETquantity=quantity-#{qty},amount=amount-#{amount},version=version+1WHEREgoods_id=#{goodsId}ANDwarehouse_id=#{warehouseId}ANDquantity>=#{qty}ANDversion=#{version};重点:
- quantity >= qty 防止负数
- version 防止并发覆盖
影响行数 = 1 才成功。
Java实现
@TransactionalpublicvoidstockOut(LonggoodsId,LongwarehouseId,BigDecimalqty){Inventoryinv=inventoryMapper.select(goodsId,warehouseId);if(inv.getQuantity().compareTo(qty)<0){thrownewRuntimeException("库存不足");}BigDecimalavgPrice=inv.getAmount().divide(inv.getQuantity(),2,RoundingMode.HALF_UP);BigDecimalamount=avgPrice.multiply(qty);introws=inventoryMapper.reduceStock(goodsId,warehouseId,qty,amount,inv.getVersion());if(rows==0){thrownewRuntimeException("并发冲突,请重试");}inventoryLogMapper.insert(newInventoryLog(goodsId,warehouseId,"OUT",qty,avgPrice,amount));}优点:
- 实现简单
- 强一致
- 适合中等并发
缺点:
高并发下重试多,性能下降
五、核心方案二:悲观锁(强一致但慢)
如果库存极度敏感,可以用:
SELECT*FROMinventoryWHEREgoods_id=?FORUPDATE;锁行再更新。
问题是:
高并发直接阻塞,吞吐量低。
食堂中午高峰可能直接卡死。
所以:
只建议小并发系统使用。
六、核心方案三:Redis + MySQL 双层扣减(高并发推荐)
当:
- 多窗口同时领料
- 上百人同时出库
单靠数据库扛不住。
必须引入 Redis。
思路是:
先扣 Redis
再异步写 MySQL
Redis Lua脚本(原子扣减)
localstock=tonumber(redis.call('get',KEYS[1]))ifstock<=0thenreturn-1endredis.call('decrby',KEYS[1],ARGV[1])return1保证:
- 原子性
- 无超卖
Java调用
Longresult=redisTemplate.execute(luaScript,Collections.singletonList("stock:"+goodsId),qty.toString());if(result==-1){thrownewRuntimeException("库存不足");}异步落库
使用 MQ:
mqProducer.send(newStockMessage(goodsId,qty));消费者再更新数据库。
优点:
- 极高并发
- 抗压能力强
缺点:
- 最终一致性
- 实现复杂
适合:
多食堂 + 集团化 + 上千并发场景。
七、成本算法实现(食堂最佳实践)
食堂不需要复杂批次。
推荐:
加权平均法
公式:
平均价=amount/quantity实现:
BigDecimalavgPrice=inv.getAmount().divide(inv.getQuantity(),2,RoundingMode.HALF_UP);简单、稳定、易对账。
八、实战选型建议
给你一句很现实的选型建议:
小学校
直接 MySQL 乐观锁
中型学校
乐观锁 + 索引优化
集团/多校区
Redis + MQ + MySQL
别一上来就搞复杂架构。
技术是为业务服务,不是炫技。
九、最后的经验总结
做食堂采购系统源码,这三条是底线原则:
第一
库存只改一张表,必须带锁
第二
所有变更必须写流水
第三
绝不允许负库存
只要守住这三条,系统稳定性至少提升一个量级。
库存做好了,这套系统才算真正可商用。