☰
基于Spring Boot的粮食供应链管理系统设计与实践
2026/10/15 6:51:53 网站建设 项目流程

接手这个项目的时候,客户那边的业务负责人给我看了一组数据:仓储账实不符率百分之三点几,每年粮食出入库单子有上万张,月底对账靠财务拿着 Excel 跟仓库、采购、销售挨个核对。听完我就知道,这个“建金粮食供应链管理系统”不能只做一个进销存,它得把粮食从采购、质检、入库、仓储、销售到结算的整条链路管起来,让每一笔粮食的去向都能追得回来。

整个系统基于 Spring Boot 来做,前后端分离,后端负责业务规则和数据处理,前端提供操作界面。说实话,这个量级的系统用 Spring Boot 单体架构完全够用,微服务反而是给自己找麻烦。今天这篇就把整个项目的设计思路、技术取舍、核心模块实现,以及我踩过的那些坑,完整复盘一遍。如果你正准备做类似的供应链管理系统,或者是在做 Spring Boot 相关的设计课题,这篇文章里的东西可以直接拿去做参考。

1. 项目概述:粮食供应链系统的定位与边界

1.1 粮食业务和普通进销存差在哪

很多人一听供应链管理系统,脑子里浮现的就是“库存加加减减、订单查查看看”。但如果放到粮食行业,事情就没那么简单了。粮食这个东西,它有品种、年份、等级、水分、杂质这些属性,同样一批玉米,不同水分的价格能差出好几个档次。刚收进来的潮粮和晾晒后的干粮,储存条件不一样,保管方式也不一样,这些都会影响库存管理中“批次”的划分。

再一个是计量方式。粮食入库要过磅,毛重减去皮重才是净重,但这个逻辑得跟质检结果联动。水分超标要扣量,杂质超标要扣价,计算完之后才是实际入库数量和金额。这些规则不是写死在代码里的死公式,而是要支持配置和调整,因为粮食收购标准每年都可能变。

还有一个行业特点是季季集中。秋粮上市那几个月,每天的采购入库单据量是平时的十几倍,系统在高峰期的并发写入能力很重要。普通进销存系统那种“一天几十单”的设计压力,在粮食收购旺季根本扛不住。

1.2 系统的四大角色与业务闭环

这个系统围绕粮食的“购、存、销、结”四个环节展开,我把核心用户拆成四个角色来设计。

采购员负责建立收购计划、登记供应商(农户或粮食经纪人)信息,发起采购入库申请。质检员对到货的粮食进行抽样检验,录入水分、杂质、容重、不完善粒等指标,给出质量等级和扣量扣价建议。仓储员根据质检结果安排入库库位,登记过磅数据,维护库存台账,定期做仓位盘点。销售员根据销售订单安排出库,财务人员则通过系统自动生成的结算单做往来款项管理。整个链路从采购到结算形成一个闭环,任何一个环节的数据异常,都可以顺着批次追踪下去。

系统管理方面,我采用 RBAC 模型(基于角色的访问控制),不同的角色看到不同的菜单和按钮。比如质检员只能看到质检任务和检验记录,看不到财务金额数据,采购员也只能操作自己权限范围内的供应商和采购单据。这在国内企业管理软件里算是标配,但在实际业务中却是最容易出问题的点。

1.3 系统模块划分与整体架构

系统整体划分为七个核心模块:

  • 基础数据管理:粮食品种、仓库库位、供应商、客户、计量单位、质检标准
  • 采购入库管理:收购计划、到货登记、质检记录、过磅数据、入库单生成
  • 库存管理:库存实时台账、批次管理、库位调整、盘点、库存预警
  • 销售出库管理:销售订单、出库计划、出库单、发货记录
  • 财务结算管理:入库结算、出库结算、往来账户、付款登记
  • 统计分析:收发存汇总表、质量分析、价格走势、客户/供应商对账单
  • 系统管理:用户管理、角色权限、操作日志、数据字典

从技术架构上看,后端是标准的 Spring Boot 单体服务。我用的是 Spring Boot 2.7.x 版本,搭配 MyBatis-Plus 做持久层,MySQL 8.0 存储业务数据,Redis 用于缓存热点数据和分布式锁,Spring Security + JWT 处理认证授权。前端用的是 Vue 3 + Element Plus,通过 HTTP 接口与后端交互。

注意:单体架构不是技术落后,而是对这个项目场景最优的选择。系统用户量在几十人以内,业务复杂度属于中等偏上,但并发要求并不高(除收购旺季外)。单体应用部署简单、排查方便、事务控制容易,这是微服务架构在这个场景下无法比拟的优势。

2. 技术选型背后的取舍与思考

2.1 Spring Boot 版本选型:为什么用 2.7 而不是 3.x

当时项目启动是在 Spring Boot 3.x 刚发布不久,按道理新项目应该尝试新版本。但我最终选了 2.7.x,基于三个实际考虑。

第一是生态兼容性问题。当时很多第三方库对 Jakarta EE 9+ 的包名改动还没有完成适配,如果用 Spring Boot 3.x,可能某些功能库要自己改源码或者降级处理,这不符合项目交付周期要求。

第二是 JDK 版本。Spring Boot 3.x 要求 JDK 17 起步,而很多企业内部系统当时还跑在 JDK 8 上。运维团队对 JDK 8 的调优经验最丰富,直接用 JDK 8 可以省掉很多环境问题。

第三是稳定性。2.7.x 是 2.x 系列的最后一个主要版本,社区反馈充分,坑基本都被踩平了。作为一个业务系统,稳定压倒一切,技术的新鲜感反而是次要的。

如果你是自己做个人项目或者学习演示,用 3.x 完全没问题;但如果是给企业交付,选型时一定要把“维护成本”这四个字放在最前面。

2.2 持久层框架:MyBatis-Plus 的取舍

持久层我选了 MyBatis-Plus 而不是 Spring Data JPA,这里面的考量值得展开讲讲。

MyBatis-Plus 最大的价值在于既保留了 MyBatis 手写 SQL 的灵活性,又提供了单表 CRUD 的现成方法。比如BaseMapper里内置的selectById、insert、updateById、selectPage这些方法,简单操作根本不用写 SQL,开发效率非常高。而碰到复杂的多表联查、报表统计、批量更新,直接写 XML 里的 SQL 语句,优化空间完全掌控在自己手里。

相比之下 JPA 在实体关系映射上虽然省事,但一旦遇到多表关联、动态查询、批量更新性能问题,调优难度就上来了。粮食行业的特点是单据字段多、状态变化频繁、报表查询复杂,这种场景下 MyBatis-Plus 显然更合适。

还有一个重要因素是国产化友好度。MyBatis-Plus 本身就是国内团队维护的开源项目,中文文档齐全,社区问答丰富,出了问题很容易找到解决方案。在项目交付时,这一点对团队来说是实打实的效率提升。

2.3 认证方案:Spring Security + JWT

权限认证这块,我用了 Spring Security + JWT 组合,这应该是当前企业级应用最常见的方案之一。

JWT 的核心思路是服务端不保存会话状态,用户登录成功后颁发一个签名令牌,后续请求带上令牌,服务端验签后即确认身份。这种方案天然支持前后端分离架构,因为令牌可以存在前端本地存储里,每次请求通过 HTTP 头传递给后端,不依赖 Session 和 Cookie。

但 JWT 也有它的问题。最典型的是令牌无法主动失效,用户退出登录后,只要令牌没过期,它仍然能用。我的处理方式是结合 Redis 做一个简单的 Token 管理:登录时把 Token 存入 Redis 并设置过期时间,每次请求检查 Redis 中是否存在该 Token,退出时直接删掉。这样既保留了 JWT 的无状态特性,又解决了主动失效的问题。

安全配置上,我对接口做了白名单放行,比如登录接口、验证码接口、文件下载接口,其余接口一律要求认证。权限控制通过自定义注解@RequiresPermission加在 Controller 方法上,用拦截器校验当前用户的角色和权限码。这个方案比在 Security 配置里写antMatchers的方式更灵活,权限变更不需要改代码和重启服务。

2.4 辅助组件:接口文档与代码生成

接口文档我集成了 Knife4j(基于 Swagger 的增强方案)。这个组件对前后端联调帮助非常大,前端同事不用等后端写完一个接口就问一次参数格式,直接打开文档页面就能看到所有接口的定义、请求参数、响应结构,联调效率提升明显。

代码生成工具我也用上了。MyBatis-Plus 官方提供了代码生成器,可以根据数据库表结构自动生成实体类、Mapper 接口、Service 层代码。对于像系统管理模块这种机械化的 CRUD 代码,能用工具生成就不要手写,省下来的时间投入到业务模块的定制开发上。我在项目初始化阶段只用了一天时间,就把整个基础框架和十几个基础表的增删改查代码全部生成完毕。

3. 核心模块的设计思路与数据库建模

3.1 数据库设计的核心:库存、批次与流水

整个系统数据库最核心的表有三张:库存表、库存流水表、批次表。这三张表的设计直接决定了系统能不能实现粮食去向的可追溯。

批次表记录每一批粮食的来源、品种、年份、等级、入库时间和对应的采购单据。库存表记录当前各仓库各批次粮食的实时数量。我知道有一种设计习惯是把批次信息全部塞进库存表,但那样做的问题在于,一旦需要拆分批次或者合并批次,库存表的数据就乱套了。所以我让批次独立成表,库存表只保存“仓库 + 批次 + 当前数量 + 锁定数量”这几个关键字段。库存流水表则记录每一次库存变动的明细,包括变动前数量、变动后数量、关联单据号和业务类型。流水表只追加、不修改、不删除,这是审计追溯的基础。

下面是我设计库存相关四张核心表的最终结构:

表名核心字段说明
stock_batchid, batch_no, grain_id, grade_level, source_order_id, inbound_time, operator_id按批次管理粮食来源
stock_inventoryid, warehouse_id, batch_id, quantity, locked_quantity, version库存实时数据,带乐观锁版本号
stock_flow_recordid, inventory_id, batch_id, flow_type, change_quantity, before_quantity, after_quantity, ref_bill_no, create_time流水审计记录,只增不改
stock_warningid, warehouse_id, grain_id, warning_type, threshold_value, current_value, status, create_time库存预警记录

在设计stock_inventory时,我对唯一性约束格外上心。同一仓库同一批次只能有一条库存记录,所以加了(warehouse_id, batch_id)唯一索引。为了避免并发插入时产生重复记录,入库更新库存采用“先查询再插入或更新,通过唯一索引兜底”的方式,遇到主键冲突时转为更新操作。

3.2 库存流水驱动设计:为什么所有变更都走一张表

库存流水驱动设计是我在这个项目里做得最正确的一个决定。所有库存变动,包括入库、出库、移库、盘点调整、锁库、解锁,都统一写入stock_flow_record表。这样做的好处是,对账的时候只需要盯着一张表,就能还原任意时刻的库存状态。

有朋友会问,为什么不把流水分散到采购入库单、销售出库单各自的明细表里,需要时再通过关联查询汇总。理论上也能实现,但实操中会出现一个严重问题:如果某个业务操作跨多张单据,每个单据的明细保存成功不代表整条业务链路的库存数据一致。比如出库单登记了,但库存表扣减失败,两者之间没有事务保护的话,账实就会立刻不符。把所有库存变更统一收敛到流水表,每笔变更都使用事务控制,出库单和流水记录要么同时成功,要么同时失败,状态一致性就好控制了。

3.3 业务单据表设计:状态机驱动数据流转

采购、销售、出入库这些业务单据,我统一采用“状态机”设计思路。每张单据表都带一个status字段,单据在不同阶段处于不同状态,特定状态下才允许执行特定操作。

以采购入库单为例,状态流转是这样的:

  • 未提交:采购员暂存,可编辑
  • 已提交:等待质检,不可删改
  • 质检中:质检员已取样,等待检验结果
  • 已质检:检验完成,等待过磅计量
  • 已入库:库存已增加,单据完成,可打印归档
  • 已取消:保存前作废,不产生业务数据

每个状态变更都记录操作时间和操作人,并且在代码层面对非法状态流转做了拦截。比如已经“已入库”的单据,系统不允许再走“取消”操作,必须走“红冲”流程生成负数单据来冲抵。这跟财务上的红字冲销逻辑类似,保证留痕不删痕。

3.4 限流设计与库存预警规则

在并发控制方面,我做了两层防护。第一层是数据库层面的行级锁,当多个请求同时扣减同一批次库存时,通过SELECT ... FOR UPDATE锁定库存行,确保不会发生超卖。第二层是应用层限流,在采购入库和销售出库的接口上加了简单的令牌桶限流,防止高峰期恶意请求直接打满数据库连接池。

库存预警规则采用了可配置的设计。每种粮食可以设置库存上下限阈值,预警类型包括低于下限、高于上限、临期(存放在库超过一定天数)。系统每天凌晨通过定时任务扫描一次库存表,生成预警记录并推送消息给仓储管理人员。

4. 关键功能实现:从采购入库到销售出库

4.1 采购入库全流程:事务与唯一约束的配合

采购入库是整个系统最复杂的事务操作。一笔完整的入库单提交,同时涉及库存更新、批次创建、流水写入三个动作。我拆成以下几个步骤:

  1. 校验采购单状态,必须是“已质检”状态
  2. 根据质检结果计算出净重和扣款金额
  3. 查询是否存在相同品种、等级、批次的库存记录
  4. 不存在则新建批次并插入库存,存在则更新库存数量
  5. 写入库存流水表,记录变更前后数量
  6. 更新采购单状态为“已入库”,同时生成待结算记录

整个过程用@Transactional注解包裹,任何一个环节抛异常都会整体回滚。这里有个细节:库存更新语句必须带条件判断,比如UPDATE stock_inventory SET quantity = quantity + #{num} WHERE id = #{id},而不是先查出来再赋值做updateById。后者在高并发场景下会丢更新,前者依托数据库的行锁保证数据准确。

代码层面的核心逻辑大概是这样的:

@Transactional(rollbackFor = Exception.class) public InboundBillResult handleInbound(InboundRequest request) { // 1. 查询并锁定采购单 PurchaseOrder order = purchaseOrderMapper.selectByIdForUpdate(request.getOrderId()); if (order == null || !"QUALIFIED".equals(order.getStatus())) { throw new BizException("采购单不存在或不可入库"); } // 2. 计算净重,扣水分、杂质 BigDecimal netWeight = CalculatorUtil.calculateNetWeight(order); // 3. 获取或创建批次 Long batchId = stockBatchService.getOrCreateBatch(order); // 4. 扣减库存(使用乐观锁) int updated = stockInventoryMapper.increaseQuantity(batchId, netWeight, order.getWarehouseId()); if (updated == 0) { // 库存记录不存在则创建,存在唯一索引冲突则转为更新 stockInventoryMapper.insertOrUpdate(batchId, netWeight, order.getWarehouseId()); } // 5. 写入流水 stockFlowService.saveFlow(batchId, "INBOUND", netWeight, order.getOrderNo()); // 6. 更新状态 purchaseOrderMapper.updateStatus(order.getId(), "INBOUNDED"); return InboundBillResult.success(order.getOrderNo(), netWeight); }

这段代码虽然不长,但每一步都是有实际意义的。比如selectByIdForUpdate是行级锁,它保证了在多个人同时对一个采购单做操作时,只有一个事务能成功完成入库,其他的会等待锁释放后重新检查状态。再比如insertOrUpdate结合唯一索引,解决了“同一批次第一次入库时创建库存记录”和“后续批量入库时累加数量”两种情况的切换。

4.2 出库并发扣减:乐观锁与数据库条件更新

销售出库的场景跟采购入库正好相反,它要做的是库存扣减。扣减最怕的就是超卖:两个销售员同时看到库存还有 100 吨,各自下单 80 吨,如果没有并发控制,最后库存会变成负数。

我采用的方案是数据库条件更新,先查询当前库存,然后在更新语句中带上数量条件:

int updated = stockInventoryMapper.decreaseQuantity( batchId, needQuantity, request.getWarehouseId() ); if (updated == 0) { throw new BizException("库存不足或批次已被锁定,请刷新后重试"); }

对应的 SQL 是:

UPDATE stock_inventory SET quantity = quantity - #{needQuantity} WHERE id = #{id} AND quantity - #{needQuantity} >= 0

这条 SQL 的意思很明确:只有当库存扣减后仍不小于 0 时才允许更新,更新行数为 0 就说明扣减失败。数据库自身的行锁和 where 条件保证了多个并发请求之间不会互相覆盖。

库存锁定的逻辑也放在出库流程中。创建销售订单时,先从库存中锁定需要的数量,库存字段locked_quantity增加但quantity不变。只有最终确认出库时,才把锁定数量转为实际扣减。这样做的好处是,订单创建和出库执行之间有时间差,库存需要一提交订单就“预留出来”,防止被别人抢走。

4.3 权限控制的落地细节:数据隔离与按钮级权限

RBAC 权限模型听起来很基础,但真正落地起来有些细节问题。我这边把权限控制分成两个维度:

第一个维度是接口权限。登录用户携带 JWT 令牌,后端从令牌中解析出用户 ID,再查询用户角色和权限码列表,存放在请求上下文中。自定义注解@RequiresPermission("purchase:inbound:add")标在 Controller 方法上,拦截器通过反射读取注解配置,跟当前用户的权限码做匹配,不匹配直接返回 403。

第二个维度是数据权限。不同的操作员只能看到自己负责的仓库数据。比如 A 仓管员只能操作 A 仓库的入库单和库存记录,不该看到 B 仓库的。实现方式是查询条件中自动拼接数据范围:在 Service 层做业务查询前,先从上下文中取出当前用户绑定的仓库 ID 列表,拼进 SQL 的 where 条件里。这个逻辑提炼成一个工具方法,所有涉及库存查询的 Service 都统一调用,避免漏传条件导致数据越权。

4.4 质检与结算联动:业务规则如何避免硬编码

质检是整个粮食供应链里非常特殊的一环。它不直接产生库存变化,但它直接决定入库数量、入库价格、甚至是否同意入库。

我把质检规则做成了数据字典 + 规则表的模式。规则表里存的是“水分超出标准值 X 个百分点,扣量 Y%”这样的配置项,业务代码只负责执行计算,不写死任何百分比数字。这样当收购标准调整时,只需要改数据库里的规则配置,不需要修改代码重新上线。

结算模块和质检规则相互联动。入库结算时,系统自动取采购单价、质检扣量结果、实际入库净重,计算应付金额。应付金额做完后就不会再变动,后续付款只是更新往来账余额,不允许反算。这种设计确保了财务数据的稳定性和可追溯性。

5. 性能优化与报表统计的实战方案

5.1 列表查询的三种优化手段

系统里列表查询是最常见的操作,尤其是库存台账和流水记录,数据量大、查询条件复杂。我做了三种优化手段:

第一种是分页优化。MyBatis-Plus 的默认分页插件会在 COUNT 时把原来的查询 SQL 包一层,这在小数据量下没有问题,但在多表关联时 COUNT 查询可能非常慢。我的做法是手工编写 COUNT SQL,去掉不必要的 JOIN,只统计主表 ID 数量,消除排序和分组。

第二种是延迟加载关联数据。列表查询只查主表字段,关联的粮食品种名称、仓库名称、供应商名称这些信息,在接口返回时通过一次批量查询按 ID 集合统一查出,再组装到结果集。坚决避免在循环里单条查询数据库,我把所有“循环内查询数据库”的代码都定义为代码评审红线。

第三种是加索引。库存流水表建了(batch_id, create_time)联合索引,采购单表建了(supplier_id, status)联合索引。索引不是越多越好,每增加一个索引都会降低写入性能,我的原则是“从慢查询日志里找优化点,加了索引之后通过 EXPLAIN 确认是否真正启用”。

5.2 Redis 缓存热点数据的读写策略

系统里有两个热点数据非常适合用 Redis 缓存:一是库存汇总数据(各仓库各品种粮食的实时总库存),二是粮食价格行情数据。

库存汇总数据的缓存策略是:任何入库、出库操作成功后,主动删除对应的缓存条目,下次查询时重新从数据库加载并写入缓存。这个叫做“Cache Aside Pattern”,实现简单、数据一致性很容易保证。价格行情数据则是定时任务每 15 分钟从数据库加载一次价格区间数据,更新到 Redis,不做主动失效。

用 Redis 还要注意一个反直觉的坑:缓存值不能太大。有些人喜欢把查询结果整个序列化成 JSON 放进 Redis,如果这个结果是几千行数据,每次读取和反序列化的开销可能比直接查数据库还大。解决方案要么是只缓存聚合结果(比如总行数、合计数),要么是给缓存设置合理的过期时间而不缓存超大集合。

5.3 Excel 导出:异步导出与 WebSocket 通知

粮油企业的月报、季报、台账导出,是财务人员和仓储人员的刚需。最初版本的导出方案是同步导出,点击按钮后浏览器等待接口返回文件流。结果导出 5 万行数据时,接口执行了好几分钟,前端一直转圈,用户体验非常差。

后来我改成了异步导出方案:

  1. 用户点击导出,后端立即返回一个“导出任务已创建”的标识
  2. 后端线程池异步查询数据,使用 EasyExcel 流式写入,每批次查询 5000 条数据就写一次文件
  3. 导出完成后将文件路径存入数据库,通过 WebSocket 推送通知给用户
  4. 用户在前端任务中心下载文件,下载采用预签名 URL,避免文件接口暴露

这个方案把耗时操作从用户请求线程中剥离出去,用户操作体验完全变成了“提交任务 -> 收到通知 -> 下载文件”的异步模式。同时流式写入也解决了大数据量导出的内存溢出问题。

5.4 报表统计:冗余聚合表代替实时汇总

月初月底,报表系统要被财务和仓储同时调用,如果每次都从流水明细表里实时汇总,数据库压力不小。我的方案是做了一张每日汇总冗余表,由定时任务每天凌晨跑批生成前一天各仓库、各品种的收发存数据。报表查询时直接查汇总表,速度是毫秒级的。

这里要注意汇总表的数据准确性。我的定时任务不是“只算昨天的数据”,而是“每天重新计算最近 30 天的数据”,并采用覆盖式写入(先删除再插入),这样即使前端改了历史单据导致数据变化,第二天凌晨自动把最近 30 天数据全部修正,不需要人工干预。

6. 项目落地过程中的踩坑记录与排查思路

6.1 事务失效的经典三个坑

事务失效问题是我调过的最多次数的 Bug 类型。总结下来无非三个原因:

第一个是同类内部调用。一个 Service 类里的方法 A 调用方法 B,而 B 上有@Transactional注解,这其实是不生效的。因为 Spring 的事务代理是通过外部调用进入代理对象的,内部this.method()直接调用了原始对象,压根儿没经过代理。

// 错误示例:内部调用,事务不生效 public void doBiz() { this.updateStock(); // @Transactional 失效 } // 正确示例:拆到另一个 Service 或注入自身代理 @Autowired private BizService self; public void doBiz() { self.updateStock(); // 事务生效 }

第二个是异常被吞掉。try-catch把异常捕获后继续往下走,事务拦截器没有收到异常信号,自然就无法触发回滚。解决方式是自定义异常处理切面,或者约定 Service 层所有业务异常必须抛出。

第三个是数据库表存储引擎不支持事务。如果表用的是 MyISAM,@Transactional注解写上也没用。MySQL 8.0 默认 InnoDB 没有这个问题,但如果是沿用老库上来的表,一定要检查。

6.2 逻辑删除与唯一索引的冲突

业务系统里比较通用的做法是逻辑删除,给表加一个deleted字段,查询时过滤掉已删除的数据。但这个方案如果遇到唯一索引就会出问题。

举个例子:仓库表对warehouse_code做了唯一约束,用户先删除编号为“WH001”的仓库(逻辑删除,deleted=1),然后新建一个同样编号“WH001”的仓库,数据库层面的唯一索引会直接报错,因为deleted=1的那条记录还存在。

我最终的解决方案是,把业务唯一索引和删除标记合并:不再使用deleted=0/1,而是增加一个deleted_id字段,正常数据的deleted_id为 0(唯一索引里所有 0 会冲突),删除操作时把deleted_id设置为当前记录的主键 ID(每个删除记录的 ID 不同,唯一索引不会冲突)。查询时统一过滤deleted_id = 0即可。这样既保留了逻辑删除的可追溯性,又解决了唯一索引冲突问题。

如果你觉得这个方案复杂,其实还有一个更简单的折中:凡是带唯一编码的字典表,例如仓库、品种、客户,直接物理删除,因为这些数据删除后不会有任何追溯需求。业务单据表如采购单、销售单,才用逻辑删除,但它们一般没有唯一索引,不会出现上面的问题。

6.3 定时任务重复执行与分布式锁

系统刚开始的定时任务是单实例部署时的@Scheduled(cron = "0 0 2 * * ?")注解。但实际环境中系统需要部署两个实例做高可用,于是问题来了:每天凌晨两点,两个实例的定时任务同时触发,数据被重复跑批两次,汇总表数据错乱。

解决这个问题,我引入了 Redis 分布式锁:任务启动前尝试获取锁,获取成功才执行任务逻辑,执行结束后释放锁。锁的过期时间要设置为任务最大耗时的 1.5 倍,防止任务执行中崩溃导致锁永远不释放。

public void executeTask() { String lockKey = "task:daily-report-lock"; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", Duration.ofMinutes(30)); if (!locked) { return; // 另一个实例已在执行 } try { // 执行跑批逻辑 } finally { redisTemplate.delete(lockKey); } }

这个方案的缺点是没有“重入”能力,如果任务本身是递归执行的,需要额外处理。但对于我这个场景,每个任务都是单层独立执行,分布式锁完全够用。

6.4 导出大数据量时的内存溢出

第一次上线后,财务导某年度的出入库明细,这批数据大概有 20 多万行。用户一点导出按钮,系统直接报OutOfMemoryError,后端应用当场卡死。

复盘原因:代码里用list()把 20 万行数据全部加载进内存,再一次性写入 Excel 文件。20 万行对象加上关联数据,内存占用轻松超过 GC 阈值。解决方案就是前面提到的异步导出 + 分批查询 + 流式写入。每次从数据库取 5000 条,写入文件后及时清空对象引用。内存峰值从顶峰降到了不到十分之一。

还有个额外经验:任何操作超过一定数据量,都不要在主线程里做,先丢到线程池异步执行,然后给前端一个“任务进行中”的反馈。这是所有企业应用都应遵守的通用原则。

7. 部署上线与运维经验

7.1 环境管理与配置文件隔离

系统上线前我搭了三套环境:开发环境、测试环境、生产环境。Spring Boot 的多环境配置通过application-{profile}.yml实现,打包时用spring.profiles.active指定当前环境。这里有个非常重要的细节:生产环境的数据源密码、Redis 密码绝不允许写在配置文件里明文保存。我这边是通过环境变量注入,运维同学在部署脚本里配置,开发同学看不到生产密码。

7.2 数据库版本控制:Flyway 与初始化策略

数据库结构变更我用了 Flyway。第一次上线时,它会自动执行db/migration目录下的基线 SQL 脚本,后续每次版本升级只需要新增一个V1.1__xxx.sql脚本,Flyway 通过版本号自动判断哪些脚本需要执行。这个工具看似简单,但真的很能打,它保证了开发、测试、生产三套环境的库表结构完全一致。

我会特别提醒:任何数据库表结构的变更,都要通过 Flyway 脚本去做,禁止直接在生产库上手工 ALTER TABLE。这不仅仅是为了记录变更历史,更是为了撤销和回滚时能清楚知道改过什么。

7.3 应用部署形态:systemd 托管 Jar 服务

生产环境我没有采用 Docker,而是用 systemd 直接托管 Spring Boot 的 Jar 包。原因很现实:项目团队的运维技能主要围绕 CentOS 和 systemd,用 Docker 还需要他们额外学习和维护镜像仓库,短期内性价比不高。

systemd 服务文件的写法很简单,核心是启动命令和重启策略:

[Unit] Description=JianJin Food Supply Chain System After=network.target mysql.service redis.service [Service] Type=simple User=appuser WorkingDirectory=/data/jianjin/app ExecStart=/usr/bin/java -Xms1024m -Xmx2048m -jar jianjin-server.jar --spring.profiles.active=prod Restart=always RestartSec=15 [Install] WantedBy=multi-user.target

部署流程也很固定:打包 -> 上传 Jar 包 -> 确认备份 -> 重启服务 -> 查看日志确认健康状态。我在系统里配置了spring-boot-starter-actuator,通过/actuator/health接口做健康检查,配合监控工具的告警策略,应用异常时能自动通知运维人员。

7.4 备份与恢复策略

数据库备份是最容易被人忽视但又最要命的事。我的备份策略分成两层:每天凌晨 2 点用mysqldump做全量备份,保留最近 15 天备份文件;每日的业务汇总任务跑批前做一次增量日志备份。恢复演练至少每季度做一次,确保备份文件真实可用。

提示:备份文件一定要跟生产服务器分开放置,最好是另一台独立的备份服务器或者对象存储。不然机房整体故障时,备份和数据库一起消失,那就是灾难了。

8. 实操总结与扩展方向

这个系统从需求分析到上线,前后经历了大概四个月。如果只让我总结一条最核心的经验,那就是:做业务系统,技术本身的门槛并不高,真正的难度在于把业务规则理清楚,然后转化成一个数据模型稳定、事务边界清晰、可追溯性强的系统。Spring Boot 提供的只是基础设施,它不会帮你解决业务逻辑的复杂度。

再分享一个小技巧。在整个项目开发过程中,我把所有表结构的字段注释写得非常详细,包括枚举状态值的含义、业务字段的计算公式、关联表的关系说明。这些注释在后期的需求变更和问题排查中,价值远超想象。有一次仓库同事反馈库存数据不对,我就通过注释和流水表对比,十分钟就定位到了问题出在“移库操作时没有写流水记录”这个逻辑漏洞上。

如果你正准备做类似的系统,我觉得可以在这个基础上继续扩展的方向有两个。一个是多组织架构支持,当系统从一个企业扩展到集团多个子公司时,数据权限要从“仓库维度”升级为“组织维度”,这是很多单体系统后期最大的改造点。另一个是移动端集成,仓储人员在外面执行盘点时用手机拍照和扫码采集数据,可以直接对接当前的接口层,后端只需要增加几个移动端专用接口即可。

希望这篇复盘对你有些帮助。项目里踩过的那些坑,你大概率也会遇到,提前有个心理准备,总比到时候现学现卖要强得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询