☰
微服务架构实战:SpringBoot+Vue智慧食堂系统设计与踩坑全记录
2026/10/3 3:23:37 网站建设 项目流程

咱们直接聊一个我最近完整走通的项目:机关智慧食堂后勤管理系统,技术栈锁定在 SpringBoot + Vue + SpringCloud 微服务分布式这套组合上。这段时间经常有人问我,机关单位食堂这种“看似不复杂”的业务,到底有没有必要上微服务?上了微服务之后,分布式事务、分布式锁、文件存储这些坑又该怎么填?这篇文章我把整个项目的拆解思路、技术选型理由、核心代码实现以及踩坑记录一次性说清楚,希望能给正在做后勤类管理系统或者打算把单体系统升级成微服务架构的朋友提供一份可以直接参考的实操手册。

这个系统面向的是机关内部食堂的完整后勤管理场景,覆盖员工档案、菜品菜品管理、档口管理、在线订餐、支付对账、食材采购、库存管理、供应商结算、设备报修、数据统计等一整套流程。之所以选择微服务架构,是因为这些模块虽然从业务上看都在一个食堂体系里,但它们的用户角色、访问频率、扩展方向和对稳定性的要求差异很大。比如菜品信息和点餐服务面向的是全体员工,访问量集中在用餐高峰期;而供应商管理和采购库存则是后勤部门的工作台,数据量不大但对事务一致性要求很高。如果全部塞进一个 SpringBoot 单体应用里,后期只要点餐高峰一来,整个系统都可能被拖垮。这一点我在项目启动前结合历史单体版本的数据想得很清楚。

从开发到上线我前后花了两个多月,中间经历了 Nacos 注册不上去、Vue 打包后路由白屏、MinIO 文件上传跨域、Seata 分布式事务回滚失效这类典型问题。为了避免大家在同样的地方浪费时间,我会把每一步的关键决策和实验过程都写出来。以下是正文。

1. 项目整体设计与微服务拆分思路

1.1 为什么机关食堂系统也要拆微服务

很多人觉得机关食堂后勤管理就是一个内部系统,用户量撑死几百人,用单体足够。这个观点在工作量不饱和、并发量很低、业务人员也比较少的场景下是成立的。但我这次接到的需求并不是只做一个点餐系统,它要对接食堂台账、供应商、财务报销、库房进出、设备维保,还要考虑后续可能的集团食堂统一管理。

单体应用最麻烦的地方在于,所有模块共用一套数据库、一套缓存、一套定时任务调度器。比如点餐模块需要 Redis 做高峰期的菜品缓存,如果菜品管理和员工管理也在同一个进程里,那么任何一次的全局缓存清理都会影响到点餐体验。更致命的是部署层面:每次改一个菜品上下架的接口,都要把整个系统重新编译打包,一旦启动失败,点餐、支付、财务、库房全链路瘫痪。

微服务拆分之后,每个服务独立部署、独立扩缩容,至少能保证核心业务的故障隔离。我在设计时把系统拆成了七个服务:

  • 用户认证与权限服务:负责员工账号、角色权限、登录态、单点登录。
  • 菜品与档口服务:菜品的增删改查、上下架、档口分类、营养标签。
  • 点餐与活动服务:在线订餐、取餐码、智慧餐台、用餐规则。
  • 订单与支付服务:订单状态流转、支付渠道对接、退款处理。
  • 库存与采购服务:食材库存、采购单、供应商供货、入库出库。
  • 后勤事务服务:设备报修、食堂公告、满意度评价、巡检记录。
  • 数据聚合服务:报表统计、经营分析、大屏展示数据接口。

这套拆分不是按照技术功能来切,而是按照“业务领域 + 独立生命周期”来切。比如点餐服务和订单服务虽然调用关系密切,但点餐服务是 C 端高频操作,订单支付服务则涉及资金安全,两者必须隔离,便于针对性地做限流和风控。

1.2 技术选型:为什么不选 Dubbo 而是 SpringCloud

这个问题我在做方案设计时被领导专门问过。Dubbo 是高性能 RPC 框架,在国内很多传统企业项目中非常常见,但它本身的定位更偏向于服务治理和远程调用,并没有像 SpringCloud 那样形成一套完整的“全家桶”生态。我们这个项目不仅仅需要服务间调用,还需要网关路由、熔断降级、配置中心、分布式事务方案等一堆配套设施。

SpringCloud 最大的优势就是与 SpringBoot 的结合足够自然,开发人员的学习曲线比较平滑。项目里我选用了 SpringCloud 的 2023 版本,搭配 SpringBoot 2.7。这套组合在实际使用中非常稳定。Alibaba 那套组件我用了 Nacos 做注册中心和配置中心,OpenFeign 做服务间调用,Sentinel 做接口限流和熔断,Seata 做分布式事务,再配合 MinIO 做文件存储,Redis 做缓存和分布式锁。

因为项目里所有服务都是 Java 生态,RPC 选择了 OpenFeign + HTTP 的默认方案,并没有引入 Dubbo。通过 Nacos 做服务发现,Feign 会根据服务名自动负载均衡到对应实例,这套方案在几百 QPS 的压力下表现得完全够用。如果你所在团队对性能有极端要求,可以再考虑 Dubbo,但这大概率不是后勤管理系统的瓶颈所在。

1.3 数据库拆分与分布式事务的取舍

微服务拆分之后,数据库不能继续放在一个 MySQL 实例里,否则服务隔离就没有意义。每个服务拥有自己的独立数据库,并通过唯一的服务入口操作自己的数据。比如订单表只由订单服务操作,库存表只由库存服务操作,用户表和角色表只由认证服务操作。

这样拆分后最大的麻烦就是跨服务的数据一致性。以点餐下单为例,这个流程涉及订单服务创建订单、库存服务锁定食材库存、用户服务扣减餐补余额,三个操作如果分属三个数据库,传统数据库事务就完全失效了。我在实际处理时,没有把每一步都强制放进 Seata 全局事务里,因为食堂点餐的流程并不需要强一致,最终一致就能满足业务需求。

具体做法是:本地事务 + 消息队列。订单服务在自己的库里写入订单记录并发送一条 MQ 消息,库存服务和用户服务各自监听消息并完成本地事务,如果任何一个环节失败,则通过消息重试和人工补偿机制处理。只有在供应商结算、财务对账这种金额敏感场景,才单独启用 Seata 的 AT 模式,确保入库、结算、财务入账三个本地事务要么全部提交,要么全部回滚。

2. 核心业务模块与数据库设计

2.1 用户认证服务:从单点登录到权限控制

机关食堂系统最麻烦的不是点餐功能,而是人员身份体系。员工入职、调岗、离职都会影响食堂用餐权限,不同部门可能还有不同的补贴标准和用餐时段。我把用户服务做成了完全独立的模块,不仅管登录,还把组织架构、部门层级、用餐补贴规则全部纳进来。

在登录方案上,我没有采用传统的 Session 共享,而是直接使用 JWT 令牌,由网关统一校验并透传用户上下文。JWT 的优势在微服务架构里非常明显,服务间调用不需要每次都回查认证中心,令牌本身携带用户 ID、角色列表、失效时间,接收方解码验证签名即可。为了避免 JWT 泄露带来的风险,我设置了较短的过期时间(两小时),并结合 Redis 做登录态管理,用户退出或换设备登录时强制刷新令牌。

权限控制方面,用的是 RBAC 模型。用户表、角色表、菜单权限表、用户角色关联表、角色权限关联表这五张核心表支撑了整个后台管理端和员工端的权限体系。前端根据用户角色动态生成路由菜单,后端接口通过 Spring Security + 自定义注解完成细粒度校验,比如食堂管理员可以看见“菜品管理”“供应商结算”,普通员工只能看见“点餐”“订单记录”“餐补查询”。

2.2 菜品与档口服务:不只是简单的 CRUD

菜品和档口服务看似最简单,无非是菜品名称、图片、价格、分类几个字段的增删改查。但在这个项目里它承担着整个智慧食堂的“商品中心”职责,所有其他服务的数据都依赖它,所以做的时候绝对不能用简单的 CRUD 思路糊弄。

我在菜品表里设计了一些关键字段:菜品编码、菜品名称、分类 ID、档口 ID、售卖价格、成本价格、营养标签、忌口标签、上下架状态、菜品图片链接、创建时间、更新时间。其中成本价格专门用于财务毛利核算,不让普通员工在前端看到。菜品图片采用的是 MinIO 对象存储,图片链接保存的是 MinIO 地址,前端通过网关代理访问,避免把存储节点直接暴露到公网。

考虑到菜品的展示需要应对每日菜单变化,我将菜品表分成了基础菜品表和每日菜单表两个层次。基础菜品表保存的是食堂做过并有完整资料的菜品,每日菜单表则记录某一天在某个档口实际售卖了哪些菜品,包含当日售价和售卖份数。这个设计能兼顾重复使用的菜品资料和每天菜单的动态变化,还能为后面做“智能备餐量预测”积累数据。

2.3 订单与支付服务:订单状态机与金额计算

订单服务是整个系统里最核心的服务。食堂点餐有两种模式:一种是在线预订某个档口套餐,另一种是线下刷卡或扫码支付。在线预订流程是这样:员工登录系统,选择档口,选择菜品或者套餐,生成订单后从餐补余额中扣款,然后生成取餐二维码,凭码到窗口核销领餐。

为了保证订单状态清晰可靠,我在代码里明确实现了一套订单状态机。状态包括:待支付、已支付、待取餐、已取餐、已退款、退款中、已完成、已关闭。每一个状态变更都对应一个具体的操作事件,禁止跨状态跳转。比如退款操作只允许发生在“已支付”和“待取餐”状态,如果订单已经“已取餐”,则只能走售后流程。整套状态机用枚举 + 状态流转表来管理,方便记录日志和排查问题。

金额计算这块也有不少细节需要注意。订单的金额分为菜品金额、打包费、优惠减免、实际支付金额四个字段。优惠减免可能来自食堂的套餐活动,也可能是员工不同等级的补贴。为了方便财务对账,订单表还保存了原始流水号和支付平台单号,每笔订单在支付成功后都会给支付服务发送一条消息,由支付服务异步更新订单状态,同时记录资金流水。

3. 微服务分布式核心问题与解决方案

3.1 分布式 ID:订单号为什么不能自增

微服务架构下,订单表如果继续使用 MySQL 自增主键会引发大问题。多个服务实例同时操作同一个订单表时会生成重复主键,订单号还会暴露当天订单量等数据。我在项目里没有用 UUID,而是采用了基于 Redis 的号段模式:每次从 Redis 领取一批 ID,比如一次领取 1000 个号段(起始值 100001,步长 1000),然后在本地内存中依次分配。

这样做的原因是,UUID 虽然全球唯一,但它是无序字符串,订单号如果使用 UUID,数据库索引的 B+ 树插入会因为节点分裂而频繁产生碎片,数据量大了之后写入性能会明显下降。号段模式生成的订单 ID 是一个长整型数字,保证全局唯一、趋势递增、存储高效,还能嵌入业务含义,比如前两位代表数据中心,中间代表业务类型,末尾是序列号。

3.2 分布式锁:库存扣减为什么不能用悲观锁

点餐高峰期最大的并发压力来自“售罄”这个操作。假如疫苗菜品只有 100 份,但 1000 个员工同时提交订单,库存服务必须保证不会超卖。最初我直接用 MySQL 的SELECT ... FOR UPDATE来锁库存,也就是悲观锁方案。这么做的确能防止超卖,但代价是数据库连接会大量占用,一旦有订单事务处理慢,连接池很快耗尽,反而拖垮整个服务。

后来我改成了 Redis 分布式锁加库存预扣方案。下单时,库存服务先尝试在 Redis 里扣减一个dish:stock:{dishId}的健值,利用 Redis 单线程执行指令的原子性保证并发安全。如果扣减成功,再创建订单;如果扣减失败,直接返回“菜品已售罄”。订单失效或超时取消时,再通过 Lua 脚本把库存加回去。

分布式锁的实现我选择了 Redisson 框架,而不是手动用 setnx 命令。Redisson 封装好了看门狗机制,可以自动续期,避免业务执行时间过长导致锁提前过期引发重复操作。Redisson 分布式锁的原生代码大概长这样:

// 使用 Redisson 做分布式锁 RLock lock = redissonClient.getLock("dish:stock:lock:" + dishId); boolean locked = lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { throw new BusinessException("系统繁忙,请稍后重试"); } try { // Redis 原子扣减库存 String script = "return redis.call('DECRBY', KEYS[1], ARGV[1])"; Long result = redisTemplate.execute( new DefaultRedisScript<>(script, Long.class), Arrays.asList("dish:stock:" + dishId), quantity ); if (result == null || result < 0) { throw new BusinessException("菜品库存不足"); } } finally { lock.unlock(); }

用 Redis 做库存扣减真正把数据库的压力降了下来,订单高峰期即使有大量并发请求,数据库只需要在支付完成后做一个最终扣减即可。这是我在这个项目里做得最值的一个优化点。

3.3 分布式事务:Seata AT 模式与消息最终一致性

虽然前面我说大部分场景使用消息队列做最终一致性,但供应商结算这个场景不能这么设计。供应商供货后要生成入库单,入库单要生成采购结算单,采购结算单要同步财务系统的应付账款,这三步如果哪一步失败而前面已经提交,财务就会对不上账。因为涉及钱,所以这里必须使用分布式事务强一致方案。

我在项目里引入了 Seata,选择了 AT 模式。AT 模式是 Seata 最省心的方案,它通过代理数据源自动记录事务的 undo_log,业务代码不需要改 SQL,只需要在业务方法上加上@GlobalTransactional注解。当全局事务结束时,如果有一个参与事务的服务提交失败,Seata 会通过 undo_log 把之前其他分支事务的数据反向回滚,恢复到操作之前的快照。

Seata 的部署本身不算复杂,需要启动一个 TC 服务器,业务服务接入时只需要配置 Nacos 地址和事务组名。配置好之后,我的入库和结算代码大概是这种结构:

@GlobalTransactional(name = "create-purchase-settle", timeoutMills = 30000) public void createSettleOrder(PurchaseOrder order) { // 本地事务 1:生成入库记录 stockService.createStockIn(order); // 远程调用库存服务:锁定供应商货款 supplierService.lockPayable(order); // 本地事务 2:生成财务应付记录 financeService.createPayable(order); }

需要特别注意的是,AT 模式的事务分支不能嵌套调用多个远程服务时使用普通连接,必须让所有参与事务的分支都走 Seata 的代理数据源,并保证全局事务 ID 能通过 OpenFeign 的请求头传递下去。我在排查 Seata 回滚失效的问题时,就发现是因为 Feign 拦截器没有显式传递TX_XID,导致下游服务根本不知道自己在全局事务里。

4. SpringCloud 基础设施搭建实战

4.1 Nacos 注册中心与配置中心:从启动到踩坑

Nacos 是整个微服务架构的“地址簿”和“配置中心”,所有服务启动后都要在 Nacos 上注册,其他服务才能通过服务名找到它。我的部署方式是:Nacos 单独部署在一台 4G 内存的服务器上,使用 MySQL 持久化存储配置和服务注册信息,避免 Nacos 重启后服务列表丢失。

SpringBoot 服务接入 Nacos 时,我建议直接用最新稳定版的对应的 starter 版本。这里有一个非常重要的坑:SpringBoot 2.4 以上版本的配置文件加载方式发生了改动,不再默认读取--spring.profiles.active来判断 profile 指定,而是必须使用优先配置方式。比如我要给订单服务用 Nacos 中心配置,需要在 bootstrap.yml 里设置:

spring: application: name: order-service cloud: nacos: server-addr: 192.168.1.100:8848 username: nacos password: nacos config: file-extension: yaml namespace: dev group: FOOD_SERVICE shared-configs: ->spring: cloud: gateway: routes: - id: user-web uri: lb://user-service predicates: - Path=/api/user/** - id: order-web uri: lb://order-service predicates: - Path=/api/order/**

网关上的全局过滤器统一处理 JWT 令牌校验。校验通过后,过滤器把用户 ID 和角色信息放入请求头,再转发给下游服务,下游服务直接从请求头里取用户信息,不再重复校验。整个链路下来,认证服务只负责发放令牌和管理用户状态,订单服务、菜品服务等都变成了无状态服务,扩展起来很轻松。

跨域问题也是在这个层面一次性解决。开发环境前端跑在 8080 端口,服务跑在 8081 等端口,如果不处理跨域,浏览器一定会拦截请求。我在网关加了一个 CorsWebFilter,设置允许来源、请求头和方法,同时放行预检请求。上线后前后端部署在同一台 Nginx 下,其实跨域影响不大,但开发阶段没有这一步基本寸步难行。

4.3 MinIO 文件存储:菜品图片与M3U8视频的私有化存储

食堂系统需要处理两类多媒体文件:一类是菜品图片,另一类厨房操作间的监控视频片段。图片还好说,直接传到 MinIO 的某个 bucket 即可,但视频我特别说一下。食堂的明厨亮灶监控要求视频可以被授权的前端播放,而浏览器原生播放视频文件一直是一个很麻烦的问题,特别是监控视频基本都是切片输出的 M3U8 格式。

MinIO 存储 M3U8 切片文件十分方便。我把每个片段的 ts 文件和 m3u8 索引文件都放在同一个目录,上传完成后生成一个对应的访问地址。前端播放时,我先通过网关生成一个带签名和过期时间的播放凭证,这个凭证本质上是一个有效期十分钟的 MinIO 直链地址,前端拿到地址后交给 hls.js 播放。免安装播放器的方案我试过很多,最后还是选了 hls.js,它在浏览器上播放 M3U8 的能力非常成熟,不需要用户安装任何软件。

接入 MinIO 后,代码并不复杂,核心是用 Java SDK 上传和生成签名 URL:

@Autowired private MinioClient minioClient; public String uploadFile(MultipartFile file, String bucketName) { String fileName = UUID.randomUUID() + "-" + file.getOriginalFilename(); // 判断 bucket 存在与否 boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); if (!exists) { minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build()); } // 上传文件 minioClient.putObject(PutObjectArgs.builder() .bucket(bucketName) .object(fileName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return fileName; } public String generateSignedUrl(String bucketName, String objectName, int expiresSeconds) { return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() .bucket(bucketName) .object(objectName) .expiry(expiresSeconds) .method(Method.GET) .build()); }

在接入 MinIO 的过程中我踩过一个大坑:网关上配置了全局跨域过滤器后,浏览器直接请求 MinIO 签名地址时还是会报跨域错误。原因是签名地址是 MinIO 自己的域名的端口,并非网关域名,浏览器认为这是跨域请求。解决办法是在 MinIO 服务端设置跨域规则,在 MinIO 的 bucket 策略中配置允许的来源和方法。这样前端就可以直接从 MinIO 拉取图片或视频。

5. 前端 Vue 应用实现与前后端联调

5.1 Vue 项目的工程化配置与目录结构

前端部分我采用的是 Vue 3 全家桶,配合 Vite 构建工具。虽然 Vue 2 还活在很多老项目里,但新项目完全没有必要再启用 Vue 2。Vite 在开发热更新速度上比 Webpack 快得多,而且配置相对简单,对微服务项目来说能够明显提高联调效率。

项目目录结构我划分得很明确。src/api放所有后端接口请求,src/router存放动态路由,src/store是状态管理,src/views是页面组件。每个大模块一个目录,比如菜品管理、订单管理、库存管理、报表管理。接口请求统一封装 axios 实例,设置请求头带上 JWT 令牌,响应拦截器统一解析错误码。后端返回结构我也做了统一约定:code、message、data三个字段,前端根据code判断是否成功,这样对接时不用各写各的。

Vue 的安装和环境配置有一个高频问题:npm install 时不时会报各种依赖版本冲突。我建议在项目开始时就锁定好 Vue 版本,使用npm create vue@latest创建项目,它会生成相对合理的默认结构。之后再装element-plus、pinia、vue-router、axios、hls.js等依赖。注意,Vite 项目里如果要用 path 别名,一定要在vite.config.js里手动配置,否则页面里写@/api会报路径不存在。

import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, server: { port: 8080, proxy: { '/api': 'http://localhost:8088' } } })

开发环境中我通过 Vite 的代理配置将/api请求转发到网关地址,避免开发时反复处理跨域。生产环境则直接构建静态文件,扔给 Nginx 托管。Nginx 上用一条location规则将前端路由交给 Vue 的 history 模式处理,将/api路径反向代理到后端网关地址。

5.2 动态路由与权限控制

后台管理系统有一个很烦的问题:不同权限的人登录进去看到的菜单不一样。如果全部用静态路由,管理员账号登录时能看到“员工管理”“供应商结算”这些菜单,普通员工账号就不应该看见。我采用了动态路由方案:登录成功后,前端携带用户 token 请求用户服务,后端返回当前用户的角色和对应菜单树,前端根据菜单树不用刷新浏览器即可动态添加路由。

要特别说明的是,动态路由不能只靠前端做隐藏。真正安全的后端接口必须校验权限,即使前端把按钮隐藏了,用户还可以手动输入 URL 访问接口。所以我在后端所有接口上加了权限注解,Spring Security 会在接口被调用时校验当前用户角色权限。前端隐藏只是提升体验,后端拦截才是安全底线。

Vue 动态路由的核心代码思路是利用router.addRoute(),把后端返回的菜单数据解析成路由对象,然后逐个注册。注册完成后再用router.replace()跳转到默认页。菜单树的数据结构一般是{ id, name, path, component, children },前端读取后递归生成路由数组。

5.3 播放 M3U8 免安装播放器与前后端联调细节

前面提到了厨房监控播放,这部分的联调细节比较多。Vue 页面拿到 M3U8 的签名播放地址后,需要立刻初始化 hls.js 播放器。由于 hls.js 运行在浏览器中,且是异步加载的脚本,所以最好在组件挂载完成后再动态 import,避免影响首页加载速度。

import Hls from 'hls.js' const playM3u8 = (url) => { const video = document.getElementById('monitor-video') if (Hls.isSupported()) { const hls = new Hls() hls.loadSource(url) hls.attachMedia(video) hls.on(Hls.Events.MANIFEST_PARSED, () => { video.play() }) } else if (video.canPlayType('application/vnd.apple.mpegurl')) { // Safari 原生支持 m3u8 video.src = url } }

前端开发时最常用的联调方法是:先把后端所有接口在网关层跑通,再把 Vue 前端代理到同一个网关,然后逐个页面测试核心流程。遇到请求出错,优先看浏览器 Network 面板的响应状态码和响应体。400 多是参数问题,401 是令牌失效,403 是权限不足,502 往往是网关转发的下游服务没起来。我调试时习惯在网关层打开日志,输出每个请求转发到了哪个服务,这样能快速定位是路由配置错误还是服务实例问题。

6. 上线前后必经的排查与调优实录

6.1 服务发现失败,Nacos 列表为空

这个问题在我第一次联调时几乎百分百出现。服务启动类明明写了@EnableDiscoveryClient,启动日志也没报错,但 Nacos 控制台的服务列表里就是看不到服务。排查步骤我建议按这个顺序来:

  • 先检查 Nacos 控制台是否能正常登录,确认自己连的 Nacos 地址端口没错。
  • 再看服务启动日志里是否打印了注册地址。如果发现服务注册到了内网 IP,外部 Nacos 所在机器访问不了,就会注册失败。
  • 检查spring.cloud.nacos.discovery.ip是否被错误配置,在本地开发时不要手动指定 IP。
  • 检查服务启动的依赖是否完整,有没有漏掉spring-cloud-starter-alibaba-nacos-discovery。

我遇到过最深的一个坑是 Nacos 的命名空间配置问题。创建了 namespace 之后,服务也必须配置相同的 namespace ID,否则注册到默认 public 空间,控制台看不到。这种错很隐蔽,因为启动日志不报错,但这个经验确实值得记下来。

6.2 SpringBoot 与 SpringCloud 版本兼容问题

SpringCloud 和 SpringBoot 的版本对应关系非常严格,用错了就是各种莫名其妙的问题,比如 Feign 接口找不到、服务启动后自动注册失败、配置项不生效等。最保险的做法是去 SpringCloud 官方文档查看版本兼容表,我用的一组版本可以直接写成表格:

组件版本
SpringBoot2.7.18
SpringCloud2021.0.8
SpringCloud Alibaba2021.0.5.0
Nacos Server2.2.3
Seata Server1.7.0
MinIO8.5.7
JDK1.8 或 17

最开始我曾经把 SpringBoot 升到 3.1,结果 SpringCloud Alibaba 对应版本尚未完全适配,导致 Nacos 配置和注册一直失败。最后我还是回到 2.7.18,这套组合在 2026 年看虽然不算最新,但非常成熟,有大量生产环境验证,适合后勤类管理系统这种求稳的场景。

6.3 高峰期超时与数据库连接池耗尽

系统第一次压测时,我用 JMeter 模拟 100 个用户同时提交订单,结果订单服务大量抛 SQL 连接超时异常。原因是订单服务默认的数据库连接池最大只有 20,而每个订单事务需要同时操作订单表、订单明细表、流水表,一个请求就要占 3 个连接,100 并发瞬间把连接池打爆。

解决方案有两个方向:一是扩大连接池,我调整成了 50;二是在数据库连接池上加了连接等待时间,并增加连接检测。除此之外,我把订单创建链路里的一些非核心操作挪出了事务,例如发送通知消息在事务提交后通过事件监听再做。只保留必要的数据库写操作,事务执行时间大幅缩短,连接释放速度也就快了。

对热点菜品,我把菜品列表和库存剩余做了 Redis 缓存,直接避免大量请求穿透到数据库。菜品详情接口的 TTL 设置为 5 分钟,菜品上下架时主动删除缓存,保证数据一致。压测之后最高吞吐量差不多提升了 3 倍,数据库负载也稳定了。

6.4 分布式事务回滚失效的排查记录

Seata AT 模式回滚失效是我排查过最头疼的一个问题。现象是:创建采购结算单的时候,供应商锁定应付金额成功,但财务服务生成应付记录时报错,按理说全局事务应把供应商锁定金额回滚掉,结果没有回滚。

最终排查下来原因有两个。第一个是全局事务的事务 ID 没有通过 OpenFeign 的请求头传递下去。因为 Seata 是靠RootContext中的XID来串联分支事务的,Feign 调用必须给定一个请求拦截器,把当前事务的 XID 塞进请求头。第二个是财务服务的数据源没有使用 Seata 的代理数据源,我需要显式在配置中使用 SeataDataSource 来包裹业务数据源。

修复之后我再做验证,用了一个整数类型的字段,先故意写入 100,再让后续分支抛异常,刷新数据库发现字段恢复到了操作前的值。说明 AT 模式的 undo_log 确实正常工作。这里提醒大家,排查 Seata 问题一定要开启 TC 的调试日志,日志里会详细打印每个分支事务的注册和提交回滚情况,比自己瞎猜高效得多。

7. 这个项目后续还能怎么扩展

按照我个人的经验,机关食堂后勤系统做完第一版之后,最自然的扩展方向是数据分析和设备联动。我们现在已经接入了明厨亮灶监控设备,下一步可以把每个档口的销售数据和监控视频联动起来,做一个“食品安全追溯”页面。点开某个可疑的售卖记录,可以同时回放对应时段的后厨操作视频。这个需求在其他餐饮系统里也比较普遍,技术上只需要完善视频切片的索引时间轴。

另一个值得做的方向是智能备餐预测。食堂最头疼的问题是菜做多了浪费,做少了不够吃。我们可以根据历史每日菜单的销售数据、季节、节假日、机关人员出勤率来预测第二天的备餐量。数据聚合服务现在已经在同步订单数据到数据仓库,后续接一个简单的统计模型,比如时间序列预测或者回归模型,就能实现这个功能。

如果需要把系统推广到多个食堂或其他单位,可以在现在的服务基础上增加一个“食堂租户”字段,把菜品、订单、库存数据按租户隔离。微服务架构天然适合这种扩展,只需要在网关层识别租户,并在每个服务的核心表中增加租户 ID,就能快速复制一整套智慧食堂系统给其他单位使用。

8. 写在最后的私人建议和个人体会

做这个项目给我最大的感受是,微服务不是银弹。如果团队本身还停留在“会用 SpringBoot 写接口”的阶段,一上来就拆十几个服务,最后大概率会因为运维复杂度和排查链路过长而崩溃。明智的做法是,先梳理清楚业务域,想清楚哪些模块真的需要独立部署,哪些模块聚合在一起反而更省事。像这个食堂系统,拆成七个服务对我来说是合理的,因为每个服务都对应了不同角色和业务生命周期,扩展和维护都清晰。

还有一条建议关于代码规范。微服务项目的时间消耗很大一部分在前后端接口联调上,所以接口文档直接落地成代码很重要。我给每个服务都配上了 Knife4j 接口文档,后端启动后自动生成文档页面,前端开发人员直接在页面上调试接口参数,大大减少了沟通成本。哪怕用户不多,这个投入也非常值得。

最后再说一句,如果要用一句话概括这套架构,那就是:业务模块垂直拆分,基础设施全部下沉,事务尽力最终一致,缓存锁保证核心链路。这个思路放在机关智慧食堂是成熟的,放在其他管理系统里也同样能打。希望这篇实操记录对你正在设计的系统有所启发。

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

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

立即咨询