做项目最怕的不是需求复杂,而是报错报得不清不楚。传智健康这个项目,是典型的Java全栈企业级应用,Spring Boot + Spring Cloud + Vue那套技术栈。表面上看起来模块清晰——会员管理、体检预约、套餐管理、报表统计,但真把项目跑起来、联调一遍、再部署上线,糟心事儿一件接一件。我前后在这个项目上踩了几十个坑,有些是代码问题,有些是配置问题,还有些是框架层面互相“打架”导致的问题。这篇就整理一下我在传智健康项目里遇到的典型bug,每个都附上现象、原因、解决方案和排查心得,给正在做同类项目的朋友当个参照。
1. 项目整体背景与bug分布
1.1 核心业务场景
我先简单描述一下传智健康项目的定位,方便后面讲bug时大家能对上号。这是一个面向体检机构的管理系统,主要模块包括:体检套餐管理、会员预约登记、体检报告录入、健康数据分析、系统权限管理。技术栈主体是Spring Boot + MyBatis + Redis + MySQL,前端用的是Vue全家桶,前后端通过RESTful接口通信。权限控制用的是Spring Security + JWT。我负责的部分涵盖了后端所有核心接口的开发,以及和前端联调的工作。
就是在开发和联调的过程中,bug一个接一个地冒出来。有的是我自己的代码逻辑漏洞,有的是需求边界没想清楚,还有的是框架间版本不匹配导致的“隐性问题”。总结下来,bug主要集中在数据库操作、接口返回、权限校验、文件上传、定时任务这几个方向。
1.2 Bug集中出现的模块分布
从统计上看,传智健康项目里bug最密集的不是业务代码本身,而是几个容易被低估的中间环节。排名第一的是日期时间处理,占了差不多两成;其次是权限系统和文件上传,各占一成半左右;剩下的分散在参数校验、异常处理、环境配置、前端联调这些方面。
这个比例其实很有代表性。只要项目涉及前后端分离,时间格式化、跨域配置、Token校验、上传文件这些“通用模块”永远是重灾区。原因很简单——这些模块跨越了技术边界,前端一套规则,后端一套规则,数据库又一套规则,三方对齐极其容易出问题。下面我就按这个思路,把实战中遇到的坑逐一拆开讲。
2. 数据库与MyBatis层的经典坑
2.1 时间字段映射的时区问题
传智健康项目里有一个体检预约功能,数据库里存的是预约时间,字段类型是datetime。后端实体类用的是java.util.Date。刚开始一切正常,直到某天前端反馈:页面上看到的预约时间比实际时间早了8个小时。
我第一反应是前端格式化的问题,结果查了接口返回值,发现后端返回的JSON时间本身就少了8小时。根因在后端和数据库之间的时区不一致。MySQL连接的url里没有配置serverTimezone,默认使用数据库服务器的时区,而本机MySQL默认时区可能是UTC,前端浏览器又是东八区,三端各执一词,时间自然就对不上。
解决方案很直接:在JDBC连接串里显式指定时区。
url: jdbc:mysql://localhost:3306/health?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai改完之后无论是数据库存、后端取、前端显示,时间都统一在东八区。这里有个额外提醒:如果是部署到云服务器,服务器的系统时区也容易是UTC,最好在启动参数里也加一下时区配置,双保险。
2.2 模糊查询引发的SQL注入风险
体检套餐管理模块有个搜索功能,前端传一个关键字,后端根据套餐名称做模糊查询。我最初写的SQL是这样的:
<select id="searchByName" resultType="Setmeal"> SELECT * FROM t_setmeal WHERE name LIKE '%${keyword}%' </select>用了${}拼接,当时图省事,想着就是模糊查询嘛,拼接一下很快。后来做安全测试的时候发现,关键字里如果传入特殊SQL片段,比如sleep(3)之类的东西,接口响应明显变慢。这就是典型的SQL注入漏洞。在真实项目里这属于高危问题,轻则数据泄露,重则整个库被拖走。
改成MyBatis的#{}后就安全了:
<select id="searchByName" resultType="Setmeal"> SELECT * FROM t_setmeal WHERE name LIKE CONCAT('%', #{keyword}, '%') </select>这里有个细节:直接用LIKE '%#{keyword}%'会报错,因为#{}占位符不能出现在字符串字面量内部。需要借助CONCAT函数拼接。同样的道理适用于所有动态查询场景,能用#{}绝不用${},除非是表名、排序字段这种无法预占位的场景,而且即便是表名排序字段,也要做白名单校验。
2.3 一对多映射导致的分页总数错乱
套餐详情页面需要展示套餐包含的检查项列表,一个套餐对应多个检查项。我用了MyBatis的collection嵌套查询来实现。但组合分页查询时出现了一个诡异的问题:列表只有5条数据,每页10条,正常应该只有1页,结果分页插件查出来的总是只有1页没错,但总数不对,而且前面几页偶尔丢数据。
排查后发现了根因。嵌套查询用的方式是SELECT * FROM t_setmeal LEFT JOIN t_checkitem ...这种方式,一对多关联后会生成重复的套餐主记录。分页插件在统计总数时,是以关联后的结果集去count的,不是按主表去重后的count。如果套餐A有4个检查项,结果集里就有4条套餐A的记录,分页自然就乱了。
解决办法是拆开查询:先分页查询套餐主表,再根据套餐ID集合批量查询检查项,然后在内存中组装。这也是实际企业项目里更推荐的做法。
// 第一步:分页查套餐 IPage<Setmeal> page = setmealMapper.selectPage(page, queryWrapper); // 第二步:批量查检查项并组装 List<Setmeal> records = page.getRecords(); List<Integer> ids = records.stream().map(Setmeal::getId).collect(Collectors.toList()); List<CheckItem> checkItems = checkItemMapper.selectBySetmealIds(ids); Map<Integer, List<CheckItem>> groupMap = checkItems.stream() .collect(Collectors.groupingBy(CheckItem::getSetmealId)); records.forEach(item -> item.setCheckItems(groupMap.getOrDefault(item.getId(), new ArrayList<>())));这样既解决了分页错乱,也避免了N+1查询的性能问题。很多新手在这里会纠结,觉得MyBatis的嵌套查询方便,但方便背后埋着分页和性能的双重隐患,实际开发中建议用“分页主表+组装子表”的模式。
2.4 MySQL保留字做字段名
传智健康项目的会员表有一列叫order,用来记录会员的预约顺序。这个字段在创建表的时候没问题,但所有用到这个字段的SQL都报语法错误。
Cause: java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'order DESC' at line 1ORDER是MySQL的保留字,用于排序。实体类映射没问题,但任何显式引用ORDER的SQL都会报错。解决方法是给字段名加反引号,或者干脆改字段名。我的建议是直接改列名,比如改成order_num,不要依赖反引号,因为以后你写原生SQL、接第三方工具,难免有忘加反引号的时候,这就是个雷。这类问题还容易出现在desc、group、rank这些词上,建表前一定要养成核对保留字的习惯。
3. 接口设计与业务逻辑的隐患
3.1 前后端分离下的跨域配置失效
这个项目前端是Vue运行在8080端口,后端Spring Boot运行在8081端口,开发阶段天然存在跨域问题。我最初在后端做了统一的CORS配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:8080") .allowedMethods("*") .allowedHeaders("*"); } }但配置上线后仍然报跨域错误。排查发现,问题不是配置本身,而是Spring Security的过滤器链执行顺序。安全过滤器先于CORS过滤器执行,导致在进入CORS处理之前,请求已经被Security拦截,OPTIONS预检请求根本没走到CORS配置就被拦下了。
解决方案是让CORS配置提前到Security过滤器链之前:
http.cors().and().csrf().disable()...在Security的配置里显式开启cors支持。实际项目里这种问题非常隐蔽,报错信息是浏览器的跨域提示,但根子却在过滤器链顺序上。排查跨域问题的时候,建议先用Postman模拟OPTIONS请求,如果后端返回了CORS头,基本可以排除后端问题;如果没返回,再逐层扒过滤器链。
3.2 登录状态校验不严谨导致的安全漏洞
传智健康项目的后台管理接口最初只是登录时发一个JWT,前端存在本地存储里,请求时放在Authorization头里,后端用一个拦截器校验Token是否存在。本来以为这样就行了,直到测试时发现一个漏洞:已经退出的用户,依然可以用旧的Token访问需要权限的接口。
原因很简单——后端只校验了Token的签名和过期时间,没有校验Token是否在服务端“失效名单”里。Token一旦签发就无法撤销,除非等到它自然过期。如果用户登出后Token被攻击者获取,依然可以继续使用。
解决方案是引入Redis做Token状态管理。登录时把Token的jti(唯一标识)存入Redis并设置过期时间,登出时删除这个key;拦截器校验时除了检查签名,还要检查Redis里是否存在这个Token的jti。改完之后,Token的作废就是实时的。
这里有个细节值得注意:Redis的key一定要设置和Token一致的过期时间。我之前忘了设置,导致Redis里堆积了大量永不失效的Token key,内存持续增长。排查了半天发现是过期时间缺失,加上之后内存曲线才恢复正常。
3.3 事务注解失效的几种姿势
传智健康项目里有一步操作:新增体检预约时,同时要插入预约主记录、插入预约详情、扣减套餐库存。三步必须是一个事务。我当时的代码长这样:
@Service public class OrderService { @Autowired private OrderDetailService orderDetailService; @Transactional public void createOrder(OrderDTO dto) throws Exception { orderMapper.insert(dto.getOrder()); orderDetailService.insertBatch(dto.getDetails()); setmealMapper.decreaseStock(dto.getSetmealId()); } }自测的时候一切正常,但后来发现库存扣减居然偶尔成功、偶尔失败,而且失败时订单详情也没回滚。查日志发现,insertBatch内部抛了异常,但外层事务没有感知到。
根因在于insertBatch用了try...catch把异常吞掉了。Spring的声明式事务默认只在遇到RuntimeException时回滚,而且一旦异常被捕获,事务管理器根本感知不到异常发生,自然不会回滚。如果方法签名上还声明了throw Exception,但调用方不往外抛,事务照样失效。
解决方法是去掉内层吞异常的逻辑,让异常向上抛,并且在@Transactional上明确rollbackFor:
@Transactional(rollbackFor = Exception.class) public void createOrder(OrderDTO dto) throws Exception { orderMapper.insert(dto.getOrder()); orderDetailService.insertBatch(dto.getDetails()); setmealMapper.decreaseStock(dto.getSetmealId()); }顺带说一句,@Transactional还有一个常见失效场景:同一个类内部方法自调用。Spring的声明式事务基于AOP代理实现,自调用走的是this.method(),绕过了代理对象,注解自然不生效。这时候要么把方法放到不同Service里,要么注入自身代理对象。
3.4 文件上传大小限制与内存溢出
套餐配置里需要上传套餐图片,前端用的是Element UI的上传组件,后端接收MultipartFile。开发时传小图没事,一传稍大一点的图就报错:
org.apache.tomcat.util.http.fileupload.FileUploadBase$SizeLimitExceededException: the request was rejected because its size exceeds the configured maximum这个是Spring Boot默认限制了上传大小,单文件默认1MB,请求总量默认10MB。在配置中心或配置文件里放开即可:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB但放开之后,又遇到一个新问题:上传大图时后台频繁Full GC,甚至直接OOM。原因是默认情况下MultipartFile会先把文件内容写到内存,文件大了内存就扛不住。Spring Boot提供了file-size-threshold参数,超过这个阈值就转存到临时目录。
spring: servlet: multipart: file-size-threshold: 2MB要到2MB才落盘,低于2MB的文件直接驻留内存。调整后内存问题明显缓解。这里提醒一句:临时目录如果是系统的/tmp目录,要注意定时清理的机制,避免磁盘被占满。生产环境最好配置一个独立的临时文件目录。
4. 前端联调过程的心累时刻
4.1 表格时间显示NaN和日期偏移
前后端联调传智健康项目时,会员列表页要展示出生日期。后端返回的是时间戳格式1591234567890,前端用moment.js格式化。但页面上有几条数据显示NaN-NaN-NaN,还有几条日期比实际日期少一天。
NaN的问题,是后端返回的某个字段值是null,前端直接调用moment(null).format(),就得到了NaN。这个问题处理起来很简单,前端做一层空值判断就行。但日期少一天的问题更隐蔽,后端返回的日期转成时间戳时丢失了时区信息,前端格式化时默认用浏览器时区解析,结果就错了一天。解决方案是后端统一返回格式化好的日期字符串,或者前端统一指定UTC解析。
传智健康这个项目里的经验是:前后端联调时,时间字段必须提前约定好格式,谁也不要自作主张。我们最后统一成了yyyy-MM-dd HH:mm:ss字符串,简单直接,避免了两端的时区再次“扯皮”。
4.2 请求参数名对不上导致接口400或参数丢失
这个bug极其折腾人。前端调用的登录接口,传的参数是{ "userName": "admin", "password": "123456" },后端实体类字段叫username,小写n。后端接收到的username始终为null,前端报登录失败。排查了半天才发现,是前端少写了一个大写N的映射。这类问题在接口联调中是最耗时的,因为前端、后端谁看自己的代码都觉得没错。
建议的做法是:
- 前后端统一维护一份接口文档,字段命名严格遵守,修改任何一方都要同步更新。
- 后端不要用Map直接收货参数,用DTO显式定义字段,IDE还能帮你检查拼写。
- 前端统一封装请求工具,字段转换问题集中在工具层处理。
这一类问题看着low,但确实占了联调时间的很大比例。传智健康项目上线前,我们花了整整一天把全项目接口的参数名逐一核对,解决了不下十个潜在问题。
4.3 权限控制的隐藏漏洞
权限模块的坑比较隐蔽。这套系统有三种角色:超级管理员、体检中心管理员、普通操作员。菜单是根据角色动态加载的,前端登录后拿到菜单列表并渲染。但后来我发现,前端可以通过直接调用接口来访问无权操作的数据,因为后端接口没有做细粒度的权限校验。
传智健康项目的权限控制是前端界面上隐藏了按钮,但后端接口本身没有做对应的判权。比如普通操作员理论上不能删除套餐,前端确实不显示删除按钮,但抓包后直接调用删除接口,依然能删除成功。
修复方案是使用Spring Security的@PreAuthorize注解做方法级权限控制:
@PreAuthorize("hasAuthority('setmeal:delete')") @DeleteMapping("/{id}") public Result delete(@PathVariable Integer id) { setmealService.deleteById(id); return Result.success(); }前端控制的是“看不看得见”,后端控制的才是“能不能操作”,安全问题永远不能依赖前端。这个观念我在这个项目里反复给团队成员强调,也是实际开发中最重要的安全意识。
5. 部署与环境配置的实战血泪
5.1 本地正常、服务器报错的环境差异问题
这是传智健康项目上线前最惊险的一幕。本地环境一切正常,部署到服务器之后,启动就报Redis连接失败。排查了很久发现,不是Redis没启动,也不是密码错误,而是服务器上的Redis配置文件默认绑定了127.0.0.1,只允许本机连接。Spring Boot应用服务在另一台机器上,连不上。
解决方法是修改Redis配置,把bind设置为0.0.0.0,同时设置requirepass密码,并配置防火墙只放行指定来源IP。这个例子充分说明:框架本身没问题时,环境差异就是最大的坑。
这类问题排查时一定要先确认环境差异清单:JDK版本、MySQL版本、Redis配置、系统时区、字符集、文件权限。先列出差异再逐项比对,比盲猜快得多。我在实际操作中还发现,Linux服务器的默认文件描述符限制、CentOS的SELinux策略这些,也可能导致各种奇怪连接问题。
5.2 端口被占用与内存溢出
部署传智健康项目时遇到过一次端口被占用导致应用启动失败。当时是8081端口被一个残留的Java进程占着,kill掉之后又遇到一个更隐蔽的问题——应用启动后运行一两天,内存就一路飙高,最终OOM。
分析堆转储文件后,定位到是文件上传功能的临时文件没有清理。Spring Boot上传文件时创建的临时文件,虽然会在请求结束后标记为待删除,但如果文件流没有被正确关闭,临时文件句柄泄漏,底层的内存映射区域无法释放。修复方式也很简单,确保在finally块里关闭InputStream。
另外,JVM参数也要做合理配置,不要动不动就-Xmx2g,要根据实际业务量设置。这个项目生产环境的JVM参数我调整后如下:
java -jar health-server.jar --spring.profiles.active=prod \ -Xms512m -Xmx1024m -XX:MaxMetaspaceSize=256m \ -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/logs/health/额外加上了HeapDumpOnOutOfMemoryError参数,之后任何内存问题都可以通过堆转储文件定位,省了不少排查时间。
5.3 Linux下定时任务未执行
体检报告每天早上需要生成前一天的统计汇总,用的是Spring自带的@Scheduled注解。本地测试没有问题,部署到服务器后定时任务没有执行。
排查发现,服务器系统时间比真实时间晚了8小时,定时任务按服务器本地时间触发,自然对不上。解决方法是统一服务器时间,安装NTP服务同步时间。这也是一个典型的“环境配置”类bug,代码层面完全没有问题。
此外,Spring Boot定时任务默认是单线程串行执行的,如果某个任务执行时间过长,会阻塞后面的任务。我后来在配置类里加上了线程池:
@Configuration @EnableScheduling public class ScheduleConfig implements SchedulingConfigurer { @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(5)); } }这样每个定时任务都有独立的线程,互不阻塞。
6. 常见问题速查表与排查思路
6.1 典型问题汇总表
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 页面时间比实际少8小时 | JDBC连接未指定serverTimezone | 连接串加serverTimezone=Asia/Shanghai |
| 模糊查询响应缓慢甚至报错 | 使用了${}拼接SQL | 改为#{}结合CONCAT |
| 分页数据总数错误 | 一对多嵌套查询导致count重复 | 分页主表,内存组装子表 |
| 上传大图后OOM | 默认文件写入内存 | 配置file-size-threshold |
| 登录后Token无法作废 | 服务端未记录Token状态 | 引入Redis存储Token的jti |
| 事务不回滚 | 异常被捕获吞掉或rollbackFor缺失 | 去掉吞异常逻辑,指定rollbackFor |
| 定时任务不执行 | 服务器时间不同步/任务互相阻塞 | NTP同步时间,配置任务线程池 |
| 跨域配置不生效 | 过滤器链顺序问题 | Security里启用cors |
6.2 排查问题的心法总结
Bug排查方法论比记住具体问题更重要。我在传智健康项目后期建立了一套固定的排查流程:先看日志,再比对参数,最后用排除法缩小范围。
第一层:看日志。不要只看报错那一行,往前看几十行,异常堆栈的Caused by往往藏着真正的根因。
第二层:看数据。传入的参数到底是什么?数据库里的值是什么?两相对比,大部分问题都能定位到是数据层面还是逻辑层面的错。
第三层:看环境。同样的代码,本地正常服务器不正常,优先比对环境差异清单。
另外有一个小技巧:遇到难缠问题时,把代码回退到上一次正常工作的版本,然后逐步对比差异,往往比在错误代码里折腾更快。Git的价值在这种时候才真正体现出来。
6.3 传智健康项目给我的几点启示
做完这个项目,我最大的感悟是:bug本身不可怕,可怕的是不知道怎么高效地定位bug。在我列出的所有问题里,真正复杂的业务逻辑bug反而很少,绝大多数都是常见的连锁反应——时区不一致、过滤器顺序不对、事务吞异常、环境差异、路径写死。这些知识点并不新鲜,但每一项都容易在忙碌中疏忽。
如果让我给正在做类似项目的同学一个建议,那就是尽早把项目的基础设施打好。统一时间处理方案、统一接口返回值结构、统一异常处理机制、统一参数校验规范、统一安全校验方案,这些基础工作做得越早,后面的bug数量呈指数级下降。传智健康项目的后期,我陆续把这些基础框架补齐之后,开发效率明显提升,新增bug的数量也大幅减少。
另外还有一件事值得做:把踩过的每个bug都记录成文档,包括现象、原因、修复方案、排查过程。我用了一个简单的表格来管理这些内容,后续开发中一旦再遇到类似问题,直接搜索文档就能快速定位,效率提升非常明显。强烈建议你也养成这个习惯。
7. 最后的实战心得
写了这么多,最后再分享一个我在这类项目中反复用到的小技巧:当你的接口出现莫名其妙的bug时,先用最原始的方式定位问题。比如怀疑某个查询有问题,就直接在数据库客户端执行一下这条SQL看结果;怀疑某个参数没传对,就直接在Controller的第一行打印一下入参。虽然原始,但往往能最快锁定方向。很多bug之所以越查越乱,就是因为排查者过早地往框架原理上想,反而忽略了最基础的数据和参数问题。
日志分段、逐个模块验证、保证每次只改一个变量,这三条是我在传智健康项目里沉淀下来的铁律。掌握这些,比你记住一百个具体bug更有用。