ARTICLE DETAIL

资讯详情

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

SpringBoot+Vue+SpringCloud微服务实战:易物小店双向确认交易系统

SpringBoot+Vue+SpringCloud微服务实战:易物小店双向确认交易系统 接手“易物小店”时我一直在想一个问题物品交换系统和普通电商到底差在哪普通电商是单向的“你付钱我发货”换物却是双向的用户A看中用户B的键盘用户B想要的可能是A的耳机两个人同时点头一桩交易才算成立。这个“双向确认”的业务模型在系统实现上比买卖复杂得多。我最终选用的技术组合是SpringBoot Vue SpringCloud后端按业务拆成多个微服务前端用Vue做单页应用中间挂SpringCloud Gateway做统一网关。目前这套系统已经跑过几轮内测今天把拆解思路、核心代码和踩坑记录整理出来给正在做微服务项目、或者准备做闲置交换类产品的朋友一个参考。1. 项目定位与整体方案选型1.1 为什么非要上微服务很多人看到“易物小店”这种体量第一反应是单体应用完全够用。确实如果只是做MVP用SpringBoot单包加一个MySQL一个月就能上线。但我们在立项时拿到两个额外条件一是后续要支持多城市、多运营方独立部署二是团队想沉淀一套SpringCloud基础组件。这两个条件直接把单体的路堵死了。微服务真正的价值不是“代码拆得细”而是数据域隔离和独立伸缩。物品服务被某个运营方买到后可以做缓存优化交易服务因为换物流程复杂可以独立扩展用户服务遇到恶意注册时单独限流不会拖垮其他服务。哪怕项目体量不大只要这些运维能力在未来一两年内会出现就值得为拆分提前布局。当然也要泼一盆冷水如果你的项目只有管理员和普通用户两种角色业务边界模糊上线时间紧到以周计算不要盲目拆微服务。微服务带来的分布式事务、链路追踪、配置管理成本是硬成本团队没有经验和运维支撑反而会拖慢交付速度。1.2 技术栈选型的那些选择题这个项目最核心的几个选型我逐一说明都是实测后的取舍不是纯背书。组件备选方案最终选择原因注册中心 / 配置中心Eureka、Consul、NacosNacos注册与配置一体化中文文档全SpringCloud Alibaba体系成熟度最高网关Zuul、SpringCloud GatewayGateway基于WebFlux性能和生态都比Zuul好路由配置更灵活远程调用RestTemplate、OpenFeignOpenFeign声明式HTTP客户端接口定义与读写清晰团队上手成本低分布式事务Seata、本地消息表RocketMQ本地消息表RocketMQ换物核心链路需要最终一致性消息表方案可控性更强代码可审查前端基础React、Vue2Vue3 Element Plus后端团队好上手双人开发效率高Element Plus组件覆盖后台管理场景对象存储FastDFS、OSS、MinIOMinIO私有部署友好S3协议兼容前端直传凭证方案容易做SpringCloud版本选择也很关键。我用的SpringBoot 2.7.x SpringCloud 2021.0.x SpringCloud Alibaba 2021.0.5.0这套组合经过大量线上验证稳定。千万不要图新鲜直接上SpringBoot 3.x加SpringCloud 2023虽然也可以但依赖坑多社区问题的排查成本高。2. 易物小店核心业务与微服务拆分的边界2.1 业务模块怎么切“易物”这个场景最粗的边界是“人、物、交易、消息、评价”。我按这个逻辑把系统拆成5个服务用户服务user-service登录注册、账号安全、收货地址、用户积分、信誉分。物品服务item-service物品发布、图片上传、分类标签、物品状态管理、搜索过滤。交易服务trade-service换物请求发起、双方确认、换物订单生成、状态流转、补充差价记录。消息服务notify-service站内信、短信通知、微信服务号模板消息、换物状态变更通知。评价服务review-service交易完成后互相评价评价结果回写用户信誉分。这个拆分遵循一个原则每个服务只在自己数据库里改状态。物品服务不管订单交易服务不直接写用户表消息服务只消费其他服务发来的事件。比如换物确认这个动作交易服务调用物品服务接口预占物品成功后才在自己的订单表里插入订单再发一条Mq消息给消息服务由消息服务异步推送给对端用户。谁拥有数据谁才有权限修改这是微服务数据域隔离的底线。2.2 数据模型与接口边界示例拿“换物请求”这个核心链路来举例。物品表最关键的是状态字段我设计成枚举0上架可见1已被发起换物未确认2已确认并生成订单3已下架或删除注意这里没有“换物中”这种模糊状态只有可以被交易流程锁定的状态。发起换物请求时交易服务调用物品服务“预占”接口把物品状态由0改成1如果对方拒绝了再调用“释放”接口改回0如果双方确认最终把两个物品都改成2。这套流程的核心是状态机一致后续订单状态才不会错乱。交换订单表的核心字段大概是字段说明order_id订单号全局唯一建议后端生成带业务前缀from_user_id发起方用户to_user_id接收方用户from_item_id发起方出的物品to_item_id接收方出的物品status订单状态1 待对方确认2 已确认3 补充差价待支付4 履约中5 已完成6 已取消diff_amount双方物品价值差可为0created_at / updated_at时间戳接口设计上我坚持一个服务只暴露自己的领域接口路径前缀与调用方无关。比如用户服务/user/profile物品服务/item/{itemId}、/item/{itemId}/lock交易服务/trade/swap/request、/trade/swap/confirm、/trade/swap/reject坏味道是指纹级的如果某个接口里同时出现了其他服务的表字段基本说明边界错了。2.3 拆分时容易犯的错我在第一批代码评审里遇到了三个典型问题。第一个是循环调用。用户服务要显示用户发布过的物品于是它调用物品服务物品服务又要显示物品主人的昵称于是反向调用用户服务。两台服务互相等对方数据链路一长就超时。最终方案是需要在列表页展示的名字、头像这类低敏信息在物品表里冗余“owner_name快照”接口统一由前端调聚合网关由网关层并行聚合服务之间不互相调。如果担心数据不一致用户昵称修改后发事件物品服务做异步更新。第二个是共享数据库。刚开始为了让查询方便几个服务连接同一个MySQL微服务拆了跟没拆一样一个慢SQL照样把隔壁服务拖死。后来我用DTS把库拆开每个服务一个库物理隔离配合数据同步工具解决统计需求。第三个是“为了拆而拆”。原本把地址也拆成一个服务结果这个服务只有一张表调用量也低白白增加一次网络IO。后来把地址收敛回用户服务把按需查询的接口留在交易服务里。记住微服务是业务语义的拆分不是数据表按数量均分。3. Vue前端与后端交互的落地3.1 Vue工程结构和路由设计前端用的是Vue3 Vite Pinia Vue Router项目管理结构是这样的src/ ├── api/ # 封装各服务接口 │ ├── user.js │ ├── item.js │ └── trade.js ├── components/ # 通用组件 ├── layout/ # 页面框架 ├── router/ │ ├── index.js # 路由实例 │ └── routes.js # 静态路由 动态路由表 ├── store/ # Pinia └── views/ ├── home/ ├── item/ ├── trade/ └── user/路由设计上我把“登录页、物品大厅、物品详情”都放进静态路由把“我的换物、后台管理、个人中心”放进动态路由登录后根据后端返回的角色权限动态addRoute。这里有个小坑Vite的项目没有路由历史模式时要配historyApiFallback否则刷新页面会404。开发环境我用server.historyApiFallback: true解决。3.2 axios封装和网关交互前端所有请求只走网关网关卡住统一前缀。我配置了Nginx指向网关的/api路径网关路由根据服务名转发到对应服务。axios封装的逻辑很简单但很关键// api/request.js import axios from axios import { useUserStore } from /store/user import { ElMessage } from element-plus const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 15000 }) request.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { useUserStore().logout() location.href /login } else { ElMessage.error(error.response?.data?.message || 请求失败) } return Promise.reject(error) } )跨域问题在开发环境也恶心过。最稳的方式不是开启后端CORS而是前端Vite配置proxy把/api代理到本地网关地址。生产环境交给Nginx反代网关本身不开放跨域既让接口清晰又少一层风险。3.3 易物场景的前端交互难点换物和电商最大的不同在于“双向选择”。系统需要让A看到某个物品后再选择一个“我这边可以用来交换的物品”。这个交互在UI上是一个物品选择弹窗有分页、关键词搜索、物品状态筛选。要注意的是后端接口必须一次校验两个物品的状态不能先锁A再锁B否则A锁定了但B失败A就卡住。我们最终加了一个“组合校验”接口先同时校验再同时锁定失败时全部回滚。图片上传也是重点。我用的方案是MinIO但前端不直接传SecretKey而是后端生成预签名URL前端先调物品服务的POST /item/upload/presign传文件名和大小。后端返回一个带签名的上传地址以及最终可访问的下载地址。前端用fetch直接PUT到MinIO完成后再把下载地址提交给物品服务保存。这个流程避开了文件流经过业务服务器带宽压力小而且MinIO的凭据不会暴露给浏览器。内测时发现预签名URL默认7天过期但我们保存的是公开读的bucket下载地址所以展示不受影响如果业务要求私有读就需要后端代理下载接口复杂度会增加一档。4. 分布式环境避坑实录4.1 分布式事务两个物品的状态交换怎么保证换物确认那一步是分布式事务改动最大的地方。原来单体里一个Transactional就搞定拆成服务后必须换思路。我试过同步调用的方式交易服务先调物品服务锁A再调物品服务锁B都成功后再更新订单。逻辑上没问题但一旦第二步挂了前面锁住的A永远不会释放。后来我引入RocketMQ做本地消息表流程是这样的交易服务开启本地事务在订单表创建待确认订单同时往local_message消息表插入一条“物品锁定请求”消息然后提交本地事务。一个定时任务轮询local_message将未发送的消息投递到MQ topic投递成功后把消息状态改成“已发送”。物品服务消费该消息同时锁定A和B两个物品成功后回复消费成功如果锁定失败转入死信队列交易服务再做补偿。订单进入“待对方确认”状态后由定时任务确认消息消费结果最终完成事务。这个方案不是强一致但换物场景允许秒级延迟只要状态最终对上就行。代码上关键是本地消息表必须和业务表放在同一个数据库事务里否则消息发出去而订单没提交就白搞了。如果团队更愿意用现成组件Seata的AT模式也能做但要注意它对数据库资源占用偏高压力测试时需要预留连接数。4.2 分布式锁避免同一个物品被重复换走内测时出现过最严重的问题两个用户同时对同一个闲置耳机发起换物物品服务两次扣减都成功了。原因就是锁丢失。高并发下单纯依赖数据库乐观锁版本号虽然不会超卖但换物场景有“预占”动作需要更长的锁粒度。我是用Redisson的RLock做的锁的Key是item:lock:{itemId}锁内做“判断状态 修改状态”释放锁放在finally里Autowired private RedissonClient redissonClient; public boolean lockItem(Long itemId, Long requestId) { RLock lock redissonClient.getLock(item:lock: itemId); boolean tryLock false; try { // 等待时间1s持有时间5s防止死锁 tryLock lock.tryLock(1, 5, TimeUnit.SECONDS); if (!tryLock) { return false; } Item item itemMapper.selectById(itemId); if (item.getStatus() ! 0) { return false; } item.setStatus(1); item.setLockedByRequestId(requestId); itemMapper.updateById(item); return itemMapper.selectById(itemId).getLockedByRequestId() requestId; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (tryLock) { lock.unlock(); } } }有两个细节要注意第一锁必须覆盖“判断状态到修改状态”的全过程不能先查询再锁否则并发时判断都是0依然会重复。第二Redis哨兵模式下主节点宕机时锁可能丢失虽然概率小但换物交易涉及双方权益我最终把Redisson的锁模式改成MultiLock锁三个Redis节点成本大一点但稳了。4.3 分布式缓存、会话与数据一致性物品详情页是热点我用Redis做缓存Key是item:info:{id}缓存15分钟。写入缓存前要在业务代码里处理缓存穿透查询空结果也短暂缓存1分钟避免恶意请求打到数据库。会话管理我直接抛弃了传统的Session共享方案使用JWT 本地内存。用户登录成功后用户服务签发JWT网关解析JWT并校验签名服务之间不再互相查用户信息只信任网关转发头里的userId。这个方案和无状态架构很搭唯一的问题是用户被禁封后token还能用一段时间所以我额外维护了Redis黑名单在用户封禁时把token jti加入黑名单。有一点要特别提醒不要所有服务共享同一个Redis实例存业务缓存。物品服务和用户服务的数据访问频率差异大混在一起容易出现缓存击穿互相拖累。我用Redis Cluster做了逻辑分库物品缓存走slot0用户会话走slot1避免业务事故放大。4.4 网关、注册中心与配置管理的实战配置注册中心用了Nacos配置管理也用的Nacos。每个服务在bootstrap.yml里指定服务名、命名空间和Nacos地址然后通过RefreshScope配合Value动态刷新配置。改配置不用重启服务但如果配置中涉及数据源连接数这种需要重试的参数还是要写一个钩子重建连接池。网关路由配置是最容易踩坑的。我一开始用Path断言结果前端调/api/item/1时网关路由到物品服务的地址还是带/api前缀导致404。后来所有服务统一去掉上下文路径网关用StripPrefix1spring: cloud: gateway: routes: - id: item-service uri: lb://item-service predicates: - Path/api/item/** filters: - StripPrefix1另外Feign调用超时容易和网关超时打架。默认Feign是10秒但网关连接超时只有2秒一旦服务间有慢SQL前端报网关504服务日志里却是自己被正常处理了。我最终统一约定服务间同步调用超时不超过3秒超过的一律异步化换物确认这种重操作直接走MQ不在调用链里同步等。5. 项目落地实操记录5.1 IDEA搭建微服务多模块工程我用Maven做多模块父pom里用dependencyManagement管理SpringBoot和SpringCloud的版本子模块不再写版本号防止依赖冲突。工程目录exchange-mall/ ├── pom.xml ├── user-service/ │ ├── pom.xml │ └── src/main/java/com/exchange/user ├── item-service/ ├── trade-service/ ├── notify-service/ ├── review-service/ └── common/ ├── common-core/ # 公共返回体、异常处理 ├── common-redis/ ├── common-mq/ └── common-web/ # Web配置IDEA里先新建一个空Maven项目作为父工程再右键New Module创建各服务不要在Module里勾选Spring Initializr时初始化太高的Java版本要统一JDK版本。我们整个团队用的是Java8SpringBoot用的自带的不额外引入Lombok之外的工具Kotlin这种反而增加沟通成本。5.2 从零开始创建订单服务的实操过程拿订单服务trade-service来说我从骨架代码落地到跑通接口大概用了几次迭代在父pom里添加trade-service模块依赖common-web、common-mq、数据库驱动、OpenFeign等。写启动类TradeApplication标注EnableFeignClients设置包扫描路径。配置bootstrap.yml指定Nacos命名空间为trade。引入MySQL/Flyway做数据库自动迁移建表脚本放在src/main/resources/db/migration下启动时自动执行。编写下单接口TradeController接收换物请求DTO调用远端物品服务接口。用Postman先直接请求网关再走Feign内部调用最后压测接口。很多新手在第一步就会卡住开启EnableFeignClients时没有指定basePackages结果第三方接口包扫描不到。我建议每个服务模块内部的FeignClient写在common的统一包下启动类直接EnableFeignClients(basePackages com.exchange)简单粗暴有效。5.3 数据库初始化与数据一致性初始化拆库后每个服务一个库命名统一为exchange_user_db、exchange_item_db。不要在item库里建order表这条铁律我从第一天坚持到现在。初始化数据时我第一次犯了个错给物品服务插入了测试物品给用户服务插入测试用户结果物品表里的owner_id对应的是另一个库的id删数据时外键不一致烦得很。后来我把基础数据的初始化脚本放在一个独立的markdown文档里维护先插用户再插物品再单独跑一个“生成演示换物订单”的后端测试入口用接口直接构造完整链路数据比手工改SQL可靠得多。5.4 本地联调与部署本地联调是分布式项目最麻烦的事没有之一。我们的本地一套环境MySQL、Redis、MinIO、RocketMQ、Nacos全用Docker Compose起。各服务在IDEA里直接以localhost启动端口分配服务端口user-service8081item-service8082trade-service8083notify-service8084review-service8085gateway8080启动顺序有讲究先Nacos再MySQL/Redis/MinIO/MQ然后启动没有依赖的兜底服务最后启动网关。如果Feign调用时报连接拒绝第一步不是去看服务代码而是看目标服务有没有注册到Nacos我见过很多人隔着屏幕查半天最后发现目标服务没启动。部署阶段我用Dockerfile打镜像GitLab CI在merge request合并后自动构建并推送到镜像仓库测试环境用Docker Compose一键拉全部服务。到现在这个规模还没有必要上K8s等真正要做灰度发布和弹性伸缩时再迁移不迟。6. 常见问题与排查技巧实录整理一份问题速查表都是我在这个项目里真实遇到过的供大家直接对照。现象可能原因排查步骤解决办法服务注册不上NacosNacos地址配置错误或安全鉴权失败看启动日志nacos:8848地址能否连通确认namespace是否一致检查bootstrap.yml统一namespace和groupFeign调用超时超时时间太短或目标服务本身慢在目标服务里打印SQL耗时查看慢查询日志调大Feign超时但不能超过网关超时慢操作异步化Redis分布式锁死锁业务代码在持有锁期间抛异常没有走unlock确认finally是否释放Redisson看门狗是否续期失败使用try-finally释放开启Redisson看门狗前端请求跨域网关CORS未配置或Vite代理配置路径不对打开浏览器Network看请求URL和响应头前后端都用代理/反代避免双跨域配置网关路由404StripPrefix配置不合理服务路径重复在网关日志里看转发后的实际路径打印路由日志统一StripPrefix1数据库连接池耗尽服务间同步调用占用连接等待并发高看连接池状态跟踪慢查询增加连接池上限异步化重操作版本启动报错SpringBoot与SpringCloud版本不匹配检查Maven依赖树确认依赖版本统一用毕业版本清单并封装到父pom再分享一个排查技巧微服务环境千万不要直接看日志传送门。我习惯是先看网关访问日志再看目标服务调用链日志最后看数据库慢查询。没有链路追踪时我在公共的日志过滤器里给每个请求生成traceId并手动在Feign调用时传递traceId到下一个服务这样一条日志能串起三个服务定位问题快很多。还有一个很多人忽略的坑本地联调时我把多个服务都跑在IDEA里经常出现端口被占用导致启动失败。后来写了一个脚本启动前自动检测端口占用并杀掉旧进程顺便把Nacos上对应的旧实例标记为下线。配合使用效果显著省了一堆“看起来启动成功实际没注册”的假象。最后再提一句如果让我重新做一次这个项目我会在一开始就统一约定好“领域事件”的名称和消息体而不是边写边改。易物小店最复杂的地方是多方状态的最终一致性而事件是整个系统解耦的命脉。提前定义好“ItemLocked”、“TradeConfirmed”、“TradeFinished”这些消息后续加服务、加消费者都会顺畅很多。此外前端别急着把页面做漂亮先把换物流程的状态机走通把“发起、拒绝、确认、履约、完成”五个动作对应的接口全部打通再回头做交互优化。只要核心状态不错页面丑一点都能接受状态错乱页面再好看也是空中楼阁。以上便是这套系统从选型到落地的完整记录也希望无论是做微服务还是做换物业务的你都能少踩几个我踩过的坑。
返回列表