做博客系统的时候,我一开始也想偷懒:直接用现成的博客框架,改个主题就上线。可真要把它做成一个能运营的内容产品,事情就没那么简单。你需要一个后台让编辑写稿、配图、定时发布;需要权限让不同的成员只看到自己该看的模块;还需要前台快速加载,甚至以后要接小程序和手机端。这些需求叠加起来,Spring Boot + Vue 这套前后端分离的组合,就成了很务实的选择。下面的内容基于我做过的一个完整案例,从数据库设计、后端接口、前端管理界面到最后的部署优化,把关键技术点逐个讲清楚,适合正在做类似项目或者准备自己搭内容平台的同学参考。
1. 为什么做博客管理系统还要前后端分离:项目背景与技术栈选型
1.1 博客管理系统要解决的真实问题
很多早期博客系统就是一个页面脚本加数据库,前台能展示文章,后台可以直接在服务端模板里嵌入编辑表单。这个方案对个人博客确实够用,但一旦涉及多作者、分类推荐、定时发布、评论审核、素材管理这些运营功能,问题就暴露出来了。服务端模板把页面渲染和业务逻辑绑在一起,每次改一个按钮、加一个筛选条件,都要动模板和接口,前端想加个富文本编辑器的拖拽排版更是费劲。
博客管理系统的核心矛盾是“读多写少”。前台用户看文章,每秒可能有几百上千次访问;后台编辑人员少,但操作复杂,需要大量交互。如果所有页面都走服务端渲染,数据库压力会集中在热门文章上,而且前后端无法独立升级。拆成 Spring Boot 提供接口、Vue 负责界面的前后端分离结构之后,前台可以加缓存、加静态托管,后台只管调接口,两边互不干扰,后续接小程序或者手机端也只是多一套客户端的事。
这个项目里的“博客管理系统”包含两层:对外是博客前台,用户能浏览文章、按分类和标签筛选、查看详情并发表评论;对内是管理后台,用户可以登录,管理员能管理文章、分类、标签、评论,还能查看基础统计。两层共用同一套接口,只是前端路由不同。这种结构也适合把“内容管理”抽出来给其他业务模块复用,比如做活动页、帮助中心、公告栏,都是同一套文章的骨架。
1.2 技术栈选型:Spring Boot、Vue 与周边组件
后端我选了 Spring Boot 2.7.x。有人可能会问:都用 2025 年了,为什么不直接上 Spring Boot 3?原因很实际:公司现有内部组件和很多数据库驱动还停留在 JDK 8 兼容的版本,团队的维护习惯也在 2.x 上。如果你是从零开始没有历史包袱,直接上 Spring Boot 3 也是没问题的,核心逻辑差别不大。选 Spring Boot 本身是因为它把 Tomcat、Spring MVC、数据访问、健康检查这些都集成到了一起,一个可执行 jar 包就能跑起来,对中小型内容系统足够轻。
ORM 选了 MyBatis-Plus。它在 MyBatis 基础上封装了单表 CRUD,内置分页插件和逻辑删除,可以减少大量重复 SQL。博客系统的表结构不复杂,但文章和标签这种多对多关系还是需要手写 Join,MyBatis-Plus 并不会限制你写复杂 SQL,这是它比纯 JPA 更灵活的地方。数据库用 MySQL 8.0,性能和功能都够用;缓存用 Redis 处理首页热点数据和文章浏览量。JWT 负责登录认证,秒杀那种复杂权限暂时用不到,后面会专门讲为什么。
前端选了 Vue 3 + Vite + Element Plus + Pinia + Axios。Vite 的冷启动比 Webpack 快得多,开发时改代码秒级热更新。Element Plus 提供了表格、表单、上传、弹窗这些后台管理系统几乎都会用到的组件,省去自己造轮子的时间。Pinia 是 Vue 3 官方推荐的状态管理工具,语法比 Vuex 简单,用起来很顺手。富文本编辑器我用的是富文本类组件,而不是纯 Markdown,因为后台的运营人员不一定熟悉 Markdown 语法,所见即所得的上手成本最低。
技术栈对比可以看这张表:
| 维度 | 前后端分离方案 | 一体化服务端模板 |
|---|---|---|
| 前后端迭代 | 可独立发布,改后台不影响前台 | 改模板容易连带全站发布 |
| 前台性能 | 可静态托管、CDN 加速 | 通常依赖服务器渲染能力 |
| 多端扩展 | 接口可直接给小程序/App 用 | 需要额外写一套接口 |
| 开发成本 | 初期略高,需要定义接口 | 早期顺手,后期耦合重 |
| 适用场景 | 多运营角色、多端、长期迭代 | 个人博客、极轻量场景 |
1.3 整体架构:从浏览器到数据库的一条链路
整个系统的请求链路是:浏览器先访问 Nginx,Nginx 负责两件事,一是托管 Vue 打包出来的静态页面,二是把/api/开头的请求反向代理到 Spring Boot 服务。Spring Boot 接口收到请求后,先从 Redis 查缓存,缓存没有再到 MySQL 查数据。用户登录后拿到 JWT,每次请求都带在请求头里,后端通过拦截器校验身份和角色。
这种结构在本地开发时也很方便:Vite 开发服务器可以通过 proxy 配置把/api转发到本机后端端口,后端写接口不用关心跨域问题,前端也不用非得和前端页面部署在同一台机器。等真正上线时再用 Nginx 做统一入口,身份校验和静态资源缓存都集中在网关层,逻辑清晰,出问题也好排查。
2. 数据库表设计:别急着写代码,先把表和关系理清
2.1 用户、角色与权限的三表模型
博客系统最容易被忽略的是用户权限模型。很多项目一开始只在用户表加一个is_admin字段,管理员就是 1,普通用户就是 0,看起来简单,等你想加一个“编辑审核员”角色时就尴尬了:要用一个is_editor字段,再加一个is_can_publish字段,字段越加越多,代码里到处是if (user.isAdmin())的判断,维护成本很高。
我用的是标准的 RBAC 模型:用户表、角色表、用户角色关联表。用户表存登录信息和基本资料,角色表存角色名称,比如ADMIN、EDITOR、USER,用户角色关联表负责两边多对多关联。注意密码字段存的是 BCrypt 加密后的密文,绝对不要存明文密码,哪怕只是内部系统也不行。角色表里我加了description字段,方便后台列表展示时知道这个角色是干什么的。
如果以后要做更细的权限,可以在中间加菜单表或者权限表。博客系统不是 ERP,角色控制在两级基本够用了。管理员有全部接口权限,编辑可以管理文章和评论,普通用户只能修改自己的资料、发表评论。如果用户表一开始没设计角色表,也不要急着把所有代码推翻,通过 SQL 脚本把用户表的is_admin字段迁移到角色表,中间表根据用户名关联生成,既能保住老数据,又能顺利扩展。
2.2 文章、分类、标签的关系设计
文章表是整个系统的核心。除了常规的 id、标题、正文、作者 ID,我还设计了summary、cover、status、publish_time、is_top、view_count、like_count这些字段。summary是从正文自动截取的摘要,列表页展示摘要可以避免每次查全文;cover是封面图 URL;status用整型表示枚举状态:0 草稿、1 已发布、2 定时发布、3 回收站。
分类和标签是两个不同维度的信息。分类是树形结构,比如“技术”“生活”“随笔”,一篇文章通常只属于一个分类;标签是扁平结构,比如“Java”“Spring Boot”“后端”,一篇文章可以有多个标签,也可以跨分类打标签。所以分类用一对多关系,文章表加category_id外键;标签用多对多关系,需要一张关联表article_tag,关联表里存article_id和tag_id,还可以加一个create_time方便排查数据。
为什么不用一张category字段存多个分类 ID?因为数据库最忌讳把多个 ID 塞进一个字符串字段里,查询时没法用索引,更新时不方便,统计分类文章数量也麻烦。多对多关系虽然多了一张表,但查询和统计数据时非常灵活,比如“某个标签下文章数量”就是一句带GROUP BY的 SQL。我建议在article表上建两个联合索引:(status, publish_time)用来查已发布文章列表,(category_id, status)用来查某个分类下的文章,分页查询会快很多。
2.3 评论表与逻辑删除的设计细节
评论表设计时,关键是parent_id字段。顶级评论的parent_id为 0,回复某条评论时,parent_id指向被回复评论的 ID。这样在后台可以递归地组装评论树,在前台也能方便展示楼中楼结构。注意要把文章 ID 和用户 ID 都加上索引,否则文章详情页查询评论时,数据量稍大就会慢。
我建议评论表增加status字段,默认待审核。博客系统很容易被灌垃圾评论,如果每条评论都直接上屏,体验会非常差。管理员在后台先审核,通过后状态改为 1,用户在前台只能看到已审核通过的评论。这里有个细节:删除评论时不要物理删除,用逻辑删除标记deleted字段,否则会导致子评论游离、计数对不上,还影响人为溯源。
逻辑删除几乎用在所有核心表上。文章删除后先进回收站,30 天内可恢复;评论删除后变成“已删除”,但帖子下面仍然保留占位提示。这要求写 SQL 时不能只写WHERE id = ?,必须带上deleted = 0条件。MyBatis-Plus 的逻辑删除插件会在 SQL 拼接时自动加上这个条件,但也意味着所有手写 SQL 都要自己注意。
2.4 数据库设计阶段常踩的三个坑
第一个坑是逻辑删除字段和唯一索引冲突。比如用户名做了唯一索引,用户注销后逻辑删除,却一直占着用户名,别人没法注册。更常见的是文章表的slug做了唯一索引,删除一篇文章后,重建同 slug 文章会失败。解决方法是把deleted字段放进唯一索引里面,或者删除的时候顺手改掉原字段值,比如追加_deleted_{id}。
第二个坑是时间字段和时区。MySQL 里建议用DATETIME,后端代码用统一的时间格式LocalDateTime,数据库连接参数里设置serverTimezone=Asia/Shanghai。如果部署在云服务器上,容器时区和数据库时区不一致,定时发布就会提前或者延后几个小时,排查起来非常迷惑。
第三个坑是正文长度。博客文章的正文用LONGTEXT,但列表页查询时如果用SELECT *,一页 20 篇文章很可能把几 MB 的 HTML 都查出来,网络传输和 JSON 解析都很慢。正确做法是列表查询只查id, title, summary, cover, publish_time, view_count,详情接口才查完整正文。数据库字段再大,也要靠接口层控制返回内容。
3. 后端核心链路:登录认证、文章发布与缓存加速
3.1 JWT认证:自己写拦截器还是用安全框架
我知道很多人推荐直接用 Spring Security,功能确实全面,但配置项也复杂,一个新手很容易被过滤器链搞懵。对博客管理系统这种只有登录、角色校验、少量接口放行的中型项目,我用的是 JWT + HandlerInterceptor 的方案,代码量小,逻辑也透明。
登录流程是:前端先请求验证码接口,后端生成图片验证码并把他存在 Redis,过期时间 5 分钟。登录时提交用户名、密码和验证码,后端先校验验证码,再查出用户,用 BCrypt 匹配密码,都通过后生成 JWT,返回给前端。JWT 的 payload 里放了userId、username、role,签名密钥放在配置中心环境变量里,过期时间设置 2 小时。前端拿到 token 后存到本地状态里,每次请求通过 Axios 拦截器加在Authorization请求头。
后端拦截器拿到 token 后解析,如果解析失败或者过期,直接返回 401。角色的校验不是所有接口都需要,所以我写了一个自定义注解@RequireRole("ADMIN"),在需要管理员权限的接口上标注即可,拦截器里读取注解做判断。这样做比在 Controller 里写一堆if判断清爽,以后加角色也方便。这里有个小提示:生产环境 JWT 的生成时间iat不要用本地时间,统一用Instant.now()或者指定 UTC 时间,很多部署后登录偶发失败的问题都和时区有关。
3.2 统一返回和全局异常处理
接口定义从开始就要统一格式,不然后端写一个样,前端写一个样,联调时一团糟。我定义一个通用响应体R<T>,字段包括code、message、data。成功时code=200,业务异常时code是业务编号,比如40001表示参数错误、40301表示无权限、50001表示系统异常。前端拿到非 200 的 code 后统一弹提示,不用每个接口单独处理错误。
配合全局异常处理器一起用,Controller 里就非常干净。我写了一个@RestControllerAdvice类,里面分别处理BizException(业务异常)、MethodArgumentNotValidException(参数校验异常)、Exception(兜底异常)。业务异常只在需要的时候手动抛出,例如“用户名已存在”“文章不存在”;参数校验异常则通过返回第一条错误消息,方便前端直接展示。兜底异常会打印完整日志堆栈,接口返回“系统繁忙”这种通用提示,避免把 SQL 错误信息直接暴露给用户。
3.3 文章发布的完整流程:草稿、定时发布与内容安全
编辑文章是一个多状态流程:新建时保存草稿,编辑完可以立即发布,也可以选择定时发布。前端把文章内容提交后,后端根据 status 字段决定如何处理。草稿状态下,正文和标题都可以随时修改,发布状态时如果再次修改,我会开启“编辑历史”功能,不过这个功能不是第一版必须的。
定时发布的实现很直接:文章记录status=2和publish_time,后端启动一个@Scheduled(cron = "0 * * * * ?")的定时任务,每分钟扫一次,查出status=2且publish_time <= now的记录,把状态批量改为 1。为了不重复发布,定时任务里用UPDATE article SET status = 1 WHERE status = 2 AND publish_time <= NOW()这种原子操作,即使任务下次又扫到也不会重复改。
发布过程中还有两个容易被忽略的点。一是 XSS 过滤,富文本编辑器提交的 HTML 必须经过白名单清洗,比如去掉<script>、onerror这类危险标签,否则文章里贴一段恶意代码就能偷用户 Cookie。二是摘要生成,如果编辑没有手动填摘要,后端就用jsoup把 HTML 转成纯文本,截取前 200 个字符。发布成功后,还要更新文章标签关联、清除对应缓存,这些操作包在一个事务里,任何一步失败都回滚,保证数据一致。
3.4 Redis缓存与更新策略
前台首页、分类页、标签页这些热点数据,直接查 MySQL 扛不住高并发。我把首页最新 20 篇文章、热门文章 TOP 10、文章详情缓存到 Redis。缓存 key 分别设计为blog:index:latest、blog:index:hot、blog:article:detail:{文章ID},设置过期时间 30 分钟。详情页访问时先查 Redis,没有再从数据库查,然后回填缓存。
浏览量这种写频繁的数据,不要每次访问都更新 MySQL,而是先在 Redis 里INCR一个计数器,文章浏览量增加之后每 5 分钟异步批量刷回数据库。这里我用一个简单的后台定时任务,把 Redis 里的 key 取出来,更新到 MySQL,再删除 Redis key。如果更新过程中崩溃,最多丢几分钟的统计数据,问题不大。
缓存更新是所有缓存系统的难点。我的原则是“修改操作主动删缓存,读操作再回填”。发布文章、修改文章、删除文章之后,都调用一个统一方法删除相关缓存 key,不依赖 Redis 的过期时间,这样用户在前台立刻能看到新内容,不用等过期。如果遇到更新缓存和读并发,最坏的情况是读到旧数据一次,下一次就正常了,对博客场景完全可接受。
4. 前端管理后台:Vue 3 + Element Plus的工程化实践
4.1 项目初始化、目录结构与路由权限
我用 Vite 快速创建项目,目录结构大概是这样:
src ├─ api # 接口请求封装 ├─ assets # 静态资源 ├─ components # 公共组件 ├─ layouts # 后台布局 ├─ router # 路由配置 ├─ stores # Pinia 状态 ├─ utils # 工具函数 └─ views # 页面组件 ├─ article # 文章管理 ├─ category # 分类管理 ├─ comment # 评论管理 ├─ login # 登录页 ├─ dashboard # 统计数据 └─ system # 用户管理路由权限用 Vue Router 的全局前置守卫实现:未登录访问任何后台页面都跳转登录页;登录后根据当前用户的角色,通过router.addRoute动态注入有权限的菜单路由。比如编辑角色看不到用户管理菜单,管理员能看到所有菜单。前端虽然隐藏了菜单,但真正的权限控制还是以后端接口校验为准,前端只是减少无效请求,不能把安全寄托在前端。
登录状态用 Pinia 管理,store 里存token、userInfo、roles。页面刷新时会读取本地存储的 token,再调用一个/auth/info接口拉取用户信息,重新生成菜单。这个过程里我会加一个全局 loading,避免刷新时菜单一闪而过。
4.2 富文本编辑器接入:让运营也能高效写文章
编辑器选型时我对比过 Markdown 和富文本两类。技术博客爱好者更喜欢 Markdown,但后台编辑通常不是开发人员,用 Markdown 排版很容易出现格式混乱。最终选了所见即所得的富文本编辑器,支持标题、加粗、引用、代码块、图片上传,工具栏和 Word 类似,上手快。
集成的关键点是图片上传。编辑器的图片上传要走我们的上传接口,后端返回{ "url": "https://xxx/upload/2025/07/xxx.jpg" }格式,编辑器会把 URL 自动插入到内容里。这里要注意:后台接口通常带 token,但编辑器组件里上传用的是它内部的 XMLHttpRequest,需要在创建编辑器实例时把 token 手动加到请求头,否则图片上传会 401。发布到线上之后,如果编辑器访问/upload路径还要求登录,也会出现图片 403,这个我在后面踩坑部分会细说。
文章编辑页还需要一个预览功能。我的做法是写一个独立的预览弹窗,拉取当前编辑内容,通过一个小型 HTML 渲染容器展示,样式尽量和前台详情页保持一致。如果编辑还没保存,预览也要能用,那就直接把当前编辑器内容传入弹窗组件,不经过保存接口。这样编辑可以在发布前检查排版效果,减少返工。
4.3 列表页、详情页与交互性能
后台文章列表页面对的是编辑人员,操作密度高,我重点优化了筛选和分页。顶部是筛选区,包括分类下拉、状态单选、时间范围选择,每个筛选条件对应后端一个查询参数;表格列固定展示标题、分类、标签、作者、浏览量、状态、发布时间、操作按钮。分页组件和筛选条件是同一个响应式对象,任何条件变化都重新请求第一页数据,避免分页错位。
详情页我的设计是前台和后台共用一套核心组件,但后台要多一个“加载未发布文章”的参数,比如前台只拉status=1,后台可以拉status=0和status=2。文章正文的渲染通过一个组件传入 HTML 字符串,前台直接渲染,后台则多一个安全过滤和编辑器切换按钮。这种共用组件能省不少代码,但前提是后端详情接口必须严格返回当前用户有权限看的内容。
性能和交互上,Axios 请求要加防重复提交。新建文章时双击保存按钮会提交两条,我用一个isSaving变量在请求期间禁用按钮。表格的 loading 状态分散在各个异步操作里,保证用户点击筛选后知道数据在加载,避免出现“点了没反应”的错觉。
4.4 图片上传、跨域代理与本地开发
图片上传接口接收MultipartFile,校验文件类型只允许 jpg、png、gif、webp,限制单张不超过 5MB,保存路径按照日期分目录,例如/upload/2025/07/18/uuid.jpg,返回完整 URL。开发环境压力小,先保存到本地上传目录,部署时用 Docker 卷挂载还是走对象存储,根据运维条件来定。
本地开发最大的坑是跨域。如果前端页面跑在localhost:5173,后端接口跑在localhost:8080,直接请求会被 CORS 策略拦掉。我没有在后端全局开启 CORS,而是用 Vite 的 proxy 把/api请求代理到8080,这样浏览器看到的是同源请求,不用后端配置跨域头。上线后用 Nginx 反代也是同样思路,把跨域问题放到网关层解决。
5. 部署与线上优化:从jar包到Docker+Nginx的落地过程
5.1 数据库、Redis、后端的Docker编排
本地开发环境通常装好 MySQL 和 Redis 就能跑,线上就不一样了,依赖版本、数据卷、网络都要提前设计。我用 Docker Compose 把四个服务编排起来:MySQL、Redis、后端应用、Nginx。下面是一个简化配置:
version: "3" services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: blog_db volumes: - mysql_data:/var/lib/mysql ports: - "3306:3306" redis: image: redis:7 volumes: - redis_data:/data backend: build: ./backend depends_on: - mysql - redis environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/blog_db?serverTimezone=Asia/Shanghai SPRING_DATA_REDIS_HOST: redis JWT_SECRET: ${JWT_SECRET} ports: - "8080:8080" nginx: image: nginx:1.24 volumes: - ./frontend/dist:/usr/share/nginx/html - ./nginx.conf:/etc/nginx/conf.d/default.conf - blog_upload:/app/upload ports: - "80:80" depends_on: - backend volumes: mysql_data: redis_data: blog_upload:后端打 jar 包时要注意,Spring Boot 的配置文件里数据库地址不要硬编码成localhost,而要用环境变量。在 Docker 网络里,MySQL 的地址是服务名mysql,Redis 是redis,如果还写 localhost,容器里连的是自己,自然连接失败。
5.2 Nginx反向代理与前端静态资源
Nginx 是这个系统上线后的统一入口,配置上我做了三件事:托管前端静态文件、反向代理后端 API、放行上传文件目录。前端打包后的dist目录挂载到 Nginx 的 html 目录,location /直接访问静态文件;location /api/把请求转发到http://backend:8080,路径里的/api可以在转发时去掉,也可以后端统一加 context-path,保持一致即可。
server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /upload/ { alias /app/upload/; expires 30d; } }try_files配置很重要,Vue Router 用 history 模式时,刷新文章详情页的 URL 如果只有前端的路由,Nginx 必须回退到index.html,否则会 404。上传目录单独配一个alias,让图片可以直接通过 URL 访问,不用经过后端鉴权;图片加expires 30d,浏览器会缓存图片,减少重复请求。前端打包时静态资源文件会生成 hash 后缀,Nginx 里给location /assets/也设置长时间缓存,提高加载速度。
5.3 慢SQL与连接池:性能优化真正要做的几件事
博客系统线上跑一段时间后,最容易出现的是首页接口变慢。我先查慢查询日志,发现大部分慢 SQL 都出在文章列表的ORDER BY publish_time DESC,原因很简单:publish_time没建索引,每次都要 filesort。补上(status, publish_time)联合索引后,接口从 400ms 降到 30ms 左右。
更隐蔽的问题是连接池耗尽。有一段时间后台文章列表接口偶发超时,排查日志发现是定时任务和前台访问共用同一个数据库连接池,定时任务一直持有连接,高峰期连接池被打满。我调整了 HikariCP 的参数:最大连接数从默认 10 调到 30,最小空闲 5,连接超时 30 秒,同时给定时任务单独配置一个只读数据源,避免它占用业务库的并发配额。
开启慢查询日志也很简单,MySQL 配置:
slow_query_log = 1 long_query_time = 1然后定期分析日志,遇到执行计划里出现ALL扫描或者索引列被函数包裹的情况,优先加索引或改写 SQL。
5.4 线上部署时最常遇到的三个环境问题
第一个是时区问题。之前部署到一台香港服务器,容器内时区没设置,MySQL 连接串也没指定时区,导致定时发布文章全部提前了 8 小时。后来我在 Docker Compose 的环境变量里给后端容器加了TZ=Asia/Shanghai,MySQL 连接串加上serverTimezone=Asia/Shanghai,这个问题才彻底稳定下来。
第二个是上传图片目录丢失。用 Docker Compose 部署时,上传目录必须挂载为 volume,否则后端容器重建,上传的文件全部消失。我把/app/upload挂载到命名卷blog_upload,Nginx 里的alias也指向同一个卷。如果以后用对象存储,这个地方就不需要卷了,接口返回的 URL 直接是 CDN 地址。
第三个是内存不足。后端默认堆内存可能不够,我在启动命令里限制了-Xms256m -Xmx512m,前端构建时如果出现 Node 内存溢出,设置NODE_OPTIONS=--max-old-space-size=1024。另外数据库连接数、Redis 最大内存也要在配置文件里设置确认,否则默认值在低配服务器上可能直接 OOM。
6. 踩坑实录:五个影响发布上线的问题与完整排查链路
6.1 定时发布不生效:时区与调度线程的联合排查
现象:在后台创建了一篇文章,设置定时发布时间为明天上午 10 点,第二天一看,文章还是“定时发布”状态,没有自动发布。
排查过程:我先看日志,发现定时任务每分钟都在执行,没有报错,SQL 里查询publish_time <= NOW(),按理说凌晨时区不对,到了 10 点也不该还有数据。然后我单独手动执行了那条 SQL,发现查询结果不对,因为publish_time存的是带时区的时间,而NOW()返回的是数据库服务器的本地时间。容器内数据库时区是 UTC,后台存储的时间是上海时间,两者相差 8 小时,我设置的文章发布时间实际还没到。修正方法是在数据库连接串里强制指定serverTimezone=Asia/Shanghai,并且在后端统一使用LocalDateTime.now()配合ZoneId.of("Asia/Shanghai")获取当前时间,不要依赖服务器默认时区。之后定时发布正常,这个坑属于典型的环境时区问题。
6.2 首页缓存不更新:缓存删除时机问题
现象:编辑人员在后台修改了一篇文章的标题,前台首页列表里显示的还是旧标题,持续了将近 30 分钟。
排查过程:后台修改接口确实返回成功了,数据库里的标题也改了,但首页缓存没有更新。我先确认首页接口走的是 Redis,然后查看修改文章代码,发现里面只删除了blog:article:detail:{id}这个缓存,没有删除blog:index:latest。首页缓存还在缓存时间范围内,所以读到旧数据。修复很简单,在文章更新、上下架、删除的所有接口里,抽一个统一方法clearArticleCache(articleId),删除文章详情、首页最新列表、标签页、搜索页所有相关缓存 key。这个坑告诉我们,缓存更新不是只删当前接口用到的 key,一定要梳理数据依赖,把关联缓存一起处理掉。
6.3 图片回显403:Nginx路径放行与鉴权区别
现象:本地开发时,文章里上传的图片能正常显示;部署到服务器几分钟后,刷新后台文章页,所有图片都变成灰色 403。
排查过程:打开浏览器控制台,发现图片请求返回 403,请求头里并没有带 Authorization。原来前端上传成功后,富文本编辑器把图片 URL 直接插入文章内容,前台详情页渲染这些图片时,请求头不会自动带 token。而我的后端有一个全局拦截器,把所有/upload/**请求也拦了,要求登录,导致图片没 token 时访问被拒。本地开发时因为前端代理把localhost:5173/upload代理到后端,浏览器同一源,可能没有触发拦截或者调试环境特殊,所以没暴露。修复方法是在 Nginx 里单独配置location /upload/放行,并且后端拦截器排除/upload/**、/api/auth/**等不需要登录的路径。更稳妥的方案是用独立域名或对象存储来放图片,管理接口和静态资源分离,权限边界就更清晰了。
6.4 列表数据“消失”:逻辑删除与状态筛选的组合陷阱
现象:后台新建一篇文章,状态设置为“已发布”,文章列表里却看不到。
排查过程:我查数据库,发现文章存在,状态确实为 1。然后看列表接口 SQL,发现查询条件里带了deleted = 0,而deleted字段默认值是 0,新数据不会有问题。于是我怀疑是前端筛选条件的问题,打开后台列表页,发现筛选区默认选中了“草稿”这个状态。原来前端筛选组件初始化时,状态值绑定为空字符串,但某个操作后设置成了默认值“0”,导致只查草稿。修复方式是把列表页的状态筛选项默认改成空值,表示全部,并让后端在没有传状态参数时不做状态过滤。这个坑很冷门,排查时容易被数据库数据“看起来正常”误导,一定记得先看请求参数。
6.5 前端表格卡顿:接口分页与前端数据量控制
现象:文章库大概有 5000 条数据后,后台文章列表页开始卡顿,筛选分类时更明显,甚至浏览器直接无响应。
排查过程:一开始以为 Element Plus 表格渲染大数据量能力差,后来打开 Network 面板发现接口一次返回了几千条记录,才知道后端分页参数没有生效。原因是前端分页组件在初始化时没有把pageSize传给接口,后端默认把size当成了 10,但某个改动把默认值弄没了,结果返回全量数据。修复后接口严格分页,每页 20 条,前端表格只渲染当前页数据,卡顿问题立刻消失。另外一个经验是:如果数据量真的达到几十万条,前端可以做虚拟滚动,但第一步永远是确保接口正确分页。
最后说几句个人的实操感受
做完这个博客管理系统,我最大的体会是:真正花时间的地方不在 CRUD,而在那些容易被忽略的“边角”。比如定时发布的时区问题、缓存删除时机、图片回显 403、列表默认筛选,这些踩坑经验看起来很琐碎,但每一个都直接影响线上体验。如果你也要做类似系统,我建议一开始就把权限模型和缓存 key 的规范定好,否则越到后期改造成本越大。
最后分享一个实用技巧:定时发布不要只依赖 Spring 的@Scheduled,可以加一张定时任务表,记录每篇文章的排期状态和下次执行时间,后台任务页能直观展示“待发布”“已发布”“发布失败”,运营在后台就能看到所有内容安排,不用查数据库日志。这样整个系统才算真正闭环,也方便以后做更复杂的发布策略。