☰
易物小店微服务架构复盘:SpringBoot+Vue+SpringCloud分布式交换系统实践
2026/10/10 11:05:34 网站建设 项目流程

三年前给社区做过一个闲置物品循环利用的小程序,用户手里有不用的电饭煲、儿童绘本、九成新的蓝牙音箱,想换别人手里需要的东西。后来这个小程序演进成了完整的微服务分布式SpringBoot+Vue+Springcloud易物小店物品交换系统。翻看当初的设计文档和代码,踩过的坑、改过的边界、为了并发和一致性熬的夜都还在眼前。这篇内容不是课程广告,而是一份项目复盘,覆盖业务拆解、技术选型、服务搭建、分布式锁、跨服务一致性、Vue前端对接、部署避坑,适合正在学微服务、想用真实业务练手的朋友,也适合准备做C2C交换类产品的技术团队参考。

1. 易物小店做的是什么:交换场景的业务闭环与拆分逻辑

1.1 核心流程:从发布物品到完成交换

易物小店和闲鱼这类平台最大的区别在于,交易媒介不是钱,是物品。用户将自己的闲置物品拍照发布,描述使用成色、希望交换的方向,其他用户看到后提交交换申请,物品主人同意后,双方进入交换履约流程。

核心业务闭环是:

  • 用户注册登录,维护个人信息和信用标签
  • 发布物品,填写标题、描述、图片、分类、期望交换物品类型
  • 浏览物品,按分类、关键词、最新发布检索
  • 提交交换申请,可以选择对自己物品的交换报价
  • 物品主人通过或拒绝申请
  • 通过后生成交换单,双方在站内确认交换物品和联系方式
  • 线下交付或快递交换,最后互相确认完成

这个流程看起来简单,技术上的复杂度全在"交换"两个字上。买家直接下单的商品是静态库存,减库存就行;交换场景是双向匹配,物品在交换期间不能被其他人再申请,还得处理两个用户同时申请同一件物品、交换步子走到一半有人反悔等状况。

1.2 为什么我决定拆微服务:规模拐点和现实约束

如果按功能量算,这个项目单体能跑,很多社区团购系统到现在还是单体。但做技术选型不能只看当前功能,还要看谁在维护、要扩展成什么。

我当时评估下来拆微服务有几个明确理由:

  • 用户端、后台管理、运营端对服务的依赖和发布节奏完全不同,耦合在一个工程里每次发布都要全量回归
  • 物品画像、搜索、消息推送这些模块后续要独立扩展,比如接Elasticsearch做全文检索,挂在单体里面变更成本很高
  • 团队虽然不大,但按领域划分成小组后,各自维护独立服务能减少代码冲突
  • 技术上也需要一套完整微服务落地经验,为后续更多项目打底

当然,这里有个反面教训:如果团队只有一两个人,业务量日均几百单,不要强行微服务。这个项目能拆是因为功能边界确实清晰,而且我预期后续会有多端接入,不是单纯为了简历好看。

1.3 服务清单:五个业务服务加基础设施的职责划分

围绕业务闭环,我把服务边界画成了五条线:

服务名职责关键表/资源端口
gateway-service统一入口、JWT鉴权、跨域处理网关路由配置8080
user-service用户、登录注册、账户资料user_account, user_auth, user_credit8091
item-service物品发布、分类、浏览、状态管理item, item_category, item_image8092
exchange-service交换申请、交换单、状态流转exchange_order, exchange_message_record8093
message-service站内通知、私信message_notify, message_letter8094
file-service图片上传下载、对象存储管理MinIO存储桶8095

公共基础设施是Nacos做注册中心和配置中心,Redis做缓存和分布式锁,MySQL做业务数据存储,MinIO做图片文件存储。这套组合在中小规模微服务项目里非常成熟,覆盖面完整还不会过度设计。

2. 技术选型复盘:这套微服务体系里哪些组件必须上

2.1 版本组合:SpringBoot 2.7 + SpringCloud 2021 + SpringCloudAlibaba 2021

微服务项目最头疼的不是写代码,是版本兼容。我从搜索引擎看到"springboot版本太高"这个词的时候特别有感触,很多人直接用了SpringBoot 3.x,然后发现SpringCloudAlibaba对应的组件版本、Nacos客户端版本都对不上,一脸懵回头换版本。

我最终采用的是一套稳妥组合:

  • SpringBoot 2.7.18
  • SpringCloud 2021.0.8
  • SpringCloudAlibaba 2021.0.5.0
  • Nacos Server 2.2.3
  • Redisson 3.20.0
  • MinIO 8.5.x
SpringBoot版本对应SpringCloud版本对应SpringCloudAlibaba版本结论
2.7.x2021.0.x2021.0.5.0稳定,中文资料多,常用组件兼容
3.2.x2023.0.x2023.0.x新项目可用,但不少组件配置方式变更
2.6.x2021.0.x2021.0.2.0偏旧,不建议新开

在Maven父工程里,我通过BOM统一管理依赖,不在业务模块里写版本号,这是多服务工程的基础卫生习惯。这么做的原因是各服务之间如果依赖版本不一致,联调时出现的诡异问题会消耗大量排查时间。

2.2 Nacos:注册中心顺手解决配置管理

服务发现我选Nacos而不是Eureka,原因是Nacos同时具备注册中心和配置中心两个能力。Eureka早就停更,Consul配置能力和中文资料又不如Nacos。Nacos客户端自带配置热更新,后面调线上参数不用重启服务。

多说一句:如果你的微服务只是demo级别的几台机器,Nacos单机模式就够,不用一上来就上集群,但生产环境至少三节点。我自己吃过亏,Nacos挂一次,所有服务间调用全部变雪崩。

2.3 Gateway+Feign:请求入口与服务间通信

网关我选了SpringCloud Gateway,因为SpringCloud中Zuul系列已经停更,Gateway基于WebFlux非阻塞模型,性能好,天然适合做统一入口。

最需要提醒的是:Gateway模块千万不能引入spring-boot-starter-web,它会跟WebFlux冲突,导致启动直接报错。我当时在pom里顺手加了web依赖,启动时出现RouterFunction和DispatcherServlet冲突的异常,排查了很久才发现是这个低级原因。

服务间调用用的OpenFeign,声明式HTTP客户端,配合Nacos做负载均衡。Feign这里有几个配置细节后面会专门讲。

2.4 MinIO:文件存储的一段弯路

图片存储最开始我用了本地磁盘存储,想着文件量不大没必要上对象存储。后来发现用户上传图片一天天增加,服务器磁盘扛不住,迁移时也麻烦。后来替换成MinIO。

选MinIO的理由很现实:S3协议兼容,以后想换云厂商的对象存储不用改业务代码;可以私有化部署在局域网;资源占用比HDFS这类分布式文件系统小很多。易物小店里的物品图片、用户头像、交换凭证照片都放在这。

这里提一下,MinIO在开发环境下内存管理比较稳,但要注意Bucket的访问策略,我一开始只设置了私有权限,前端拿到图片URL直接404,排查发现还需要配置访问策略或者生成预签名URL。

3. Maven父工程与公共模块:从零拉起可运行的服务骨架

3.1 父工程pom的dependencyManagement思路

微服务第一件事不是写代码,是把多模块工程立起来。整个项目是一个Maven多模块结构,父工程只做两件事:定义所有依赖版本、管理子模块清单。

父工程pom.xml关键内容如下:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <spring-cloud.version>2021.0.8</spring-cloud.version> <spring-cloud-alibaba.version>2021.0.5.0</spring-cloud-alibaba.version> <redisson.version>3.20.0</redisson.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-dependencies</artifactId> <version>${spring-cloud-alibaba.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>

子模块列表这样组织:

<modules> <module>easy-exchange-common</module> <module>easy-exchange-gateway</module> <module>easy-exchange-user</module> <module>easy-exchange-item</module> <module>easy-exchange-exchange</module> <module>easy-exchange-message</module> <module>easy-exchange-file</module> </modules>

之所以用BOM统一管理,是避免每个服务自己定义SpringCloud版本。版本冲突在微服务里是最难查的问题之一,日志压根不会告诉你是版本不匹配,只会在运行时抛奇怪的ClassNotFoundException。

3.2 公共模块决定代码卫生

公共模块easy-exchange-common是整个项目的基石,所有服务都会依赖它。我在这里放了几类内容:

  • 统一返回结构Result<T>和状态码枚举
  • 全局异常处理器
  • 工具类,包括时间、字符串、JSON处理
  • 上下文工具类,用来存储当前登录用户信息
  • 常量类,定义Redis key前缀和MQ topic

统一返回结构特别重要。如果每个服务自己定义返回格式,前端对接就变成灾难,今天这个接口返回data,明天那个返回result,网关层根本没法统一处理。

全局异常处理器示例:

@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BizException.class) public Result<Void> handleBizException(BizException e) { return Result.error(e.getCode(), e.getMessage()); } @ExceptionHandler(Exception.class) public Result<Void> handleException(Exception e) { log.error("系统异常", e); return Result.error(ResultCode.SYSTEM_ERROR); } }

3.3 以user-service为例:注册进Nacos

一个业务服务要跑起来,其实就是四步:引入依赖、写主类、配bootstrap.yml、实现接口。

核心依赖:

<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency>

application.yml核心配置:

server: port: 8091 spring: application: name: user-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 config: server-addr: 127.0.0.1:8848 file-extension: yaml datasource: url: jdbc:mysql://localhost:3306/easy_exchange?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: xxxxxx

启动主类:

@SpringBootApplication @EnableDiscoveryClient @MapperScan("com.easy.exchange.user.mapper") public class UserServiceApplication { public static void main(String[] args) { SpringApplication.run(UserServiceApplication.class, args); } }

Nacos服务端如果是2.x,建议配置spring.cloud.nacos.discovery.username和密码,否则会一直报鉴权失败。我第一次搭的时候没注意,服务注册不上,日志里全是403。

4. 物品被抢着要的瞬间:Redis分布式锁在易物场景的实战

4.1 竞争场景描述:同一件物品被多人申请

易物小店上线一周后遇到一个真实场景:一件九成新的戴森吸尘器挂在网站上,一小时内收到五个交换申请。五个申请同时创建,物品状态被反复修改,最后交换单里出现了三条"已通过",其他两个用户的聊天窗口弹出了错误的成功通知。

这就是典型的并发竞争。在单体应用里,可以用synchronized锁住方法,但微服务按服务拆开后,用户请求可能落到不同实例,每个实例有自己的JVM锁,互相之间根本感知不到,这就是单机锁失效的根因。

4.2 为什么锁要放在业务代码前面

这个并发场景的竞争资源是"一件物品的可交换状态"。如果不加锁,五个请求同时读到status = AVAILABLE,同时创建交换申请,同时更新状态,操作全部成功,数据却乱了。

分布式锁的作用是在多实例之间制造一个互斥窗口。我用Redisson这个框架来实现Redis分布式锁,比手写SETNX加超时时间的方式更可靠,它内置了看门狗机制,可以自动续期,避免业务没执行完锁就过期。

手写方式最大的风险是忘记设置过期时间,服务宕机后锁永远不会释放,后面所有请求全部卡死。Redisson把这些问题都处理了,所以能用成熟框架就不要自己重复造轮子。

4.3 Redisson代码:tryLock加状态校验加状态更新

我在exchange-service里申请交换的核心逻辑是:先获取物品信息,校验状态,创建交换申请,然后将物品状态改为LOCKED。关键代码如下:

public Result<Long> applyExchange(ApplyExchangeRequest request) { String lockKey = "exchange:lock:item:" + request.getItemId(); RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { locked = lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { return Result.error("操作的人太多,请稍后重试"); } // 重新查询物品状态,以数据库为准 ItemDTO item = itemClient.getItemById(request.getItemId()); if (item == null || !"AVAILABLE".equals(item.getStatus())) { return Result.error("物品已被预约或者已下架"); } // 创建交换申请单,写入exchange-service自己的库 Long exchangeId = exchangeMapper.createApply(request.getUserId(), request.getItemId(), request.getOfferItemId()); // 更新物品状态为LOCKED,这里走的是item-service的原子接口 boolean updated = itemClient.lockItem(request.getItemId(), exchangeId); if (!updated) { throw new BizException("物品状态更新失败"); } return Result.success(exchangeId); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }

注意几个细节:

  • tryLock(3, 10, TimeUnit.SECONDS)的含义是等待锁最多3秒,锁自动释放时间是10秒。如果业务超过10秒还没执行完,Redisson的看门狗会自动续期,但前提是没有显式传leaseTime。这里传了10秒的leaseTime,看门狗就不会生效,所以必须确保持锁时间内只做快操作。
  • 锁的粒度是item:{itemId},不是全局锁。这非常关键,如果锁粒度太大,比如用一个exchange:lock:all,那么所有交换申请全部串行,系统的并发能力瞬间归零。
  • 锁不能替代业务校验。获取锁后必须重新查库确认物品状态,因为可能在等待锁的过程中,前面一个事务已经把状态改掉了。
  • 持有锁期间不要做耗时操作,尤其是不要做Slow SQL查询和第三方接口调用。这里调用itemClient.lockItem是远程调用,要保证它的响应足够快,否则锁会被拖到临界点。

item-service的lockItem接口必须设计成原子操作,用乐观锁做兜底:

@Update("UPDATE item SET status = 'LOCKED', lock_exchange_id = #{exchangeId}, " + "update_time = now() WHERE id = #{itemId} AND status = 'AVAILABLE'") int lockItem(@Param("itemId") Long itemId, @Param("exchangeId") Long exchangeId);

这里用受影响行数判断是否更新成功,即使分布式锁出了意外,数据库也会拦截重复更新。

4.4 锁使用的边界与替代方案

分布式锁不是万能的。我在后续迭代中又加了一个数据库唯一约束作为双保险,给exchange_apply表加了一个业务唯一索引(item_id, applicant_id),同时保持状态为有效申请时唯一。这样如果Redis锁因为网络抖动失效,数据库也能挡住重复申请。

另外一个替代方案是纯数据库乐观锁,也就是把version字段加到物品表里,更新时检查版本号。但纯乐观锁的问题是申请方失败了要自己重试,用户体验不好。分布式锁加乐观锁的组合在这个场景下体验和正确性都能兼顾。

5. 跨服务状态流转:不上分布式事务也能保证最终一致

5.1 状态机设计:AVAILABLE、LOCKED、EXCHANGING、FINISHED

物品交换涉及多个服务,但状态不能满天飞。必须由一个服务来主导状态机,我选择让exchange-service作为"交换流程的总导演",item-service只提供物品状态的原子变更接口。

物品状态流转:

AVAILABLE 可用 -> LOCKED 已被预约 -> EXCHANGING 双方确认交换中 -> FINISHED 完成 LOCKED 可以回到 AVAILABLE,比如申请被拒绝或超时未确认

交换单状态流转:

PENDING 申请中 -> ACCEPTED 已接受 -> CANCELLED 已取消 -> FINISHED 已完成

设计原则是:业务服务之间不互相操作对方的表。比如想取消交换,exchange-service不能直接更新item表,必须调用item-service的unlockItem接口。这样每个服务的数据库边界清晰,后续改表结构不会互相破坏。

5.2 本地消息表:给站内消息减负

交换申请通过后,系统要通知物品主人和申请人。当时我第一个版本直接在业务代码里同步调用message-service,结果message-service一抖动,主流程整个失败,用户看到的是"交换申请失败请重试",其实交换单已经创建成功了,这就产生了脏数据。

后来改成本地消息表方案。

exchange-service在本地创建交换单时,同时往notify_message表插入一条待通知记录:

CREATE TABLE notify_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exchange_id BIGINT NOT NULL, message_type VARCHAR(20) NOT NULL, receiver_id BIGINT NOT NULL, content VARCHAR(500) NOT NULL, status TINYINT DEFAULT 0 COMMENT '0未发送 1已发送', retry_count INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );

然后有一个定时任务兜底扫描:

@Scheduled(fixedDelay = 5000) public void scanUnsentMessages() { List<NotifyMessage> list = notifyMessageMapper.selectUnsent(100); for (NotifyMessage msg : list) { try { messageClient.sendNotify(msg.buildRequest()); notifyMessageMapper.markSent(msg.getId()); } catch (Exception e) { log.warn("通知发送失败,messageId={}", msg.getId(), e); notifyMessageMapper.increaseRetry(msg.getId()); } } }

这个方案的核心思想是:最终一致性。业务主流程不依赖其他服务的即时响应,跨服务副作用通过本地表和定时任务异步推进。对易物小店这个体量来说,完全没有必要为了几个消息通知引入分布式事务框架,本地消息表加上重试机制就能让数据最终一致。

5.3 服务间调用的幂等与重试

定时任务天然会重复,因为一次调用失败后,下一次扫描会重新发送同一条消息。所以message-service的sendNotify接口必须做幂等。

幂等实现方式:message-service建一张message_receive_record表,用notify_message_id作为唯一键,处理前先插入,插入冲突说明已经处理过,直接返回成功。

还有一个我踩得很实的坑:OpenFeign的重试机制。Feign默认在连接超时时会重试,但重试对写接口来说极其危险。比如调用unlockItem,第一次请求其实已经成功了,只是响应超时,Feign重试再次调用,物品被解锁两次,第二次调用因为状态已经不是LOCKED,返回失败,但业务侧看到的是失败,实际数据却成功改变。

后来我做了两件事:

  • 关闭Feign对写接口的重试,或者用幂等键保证重复请求安全
  • 所有写接口都设计成天然幂等,请求中带上requestId,服务端根据requestId判断是否已处理

Feign超时配置如下:

feign: client: config: default: connectTimeout: 3000 readTimeout: 5000

5.4 超时问题的真实案例

线上出现过一次很诡异的问题:用户提交交换申请后,前端一直转圈,最后提示失败,但库里有交换单记录。排查时发现是因为exchange-service调用item-service的lockItem超时,Feign重试了一次,第二次成功,但第一次的响应超时让exchange-service认为整个调用失败,直接把事务回滚了,结果item-service那边物品已经被锁了。

这个问题的根因是跨服务调用的事务边界不一致。exchange-service本地事务回滚,并不会回滚item-service已提交的事务。

解决思路有三层:

  • 接口幂等:item-service的lockItem用exchangeId做唯一约束,重复调用返回相同结果
  • 尽量缩短服务间调用链:把申请交换核心流程控制在两个服务内,不在长事务中做过多远程调用
  • 如果业务允许,把关键写操作设计成"先落库再同步",避免强一致

这个案例让我深刻理解一个道理:微服务架构里,分布式事务不是靠某个框架一劳永逸解决的,更重要的是业务流程设计如何降低对强一致性的依赖。

6. Vue前端双工程对接:前台大厅、后台管理与网关联调

6.1 前台大厅和后台管理的工程划分

易物小店用户端叫store-front,面向C端用户,负责浏览物品、发布物品、申请交换、聊天、个人中心。后台管理叫admin-web,面向运营人员,负责用户管理、物品审核、分类管理、交换单查询。

两个工程都用Vue3加Vite加Pinia加Element Plus。Vue3和Vite是现在的默认选择,构建速度快,组合式API写起来比Options API更容易维护。工程目录我做了划分:

store-front/ src/ api/ # 接口请求封装 views/ # 页面组件 router/ # 路由配置 store/ # Pinia状态 components/ # 公共组件 utils/ # 工具函数

前后台分开部署,用户端打包后放到Nginx的/目录,管理端放到/admin目录,通过不同location反代到网关,这样两者可以独立升级发布。

6.2 开发期Vite转发与线上网关的双轨玩法

开发环境最大的痛点是前端跑在localhost:5173,后端服务跑在各自端口,直接请求必然跨域。我的做法是开发环境用Vite的server.proxy配置,把所有/api开头的请求转发到网关的8080端口。

server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } }

网关侧的路由配置是StripPrefix=1,也就是说前端访问/api/user/info,经网关转发到user-service时,路径变成了/user/info。这样设计的好处是:前端只需要关心统一的/api前缀,后端可以自由调整服务拆分,对前端透明。

生产环境则不需要Vite转发,前端构建后放在Nginx,Nginx配置/api反向代理到网关:

location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

这里用proxy_pass加末尾斜杠的方式,将前端的/api/user/info重写为网关的/user/info,与开发环境的rewrite逻辑保持一致。

6.3 动态路由与角色权限

易物小店涉及两种角色:普通用户和运营管理员。前台的C端路由基本固定,直接写在静态路由里就行。后台管理则需要动态路由,根据登录用户的角色和权限动态注册菜单路由。

后台管理登录后,后端返回当前用户的菜单列表,格式大致是:

[ { "path": "/item/audit", "component": "item/Audit", "name": "ItemAudit", "meta": { "title": "物品审核", "icon": "document" } }, { "path": "/user/manage", "component": "user/Manage", "name": "UserManage", "meta": { "title": "用户管理", "icon": "user" } } ]

前端核心代码:

const modules = import.meta.glob('../views/**/*.vue') export function addDynamicRoutes(menus) { menus.forEach((menu) => { const component = modules[`../views/${menu.component}.vue`] router.addRoute('Layout', { path: menu.path, name: menu.name, component, meta: menu.meta }) }) }

这里用了import.meta.glob,作用是在构建时扫描views目录下的所有Vue文件,这样动态注册路由时才能根据字符串找到对应的组件。如果不用这种写法,直接写component: () => import(menu.component),构建工具无法在编译期识别动态路径,打包后组件找不到,页面白屏。

动态路由有个经典问题:刷新页面后路由丢失。原因是Vue Router的路由表是运行时注册的,刷新后重新走初始化流程,如果没有重新拉取菜单再addRoute,用户访问的页面就没有对应路由,直接跳到404。我的解决方案是在路由全局守卫中加一个判断:

router.beforeEach(async (to, from, next) => { const userStore = useUserStore() if (userStore.token && !userStore.menusLoaded) { const menus = await userStore.fetchMenus() addDynamicRoutes(menus) next({ ...to, replace: true }) return } next() })

next({ ...to, replace: true })是关键,先把路由注册完,再重新进入一次目标路由,否则当前导航仍然找不到组件。

6.4 图片上传:文件服务与MinIO的对接

物品发布表单里图片上传是最容易被忽视的环节。前端我封装了一个Upload组件,选择图片后直接调用file-service的上传接口。

const uploadFile = async (file: File) => { const formData = new FormData() formData.append('file', file) const { data } = await http.post('/api/file/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) return data.url }

file-service收到文件后,存入MinIO并返回访问路径:

@PostMapping("/upload") public Result<FileUploadVO> upload(@RequestParam("file") MultipartFile file) { String originalFilename = file.getOriginalFilename(); String ext = StringUtils.substringAfterLast(originalFilename, "."); String objectName = UUID.randomUUID().toString().replace("-", "") + "." + ext; minioClient.putObject( PutObjectArgs.builder() .bucket("easy-exchange") .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build() ); String url = "http://your-domain/files/" + objectName; return Result.success(new FileUploadVO(url, objectName)); }

这里有两个容易踩的坑。第一个是MinIO Bucket访问权限,如果Bucket是私有权限,返回的URL直接访问会报403。我用的是预签名URL或者把Bucket设置为public并配置公开读策略,考虑到物品图片本身不涉密,公开读合理。第二个是图片访问的跨域问题,前端如果在浏览器直接加载图片,Oss跨域问题不会出现,但如果是通过Canvas处理图片就可能有CORS,需要在MinIO中配置Bucket跨域规则。

7. 部署与复盘:一台服务器上的微服务活下来

7.1 资源紧张的部署策略

项目验收阶段只有一台4核8G的服务器,要把Nacos、MySQL、Redis、MinIO再加上五个微服务全部跑起来,内存压力很大。如果不加控制,光这些组件启动后就能吃满8G内存。

我当时做了几件事把内存压了下来:

  • 所有SpringBoot服务设置JVM参数-Xms256m -Xmx256m
  • Nacos用单机模式,并调低JVM堆内存-Xmx256m
  • MySQL和Redis用容器自带的默认配置,不做额外调优
  • MinIO单独放一个容器

Docker Compose是这阶段最好的编排工具,不需要上Kubernetes那些重型基础设施。服务之间用Docker内网互相访问,只用网关端口对外暴露。

version: "3" services: nacos: image: nacos/nacos-server:v2.2.3 environment: MODE: standalone JVM_XMS: 256m JVM_XMX: 256m ports: - "8848:8848" mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: xxxxxx volumes: - ./mysql-data:/var/lib/mysql user-service: build: ./easy-exchange-user depends_on: - nacos - mysql

部署时最需要留意的是Nacos的注册地址。容器内启动的服务,注册到Nacos的IP是容器IP,外部服务通过宿主机访问不到。解决办法是在每个服务容器环境变量里配置:

NACOS_ADDR: nacos:8848 SPRING_CLOUD_NACOS_DISCOVERY_IP: 宿主机IP

否则网关通过lb://user-service转发给服务时,连接的全是容器内网IP,外部请求根本到不了。

7.2 网关日志与链路排查

微服务联调时,最痛苦的事情是定位一个请求到底在哪个服务出问题了。没有成熟的链路追踪系统时,我用了一个轻量方案:在网关生成一个traceId,通过HTTP Header传给下游所有服务,每个服务在日志里输出这个traceId。

@Component public class TraceIdFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String traceId = UUID.randomUUID().toString().replace("-", ""); ServerHttpRequest request = exchange.getRequest().mutate() .header("X-Trace-Id", traceId) .build(); return chain.filter(exchange.mutate().request(request).build()); } @Override public int getOrder() { return -100; } }

各服务在日志pattern中加入%X{traceId}或者手动在logback配置里放MDC,这样报错后直接按traceId搜索日志,能在几十万行日志中快速切片出完整调用链。

这个方案虽然没有SkyWalking那种可视化链路图,但对于中小项目来说成本极低、效果直接。等系统规模大了再换分布式链路追踪中间件也不迟。

7.3 后续演进:从"可用"到"好用"

项目上线跑稳之后,我看清了后续要补的方向。

物品搜索目前是简单的SQL模糊查询,一旦数据量上来就会拖垮MySQL,后面需要引入Elasticsearch,把物品的标题、描述、分类做全文检索,再用MQ异步同步索引数据。

站内信目前是私信和通知,用户双方聊天的实时性要求很高,可以升级为WebSocket消息推送,message-service作为一个长连接网关来承载在线会话。

服务治理方面,熔断限流目前只是靠Feign超时兜底,后续要引入Sentinel,给每个依赖接口配置流控和熔断规则,保证某个服务被打垮时不影响其他服务。

数据一致性设计上,如果后续要增加积分、支付、物流等强一致场景,就需要引入Seata的AT模式或者TCC模式。但我的经验是,能通过业务设计规避的分布式事务尽量不要上框架,框架本身也有性能和一致性风险。

回头复盘这个项目,最值钱的不是"用了SpringCloud这套技术",而是搞懂了服务边界在哪里、锁和事务怎么配合、跨服务调用如何保证最终一致。这套思路换到任何分布式系统都通用。如果你也在做类似的C2C交换或二手交易平台,我建议先从单体结构把业务跑通,然后把最容易出现并发和跨服务依赖的部分按本文的方式逐步微服务化,不要一开始就追求技术架构的极致,稳定跑起来再演进才是正路。

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

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

立即咨询