ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Spring Cloud Alibaba的超市进销存系统微服务架构设计与实践

基于Spring Cloud Alibaba的超市进销存系统微服务架构设计与实践 1. 超市进销存需求天然适合微服务拆解关键在于边界怎么画做了这么多年Java后端我最深的体会是微服务架构不是拿来炫技的而是业务复杂度逼出来的。像超市购物采购库存管理系统这种项目业务链路长、参与角色多、并发特征差异大从单体到微服务的演进几乎是必然。一个超市进销存系统从采购端看有供应商管理、采购订单、收货验收从门店运营看有商品档案、库存台账、日常盘点从销售端看有收银结算、线上购物车、订单履约。这几块业务听起来都围绕货转但它们的并发模型完全不同采购模块一天就几十单库存模块在盘点和出入库时压力集中销售模块在节假日高峰期可能一秒几百请求。硬塞进一个SpringBoot单体应用里最直接的后果就是销售高峰时整个系统响应变慢连后台补货员录入采购单都卡。所以我给这套系统的第一个建议就是按业务能力域拆分而不是按三层架构拆分。最典型的拆分维度是围绕商品这个核心实体把产生的不同业务行为划分到不同服务里微服务职责边界核心数据表并发特征商品服务商品档案、分类、品牌、价格策略product_info, category读多写少采购服务采购计划、供应商、采购订单supplier, purchase_order, purchase_item低频写密集库存服务库存台账、出入库流水、盘点、预警stock_info, stock_log, stock_alert高频写密集订单服务购物车、销售订单、支付回调cart, sales_order, order_item高并发读写用户服务会员、员工账号、权限角色member, sys_user, sys_role读多写少报表服务采购分析、销售分析、库存周转基于汇总表或ES定时聚集分析有人会问为什么商品服务不顺便管库存这就涉及一个高频踩坑点商品是基础数据库存是状态数据两者的更新频率和一致性要求完全不同。商品改了价格不能影响库存的锁定和扣减库存扣减时也不需要每次带上商品的全部信息。拆开之后服务自治能力更强也避免了一个服务的数据库锁影响另一个服务。还要控制拆分粒度。我见过一些项目把供应商拆成一个独立服务甚至把购物车拆成独立服务结果服务间为了查一条关联数据要来回调用三次事务一致性还难保障。拆服务的黄金法则是一个服务内的表能够独立支撑一个完整的业务事件。比如采购服务要有供应商表、采购单表、采购明细表这样创建采购单——通知审核——生成入库预期这个事件才能闭环。2. 技术选型定位SpringCloud组件在系统里各管哪一段微服务不是SpringCloud一个框架搞定所有事情而是一组组件各司其职。这套系统涉及注册中心、网关、远程调用、配置管理、熔断限流、分布式事务等多个环节。下面是我在项目里实际采用的组件搭配及理由。2.1 注册中心为什么选Nacos而不是Eureka说到底Eureka 2.x已经停止维护了而Nacos自带服务发现和配置管理双能力更适合国内团队。Spring Cloud Alibaba生态里Nacos是最活跃的组件之一。在超市这个项目里Nacos承担两个角色。第一是服务注册与发现商品服务、库存服务、订单服务启动后自动注册到Nacos订单服务要调用库存服务时通过服务名(如stock-service)就能拿到可用的实例地址列表。第二是配置管理数据源配置、Redis连接参数、业务开关比如库存预警阈值统一放在Nacos配置中心修改后实时推送不用重启服务。配置Nacos的可用性很重要。最好是独立部署三节点集群超市系统规模不大也可以单机加持久化MySQL存储配置但千万别用默认的内嵌Derby存储重启丢配置这类坑会让你排查到崩溃。2.2 网关层Spring Cloud Gateway的路由和过滤设计所有的前端请求无论是Vue页面发起的/api/product/page还是/api/order/create统一经过网关转发。网关层要做的三件事路由转发根据路径前缀将请求分发到具体的微服务如/api/auth/**转发到用户服务/api/stock/**转发到库存服务。鉴权过滤JWT Token的解析和校验在网关统一做避免每个微服务都写一遍鉴权逻辑。我习惯用GlobalFilter实现Token通过Redis或本地缓存校验后将userId放入请求头X-User-Id下游服务直接取用。跨域处理Vue项目开发环境跑在8080端口网关跑在9000端口如果不统一处理CORS前端联调时浏览器会拦截响应。在网关层配置CORS过滤器是最干净的做法。要提醒的是网关不要写任何业务逻辑。我就见过有人把购物车的价格计算放到网关里做理由是不想改订单服务。这就是典型的坏味道网关只做路透转发和横切逻辑业务进化必须回到具体服务去实现。2.3 OpenFeign实现服务间调用以及真正容易被忽略的超时与容错服务之间调用最常见的是订单服务扣减库存代码大致是FeignClient(name stock-service, fallbackFactory StockClientFallbackFactory.class) public interface StockClient { PostMapping(/api/stock/deduct) ResultBoolean deduct(RequestBody StockDeductDTO dto); }OpenFeign的声明式写法很方便但有几个参数一定要配置到位否则生产环境会出鬼feign: client: config: default: connectTimeout: 3000 readTimeout: 5000 circuitbreaker: enabled: true # 配合Sentinel做熔断降级默认的OpenFeign超时往往是60秒如果库存服务因为数据库锁等原因慢下来订单服务会一路挂起把线程池打满。设置合理的超时时间3~5秒加上Sentinel熔断把故障隔离在源头是微服务落地的基本功。2.4 为什么需要Sentinel而不是自己写限流超市系统有一个典型场景周末促销开始时瞬时并发全部打在订单服务和库存服务上。这时候如果库存服务扛不住不能让它直接抛异常影响前端下单而是快速失败并提示目前抢购人数较多。Sentinel给每个资源接口配置QPS阈值和降级规则后超限请求会直接走降级逻辑。配合Nacos做规则持久化调整限流阈值不用改代码。3. 一条采购入库链路如何串联起多个微服务协作理解了组件我们走一遍真实的业务链路。采购入库是超市系统最核心的流程它跨了采购、库存、用户三个服务是理解微服务交互的最佳案例。3.1 从创建采购单到完成入库的完整时序操作员在前端Vue页面点击新增采购单填入供应商、选择商品、填写采购数量和预期到货日期。此时前端不是直接调用某一个后端接口而是调用网关转发的采购服务POST /api/purchase/order。采购服务拿到请求后做这几件事根据商品编码调用商品服务获取商品的最新名称、规格和含税进价避免操作员手工录入价格产生误差。校验供应商状态确认该供应商是合作中状态。写入采购单主表和明细表状态为待审核。如果配置了审批流向审核人发送待办通知。审核人登录后台看到待审核列表点击审核通过。这个步骤下发一条MQ消息到order.audit.success主题库存服务监听该消息做预占库存处理——在库存台账里增加一条预期入库记录。为什么要加预期入库因为超市采购是按计划做的仓库需要根据预期到货量提前安排货架和库位。整个过程中库存服务是异步监听消息不参与审核接口的同步调用链这样即便库存服务短暂不可用审核操作也可以完成后续重试消费消息即可。真正入库时仓管员根据到货商品扫码调用库存服务的POST /api/stock/warehousing接口传入采购单号和实际到货数量。库存服务执行两件事更新库存台账增加可用库存记录一条入库流水。同时通知采购服务更新采购单状态为已完成采购单明细中的已到货数量同步增加。3.2 服务间数据一致的常见问题什么时候用同步什么时候用异步这条采购链路里审核后的预占库存用的是异步消息而入库时用的是同步接口。判断依据是业务对一致性和实时性的要求。同步调用入库动作必须立刻更新库存台账操作员要看到最新的可用库存此时同步调用最合适。同时可以用本地事务保证stocks表和stock_log表的一致性。异步消息审核通过后的预占动作允许有秒级延迟且不影响审核结果的返回此时用MQ把耦合解开即便库存服务挂掉消息积压后还可以重新消费系统的韧性更强。这是单体应用里感受不到的权衡。在单体中一个事务里想把采购单状态、库存台账、流水一起更新是很自然的事但在微服务里没有全局事务兜底必须在设计时就想清楚每个环节的一致性是强还是弱。3.3 分页查询优化采购单列表为什么不能直接join查询传统单体项目里列表页最常见的就是JOIN查询采购单表关联供应商表、关联商品表。微服务拆分后服务之间数据库是隔离的不能直接跨库JOIN。实际做法是接口聚合。采购服务查询出采购单列表后拿到供应商ID和商品ID集合再分别调用用户服务的批量查询供应商接口、商品服务的批量查询商品接口在服务层完成数据聚合。代码如下public PurchaseOrderVO convert(PurchaseOrder order, ListSupplierDTO suppliers, MapString, ProductDTO productMap) { PurchaseOrderVO vo new PurchaseOrderVO(); vo.setId(order.getId()); vo.setSupplierName(suppliers.stream() .filter(s - s.getId().equals(order.getSupplierId())) .findFirst() .map(SupplierDTO::getName) .orElse(-)); // 商品信息从productMap中取 return vo; }为了减少多次调用带来的性能损耗商品接口要支持批量查询如GET /api/product/batch?ids1,2,3返回MapLong, ProductDTO结构一次调用拿到所有需要的商品数据。开发时还要在接口层加Cacheable缓存商品基础信息在Redis里缓存15分钟热点数据的查询压力会小很多。4. 库存扣减的并发控制分布式锁、乐观锁、幂等设计的落地细节超市系统的库存扣减是并发压力最集中的环节也是最容易出线上事故的地方。这个系统里有两个典型的库存扣减场景销售下单扣减和采购入库增加库存。人一多并发扣减就会出现严重的超卖问题——订单产生的数量超过真实库存。4.1 PSS为什么会出现超卖以及三种主流方案的取舍数据库表结构通常是这样的CREATE TABLE stock_info ( id BIGINT PRIMARY KEY, product_id BIGINT NOT NULL, available_stock INT NOT NULL, locked_stock INT NOT NULL DEFAULT 0, version INT DEFAULT 0 );问题代码往往是新手最容易犯的错误// 错误示范先查再改 Stock stock stockMapper.selectByProductId(productId); if (stock.getAvailableStock() quantity) { stock.setAvailableStock(stock.getAvailableStock() - quantity); stockMapper.updateById(stock); }这是典型的check-then-act模式。两个并发请求同时读到可用库存为100都判断可以扣减都更新为95最终实际卖了200件但库存只扣了100超卖出现。解决超卖有几种主流方案方案原理优点缺点适用场景数据库乐观锁UPDATE stock SET available_stock available_stock - #{qty}, version version 1 WHERE id #{id} AND available_stock #{qty}简单可靠无锁竞争失败需重试并发高时失败率高低价竞争激烈的热门商品悲观锁SELECT ... FOR UPDATE强一致实现简单锁等待吞吐低死锁风险后台低频操作如盘点Redis分布式锁先获取锁再执行本地事务支持跨服务互斥需维护锁的粒度与过期机制多个微服务同时操作同一库存的兜底我在这个项目里最终的组合方案是数据库条件更新为主Redis分布式锁兜底。数据库条件更新的SQL如下Update(UPDATE stock_info SET available_stock available_stock - #{quantity}, locked_stock locked_stock #{lockedQuantity}, version version 1 WHERE product_id #{productId} AND available_stock #{quantity}) int deductStock(Param(productId) Long productId, Param(quantity) Integer quantity, Param(lockedQuantity) Integer lockedQuantity);这一条SQL利用数据库行锁在同一个语句里完成了条件判断和值更新无论多少个并发请求过来数据库层面只会让一个成功其余返回影响行数为0。配合乐观锁的version字段做兜底避免并发下的流失更新是最高效的方案。4.2 Redis分布式锁的正确写法与常见死锁问题有些场景下库存服务需要先查询商品缓存、预热数据、或跨多个服务协调前面的条件更新就不好使了。比如促销活动秒杀时我们需要在订单服务入口先锁住这个商品的扣减资格防止下游库存服务在处理超时后仍重试扣减。这时候要用到Redis分布式锁。我使用的是Redisson框架它封装了完整且可靠的锁实现RLock lock redissonClient.getLock(stock:lock: productId); boolean isLocked false; try { // 尝试3秒内获取锁 isLocked lock.tryLock(3, TimeUnit.SECONDS); if (!isLocked) { throw new BizException(系统繁忙请稍后再试); } // 执行库存扣减业务 } finally { if (isLocked) { lock.unlock(); } }Redisson的锁有一个明显的优势它默认实现了看门狗机制如果业务执行时间超过了锁的过期时间默认30秒后台会不断自动续期避免锁被自动释放导致其他线程进入同时又保证了业务执行完毕时必须释放锁。新手常见的两个坑锁的粒度粒度不当。对整个库存服务加锁所有商品的扣减串行化性能急剧下降。正确做法是只对当前操作的product_id加锁不同商品互不干扰。锁的误删。如果使用Redis的SET key value NX PX手工实现分布式锁获取锁的线程A执行超时后锁自动释放线程B拿到锁此时A执行完毕调用DEL key会把B的锁错误删除。解决办法是删除前校验value是否为当前线程生成的一个唯一标识。Redisson已经内置处理所以尽量用框架而不是手写。4.3 幂等设计防止重复下单和重复扣减另一个并发场景的重灾区是用户重复点击提交订单按钮。前端虽然做了按钮防抖但网络重试、客户端异常重连等情况仍会导致相同下单请求到达后端两次。方案是请求唯一键。前端在用户点击提交时生成一个UUID作为requestId后端在订单服务中先查Redisboolean first redisTemplate.opsForValue() .setIfAbsent(order:request: requestId, 1, 10, TimeUnit.MINUTES); if (!first) { // 重复请求直接返回上一次的结果 return Result.success(existingOrderVO); }用Redis的SETNX特性保证同一requestId只有一次能进入下单流程。后续的库存扣减也要配合幂等标识比如stock:deduct:log:orderId记录对应的扣减流水重复扣减时直接返回已扣减结果。5. 跨服务的分布式事务下单扣库存防超卖的数据一致性保障库存扣减和订单创建一旦分布在两个服务里就引出了微服务最头疼的问题——分布式事务。5.1 一个典型的跨服务事务场景产单与扣库存的原子性下单场景涉及订单服务和库存服务典型的流程是订单服务创建订单状态为待支付。订单服务调用库存服务的扣减接口。扣减成功返回下单成功扣减失败订单要回滚为已取消。前面两条操作处于两个独立的服务、各自的数据库中。如果订单服务创建完订单后在调用库存服务时网络超时就会出现订单创建了但库存没扣或库存扣了但订单状态还是待支付的不一致。网络超时还带来一个库存扣减到底成没成功的语义问题。OpenFeign调用超时客户端拿不到响应但服务端可能已经完成扣减。处理这个问题的关键是把扣减接口设计成幂等的并且每次调用都带有唯一的deductNo扣减流水号超时后通过查询接口确认状态。5.2 用Seata的AT模式保证强一致以及它的适用边界对于强一致要求的场景如订单创建时同步扣减库存我选用国产分布式事务框架SeataAT模式是其最常用的模式。AT模式的核心机制是两阶段提交2PC但相比传统方案做了优化。它拦截业务SQL在事务分支执行前将数据快照beforeImage和操作后的数据afterImage写入undo_log表。全局事务管理器TC协调各分支事务的提交或回滚。如果某个分支失败TC通知已提交的分支逆序执行回滚利用undo_log恢复原始数据。业务代码的接入非常轻量只需要在全局事务发起方的入口方法加注解GlobalTransactional(rollbackFor Exception.class) public OrderCreateResult createOrder(OrderCreateDTO dto) { // 1. 订单服务创建订单 // 2. 调用库存服务扣减库存库存服务内部是本地事务Transactional }但我不建议把所有的跨服务场景都交给Seata。AT模式引入全局锁在数据竞争激烈的商品秒杀场景下会拖慢性能。所以在超市系统里的做法是下单扣减库存利用Seata补偿保证最终一致但允许短暂的不一致窗口。采购审核后的预占库存直接用MQ异步消息采购服务审核提交后发消息库存服务消费消息预处理。如果处理失败MQ重试机制兜底。5.3 终极补偿方案消息事务的落地方案如果项目不想引入Seata这样重的框架另一种经典的可靠消息最终一致性方案是本地消息表。下单时在订单服务本地事务中执行插入订单记录。插入一条order_message本地消息表中的待发送记录status0。事务提交后异步任务定时扫描消息表将待发送记录发送到MQ。库存服务消费到MQ消息后执行扣减扣减成功后回调订单服务更新消息状态为已发送status1。如果回调失败定时任务会重新发送库存服务通过消息幂等处理避免重复扣减。这个方案不依赖Seata架构简单可控适合中小规模项目。缺点是需要自己写消息表的定时任务和重试逻辑开发工作量会多一些。6. 前端Vue与Spring Cloud Gateway的对接从登录态传递到接口调用SpringBoot后端搭建好了前端的工程化效果直接决定整个系统的协作效率。在这个超市系统里我采用的是Vue 3 Element Plus Vite Pinia的技术栈与Spring Cloud生态对接顺畅。6.1 前端工程结构和环境变量配置前端项目我按模块划分目录src/ api/ # 接口定义按微服务模块拆分 product.js purchase.js stock.js order.js user.js router/ # 路由表含动态路由逻辑 store/ # Pinia状态仓库 views/ # 页面组件 product/ purchase/ stock/ order/ dashboard/ utils/ request.js # axios封装 auth.js # Token存取环境配置是重点。开发环境调用网关接口通常配置为// .env.development VITE_API_BASE_URL http://localhost:9000/api生产环境如果是前后端分离部署网关暴露为https://xxx.yourdomain.com/api如果希望简化部署也可以把Vue打包后的dist目录塞进Spring Boot的static目录里由后端统一托管但这样就绕开了网关对前端静态资源的处理我只建议在开发联调或对内演示时这么干。6.2 axios拦截器Token注入和401处理的关键细节axios封装里请求拦截器负责从localStorage取Token并加入Headerservice.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config })网关的GlobalFilter会解析JWT校验通过后把userId写在请求头往下传。这样下游微服务不需要再解析Token减少重复验签的开销。响应拦截器要处理几个特殊状态码service.interceptors.response.use( response { const res response.data if (res.code 401) { // Token过期清除本地登录态跳转登录页 localStorage.removeItem(token) router.push({ path: /login, query: { redirect: currentRoute } }) return Promise.reject(new Error(未登录或登录已过期)) } if (res.code ! 200) { ElMessage.error(res.message || 系统异常) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 502) { ElMessage.error(服务暂不可用请稍后重试) } return Promise.reject(error) } )这里要特别提示一个细节后端接口返回结构必须统一。我采用的是ResultT标准结构{ code: 200, message: success, data: { ... } }如果有些老接口返回的data直接是字符串有些直接是对象前端响应拦截器会写得非常痛苦。所以在项目启动时团队必须约定所有微服务返回结构从底层基类继承这也是微服务开发的一致性原则之一。6.3 动态路由与权限控制超市系统的用户角色有超级管理员、采购经理、仓管员、收银员等不同角色看到的菜单和可操作的页面不同。后端在用户登录成功后根据用户的角色权限返回菜单路由表前端利用Vue Router的addRoute动态注册这些路由。核心思路登录成功后调用/api/auth/getUserInfo获取用户信息和权限标识列表。前端持有权限标识在路由守卫中判断meta.permission是否包含。菜单组件根据路由表递归生成侧边栏。动态路由有一种常见的坑刷新页面时Vuex/Pinia里的路由表会丢失导致刷新后404。解决方法是把用户信息和权限路由表缓存在localStorage或Pinia配合持久化插件刷新时重新读取并恢复路由。7. 上线前必须处理的硬伤版本兼容、超时配置、缓存一致性一套微服务系统的细节决定了它是能稳定跑在超市门店还是每天被店长打电话投诉。这里分享几个我实际踩过的坑都是文档不太会强调但线上必现的问题。7.1 Spring Cloud Alibaba各组件的版本对应关系新手在做这套系统时最抓狂的是框架版本问题。Spring Cloud、Spring Cloud Alibaba、Spring Boot三者的版本必须严格对应否则启动就报各种ClassNotFoundException或NoSuchMethodError。以我自己的稳定组合为例技术栈版本号Spring Boot2.7.18Spring Cloud2021.0.8Spring Cloud Alibaba2021.0.5.0Nacos Server2.2.3Sentinel1.8.6Seata1.7.0这套配合很稳定网上资料也多。建议不要追新版本2023年以后的Spring Boot 3.x对JDK 17和Jakarta命名空间有要求老项目迁移成本极高。刚开始做项目选一套成熟的版本组合比追新更重要。7.2 服务调用超时和线程池隔离OpenFeign超时时间、Sentinel的规则阈值、Tomcat/Undertow的线程池大小这些配置必须提前规划。在超市促销日如果订单服务没有对库存服务的调用做线程池隔离库存服务一旦变慢会迅速拖垮订单服务的整个线程池引发级联故障。Sentinel的SentinelResource配合熔断降级在慢调用比例或异常比例达到阈值时直接降级返回而不等待。我这里给出一个适合中小项目的参考配置spring: cloud: sentinel: transport: dashboard: localhost:8080 datasource: flow: nacos: server-addr: localhost:8848 dataId: sentinel-flow-rules rule-type: flow feign: circuitbreaker: enabled: true降级逻辑要友好比如返回当前购买人数过多请稍后重试而不是直接报500。7.3 缓存与数据库的一致性库存数据不能只读Redis超市系统里商品详情和库存数量通常先查Redis缓存。但缓存和数据库的一致性问题处理不好就会出现前台显示有货下单却说库存不足的尴尬。我采用的策略是Cache Aside Pattern旁路缓存 延迟双删读请求先查Redis命中直接返回。写请求先更新数据库再删除Redis对应的缓存。删除缓存后在短暂时间窗口比如500ms延迟再次删除一次防止并发读请求把旧数据回填到缓存。由于库存扣减本身在数据库层面是有条件的原子操作库存服务可以不用频繁更新缓存而是让缓存在热点商品上失效重载避免缓存雪崩。商品服务里的商品基础信息缓存时间可以设长一点15~30分钟库存数量缓存时间设短30~60秒。最后分享一个我在这个项目落地过程中的真实体会微服务技术栈本身不是难点难点在于把要拆分想明白再把拆完怎么协作设计清楚。超市购物采购库存系统因为业务链路完整、角色清晰、并发特征鲜明非常适合用来完整走一遍微服务架构的实战流程。如果你正在做类似系统建议从商品、采购、库存、订单这四个服务起步先把网关、Nacos、OpenFeign这一条最短的链路跑通再逐步扩展缓存、分布式锁和分布式事务你会发现微服务架构的学习曲线并没有想象中那么陡只要业务边界切对了后面的工作就是按部就班地补充组件。
返回列表