这套springboot汽车资讯网站系统是我这几年接触过最典型的Spring Boot业务型项目之一也是初级开发绕不开的一道综合应用题它把用户体系、内容发布、评论互动、搜索分页、文件上传、权限控制这些日常开发的高频技能点全部串在了一起但又没有复杂到让人无从下手。我去年接手过一个类似的资讯平台代码量不算夸张但把整套链路理顺之后几乎覆盖了一个初级到中级Java开发者80%的日常基本功。这篇文章就把我当时的设计思路、表结构、核心代码片段和踩过的坑原原本本写出来适合正在准备毕业设计、面试项目或者刚入职想快速上手Web业务开发的读者直接参考。表面上这就是一个汽车资讯垂直领域的CRUD系统但一旦把资讯、车型频道、标签、评论、收藏、个人中心、后台审核这些模块串起来你就会发现它其实是用最小的成本在模拟一个内容型产品的完整闭环。而恰恰是这种麻雀虽小五脏俱全的项目最能锻炼一个人对业务的理解、对数据模型的设计能力以及对细节的把控。下面我从整体设计开始逐步拆解每个环节。1. 项目定位与整体架构设计1.1 汽车资讯网站的核心需求拆解在做任何系统之前先把需求摆到桌面上。当时我们跟业务方反复确认后把整个网站分为前台、用户端、后台三块前台面向游客和普通用户核心是资讯的浏览与检索。包括首页聚合资讯流、按车型频道和标签筛选资讯、资讯详情页、评论展示。用户端就是在前台基础之上增加登录注册、收藏资讯、点赞评论、个人中心查看收藏记录和互动历史。后台则是运营人员使用的管理端主要承担资讯内容的编辑与审核、车型与频道管理、标签维护、用户状态管理、评论的审核和删撤。需求拆完你会发现所有功能最终都能归到内容、用户、互动这三个维度上。内容维度是资讯、车型、标签和频道用户维度是注册登录、个人信息、权限等级互动维度是评论、收藏、点赞和浏览记录。这三个维度互相交织构成了系统的核心业务闭环。汽车资讯和普通博客系统有一个明显区别资讯本身带有强结构化的属性比如涉及某款车型时可能需要关联车型名称、上市时间、售价区间、动力类型、续航里程这些参数资讯内容时效性很强需要按发布时间和热度综合排序配图多、富文本富媒体多对上传和访问链路有要求。如果拿到需求直接开写多半会在半路返工所以先花一两天把模型想清楚比节省这一两天开发时间更重要。1.2 单体架构还是微服务我的选择逻辑做这个系统的时候有同事提了一句要不要上微服务以后好扩展。我直接否了。原因很简单单体架构完全撑得住这个业务规模而微服务带来的运维成本、服务治理成本、分布式事务复杂度对一个日PV不过几万的内容型网站来说纯属负担。我说几个判断依据。第一看业务耦合度汽车资讯系统里用户、资讯、评论、收藏虽然功能不同但数据关联紧密比如查资讯详情需要同时拿作者信息、评论数、标签列表单体架构下一次Service调用就能搞定拆成服务反而要三次远程调用。第二看并发量资讯类网站写入低频、读取高频通过Redis缓存热点数据后单体能扛的QPS远高于实际请求量。第三看团队和部署能力小团队维护一个jar包只需要一台服务器而微服务至少要三台起步还要搭网关、注册中心、链路追踪出了问题排查链路长到崩溃。当然单体不代表代码坨在一起。我在设计时特意按模块划分包结构每个业务域独立成package未来如果必须拆分可以按包边界原地进化成模块化单体再逐步演进到微服务。这个能单体不微服务要单体先模块化的思路才是这类业务型项目最稳妥的选择。2. 技术选型与项目搭建2.1 Spring Boot版本与关键依赖的取舍Spring Boot版本选型上我最终选了2.7.x而不是最新的3.x。原因很现实当时项目要求JDK8环境而Spring Boot 3.x强制要求JDK17直接升级JDK对部署环境和团队习惯都是个不小的改动。另一方面国内大量生产环境、教程资料、招聘要求目前还是围绕JDK8和Spring Boot 2.x展开遇到问题能搜到的解决方案更多学习成本和坑位成本都更低。如果你是新起项目且运维环境已经支持JDK17用3.x也没问题但核心逻辑基本一致。ORM层面我用了MyBatis-Plus而不是Spring Data JPA。两个框架都能做持久化但风格完全不同。JPA的强项是实体映射和自动建表适合领域模型驱动的场景而MyBatis-Plus的优势是单表CRUD零SQL、内置分页插件、代码生成器非常适合这种业务表结构清晰、以查询和展示为主的项目。后来在实际开发中MyBatis-Plus的LambdaQueryWrapper写起来确实爽条件构造不需要拼字符串SQL编译期就能发现字段名拼写错误比JPA的JPQL更直观。认证方案用了JWT没有用Session。理由也很简单前后端分离之后Session跨域要处理Cookie、CSRF这些问题而JWT无状态、天然适合接口鉴权。缺点是token吊销麻烦但对于汽车资讯这种普通用户级应用短有效期加上前端登录拦截已经足够安全。缓存用了Redis不做二级缓存只缓存热点数据和计数类数据。构建工具用了MavenJava版本8引入Lombok减少样板代码用Hutool处理日期、文件上传这些工具类操作。前端因为要快速上线选择了Thymeleaf配合Bootstrap做服务端渲染局部刷新用Ajax这个选择我在后面专门聊。核心pom依赖大致长这样parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- MyBatis-Plus 适配 Spring Boot2 的版本3.5.x即可 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- JWT认证 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency !-- 省略 lombok/hutool 等 -- /dependencies这里特别提醒一个问题Spring Boot 2.7.18是2.x最后一个版本安全维护期也足够覆盖常规项目生命周期但如果你的依赖里有和Boot版本强绑定的组件比如Spring Cloud、Spring Security务必确认版本兼容性最好直接从Spring Initializr生成工程再手动加核心库避免依赖冲突从头炸到尾。2.2 分层架构与目录规范分层方面我保持了经典的三层架构Controller负责接收参数和返回结果Service处理业务逻辑Mapper负责数据访问。但在这个基础上我加了两层封装Entity对应数据库表结构VO负责前端展示字段DTO负责接口传输数据。为什么不直接用Entity返回给前端举个实际例子用户表里存了密码字段如果不加隔离直接返回给前端等于把密码哈希暴露给任何人。另外资讯表里有一个content大字段存正文列表接口根本不需要返回完整正文只需要标题、摘要、封面图、发布时间这时候专门定义一个NewsVO只包含必要字段既安全又省流量。这种表结构、接口结构、页面结构三者解耦的做法是我在项目初期就定下的规矩。统一返回结构我写了一个Result类泛型指定数据类型包含code、message、data三个字段。所有Controller返回值都用它包裹前端只需要判断code就能确定请求是否成功。配套写了全局异常处理器用RestControllerAdvice捕获业务异常和未知异常转换成统一的错误响应。这一套下来前端联调时不用再纠结到底返回的是什么结构。目录规范上我按业务域而不是按技术层分包com.example.autonews ├── controller # 接口层按业务模块分包 │ ├── admin # 后台管理接口 │ └── web # 前台接口 ├── service # 业务逻辑 ├── mapper # MyBatis-Plus数据访问 ├── entity # 数据库实体 ├── dto # 请求/传输对象 ├── vo # 视图对象 ├── config # 配置类 ├── common # 通用类Result、异常、常量、工具类 └── interceptor # 拦截器这个分包思路有一个好处新同学接手代码时从controller入口按图索骥一小时内就能找到资讯列表从哪个接口进来、经过哪个Service、查了哪张表。我把项目整体架构确定下来后才开始建表写代码这一步顺序不能反。3. 数据库设计与核心数据模型3.1 核心表结构规划数据库设计是整个项目我最看重的一环因为如果表设计有问题后面查询性能、扩展性、代码复杂度都会跟着糟糕。我最初规划了10张表后来精简合并到8张核心表如下表名用途关键字段user用户表id、username、password、nickname、avatar、role、statuscar_series车型系列表id、name、brand、price_range、energy_type、statuscategory资讯分类/频道表id、name、sort、statusnews_info资讯主表id、title、summary、cover_image、series_id、category_id、author_id、content、status、publish_time、view_counttag标签表id、namenews_tag_relation资讯标签关联表id、news_id、tag_idcomment评论表id、news_id、user_id、parent_id、content、like_count、statususer_favorite收藏表id、user_id、news_id、create_time这里重点说说资讯主表的设计。最初我按一个新闻一张表全字段搞定的思路把content大字段直接放主表后面发现列表查询性能很差。因为MySQL的InnoDB存储引擎默认使用聚簇索引行数据存在主键索引的叶子节点上如果一行数据里包含几十KB的content大字段即使查询只select几个列表字段InnoDB也需要从磁盘读取整行数据。后来我把资讯表拆成news_info主表和news_content详情表主表只放标题、摘要、封面、统计字段等轻量数据详情表存放完整正文通过news_id一对一关联。这样列表查询和详情查询互不干扰性能提升非常明显。为了让你直观参考我把用户表和资讯表的关键DDL列出来CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, username varchar(50) NOT NULL COMMENT 登录用户名, password varchar(100) NOT NULL COMMENT 加密后的密码, nickname varchar(50) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像URL, role tinyint(4) NOT NULL DEFAULT 1 COMMENT 角色0-管理员1-普通用户, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态1-正常0-禁用, create_time datetime NOT NULL COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;CREATE TABLE news_info ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 资讯主键, title varchar(200) NOT NULL COMMENT 标题, summary varchar(500) DEFAULT NULL COMMENT 摘要, cover_image varchar(255) DEFAULT NULL COMMENT 封面图URL, category_id bigint(20) DEFAULT NULL COMMENT 分类ID, series_id bigint(20) DEFAULT NULL COMMENT 关联车型系列ID, author_id bigint(20) NOT NULL COMMENT 发布人ID, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0-草稿1-待审核2-已发布3-下架, publish_time datetime DEFAULT NULL COMMENT 发布时间, view_count int(11) NOT NULL DEFAULT 0 COMMENT 浏览量, create_time datetime NOT NULL COMMENT 创建时间, update_time datetime DEFAULT NULL COMMENT 更新时间, PRIMARY KEY (id), KEY idx_category_publish (category_id, status, publish_time), KEY idx_series_publish (series_id, status, publish_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT资讯主表;用户表中我对password字段存的一律是BCrypt加密后的哈希值绝不存明文。角色字段用tinyint而不是字符串省空间而且查询更快。status字段用来做逻辑删除和上下架控制不直接物理删除数据方便后期运维追溯。3.2 索引设计与查询取舍数据库设计里表结构决定数据怎么存索引决定数据怎么取。我在建索引时遵循了一个原则先列出所有高频查询语句再针对where条件、排序字段、关联字段建立索引而不是一股脑给所有字段加索引。汽车资讯站的高频查询其实很明确首页按发布时间拉取指定分类下的已发布资讯列表分页查询资讯详情按主键查询评论按资讯ID拉取用户收藏按用户ID拉取。针对这些场景我建立了几个关键索引news_info表的idx_category_publish组合索引覆盖分类 状态 时间的查询路径user_favorite表建立(user_id, news_id)唯一索引既保证同一用户不会重复收藏同一条资讯又能快速查出用户的收藏列表评论表在news_id上建普通索引因为所有评论查询都是先定位资讯再拉列表。组合索引的字段顺序很有讲究。比如idx_category_publish(category_id, status, publish_time)MySQL在最左前缀原则下只有查询条件中包含category_id时才能走这个索引。如果你经常按status过滤所有分类的资讯这个索引就不适用需要单独建status索引。我在实际调试中遇到过索引建了但没生效的情况用EXPLAIN一看发现是where条件里对索引列做了函数运算或者隐式类型转换导致索引失效。所以写完SQL一定要养成explain的习惯尤其在看type字段是否从ALL全表扫描变成了ref或range的范围查询。4. 核心业务功能的实现细节4.1 资讯发布与后台管理的状态机后台管理端最核心的流程就是资讯发布。我设计了一套状态流转草稿到待审核待审核到已发布已发布到下架下架后可以再次编辑转为待审核。这个状态机用数据库里的status字段表达代码里用常量类统一定义。新增资讯的Service方法一定要加事务注解因为一次发布涉及两个动作往news_info主表插入一条记录往news_content详情表插入完整的正文内容同时还要往news_tag_relation表批量插入标签关联数据。任何一步失败前面的插入都要回滚不然就会出现资讯主表有记录但详情查不到的情况。核心代码大致是这样的Transactional(rollbackFor Exception.class) public Long publishNews(NewsPublishRequest request) { // 1. 插入主表 NewsInfo newsInfo new NewsInfo(); newsInfo.setTitle(request.getTitle()); newsInfo.setSummary(request.getSummary()); newsInfo.setCoverImage(request.getCoverImage()); newsInfo.setCategoryId(request.getCategoryId()); newsInfo.setSeriesId(request.getSeriesId()); newsInfo.setAuthorId(CurrentUserHolder.getUserId()); newsInfo.setStatus(NewsStatus.PENDING); newsInfo.setCreateTime(new Date()); newsInfoMapper.insert(newsInfo); // 2. 插入详情表 NewsContent newsContent new NewsContent(); newsContent.setNewsId(newsInfo.getId()); newsContent.setContent(request.getContent()); newsContentMapper.insert(newsContent); // 3. 批量插入标签关联 if (CollectionUtils.isNotEmpty(request.getTagIds())) { for (Long tagId : request.getTagIds()) { newsTagRelationMapper.insert( new NewsTagRelation(newsInfo.getId(), tagId)); } } return newsInfo.getId(); }这里有个细节值得展开为什么要单独建news_tag_relation而不是在news_info里加一个tagIds字段用逗号分隔因为逗号分隔的字段没法走索引查询某标签下所有资讯时只能用like去匹配数据一多必炸而且关联表在统计标签热度、做推荐系统时都是标准的多对多建模方式。规范的表结构后期扩展才能不伤筋动骨。图片上传也是资讯发布的高频操作。我一开始把图片存到本地磁盘的某个绝对路径下比如/opt/autonews/upload/结果前端访问图片时发现404。排查下来发现Spring Boot默认不会把磁盘任意路径映射成静态资源URL需要实现WebMvcConfigurer重写addResourceHandlers方法Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(fileUploadPath /); }这个坑如果提前知道能省两小时的排查时间。另外一点生产环境千万不要把图片路径写死成C:/xxx或者/user/xxx这种本机路径最好通过配置文件统一管理配合Nginx或者对象存储服务做静态资源分发后面我在性能优化部分会再提。4.2 用户认证与权限控制用户模块最核心的两个能力是注册登录和权限校验。注册时用BCrypt加密密码加密后存入数据库。登录成功后签发JWT返回给前端前端在请求头里带上Authorization: Bearer token后端通过拦截器统一解析校验。JWT的生成和解析我封装在JwtUtil工具类里关键配置包括token有效期和密文密钥。有效期我设了2小时这个时间需要根据产品场景调整太短用户反复登录体验差太长有安全风险。对于资讯类网站2到4小时比较合理同时配合前端在请求返回401时自动跳转登录页。拦截器里做两层校验第一层解析token是否存在、是否有效第二层根据请求路径判断是否需要管理员权限。admin/**下的接口由AdminInterceptor校验用户角色必须是管理员否则直接返回403。这样业务Controller里就不需要写一堆权限判断代码各司其职。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录注册接口 String uri request.getRequestURI(); if (uri.startsWith(/api/auth/)) { return true; } // 解析token String token request.getHeader(Authorization); if (StringUtils.isBlank(token) || !token.startsWith(Bearer )) { throw new BizException(ErrorCode.UNAUTHORIZED); } Long userId jwtUtil.parseUserId(token.substring(7)); if (userId null) { throw new BizException(ErrorCode.UNAUTHORIZED); } // 将当前用户信息放入ThreadLocal上下文 CurrentUserHolder.setUserId(userId); return true; } }这里还想讲一下ThreadLocal的使用。每个请求都是一个线程拦截器里把当前登录用户ID放到ThreadLocal中业务代码里通过CurrentUserHolder.getUserId()随时取到当前用户请求结束在afterCompletion里必须调用remove()清理。很多初学者会忘记清理ThreadLocal在Tomcat线程池复用场景下线程会一直残留上一次请求的用户信息严重的会导致越权访问。这是我见过最隐蔽也最危险的bug之一。4.3 评论与互动链路评论模块我采用了父子层级设计不是无限嵌套而是支持一级回复。parent_id为0表示一级评论非0表示对某条评论的回复。这种设计既满足展示需求又不会让递归查询太复杂。查列表时先查某个资讯下所有一级评论再把每条一级评论下的回复批量查出来在Service层组装成树形结构返回。组装树的代码最忌讳N1查询先查出一级评论循环每条评论再查一次回复如果资讯有100条一级评论就有100次额外查询。我的做法是先把该资讯下全部评论一次性查出在内存里按parentId分组拼装数据库只查一次性能天差地别。点赞功能我用Redis做了热度缓存。对某条资讯或评论点赞时先用Redis的set集合记录哪些用户点过赞防止重复点赞。点赞数先更新Redis中的计数再通过定时任务每隔几分钟把计数批量同步回MySQL。这种Redis计热、MySQL保温的模式在资讯站很常见能有效把数据库压力降下来。定时任务我用Spring自带的Scheduled实现单机部署完全够用。收藏功能的表设计要在user_favorite上建(user_id, news_id)唯一索引在插入前判断是否存在相同记录。点击收藏时先查索引存在就提示已收藏不存在才插入。取消收藏直接根据user_id和news_id删除记录。收藏列表联查资讯主表只查询资讯ID、标题、封面、发布时间这几个字段避免把大字段一起查出来。4.4 搜索与标签推荐起步阶段的搜索不需要上Elasticsearch。数据量在几万条级别时MySQL的LIKE模糊查询完全够用。我在资讯标题和摘要上做搜索SQL写法类似SELECT id, title, summary FROM news_info WHERE status 2 AND (title LIKE CONCAT(%, #{keyword, %) OR summary LIKE CONCAT(%, #{keyword, %)) ORDER BY publish_time DESCMyBatis-Plus里用LambdaQueryWrapper的and嵌套方法可以拼出类似的where条件。当数据量超过几十万或者对搜索速度和相关性排序有更高要求时再考虑引入Elasticsearch把资讯同步到ES索引搜索走ES详情回表MySQL。这个演进路径是平滑的不影响业务层接口设计。标签推荐相对简单。每篇资讯挂多个标签用户查看资讯详情时后台根据这条资讯的标签集合找出其他包含相同标签的已发布资讯按发布时间倒序取前10条展示为相关推荐。SQL用IN查询SELECT DISTINCT n.id, n.title, n.cover_image FROM news_tag_relation r JOIN news_info n ON r.news_id n.id WHERE r.tag_id IN (1, 2, 3) AND n.id ! #{currentNewsId} AND n.status 2 ORDER BY n.publish_time DESC LIMIT 10;这个查询要注意DISTINCT去重因为一条资讯可能同时包含多个匹配标签不去重会出现重复结果。数据量大后distinct有性能损耗也可以改成先用group by按news_id聚合再取其他字段。5. 前端展示与数据交互模式5.1 服务端渲染还是前后端分离这个项目我选了Thymeleaf服务端渲染配合少量Ajax做局部交互没有上Vue。我知道很多读者会问现在不都流行前后端分离吗为什么还要用模板引擎原因有三。第一MVP阶段要追求最快上线速度服务端渲染天然SEO友好资讯网站的流量大头来自搜索引擎百度爬虫对纯JS渲染的单页应用收录一直不算友好服务端渲染的好处是爬虫直接拿到完整HTML。第二这个项目以展示查询为主页面交互远没有后台管理系统那么复杂。第三服务端渲染减少了跨域、CORS、token存储这些前后端分离的额外工作。等后续确实需要复杂交互和移动端复用接口时再把接口抽成标准REST API模板页面迁移到Vue或React也不迟。Thymeleaf的写法大家应该都见过在HTML里用th:each遍历资讯列表用th:href拼接详情页链接服务端Model传值div classnews-list th:eachnews : ${newsPage.records} a th:href{/news/detail/ ${news.id}} classnews-title th:text${news.title}这里显示标题/a span classnews-meta th:text${news.publishTime}发布时间/span /divAjax部分我用jQuery功能集中在三个场景分页加载更多、收藏/点赞的即时交互、搜索提示。其余页面都是完整刷新。这样做既避免了纯服务端渲染导致交互生硬也不至于引入前端工程化的复杂度。5.2 接口设计规范与数据格式统一前端联调时最怕接口格式不固定。所以我从一开始就定了一套接口规范所有接口统一走/api前缀前台接口用/api/web后台接口用/api/admin登录注册用/api/auth。返回结构统一使用前面说的Result类分页数据统一为PageResult结构包含records、total、current、size四个字段。几个具体接口的设计可以参考接口请求方式说明/api/web/news/pageGET资讯分页列表参数page、size、categoryId、seriesId/api/web/news/detail/{id}GET资讯详情返回正文、标签、作者信息/api/web/news/tagsGET标签列表/api/auth/loginPOST登录参数username、password/api/web/news/favoritePOST收藏资讯参数newsId/api/admin/news/publishPOST后台发布资讯/api/admin/news/audit/{id}PUT审核通过/驳回接口的参数校验我用了javax.validation在DTO字段上标注NotBlank、NotNullController参数用Validated接收校验失败由全局异常处理器统一包装结果。不用在业务代码里写一堆if判断代码干净很多。这里补充一个注意点DTO校验通过不代表业务正确比如发布资讯时分类ID必须真实存在这就需要在Service层校验一次关联关系防的是人为构造请求绕过前端限制。6. 常见问题与排查技巧实录6.1 高频踩坑与解决思路第一个坑是图片上传后访问404。前面提过本地磁盘路径没有映射为静态资源URL需要在配置类里加资源映射。另外我遇到过磁盘路径带中文或空格造成读取失败的问题解决方案是上传文件时统一重命名为UUID文件名并且路径中不保留原始文件名。这个习惯后来帮我避免了非常多的乱码和跨平台问题。第二个坑是MyBatis-Plus分页插件不生效。很多人引入了mybatis-plus-boot-starter后直接调用selectPage发现返回的total一直是0或者分页参数没生效。原因是没有配置分页插件拦截器。新版MyBatis-Plus需要注入MybatisPlusInterceptor并添加PaginationInnerInterceptor这是配置不是自动生效的。我在项目启动类或者配置类里补上Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }第三个坑是事务注解失效。初学时最容易犯的错是把Transactional加在同类内部调用的方法上比如AdminService里publishNews方法内部直接this.changeStatus()changeStatus上有事务注解也不会生效。因为Spring事务通过AOP代理实现this调用不会经过代理对象。解决方案是拆分Service或者通过AopContext.currentProxy()获取代理对象调用。另外一个常见失效原因是异常被catch吞掉事务感知不到异常自然无法回滚。我建议事务方法内要么不捕获异常要么捕获后手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。第四个坑是JSON序列化死循环。资讯实体里如果配置了作者关联对象序列化返回前端时可能出现循环引用Jackson直接抛异常。我当时的方案是在涉及关联查询的VO上使用JsonIgnore或者直接用VO类而不是实体类从根源避免把关联对象序列化出去。后来我统一了规则Controller不直接返回Entity全部转VO。第五个坑是Redis缓存和数据库的一致性。我缓存了热点资讯列表设置5分钟过期。后台运营编辑资讯后我做的第一版是直接删除缓存让下次请求重建缓存。后来发现删除缓存瞬间会有大量请求同时打到数据库造成缓存击穿。改进方案是使用双重判定锁重建缓存时加锁保证只有一个请求去查询数据库。对这类业务系统缓存不一致可以接受短暂延迟但缓存击穿会导致数据库瞬时压力必须防范。6.2 性能优化与上线部署要点网站上线前我做了一轮性能优化核心集中在查询链路和资源加载上。热点资讯列表和详情都用了Redis缓存区分不同分类和时间维度做缓存键。首页聚合接口设计成从Redis读取缓存为空时才查数据库并设置合理的TTL。浏览量字段如果每次请求实时UPDATE会频繁触发数据库写操作我把它改成先在Redis用Hash结构计数定时批量同步到MySQL实测数据库压力降了一个数量级。这个优化虽然代码多一点但在资讯类站点里属于必做项。图片资源是流量大头我采用了两步策略上传时用工具类压缩图片超过一定大小先做等比压缩列表页缩略图直接用压缩后的文件部署后用Nginx直接托管/upload/静态目录经Spring Boot转发一次、再由Nginx处理高速缓存配了浏览器Cache-Control头大量图片请求根本不会到达Java应用。这一步配合CDN使用效果更佳。如果你有对象存储资源直接把上传目标切到云端配置一个访问域名即可实现方式更简单。打包部署我用的Maven多环境profile本地开发用application-dev.yml连本机数据库生产用application-prod.yml配置线上数据源和Redis连接信息。打包指令区分环境执行比如mvn clean package -DskipTests -Pprod。发布时直接用systemd托管jar包写一个简单的restart脚本避免每次手动kill再java -jar#!/bin/bash PID$(pgrep -f autonews.jar) if [ -n $PID ]; then kill -9 $PID fi nohup java -Xms512m -Xmx512m -jar autonews.jar \ --spring.profiles.activeprod logs/autonews.log 21 这条命令行里的JVM参数我用-Xms和-Xmx保持一致避免运行期堆内存动态伸缩带来的性能抖动。日志统一输出到logs目录配合定时清理脚本防止磁盘写满。生产环境开启Spring Boot Actuator的health端点用于监控应用存活状态。还有一点心得上线前一定要做一次权限自查。我当时用了一个普通测试账号把所有接口过了一遍确认admin接口都返回403未登录访问详情页跳转登录收藏、评论这些需要登录的操作都能正确校验token。很多安全漏洞都是在联调时只测了正常流程忘了测未授权和越权场景。这套系统从设计到上线前后用了差不多三周时间。如果你也是一个人从零开始做一个类似的Spring Boot项目我的建议是不要急着写代码先把需求拆清楚把表结构设计好然后按照用户、内容、互动三条线逐一实现。尤其是事务、索引、缓存这些关键点看起来每个都是小知识点但在一个完整项目中会把它们全部串联起来理解会比单纯看教程深得多。最后再补一句收藏和点赞那个唯一索引最容易被忽略但恰恰是防止脏数据的关键建表的时候多花两分钟后面省下两小时的排错时间。