前段时间帮朋友做一个易物小店的物品交换系统简单说就是大家把闲置物品放上来看对眼了直接申请交换而不是买卖。这类系统最容易被低估表面看是“发布物品 聊天工具”但真要把并发、状态一致性、图片视频存储都理顺踩的坑一点不比电商少。我最终选择了 SpringBoot Vue SpringCloud 的微服务方案把系统拆成网关、用户、物品、交换、消息、评价六个服务前端用 Vue 3 Vite中间件用 Nacos、Redis、RabbitMQ、MinIO。这篇文章把我从架构选型、核心实现到分布式锁/事务、前端联调、上线排障的过程整理出来代码不会贴全但关键设计思路和可复制的步骤都会展开适合正在用 SpringCloud 做微服务项目、或者想自己搭一套交换类后端的朋友参考。1. 先想明白易物小店为什么需要微服务1.1 从业务场景反推架构需求很多人一听到“微服务”就先堆服务结果服务拆了一堆业务却跑不通。我做这个项目时是先画业务流程图再倒推架构。易物小店和电商最大的区别是没有支付环节但多了一个“双向履约”的动作用户A发布一个闲置的蓝牙音箱用户B看到了很喜欢于是发起交换请求A可以同意也可以拒绝同意之后两个人需要线下或通过平台确认完成。这个过程中没有钱款流转但有物品状态变化、交换单状态变化、积分冻结、消息通知。任何一个环节出现并发冲突都会出现两个人同时换到同一个物品的情况。这种需求如果用传统单体初期确实能跑但等到要支持多人同时申请热门物品、要独立扩展消息推送服务、要让不同团队分别迭代物品和交易模块时耦合会越来越严重。所以我把系统按业务域切成了六个服务用户、物品、交换、消息、评价外加一个网关。每个服务有自己的数据库表服务之间通过 OpenFeign 或者异步事件通信。这样做的好处是物品服务的发布逻辑不会因为交换状态变复杂而被拖垮交换服务出了问题也不会把用户登录拖挂。需要坦白说如果你只是做一个 demo单体完全够用。微服务的成本在于部署、监控、分布式事务、版本兼容这些额外负担。但如果目标是一个可以长期迭代、多人协作的中型平台微服务的边界划分越早做越好。易物小店这种业务“物品”和“交换”是核心域“消息”和“评价”是支撑域按这个维度拆边界比较干净不会出现服务之间循环调用。1.2 服务拆分到底怎么切拆分服务不是“多就是好”而是要看数据边界和变更频率。我最后确定的服务列表如下服务名核心职责关键表端口gateway-service路由、鉴权、限流无8888user-service用户注册登录、积分账户、个人资料user, user_account9001item-service物品发布、物品列表、物品状态、图片附件item, item_image9002exchange-service交换单创建、状态流转、交换履约exchange_apply, exchange_confirm9003message-service站内信、通知、简单聊天message, notification9004review-service双方互评、信用分review9005每个服务独立连自己的数据库我实际用的都是 MySQL但物理库分开。物品服务只关心物品本身不需要知道交换单长什么样交换服务通过 Feign 调用物品服务获取物品快照、调用用户服务校验账号状态。服务之间用 DTO 传递数据禁止直接传实体对象否则一旦表结构变化所有下游都得跟着改。拆分时最容易犯的错是把“用户上传的头像图片”和“物品图片”混在一个附件服务里。虽然表面上都是文件但前者属于用户域后者属于物品域。我一开始统一放 MinIO 里路径上分目录看起来没差但到后面权限控制时才发现物品图片要按物品状态做私有访问用户头像可以公开读混在一起权限规则很难写。后来干脆在 file 处理上只做一层存储抽象业务服务各自生成预签名 URL比较干净。1.3 技术栈为什么选中这套技术选型很大程度上决定你后面要踩多少坑。我用的是 SpringBoot 2.7 SpringCloud 2021.0.x SpringCloud Alibaba 2021.0.5.0。注册中心和配置中心用 Nacos网关用 Spring Cloud Gateway服务间调用用 OpenFeign分布式锁用 Redisson分布式事务用 Seata缓存用 Redis消息用 RabbitMQ对象存储用 MinIO。前端是 Vue 3 Vite Pinia Element Plus。这套组合最大的好处是有大量现成的微服务脚手架可以参考尤其是若依微服务 plus 这类开源项目已经把用户、菜单、权限、定时任务都做好了拿来做底座能省很多事。SpringBoot 提供业务开发效率SpringCloud 负责分布式能力。没有选 Dubbo 是因为 Dubbo 更偏 RPC 通信服务治理和网关配套还得另搭SpringCloud Alibaba 对中文社区更友好Nacos 控制台清晰Seata 做分布式事务也很成熟。前端选 Vue 而不是 React一方面团队熟悉另一方面 Vue 3 的组合式 API 写中后台非常顺手。易物小店的管理员后台、C 端 H5、卖家端小程序这三类界面用 Vue 生态可以复用大量逻辑。Vite 开发热更新比 Webpack 快很多配合 Axios 封装和动态路由前后端联调效率很高。2. 后端核心模块与关键实现2.1 用户服务与统一认证登录用户服务是基础服务所有接口都要知道当前操作人是谁。我采用 JWT 网关统一鉴权用户登录成功后user-service 签发 token前端把 token 存到本地请求时放在 Authorization 头里。网关解析 token把 userId 放到请求头转发给下游服务。这样业务服务不需要重复解析 JWT逻辑只收敛在网关层。密码存储必须用 BCrypt不要用 MD5 或者 SHA 明文盐。即便数据库泄露BCrypt 的慢哈希也能拖延破解时间。注册时还要做邮箱或手机号唯一校验否则一个用户开多个小号去“刷交换”。积分账户单独建表用户注册时初始化一个可用积分账户。交换物品不一定是等值交换双方可以约定用积分补差所以冻结、扣减、退还积分的操作要有流水记录方便对账。JWT 密钥要放到 Nacos 配置中心并支持动态刷新。如果你把密钥写死在每个服务里轮换密钥时要改一堆服务然后重新发布。我最初就是写死的后来为了安全要换密钥结果还漏了一个服务导致一段时间客户端拿到新 token 但网关不认排查了半天才定位到。这个坑强烈建议避开。2.2 物品服务发布、图片与视频存储物品表设计上要预留扩展字段但不要过度。核心字段包括id、user_id、title、description、category、condition_level、expected_exchange、status、created_at、updated_at。物品状态我用 AVAILABLE、HOLD、EXCHANGED、OFF。AVAILABLE 表示可被交换HOLD 表示交换单创建后暂扣EXCHANGED 表示已完成OFF 表示用户主动下架。只有 AVAILABLE 状态能被发起交换这个校验在接口层和数据库状态流转中都要做。图片和视频存储用 MinIO而不是直接丢到服务器磁盘。把 MinIO 整合进 SpringBoot 很简单先配置连接信息minio: endpoint: http://localhost:9000 access-key: minioadmin secret-key: minioadmin bucket: swap-item-images上传时生成对象名我的策略是/物品ID/UUID.扩展名避免中文名和重复名。这里要注意不要把整个文件读进内存再传给 MinIO要走流式上传否则大文件会频繁 OOM。图片上传后顺手用 Thumbnailator 压缩一下生成一个 webp 或缩略图列表页用缩略图详情页用原图。聊到视频易物系统初期不建议直接支持视频上传成本很高。如果真有 m3u8 需求做法是先转码成 HLS 切片再把 .m3u8 和 .ts 文件传到 MinIO前端用 hls.js 播放。MinIO 本身不关心文件格式只关心对象存储和访问权限。Bucket 权限要设成私有后端生成预签名 URL 给前端访问这样能避免硬编码 URL 导致的东西被盗链。2.3 交换服务状态机是核心交换服务是整个系统的核心最容易乱的地方就是状态机。我把交换单状态定义为状态含义可流转到INITIATED申请方已发起等待物品方处理ACCEPTED, REJECTED, CANCELLEDACCEPTED物品方已同意等待双方确认CONFIRMED, CANCELLEDCONFIRMED双方确认交换COMPLETED, REPORTEDCOMPLETED交换完成终态REJECTED物品方拒绝终态CANCELLED超时自动取消或手动取消终态发起交换的接口是POST /exchange/apply入参只需要 itemId 和备注。服务内部要做几件事检查物品状态为 AVAILABLE检查申请人不是物品发布人检查用户账号状态然后创建 exchange_apply 记录再通过 Feign 调用物品服务把物品状态改成 HOLD。这里涉及两个服务的数据变更后面会专门说分布式事务。交换确认流程不能用前端按钮状态控制后端每次状态流转都必须校验当前状态是否允许迁移。比如一个交换单已经被 REJECTED攻击者直接调 confirm 接口想把状态改成 COMPLETED接口必须拒绝。我加了一张状态流转历史表记录每次变更的操作人和时间一方面方便审计另一方面定位线上 bug 时有日志可查。2.4 消息服务与站内信解耦交换流程中会产生很多通知物品被申请时通知发布人交换成功时通知双方超时取消时通知申请人。如果这些通知都放在交换服务里同步发送一旦消息服务卡顿或 RabbitMQ 阻塞交换接口就会变慢。所以我用异步事件解耦交换服务只负责发送 MQ 消息message-service 消费后写库并推送 WebSocket。事件消息体不要太大只需要包含事件类型、相关业务 ID、接收人列表。比如“交换申请创建”事件消息体就是{type: EXCHANGE_APPLY, exchangeId: 123, ownerId: 9, applicantId: 10}。message-service 拿到后组装站内信文案保存到 notification 表再通过 WebSocket 推送给在线用户。用户不在线也没关系下次登录拉取未读列表即可。如果后续要做实时聊天最简单的方案是 WebSocket Redis Pub/Sub。单机可以直接用一个 WebSocket 服务集群时要通过 Redis 广播消息让不同节点的连接都能收到。这个项目前期我直接用轮询接口每 10 秒拉一次未读数量上线后觉得体验一般才换成 WebSocket。所以这个模块可以先做轮询保留升级接口。3. 分布式场景下的三个坑锁、事务、缓存3.1 用分布式锁解决“重复发起交换”易物系统虽然没有秒杀但热门物品被多人同时申请时并发压力不比秒杀小。前端虽然会在点击“立即交换”后把按钮置灰但用户刷新页面、多端操作、脚本刷接口都可能让后端收到多次请求。单体项目里可以直接用synchronized锁但微服务多实例下每个实例的锁是独立的连点两个请求打到不同实例照样会通过校验。我在申请接口上用了 Redisson 分布式锁。锁的 key 分两级考虑一个物品同时只能被一个交换单申请所以先用物品维度锁防止两个不同用户同时申请同一件物品再用用户物品维度锁防止同一个用户重复申请。物品维度锁代码大致这样RLock lock redissonClient.getLock(exchange:item: itemId); try { boolean tryLock lock.tryLock(3, 30, TimeUnit.SECONDS); if (!tryLock) { throw new BusinessException(当前申请人数过多请稍后重试); } // 重新查询物品状态若已是 HOLD 则拒绝 // 创建交换单调用物品服务改为 HOLD } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这里有两个容易踩的坑。第一个是释放锁时一定要判断当前线程是否持有锁否则可能把别人的锁给释放了。第二个是锁过期时间不能太短。我一开始把 leaseTime 设成 5 秒结果业务里为了查库存、发消息、调 Feign偶尔超过 5 秒锁自动释放后另一个线程进来了导致重复申请。现在要么把 leaseTime 设得足够长并加监控要么用 Redisson 看门狗自动续期。如果业务逻辑简单推荐 tryLock 按场景设置 10~30 秒宁可等待期短一点也不要锁内事务过长。3.2 分布式事务把交换流程变成最终一致创建交换单涉及用户服务、物品服务、交换服务三处数据用户服务要冻结积分物品服务要把物品改成 HOLD交换服务要插入 exchange_apply。如果其中一个成功、一个失败数据就乱了。比如物品变成 HOLD但交换单没建出来那物品就永远没法再被交换或者交换单建了但用户积分没冻结后面确认时积分又扣不动。我用了 Seata AT 模式。在创建交换单的入口方法上加GlobalTransactional后续的本地事务和 Feign 调用都会自动纳入全局事务。Seata AT 模式对业务代码侵入很小核心依赖是数据库的 undo_log 表Seata TC 会记录事务执行前镜像回滚时自动恢复数据。使用 Seata 有两个注意点。一是一整个全局事务里不要做远程调用以外的长时间操作比如发短信、计算图片压缩这些会拉长全局事务降低并发。二是 MQ 发送不能直接放到事务方法体内因为事务还没提交消费者可能提前感知到数据。更稳妥的做法是事务提交后发送事件或者用本地消息表。我实际把消息发送挪到了交换单创建成功后的监听器里用TransactionalEventListener(phase AFTER_COMMIT)触发保证“先提交业务数据再发通知”。如果不想引入 Seata也可以靠状态机和定时任务做最终一致。比如先把物品状态更新为“预占”再创建交换单如果创建成功就改成 HOLD如果失败就通过定时任务把预占状态回滚。这个方案更轻但状态分支比较多对开发人员要求高。项目时间紧、追求稳定的话用 Seata 是更省事的选择。3.3 缓存热点物品与搜索性能物品列表和详情是读多写少的典型场景。第一次查询数据库之后把数据放到 Redis能明显降低数据库压力。我用的 Spring Cache Redis 实现物品详情接口加Cacheable更新物品后加CacheEvict。这里要处理三个经典问题。缓存穿透有恶意用户或并发场景请求一个不存在的 itemId每次都穿透到数据库。解决方式是对空结果也缓存 5 分钟。缓存击穿某个热点物品的缓存过期瞬间大量请求同时打数据库。解决方式是用 Redis 互斥锁重建缓存或者对热点 key 不设置过期时间后台主动更新。缓存雪崩大量物品同时过期导致 DB 压力飙升。解决方式是在缓存过期时间上加随机值比如 10 分钟到 15 分钟之间取随机数避免同时过期。易物系统最怕的不是缓存击穿而是数据一致性问题。用户发起交换后物品状态从 AVAILABLE 变成 HOLD如果缓存没及时更新其他用户仍然看到“可交换”点击后会失败。所以我在状态流转的关键接口上必须主动删除对应物品缓存不能只依赖CacheEvict的方法。比如 exchange-service 通过 Feign 调用 item-service 更新状态时返回最新状态同时 item-service 内部要把缓存清掉。上线后我加了一个简单的核对任务每 5 分钟检查 Redis 里状态为 AVAILABLE 但数据库中状态为 HOLD 的物品自动刷新缓存避免人为遗漏。3.4 接口幂等与重复提交防护分布式锁能挡住并发但防不住极端情况下的重复请求更可靠的是数据库唯一索引。我给 exchange_apply 表加了request_no唯一键。前端在发起交换时生成一个全局唯一的请求号后端插入时如果重复数据库会抛 DuplicateKeyException捕获后返回“请勿重复提交”。ALTER TABLE exchange_apply ADD UNIQUE KEY uk_request_no (request_no);这个方案的好处是即使分布式锁因为 Redisson 版本问题失效、或者网关重试导致同一请求发两次数据库层面也会拦截。分布式锁是并发控制数据库唯一索引是最后一道保障。两者配合才能把重复交换单的概率降到最低。还要注意接口层面的“重复”不一定是同一个请求号。用户连点两次如果前端没有生成新 request_no第二次会直接被唯一索引拦掉。如果用户刷新页面后再点一次前端重新生成 request_no那后端会认为是两个不同请求。这时靠锁和业务校验处理同一用户对同一物品发起第二次交换锁内会发现已经存在交换单直接拒绝。所以前端在创建交换单前也要查询“当前用户是否已对该物品发起过交换”体验上能提前提示。4. 前端 Vue 实现与联调细节4.1 Vue 项目搭建与动态路由前端我用了 Vue 3 Vite。创建项目很简单npm create vitelatest swap-front -- --template vue cd swap-front npm install npm run dev再安装 Vue Router、Pinia、Axios、Element Plus。页面结构分为几种角色普通用户看到首页、物品详情、发布物品、我的交换、消息列表管理员看到后台管理菜单用户管理、物品审核、交换记录、评价管理。前端不能直接写死路由菜单否则改权限要重新发版。我从后端登录接口拿用户角色和菜单列表然后通过router.addRoute动态注册。动态路由有个经典坑刷新页面时 Pinia 状态被清空动态路由也没了用户一刷新就白屏。解决办法是在全局前置守卫里判断路由是否已加载如果没有先重新请求用户信息和菜单再调用next({ ...to, replace: true })重新进入目标路由。代码逻辑大概这样router.beforeEach(async (to, from, next) { if (!userStore.hasRoute) { const menus await getMenus(); menus.forEach(menu router.addRoute(menu)); userStore.hasRoute true; next({ ...to, replace: true }); } else { next(); } });如果懒得自己写可以直接参考若依微服务 plus 前端它对菜单权限、按钮权限、动态路由封装得比较完整。唯一要注意的是若依老版本是 Vue2新版本有 Vue3 改造版选型时看清楚依赖版本。4.2 文件预览图片、PDF 与 M3U8 播放易物系统里用户会上传物品图片偶尔还有说明书 PDF后续可能支持视频。图片最简单用img :src就行。但如果 MinIO Bucket 是私有权限不能直接用这个 URL需要后端生成带过期时间的预签名 URL前端拿到后设置到 src。PDF 在浏览器里默认不一定能直接预览。把对象存储的 PDF 链接扔到 iframe 里有些浏览器会显示下载按钮或空白。我用的方案是 pdfjs-dist 或 vue-pdf-embed把 PDF 文件二进制拉下来用 Canvas 渲染。这个体验更好但要注意处理加载失败时的错误提示。M3U8 视频这里多说一句浏览器不会原生播放 m3u8必须引入 hls.js。代码很简洁import Hls from hls.js; if (Hls.isSupported()) { const hls new Hls(); hls.loadSource(videoUrl); hls.attachMedia(videoElement); } else { videoElement.src videoUrl; }如果视频在 MinIO 里CORS 配置不对会导致切片请求失败。需要在 MinIO 的 bucket 策略或 Nginx 层加上Access-Control-Allow-Origin。我在测试时经常遇到“hls.js: Error while trying to load resource”的报错十有八九是浏览器跨域被拦先在控制台看 Network 里的响应头。4.3 axios 封装与网关路由配置要点前后端联调最麻烦的是鉴权和网关路由。我封装了一个 request.js 工具请求拦截器里带上 tokenservice.interceptors.request.use(config { config.headers.Authorization Bearer ${getToken()}; return config; });响应拦截器统一处理错误码比如 401 跳登录页403 提示无权限500 弹业务错误。不要把后端返回的code和 HTTP status 混在一起我习惯后端所有业务错误都返回 HTTP 200业务码放在 response body 的 code 字段里前端只处理业务 code。这样网关的 HTTP 状态码只反映网络层面问题不会出现“业务失败但 HTTP 200”和“业务成功但 HTTP 500”的混乱。开发环境的跨域交给 Vite 代理server: { proxy: { /api: { target: http://localhost:8888, changeOrigin: true } } }生产环境用 Nginx 把/api转发到网关。网关路由配置里我用 StripPrefix1意思是从/api中把前缀剥掉再转发到具体服务。比如前端请求/api/item/list网关先剥离/api再按 pathitem/list匹配到 item-service。如果 StripPrefix 设置不对服务端看到的是/api/item/list而服务里的接口是/item/list就会报 404。排查这种问题先看网关日志中的原始路径和转发路径几乎一眼定位。5. 上线过程中遇到的常见问题与排查实录5.1 版本兼容性SpringBoot 版本太高/SpringCloud Alibaba 版本不匹配这是微服务项目里最折磨人的问题。我最初把 SpringBoot 升到 3.x结果 SpringCloud Alibaba 里很多组件跟不上启动报一堆 Bean 找不到或方法签名错误的异常。后来老老实实锁定版本SpringBoot 2.7.xSpringCloud 2021.0.xSpringCloud Alibaba 2021.0.5.0。这个组合经过大量开源项目验证稳定很多。如果你用的不是 SpringCloud Alibaba而是其他分布式方案也是一样的原则先确认 BOM 依赖而不是网上随便拉一个最新版。SpringCloud 的版本号是伦敦地铁站名比如 2021.0.x、2022.0.x不写版本号可能会拉到不相容的版本。建议用一个父 pom 统一管理dependencyManagement dependencies dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-alibaba-dependencies/artifactId version2021.0.5.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement项目启动后如果日志里提示 Nacos 客户端连不上先别急着改代码检查 Nacos 的 namespace 和 group 是否一致。我有一次所有服务都注册不上查了半天发现是 Nacos 2.x 开启了鉴权但服务配置里没写 username/password控制台看得见连接却鉴权失败。控制台可见不代表配置没问题要在服务端日志里找真正的报错。5.2 分布式锁和事务的本地复现技巧本地调试多实例有一个简单方法同一个服务启动两个端口比如--server.port9003和--server.port19003都注册到同一个 Nacos 服务名。这样能模拟两个实例并发调用接口。测试重复交换时我写了一个简单的多线程脚本用同一个 token 并发发送 20 次申请。没有锁之前数据库里会留下多条状态乱七八糟的记录。加了锁之后日志里能看到只有一个请求进入业务区其余线程都等锁或直接拒绝。测试 Seata 回滚最直接的方式是在确认交换的接口里故意抛一个运行时异常然后看数据库。如果 Seata 配置正确物品状态会从 HOLD 恢复成 AVAILABLEexchange_apply 也会被回滚删除。如果发现没有回滚先检查 undo_log 表有没有记录再看 Seata Server 的日志。这里有个坑Seata 版本和 SpringBoot 版本不匹配会导致全局事务不生效但业务代码不报错。所以上线前一定要做一次真实的“异常注入测试”比看一百篇文档都管用。5.3 缓存与 DB 一致性、慢查询优化上线后物品列表慢慢变慢我这里出现过两个问题。一个是没有索引的大表全表扫描。goods 表数据量到几十万后where statusAVAILABLE order by created_at desc就需要几百毫秒。解决办法是加联合索引(status, created_at)查询时间降到个位数毫秒。另一个是列表页连表查图片和用户信息一条 SQL join 太多。我把物品列表的图片改成冗余存储在 item 表里直接存主图 URL避免每行都去 item_image 表查。缓存更新顺序我采用“先更新数据库再删除缓存”。这个方案比先删缓存再更新数据库要稳因为并发期间可能出现短暂旧数据但下次请求时缓存已删除会回源数据库缓存新值。偶尔还是会出现删除缓存失败所以加了一个延迟双删策略更新数据库后删除一次缓存等 500 毫秒后再删除一次用来兜底并发读请求可能重建的旧缓存。5.4 部署与运维整个系统用 Docker Compose 编排。Nacos、Redis、RabbitMQ、MySQL、MinIO 各自一个容器六个业务服务也各自打包成镜像。数据库连接串、Nacos 地址、Redis 地址都放到环境变量不要写死在 application.yml否则换环境就要重新打包。网络规划上所有服务放在一个 Docker 网络里服务名直接写容器名。比如 user-service 的 datasource url 是jdbc:mysql://mysql:3306/user_db而不是 localhost。在 Nacos 里注册时IP 要配置为局域网或容器 IP服务间 Feign 调用才能走通。很多人本地跑得好好的放到 Docker Compose 里就注册不上就是因为 localhost 指的不是容器本身而是服务所在容器造成网络隔离。网关是唯一对前端暴露的入口生产环境我用 Nginx 做了一层安全代理把 TLS 终结在 Nginx再转发到网关 8888。这样网关不需要处理证书业务服务也不直接对外暴露端口安全性好得多。最后说点实际的体会。这套系统我前后折腾了一个多月最大的感受是微服务不是银弹易物系统的业务难点在信任机制和状态一致性而不在于用了多少新技术。用户最关心的不是你的服务拆了几个而是“我发起交换之后到底能不能真的换到东西”“有没有人会骗我”。如果你只是想学习分布式技术这套 SpringBoot Vue SpringCloud 的组合很值得完整做一遍能从网关、注册中心、锁、事务、缓存、文件存储一路练过去但如果只是为了快速验证业务单体加 Redis 完全够用不要一开始就套微服务。先把核心状态机和幂等设计理清楚再考虑拆分才不会把自己绕进去。