准备仿照本地一家市级博物馆的预约加文创商城流程来做项目时,我最初的想法特别简单:SpringBoot 写 CRUD,Vue 写页面,能跑通预约下单就行。但真正动手之后才发现,一旦把“预约放票”“商品库存”“支付回调”这些环节都考虑进去,单体应用会越来越臃肿,改一个功能要重启一整套服务,接口之间也缠在一起。所以这一版我干脆用微服务架构重写,以 SpringBoot + SpringCloud 作为后端底座,Vue 做前端,把整个业务按领域拆成独立的服务。用这个项目练手,最大的价值在于它不是一个纯展示型的 CRUD 系统:预约业务天然有集中放票的流量压力,商城业务有订单、库存、支付、退款,还涉及用户、展品、文创商品等多个业务域,拆成微服务几乎能碰到实际开发中所有常见问题——分布式锁、分布式事务、缓存一致性、服务熔断、接口幂等。这篇文章记录的就是我从单体改造成微服务、最终跑通整套预约商城系统的完整过程,以及一步步踩坑后的排错思路。如果你是正面临 Java 毕设选题、或者想从单体过渡到微服务的开发者,这篇内容应该能帮你省下不少时间。
1. 项目整体定位与架构设计思路
1.1 为什么选博物馆预约商城这个场景
很多同学做毕设或者练手项目,喜欢选“图书借阅系统”“新闻发布系统”这类业务。不是不行,但这类系统几乎只有用户的增删改查,很难体现微服务的优势,反而容易做成“为了微服务而微服务”。博物馆预约商城这个场景的妙处在于,它天然就是多业务域协作的形态:
用户要预约,就得有用户认证服务;用户要看展品,就得有展品与藏品服务;预约要限流,就得有预约时段库存管理;用户还可能顺便买文创产品,这就引出商品、订单、支付、库存这一整条商城链路;支付完成后还要发短信、发邮件通知,这又是一个独立的通知服务。每个业务之间的边界相对清晰,数据模型也不交叉,拆分起来不会像“订单和支付到底归谁管”那样纠结。
更关键的是,这个场景里有真实的并发压力。热门博物馆节假日放票,往往是几万人在同一秒抢,这比一个普通的管理后台更能激发你做分布式设计的动机。你会主动去想:库存放在哪里扣?同一个用户重复预约怎么办?支付回调延迟怎么处理?这些问题都来自真实需求,不是凭空造出来的。
1.2 服务拆分边界怎么定
服务拆分这件事,我的原则是:不要按页面拆,要按业务能力拆。也就是说,不能因为“预约管理页面”就建一个预约管理服务,而是要把“预约”这个业务域完整地放进一个服务里,包括预约的创建、取消、查询、库存校验。按页面拆会导致一个服务什么都沾一点,服务之间循环依赖严重。
我这个项目最终拆成了六个核心服务和两个基础组件:
| 服务名 | 职责边界 | 依赖的主要中间件 |
|---|---|---|
| gateway-server | 统一入口、路由转发、跨域处理、简易鉴权 | Nacos、Spring Cloud Gateway |
| user-server | 注册登录、用户信息、会员等级、积分 | MySQL、Redis、JWT |
| exhibit-server | 展品/藏品信息、展览排期、展品多媒体 | MySQL、Redis、Elasticsearch(可选) |
| reserve-server | 预约时段管理、门票库存、预约单 | MySQL、Redis、RabbitMQ(可选) |
| order-server | 文创商品、购物车、订单、库存扣减 | MySQL、Redis、Seata |
| pay-server | 支付单创建、支付回调、退款 | MySQL、Redis、Seata |
每个服务独立数据库,虽然在开发环境我并没有真的分库部署,但表归属严格按服务划分,service 之间不直接跨库查询。这是微服务最基本的一条纪律:如果需要查另一个服务的数据,调接口,而不是连它的库。刚开始可能觉得麻烦,但到后面你会发现,正是这条纪律保证了各服务可以独立开发和独立部署。
1.3 一次预约请求的完整调用链
用一个具体的场景说明整体架构:用户在小程序或者网页端选择“3 月 15 日上午场”的预约票,点击提交。
浏览器请求先到 gateway-server,网关根据路径前缀把请求路由到 reserve-server。reserve-server 收到请求后,先校验预约时段是否开放、当日余票是否充足,然后调用 user-server 的接口确认用户身份和实名信息(因为博物馆预约通常要实名),校验通过后扣减 Redis 中的余票计数,再向数据库写入预约单,最后通过消息队列通知 notify-server 发送预约成功短信。
整个过程里,服务之间的调用通过 OpenFeign 完成,服务实例的地址全部从 Nacos 注册中心动态获取,网关本身不感知后端服务部署在哪台机器上。这就实现了最基础的“分布式”:后端服务的多个实例可以水平扩展,前端只需要面对一个统一的网关入口。
2. 技术选型:版本组合是最大的坑
2.1 SpringBoot 与 SpringCloud 版本对照
这个项目里我踩的第一个大坑就是版本兼容问题。微服务项目里,SpringBoot、Spring Cloud、Spring Cloud Alibaba 三者有严格的版本对应关系,随意组合轻则启动报错,重则某些组件静默失效,查起来极其痛苦。我最终锁定的版本组合如下:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 1.8(或 11) | 不要轻易上 17,部分 Alibaba 组件适配有坑 |
| Spring Boot | 2.7.x | 2.x 生态最成熟,资料最多 |
| Spring Cloud | 2021.0.x | 对应 Spring Boot 2.7 版本线 |
| Spring Cloud Alibaba | 2021.0.x | 配套版本,包含 Nacos、Sentinel、Seata |
| Nacos | 2.2.x | 注册中心和配置中心,注意与客户端版本一致 |
| Vue | 2.7 + ElementUI | 前后端分离;Vue 3 + Element Plus 也可以 |
为什么不选 SpringBoot 3.x?因为 3.x 强制 JDK17,Spring Cloud 版本也需要跳到 2022.0.x 以上,很多第三方 starter 还没完全跟上。如果你只是做项目而不是生产环境验证新特性,没必要拿自己练手的项目去试生态兼容性。SprinBoot 版本太高遇到的问题往往比解决的问题多,这一点后面第 6 章会详细说。
2.2 微服务核心组件选型
注册中心和配置中心用 Nacos,这是目前最主流的选择。Nacos 相比 Eureka 的优势在于,它同时把“注册中心”和“配置中心”两件事都干了,我们不再需要额外部署 Spring Cloud Config。对于开发环境,Nacos 还是一个独立进程,启动后浏览器访问 8848 端口就能看到控制台,非常直观。
网关我选了 Spring Cloud Gateway,而不是 Zuul。Gateway 基于 WebFlux 响应式模型,性能好,而且天然集成 Spring Cloud 的熔断和限流配置。虽然大多数人用 Gateway 只做路由转发,但它在项目里还可以统一做跨域配置和账号状态校验,避免每个后端服务都处理一次 CORS。
远程调用用 OpenFeign,它内部集成了 Ribbon 负载均衡,配合 Nacos 注册中心,调用方只需要声明一个接口就能像调本地方法一样调用远程服务。对于初学者,OpenFeign 可能是上手门槛最低的 RPC 工具。
限流和熔断我接入了 Sentinel。预约放票那一刻流量会瞬间打上来,如果不对接口做限流,一个瞬间的峰值就可能把 MySQL 连接池打满。Sentinel 的 QPS 限流按服务维度配置,控制台上能实时看到通过 QPS 和被拦截的请求数,对理解“高并发保护”非常直观。
分布式事务组件选用 Seata,这个是第 5 章的重点,这里先提一句:不要一开始就把 Seata 引进来,等单体架构跑通、服务拆分稳定之后再加,否则你分不清问题是出在业务代码还是出在事务拦截器上。
2.3 Vue 前端环境与依赖配置
Vue 部分我用了 Vue 2.7 配合 ElementUI,如果你更想用新语法,直接上 Vue 3.4 + Vite + Element Plus + Pinia,差别主要在于组合式 API 和状态管理库。注意 Vite 和 Vue CLI 的启动方式不一样,别对着 Vue CLI 的文档跑 Vite 项目。
前端环境配置有几个高频坑:npm 安装依赖极慢,先设置国内镜像源;node-sass 和 sass-loader 版本与 Node 版本强绑定,Node 18 以上建议直接用 sass(dart-sass)。如果是做视频流相关的展厅直播功能,Vue 播放 m3u8 视频流推荐使用 video.js 配合 videojs-contrib-hls,不需要额外安装原生播放器插件。如果你需要在页面上直接展示展品介绍 PDF,最简单的方案是用 iframe 直接指向 PDF 文件地址,但如果涉及带签名的私有文件,就需要用 pdf.js 来解析和渲染,具体做法在第 4 章说。
3. 数据库设计与核心业务实现
3.1 预约业务的表设计与限流思路
预约业务是整项目的核心。博物馆预约有一个典型特征:同一时段可预约数量有限,而且用户通常要实名登记。所以预约表的设计要考虑的唯一性约束不是主键,而是“用户 + 参观日期”的组合约束,防止同一用户重复预约同一天。
预约单表我用近似这样的结构:
CREATE TABLE reserve_order ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, exhibit_id BIGINT NOT NULL, visit_date DATE NOT NULL, time_slot TINYINT NOT NULL COMMENT '1上午场 2下午场 3夜场', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已预约 2已取消 3已参观', ticket_count INT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL, UNIQUE KEY uk_user_visit (user_id, visit_date) );注意这里的主键 id 不是自增,而是由后端生成的分布式 ID。因为服务拆分之后,每个库都有自己的自增序列,跨服务合并数据时会冲突,所以主键统一用 MyBatis-Plus 的 ASSIGN_ID 策略,底层是雪花算法。关于分布式 ID 的细节在第 3.3 节展开。
限流思路分两层:数据库层通过唯一索引保证同一用户不重复预约;内存层用 Redis 保存每个时段的可预约余量。用户提交预约时先走 Redis 预扣,扣减成功再创建订单。如果订单创建失败,再把额度回补。这套“预扣-确认-回滚”的流程和商城库存扣减本质是一样的,在第 3.2 节里一起讲。
3.2 商城库存扣减:如何避免超卖
商城模块的库存扣减是另一个容易出问题的点。我一开始用数据库的乐观锁:update stock set count = count - 1 where id = ? and count > 0,这个写法在单体应用里完全没问题。但微服务场景下,用户下单要走 order-server,库存服务如果独立拆分,两次数据库操作之间会有网络调用间隔,并发场景下就会出现超额扣减。
项目里最终方案是 Redis 预扣 + 数据库落单。具体逻辑是:用户提交订单时,先用 Lua 脚本原子地扣减 Redis 中的库存计数;扣减成功后才调用数据库创建订单;支付超时或者用户取消,再异步回补 Redis 库存。
-- 扣减库存 Lua 脚本 if redis.call('get', KEYS[1]) >= tonumber(ARGV[1]) then return redis.call('decrby', KEYS[1], ARGV[1]) end return -1这种方案的好处是 Redis 单线程执行 Lua 脚本,扣减操作天然原子,不依赖分布式锁。库存的最终一致性由“订单状态”驱动:订单支付成功后,数据库库存才真正扣减;订单超时关闭,Redis 库存回补。短期不一致是可以接受的,因为用户能看到的“有票”只是可下单的凭据,真正占住名额的是订单,不是 Redis 中的数字。
3.3 分布式ID与MyBatis-Plus集成
前面说过主键不用自增。分布式场景下,多个服务同时写自己的库,如果都用自增 ID,未来数据汇聚、联调排查、做分库分表都会很难受。所以主键统一用雪花算法生成的趋势递增长整型。
MyBatis-Plus 集成非常简单,在实体主键上加注解即可:
@TableId(value = "id", type = IdType.ASSIGN_ID) private Long id;这会使用 MyBatis-Plus 内置的雪花算法生成 ID。唯一要注意的是雪花算法依赖机器时间,如果服务器时钟发生回拨,可能出现 ID 重复。开发环境不受影响,生产环境需要确保 NTP 时间同步。项目里你可以把时钟回拨做一次简易兜底:生成 ID 前记录上一次的时间戳,如果当前时间小于上次时间,就 sleep 几毫秒等待时钟修正后再生成。
3.4 接口幂等与前端防重
预约系统和支付系统对幂等要求很高。用户快速双击“提交预约”按钮,网络超时后重试同一笔支付回调,这些场景如果后端不做幂等,就会生成重复订单或者重复扣款。
后端幂等方案我用了 Redis 幂等令牌:用户进入预约页面时,后端生成一个唯一 token 存在 Redis 并返回前端;提交预约时必须携带这个 token;后端处理请求时先尝试删除 Redis 中的 token,删除成功说明这是第一次请求,可以继续执行,删除失败说明请求重复,直接返回“请勿重复提交”。Redis 单线程删除的原子性保证了同一 token 只会被处理一次。
支付回调的幂等更简单:支付服务通过 out_trade_no(商户订单号)去查支付单状态,如果已经处于“支付成功”状态,直接返回成功响应,不重复触发后续流程。不要小看这一点,银行和支付平台的回调在弱网环境下的重试频率非常高,没有幂等保护,你会看到通知短信被发好几遍的“奇观”。
4. 前端Vue的关键实现
4.1 axios封装与登录状态管理
前端如果每个页面都自己写 axios 请求,到后面必然是一团乱麻。项目里我单独封装了一个 request.js,创建 axios 实例,设置 baseURL 指向网关地址,然后在请求拦截器里统一从 localStorage 取 token 加在请求头里,在响应拦截器里统一处理 401 跳转登录、500 弹出错误提示。
// request.js 核心片段 const service = axios.create({ baseURL: '/api', timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { router.push('/login') } return Promise.reject(error) } )开发环境下 baseURL 配成/api,利用 Vue 开发服务器的 proxy 把请求转发到后端网关,这样就不需要后端处理跨域。生产环境用 Nginx 把/api反向代理到网关,前后端完全隔离。注意不要在前端直接写后端服务的 IP 和端口,否则每次部署位置变化都要重新打包前端。
4.2 路由映射与权限控制
博物馆预约系统里有普通用户和管理员两种角色。普通用户看展览预约和商城,管理员看后台管理页面。如果所有页面都一起打包进前端路由,任何懂前端的人都能从代码里看到后台接口地址,所以路由必须做权限控制。
我采用的是动态路由方案:用户登录后,后端返回该用户可访问的菜单列表,前端拿到菜单后通过 router.addRoute 动态添加路由。未登录用户只保留登录页和公开页面的路由。路由守卫里同时判断 token 是否存在:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else { next() } })按钮级别的权限用自定义指令实现,比如删除商品按钮加一条v-permission="'order:delete'",没有该权限的用户在当前组件里就看不到这个按钮。这种前端控制只是体验层面的优化,真正的接口权限还是要靠后端校验,前端权限无论如何都不能作为安全边界。
4.3 展品详情中的PDF与视频流处理
展品详情页会遇到两类多媒体展示需求:一是展品的简介 PDF 文件,二是展厅慢直播的视频流。
PDF 展示最简单可靠的办法,是把文件放到对象存储或者服务器静态目录,然后前端直接用 iframe 指向文件 URL,浏览器原生支持 PDF 预览。但如果文件是私有资源、带访问签名,就不能直接暴露 URL,这时用 pdf.js 加载二进制流渲染。项目里我把两种方式都做了封装,优先用 iframe 直链,私有文件走 pdf.js。
视频流这里我需要提醒一句:Vue 播放 m3u8 视频流如果你用原生 video 标签去指向 m3u8 地址,在 Chrome 上是播放不了的,因为浏览器原生不支持 HLS 流。需要引入 hls.js,或者用 video.js 配合 videojs-contrib-hls。我选择的是 hls.js,安装后用十几行代码就能跑通:
import Hls from 'hls.js' const video = document.getElementById('liveVideo') if (Hls.isSupported()) { const hls = new Hls() hls.loadSource('https://your-cdn/live/stream.m3u8') hls.attachMedia(video) }4.4 前端打包并集成到SpringBoot
项目开发完成后,如果只想把前端塞进后端一起发布,可以执行npm run build得到 dist 目录,然后把静态文件复制到某个服务的src/main/resources/static下。这样确实省事,但要注意两个问题。
第一个是路由模式:Vue 如果用 history 模式,刷新一个非根路径时会请求后端接口,而后端没有对应接口就返回 404。需要在后端加一个 fallback 控制器,把所有非 API 请求转发到 index.html。开发时这个坑特别隐蔽,因为前端开发服务器自己会处理 history fallback,你不会察觉,部署后才暴露。
第二个问题是资源路径:Vue 打包出来的静态资源默认使用绝对路径/assets/xxx.js,如果后端服务的 context-path 不是根路径,资源就会加载失败。要么把 publicPath 配成相对路径./,要么让网关层统一转发静态资源。
如果你有条件,我建议不要用 Java 后端托管前端静态文件,而是独立部署一个 Nginx。前后端分离的项目,Nginx 处理静态资源效率更高,同时可以统一做 gzip、缓存控制、反向代理。把后端托管作为快速演示方案就好。
5. 分布式场景下的核心问题
5.1 分布式事务:订单、支付与库存的一致性
单体应用里,一个事务可以同时操作订单表、库存表、支付流水表,要么全部成功要么全部回滚。但微服务拆开后,订单数据在 order-server,库存数据在 reserve-server,支付数据在 pay-server,三个库之间没有本地事务可言。
在 Seata 的 AT 模式里,发起方方法上加上@GlobalTransactional注解,Seata 会拦截所有参与分支事务的数据源操作,自动记录数据快照和 undo_log。如果后续某个分支事务失败,Seata 会根据 undo_log 逆向回滚之前已提交的数据。使用起来非常方便,几乎是“零侵入”。
@GlobalTransactional(name = "create-order-tx", timeoutMills = 30000) public void createOrderAndDeductStock(OrderDTO dto) { // 这里会调用 order-service 写订单 // 调用 reserve-service 扣库存 // 调用 pay-service 创建支付单 // 任何一个子调用异常,前面已经提交的本地事务都会被自动回滚 }但要清醒地认识到:AT 模式适合并发量不高的业务场景,它通过锁和日志换一致性,性能开销不小。对于预约这种高并发抢票场景,更合理的设计是先扣 Redis 库存、异步建单、支付成功后再异步扣减数据库库存,把一致性从“强一致”降级为“最终一致”。项目里我把 Seata 用在订单创建和支付回调的低并发环节,把 Redis 预扣放在高并发入口,这个组合是我实测下来比较稳妥的分层方案。
5.2 分布式锁:Redis锁的正确写法
微服务每个服务都可以多实例部署,比如 order-server 起了两个实例,同一个用户同时提交两笔订单时,两个实例各自执行代码,如果有共享资源需要互斥,就必须引入分布式锁。
分布式锁最经典的实现是 Redis 的SET key value NX EX seconds,但如果你自己封装,要注意两个细节:一是 value 要唯一,释放锁时只能释放自己加的锁;二是释放锁要用 Lua 脚本原子完成“检查-删除”,避免先 get 再 del 之间的并发窗口。
项目里我用的 Redisson,它封装的RLock天然支持看门狗自动续期,业务执行时间长于锁有效期时,锁不会因为超时被提前释放。但 Redisson 默认锁等待时间是 30 秒,如果业务执行时间确实长,可以在加锁时显式指定 leaseTime,避免锁无限续期导致其他请求长时间阻塞。
RLock lock = redissonClient.getLock("reserve:user:" + userId); boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS); if (locked) { try { doReserve(); } finally { lock.unlock(); } }这里最核心的心得是:锁粒度要小。不要对整个“创建预约”方法加锁,要只对真正需要互斥的资源加锁,比如“某个用户同一时段”加锁,或“某个展览的库存”加锁。锁范围越大,系统吞吐越差,分布式锁也就从保护机制变成了性能瓶颈。
5.3 缓存一致性:展品信息缓存更新
展品信息、展览排期这类读多写少的数据,一定需要加缓存。我在 exhibit-server 里用 Redis 缓存展品详情,key 类似exhibit:detail:{id},value 是 JSON。问题在于,运营人员修改展品介绍后,缓存如何更新。
最简单的策略是“更新数据库后删除缓存”,而不是“更新数据库后更新缓存”。因为更新缓存涉及并发写,很容易出现旧缓存覆盖新缓存的问题;删除缓存则让下一次读取时回源数据库并重建缓存,实现简单也不容易出现数据不一致。
但删除缓存也有一个经典的并发问题:一个线程更新数据库后删除缓存,另一个线程在删除前读到了旧缓存并准备回写,刚好覆盖了新写的数据。所以项目里我用了延迟双删策略:更新数据库后立即删一次缓存,然后等 500 毫秒再删一次。第二次删除把并发线程回写的旧值清掉,最终下一次读取会拿到最新数据。这个延迟时间要大于一次业务查询的耗时,实测 500ms 在大多数场景是够用的。
5.4 服务容错与降级
微服务链路很长,任何一个节点出问题都可能拖垮整个调用链。预约高峰时段,如果展品服务因为慢 SQL 响应变慢,预约服务调用它时 Feign 默认的 1 秒超时就会大量报错,进而占用预约服务的线程池。
所以每个 Feign 调用都要配置超时和降级。超时时间不要设太短也不要太长,内部接口 3 秒左右比较合适;降级逻辑返回一个稳定的默认值或友好提示,而不是把异常直接抛给用户。Sentinel 的熔断规则可以按接口的异常比例或慢调用比例触发,熔断后请求直接走降级方法,让后端服务有时间恢复。
我在预约场景里对两个非核心功能做了降级:展品 3D 模型加载和展厅直播流。这两个功能流量大但对主流程(预约下单)没有直接影响,放票高峰期把它们的 Sentinel 阈值调低,可以保证预约接口的稳定。这个取舍在单体架构里很难做,但微服务架构天然支持按服务维度独立控制,这也是微服务架构对比单体的一个真实优势。
6. 常见问题与排查技巧实录
6.1 启动类问题速查表
我项目排错过程中遇到的启动问题基本都能归到下面几类,整理成速查表供直接对照:
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| Nacos 控制台访问不了 | 8848 端口被占用或未放行 | netstat -ano查端口占用,改 Nacos 配置或释放端口 |
| 服务启动后注册不上 Nacos | 客户端版本与 Nacos 服务端版本不一致 | 统一使用 Spring Cloud Alibaba 2021.0.x 对应的 nacos-client 版本 |
| 启动报 NoSuchMethodError | SpringBoot 与 Cloud 版本不匹配 | 按第 2.1 节版本表对齐 pom 依赖 |
| Feign 调用一直超时 | 服务在 Nacos 中注册的 IP 是内网不可达地址 | 检查 Nacos 所在 host 配置,微服务注册使用真实 IP(spring.cloud.nacos.discovery.ip可手动指定) |
| 数据库连接池被占满 | 高峰期请求量过大,存在慢 SQL | 给核心接口加 Sentinel 限流;加索引;排查慢查询日志 |
| Redis 内存不断增长 | 缓存 key 没有设置过期时间,或过期策略有误 | 检查缓存写入逻辑,统一设置 TTL,必要时用 SCAN 扫描大 key |
6.2 服务间调用与IDEA配置技巧
用 IDEA 同时启动多个服务时会有不少小问题。多模块项目里多个 SpringBoot 默认端口都是 8080,如果不改端口,后启动的服务会因为端口占用直接退出。我建议每个服务在application.yml里显式设置不同端口:
server: port: 8801 # user-serverIDEA 里 Run Configuration 很强大,但你不需要给每个服务都新建一份启动配置,直接在服务模块的 Application 类右键 Run 就行,关键是确认每个服务的spring.profiles.active正确加载了对应环境的配置。如果你要把启动参数传给 SpringBoot(比如临时改端口),在 IDEA 的 VM options 里加:
-Dserver.port=8802如果希望端口随机分配(比如本地起多个实例调试负载均衡),可以设置server.port=0,SpringBoot 会自动找一个空闲端口,然后在 Nacos 控制台观察服务实例注册情况。这是验证负载均衡最简单的方法,我在本地起了两个 user-server 实例,通过网关连续请求几次,能看到请求被分发到不同端口实例上。
6.3 前端跨域与联调问题
前后端联调阶段跨域是必踩的坑。开发环境我推荐用 Vue 的 devServer proxy 解决,因为它完全不需要后端配合,还不会导致生产环境遗留额外的跨域代码:
// vue.config.js module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8800', // 网关地址 changeOrigin: true, pathRewrite: { '^/api': '' } } } } }生产环境如果你用 Nginx,做一层反向代理,同样不需要后端开 CORS。只有在“前端静态文件在 CDN、后端接口在另一个域名”这种场景下,才需要后端网关统一配置 CORS。注意网关层面配置 CORS 时,一旦透传头重复(比如你又在某个微服务里加了 CORS 配置),浏览器会报“Multiple CORS header”错误,这个排查起来很容易懵。
6.4 三条避坑建议
最后分享三条项目过程中沉淀下来的实操经验,也是我如果再做一个微服务项目会立刻执行的三件事。
第一,先跑通骨架再加业务。不要一上来就写预约下单的逻辑。先把六个基础模块建好、Nacos 启动、网关连上、一个最简单的 user 查询接口调通,把整条链路走通后再往里面填业务。微服务项目的大部分复杂度都集中在“通信链路”上,业务逻辑反而是相对简单的部分。骨架通了,后面就是往流水线上加零件。
第二,依赖版本要锁死。整个项目的 pom 里不要出现一个依赖多个版本的情况,特别是 Spring Boot、Spring Cloud、Spring Cloud Alibaba 三者一定要用官方版本矩阵里对应的组合。最好在建项目初期就把 parent 的依赖管理理顺,后期不要轻易升级。这个项目的成功,一半功劳要给稳定的版本组合。
第三,日志要先规划好。每个服务都要配置独立的日志文件,并且日志中要带上 traceId,这样一次请求经过多个服务时,你可以根据同一个 traceId 把所有日志串起来看。没有 traceId,微服务里排查一个问题要在好几个服务之间来回翻日志,效率低到怀疑人生。哪怕你不用专门的链路追踪框架,也要在网关过滤器中生成一个 UUID,放入请求头透传给各个服务,在日志 pattern 中打印出来。
做完这个项目,我的个人体会是:微服务本身不难,难的是在拆分边界和一致性方案上做权衡。预约商城这个题材刚好能逼你面对这些权衡——你是选择强一致还是最终一致,是把库存放在 Redis 还是 MySQL,是同步调服务还是异步走消息队列,每个决定背后都有真实的业务代价。把这些想清楚,比敲一万行业务代码更有成长。也建议你做完后再把 Sentinwel 限流规则细化、把 Seata 换成 TCC 模式、或者用 Docker 把整套服务编排起来,这些方向的扩展会让这个项目从“毕业设计”变成真正能说自己“懂微服务”的验证作品。