接手这个项目的时候客户那边的业务负责人给我看了一组数据仓储账实不符率百分之三点几每年粮食出入库单子有上万张月底对账靠财务拿着 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 采购入库全流程事务与唯一约束的配合采购入库是整个系统最复杂的事务操作。一笔完整的入库单提交同时涉及库存更新、批次创建、流水写入三个动作。我拆成以下几个步骤校验采购单状态必须是“已质检”状态根据质检结果计算出净重和扣款金额查询是否存在相同品种、等级、批次的库存记录不存在则新建批次并插入库存存在则更新库存数量写入库存流水表记录变更前后数量更新采购单状态为“已入库”同时生成待结算记录整个过程用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 万行数据时接口执行了好几分钟前端一直转圈用户体验非常差。后来我改成了异步导出方案用户点击导出后端立即返回一个“导出任务已创建”的标识后端线程池异步查询数据使用 EasyExcel 流式写入每批次查询 5000 条数据就写一次文件导出完成后将文件路径存入数据库通过 WebSocket 推送通知给用户用户在前端任务中心下载文件下载采用预签名 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 层所有业务异常必须抛出。第三个是数据库表存储引擎不支持事务。如果表用的是 MyISAMTransactional注解写上也没用。MySQL 8.0 默认 InnoDB 没有这个问题但如果是沿用老库上来的表一定要检查。6.2 逻辑删除与唯一索引的冲突业务系统里比较通用的做法是逻辑删除给表加一个deleted字段查询时过滤掉已删除的数据。但这个方案如果遇到唯一索引就会出问题。举个例子仓库表对warehouse_code做了唯一约束用户先删除编号为“WH001”的仓库逻辑删除deleted1然后新建一个同样编号“WH001”的仓库数据库层面的唯一索引会直接报错因为deleted1的那条记录还存在。我最终的解决方案是把业务唯一索引和删除标记合并不再使用deleted0/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] DescriptionJianJin Food Supply Chain System Afternetwork.target mysql.service redis.service [Service] Typesimple Userappuser WorkingDirectory/data/jianjin/app ExecStart/usr/bin/java -Xms1024m -Xmx2048m -jar jianjin-server.jar --spring.profiles.activeprod Restartalways RestartSec15 [Install] WantedBymulti-user.target部署流程也很固定打包 - 上传 Jar 包 - 确认备份 - 重启服务 - 查看日志确认健康状态。我在系统里配置了spring-boot-starter-actuator通过/actuator/health接口做健康检查配合监控工具的告警策略应用异常时能自动通知运维人员。7.4 备份与恢复策略数据库备份是最容易被人忽视但又最要命的事。我的备份策略分成两层每天凌晨 2 点用mysqldump做全量备份保留最近 15 天备份文件每日的业务汇总任务跑批前做一次增量日志备份。恢复演练至少每季度做一次确保备份文件真实可用。提示备份文件一定要跟生产服务器分开放置最好是另一台独立的备份服务器或者对象存储。不然机房整体故障时备份和数据库一起消失那就是灾难了。8. 实操总结与扩展方向这个系统从需求分析到上线前后经历了大概四个月。如果只让我总结一条最核心的经验那就是做业务系统技术本身的门槛并不高真正的难度在于把业务规则理清楚然后转化成一个数据模型稳定、事务边界清晰、可追溯性强的系统。Spring Boot 提供的只是基础设施它不会帮你解决业务逻辑的复杂度。再分享一个小技巧。在整个项目开发过程中我把所有表结构的字段注释写得非常详细包括枚举状态值的含义、业务字段的计算公式、关联表的关系说明。这些注释在后期的需求变更和问题排查中价值远超想象。有一次仓库同事反馈库存数据不对我就通过注释和流水表对比十分钟就定位到了问题出在“移库操作时没有写流水记录”这个逻辑漏洞上。如果你正准备做类似的系统我觉得可以在这个基础上继续扩展的方向有两个。一个是多组织架构支持当系统从一个企业扩展到集团多个子公司时数据权限要从“仓库维度”升级为“组织维度”这是很多单体系统后期最大的改造点。另一个是移动端集成仓储人员在外面执行盘点时用手机拍照和扫码采集数据可以直接对接当前的接口层后端只需要增加几个移动端专用接口即可。希望这篇复盘对你有些帮助。项目里踩过的那些坑你大概率也会遇到提前有个心理准备总比到时候现学现卖要强得多。