接手“易物小店”时,我一直在想一个问题:物品交换系统和普通电商到底差在哪?普通电商是单向的“你付钱我发货”,换物却是双向的:用户A看中用户B的键盘,用户B想要的可能是A的耳机,两个人同时点头,一桩交易才算成立。这个“双向确认”的业务模型,在系统实现上比买卖复杂得多。我最终选用的技术组合是SpringBoot + Vue + SpringCloud:后端按业务拆成多个微服务,前端用Vue做单页应用,中间挂SpringCloud Gateway做统一网关。目前这套系统已经跑过几轮内测,今天把拆解思路、核心代码和踩坑记录整理出来,给正在做微服务项目、或者准备做闲置交换类产品的朋友一个参考。
1. 项目定位与整体方案选型
1.1 为什么非要上微服务
很多人看到“易物小店”这种体量,第一反应是单体应用完全够用。确实,如果只是做MVP,用SpringBoot单包加一个MySQL,一个月就能上线。但我们在立项时拿到两个额外条件:一是后续要支持多城市、多运营方独立部署,二是团队想沉淀一套SpringCloud基础组件。这两个条件直接把单体的路堵死了。
微服务真正的价值不是“代码拆得细”,而是数据域隔离和独立伸缩。物品服务被某个运营方买到后可以做缓存优化,交易服务因为换物流程复杂可以独立扩展,用户服务遇到恶意注册时单独限流,不会拖垮其他服务。哪怕项目体量不大,只要这些运维能力在未来一两年内会出现,就值得为拆分提前布局。
当然也要泼一盆冷水:如果你的项目只有管理员和普通用户两种角色,业务边界模糊,上线时间紧到以周计算,不要盲目拆微服务。微服务带来的分布式事务、链路追踪、配置管理成本是硬成本,团队没有经验和运维支撑,反而会拖慢交付速度。
1.2 技术栈选型的那些选择题
这个项目最核心的几个选型,我逐一说明,都是实测后的取舍,不是纯背书。
| 组件 | 备选方案 | 最终选择 | 原因 |
|---|---|---|---|
| 注册中心 / 配置中心 | Eureka、Consul、Nacos | Nacos | 注册与配置一体化,中文文档全,SpringCloud Alibaba体系成熟度最高 |
| 网关 | Zuul、SpringCloud Gateway | Gateway | 基于WebFlux,性能和生态都比Zuul好,路由配置更灵活 |
| 远程调用 | RestTemplate、OpenFeign | OpenFeign | 声明式HTTP客户端,接口定义与读写清晰,团队上手成本低 |
| 分布式事务 | Seata、本地消息表+RocketMQ | 本地消息表+RocketMQ | 换物核心链路需要最终一致性,消息表方案可控性更强,代码可审查 |
| 前端基础 | React、Vue2 | Vue3 + Element Plus | 后端团队好上手,双人开发效率高,Element Plus组件覆盖后台管理场景 |
| 对象存储 | FastDFS、OSS、MinIO | MinIO | 私有部署友好,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 | 双方物品价值差,可为0 |
| created_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。后来所有服务统一去掉上下文路径,网关用StripPrefix=1:
spring: cloud: gateway: routes: - id: item-service uri: lb://item-service predicates: - Path=/api/item/** filters: - StripPrefix=1另外,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版本。我们整个团队用的是Java8,SpringBoot用的自带的,不额外引入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-service | 8081 |
| item-service | 8082 |
| trade-service | 8083 |
| notify-service | 8084 |
| review-service | 8085 |
| gateway | 8080 |
启动顺序有讲究:先Nacos,再MySQL/Redis/MinIO/MQ,然后启动没有依赖的兜底服务,最后启动网关。如果Feign调用时报连接拒绝,第一步不是去看服务代码,而是看目标服务有没有注册到Nacos,我见过很多人隔着屏幕查半天,最后发现目标服务没启动。
部署阶段我用Dockerfile打镜像,GitLab CI在merge request合并后自动构建并推送到镜像仓库,测试环境用Docker Compose一键拉全部服务。到现在这个规模还没有必要上K8s,等真正要做灰度发布和弹性伸缩时再迁移不迟。
6. 常见问题与排查技巧实录
整理一份问题速查表,都是我在这个项目里真实遇到过的,供大家直接对照。
| 现象 | 可能原因 | 排查步骤 | 解决办法 |
|---|---|---|---|
| 服务注册不上Nacos | Nacos地址配置错误,或安全鉴权失败 | 看启动日志,nacos:8848地址能否连通;确认namespace是否一致 | 检查bootstrap.yml,统一namespace和group |
| Feign调用超时 | 超时时间太短,或目标服务本身慢 | 在目标服务里打印SQL耗时,查看慢查询日志 | 调大Feign超时,但不能超过网关超时;慢操作异步化 |
| Redis分布式锁死锁 | 业务代码在持有锁期间抛异常,没有走unlock | 确认finally是否释放;Redisson看门狗是否续期失败 | 使用try-finally释放;开启Redisson看门狗 |
| 前端请求跨域 | 网关CORS未配置,或Vite代理配置路径不对 | 打开浏览器Network看请求URL和响应头 | 前后端都用代理/反代,避免双跨域配置 |
| 网关路由404 | StripPrefix配置不合理,服务路径重复 | 在网关日志里看转发后的实际路径 | 打印路由日志,统一StripPrefix=1 |
| 数据库连接池耗尽 | 服务间同步调用占用连接等待,并发高 | 看连接池状态,跟踪慢查询 | 增加连接池上限,异步化重操作 |
| 版本启动报错 | SpringBoot与SpringCloud版本不匹配 | 检查Maven依赖树,确认依赖版本 | 统一用毕业版本清单并封装到父pom |
再分享一个排查技巧:微服务环境千万不要直接看日志传送门。我习惯是先看网关访问日志,再看目标服务调用链日志,最后看数据库慢查询。没有链路追踪时,我在公共的日志过滤器里给每个请求生成traceId,并手动在Feign调用时传递traceId到下一个服务,这样一条日志能串起三个服务,定位问题快很多。
还有一个很多人忽略的坑:本地联调时我把多个服务都跑在IDEA里,经常出现端口被占用导致启动失败。后来写了一个脚本,启动前自动检测端口占用并杀掉旧进程,顺便把Nacos上对应的旧实例标记为下线。配合使用效果显著,省了一堆“看起来启动成功实际没注册”的假象。
最后再提一句
如果让我重新做一次这个项目,我会在一开始就统一约定好“领域事件”的名称和消息体,而不是边写边改。易物小店最复杂的地方是多方状态的最终一致性,而事件是整个系统解耦的命脉。提前定义好“ItemLocked”、“TradeConfirmed”、“TradeFinished”这些消息,后续加服务、加消费者都会顺畅很多。
此外,前端别急着把页面做漂亮,先把换物流程的状态机走通,把“发起、拒绝、确认、履约、完成”五个动作对应的接口全部打通,再回头做交互优化。只要核心状态不错,页面丑一点都能接受;状态错乱,页面再好看也是空中楼阁。以上便是这套系统从选型到落地的完整记录,也希望无论是做微服务还是做换物业务的你,都能少踩几个我踩过的坑。