Spring Boot手写个人博客系统:从数据库设计到部署全解析

发布时间:2026/10/1 10:43:33
Spring Boot手写个人博客系统:从数据库设计到部署全解析
个人博客这个东西真的没有必要一上来就上微服务。我自己的博客系统从零手写选型时基本没犹豫直接锁定了 Spring Boot。前后折腾了个把月把文章发布、分类标签、评论、搜索、文件上传、后台管理、Docker 部署全串了起来最后跑起来的这套系统不光是个能写文章的站点更是一次把 Java Web 那些核心知识点完整过了一遍的实战项目。如果你也想搭一个属于自己的 Spring Boot 个人博客系统或者正在为毕业设计、简历项目发愁这篇东西应该能让你少走不少弯路。我不会给你讲那种“拷贝代码就能跑”的空话而是尽量把每一步的设计思路、版本坑、实现细节都拆开说清楚。毕竟博客系统麻雀虽小五脏俱全里头的表设计、缓存策略、文件存储、部署方式每一样换到真实的业务系统里其实都是同一个套路。1. 为什么用 Spring Boot 写个人博客整体设计与技术选型1.1 博客系统到底需要哪些核心能力很多人刚动手时会犯一个毛病就是“什么都想要”。想着要做会员体系、要做付费阅读、要做复杂权限结果项目拖了两周还没跑通一篇文章的新增接口。实际上一套个人博客最核心的能力翻来覆去就那么几块内容发布和管理写文章、编辑文章、管理草稿、发布文章、逻辑删除、回收站。内容组织和检索分类、标签、文章归档按日期归档、关键字搜索。互动能力评论、评论审核、回复评论、浏览量统计。文件管理文章图片上传、头像上传、附件管理。后台管理管理员的简单鉴权、文章管理列表、评论管理。门户展示首页、文章详情页、分类页、标签页、归档页、关于页。这六个能力如果再往下拆会发现其实都是在围绕两个字转内容。个人博客的流量和复杂度远没有到需要微服务的程度一个 Monolith 项目配合缓存和异步任务完全能扛住日常访问。因此我最终选择的是单应用架构没有拆独立用户服务、评论服务之类的东西省下来的精力全部放在内容体验上。1.2 技术栈选择的理由从单体到前后端分离技术选型这块我纠结过一阵最后定下来的组合是Spring Boot MyBatis-Plus Redis MinIO Thymeleaf/前端模板。为什么这么选我分别说说理由。Spring Boot 本身没什么好争的自动装配、Starter 依赖管理、内置容器、生产就绪监控这些特性几乎就是为“快速做一个可用系统”设计的。相比传统 SSM 那套繁琐的 XML 配置Spring Boot 能让我们把注意力集中在业务逻辑上。至于版本我建议新项目优先思考是留在 2.7 还是直接上 3.xSpring Boot 3 要求 JDK 17 起跳并且javax.*改成了jakarta.*如果你手头教程是 2.x 的照抄代码大概率要踩坑。我自己最终选的是 Spring Boot 2.7.18因为它处于 2.x 的末期稳定、教程多各种三方库兼容性也最齐。MyBatis-Plus 对我来说最大价值不是它封装了多少 CRUD而是分页插件和 Lambda QueryWrapper 能省掉大量琐碎的 SQL。博客系统的查询条件非常典型按分类查、按标签查、按关键字查、按状态查条件一多传统 MyBatis XML 里就要堆一堆if标签用 MyBatis-Plus 的 LambdaQueryWrapper 之后代码可读性好了很多。Redis 在这个项目里不是配角。博客读多写少一篇文章发布之后很长时间内内容不变唯独浏览量是实时变化的。我一开始把浏览量每次都打到数据库结果一个 SQL 要等 20ms虽然流量不大但总觉得别扭。后来改成 Redis 记录浏览量定时批量刷回数据库压力瞬间小很多。另外最新文章列表、热门文章列表这种热点数据也可以缓存只要保证发布、编辑、删除文章时主动更新缓存或删除缓存键即可。MinIO 的引入是为了解决图片和附件的存储问题。很多人图省事把图片直接传到服务器本地目录博客数据一迁移或者服务器磁盘一坏图片全没。MinIO 是 S3 兼容的对象存储个人博客用它再合适不过既能练手后续想迁到云上对象存储也几乎零成本。如果你只有一台小内存服务器MinIO 单独跑可能会有压力好在 2G 内存以上的机器跑个 MinIO 也还凑合真不行就退一步用本地磁盘目录存储但要考虑好迁移方案。前端方案上我做过两版。第一版是 Thymeleaf 服务端渲染当时考虑的是 SEO 和简单。第二版改成了 Vue 前后端分离后台管理和门户都拆成了接口对接。这里我的真实建议是如果你的博客主要用来记录技术文章、希望搜索引擎能明显收录优先 Thymeleaf 服务端渲染或者用 Next.js/Nuxt 那类 SSR 框架如果只是练手 API 设计、展示前后端分离项目那 Spring Boot Vue 是更常见、更容易写进简历的组合。1.3 模块划分与项目结构我用的是普通 Maven 多模块结构但不是那种夸张的微服务目录而是按职责分模块让代码环境清爽一些。整体大概是这样blog-parent ├── blog-common # 通用枚举、异常、返回结构、工具类 ├── blog-admin # 后台管理模块控制器、配置、权限相关 ├── blog-portal # 门户模块文章展示、搜索、评论提交 ├── blog-service # 业务层文章、分类、标签、评论等Service ├── blog-mapper # 持久层Mapper接口、XML、实体 └── blog-main # 启动模块Application入口、综合配置这里要注意一个点很多人喜欢把 Controller、Service、Mapper 直接分层成三个包扔在同一个工程里这不丑。但如果你打算让这套博客系统长期维护还是建议在模块上稍微分一下。博客系统虽然小但后台管理的接口和门户的接口配置策略经常不一致比如后台管理员接口需要登录拦截门户接口是公开的分模块之后拦截器写起来更干净。代码分层方面我用的是常见的 Controller - Service - Mapper没有引入复杂的 DDD 概念。博客系统这种业务核心价值在于写清楚文章流转和查询逻辑分层太厚反而累赘。DTO 和 VO 有必要保留入参和出参尽量不要直接拿实体类往外抛否则后面加字段很容易污染接口。2. 核心数据模型与关键实现细节2.1 数据库表设计一篇文章如何组织自己的分类与标签博客系统里最容易设计跑偏的就是文章、分类、标签三者的关系。我第一版把分类表做成了一棵无限级树还支持分类下建子分类标签表则设计成了和文章一对多结果废了。后来回归简单分类是一级分类标签是多对多关系文章属于一个分类拥有多个标签。这是博客系统的约定俗成不要盲目拔高设计复杂度。核心表结构我给出关键的部分CREATE TABLE blog_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL COMMENT 登录名, password VARCHAR(128) NOT NULL COMMENT 加密密码, nickname VARCHAR(64) DEFAULT NULL COMMENT 昵称, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像URL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除 ) COMMENT用户表; CREATE TABLE blog_category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, slug VARCHAR(128) DEFAULT NULL COMMENT URL别名, sort INT DEFAULT 0 ) COMMENT分类表; CREATE TABLE blog_tag ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL ) COMMENT标签表; CREATE TABLE blog_article ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL, summary VARCHAR(255) DEFAULT NULL COMMENT 摘要空则自动截取, content MEDIUMTEXT NOT NULL COMMENT Markdown原文, content_html MEDIUMTEXT COMMENT 渲染后的HTML, cover VARCHAR(255) DEFAULT NULL COMMENT 封面图, category_id BIGINT NOT NULL, status TINYINT DEFAULT 0 COMMENT 0草稿 1发布 2回收站, views INT DEFAULT 0 COMMENT 浏览量, comment_count INT DEFAULT 0, is_top TINYINT DEFAULT 0, publish_time DATETIME DEFAULT NULL COMMENT 发布时间, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, deleted TINYINT DEFAULT 0 ) COMMENT文章表; CREATE TABLE blog_article_tag ( article_id BIGINT NOT NULL, tag_id BIGINT NOT NULL, PRIMARY KEY (article_id, tag_id) ) COMMENT文章标签关联表; CREATE TABLE blog_comment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, article_id BIGINT NOT NULL, parent_id BIGINT DEFAULT 0 COMMENT 父评论ID0表示顶级评论, nickname VARCHAR(64) NOT NULL, email VARCHAR(128) DEFAULT NULL, content VARCHAR(1024) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待审核 1通过 2拒绝, create_time DATETIME NOT NULL ) COMMENT评论表;说说几个容易被忽略的设计点。content存的是 Markdown 原文另开一个content_html存渲染后的 HTML这是为了查询列表或生成搜索摘要的时候不用每次都现场渲染代价是有编辑操作时要双写。很多教程里只存 Markdown页面请求时再实时渲染这在小博客上没问题但会白白浪费 CPU。前端展示也不需要 Markdown 原文只取 HTML 字段就够。deleted逻辑删除字段我几乎在每个表里都加了。文章删除时先放进回收站用户反悔了还能恢复过几天再物理清理。这种做法在业务系统里很常见千万别图省事直接 DELETE。views字段在文章表里是一个冗余计数实际上热门文章排序和详情页直接用它。真正的浏览量累计可以先走 Redis再异步合并回这张表关于这部分后面我会细说。2.2 文章发布与状态流转草稿、预览、定时发布博客系统最核心的流程不是增删改查而是文章的状态流转。一篇文章从创建到最终展示会经历几种状态草稿、发布、回收站。后台还有一个可选操作是定时发布但定时发布本质上是“文章状态发布字段一个延迟任务”的组合。我实现的文章状态机很简单status0草稿。只有作者和管理员能通过后台看到门户不展示。status1已发布。门户文章列表和详情页可访问。status2回收站。前台不展示后台列表里还能看到但只能做恢复或永久删除。编辑文章时如果文章已经是发布状态保存草稿会生成一个新版本吗不会。博客系统没必要做版本管理编辑已发布文章时直接覆盖原文然后同步更新content_html、summary和update_time。定时发布这块我用的是 SpringScheduled扫描一张表里的待发布任务到了时间就把状态改成 1。你也可以用 Quartz但博客场景真的用不上那么重一个简单的Scheduled(fixedDelay 60000)每分钟扫一次就够。要注意的是服务器时区问题部署到云服务器后如果时区不是 Asia/Shanghai定时任务会差 8 小时这个我踩过后面会提一下。发布流程的伪代码大致是Transactional public void publishArticle(Long articleId, boolean publishNow) { Article article articleMapper.selectById(articleId); if (article null) { throw new BizException(文章不存在); } if (publishNow) { article.setStatus(ARTICLE_PUBLISHED); article.setPublishTime(LocalDateTime.now()); } else { // 定时发布记录待发布任务表 } // 同步生成HTML和摘要 article.setContentHtml(markdownService.render(article.getContent())); article.setSummary(StringUtils.isBlank(article.getSummary()) ? generateSummary(article.getContent()) : article.getSummary()); articleMapper.updateById(article); // 删除相关缓存 cacheService.deleteArticleCache(articleId); }注意Transactional的使用。文章发布涉及更新文章表、可能更新标签关联表、删除缓存缓存清理虽然不在事务范围内但最好放在事务提交后执行否则事务回滚了缓存却删了下次查询反而是旧数据。这里我用TransactionalEventListener监听事务提交事件也算一个小经验。2.3 Markdown 渲染与 XSS 处理不能只把页面当成字符串博客文章内容是从 Markdown 编辑器写入的用户如果直接输入 HTML 或 JavaScript 代码服务端如果原样渲染会有 XSS 风险。Markdown 渲染库我一开始用的 commonmark-java后来换成 flexmark因为 flexmark 对表格、任务列表这种扩展语法支持更全而且速度也够。但 Markdown 渲染库本身会把原始 HTML 原样放进输出里所以必须做过滤。我记得当时测了一个输入测试scriptalert(1)/script以及 img srcx onerroralert(1)如果不做处理响应给浏览器就是活生生的 XSS。我的处理方式是渲染完成后再经过一层白名单过滤器用Jsoup的Cleaner或Safelist把非白名单的标签和属性去掉public String renderToHtml(String markdown) { String html markdownProcessor.render(markdown); return Jsoup.clean(html, Safelist.relaxed() .addTags(h1, h2, h3, pre, code, blockquote, table, thead, tbody, tr, td, th, img, a) .addAttributes(img, src, alt, title) .addAttributes(a, href, title) .addProtocols(img, src, http, https) .addProtocols(a, href, http, https)); }文本里的纯代码块没问题但要确保code标签不解析里面的 HTML。所以这里有个实现细节在渲染前先把 Markdown 里的代码块和行内代码用占位符替换等 HTML 过滤完再换回来这样用户写的alert(1)就不会被删掉或执行。这个逻辑说起来简单但实现时容易漏。还有一个细节是代码高亮。门户详情页默认用的是 highlight.js在 Thymeleaf 模板里直接引入在线 CSS 和 JS。如果你不想依赖公网 CDN可以把 highlight.js 的资源和主题放到自己的 MinIO 上这样页面加载完全可控。2.4 评论模块一次性自建评论系统的实现思路有人建议我用 Gitalk 或 Waline 这类第三方评论系统但我还是想自己写一个。倒不是为了炫技而是评论这种基础功能正好可以练一下关联查询和事务操作。自建评论系统完全没必要做成博客的中心模块一张评论表加几个接口就够了。评论的展示结构我用了最简单的父子两级模型。顶级评论的parent_id为 0子评论的parent_id是顶级评论 ID。这样前端渲染时先取顶级评论再根据顶级评论 ID 查出所有子评论。如果再往下设计楼中楼三层以上复杂度会上升一个量级个人博客真没必要。评论提交接口需要考虑防刷。我的策略是评论昵称和邮箱必填限制长度。用 Redis 对同一 IP 或同一邮箱做提交频率限制例如 30 秒内只能评论一次。评论内容做敏感词过滤最简单的做法是维护一个黑名单词表对文本做包含判断。新评论默认待审核后台管理员审核通过后才出现在前台。这个“评论待审核”机制很有必要。博客一旦开放评论垃圾广告会非常多纯靠过滤器挡不干净。审核机制也不复杂后台一个状态字段切换而已。评论数量的话我是在评论通过审核后把文章表的comment_count加一前台文章列表直接展示这个冗余数量不用每次实时 count。3. 搭建与实操从零实现博客门户和后台3.1 工程初始化与依赖配置版本坑从第一步就埋下了初始化项目我用的是 Spring Initializr如果你在 IntelliJ IDEA 里直接建也可以。注意几个点Group 和 Artifact 随意但 Java 版本和 Spring Boot 版本要匹配。我用的是 JDK 8 Spring Boot 2.7.18虽然 JDK 8 已经比较老了但胜在大多数服务器环境都有而且不需要额外处理jakarta命名空间问题。如果你非要用 Spring Boot 3.x那就老老实实 JDK 17后续引入三方包时要多留意是否发布了适配 3.0 的版本。比如一些老牌的 Shiro 版本在 Spring Boot 3 上就支持不好MyBatis-Plus 也要用mybatis-plus-spring-boot3-starter而不是老的mybatis-plus-boot-starter。核心依赖我列一下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.7/version /dependency dependency groupIdorg.springdoc/groupId artifactIdspringdoc-openapi-ui/artifactId version1.7.0/version /dependency这里要重点提醒MinIO 的 Java SDK 会引入okhttp和okio如果你项目中还有别的组件也依赖okhttp很容易出现版本冲突。我在一开始没加 MinIO 时项目跑得很顺加了 MinIO 之后报了一堆NoSuchMethodError最后把okhttp通过依赖排除再单独指定版本才好。后面第四部分会专门讲这个问题。3.2 配置文件的写法数据源、Redis、MinIO 以及自定义属性Spring Boot 的好处就是多数东西可以在application.yml里声明式搞定。我的配置文件除了标准数据源和 Redis还加了不少博客业务自定义参数。比如博客名称、SEO 关键字、上传文件路径前缀、默认封面图等全部绑定到一个BlogProperties配置类里。spring: datasource: url: jdbc:mysql://localhost:3306/blog_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 redis: host: localhost port: 6379 database: 0 thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html mvc: hiddenmethod: filter: enabled: true minio: endpoint: http://localhost:9000 access-key: minioadmin secret-key: minioadmin bucket: blog-images blog: name: 阿杰的博客 domain: https://blog.example.com admin-email: adminexample.com page-size: 10 seo-keywords: Spring Boot,个人博客,java自定义配置项绑定到类上使用ConfigurationPropertiesComponent ConfigurationProperties(prefix blog) Data public class BlogProperties { private String name; private String domain; private String adminEmail; private Integer pageSize; private String seoKeywords; }这个类在模板或者工具类里都能直接注入比到处写Value清爽。另外我建议在application.yml里区分环境配置用spring.profiles.active指定dev或prod比如本地连本地数据库生产连云数据库。配置文件这东西不提前分环境上线的时候临时改数据库地址最容易出事。3.3 文章接口的完整实现Controller、Service、Mapper 的代码节奏我先把门户文章列表接口的代码逻辑展开讲因为这几乎就是整个博客系统 API 的主心骨。列表接口参数包括页码、每页条数、分类、标签、关键字、状态门户默认只看已发布文章。Controller 层我统一使用ResultT封装静态工厂方法Result.ok(data)返回成功失败抛出全局BizException。许多新手喜欢直接返回Map或者裸对象短期看着方便但接口一多返回结构没法保持一致前端对接会非常痛苦。RestController RequestMapping(/api/article) public class ArticlePortalController { Resource private ArticleService articleService; GetMapping(/list) public ResultPageResultArticleVO list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer size, RequestParam(required false) Long categoryId, RequestParam(required false) Long tagId, RequestParam(required false) String keyword) { return Result.ok(articleService.pagePublished(page, size, categoryId, tagId, keyword)); } GetMapping(/detail/{id}) public ResultArticleDetailVO detail(PathVariable Long id) { return Result.ok(articleService.detail(id)); } }Service 层的实现是重头。发布文章列表查询的时候条件非常多我统一用 MyBatis-Plus 的 LambdaQueryWrapperpublic PageResultArticleVO pagePublished(Integer page, Integer size, Long categoryId, Long tagId, String keyword) { PageArticle articlePage new Page(page, size); LambdaQueryWrapperArticle wrapper new LambdaQueryWrapper(); wrapper.eq(Article::getStatus, ARTICLE_PUBLISHED) .eq(categoryId ! null, Article::getCategoryId, categoryId) .and(keyword ! null !keyword.isBlank(), w - w .like(Article::getTitle, keyword) .or() .like(Article::getSummary, keyword)) .orderByDesc(Article::getIsTop) .orderByDesc(Article::getPublishTime); PageArticle result articleMapper.selectPage(articlePage, wrapper); ListArticleVO voList result.getRecords().stream() .map(articleConverter::toVO) .toList(); return new PageResult(voList, result.getTotal(), result.getCurrent(), result.getSize()); }这里有个潜在坑如果keyword为空字符串时isBlank()判断后不追加条件。如果用了.like()但传入keywordnullMyBatis-Plus 默认会生成LIKE %%查询这不好容易让本来该走索引的查询变成全表扫。所以条件判断要严格一点。文章详情接口要做几件事。第一文章浏览量加一这一步走 Redis。第二查询分类名和标签列表。第三把 Markdown 渲染后的 HTML 字段直接返回给前端。第四如果文章状态不是已发布且不是管理员场景直接抛异常。浏览量加一的 Redis 写法public Long incrementView(Long articleId) { String key blog:article:views: articleId; Long count redisTemplate.opsForValue().increment(key); // 如果 key 不存在说明还没有缓存初始值需要从数据库加载一次 if (count ! null count 1L) { Article article articleMapper.selectById(articleId); redisTemplate.opsForValue().set(key, article.getViews() 1); count article.getViews() 1L; } return count; }然后写一个定时任务定期将 Redis 里的浏览增量合并到 MySQL。这里不能用getAndSet直接把值覆盖掉要累加防止丢数据。我当时实现的简化版本是用一个scheduledTask每分钟跑一次遍历所有有增量的 key把增量累加到文章表再删除 Redis key。3.4 文件上传与 MinIO 集成给博客加上图片床能力博客编辑器支持粘贴图片上传、本地上传封面我统一切到 MinIO。封装一个StorageService接口接口之下先实现MinioStorageService将来要换云厂商对象存储也方便。上传流程就是接收MultipartFile生成带日期前缀的对象名调用 MinIO 客户端putObject最后返回一个可以访问的 URL。关键点在于对象名的生成。我习惯按日期分目录public String upload(MultipartFile file) { String originalFilename file.getOriginalFilename(); String ext StringUtils.getFilenameExtension(originalFilename); String objectName blog/ LocalDate.now().format(BASIC_ISO_DATE) / UUID.randomUUID().toString().replace(-, ) . ext; minioClient.putObject(PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucketName) .object(objectName) .expiry(24 * 60 * 60) .build()); }这里有一个很隐蔽的坑getPresignedObjectUrl生成的是临时签名 URL默认有效期很短。如果你的 MinIO 存储桶是私有的前端拿这个 URL 去加载图片有效期一过就 401。对于博客这种展示型站点最简单的方案是把存储桶权限设为公共读然后直接返回http://endpoint/bucket/objectName这样的普通 URL。这样做虽然有安全风险但博客本来都是公开内容所以问题不大。如果你想再讲究一点可以把私有桶里的图片通过 Nginx 反向代理转发到 MinIO同时关闭 MinIO 公网端口这个属于进阶玩法的。上传接口要限制文件类型和大小。博客系统最常见的图片类型无非 jpg、png、gif、webp我在前端和后端都做了限制。后端用spring.servlet.multipart.max-file-size设最大文件大小再在代码里判断扩展名白名单。千万别只靠前端限制绕过前端直接 POST 是可以把恶意文件传上来的。3.5 前端渲染选择 Thymeleaf 还是 Vue 前后端分离我一版用 Thymeleaf页面直接通过 Controller 返回 ModelAndView。Thymeleaf 的好处是天然适合 SEO搜索引擎爬虫可以直接拿到完整 HTML。文章详情页和列表页我都是服务端渲染然后部分区域比如浏览量、评论提交后的局部刷新再用一点 jQuery 或原生 JS 做异步。这样最简单也最省事。后来为了实现后台管理我用 Vue 3 Element Plus 写了一个独立后台工程和 Spring Boot 的/api/admin接口对接。后台管理不需要 SEO做成前后端分离很舒服。于是项目最终变成了混合架构门户是服务端渲染后台是单体后端的分模块 API。这个组合我很推荐给个人博客既保留 SEO 优势又不耽误练前后端分离。如果你要做纯前后端分离那门户前端就用 Vue 或 React后端只出 JSON 接口。这个方案在搜索收录上会吃亏一点如果非要追求 SEO可以考虑让后端在收到爬虫请求时返回预渲染页面或者干脆把门户放到服务端渲染框架里。对于一般开发者我建议还是先做服务端渲染的博客等上线一段时间有真实访问了再考虑要不要折腾分离。Thymeleaf 热更新这块spring.thymeleaf.cachefalse是开发期标配。但只配这个还不够你还需要在 IntelliJ IDEA 中设置不缓存 Thymeleaf 模板并且用 DevTools 实现自动重启。我在第四部分会详细说因为很多人配了cachefalse还是不能热更新。3.6 Docker 部署与域名上线让别人也能访问你的博客部署我优先选择 Docker Compose。一台内存 4G 以上的云服务器就够不需要上 K8s。我的服务器上编排了四个容器MySQL、Redis、MinIO、博客应用本体。应用镜像采用多阶段构建FROM maven:3.8-openjdk-8 AS builder WORKDIR /app COPY pom.xml . COPY . . RUN mvn clean package -DskipTests FROM openjdk:8-jre WORKDIR /app COPY --frombuilder /app/blog-main/target/blog-main.jar /app/blog.jar EXPOSE 8080 ENTRYPOINT [java, -jar, /app/blog.jar, --spring.profiles.activeprod]然后docker-compose.yml里对应用容器做健康检查用depends_on控制启动顺序。注意 MySQL 首次启动初始化比较慢如果应用容器先起来连接数据库会失败。我当时处理方法是给应用容器加一个restart: always同时等 MySQL 健康检查通过后再启动应用。Docker Compose 的healthcheck配置一定要用上services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: blog_db healthcheck: test: [CMD, mysqladmin, ping, -h, localhost] interval: 5s timeout: 5s retries: 20 app: build: . depends_on: mysql: condition: service_healthy redis: condition: service_healthy ports: - 8080:8080生产环境 Nginx 直接放在宿主机上监听 80/443SSL 证书用 Lets Encrypt反向代理转发到容器的 8080 端口。配置里要注意proxy_set_header否则获取客户端 IP、HTTPS 协议都会有问题server { listen 443 ssl; server_name blog.example.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }还有一点应用容器里要设置TZAsia/Shanghai否则日志时间和定时任务时间都会差 8 小时。这个很小但非常烦。4. 常见问题与排查技巧实录4.1 Spring Boot 版本太高引发的自动装配失效我在开发中期遇到过一个特别隐蔽的问题自定义了一个全局配置类里面写了Configuration和Bean本来期待 Spring Boot 启动时自动装配结果应用跑起来完全没有加载这个 Bean。排查了很久才发现这不是代码问题而是 Spring Boot 3.x 改动导致的。Spring Boot 3 里自动装配机制从spring.factories迁移到了META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。如果你还在用 2.x 的写法在 3.x 项目里自定义 Starter 或者把配置类放在自动装配清单里AutoConfigurationImportSelector扫描不到。我当时是照着 2.x 教程建的 3.2 项目就踩了这坑。解决方式有两种要么把项目退回 2.7要么按照新机制创建.imports文件。如果你打算长期用 Spring Boot 3建议直接学新机制。类似的坑还有javax包名的问题。Spring Boot 3 把所有包名从javax.*改成了jakarta.*很多老代码直接编译报错。这个只能靠全局替换加上手动检查没有捷径。4.2 Thymeleaf 热更新失效改了模板要重启怎么办开发阶段每改一次 HTML 都手动重启应用太影响心情。我的目标是在页面上改完立即刷新浏览器看到效果。配置上除了spring.thymeleaf.cachefalse还需要引入spring-boot-devtoolsdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-devtools/artifactId scoperuntime/scope /dependencyDevTools 的自动重启原理是监控 classpath 下的文件变化静态资源和模板文件改动默认不会触发完整重启所以还要在application.yml里配置spring: devtools: restart: enabled: true additional-paths: classpath:/templates如果你用 IntelliJ IDEA还要注意编译器选项。IDE 默认不会自动编译静态资源你需要打开 “Build project automatically”并在高级设置里允许 “Allow auto-make to start even if developed application is currently running”。我经常遇到同事配了 devtools 却没用最后发现是 IDE 的自动编译没开。4.3 MyBatis 扫描不到 Mapper 的三种表现与解决博客系统里使用 MyBatis-Plus 时Mapper 扫描是最常见的坑。表现有好几种第一种是启动报错Invalid bound statement (not found)。这种多半是 XML 文件没有和 Mapper 接口同名同包或者没在mybatis-plus.mapper-locations里指定 XML 位置。我把 XML 放在resources/mapper下配置mybatis-plus: mapper-locations: classpath:mapper/*.xml第二种是启动时直接报无法注入 Mapper Bean。这种一般是MapperScan没有扫描到接口包。我习惯在启动类上写SpringBootApplication MapperScan(com.example.blog.mapper) public class BlogApplication { public static void main(String[] args) { SpringApplication.run(BlogApplication.class, args); } }注意如果拆分多模块MapperScan要确保路径覆盖所有 Mapper 接口或直接在接口上添加Mapper。第三种是查询返回空列表但接口没报错。这种多半是deleted逻辑删除字段没生效或者明明删过又恢复了数据还在但被过滤。总之排查顺序永远是先看 SQL 日志确认 MyBatis 实际执行了什么语句。开发环境把日志级别调成 DEBUG打开 SQL 输出logging: level: com.example.blog.mapper: debugSQL 一打出来问题基本就浮出水面了。4.4 MinIO 放到 Spring Boot 时的版本冲突与连接超时MinIO Java SDK 8.x 依赖okhttp这个 HTTP 客户端如果你项目中还有aliyun-oss或者其他也依赖 okhttp 的库经常会出现NoSuchMethodError或者ClassNotFoundError。当时我引入 MinIO 后直接报java.lang.NoSuchMethodError: okhttp3.internal.platform.Platform.get()就是 okhttp 版本冲突。我最后把 MinIO 自带的 okhttp 排除单独引入了一个匹配的 okhttp 版本dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.7/version exclusions exclusion groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId /exclusion /exclusions /dependency dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency另外如果你是在本地开发连服务器上的 MinIO而 MinIO 的endpoint配置的是内网 IP前端拿到的图片 URL 也是内网 IP外网访问肯定打不开。我在本地开发时就有这个问题图片能传、前端能看但换台机器就显示不出来。解决方式是在application.yml里区分环境开发环境用服务器的公网地址或做内网穿透生产环境直接用域名。这里没有偷懒的办法必须把访问地址配置成对最终用户可访问的地址。4.5 SpringDoc 接口文档关闭与配置Swagger 的那点事博客系统后台接口我配了 Swagger 文档方便调试。但上线后不想对外暴露太多接口信息所以要在生产环境关闭。SpringDoc 的关闭方式我踩过一次网上很多教程让你把springdoc.api-docs.enabledfalse配在配置文件里这确实能不开/v3/api-docs但要注意/swagger-ui.html仍然可能被静态资源拦截到。后来我统一配了springdoc: api-docs: enabled: false swagger-ui: disabled: true注意swagger-ui的配置在 1.x 和 2.x 版本里有差异。我项目里用的是springdoc-openapi-ui1.7.0用swagger-ui.disabledtrue是有效的。如果你升级到了 2.0 以上的版本配置项可能变成springdoc.swagger-ui.path这一类所以最好查一下对应的官方文档。还有一个坑当你的项目同时有spring.mvc.servlet.path自定义时Swagger UI 的访问路径可能变成{servlet.path}/swagger-ui.html不是默认的根路径也会让人误以为文档没开。这种小问题排查的时候先看控制台有没有打印 Swagger 相关日志比瞎猜快得多。4.6 关于版本、环境和心态的其他碎碎念除了上面几个典型问题我还想分享几个不那么起眼但实际很影响心情的细节。博客上线后要记得给 MySQL 和 Redis 加上密码策略开发环境用 root 空密码没问题生产环境还裸奔就是给扫描器送菜。MinIO 的默认账号是minioadmin/minioadmin上线后第一时间改掉。浏览量计数走 Redis 后数据库里文章的views字段和 Redis 里的数值会有一段时间不一致这是正常的。但如果你想实时展示浏览量前端应读 Redis 缓存值而不是去查数据库。我的实现是详情接口先从 Redis 拿值拿不到再从库里取。定时刷新数据库的时候也同时更新一下 Redis 的值避免计数器回跳。还有个人博客的核心是原创内容和持续写作技术框架只是载体。我见过不少人把写博客变成“搭博客”搭了半年文章没几篇。如果你的目标是把博客当练习项目那多实践没问题如果真想运营一个博客记住早日上线天天更新比反复重构框架重要得多。最后聊两句我的实际体会。这套 Spring Boot 个人博客系统让我把从前零散学过的东西完整串了一遍从数据库建模、后端分层、缓存使用到文件存储、部署上线、日志排查每一步都有真实的坑等着你。尤其建议你以后遇到问题别急着搜答案先把日志打开跑一遍再试着分析原因。自己踩出来过一次以后遇到同类问题就有底气了。如果这篇文章能帮你也少踩几个坑那这折腾就值了。