
1. 前后端分离改造的核心思路与第一步规划1.1 分离之前遇到的真实问题“前后端分离”这四个字很多刚接触全栈开发的同事理解成“我不用 JSP 了”或者“我把 JS 单独放一个文件”这个理解太浅了。真正意义上的前后端分离是让后端不再关心页面长什么样前端也不再关心数据是怎么查出来的双方只通过接口契约协作。我之前维护过一个用模板渲染的老项目改一个按钮就要动后端页面、重启应用。最难受的是前后端没法并行开发前端要等后端把页面模板写完才能套样式一套迭代下来周期被拉得很长。这次做“知识回顾”其实就是把类似这样的老模块拆成 Vue 3 Spring Boot 的前后端分离结构。核心目标很简单第一页面展示和数据处理彻底解耦第二让团队可以并行开工第三所有数据交互都收敛到接口层方便统一加权限、加日志、做性能优化。拆完之后回头看意义不只是代码更整洁而是整个团队的开发流程变了前端不用再等后端出页面后端也不用被前端样式折腾各干各的联调时只需要对着接口文档对数据。1.2 分层思路Controller、Service、Mapper 各干各的分离改造最容易犯的错是只把接口拆出来但后端代码还糊在一块。比如直接在 Controller 里写 SQL、做判断、拼返回结构前端确实拿到接口了但后端复用性和可维护性都很差。我这次的项目严格按三层来切Controller 只负责接收参数、调用 Service、把结果封装成统一响应Service 负责业务规则、事务边界Mapper/Repository 只做数据访问。举个例子查询一篇文章详情的接口Controller 只做三件事接收 id、调 service、返回 ApiResponse。真正的查询逻辑放在 Service再往下拼接查询条件、处理结果在 Mapper。代码结构大概是RestController RequestMapping(/api/articles) public class ArticleController { private final ArticleService articleService; public ArticleController(ArticleService articleService) { this.articleService articleService; } GetMapping(/{id}) public ApiResponseArticleVO getArticle(PathVariable Long id) { return ApiResponse.ok(articleService.getArticleById(id)); } }这样写的好处是接口升级时不用动前端改动集中在服务层。比如后续要给文章加阅读量统计前端接口路径和入参都不用变只要在 Service 里补充逻辑再把 VO 里加一个字段就行。为了不让数据库实体直接暴露给接口我还在 Controller 和 Service 之间加了一层 DTO/VO 转换。很多新人图省事直接把实体类返回给前端这样做问题很大表结构字段改了会影响接口返回后端加了个敏感字段前端也能看到。我用了一个简单约定Controller 层永远不返回数据库实体只返回 VO前端传入的数据也先转成 DTO 再交给 Service。这样多写几个转换方法但长期维护成本低很多。1.3 拆分过程最容易被忽视的四个点第一是跨域。前后端分离之后前端页面在 5173 端口后端接口在 8080 端口浏览器默认不允许跨域请求。我用 Spring Boot 的 CORS 配置解决但注意“带 cookie 的请求不能把 Allow-Origin 设为 *”。我一开始图省事用了通配符结果登录一直失败查了很久才发现是允许的来源没写具体域名。第二是会话状态。以前 Session 存在服务器现在前端是独立部署我改成 JWT 方案后端只负责签发和校验前端把 token 放在请求头里这样后端接口天然支持多个前端网页、小程序共用。这里要提醒的是JWT 的 secret 不要写死在代码里放到配置中心或者环境变量里更安全。第三是静态资源。后端不要再管 CSS/JS 的引用了前端构建完的 dist 目录交给 Nginx 托管接口请求统一走 /api 前缀转发到后端。Nginx 配置里要注意 proxy_pass 末尾的 /多一个少一个 slash 会导致路径拼接错乱。第四是环境配置。开发环境前端访问 http://localhost:8080 或者通过 Vite 代理转发生产环境走 Nginx 反代这个变化要在前后端各自的配置里分开维护。我习惯前端用 .env.development 和 .env.production 两个文件后端用 application-dev.yml 和 application-prod.yml各自维护好 baseURL 和接口地址这样部署切换时不会手忙脚乱。这几点看起来琐碎但每一条都会在联调时变成“时间黑洞”。2. 接口编写从 RESTful 设计和参数校验到统一响应2.1 RESTful 设计资源、动作、状态码三条规则接口编写看似简单实际规划和写代码是两回事。我在这次项目里统一采用 RESTful 风格核心规则就三条用名词表示资源用 HTTP 方法表示动作用状态码表达结果。文章相关的接口设计大概是这样的方法路径说明GET/api/articles文章列表分页 筛选GET/api/articles/{id}文章详情POST/api/articles创建文章PUT/api/articles/{id}更新文章DELETE/api/articles/{id}删除文章列表接口用 query 参数做分页、筛选、排序比如GET /api/articles?page1size10categoryId3sortcreatedAt,desc。一开始我们有的同事习惯用“/getArticleList”这种动词 URL后来统一改成资源型 URL前端对接起来发现非常直观看 URL 就知道在操作什么资源。状态码这块也要较真。很多项目不管成功失败都返回 200把错误信息塞在 body 里这种设计会让前端拦截特别费劲。我的约定是成功用 200创建用 201删除成功返回 204参数错误 400未认证 401没权限 403资源不存在 404服务器异常 500。前端 axios 拦截器可以区分“HTTP 层错误”和“业务层错误”处理起来清晰很多。2.2 统一响应体前端不用猜的接口约定接口响应如果每个人结构都不一样前端写死三个字段就够头大了。这次项目的响应格式统一成这样public class ApiResponseT { private int code; // 0 成功非 0 业务错误 private String message; private T data; public static T ApiResponseT ok(T data) { ApiResponseT response new ApiResponse(); response.setCode(0); response.setMessage(success); response.setData(data); return response; } public static T ApiResponseT error(int code, String message) { ApiResponseT response new ApiResponse(); response.setCode(code); response.setMessage(message); return response; } }业务错误码我们分了几大类1001 参数校验失败2001 未登录或 token 过期3001 资源不存在5000 系统异常。data 放业务数据列表接口固定返回{ total, list }结构方便前端做分页组件。后端实现写一个 ApiResponse 泛型类之后用 RestControllerAdvice 做全局异常处理所有接口的响应格式都是套好模板的不会出现“这个接口返回 { success: true }那个接口返回 { status: 1 }”的不一致。我在项目里还约定Controller 里不能手动去 new ApiResponse而是通过抛业务异常或者直接返回 ok() 来处理让异常处理流程统一走全局 Advice。2.3 参数校验和幂等性防呆比实现功能更重要接口能跑通只能算完成 30%参数防呆才是后面省事的关键。我用 Validation 注解做第一道防线比如创建文章接口的请求对象public class ArticleCreateRequest { NotBlank(message 标题不能为空) Size(max 100, message 标题长度不能超过100) private String title; NotBlank(message 内容不能为空) private String content; NotNull(message 分类ID不能为空) private Long categoryId; }这样非法参数根本进不到 Service 层直接返回统一的参数错误码。全局异常处理里加上 MethodArgumentNotValidException 的处理前端拿到的还是同一个结构code 1001message 就是校验注解里的提示。幂等性是我这次特别关注的点。以前遇到过用户快速点两次“创建订单”生成了两条重复订单后端也没拦住。这次的方案有两种一种是前端生成幂等键放在 Header后端在 Redis 里检查重复另一种是业务字段加唯一索引比如订单号。两者相比唯一索引更可靠幂等键能提前拦截。对于登录、支付这类高频敏感请求我还在接口上简单做了次数限制防止被脚本轰炸。防呆逻辑写在接口层既保护了后端也减少了前端排查问题的难度。3. 其他优化从接口性能到前端加载3.1 数据库查询优化慢SQL、索引和深分页接口写完了下一步就是优化。这次“知识回顾”里的其他优化重点落在了数据库、缓存、并发和前端几个方向其中数据库优化性价比最高。我先在 MySQL 开了慢查询日志收集执行时间超过 1 秒的 SQL结果发现列表接口的一条查询 join 了四张表还带 order by全表扫描跑了 3 秒多。用 explain 一查type 是 ALLrows 十万多Extra 里还有 Using filesort。解决办法不复杂给 join 条件字段和 where 条件字段建合适的联合索引把 select * 改成只查需要的字段深分页从 offset 改为游标分页。比如文章列表的查询核心条件往往包含 category_id 和 status那就建这样的索引ALTER TABLE article ADD INDEX idx_category_id_status (category_id, status);改完这条 SQL 从 3 秒降到了 20 毫秒左右效果非常直观。索引也不是越多越好我这次总结的经验是联合索引按“等值条件字段放前面、范围条件字段放后面、order by 字段尽量包含在索引里”的顺序建优先用覆盖索引让查询能直接在索引里拿到字段避免回表。还有几个常见坑要记住对索引字段做函数运算会导致索引失效LIKE %关键词%也会失效隐式类型转换同样会让索引失效。这些细节排查一次比盲目加一百个索引管用得多。3.2 缓存和并发控制热点数据不要每次查库数据库优化之后还有一个更省力的优化点把读多写少的热点接口加到缓存层。我在这套项目里用的是 Redis主要缓存两类数据一类是文章详情这类单个资源另一类是热门分类列表这种聚合查询结果。实现上直接用 Spring Cache 的 Cacheable 注解key 用业务前缀加 id缓存过期时间看业务来定文章详情这种半实时数据我设了 60 分钟。这里有一个值得专门强调的点大促或热点活动时一个热点 key 突然失效如果大量请求同时去查库就会出现缓存击穿。我的处理方案是热点 key 不设过期时间依赖后台任务刷新或者加互斥锁只让一个请求去重建缓存。缓存穿透也要防范有人恶意请求一个不存在的商品 ID每次都打不到缓存直达数据库我采用了两种方案一种是把空结果也缓存几十秒另一种是上线前加布隆过滤器拦截明显不存在的 ID。对于并发问题写操作我用数据库乐观锁表上加 version 字段更新时带上 version 条件更新行数为 0 就重试。这个方案比直接用分布式锁简单很多适合文章更新、评论点赞这类并发冲突不严重的场景。这样优化后接口平均耗时有明显下降数据库连接数也稳定了。3.3 前端性能优化打包体积、懒加载、请求合并前端这边我也做了一些优化。构建工具用 Vite第一个优化点是路由懒加载把详情页、大页面都改成动态 import首页首屏只加载必要的 chunkconst ArticleDetail () import(/views/ArticleDetail.vue)然后是依赖拆分把体积较大的第三方库单独打包成 vendor 块利用浏览器的长缓存。改完以后打包出来的 js 体积比原来小了约三分之一首屏加载时间明显下降。请求层优化我主要做了三件事。第一是 axios 拦截器统一处理 code业务错误直接弹出提示未登录跳转逻辑不用每个页面重复写。第二是请求合并列表页存在多个接口串行请求的情况能并行的用 Promise.all 合并减少网络往返。第三是重复请求拦截用户快速切换筛选条件时上一个请求未完成就不要发下一个避免响应乱序覆盖。还顺手给图片加上了懒加载大图走 CDN。前端优化不像后端优化有那么明显的数字直观但用户体感差别很大值得投入。4. 常见问题与排障记录4.1 接口调用报 404/405/500 的排查顺序前后端联调最常遇见的三大问题无非是 404、405、500排障顺序其实有套路可循。我一般先看网络请求的完整 URL 和响应状态码然后用 curl 直接打后端接口绕开前端代理确认是后端的问题还是前端代理的问题。如果 curl 通而浏览器不通优先查 Nginx 的 proxy_pass 配置和 context-path如果 405就去查前端用的 HTTP 方法跟后端映射是否一致如果 500看后端日志的堆栈信息八成是空指针、SQL 报错、Redis 连接断开这三类。下面这个表格是我这段时间总结的排查速查表现象检查项常用排查方向404路由 path、Nginx proxy_pass、后端 context-pathcurl 直连后端逐层确认哪一层断了405HTTP 方法、URL 映射确认 POST/PUT/DELETE 是否用对500后端日志、异常堆栈检查 SQL、NPE、Redis、连接池400参数格式、Validation 注解看是否有必填字段缺失或类型不匹配跨域报错CORS 头、Origin、Credentials带 cookie 时 Allow-Origin 不能为 *这表看起来简单但每次联调时能省不少时间。强烈建议团队把“先 curl 直连后端”放进排查流程很多人查了半天结果是 Nginx 少配了一个 slash。4.2 跨域通了但 Cookie 带不上、业务 code 对不上跨域问题是前后端分离项目的高频问题。我的经验是“看起来通了”和“真正能用”是两件事。比如前端请求能拿到数据但登录状态总是丢十有八九是跨域配置里没有开启 credentials。另外 OPTIONS 预检请求也要处理好否则前端发出非简单请求时会直接失败。这里建议后端接口层统一处理 CORS把允许来源、允许方法、允许头、是否允许凭证一次配好别让每个 Controller 自己加注解。还有一类问题是接口响应 body 里有 code 字段前端拦截器却总是提示“接口异常”。我排查过几次原因通常是后端某个异常没有走全局处理器返回了默认的 Spring Boot 错误结构前端拿到后没有 code 就误判了。解决办法是全局异常处理里加一个兜底方法确保所有未知异常都被包装成统一响应格式。这样前端拦截逻辑就简单了只看 code 是不是 0其他都是失败。4.3 优化前后的实测数据与复盘这里放一组我自己项目里的实测记录给想看到量化效果的同学一个参照。改造前列表接口涉及多表 join平均耗时约 3.2 秒慢查询一天能抓到几十条优化索引和查询字段后平均耗时降到 24 毫秒慢查询基本消失。缓存上线后文章详情接口的 P95 耗时从 180 毫秒降到 25 毫秒左右数据库连接池的活跃连接数直接降了一半。前端侧路由懒加载和依赖拆分让首次访问的静态资源总大小减少了约 35%页面加载时间从 4.5 秒降到了 2.1 秒左右本地模拟网络环境测试。这些数据并不代表一定能复现因为项目规模和数据量不同但优化方向和收益比例是可以参考的。我给团队定的原则是任何优化上线前都要记录优化前和优化后的数据哪怕是一个字段长度的调整也要有对比记录这样才知道是不是在做无用功。5. 复盘这套改造方案落地后的真实体会5.1 我的建议按这个顺序推进踩坑最少如果想在现有项目里做前后端分离改造我建议不要一次性推倒重来而是按模块逐步迁移。第一个要定的是接口规范和错误码这是前后端并行开发的地基第二个是后端的全局异常处理和各种拦截器这会减少联调时一半的沟通成本第三个才是跨域、鉴权、环境配置这些支撑设施。最后再开始抽页面、改接口。这个顺序看起来慢实际上最稳。团队如果小一个人兼着前后端也要坚持“先定契约再写代码”的习惯不然后面改接口会改到怀疑人生。还有一点就是要及时更新接口文档。我自己吃过亏接口改了之后没有同步文档前端拿着旧文档联调来回对了好几轮才发现。后来约定接口变更必须在合并代码前更新文档或者附上响应示例这一条坚持下来线上问题少了很多。5.2 后续可以继续优化的方向这次“知识回顾”做到数据库、缓存和前端优化就告一段落了。如果项目规模再往上走我会优先考虑几个方向一是把部分实时统计接口改成异步任务预计算查询时直接读结果表二是引入消息队列削峰比如订单创建、日志收集这类写操作三是接口文档自动化用 OpenAPI 或类似工具维护保证接口变更能自动同步给前端。另外近期有很多针对向量数据库集成与优化的讨论如果业务里出现语义检索需求可以把文章内容的向量索引也纳入改造范围。但前提是现有接口和数据结构已经足够稳定不然一步到位的收益会被复杂度过快抵消。技术选型没有统一答案关键是在合适的规模用合适的方案。这次回过头来梳理我个人最受益的一个习惯是每次改完接口都顺手在文档里更新一下响应体示例并且把请求参数和返回字段的约束写清楚。一开始看着傻实际联调阶段帮我少接了很多沟通电话。做“知识回顾”的过程中我把这些踩过的坑又重新串了一遍发现多数问题的根源不是技术本身有多难而是开始之前没有把边界和责任讲清楚。前后端分离的真正价值最后都体现在协作效率上接口规范、错误码、环境配置这些基本功做到位后面的优化才有稳定的地基可以站。