做社区类项目最磨人的不是某个功能写不出来,而是所有功能串起来之后,系统像一盘散沙。我之前花了不少业余时间搭了一个IT交流分享平台,就是标题里说的这套组合:SpringBoot+Vue3+MyBatis,前后端分离,数据库用MySQL。做这个项目的初衷很简单,想给自己和身边同行一个发技术文章、问问题、交流踩坑经验的地方,后来发现把它做成一个完整系统,反而能梳理清楚自己在后端、前端、数据库设计上的很多模糊认知。
这篇文章我会把整个项目的思路从头到尾捋一遍:为什么选这套技术栈,数据库表怎么设计,后端接口和前端页面各自怎么落地,联调时踩过哪些印象深刻的坑,以及最后怎么部署上线。如果你也在做类似的内容社区、博客系统、内部知识库,这篇文章应该能帮你省不少时间,至少能在我踩过的坑上面少摔几次。
1. 技术选型逻辑:为什么是SpringBoot+Vue3+MyBatis,而不是其他组合
1.1 前后端分离到底在解决什么问题
社区平台这种项目,未来大概率要接小程序、移动端或者给第三方开放接口。如果继续用服务端模板渲染页面,后端改一个按钮的位置都要重新打包整个应用,前端拿到的还是HTML片段,想复用只能重新爬内容。
所以我从一开始就定了原则:后端只提供JSON接口,所有页面交互都走RESTful API。前端独立开发、独立部署,两边约定好接口文档,并行推进互不阻塞。这也是这套项目能快速成型的关键原因——后端把接口调通,前端自己Mock数据开发页面,最后联调时再统一处理细节问题。
1.2 SpringBoot、MyBatis、MySQL各自扮演的角色
很多人选技术栈只看热度,不看边界。在做这个平台时,我对每一个组件的要求很明确:
- SpringBoot负责把业务逻辑、接口暴露、安全校验、事务管理串起来。它最大的价值是自动配置和生态整合,我只需要关注自己的业务代码,不用手动去配置一堆XML和Bean。项目里用的是SpringBoot 2.x版本,稳定、资料多、绝大多数第三方库都兼容。
- MyBatis负责数据库访问。刚接触的人可能会问,为什么不用Spring Data JPA?我的理由是:社区平台的内容查询比较复杂,比如按标签筛选、关键词搜索、联表统计点赞数,这些场景下MyBatis的SQL是显式写出来的,性能好不好、索引能不能命中,我一眼就能看出问题。JPA虽然开发快,但复杂查询生成的SQL不好把控,一旦数据量上来,排查起来很痛苦。
- MySQL负责数据持久化。社区平台是典型的读多写少场景,MySQL加上合理的索引和缓存设计完全够用。我没有一上来就上Redis,而是等到部署阶段才考虑缓存,避免项目初期架构过重。
1.3 Vue3这边的选择
前端选了Vue3+Vite+Element Plus。Vue3的组合式API(Composition API)比Vue2时代舒服很多,逻辑复用不再依赖mixins,一个useXxx函数就能把一组状态和行为抽出来。Vite的冷启动极快,开发时改代码秒级热更新,体验比Webpack好太多。
组件库选Element Plus,主要是因为它成熟、文档全,表格、表单、弹窗这些后台管理常见的组件都齐。对于内容展示类的页面,我自己的组件再单独封装,比如帖子卡片、评论列表、Markdown编辑器,而Element Plus主要管后台管理和表单部分。
表格对比一下选型时的核心考量:
| 组件 | 选择 | 不选什么 | 理由 |
|---|---|---|---|
| 后端框架 | SpringBoot 2.7 | Spring MVC手写配置 | 自动配置省事,生态成熟 |
| ORM | MyBatis | JPA/Hibernate | SQL可控,复杂查询好优化 |
| 数据库 | MySQL 8.0 | PostgreSQL(当时) | 团队最熟,部署资料多 |
| 前端框架 | Vue3 | Vue2 | 组合式API更好用,长远趋势 |
| 构建工具 | Vite | Webpack | 开发体验好太多 |
| UI组件库 | Element Plus | 自研组件 | 成熟稳定,省时间 |
提示:如果你的项目将来有大量全文搜索需求,MySQL里的LIKE '%关键词%'在数据量大了以后会很吃力。这个后面部署优化部分我会展开讲。
2. 数据库模型设计:一张张表怎么把社区跑起来
2.1 用户体系:不只是用户名和密码
用户表是平台的基石,我设计了这些核心字段:id、username、password、nickname、avatar、email、role、status、create_time。
密码不能明文存,我用了BCrypt加密。Spring Security自带的BCryptPasswordEncoder拿来即用,每个用户的密码哈希都带随机盐,即使数据库泄露,反查原始密码的成本也很高。
role字段我一开始只设计了user和admin两种,admin可以删除违规帖子和评论,普通用户只能管理自己的内容。status字段做账号状态,1为正常,0为禁用。后来发现还需要一个banned的中间状态,用来处理"暂时封禁"的场景,就在需求评审后加上了。
2.2 内容模块:帖子、评论、标签如何关联
平台的核心内容是帖子(post),我给它设计了如下字段:id、user_id、title、content、view_count、like_count、comment_count、category_id、status、create_time、update_time。
帖子内容我存的是Markdown原文。前端编辑器直接产生Markdown,后端存储时原样保存,展示时由前端渲染成HTML。这样将来如果想切换编辑器,或者做全文搜索,数据都是干净可用的。
评论(comment)字段相对简单:id、post_id、user_id、parent_id、reply_user_id、content、create_time。这里比较关键的是parent_id和reply_user_id。当A评论B的评论时,parent_id指向B那条评论的id,reply_user_id指向B的用户id。前端展示时可以按这个关系把评论串成树形结构。如果不加这个设计,就只能做一层评论,做不成"楼中楼",体验会差很多。
标签(tag)和帖子的关系是多对多,我拆了一张中间表post_tag,字段是id、post_id、tag_id。为什么不直接用post表存一个tag字符串列表?因为按标签筛选时必须走索引,字符串列表没法高效筛选。后面统计热门标签、做标签云也是靠这张中间表GROUP BY。
2.3 冗余字段的取舍:like_count和comment_count
一开始我设计like_count和comment_count是想实时统计的。比如用户点赞帖子,就向like表插一条数据,然后通过COUNT查询得到总数。但后来发现这样性能有问题——热门帖子被访问时,每次都去COUNT一次,评论列表也要COUNT,数据库压力不小。
我的方案是冗余计数。like表照常记录用户点赞关系(防止重复点赞),同时更新post表的like_count字段。具体做法是在一个事务里完成:插入点赞记录、更新帖子的like_count。这样查询帖子列表时直接读取冗余字段,性能好很多。唯一需要注意的是,如果点赞和取消点赞很频繁,这个字段会频繁更新,但在社区平台的规模下完全没问题。
2.4 索引设计:最容易忽略但最影响性能的部分
分享项目时我经常提醒别人:建表时就要把索引想好,别等数据多了再补。
我主要加了这些索引:
- post表:create_time索引(按时间倒序查最新)、user_id索引(查某人发布的帖子)、category_id索引(按分类筛选)。
- comment表:post_id索引(查帖子下的所有评论)。
- post_tag表:tag_id索引(按标签反向查帖子)、post_id索引(查帖子的标签)。
组合索引在这类平台里也很有用。比如"最新帖子"场景会同时按status和create_time过滤,我建了一个(status, create_time)的组合索引。注意,组合索引最左前缀原则——查询条件里如果用到两个字段,要把等值查询的字段放前面,范围查询的字段放后面。
3. 后端核心实现:认证、接口分层、MyBatis实战
3.1 登录认证:JWT + 拦截器,够用且无状态
这个平台我没有上完整的Spring Security框架,而是用了更轻量级的方案:JWT + 拦截器。理由是这个项目需要保护的接口大多是简单的登录校验,Spring Security的过滤器链配置起来有一定学习成本,而JWT方案足够清晰可控。
登录流程是这样的:用户提交用户名密码,后端校验通过后生成一个JWT,把userId和role放进token里,签名用HMAC密钥,有效期设为7天。前端拿到token后存到localStorage,每次请求时在Header里带Authorization: Bearer xxx。后端拦截器解析token,解析成功就把userId放到ThreadLocal里,供Controller层直接取用。
ThreadLocal这个细节值得说一句。如果不把它放到ThreadLocal,每个接口都要从参数里带userId,接口签名会很难看。我封装了一个UserContext工具类,set和get都很方便。需要注意,拦截器处理完请求后一定要调用remove方法清理ThreadLocal,否则Tomcat的线程池复用会导致数据串到下一个请求。
3.2 统一返回体和全局异常处理:把代码写干净的基石
前后端联调时最怕什么?各写各的返回结构。前端一会儿拿data,一会儿拿message,一会儿又拿result,根本没法统一处理错误。
我定义了一个Result 类,结构是code、message、data。code为200表示成功,401表示未登录,500表示服务器错误,业务校验失败可以自定义code。所有Controller都返回Result,前端axios封装里统一判断code,非200就弹错误提示。
全局异常处理用的是@RestControllerAdvice。我在里面捕获了这么几类异常:参数校验异常(MethodArgumentNotValidException)、业务异常(自定义BizException)、通用异常(Exception)。项目里有一个小技巧:业务代码中该抛BizException就直接抛,比如"用户名已存在"、"帖子不存在",全局异常处理器会把这些信息转成统一的错误返回,省去在每个Controller里写try-catch的麻烦。
3.3 MyBatis的XML写法和动态SQL
这个项目的数据访问层我用的是MyBatis的XML方式,因为部分查询的SQL比较复杂,写在注解里那可读性特别差。
举一个典型场景:帖子列表页的分页筛选,筛选条件可能有分类ID、标签ID、关键词、排序方式。这些条件都不固定,必须用动态SQL拼。我在XML里写成这样:
<select id="selectPostPage" resultType="com.example.vo.PostVO"> SELECT p.*, u.nickname, u.avatar FROM post p LEFT JOIN user u ON p.user_id = u.id <where> <if test="categoryId != null"> AND p.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (p.title LIKE CONCAT('%', #{keyword}, '%') OR p.content LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="tagId != null"> AND EXISTS (SELECT 1 FROM post_tag pt WHERE pt.post_id = p.id AND pt.tag_id = #{tagId}) </if> AND p.status = 1 </where> ORDER BY <choose> <when test="orderBy == 'hot'">p.view_count DESC</when> <otherwise>p.create_time DESC</otherwise> </choose> </select>标签会自动去掉第一个多余的AND,这个特性很实用。不过这里有个性能隐患:LIKE '%关键词%'会全表扫描,数据量大了以后不建议这么查。后面部署优化部分我会具体说。
3.4 分页查询:PageHelper还是手写Limit
分页这块我用了PageHelper,但在用之前也犹豫过。PageHelper的优点是省事,缺点是有时候会莫名其妙在不需要分页的查询里也执行COUNT语句,尤其是写了复杂SQL的时候。
用PageHelper有几个容易踩的坑:
- 永远在紧跟着要分页的查询语句之前调用PageHelper.startPage(pageNum, pageSize),中间不能插入其他SQL操作。
- 如果你的Mapper返回的集合类型是List,PageHelper会自动把总数塞到Page对象里,但如果你在Service层又对List做了二次处理(比如转成VO),分页信息会丢。我的做法是查询出来之后立即用PageInfo包装,再从PageInfo里取list。
分页参数我是这么定的:pageNum从1开始,pageSize默认10,最大不超过50。前端请求时传这两个参数,后端根据前端传来的排序字段拼接ORDER BY。注意排序字段不能直接用字符串拼接进SQL,否则会有SQL注入风险。我的方案是做一个字段白名单映射,前端传hot、new、lastest这些枚举值,后端映射到对应的数据库列名。
4. 前端Vue3落地:路由守卫、状态管理、Axios封装
4.1 工程结构:先把目录理清楚
前端工程用Vite创建,目录我习惯这样划分:
- src/api:所有调用后端的接口方法,一个模块一个文件,比如post.js、comment.js、user.js
- src/router:路由配置,登录页和首页是公开页面,其他页面需要登录
- src/store:Pinia的store,主要存用户信息和全局状态
- src/views:页面组件,按业务模块分文件夹
- src/components:公共组件,比如PostCard.vue、CommentList.vue、MarkdownEditor.vue
- src/utils:axios实例、工具函数
我见过很多半途而废的项目,前端代码全部堆在一个.vue文件里,动一下就怕改出bug。组件化不只是为了复用,更是为了降低单文件复杂度。PostCard组件只需要接收一个post对象,内部负责展示标题、摘要、标签、作者头像和浏览量,这样首页列表、个人主页列表、搜索结果页都能复用同一个组件。
4.2 路由守卫:没有登录就不能进
路由守卫是前端权限的第一道关卡。我配置了全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })这个写法很常规,但有一个细节容易被忽略:用户可能拿着一个过期token进入页面,前端守卫只判断"有没有token",不判断"token是否有效"。所以我会在Axios响应拦截器里统一处理401状态码——如果token过期,后端返回401,前端自动清理本地token并跳转登录页。
4.3 Pinia存储用户信息
登录成功后,后端返回token和用户信息。用户信息放在Pinia的userStore里,同时持久化到localStorage。页面刷新时,Pinia里的数据会丢失,所以要在应用初始化时从localStorage恢复。
我封装了一个storeToRefs的用法:在组件里通过userStore获取用户信息时,必须用storeToRefs包裹,否则响应式会丢失。这是Pinia和Vuex的一个明显区别,很多人第一次用Pinia会在这里踩坑。
4.4 Axios封装:统一处理Token和错误提示
Axios实例的封装直接决定前后端联调的效率。我的做法是:
- 创建axios实例,设置baseURL为/api。
- 请求拦截器里从localStorage取token,放到Authorization头。
- 响应拦截器里判断后端返回的code,如果code不是200,用Element Plus的ElMessage弹出错误信息。如果是401,执行退出登录逻辑。
开发环境下的baseURL如何代理到后端也是个重点。我在vite.config.js里配置了proxy,将/api代理到http://localhost:8080,并开启changeOrigin。这样开发时前端请求都是相对路径,不需要写死本机IP,多人协作时谁都能跑起来。
5. 前后端联调踩坑实录:跨域、时间格式、空指针与分页错乱
5.1 跨域问题:CORS配置和前端代理的配合
前后端分离项目第一个遇到的就是跨域。开发环境我通过Vite的proxy解决了,但部署到服务器上时,前端域名和后端域名如果不一致,还需要后端开启CORS。
我在SpringBoot里写了一个WebMvcConfigurer配置类,addCorsMappings设置允许的来源、允许的方法、允许的请求头和是否允许携带凭证。这里有个坑:如果前端Axios请求里携带了自定义Header(比如Authorization),后端必须在allowedHeaders里明确允许,否则请求会失败。
允许的来源不能设置成*,否则在携带凭证时会出问题。我上线时把允许来源设置成了具体的域名,例如https://example.com,安全性更高。
5.2 时间格式化:前后端显示差了8小时
联调时发现,前端显示的时间总是比实际时间少8小时。原因很简单:后端返回的LocalDateTime是ISO格式的字符串,没有包含时区信息,前端解析时默认按UTC处理了。
我的解决方案是后端全局配置Jackson的日期序列化格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8同时把LocalDateTime的序列化也统一了。这样前端拿到的是带加8时区的字符串,解析出来就是正确时间。
注意:如果将来做国际化的场景,不能把时区写死。但在国内项目里,这个写法是最省心的。
5.3 空列表和null的处理:别让前端给你擦屁股
后端返回空列表时,有的团队会返回null,有的返回[]。这个看起来小,但前端处理起来完全不同。我统一了规范:凡是List类型的返回,一律返回[],不返回null。字段值为null的情况也在全局配置里做掉了——Jackson设置includeNonNull,序列化时忽略null字段,前端拿不到对应字段就不用担心undefined。
Service层的空指针是另一个高发点。比如获取帖子详情时,帖子不存在,如果代码直接post.getAuthor()就会NPE。我统一在处理之前判断post == null,为null直接抛BizException,返回"帖子不存在或已删除"的错误信息。
5.4 分页错乱:PageHelper的"灵异事件"
我在联调时遇到一个记忆深刻的bug:首页帖子列表和侧边栏热门榜单明明是两个独立接口,但热门榜单返回的数据总是跟首尾对不上。
排查了很久,发现问题是PageHelper的ThreadLocal污染。首页帖子列表接口用了PageHelper.startPage,查询后没有正确分页。而侧边栏热门榜是另一个线程池任务,它执行SQL时"继承"了上一个线程遗留的分页参数,导致热门榜SQL也被强行拼接了LIMIT。
修复方案有两个:一是确保PageHelper.startPage之后紧跟的查询语句在同一次调用中完成,并且查询后调用PageHelper.clearPage()清理线程变量;二是使用分页插件时,避免在同一个请求里多次执行SQL但没有分页意图的查询。从那之后,我写了一个分页工具方法,所有分页查询都走这个工具方法,副作用就消除了。
5.5 相对路径和刷新404:前端路由的经典问题
前端使用Vue Router的history模式时,在Nginx部署后点击刷新,会出现404。原因是Nginx默认找不到/front/post/123这个真实存在的路径。
解决方案是在Nginx配置里加try_files:
location / { try_files $uri $uri/ /index.html; }这个配置的含义是:如果请求的路径匹配不到真实文件,就回退到index.html,让前端路由自己去匹配。部署时一定记得把这个配置加上。
6. 部署上线的完整流程和后续优化方向
6.1 后端打包与运行
后端打包用的是Maven的package命令,打成jar包。这里有个小技巧:我配置了SpringBoot Maven插件,打包后的jar包是可执行的,直接用java -jar命令就能跑。
服务器部署时我加了下面几个JVM参数,用于控制内存和编码:
java -jar -Xms512m -Xmx1024m -Dfile.encoding=UTF-8 platform.jar生产环境的配置我放在application-prod.yml里,通过--spring.profiles.active=prod指定。数据库账号密码、JWT密钥这些不能写死在代码里,我用环境变量的方式注入,避免配置文件泄露在代码仓库中。
6.2 Nginx配置:前端静态资源和反向代理
前端打包后生成dist目录,放到服务器上,用Nginx托管。Nginx配置里除了try_files,还需要把/api的请求反向代理到后端服务:
server { listen 443 ssl; server_name yourdomain.com; ssl_certificate /path/cert.pem; ssl_certificate_key /path/key.pem; root /var/www/dist; index index.html; location / { try_files $uri $uri/ /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; } }这里有个容易忽略的细节:proxy_pass后面如果带了路径,转发时会把匹配的路径部分替换掉。我这里的配置里proxy_pass后面没有带路径,所以/api这个前缀会被一起转发给后端。如果后端接口路径是/api开头,这样就没问题;如果后端没有/api前缀,就要写成proxy_pass http://127.0.0.1:8080/并配合rewrite处理。
6.3 数据备份和基础监控
上线后最重要的两件事:备份和日志。
我写了一个shell脚本,每天凌晨用mysqldump备份数据库,保留最近7天的备份:
mysqldump -uusername -ppassword platform > /backup/platform_$(date +%Y%m%d).sql find /backup -name "*.sql" -mtime +7 -delete日志方面,SpringBoot默认只输出到控制台,我通过logback配置了按天滚动输出到文件,并且按大小切割,避免日志文件把磁盘占满。排查问题时,grep关键异常信息就能快速定位。
6.4 这个平台的下一步:搜索、缓存、消息通知
项目跑起来之后,我复盘了哪些地方值得继续投入:
- 搜索功能。目前用的是MySQL的LIKE模糊查询,数据量到十万篇帖子的量级后,查询速度会明显下降。要么用Elasticsearch做全文检索,要么用MySQL的全文索引过渡。
- 热点数据缓存。帖子详情页和热门榜单是访问量最高的接口,将来可以在Redis里缓存,设置合理的过期时间,减少数据库压力。
- 消息通知。有人回复了我的帖子或评论,目前只能靠用户刷新页面发现。下一步可以做站内信或者邮件通知,这是提升用户活跃度的重要手段。
- 模块化扩展。目前平台只有帖子和评论,将来加入问答模块后,可以把通用能力(用户、权限、标签)抽象成公共服务,避免每个模块都重复开发。
其实做这类平台项目最难的不是技术本身,而是对需求和数据的理解。比如设计评论的parent_id时,如果一开始没考虑楼中楼,后面改表结构会牵动前端组件和接口返回格式,成本很高。关于这点,我个人的体会有两点:第一,建表时多想想未来半年的业务发展,尽量把扩展字段留好;第二,前后端接口的返回结构一定要统一,哪怕前期慢一点,联调阶段你会感谢当初的决定。
最后分享一个小建议:如果你也想练手类似平台,不用一上来就追求功能多,先把"用户注册、发帖、评论、个人主页"这条主干链路跑通,部署上线体验一遍,再考虑加标签、搜索、消息这些附加功能。一条能真实访问的链路,比十个停留在代码层面的半成品模块有价值得多。