这个标题看着很"课程设计",但实际做起来涉及的东西比大部分人想的复杂得多。我一直觉得零售和仓储是两个必须联动、不能割裂的业务域,谁把这两个模块分开设计,后期库存账目一定对不上。借着这篇博文,我把当时基于Spring Boot分布式开发这套系统的完整思路、表结构设计、核心流程实现,以及在联调、并发、报表上踩过的坑都梳理一遍,给准备做进销存、仓储系统的朋友一个参考。
1. 项目缘起与业务痛点:为什么必须把零售和仓储放进同一套系统
1.1 从Excel台账到系统重构,到底卡在了哪里
事情起因是帮朋友改造一家连锁零售企业的后台管理。单店日均几百单,SKU大概两千多个,之前用Excel台账配合进销存软件,结果盘点和财务对账永远对不上。采购凭感觉进货,畅销品缺货、滞销品压在库房,月底盘点效率极低。核心问题有三类:销售出库与库存扣减脱节、采购入库数据滞后、缺乏统一的库存视图。
所以当决定自研一套管理系统时,第一条原则就是:零售订单和仓储出入库必须在一个事务链路里闭环,不能先记订单再异步扣库存。这听起来像是理所当然,但很多团队的坏味道恰恰来自把订单系统和仓储系统拆成两个服务,导致数据最终一致性迟迟做不出来。
1.2 系统边界与我最终划定的六大模块
我最终把系统划分为用户与权限、商品与分类、仓储管理、零售收银与订单、采购与供应商、统计报表六个核心模块。实际开发中遇到最多的需求围绕仓储和零售两块,例如门店收银实时扣减可分库存、创建采购入库单后自动更新可用库存、盘点差异生成盈亏调整单。
系统的使用角色包括门店收银员、门店店长、仓库管理员、采购专员和系统管理员,不同角色只开放对应菜单和数据范围。做权限控制时我参考了RBAC模型,但只在后端基于Spring Security拦截URL和接口权限,前端菜单不做按钮级控制。原因很直接——门店收银员用的触屏终端,谈不上复杂权限场景,过度设计反而拖慢开发进度。
2. 技术选型思路与核心依据:为什么是Spring Boot + Java
2.1 框架对比:Spring Boot不是唯一解,但是最稳的解
选型时确实比较过几个方案。Python的Django开发快,但团队更熟悉Java;Node.js写接口效率高,但事务处理能力弱;若用Spring Cloud微服务又太过。最终我选择Spring Boot 2.7 + Java 8这套组合,理由很简单:Spring Boot的自动装配机制让项目搭建成本极低,哪怕只用一个main方法也能快速起服务;Java 8的Stream和Lambda在处理订单明细汇总、库存流水统计时非常顺手;且生态里几乎能找到所有仓储场景需要的中间件客户端。
持久层方面选了MyBatis-Plus而不是JPA。JPA对复杂查询的支持比较弱,而仓储报表经常要写多表联查、分组聚合的长SQL。MyBatis-Plus既保留了手写SQL的灵活性,又提供了单表CRUD的BaseMapper,开发效率和查询可控性达到不错平衡。
2.2 选型清单与各个组件的误用风险
具体的核心组件选型如下表,里面附带了容易踩坑的备注。
| 组件 | 实际选型 | 核心用途 | 需要注意的问题 |
|---|---|---|---|
| 开发框架 | Spring Boot 2.7 | 提供依赖注入、事务管理、自动配置 | 版本差异较大,建议锁定2.7.x系列 |
| ORM框架 | MyBatis-Plus 3.5.x | 单表CRUD与自定义SQL | 分页插件需要显式配置拦截器 |
| 数据库 | MySQL 8.0 | 核心业务数据存储 | 事务隔离级别默认即可,但库存表必须用行锁 |
| 缓存 | Redis | 验证码、Token、限流、热点商品缓存 | 缓存和数据库一致性是核心难题 |
| 消息队列 | RabbitMQ(可选) | 预留库存变更消息推送 | 非必需,后期拆服务时才建议引入 |
| 前端 | Vue 2 + Element UI | 管理后台与收银台 | 前后端分离后注意跨域配置 |
| Excel导入导出 | EasyExcel | 商品批量导入、库存报表导出 | 大数据量导出建议分页查询避免OOM |
2.3 前后端分离还是服务端渲染
起初想用Thymeleaf做服务端渲染,因为部署简单,一个Jar包搞定。但考虑到收银台界面需要频繁刷新局部库存、管理后台需要异步加载大量表格数据,最终选择了前后端分离。前端是Vue 2 + Element UI,后端只提供RESTful接口。这也更方便后期可能的APP端接入,接口复用而不需要重写后端。
3. 数据模型与模块拆分的设计要点:核心表和关系
3.1 库存表的核心设计:必须按"仓库+门店+SKU"拆分字段
库存表是这套系统里最关键的物理表,我设计了sku_id、warehouse_id、store_id、available_stock(可用库存)、frozen_stock(冻结库存)、total_stock(总库存)、safety_stock(安全库存)这几个核心字段。
为什么要拆成可用库存和冻结库存?因为零售订单创建后不能马上扣减,用户可能取消订单;仓库出库单创建后可能需要等待拣货。此时我已预占库存但还没真正移出,放在available里会出现超卖,放进frozen_stock再配合定时清理任务,就能精准表达"已被订单预占但未发货"的状态。所有库存变化必须同时写入库存流水表,流水表记录变更前值、变更后值、业务单号、业务类型,这样才能追账。
3.2 订单、出入库单、盘点单的主表与明细表如何拆
主表与明细表拆分遵循业界标准。销售订单表和订单明细表、采购入库单和入库明细表、盘点单和盘点明细表都是1:N结构。每一层单据都带状态字段,例如销售订单状态分为待支付、支付成功、待出库、已出库、已取消、已退款。
在单据编号生成上我吃过亏:直接用数据库自增ID当单号会暴露业务量,后期打印小票和客户沟通也不方便。最终用日期前缀加Redis自增序列生成唯一业务单号,例如SO202506100001,既方便人眼识别,也避免了数据库主键自增在大批量导入时的性能问题。
3.3 需要特别加索引的SQL场景,以及一个最容易被忽略的唯一约束
库存表必须建立(sku_id, store_id, warehouse_id)的唯一联合索引,否则同一条库里可能出现多条记录,后续更新就会互相覆盖。订单明细表需要建立(order_id)索引,出入库明细表需要建立(parent_id)索引,否则后台详情页查询慢到难以忍受。操作日志流水表按(create_time)建立范围索引,因为报表查询大多按日期筛选。
一个最容易被忽略的约束是:销售订单号一定要建唯一索引。生产环境发生过业务重试导致同一笔订单插入两次,万幸有唯一索引挡住了,否则对账直接崩溃。类似地,盘点单号和入库单号也建议加唯一索引,天然具备防重语义。
4. 零售端核心流程实现:开单、收银、库存联动的具体做法
4.1 下单到底什么时候扣库存,我选了支付回调之后扣减
这是被问过最多的问题。零售系统里有两种策略:下单即扣减库存,还是支付成功后再扣减。我的选择是:生成订单时只校验库存充足且预占库存,支付成功后再真正扣除。原因是线下门店场景里,顾客经常加购后不付款,如果下单即扣减,货架明明有货但库存已被未支付订单占满,会导致大量拒售。而移动支付回调通常有异步延迟,采用RabbitMQ通知支付结果,再由库存服务消费并扣减,兼顾了体验和一致性。
实现时订单服务调用库存服务需要处理分布式事务问题,但早期单体应用内部直接用@Transactional注解即可。库存扣减的SQL必须使用乐观锁条件更新:UPDATE sku_stock SET available_stock = available_stock - #{count}, version = version + 1 WHERE sku_id = #{skuId} AND available_stock >= #{count},如果更新影响行数为0,说明库存不足或并发冲突,立即回滚订单。
4.2 挂单、整单退款与部分退款的三段式处理
收银台必支持挂单。实现方案就是给订单增加一个挂单状态,挂单操作把订单状态改为pending,30分钟内可以取回订单继续结算。这个功能看似简单,但要注意挂单期间其他操作不能干扰该订单的预占库存,所以挂单不释放冻结库存。
退款设计分成整单退款和部分退款。整单退款直接冲销订单金额、回补库存,状态改为已退款。部分退款需要拆分订单明细,将退货数量回补库存并计算退款金额。这个流程里我加了幂等校验,防止重复退款:退款流水表对(原订单号, 退款批次号)建唯一索引。支付回调可能重复通知,业务接口需要先查退款流水是否存在。
4.3 线下门店特有的抹零和会员积分玩法
零售门店和纯电商不同,常有抹零需求。我在订单表里单独记录discount_amount字段,抹零操作只修改优惠金额,不改变商品明细和成本核算。会员积分在支付成功后按实付金额发放,积分的消耗和退款回退都记录在积分流水表里。
这里有个细节容易忽略:抹零金额和积分逻辑必须走独立接口,不该塞进商品明细的单价修改里。否则订单明细里的销售单价和实际收款金额对不上账,财务月底查账会非常痛苦。
5. 仓储端核心流程实现:出入库、盘点、库存预警的落地细节
5.1 采购入库的完整业务流程
采购入库流程是:采购专员创建采购单,仓库管理员收货验货后创建入库单并关联采购单,系统选择目标仓库和库位,填写实际收货数量。如果实际数量与采购数量不一致,系统标记为差异入库并强制填写差异原因。
入库单审核通过后,库存表执行总库存和可用库存同时增加,同步写入库存流水。这里不能用简单的insert或update覆盖,否则会丢失历史记录。我在库存流水表里保存了业务类型和业务单号,后期做商品批次溯源直接根据SKU和时间范围查流水即可。
5.2 销售出库与门店配货:从订单池到拣货任务
门店订单产生后,当订单状态转为待出库,系统生成一张出库任务单。出库单可以按门店聚合多个订单,仓库管理员持PDA按单拣货。之所以不做逐单出库,是因为仓储作业场景里批量拣货效率远高于单件拣货,且能减少仓库内部移动路径。
拣货完成后,出库单状态变为已完成,库存扣减逻辑与零售支付扣减保持一致。如果商品从仓库调到门店在架销售,需要创建一个调拨单,仓库出库时扣减仓库库存,门店收货后增加门店库存。这个跨仓流转如果不处理,会导致门店明明收到货但可用库存没有增加,收银台看到的库存永远是0。
5.3 盘点差异与盈亏调整:不能直接改库存数字
盘点绝对是仓储模块里最需要细心的环节。盘点单生成时会把当前系统库存作为账面数快照;盘点人员录入实盘数;系统自动计算差异数并生成盘盈盘亏明细。
盘亏商品需要审核通过后才允许调整库存,调整时系统记录盈亏调整单,并关联会计凭证所需的库存变动金额。我在这个环节特意拦截了一件事:不提供直接修改库存的接口。所有库存调整必须通过单据完成,这是为了确保库存流水可追溯。任何人想绕过单据直接改库存表,连API入口都不存在,从机制上杜绝了脏数据。
6. 落地过程中踩过的坑与性能优化:并发扣减、慢查询与内存溢出
6.1 并发扣减导致的库存超卖问题:从解决思路到最终方案
联调阶段出现过一次严重问题:同一门店的两台收银机同时卖出最后一瓶饮料,系统却都返回下单成功。原因很简单:库存扣减SQL没有加库存充足判断,用的是先查询再更新的非原子操作。两个请求同时查询库存为1,都认为库存足够,各自执行了扣减。
修复方案分两层。第一层是数据库层面的乐观锁和条件更新,扣减SQL必须带上available_stock >= #{count}条件。第二层是Redis预扣减:先用Lua脚本原子扣减Redis中的库存Key,失败直接返回库存不足;Redis扣减成功后再执行MySQL扣减并异步发送MQ消息,若MQ长时间未确认或数据库扣减失败,定时任务回补Redis。这个两点式方案彻底解决了超卖,也降低了数据库行锁的竞争压力。
Redis与MySQL的一致性确实有风险,比如Redis扣减成功但数据库扣减失败导致Redis数据偏少。我的兜底办法是每天晚上跑一个对账任务,以MySQL库存流水为准重算Redis库存。线上发现偏差次数很少,但兜底逻辑必须存在。
6.2 报表统计在百万级数据下的慢查询优化
统计报表模块上线一个月后出现页面加载超过10秒的问题。原始SQL用多张明细表直接full join再聚合,数据量达到几十万行之后性能急剧下降。
优化思路是引入中间汇总表。每天凌晨跑定时任务,将订单明细、出入库明细汇总到日维度的销售汇总表和库存变动汇总表,报表查询只查汇总表。对于临时导出的多维分析,禁用实时全量查询,改用分页导出的方式。我还给商品表加了一个冗余字段category_name,避免每次报表都去关联分类表。这对大数据量查询的提升非常明显,查询时间从秒级降到毫秒级。
6.3 导出大Excel导致的内存溢出与事务失效问题
导出Excel踩过一次直接OOM的坑。最初用一次性查出全量数据再写入Workbook,数据量超过五万条时内存直接爆掉。后来改用EasyExcel的流式写文件,并分页查询数据库,每页两千条写一次,配合写入临时文件再下载。整个过程内存占用恒定,哪怕导出二十万条记录也稳定。
另一个经典问题是Spring事务失效。我在一个类内部写了两个方法,A方法没有加事务注解,B方法加了事务注解,A内部调用this.B(),结果B的事务完全没生效。Spring的@Transactional是基于代理实现的,内部方法自调用会绕过代理对象。后来我把事务方法提取到另一个Service类中,或者通过注入自身代理调用,才解决。
还有一次是死锁问题:入库操作和出库操作同时更新同一条库存记录,且更新顺序不一致。死锁日志显示两个事务都在等待对方持有行锁。规范是同一张表的操作必须按固定顺序更新,例如先更新库存主表再更新明细表、先更新可用库存再更新冻结库存,极大降低了死锁概率。
7. 部署经验与项目复盘:一套系统从开发到上线的完整落地心得
7.1 环境拆分与配置管理
我按dev、test、prod三套环境维护配置,配置文件用application-{profile}.yml区分,数据库连接、Redis地址都通过环境变量注入。这一块最大的教训是生产环境的数据库连接字符串绝对不要出现在代码仓库里,否则信息泄露风险极高。我用了在服务器上配置环境变量读取的方式,代码库里只留占位符。
部署架构上,初期单体应用一台服务器即可。Nginx负责前端静态资源、反向代理和接口负载均衡。虽然现阶段是单体,但如果未来要拆分服务,Nginx的upstream配置已经预留了多节点扩展的空间。
7.2 备份与监控:每天都在做的三件事
系统上线后我固化了几条运维习惯:每天凌晨全量备份MySQL,使用mysqldump加定时任务,保留最近七天备份;Redis开启AOF持久化并设置自动rewrite策略,防止缓存重启后雪崩;核心业务接口接入简单的耗时日志,超过两秒打印警告日志。
监控方面没有引入特别重的组件,先用了Spring Boot自带的Actuator暴露health和metrics端点,配合定时脚本探测接口可用性。当订单量增大后再考虑引入更完整的监控组件也不迟——项目的演进是一个渐进过程,不是一上来什么都上。
7.3 如果重新做一遍,我会在哪几个地方做调整
如果让我重来一次,我会把支付回调的异步链路在一开始就用MQ设计好,而不是联调后期才补救。很多看似简单的流程,到了并发场景下都会有完全不同的实现难度。最初用同步Feign调用扣减库存,高并发下线程池直接被占满,接口耗时直线上升。改成MQ之后,削峰填谷效果立竿见影。
还有库存初始化的时候一定要做一次全面的数据治理。历史货品名称、单位、分类信息参差不齐,不清理干净直接导入系统,报表出来根本没法看。清数据这件事没有捷径,就是Excel模板加人工核对,再加上上线后第一周每天核对系统库存与实际库存的差异,直到连续一周零差异才算稳定。
8. 给即将做同类系统的人几条实在建议
第一点,表结构设计阶段多花三天画关系图,后面开发能省三周。尤其是库存流水表的设计,直接决定了账能否追寻,这类系统最怕的就是时间一长数据根本解释不清。
第二点,并发控制永远优先考虑数据库层面的原子性操作,缓存只是加速手段。Redis用得好能提升体验,用不好反而会引入更多一致性麻烦。对业务体量不大的系统,单靠数据库乐观锁已经能解决绝大多数问题。
第三点,不要过度设计。很多人上来就规划微服务、分布式事务,其实单体应用加定时任务、MQ,已经能覆盖百分之八九十的零售仓库业务形态。等到业务量真的到了需要拆分开的程度,再按模块边界拆分也不迟。
我在做这套系统时最大的体会是,业务系统开发难的不是技术,而是理解业务。零售和仓储管理系统的核心是库存账实相符和单据流水的闭环,把这个目标理解透了,技术路线自然就清晰了。希望这篇文章的经验能帮你少走一些弯路。