SpringCloud微服务博客项目实战:版本选型、网关路由与Redis缓存设计
简介基于Vue与SpringCloud构建的博客系统完整项目采用前后端分离与微服务架构面向Java全栈及微服务方向学习者适合作为分布式系统综合实战参考。资源共1025个文件约91.44MB涵盖Vue前端源码、Java后端服务、yml配置、XML与JSON数据等前后端代码分层清晰。项目集成Eureka注册中心、Zuul网关、Feign声明式负载均衡、Redis缓存与Redisson分布式锁、RabbitMQ延迟队列、Elasticsearch搜索、WebSocket推送等常见中间件并包含支付宝支付接口借助Elasticsearch作为Zipkin存储实现微服务接口调用链跟踪配合回退机制避免级联故障。代码注释完善扩展方便最终采用Docker方式部署贴近生产环境。压缩包内另含设计实现文档docx及项目结构说明便于对照学习微服务治理、中间件整合与性能指标监控。已有648人学习下载适合具备SpringBoot基础、希望掌握SpringCloud微服务及全栈实战的开发者。1. 为什么拿 SpringCloud 搭博客微服务不是炫技是给简历凑技能点我先说结论这年头用 Vue 加 SpringCloud 写博客系统早就不只是为了发文章。一个博客单机 SpringBoot 就能跑但面试官问的不是博客本身而是你简历上写了 SpringCloud 之后能不能把注册中心、网关、Redis 缓存、前后端分离这条链路讲清楚。这套项目就是干这个用的——它把 Vue 做前端、SpringCloud 微服务做后端、Redis 做缓存的完整骨架串起来了适合正在准备毕设、想往 Java 后端方向投简历的人。技术栈不算新但胜在全从网关路由到文章详情页的渲染每一步都能看到数据是怎么流的。我拆这套项目的时候最大的感受是它不教你写业务它教你把 SpringCloud 那套东西落地到能跑的程度。2. SpringCloud 版本选型与工程骨架版本不对后面全是坑2.1 版本定死Spring Boot 2.3.12 配 Spring Cloud Hoxton.SR12这套项目最值得先看的就是版本因为 SpringCloud 的版本兼容问题能把人逼疯。我见过太多人拿着 Spring Boot 2.7 去配 Spring Cloud 2021.x结果 Nacos 注册不上、Feign 调用报错查半天发现是版本不兼容。这套项目里用的是我现在还会推荐的组合Spring Boot 2.3.12.RELEASE 加 Spring Cloud Hoxton.SR12配 Spring Cloud Alibaba 2.2.6.RELEASENacos 服务端用 1.4.2。这个组合是 2020 年到 2021 年非常多生产项目在用的资料多、坑早就被人踩完了。组件版本说明Spring Boot2.3.12.RELEASE稳定的 2.x 基线Spring CloudHoxton.SR12兼容 Spring Boot 2.3Spring Cloud Alibaba2.2.6.RELEASE提供 Nacos、SentinelNacos Server1.4.2注册中心 配置中心MyBatis-Plus3.4.3数据访问层Vue2.6.14前端框架Element UI2.15.x后台管理界面组件库这个版本组合有一个硬理由Spring Cloud Alibaba 2.2.6 官方明确对应的就是 Spring Boot 2.3.x。你如果把 Spring Boot 升到 2.4 以上Nacos 的自动装配就会出问题服务注册地址会变成内网 IP别人访问不到。所以拿这个项目做二次开发时pom 里的版本号一个都别乱动动的顺序应该是先查对应关系再改代码。2.2 拆模块父工程 公共模块 三个业务服务这套项目的工程结构是我比较认可的方式一个 Maven 父工程管依赖底下分了一个公共模块和三个业务服务。三个业务服务分别是用户服务、文章服务、评论服务网关单独做一个服务注册中心和配置中心用 Nacos。前端是独立的 Vue 工程不走 Maven。这种拆法比把全部代码堆在一个服务里强的地方在于每一层的职责清楚排查问题的时候直接看日志前缀就能定位是哪个服务挂了。dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-dependencies/artifactId version2.3.12.RELEASE/version typepom/type scopeimport/scope /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId versionHoxton.SR12/version typepom/type scopeimport/scope /dependency dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-alibaba-dependencies/artifactId version2.2.6.RELEASE/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement这段 dependencyManagement 是整个工程的定海神针它只声明版本不引入依赖真正用到什么由子模块自己决定。这样做的目的是避免每个服务的 pom 里都写一遍版本号升级的时候只改父工程一处就够了。公共模块里放的是 Result 统一返回体、JWT 工具类、全局异常处理这部分代码所有业务服务都要引所以单独拆了一个 common 模块让其他服务用dependency引入。2.3 网关路由配置SpringCloud Gateway 统一入口网关用的不是 Zuul是 SpringCloud Gateway。配置都在 application.yml 里核心就两段路由规则和跨域。路由规则负责把 /api/user/** 转发到用户服务把 /api/article/** 转发到文章服务跨域配置解决 Vue 开发环境 8080 端口调网关 8088 端口被浏览器拦截的问题。server: port: 8088 spring: application: name: gateway-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 gateway: routes: - id: user-service uri: lb://user-service predicates: - Path/api/user/** filters: - StripPrefix1 - id: article-service uri: lb://article-service predicates: - Path/api/article/** filters: - StripPrefix1 globalcors: cors-configurations: [/**]: allowedOrigins: * allowedMethods: * allowedHeaders: *这里的lb://是关键字意思是让 Gateway 从 Nacos 注册中心拿服务实例列表做负载均衡。StripPrefix1的含义是转发时把路径第一段去掉比如前端请求/api/article/list网关转发给文章服务时路径变成了/article/list文章服务的 Controller 里写的是RequestMapping(/article)。如果你不写 StripPrefixController 的路径就得写成/api/article那样服务的接口路径就和网关暴露的路径耦合了不推荐。跨域这段配置在开发时能省不少事但上线前建议把allowedOrigins从*改成具体的域名否则任何网站都能往你接口发请求。3. 文章服务与 Redis 缓存从建表到热点数据落地3.1 基础表设计与 MyBatis-Plus 实体映射文章服务是这个项目的核心我拆的时候最先看的是它的表设计。文章表、标签表、评论表三张表是基础文章和标签是多对多所以中间加了一张关联表。这套表设计不复杂但覆盖了博客系统的全部读写场景。CREATE TABLE article ( id bigint(20) NOT NULL AUTO_INCREMENT, title varchar(128) NOT NULL, summary varchar(256) DEFAULT NULL, content_md longtext NOT NULL, content_html longtext NOT NULL, author_id bigint(20) NOT NULL, status tinyint(4) DEFAULT 1, view_count int(11) DEFAULT 0, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE tag ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(32) NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE article_tag ( id bigint(20) NOT NULL AUTO_INCREMENT, article_id bigint(20) NOT NULL, tag_id bigint(20) NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里我特别说一下content_md和content_html双存储的设计。很多新手只存 Markdown 原文前端拿到之后再用 JS 解析这样做有两个问题一是前端每个用户都要重复解析一遍性能浪费二是 Python 或者 Java 后端生成的解析结果和 JS 解析的结果可能不一致。这套项目采用的方式是发布文章时后端一次性把 Markdown 转成 HTML 存进去前端详情页直接渲染 HTML省事且稳定。view_count字段是给 Redis 缓存统计用的下面会讲到。3.2 发布文章的完整链路Controller、Service 到 Markdown 解析文章发布接口的调用链是Vue 管理后台提交 Markdown 内容 → 网关转发 → 文章服务 Controller → Service 保存文章和标签关联。Service 里最关键的是 Markdown 转 HTML 这一步项目用的是 commonmark-java 这个库比自定义正则靠谱得多。Service public class ArticleServiceImpl implements ArticleService { Autowired private ArticleMapper articleMapper; Autowired private ArticleTagMapper articleTagMapper; Override Transactional(rollbackFor Exception.class) public Long publishArticle(ArticlePublishRequest request) { Article article new Article(); article.setTitle(request.getTitle()); article.setSummary(request.getSummary()); article.setContentMd(request.getContentMd()); article.setContentHtml(markdownToHtml(request.getContentMd())); article.setAuthorId(request.getAuthorId()); article.setStatus(1); articleMapper.insert(article); if (request.getTagIds() ! null) { for (Long tagId : request.getTagIds()) { ArticleTag articleTag new ArticleTag(); articleTag.setArticleId(article.getId()); articleTag.setTagId(tagId); articleTagMapper.insert(articleTag); } } return article.getId(); } private String markdownToHtml(String markdown) { Parser parser Parser.builder().build(); Node document parser.parse(markdown); HtmlRenderer renderer HtmlRenderer.builder().build(); return renderer.render(document); } }Transactional(rollbackFor Exception.class)这一段值得讲清楚默认的 Spring 事务只在运行时异常时回滚如果 Service 里抛出的是 IOException 这类受检异常事务是不会回滚的。这里强制指定了所有异常都回滚防止文章插进去了但标签关联插入失败时产生脏数据。实际项目中文章表插入成功、标签关联表插入失败你打开文章详情页会发现标签是空的但文章还在。这个问题在团队协作时很容易被忽略所以我习惯在写事务方法时都加这个参数。3.3 Redis 缓存热点文章读多写少的场景怎么处理博客文章的读请求远多于写请求这是典型的缓存适用场景。这套项目在文章详情接口上做了 Redis 缓存key 是文章 IDvalue 是序列化后的 ArticleVO。我拆代码时发现它的缓存逻辑写得比较实在没有过度设计就是先查缓存、命中直接返回、没命中查数据库再回填缓存。public ArticleVO getArticleDetail(Long articleId) { String cacheKey article:detail: articleId; String cachedJson redisTemplate.opsForValue().get(cacheKey); if (cachedJson ! null) { return JSON.parseObject(cachedJson, ArticleVO.class); } ArticleVO articleVO loadArticleFromDb(articleId); if (articleVO ! null) { redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(articleVO), 30, TimeUnit.MINUTES); } return articleVO; }这段代码看着简单但有两个细节我得点一下。第一个是缓存过期时间设了 30 分钟而不是永久有效因为文章可能会有浏览量变动、管理员修改内容这些情况永久缓存会导致数据不一致。第二个是只缓存了 ArticleVO没有缓存整整 30 分钟的浏览量统计——浏览量是用 Redis 的increment命令单独做的原子计数和文章内容分开存。这里有个常见的翻车点如果你把浏览量直接放在缓存对象里每次浏览都要更新 Redis并发高的时候 Redis 序列化反序列化的开销会拖慢接口响应。更常见的设计是浏览量单独存一个 key定时批量同步到 MySQL。这套项目在这一点上处理得没问题。4. Vue 前端的路由与渲染从列表页到详情页的前后端接力4.1 路由配置vue-router 的路径传参方式前端工程用的是 Vue 2.6 加 vue-router 3.x这个组合很成熟。路由配置里最值得说的是文章详情页的参数传递这里用的不是$router.push({ query: { id: 1 } })这种显式查询参数方式而是用了路径参数。const routes [ { path: /, component: HomeView }, { path: /article/list, component: ArticleListView }, { path: /article/detail/:id, component: ArticleDetailView, props: true }, { path: /admin, component: AdminLayout, meta: { requiresAuth: true } } ];/article/detail/:id这种写法把文章 ID 直接拼在 URL 路径里比 query 方式的好处是分享链接时 URL 更干净而且props: true这个配置能让组件里直接用this.id拿到路由参数不用写一堆$route.params.id。我见过很多 Vue 项目路由里忘了写props: true组件里到处是this.$route.params.id等路由层级一变这些代码全要改。从可维护性来说路由参数用 props 接收是更好的习惯。4.2 页面生命周期与数据加载时机文章详情页的数据加载时机是另一个值得看的地方。组件里在created钩子里调接口而不是在mounted里原因是created阶段组件实例已经创建、data 已经初始化此时发请求能比mounted早那么几毫秒虽然差别很小但在弱网环境下能感知到。更重要的是created阶段 DOM 还没渲染不会有“页面先显示空白再突然出内容”的闪烁感。export default { name: ArticleDetailView, props: [id], data() { return { article: {}, loading: false }; }, created() { this.fetchArticle(); }, watch: { $route.params.id(newId) { if (newId newId ! this.id) { this.fetchArticle(); } } }, methods: { async fetchArticle() { this.loading true; try { const res await api.getArticleDetail(this.id); this.article res.data; } finally { this.loading false; } } } };watch 这一段是我尤其想强调的。很多 Vue 项目只在created里加载数据当用户从文章 A 点击推荐位跳到文章 B 时因为组件被复用了created根本不会再次触发页面显示的还是文章 A 的内容。加上watch: { $route.params.id }之后路由参数一变就重新拉数据才真正解决了这个翻车场景。你如果自己改这套项目建议把每个详情类页面都加上这个 watch。4.3 开发环境代理与 Token 携带前后端联调时最麻烦的是跨域和 Token 传递。这套项目的 Vue 开发环境里配了 devServer 代理所有/api开头的请求都转发到网关的 8088 端口浏览器侧不会出现跨域报错。同时用 axios 的拦截器统一在请求头里加 Token这样用户登录后刷新页面不用重新登录。// vue.config.js module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8088, changeOrigin: true } } } }; // src/utils/request.js axios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] Bearer token; } return config; });代理配置里的changeOrigin: true必须写否则请求到网关时 Host 头还是前端的 localhost:8080Nacos 里做基于 Host 的校验时会出问题。Token 用Bearer前缀是后端 JWT 过滤器解析时约定的你在网关层的 GlobalFilter 里也是按照Bearer前缀去截取 Token 的两边字符串拼接逻辑要对上否则就会一直报 401。5. 避坑手册五个最常见的翻车现场5.1 Nacos 注册不上服务列表一直是空的现象是启动 article-service 后Nacos 控制台的服务列表里看不到这个服务但启动日志里没有报错。原因排查下来有两种最常见的是服务端版本和客户端版本不兼容Nacos 服务端是老版本客户端用了 2.x 的 spring-cloud-alibaba注册协议对不上。解决方法是把 Nacos 服务端升到 1.4.2或者把 spring-cloud-alibaba 版本降到 2.2.6.RELEASE。另一个原因是服务里没引入 spring-cloud-starter-alibaba-nacos-discovery 依赖只引入了 spring-cloud-starter-alibaba-nacos-config这个依赖只做配置中心不做注册所以服务静默失败。检查方法就是看 pom.xml 里 discovery 依赖是否齐全。5.2 Feign 调用超时默认的 1 秒根本不够现象是服务间调用偶尔报 Read timed out尤其是文章服务调用户服务获取作者信息的时候。原因是 Feign 默认的连接超时是 1 秒读超时是 2 秒而博客项目里文章列表接口要查文章、查标签、查作者任何一个数据库慢查询都能超过这个时间。解决方法是给 Feign 单独配置超时时间在 application.yml 里把 ribbon 的超时时间调大ribbon: ReadTimeout: 10000 ConnectTimeout: 10000但我建议你别只调超时也顺手加上重试机制不然偶发超时直接报错给前端用户体验很差。这里注意一点如果该接口不是幂等的比如新增评论重试会重复插入数据。所以我的习惯是在非幂等接口上不放重试或者用带幂等标识的请求。这套项目里 Feign 调用都是查询类接口重试是安全的。5.3 Redis 缓存击穿热点文章过期后同时打爆数据库现象是某篇热门文章缓存过期的一瞬间接口响应突然从 20 毫秒变成 1 秒数据库 CPU 暴涨。原因是缓存 key 过期后大量并发请求同时穿透到数据库。解决方法是给热点数据加逻辑过期时间或者用互斥锁。这套项目里用的是互斥锁方式思路是缓存没命中时先尝试获取分布式锁拿到锁的线程才去查数据库其他线程短暂自旋等待锁释放后再查缓存。public ArticleVO getArticleDetailWithLock(Long articleId) { String cacheKey article:detail: articleId; String cachedJson redisTemplate.opsForValue().get(cacheKey); if (cachedJson ! null) { return JSON.parseObject(cachedJson, ArticleVO.class); } String lockKey lock:article: articleId; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (!locked) { try { Thread.sleep(50); return getArticleDetailWithLock(articleId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } try { cachedJson redisTemplate.opsForValue().get(cacheKey); if (cachedJson ! null) { return JSON.parseObject(cachedJson, ArticleVO.class); } ArticleVO articleVO loadArticleFromDb(articleId); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(articleVO), 30, TimeUnit.MINUTES); return articleVO; } finally { redisTemplate.delete(lockKey); } }锁的过期时间我建议至少设 5 秒因为如果查询数据库耗时超过锁的过期时间其他线程会重新拿到锁再查一次锁就白加了。10 秒是相对稳妥的数值不会因为单次慢查询导致锁提前失效。这里还有一个细节加锁后要再查一次缓存因为可能在你等待锁的过程中持有锁的线程已经把缓存回填好了如果不查直接走数据库等于又打一次库。5.4 Vue 打包部署刷新 404现象是打包后的 dist 文件扔到 Nginx 下首页正常但用户手动刷新 /article/detail/1 这个地址时 Nginx 返回 404。原因是 Nginx 默认配置找的是磁盘上真实的文件路径而 vue-router 用的是 history 模式前端路由是虚拟的Nginx 找不到对应文件。解决方法是让 Nginx 在找不到文件时回退到 index.htmllocation / { try_files $uri $uri/ /index.html; }这里有个常见的连带问题刷新 404 解决了但刷新后 JS 文件加载报错通常是静态文件路径写成了绝对路径assets部署到子目录时就挂了。Vue 项目打包前记得把publicPath改成./这样资源走相对路径放到任何子目录都能加载。那以后我每次部署前端都检查两处vue.config.js 里的 publicPath 和 Nginx 的 try_files。5.5 前后端日期格式不一致现象是文章列表里的创建时间在管理后台显示正常接口返回给前端时变成了时间戳数字或者前端显示Invalid Date。原因是 Java 的 LocalDateTime 默认序列化成数组格式而前端期望的是yyyy-MM-dd HH:mm:ss的字符串。解决方法是加一个全局的 Jackson 配置Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.simpleDateFormat(yyyy-MM-dd HH:mm:ss); builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }; } }如果你不想改全局配置也可以在每个实体的 LocalDateTime 字段上加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)。但加注解的方式只对单个字段生效服务里新增了字段容易忘记加注解所以我更推荐全局配置。这个坑在前后端分离项目里非常常见不只在博客系统里。数据库里存的是 datetime后端拿到的是 LocalDateTime不配序列化规则的话接口给出去的 JSON 里是一串带 T 的 ISO 字符串前端不处理直接显示会很难看。6. 本地完整跑通按这个顺序启动按这条路验证第一次跑这套项目最忌讳的是六个服务一起启动然后看哪个报错再修哪个。我的习惯是按依赖顺序启动每启动一个服务就确认它在 Nacos 里注册成功了再进行下一步。顺序是 Nacos → MySQL → Redis → 网关服务 → 用户服务 → 文章服务 → 评论服务 → Vue 前端。Nacos 服务端启动后先访问http://localhost:8848/nacos确认控制台能打开再用curl http://localhost:8848/nacos/v1/ns/service/list?pageNo1pageSize10查看服务列表确认命名空间和分组都是默认值。后端服务都起来之后验证的第一条链路是注册登录调用用户服务的注册接口创建账号再调登录接口拿 Token。这条链路通了说明网关路由、Nacos 服务发现、数据库连接都没问题。第二条链路是发文章拿着 Token 调文章服务发布接口传一段 Markdown 内容然后调查询接口确认返回的 content_html 是已渲染的 HTML。Redis 里用keys article:detail:*看到缓存 key 生成说明缓存链路是通的。最后一条链路是看前端启动npm run serve打开文章列表页点击标题进入详情页确认 URL 是/article/detail/1这种带 ID 的路径刷新页面不报 404。验证完这三条链路整套项目的核心流程就跑通了。后端服务排查时最常用的命令是看每个服务的日志前缀Gateway 的日志里能看到请求转发到了哪个服务用户服务的日志里能看到 SQL 执行情况文章服务的日志里能看到缓存命中的打印。Redis 那边redis-cli --bigkeys可以快速看缓存有没有大量过期 key。这套项目我断断续续拆了两遍第一遍被 Nacos 版本问题卡了半下午第二遍就顺畅多了。从那以后我每次搭 SpringCloud 项目都会强制走一遍第 2 章的版本核对流程确认 Spring Boot、Spring Cloud、Spring Cloud Alibaba 的版本对应关系没问题再动工省下的时间远比耗费的多。希望这份拆解能帮你少踩几个我踩过的坑。本文还有配套的精品资源点击获取