
1. 把四川自驾游攻略做成微服务先想清楚值不值先说结论如果你只是交个毕设作业或者做个几千人用的校园级项目单体 SpringBoot 应用两三天就能跑通微服务反而是在给自己挖坑。但我最终仍然选择用微服务架构来做这个四川自驾游攻略管理系统原因有三。第一自驾游攻略这个业务天然是读多写少、局部热点高并发的典型场景。节假日川西大环线、稻城亚丁这些路线一旦进入热门榜单某个路线的详情页可能瞬间涌入大量并发访问。单体应用遇到这种情况只能整体扩容而微服务可以把路线服务、攻略服务单独拆出来弹性伸缩代价完全不同。第二这个系统的功能边界足够清晰。用户体系、路线管理、攻略发布、景点头条、酒店关联、文件存储、消息通知每个模块的迭代频率和团队分工完全不同。拆成微服务后各个服务可以独立发布、独立测试互不干扰。第三也是最现实的——SpringCloud 全家桶是这个领域最常被问到的技术栈。做这个项目不只是为了能跑更是为了把微服务拆分思路、服务注册发现、网关路由、配置中心、分布式锁这些面试必问题在真实代码里跑一遍。等你去面 Java 后端岗面试官问微服务项目里你们怎么用的 Nacos你至少能说出真实场景中遇到过的坑而不是只会背八股文。当然我前面说的挖坑也是真的。微服务意味着你要同时维护多个服务进程Debug 复杂度翻倍分布式事务、分布式锁、服务间调用都要额外处理数据库也不能一个库全搞定。所以如果你决定做微服务一定要先接受一个前提前期多花 1-2 周搭基础设施后期才有收益。临时抱佛脚用微服务最后大概率被环境配置活活拖垮。我这套系统的最终技术选型是这样SpringCloud Alibaba 全家桶Nacos 做注册中心和配置中心、SpringCloud Gateway 做网关、SpringBoot 2.7 做服务基础框架、Vue3 Vite Element Plus 做前端、MySQL 做业务数据库、Redis 做缓存和分布式锁、MinIO 做文件存储。这套组合的好处是每个组件的社区资料都极其丰富踩坑时有大量现成答案。2. 服务边界和数据库拆分先画业务地图别一上来就写代码很多人做微服务项目最常犯的错就是先把单体写出来然后强行按 controller 拆包。这根本不叫微服务叫单体的多个端口。我这次做四川自驾游攻略系统第一周全在画业务边界和设计数据模型一行业务代码没写。2.1 五个核心服务怎么拆我把整个系统拆分成了五个服务用户服务user-service负责注册登录、个人资料、收藏记录、关注关系。这是最基础的服务几乎所有服务都要调它的用户信息接口。路线服务route-service负责自驾游路线的增删改查、路线详情、途径景点、路线评分、热门路线排行。这是系统最核心的服务也是高并发访问最集中的地方。攻略服务guide-service负责游记攻略的发布、编辑、审核、评论、点赞。攻略和路线是强关联的攻略可以挂载某条路线路线详情页也要推荐相关攻略。酒店服务hotel-service负责沿途酒店信息维护、酒店关联路线、酒店预订简化版。文件服务file-service负责图片、视频、攻略附件等对象存储的上传下载。所有服务产生的文件都统一走这个服务处理。为什么把用户和文件拆出来因为用户服务以后要对接微信登录、OAuth2 这些第三方认证迭代频率高文件服务本质是I/O 密集型操作和业务服务的 CPU 密集逻辑混在一起会互相拖累。道理很朴素拆分不是按数据表拆而是按变化频率和资源特征拆。2.2 数据库拆分方案每个服务一套库数据库我按服务边界拆成了五套库每套库里只放自己的表。这里我做了两个重要取舍。第一个取舍基础数据是否冗余。用户服务里有 user 表路线服务里也需要展示创建人昵称、头像按规范做法是服务间通过接口调用获取但真实项目里每次都同步调接口太慢。我最终采用了折中方案在路线服务里冗余了用户 ID 和用户昵称两个字段用户昵称变更时通过消息通知更新冗余字段。这个方案在学校毕设里完全够用面试时也能顺便讲清楚冗余字段 最终一致性的思路。第二个取舍关系统怎么建。路线和景点是一对多路线和攻略是多对多而攻略和酒店又是弱关联。强行跨库 JOIN 是不现实的我在代码里通过聚合查询加缓存来解决。以路线详情页为例页面上要展示路线基本信息、途径景点列表、关联攻略数、附近酒店推荐、用户收藏状态这其实是 4 个服务的数据。我的做法是路线服务提供一个聚合接口内部依次调用景点信息和攻略信息然后组装返回。虽然多了一次网络开销但用上 Redis 缓存后实际响应时间在 20ms 以内完全可以接受。2.3 核心表结构和字段速览我挑几张最有代表性的表说说设计思路这些表决定了后面代码怎么写。路线主表routeCREATE TABLE route ( id bigint(20) NOT NULL AUTO_INCREMENT, route_name varchar(100) NOT NULL COMMENT 路线名称, start_point varchar(100) DEFAULT NULL COMMENT 起点, end_point varchar(100) DEFAULT NULL COMMENT 终点, days int(11) DEFAULT NULL COMMENT 推荐天数, distance_km decimal(10,2) DEFAULT NULL COMMENT 总里程, difficulty tinyint(4) DEFAULT 2 COMMENT 难度等级 1-5, cover_url varchar(500) DEFAULT NULL COMMENT 封面图, creator_id bigint(20) DEFAULT NULL COMMENT 创建人, creator_name varchar(50) DEFAULT NULL COMMENT 创建人昵称冗余, like_count int(11) DEFAULT 0 COMMENT 点赞数, status tinyint(4) DEFAULT 1 COMMENT 1-上架 0-下架, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_status_like (status, like_count) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里加了一个idx_status_like联合索引因为热门路线榜单就是查所有上架路线按点赞数降序这个索引可以直接命中。微服务架构下数据库索引设计更容易被忽略总觉得拆了服务就万事大吉其实单表查询性能才是兜底的。景点表scenic_spot我单独讲一下它和路线是多对多关系我用了一张中间表route_spot_rel来维护。这里有个小技巧中间表里加上行程天数序号字段比如第 1 天在雅安、第 2 天在康定这样前端渲染路线图时直接按序号排序不用复杂的查询逻辑。3. 注册中心、配置中心与网关SpringCloud 地基怎么打微服务的第一道坎永远是基础设施。我见过太多人把代码写完了结果 Nacos 起不来、网关路由配不通几天搞不定还找不到原因。这部分的坑不在代码在于你对组件工作机制的理解。3.1 用 Nacos 还是用 Eureka我直接选型 SpringCloud Alibaba Nacos原因很干脆Eureka 2.0 已经停止开发而且它只做注册中心配置中心还要单独搭 Spring Cloud Config。Nacos 一个组件同时干两件事还提供了控制台可视化界面服务列表、配置列表清清楚楚对新手极其友好。热搜词里有人搜springcloud入门简洁如果你想简洁入门Nacos 就是最简单的那个答案。我用的版本组合是 Spring Cloud Alibaba 2021.0.5.0对应 Nacos Server 2.2.0。版本对应关系是最大的坑点Spring Cloud 和 Spring Boot 版本捆绑极严稍微不匹配就翻车。推荐直接参考官方文档里的版本说明矩阵别自己猜。Nacos Server 我用 Docker 起docker run -d \ --name nacos-server \ -p 8848:8848 \ -p 9848:9848 \ -e MODEstandalone \ -e SPRING_DATASOURCE_PLATFORMmysql \ -e MYSQL_SERVICE_HOST127.0.0.1 \ -e MYSQL_SERVICE_DB_NAMEnacos_config \ -e MYSQL_SERVICE_PORT3306 \ -e MYSQL_SERVICE_USERroot \ -e MYSQL_SERVICE_PASSWORD123456 \ nacos/nacos-server:v2.2.0注意我额外映射了 9848 端口这是 Nacos 2.x 的 gRPC 通信端口。如果你只映射 8848服务注册大概率会失败但控制台看起来又是正常的这个坑我排查了很久才反应过来。3.2 每个服务如何接入注册中心每个 SpringBoot 服务的 pom.xml 引入依赖dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-nacos-discovery/artifactId /dependency配置文件 application.ymlspring: application: name: route-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: travel-dev config: server-addr: 127.0.0.1:8848 file-extension: yaml namespace: travel-dev group: DEFAULT_GROUP server: port: 8082这里必须强调spring.application.name的值它决定了服务在注册中心里的身份标识也是后续网关路由的 key务必用短横线风格命名。我把公共的配置放到了 Nacos 配置中心里比如数据库连接信息、Redis 地址、日志级别。这样调整配置不用重新打包镜像Nacos 里改完点击发布服务端会自动刷新。配置中心还可以设置命名空间namespace我用travel-dev区分开发环境以后想加travel-prod生产环境几秒钟就能完成隔离。3.3 网关路由与跨域问题所有前端请求一律先打网关网关再转发到具体服务。网关的核心作用有三个统一入口、统一鉴权、统一跨域处理。路由配置spring: cloud: gateway: routes: - id: route-service uri: lb://route-service predicates: - Path/api/route/** filters: - StripPrefix1 - id: guide-service uri: lb://guide-service predicates: - Path/api/guide/** filters: - StripPrefix1 default-filters: - DedupeResponseHeaderAccess-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRSTlb://是 LoadBalance 协议的缩写网关会根据服务名从 Nacos 拉取实例列表然后负载均衡转发。前端访问/api/route/list时网关会把前缀/api剥掉然后转发到 route-service 的/route/list接口。跨域配置我建议只在网关层做一次后端服务一律不处理跨域否则多处配置会出现重复响应头问题。我的全局跨域配置长这样Configuration public class CorsConfig { Bean public CorsWebFilter corsWebFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsWebFilter(source); } }这里有个血泪教训前端如果用了axios默认的withCredentials: true网关配置又同时设置了Access-Control-Allow-Origin: *浏览器会直接拦截请求。解决方案就是上面代码里addAllowedOriginPattern(*)配合setAllowCredentials(true)同时使用这两个配置缺一个都不行。4. 攻略服务与路线服务SpringBoot 核心代码怎么落地基础设施搭好之后就是踏踏实实写业务代码了。我挑核心的路线列表、攻略详情、文件上传三个功能把代码设计讲清楚。4.1 路线服务分页查询与缓存策略热门路线列表是读多写少的典型接口。我的实现思路是先查缓存缓存没有查数据库查完回填缓存再用定时任务主动刷新热点数据。Service 层核心代码Service RequiredArgsConstructor public class RouteServiceImpl implements RouteService { private final RouteMapper routeMapper; private final StringRedisTemplate redisTemplate; private static final String ROUTE_HOT_KEY route:hot:list; private static final long CACHE_TTL 30L; // 分钟 Override public ListRouteVO getHotRoutes() { // 1. 先读缓存 String cached redisTemplate.opsForValue().get(ROUTE_HOT_KEY); if (StringUtils.hasText(cached)) { return JSON.parseArray(cached, RouteVO.class); } // 2. 缓存未命中查数据库 ListRouteVO routes routeMapper.selectHotRoutes(); // 3. 回填缓存 redisTemplate.opsForValue().set(ROUTE_HOT_KEY, JSON.toJSONString(routes), CACHE_TTL, TimeUnit.MINUTES); return routes; } Override Transactional(rollbackFor Exception.class) public Boolean likeRoute(Long routeId, Long userId) { // 用分布式锁防止重复点赞具体实现见第 6 章 RoutePO route routeMapper.selectById(routeId); if (route null) { throw new BizException(路线不存在); } // 点赞数变更缓存也要同步失效 routeMapper.increaseLikeCount(routeId); redisTemplate.delete(ROUTE_HOT_KEY); return true; } }这个代码本身不复杂但我要强调三个容易被忽略的点第一缓存一旦更新必须主动删除或更新对应缓存。我直接删掉整个热门列表缓存简单粗暴且有效。如果只更新单个路线缓存反而容易出现数据不一致因为列表的排序可能已经变了。第二分页查询我建议用 MyBatis-Plus 的分页插件但注意分页查询不要迷信缓存穿透保护。真正的缓存穿透发生在请求一个不存在的 ID 时每次都会打到数据库我开发时用空值缓存方式解决查询结果是 null 也写入缓存TTL 设短一些比如 5 分钟这样能有效防止恶意请求穿透。第三热点路线的排序我用的字段是点赞数加浏览量加权分值。SQL 里用ORDER BY (like_count * 0.6 view_count * 0.4) DESC实现推荐算法简单又能自圆其说面试时也讲得清楚。4.2 攻略服务富文本内容与关键词提取攻略服务相比路线服务难点在正文是富文本而且要做简单的搜索。富文本内容我用的是text类型存储前端用 WangEditor 生成 HTML 片段提交。这里踩过一个坑编辑器上传的 base64 图片会把 MySQL 的max_allowed_packet打爆默认 4MB 完全不够用。解决方式是在 file-service 做图片上传编辑器只拿到 URL 再插入正文。关于搜索我用了一个取巧的方案——不是用 ElasticSearch而是用HanLP 分词工具提取关键词把关键词字段存在攻略表里再用 MySQL 的LIKE查询。热搜词里有人搜hanlp分词在springboot说明不少人也在研究这个思路。HanLP 引入 pom 依赖后几行代码就能对标题和摘要分词public ListString extractTags(String title, String summary) { ListTerm termList HanLP.segment(title summary); return termList.stream() .filter(term - term.nature ! null term.nature.toString().startsWith(n) term.word.length() 2) .limit(5) .map(term - term.word) .collect(Collectors.toList()); }过滤掉非名词和单字词后提取出来的稻城、亚丁、自驾、路线这些标签直接存进guide_tag字段列表页展示关键词标签详情页根据标签推荐相似攻略。这套方案虽然没有 ES 召回归档那么专业但足够一个课设或小规模项目使用而且很好讲明白。4.3 文件服务MinIO 整合与防盗链文件服务我选 MinIO 而不是 FastDFS原因很简单MinIO 部署简单兼容 S3 协议还有一套好看的控制台。SpringBoot 整合 MinIO 是热搜词 top 级别说明大家都在这上面踩过坑我同样分享一下完整流程。pom 依赖dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.2/version /dependency配置类Configuration public class MinioConfig { Value(${minio.endpoint:http://127.0.0.1:9000}) private String endpoint; Value(${minio.access-key:minioadmin}) private String accessKey; Value(${minio.secret-key:minioadmin}) private String secretKey; Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } }上传接口PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file, RequestParam(defaultValue travel) String bucket) throws Exception { if (file.isEmpty()) { return Result.fail(文件不能为空); } String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); String objectName travel/ DateUtil.format(new Date(), yyyyMMdd) / UUID.randomUUID().toString().replace(-, ) suffix; boolean bucketExists minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build()); if (!bucketExists) { minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); } minioClient.putObject(PutObjectArgs.builder() .bucket(bucket) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); String url endpoint / bucket / objectName; return Result.success(url); }两个细节我后来才补齐一是对象名用travel/日期/uuid.后缀的方式做多层路径避免对象数量过多后出现目录性能问题二是上传后返回的 URL 如果希望长期有效MinIO 默认桶权限只能设为public否则要到控制台配置访问策略这块容易忽略。5. Vue3 前端把路线画上地图才算自驾游系统前端这块我用 Vue3 Vite Element Plus 搭建路由用 Vue Router 4状态管理用 Pinia。整体页面包括首页自驾路线推荐、路线详情、攻略发布、个人中心四个大模块。这里挑两个最有技术含量的部分讲地图路线渲染和 m3u8 视频播放。5.1 腾讯地图组件与路线绘制自驾游系统的灵魂是路线可视化。我在路线详情页用腾讯地图 JavaScript API 实现途经景点的连线展示。选择腾讯地图的原因有二个人开发者免费配额高且国内访问稳定附近没有麻烦的地域限制问题。注册腾讯地图开发者账号后得到 key然后在入口 HTML 引入script srchttps://map.qq.com/api/gljs?v1.expkeyYOUR_KEY/scriptVue 组件里初始化地图并绘制折线script setup import { onMounted, ref } from vue import { getRouteSpots } from /api/route const props defineProps({ routeId: { type: Number, required: true } }) let map null const spots ref([]) onMounted(async () { const res await getRouteSpots(props.routeId) spots.value res.data initMap() }) function initMap() { map new TMap.Map(document.getElementById(route-map), { center: new TMap.LatLng(30.67, 104.06), zoom: 8 }) // 构造折线 const path spots.value.map(item new TMap.LatLng(item.lat, item.lng)) const polyline new TMap.MultiPolyline({ map, geometries: [{ paths: path }], styles: { default: new TMap.PolylineStyle({ color: #FF6B35, lineWidth: 6, borderWidth: 2, borderColor: #FFFFFF }) } }) // 添加途经点标记并绑定点击事件弹出景点卡片 const markers spots.value.map(item ({ id: item.id, position: new TMap.LatLng(item.lat, item.lng), content: div classspot-marker${item.name}/div })) new TMap.MultiMarker({ map, geometries: markers }) } /script这里最核心的体验点是从后端拿到的景点数据必须按第几天经过顺序排序折线才不会乱画。我在 2.3 节提到中间表加了行程天数序号就是为这里准备的。如果前端把所有景点直接连起来很可能出现一条绕来绕去的乱线用户一看就觉得不专业。你可能会问为什么不用 ECharts 的地图直接画因为自驾游的路线不是行政地图上的静态线而是真实的经纬度轨迹腾讯地图能提供路线缩放、拖拽、路径重新规划等交互能力这是 ECharts 给不了的。5.2 m3u8 视频播放免安装播放器的思路攻略详情页里用户上传的自驾游视频我解释一下思路移动端 H5 直接播放 m3u8 格式有点麻烦最简单的方案是集成hls.js。它本质是 JS 库无需安装任何原生插件npm 装完之后几行代码就能播放script setup import Hls from hls.js import { onMounted, ref } from vue const videoRef ref(null) const videoUrl ref(https://your-minio-server/travel/video/xxx/playlist.m3u8) onMounted(() { if (Hls.isSupported()) { const hls new Hls() hls.loadSource(videoUrl.value) hls.attachMedia(videoRef.value) } }) /script template video refvideoRef controls classw-full/video /template这里要说两个实际项目里才会遇到的问题一是视频文件一般比较大file-service 上传接口要注意 Nginx 上传大小限制我配置文件里设置client_max_body_size 200m才能传视频二是 m3u8 文件指代的 ts 分片文件必须和 m3u8 在同一个 bucket 下且路径正确否则播放器会一直报NetworkError控制台看是 404。这个坑我在本地测试时花了半天才定位。5.3 动态路由与登录鉴权攻略发布、收藏路线这些接口需要登录后才能访问。我在前端用 Pinia 存储 JWT Token路由守卫里判断是否有 token。动态路由我采用的是后端返回用户权限码前端根据权限码过滤路由表生成。这个功能在热搜词里是高频搜索对象我来分享一个简版逻辑// router/index.js const allRoutes [ { path: /guide/edit, component: () import(/views/guide/edit.vue), meta: { auth: true } }, { path: /admin, component: () import(/views/admin/index.vue), meta: { role: admin } } ] router.beforeEach((to, from, next) { const userStore useUserStore() if (to.meta.auth !userStore.token) { next(/login) return } if (to.meta.role to.meta.role ! userStore.userInfo.role) { next(/401) return } next() })前端纯靠路由守卫做鉴权只是「防君子不防小人」真正的安全防线在后端接口的PreAuthorize注解和 Token 校验过滤器。前端只是提升体验后端才是安全兜底。6. 分布式锁、事务与登录态分布式系统绕不开的三个坑微服务项目做到这一步会 CRUD已经不能体现你的水平了真正让你和普通人拉开差距的是分布式场景下的三个经典问题。我逐个讲讲我在这个系统中是怎么处理的。6.1 Redis 分布式锁解决重复点赞先讲最简单的场景。用户给路线点赞如果网络慢或者手速快连续点了两次赞前端没有做防抖的话就会发两个请求。单体应用里靠数据库唯一索引就能挡住但微服务场景下点赞接口可能被负载均衡分到多个实例上单纯靠数据库已经不够高效需要 Redis 分布式锁介入。我用 Redis 的SET NX EX指令实现最简单可靠的分布式锁public Boolean likeWithLock(Long routeId, Long userId) { String lockKey lock:route:like: routeId : userId; // 设置锁尝试获取 3 秒锁过期时间 10 秒 Boolean result redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (Boolean.FALSE.equals(result)) { return false; // 已经有请求在操作直接返回 } try { // 执行点赞逻辑 return likeRoute(routeId, userId); } finally { redisTemplate.delete(lockKey); } }这段代码看起来简单实际生产中还有一个大坑——锁误删。如果 A 线程加锁后执行时间超过 10 秒锁自动过期此时 B 线程拿到锁A 线程执行完 finally 里的delete把 B 刚加的锁误删了。解决方式是存入锁的线程标识删除前比对是同一线程才删除。我用的是 UUID 放入锁 value删除前再取出比对String requestId UUID.randomUUID().toString(); Boolean result redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(result)) { try { // 业务逻辑 } finally { String currentValue redisTemplate.opsForValue().get(lockKey); if (requestId.equals(currentValue)) { redisTemplate.delete(lockKey); } } }这是面试高频考点之一能讲出这个细节比背十道Redis 分布式锁原理都管用。6.2 分布式事务别一上来就上 Seata攻略详情页要展示攻略基本信息 攻略下的评论数 攻略挂载的路线点赞数这三个数据分布在三个服务里。如果我只提交了攻略基本信息评论数和点赞数更新失败就会出现页面数据不一致。这是标准的跨服务事务问题。主流方案无非三个Seata 分布式事务框架、本地消息表、TCC 补偿。但我实际做下来强烈建议如果不是强一致场景别一上来就上 Seata。Seata 部署复杂AT 模式对数据库有侵入要建 undo_log 表TCC 模式写业务补偿代码又极其繁琐。对毕设项目来说这完全是过度设计。我最终采用的是本地消息表 定时任务补偿方案。具体做法是攻略发布成功后在攻略服务里同时写一条消息记录到消息状态表状态为待处理。定时任务每 5 分钟扫描一次把未同步的评论数、点赞数推送到路线服务的 MQ 或者直接通过 Feign 调用补偿接口。这样即使某一次同步失败下次定时任务还能重试保证最终一致。这个方案代码量不多道理却非常契合真实分布式系统最终一致性的设计思路面试官听到这里通常会点头。如果你确实想体验 Seata比如模拟酒店预订扣库存生成订单的强事务场景我可以给一条选型建议用 AT 模式就对了尽量别碰 TCC。AT 模式对业务代码侵入最小几乎只加一个GlobalTransactional注解适合演示和入门理解。6.3 登录态JWT 还是分布式 Session微服务拆开后用户登录了 A 服务B 服务怎么知道这个用户是谁两种方案。传统的 Spring Session Redis 方案登录成功把 Session 存 Redis所有服务共享 Session。这个方案实现简单但状态都在服务端每次请求都要查一遍 Redis而且 Session 序列化经常出幺蛾子。我用的方案是 JWT登录成功后生成 Token前端每次请求带上放在 Header 里网关的全局过滤器校验 Token 后把 userId 解析出来放进请求头后端服务直接从请求头里取用户 ID。JWT 无状态、天然适合分布式但要注意 Token 不能太长否则请求头带宽压力大而且明文的 Payload 不要放敏感数据。网关统一鉴权的代码骨架Component public class AuthFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token exchange.getRequest().getHeaders().getFirst(Authorization); // 白名单路径直接放行 String path exchange.getRequest().getURI().getPath(); if (path.startsWith(/api/user/login) || path.startsWith(/api/user/register)) { return chain.filter(exchange); } // 解析 Token失败则拦截 try { Claims claims JwtUtil.parseToken(token.replace(Bearer , )); ServerHttpRequest mutatedRequest exchange.getRequest().mutate() .header(userId, claims.get(userId).toString()) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (Exception e) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } } Override public int getOrder() { return -100; } }注意我在网关里是把解析出来的 userId 写进自定义 Header 传给下游而不是只校验 Token 合法性。这样做的好处是后端服务完全不用关心 Token 解析逻辑直接从request.getHeader(userId)取就行了。有人会担心 Header 被伪造真实项目里还应当在网关和下游服务之间加上内部鉴权凭证我这里为了演示就简化了。7. 本地联调、Docker 部署与一次真实排错记录最后一部分是部署和运维。我不讲怎么装 JDK、Maven 这些基础环境重点讲自动化部署编排和一次真实的排错过程这可能是全网最有价值的一段经验。7.1 用 Docker Compose 一键拉起整套环境整套系统依赖 MySQL、Redis、Nacos、MinIO 四个基础组件加上五个微服务手动启动不仅慢还容易出错。我写了一个 docker-compose 文件把基础组件全部编排起来version: 3.8 services: mysql: image: mysql:8.0 container_name: travel-mysql environment: MYSQL_ROOT_PASSWORD: 123456 MYSQL_DATABASE: travel ports: - 3306:3306 volumes: - ./mysql-data:/var/lib/mysql redis: image: redis:7.0 container_name: travel-redis ports: - 6379:6379 nacos: image: nacos/nacos-server:v2.2.0 container_name: travel-nacos environment: MODE: standalone ports: - 8848:8848 - 9848:9848 minio: image: minio/minio:latest container_name: travel-minio command: server /data --console-address :9001 ports: - 9000:9000 - 9001:9001 volumes: - ./minio-data:/data一条docker compose up -d基础环境全部就绪。微服务本身我直接在 IDEA 里启动倒不是不能容器化而是开发阶段热更新调试更方便。到了演示或者上生产再打包镜像加部署。7.2 一次网关 404的排错全过程我在联调时遇到一个现象直接访问微服务接口一切正常但从网关转发到 route-service 的接口却一直 404。这个错误没有日志没有堆栈纯粹是路由配了但找不到路径。我的排查链路是这样的第一步检查网关路由配置。我确认配置里Path/api/route/**和StripPrefix1都写对了理论上访问/api/route/list应该转发到/route/list。但从前端发出的请求网关确实返回 404。第二步打开浏览器开发者工具看具体请求路径。发现前端实际访问的是/api/route/list但网关转发后route-service 收到的请求路径是/api/route/list而不是我预期的/route/list。问题就在StripPrefix过滤器的生效顺序上。第三步查 Spring Cloud Gateway 过滤器顺序文档。StripPrefix是一个GatewayFilter它会作用在路由的 filter 链上但问题是如果我在default-filters里定义了别的全局过滤器比如自定义的 AuthFilter它的执行顺序可能会影响路由重写。我最后发现是spring.cloud.gateway.routes[0].filters与default-filters中的响应头去重过滤器混用时有些版本的实现没有正确执行 StripPrefix。最终解决方式很简单——把 StripPrefix 移动到全局过滤器之前或者在路由级明确指定执行顺序。这里最大的启发是网关层的问题不要靠猜先用短路测试排除再查日志最后怀疑顺序问题。Spring Cloud Gateway 的过滤器执行顺序机制Ordered接口是很多人面试时说不清的地方实际排一次错比背十遍都管用。7.3 前端打包塞进 SpringBoot 还是独立部署有个热搜词是vue打包放进springboot中确实在日常开发中很多同学图省事把 Vue 构建出来的 dist 包扔进 SpringBoot 的static目录直接让后端服务托管前端资源。但对微服务架构来说我强烈不推荐。原因是微服务有多个服务实例Nacos 负载均衡后如果请求落在了没有前端资源的实例上用户看到的就是打不开页面。你当然可以把前端资源打包进网关服务但这违背了动静分离的初衷——静态资源应该交给 Nginx 处理。前后端分离后前端用 Nginx 单独跑反向代理把/api开头的动态请求转发到网关静态请求直接本地响应简单高效、思路清晰。Nginx 关键配置server { listen 80; server_name travel.example.com; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }这段配置里try_files $uri $uri/ /index.html是 Vue Router 的 history 模式必须的否则用户直接刷新/guide/detail/3页面时Nginx 找不到对应的静态文件会返回 404。这个配置几乎每个 Vue 部署都要用也是面试高频题。8. 如果让我重新做一次我会怎么调整整个系统从架构设计到落地部署前后花了一个多月。如果让我再来一次有三件事我会从第一天就开始做第一先给每个服务写好独立的本地配置文件再整合到 Nacos 配置中心不要一上来就直接弄配置中心。Nacos 配置中心虽然方便但如果所有配置都集中管理本地调试时改一个配置发布要等刷新、要清缓存效率很低。先本地跑通再统一收拢到配置中心平滑过渡。第二Hot 路线的聚合接口从设计的第一版就加上防缓存穿透设计。实际项目里我曾因为缓存穿透把数据库连接池打满过一次虽然这个系统用户量不至于造成严重影响但它让我养成了所有热数据查询都带上空值缓存的习惯。第三分布式事务的方案从第一天就要想清楚。我中途换过一次方案从 Seata 切换到本地消息表导致部分业务逻辑推翻重写。这种架构选型如果能在数据库设计阶段就定好至少能省掉三分之一的返工。如果你也想做类似的微服务项目我的建议是先画清楚服务边界再搭基础设施然后逐个服务填业务代码最后再做部署。这个顺序看着慢实际上是最快的路径。每一步都前一步的可靠基础跳步才是最大的坑。