前后端联调,十个报错里有八个是接口问题,而接口问题里,404、400、500这三兄弟又占了绝大多数。我这些年帮同事、帮网友排查过太多SpringBoot接口疑难杂症,发现大量问题其实翻来覆去就是那几个根源——路径没对上、参数没绑上、服务端没兜住。这篇文章干脆把SpringBoot接口开发里最常见的报错场景做一次系统性梳理,从404的路径映射、400的参数绑定,到500的服务端异常,每类报错我都给出排查思路、常见误区和可直接落地的解决方案。不管你是刚入门写Controller的新手,还是在为前后端联调憔悴的老手,这套方法应该都能帮你省下不少排查时间。
1. 404 报错:路径映射问题,先分清是谁甩的锅
1.1 服务没启动、路径前缀和注解用错,最常见的404根因
出现404时,很多人的第一反应是改代码,但我建议先分清楚这个404到底是谁抛出来的。SpringBoot项目自身的404通常是一个白标签错误页,页面上写着Whitelabel Error Page, status=404;如果是nginx返回的,页面风格明显不同;如果项目前面还有网关,响应体里往往是一个JSON,里面带着status=404。先确认是哪一层抛的,比直接埋头查Controller快得多。
最常见的404场景,是服务压根没起来。前端一调接口直接网络错误,console里提示net::ERR_CONNECTION_REFUSED,这种情况下后端应用要么进程挂了,要么启动失败了。实际开发中有个坑:IDE里看起来应用还在运行,但你改完代码触发了重启,启动过程中端口还没监听,前端这时候请求就会失败;更离谱的是你改了application.yml里的端口,旧进程没杀掉,新进程启动报"Port already in use",IDE还显示在运行中。遇到这种,直接看启动日志里有没有"Started Application in x seconds",或者用命令行curl一下本地的健康检查地址,立刻见分晓。
第二种是路径前缀问题。SpringBoot里有两个地方容易出404:server.servlet.context-path配置和Controller类上的@RequestMapping前缀。配置了context-path=/api,前端请求却漏掉了/api,那必然404。还有一种情况是类上写着@RequestMapping("/user"),方法上写@PostMapping("/create"),前端请求POST /user/create没问题,但如果前端把方法名一起拼进去,比如POST /user/createUser,那后端没有这个映射,404就是板上钉钉的事。
第三种是注解用错了。@RestController和@Controller的区别,很多人背得滚瓜烂熟,写代码时还是会混。直接用@Controller而方法上没有加@ResponseBody,方法返回字符串时会走视图解析器,Spring尝试去找一个同名的HTML模板文件,找不到就返回404。我帮人排查过一个接口,后端Controller明明有对应方法,断点都进不去,原因就是方法返回的是String,但类上用了@Controller,导致Spring去解析视图而不是直接返JSON。很多老项目里,某些方法忘了加@ResponseBody,就会出现这种"接口明明存在却404"的诡异现象。
还有一类要特别拎出来:请求方法不对并不会返回404,而是返回405 Method Not Allowed。前端用了GET去请求一个@PostMapping的接口,看到的是405而不是404。但很多前端框架对非2xx状态码的处理比较粗暴,统一弹一个错误,导致前端同学以为又是404。这种信息差,浪费了不少联调时间。
1.2 五步定位法:从完整URL到Handler映射逐段排查
遇到404别急着改代码,按这个顺序走一遍:
第一步,打开浏览器F12看Network里请求的完整URL。重点看三样:HTTP方法、完整路径、请求的域名端口。用Postman或Apifox单独再发一次同样的请求,如果工具里能通、浏览器里不通,那大概率是跨域、Cookie或代理问题,和SpringBoot本身没关系。
第二步,把URL跟Controller的映射逐段比对。尤其是类级别@RequestMapping加上方法级别的组合映射,两段拼接时容易出问题。一个容易忽略的点是,Spring Boot 2.5之后,特别是Spring Boot 3.x里,路径匹配策略从AntPathMatcher换成了PathPatternParser,如果你项目里用了带正则或通配符的路径,行为可能和以前不完全一样。升级版本后接口404的,优先查这个。
第三步,看后端日志。如果请求根本没进Controller,日志里通常连一条Mapping信息都没有。Spring Boot默认不会打印所有请求日志,需要额外配置AccessLog,嫌麻烦的话可以直接在DispatcherServlet上打断点,看有没有匹配到HandlerExecutionChain。这个断点能看到请求进来后,Spring框架有没有找到对应的handler,是定位404的利器。
第四步,用actuator的mappings端点核对所有已注册的URL。只要引入了spring-boot-starter-actuator,再配上management.endpoints.web.exposure.include=mappings,访问/actuator/mappings就能看到项目里所有Handler映射的完整路径。这个列表是Spring自己维护的,直接看它,比自己翻代码猜路径准确得多。
第五步,检查拦截器和过滤器有没有劫持路径。比如全局拦截器里做登录校验,路径没放行,直接返回了个404响应;还有后端做了URL重写,或者nginx层规则把前端请求转发到了不存在的服务上。这种404严格来说不是SpringBoot的锅,但前后端联调时经常遇到,需要两边一起对nginx配置和网关路由。
1.3 404排查中的三个真实踩坑案例
我踩过几个坑,值得单独说说。第一个是https和http混用导致的404。前端页面是https,接口请求用的是http,浏览器默认会拦截mixed content,但有些浏览器console里的报错不显眼,接口看起来就像404。这种排查起来特别容易绕弯子,后来我养成了习惯:一看接口不通,先看一眼页面协议和接口协议是否一致。
第二个是端口被系统占用,实际监听端口和配置文件对不上。用IDEA启动项目时,如果8080被别的进程占了,SpringBoot启动会失败;但某些IDE配置了自动切换端口,应用启动后监听了8081,前端代码里还写死了8080,那所有请求都是404。这个场景尤其在多人共用一台开发机时高发,建议前端把接口域名端口收敛到一个配置文件里,别写死在代码各处。
第三个是纯粹的前端路径写错。前端说接口404,我查了半天Controller没问题、路径没冲突,最后发现前端请求的是/user/info,而后端接口写的是/user/getInfo,纯属前端代码笔误。所以排查404,一定要先拿完整的请求URL说话,而不是拿一句"接口404"就不停地翻后端代码。
2. 400 报错:参数绑定失败,九成是类型和字段对不上
2.1 类型不匹配、字段名不一致、缺参漏参,逐类拆解
400 Bad Request在SpringBoot里,绝大多数是参数绑定阶段出了问题。第一种最常见的是类型不匹配:接口声明Integer、Long、BigDecimal,前端传过来一个"abc"字符串,Spring做类型转换时抛TypeMismatchException,框架直接回400。这属于前端数据格式不符合后端预期,但Spring默认的错误页很难看,前端只看到"400 Bad Request",具体哪个参数错了完全靠猜。
第二种是@RequestBody绑定的JSON和实体类字段对不上。比如前端传{"username":"张三","age":18},但后端实体类里字段叫name,不叫username。这种情况有两个走向:如果Jackson配置了FAIL_ON_UNKNOWN_PROPERTIES,会直接抛UnrecognizedPropertyException;没配置的话,就是浅层绑定,username被忽略,age正常绑上。看起来没报错,但业务层拿到的username是null,这比400还坑,因为表面上是200,实际数据不对。
第三种是该传的参数没传。@RequestParam(value = "page", required = true) Integer page,前端没带page参数,就会抛MissingServletRequestParameterException,直接400。这个错误提示相对明确,但很多前端同学对HTTP状态码不敏感,以为传个空值就行,结果后端解析时又出幺蛾子。
第四种是body里的字段类型对不上。比如后端是LocalDateTime,前端传的是"2024-01-15",按默认的Jackson配置转换不了,抛HttpMessageNotReadableException,也是400。日期时间格式是联调里高频踩坑点,后面专门说。
2.2 让400错误"开口说话":全局异常处理统一响应
SpringBoot默认的400响应几乎不包含任何调试信息,这就是前后端联调最痛苦的地方。前端只看到"400 Bad Request",后端日志里其实有具体异常,但很多同学不知道去哪里看,或者日志级别没开,关键的绑定异常被淹没了。
我的习惯分三步:第一步,开发环境下把Spring Boot日志里的debug开起来,能看到RequestResponseBodyMethodProcessor和HandlerMethodArgumentResolver这些组件打印的参数绑定过程。第二步,在全局异常处理器里专门捕获MethodArgumentNotValidException、MethodArgumentTypeMismatchException、HttpMessageNotReadableException、MissingServletRequestParameterException,把错误信息包装成统一的JSON返回给前端。前端能立刻看到"参数xxx类型不正确,期望Integer,实际传入abc",联调效率直接提升一个档次。第三步,在Controller方法参数上把注解和required属性写清楚,不要依赖默认值,尤其是分页参数、状态参数这些前端容易漏传的。
全局异常处理这块,我见过不少项目上来就复制网上的代码,捕获了一堆异常,但没区分业务异常和系统异常,导致前端看到的错误信息含糊不清。我建议至少区分三层:参数绑定异常(400)、业务校验异常(可自定义BizException)、系统异常(500)。参数绑定异常要把字段名和错误原因一起返回,业务异常要带业务语义的错误码,系统异常只给一个"系统繁忙"的统一提示,具体堆栈打进日志。
2.3 @Valid校验失败与枚举、日期格式的隐藏坑
还有一个容易被忽略的:@Valid或@Validated参数校验失败,同样返回400。比如@NotBlank(message = "用户名不能为空"),前端没传用户名,Spring在参数校验阶段抛MethodArgumentNotValidException,返回400。这个场景联调中出现频率极高,但前端看到的还是那句"400 Bad Request",如果不做全局异常处理,后端只能一条条翻日志。
两个隐藏坑值得单独说。第一个是枚举转换:接口参数是枚举类型,前端传"1"或"A",Spring默认不支持字符串直接转枚举对象。需要自定义ConverterFactory,或者用@JsonCreator配合@JsonValue注解处理。如果没做转换配置,前端传过来的值没法转换,一样是400。我在实际项目里给前端定的规矩是:枚举统一用code值传输,后端写一个通用的枚举转换工厂,一劳永逸。这踏实的规则能避免双方在枚举字段上反复扯皮。
第二个坑是全局日期格式配置。SpringBoot默认的Jackson时间格式是ISO格式,比如2022-11-20T15:03:22,前端如果习惯传"yyyy-MM-dd HH:mm:ss",几乎必报错。可以这样配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8但要注意,这个date-format只对java.util.Date生效,对Java 8的时间API(LocalDateTime、LocalDate)是不起作用的。LocalDateTime需要另外注册Jackson的JavaTimeModule,自定义LocalDateTimeSerializer和LocalDateTimeDeserializer。这条如果不处理,前后端每次对日期字段都要争论一次,属于那种"不改就一直疼,改了一次以后再也不疼"的配置。
3. 500 报错:服务端异常,看堆栈才能救命
3.1 空指针、SQL1064、依赖注入与事务,五大高频根因
500系列报错,本质是服务端代码运行期抛了异常。我从接触过的项目里统计,高频根因大概有五个:空指针、SQL异常、依赖注入问题、事务问题、外部调用异常。
空指针是当之无愧的第一名。尤其是从数据库查出实体后,没判空直接访问对象的字段或方法。这种问题在异常堆栈里最典型的表现是NullPointerException at com.xxx.service.UserServiceImpl.checkUser。排查方式很直接:看堆栈到第几行,点进去看哪个对象为null,再往前追这个对象是从哪来的。很多同学一看到空指针就心慌,其实按照"谁调用了这个方法、参数有没有可能是null、代码里哪里取了这个值"的思路追一遍,大多数五分钟能定位。
SQL类异常里,MySQL的1064语法错误上榜率极高。这个报错通常带SQL片段和错误位置,看错误信息里"near 'xxx'"基本能定位是表名、字段名写错,还是把保留字当成了字段名。还有一个容易被忽略的场景:MyBatis的动态SQL拼接出了问题。这里必须再三强调#{}和${}的区别:前者是预编译参数占位符,后者是字符串直接拼接。很多人把参数写到${}里,一旦字段值是字符串,单引号拼少了就语法报错,这是1064的经典来源。实际遇到报错时把日志里MyBatis打印的Preparing语句复制到数据库客户端里执行一遍,语法对不对一眼就能看出来。
依赖注入问题也很常见。典型错误是字段报空指针,但本质是Bean没注入进来。比如@Service注解忘了加、@Autowired的类被new出来了、或者一个接口有多个实现类却没用@Qualifier指定。Spring启动时如果配置了懒加载,这些问题可能不会立刻暴露,直到某个请求进来才在运行期炸出来。所以项目里发现一个接口第一次调用就500,优先检查这个接口依赖的Bean是否真的被Spring管理了。
事务问题也值得单独说。@Transactional默认只回滚RuntimeException和Error,检查异常(比如IOException)不会触发回滚。很多人在方法里try-catch之后发现事务没生效,数据写到一半,后续逻辑失败但没有回滚。两种解法:一是把检查异常包一层RuntimeException抛出,二是在@Transactional上声明rollbackFor = Exception.class。我个人更推荐后者,因为团队里不是每个人都清楚回滚机制,主动声明更保险。
3.2 堆栈定位实操流程与Caused by的正确读法
500出现的瞬间,别急着刷新页面或者反复请求。先把后端控制台或日志文件里的堆栈捞出来,这是最直接的路径。
第一,看异常类型。根据异常包名能快速判断层次:org.springframework.dao开头的和数据库相关,org.apache.ibatis开头的是MyBatis解析问题,java.lang.NullPointerException是业务代码问题,java.net.ConnectException是网络调用问题。比如看到MySQLSyntaxErrorException,直接往SQL方向走;看到ClassCastException,往类型转换方向走。
第二,找Caused by。很多异常是有因果链的,最外层的异常往往不具代表性,最里面的Caused by才是真正的根因。比如接口报了500,最外层是org.springframework.transaction.UnexpectedRollbackException,这个看起来和事务有关,但往下一层很可能是一个业务Service里的算术异常导致的。只看最外层就写解决方案,很容易被误导。
第三,定位到业务代码层时,把出错的那行代码和入参结合起来看。重点看参数传的是什么、从哪来的,如果入参本身就是null,那问题可能更早,是调用方的锅。IDEA里可以直接在异常堆栈行点进去,看到具体代码行,这时候用Evaluate Expression看看局部变量的值,往往立刻就能明白哪里出了岔子。
3.3 从500到全局异常处理:输出可读的错误响应
500对应的是代码抛异常,从联调体验来考虑,我建议项目初期就做好两件事。
第一,定义一个全局异常处理类。用@RestControllerAdvice加@ExceptionHandler,把常见异常都捕获,转换成统一响应体返回。一个基础版本长这样:
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public R<Void> handleValidException(MethodArgumentNotValidException e) { String msg = e.getBindingResult().getFieldErrors().stream() .map(f -> f.getField() + " " + f.getDefaultMessage()) .collect(Collectors.joining("; ")); return R.fail(400, msg); } @ExceptionHandler(Exception.class) public R<Void> handleException(Exception e, HttpServletRequest request) { log.error("request {} error", request.getRequestURI(), e); return R.fail(500, "系统繁忙,请稍后重试"); } }切记,不要把完整的堆栈信息直接返回给前端,内部异常细节只进日志,响应体里放人类可读的错误提示即可。
第二,在Service层和Controller层合理打日志。错误级别的日志统一用log.error("操作描述:{}", 关键参数, e)这种形式,保证堆栈完整打印。日志是排500的第一工具,多打一行日志,排查时间能省十分钟。我见过太多项目,一个Service方法几十行,毛都没有一个log,一报错就只能靠猜,这种代码应该被抓去面壁。
4. 前后端联调高频问题:跨域、接口契约与序列化
4.1 CORS跨域配置的几种正确姿势与常见冲突
前后端分离开发时,前端跑在8080,后端跑在9090,前端页面发起请求时如果没做任何跨域配置,浏览器会拦截响应,控制台报CORS error,Network里可能显示"blocked by CORS policy"。但注意,后端其实已经收到请求了,只是响应被浏览器拦了,所以前端看起来像"接口挂了",后端日志里却能看到这个请求的访问记录。这个认知差经常导致前后端互相甩锅。
SpringBoot里解决跨域的方式很多。最简单的是写一个CorsFilter,或者实现WebMvcConfigurer的addCorsMappings方法。但要注意,Spring Boot 2.4前后写法有差异,allowedOrigins("")和allowCredentials(true)不能同时使用,否则Spring会直接拒绝配置。因为allowCredentials本身就是允许携带Cookie,而代表所有来源,两者同时开是有安全矛盾的。要允许所有来源又需要携带凭证,得用allowedOriginPatterns("*")。这个坑我见过好多次,配置了半天发现还是跨域,其实就是这里冲突了。
另外,如果项目前后端之间还有网关或nginx,跨域配置的位置也要想清楚。可以在网关层统一处理,也可以通过nginx配置Access-Control-Allow-Origin响应头解决。遵循就近原则:哪里离浏览器最近,就在哪里处理最合适。别在SpringBoot、网关、nginx三层各配一套,配乱了你都不知道是谁在拦截。
4.2 RESTful接口规范与统一响应体设计
联调报错里,很多不是代码问题,是接口约定问题。前端和后端对"接口应该返回什么"的理解不一致。有的后端喜欢返回{code: 0, data: xxx},有的返回{success: true, result: xxx},前端没有统一封装之前,每个请求的解析逻辑都不一样,联调效率低到令人发指。
我强烈建议项目初期把接口契约定死。响应体统一一个顶层结构,比如{code, message, data, traceId},成功时code=0,失败时code非0。HTTP状态码只用来表示传输层语义:404表示资源不存在,400表示请求参数有问题,500表示服务端错误。具体业务错误码通过响应体的code字段下发,前端只解析code,不根据HTTP状态码猜业务结果。这样设计的意义在于,HTTP状态码的种类有限,根本表达不了复杂的业务异常,而业务错误码可以无限扩展。
还有请求路径的规范。RESTful风格下,资源用名词复数,动作用HTTP方法表达。获取用户列表是GET /users,创建用户是POST /users,删除用户是DELETE /users/{id}。不要出现GET /getUserList、POST /deleteUser这种动词满天飞的写法。新项目一定要坚持这套规范,虽然老项目很难推倒重来,但每个团队都应该有意识地往这个方向收敛,不然接口路径一多就彻底失控,前端光记路径就能记疯。
4.3 Long精度丢失、日期格式与Swagger/Knife4j联调提效
前后端联调中,JSON序列化问题也是高频故障点。第一个是Long类型ID精度丢失。前端JavaScript的Number类型超过2^53后精度会丢失,如果后端直接返回Long类型主键,前端拿到后可能变成另一个数字。我见过真实案例,订单ID的尾部两位直接被抹掉变成0,前端拿着截断后的ID去查询详情,查出来的东西牛头不对马嘴。解决方案是在Jackson配置里把Long类型序列化为String,或者用ToStringSerializer处理ID字段。
第二个是BigDecimal精度问题,尤其是金额字段。默认Jackson序列化BigDecimal会输出原始精度,但如果前后端传参时用了Double接收,很容易丢精度。建议后端用BigDecimal接收,前端传字符串。这个知识点做支付、订单系统的一定要记牢。
第三个是日期格式,前面说过LocalDateTime的坑,这里不重复。在接口文档这块,我建议每个SpringBoot项目接入springdoc-openapi(Spring Boot 3)或springfox(Spring Boot 2老项目),配合Knife4j增强UI。每个接口的路径、入参、响应体结构、参数示例都可视化,前端直接照着文档调,能大幅减少"路径写错""字段名写错"这类低级404/400。
不过文档工具也不是零成本。第一次接入时要注意Knife4j和Spring MVC版本兼容性,否则静态资源路径被Servlet容器拦截,文档页面可能打不开;文档页打开后,如果注解里没写清楚字段说明,前端照样迷茫,所以Controller里该写的@Schema(description = "用户ID")这些元数据一定别省;在网关模式下,Knife4j的basePath设置也容易出错,需要手动指定服务路径前缀。
5. 一套实用的接口报错排查工具链
5.1 日志分级与出入口打点,让报错可追踪
日常接口报错,我强烈建议把日志分级用好。开发和测试环境用DEBUG级别,生产环境至少INFO,错误日志统一用log.error输出。每个接口的入口和出口都打日志:入口打印接收到的参数,出口打印返回结果和耗时。一旦前端反馈接口报错,先看日志里有没有这个URL的入口记录。有,说明请求进到了后端,问题出在业务逻辑;没有,说明请求被前一层拦截了,或者压根没到后端,直接去查网关、nginx和网络。
这个入口出口打点的习惯,能做到"任何一个接口出问题,五分钟内能定位到是哪一层"。对老项目,一时半会儿没法给所有接口补日志的话,优先补涉及金额、状态变更的核心接口,别等出了事故再后悔。
5.2 IDEA断点调试与Apifox/Postman配合技巧
日志能解决八成问题,剩下两成需要断点。SpringBoot接口调试时,很多人喜欢直接在Controller方法上打断点,但这里有个盲区:参数在进入Controller之前可能已经被Spring转换过了,要看原始参数还得往前找。我习惯在Service层和Mapper层都打断点,这样能看到参数是怎么一层层传下来的。用IDEA的Evaluate Expression可以临时计算表达式,快速判断某个对象是否为null,或者实时算一下集合的size。
接口测试工具我推荐Apifox或Postman,配合环境变量管理能模拟各种复杂场景。但有个残酷的事实:工具里测试通过,不代表前端浏览器能通过,因为存在CORS、Cookie、HTTP缓存这些差异。所以联调时两端都要具备"用工具再验证一次"的能力,这能避免很多"我这边明明通了"的吵吵。
5.3 高频报错速查表
整理了一份我日常用得最多的排查表,贴出来供参考:
| 报错特征 | 大概率原因 | 第一步操作 |
|---|---|---|
| Whitelabel Error Page 404 | 路径不匹配/Controller未映射 | 查看actuator/mappings核对映射 |
| 404 但后端日志无请求记录 | 请求没到后端/被网关或nginx拦截 | 检查nginx配置和网关路由 |
| 400 MissingServletRequestParameterException | 必填参数缺失 | 对照接口文档补参数 |
| 400 类型转换失败 | 参数类型不一致 | 核对前端传参类型,调整后端接收类型 |
| 400 HttpMessageNotReadableException | JSON格式错误/字段类型不对 | 检查请求体JSON结构是否符合实体类 |
| 500 NullPointerException | 空指针 | 看堆栈定位null来源 |
| 500 MySQLSyntaxErrorException 1064 | SQL语法错误 | 复制日志SQL到数据库客户端执行验错 |
| 500 NoSuchBeanDefinitionException | Bean未注入/未扫描 | 检查包扫描路径和@Autowired |
| CORS error | 跨域配置缺失/allowCredentials冲突 | 配置CorsFilter或网关统一处理 |
| Long ID精度丢失 | 序列化Long为Number | 配置Long转String序列化 |
这张速查表是我自己整理贴在工位上的版本,实测下来解决了我日常八成的接口报错排查需求。接口报错这件事,本质上是信息差问题:请求方不知道服务端的契约,服务端不知道请求方的格式。把404、400、500这三类问题的排查思路理顺,再配合统一的异常处理、完善的日志打点和一份能对号的速查表,联调真的可以少掉很多头发。后面你要是再遇到新的奇葩报错,也可以往这张表里继续补自己的排查思路,攒成属于你自己的排错手册。