☰
Spring Boot重构汽车资讯网站:从业务建模到线上排错全记录
2026/10/10 13:23:37 网站建设 项目流程

上个月我把一套跑了多年的老牌汽车资讯网站后端拆了重写,技术栈从老旧的SSH换到了Spring Boot。整个项目从需求梳理到上线,踩了不少坑,也沉淀了不少经验。今天把springboot汽车资讯网站系统的设计实现过程完整梳理一遍,从业务建模、数据表设计、核心功能落地,到上线后的性能优化和排错实录,都会讲到。如果你正打算用Spring Boot做内容型网站,或者对汽车资讯类系统怎么设计没有头绪,这篇文章应该能帮你省下不少试错时间。

老规矩,先交代背景。我接手的是一个运行多年的汽车资讯平台,之前用的是JSP+Servlet那套老架构,每次发版都要重启,线上问题排查全靠翻日志,数据库层面更是早就扛不住日益增长的资讯量和用户访问。所以公司决定用Spring Boot重写。汽车资讯网站和一般的内容管理系统不太一样,它除了资讯文章,还有一个非常核心的车型库,这两个业务交织在一起,对数据建模和搜索检索都有额外要求。我重写的时候重点解决的也是这两个方向的问题。

1. 业务梳理:汽车资讯网站到底在做什么

很多文章一上来就贴技术栈,但业务都还没说清楚。这种内容型系统,业务模型没理顺,后面所有设计都是空中楼阁。所以我先把业务拆开讲。

1.1 核心用户与使用路径

汽车资讯网站的用户分两类:一类是普通用户,他们的典型路径是打开首页 → 看今日推荐资讯 → 点进一篇评测文章 → 看完去车型库查这款车的参数和价格 → 顺藤摸瓜看同级别其他车型 → 收藏或者发评论。另一类是内容运营人员,他们的日常工作是在后台发布文章、管理车型参数、审核评论、上下架资讯。

这两类用户的使用路径差异非常大。用户的路径是“看”,运营的路径是“发”。所以在系统设计上,前台读多、后台写多的特征一定要分清楚。前台的查询要快,要有缓存;后台的发布流程要稳定,要有审核状态控制。

1.2 核心功能模块划分

从信息架构上,我把整个系统拆成六大模块:

  • 资讯模块:文章的发布、编辑、上下架、分类、标签、置顶、热门推荐。
  • 车型库模块:品牌、车系、车型的三级模型,以及车型参数、价格、图片、上市时间等信息。
  • 用户模块:注册登录、个人中心、关注车型、收藏文章、浏览历史。
  • 评论互动模块:文章评论、评论回复、点赞。
  • 检索模块:关键词全文搜索、车型多条件筛选。
  • 后台管理模块:运营管理后台、数据统计、敏感词过滤。

这六个模块基本覆盖了一个汽车资讯网站的全部业务。刚开始做的时候不要贪多,先把这六块跑通,再考虑社区、问答、二手车这些衍生业务。

1.3 单体架构为什么够用

不少朋友一听到“系统设计”就觉得要上微服务、要搞分布式,其实完全没必要。汽车资讯网站是一个典型的读多写少的内容型系统,它不像电商那样有高并发秒杀,也不像IM那样有海量消息推送。大部分请求是浏览资讯和查车型参数,只有少量请求是发布评论。

我当时估算了一下,日均PV在几十万级别,高峰期QPS大概几百,这个量级用单体Spring Boot应用,配合Redis缓存和MySQL优化,完全能扛住。我实际部署的时候用的是8核16G的虚拟机,压测下来峰值QPS能到3000以上,CPU和内存都还有余量。

单体架构的好处很明显:部署简单、调试容易、没有网络开销、事务控制方便。很多人容易陷入“微服务崇拜”,但服务的拆分应该由业务复杂度和团队规模决定,而不是由技术潮流决定。我的建议是:流量没到那个量级,团队没到那个规模,就不要碰微服务。先把单体做扎实,等业务真到了要拆的时候,再按模块边界慢慢拆出去。

2. 技术选型:Spring Boot背后的一整套取舍

这一章我想讲清楚我做技术选型的思考过程。技术选型没有绝对的对错,关键是要匹配自己的业务场景和团队能力。

2.1 后端与持久层

核心框架自然是Spring Boot,我用的版本是2.7.x,这个版本比较成熟,坑少,社区资料多。为什么不直接上Spring Boot 3?因为我们的JDK还在8,团队对Java 17的迁移还需要时间,没必要为了用新版本而用新版本。

持久层我在MyBatis-Plus和Spring Data JPA之间纠结了很久。JPA在简单CRUD上确实省事,实体类一映射,基本不用写SQL。但汽车资讯网站有一个非常典型的场景——车型库的多条件筛选,品牌、价格区间、级别、能源类型、变速箱类型等十几个筛选条件随意组合。这种动态SQL用JPA写不但费劲,而且后期优化SQL看不明白。所以我选了MyBatis-Plus,理由是动态查询可控、团队熟悉、性能问题好排查。事实也证明这个选择是对的,后面写车型筛选的时候,MyBatis-Plus的QueryWrapper帮了大忙。

数据库用MySQL 8.0。选择8.0很重要,因为它有JSON类型、中文全文索引(ngram parser)、窗口函数,这几个能力对我的车型参数存储和资讯搜索都很有用。MySQL 5.7虽然也有JSON和全文索引,但能力和性能都比8.0差一截。

2.2 模板渲染还是前后端分离

这是我这次重写中一个非常关键的决策。汽车资讯网站是典型的SEO敏感型业务,资讯文章、车型详情页都需要被搜索引擎收录。如果前端全部用Vue/React做SPA,搜索引擎的爬虫拿到的是空壳HTML,收录效果会很差。如果要解决这个问题,就得用Nuxt/Next做服务端渲染,那又是一整套工程复杂度。

我最终选了Thymeleaf模板引擎 + Vue局部组件的混合方案。资讯列表页、文章详情页、车型详情页用Thymeleaf服务端渲染,保证SEO和首屏速度;评论区、搜索框、筛选面板这类交互性强的区域用Vue挂载一个独立组件。这样既保住了SEO,又避免了前后端分离带来的跨域和鉴权复杂度。

这个方案在维护上比纯前后端分离直观得多,因为页面就在后端工程里,模板改起来也方便。对内容型网站来说,这才是“最舒服”的架构。

2.3 容易被忽略的基础设施组件

除开Spring Boot本身,有几个基础设施组件看起来不起眼,但对系统质量影响很大:

  • HikariCP连接池:Spring Boot默认配置,不用额外引入,但一定要调参。我实际把maximumPoolSize调到了20,minimumIdle调到5,这个配置匹配我的数据库规格。
  • Flyway数据库版本管理:所有表结构的变更都通过Flyway脚本执行,上线的时候再也不用担心“谁改了表没同步”这种问题了。
  • Spring Scheduling定时任务:用来做热榜重算、浏览量异步刷库、缓存预热。
  • Redis:缓存、分布式锁、浏览量计数器、ZSet热榜,全都要靠它。
  • MinIO:图片和附件存储,汽车资讯网站图片量大,用一个独立的对象存储服务比较合理。

我把这块的依赖清单贴在下面,供参考:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-jsqlparser</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.flywaydb</groupId> <artifactId>flyway-core</artifactId> </dependency> <dependency> <groupId>org.flywaydb</groupId> <artifactId>flyway-mysql</artifactId> </dependency>

3. 数据模型:汽车资讯系统的表结构设计

数据模型是整个系统里最考验经验的环节。汽车资讯网站的表结构比普通CMS复杂,因为它有“品牌-车系-车型”这个三级模型,还有车型参数这种变长字段。这个章节我讲几个核心表的设计逻辑。

3.1 资讯表与文章字段设计

资讯表是内容系统的地基。我设计的article表核心字段如下:

CREATE TABLE `article` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `title` varchar(120) NOT NULL COMMENT '标题', `summary` varchar(500) DEFAULT NULL COMMENT '摘要', `content` longtext NOT NULL COMMENT '正文', `category_code` varchar(32) NOT NULL COMMENT '分类编码', `author_id` bigint(20) NOT NULL COMMENT '作者用户ID', `cover_url` varchar(255) DEFAULT NULL COMMENT '封面图', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0草稿,1待审核,2已发布,3已下线', `like_count` int(11) NOT NULL DEFAULT '0' COMMENT '点赞数', `view_count` int(11) NOT NULL DEFAULT '0' COMMENT '浏览量', `comment_count` int(11) NOT NULL DEFAULT '0' COMMENT '评论数', `top_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否置顶', `publish_time` datetime DEFAULT NULL COMMENT '发布时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category_status_publish` (`category_code`,`status`,`publish_time`), KEY `idx_top_status` (`top_flag`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资讯文章表';

有几个字段的设计我特别想说明一下:

  • summary摘要字段。列表页显示的是摘要,不是正文,如果不加这个字段,每次列表查询都要去捞content这个大字段,性能会差很多。
  • like_count、view_count、comment_count这三个计数都是冗余字段。虽然冗余,但非常必要。如果列表页要显示点赞数、浏览量、评论数,每次实时去count的话数据库会被打爆。冗余字段的正确维护方式是:写入的时候同步更新,显示的时候直接读。
  • publish_time和create_time分开。因为资讯有“草稿提前写、定时发布”的场景,发布时间和创建时间不能混为一谈。
  • 联合索引idx_category_status_publish是为了支撑“栏目页 + 已发布 + 按时间倒序”这个最强查询条件。

3.2 品牌-车系-车型三级模型

汽车行业的数据结构和一般商品不一样,它有明确的层级:一个品牌(比如某品牌)下有多个车系(比如某家族的轿车系列、SUV系列),一个车系下又有多个具体车型配置(比如某配置款、某旗舰版)。这决定了车型库要设计成三张表:

CREATE TABLE `brand` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '品牌名称', `logo_url` varchar(255) DEFAULT NULL, `sort` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='品牌表'; CREATE TABLE `series` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `brand_id` bigint(20) NOT NULL COMMENT '所属品牌ID', `name` varchar(50) NOT NULL COMMENT '车系名称', `level` varchar(20) DEFAULT NULL COMMENT '级别:小型车/紧凑型/中型/中大型/SUV/MPV', `guide_price_min` decimal(10,2) DEFAULT NULL, `guide_price_max` decimal(10,2) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_brand_id` (`brand_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车系表'; CREATE TABLE `car_model` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `series_id` bigint(20) NOT NULL COMMENT '所属车系ID', `name` varchar(100) NOT NULL COMMENT '车型名称', `energy_type` varchar(20) DEFAULT NULL COMMENT '能源类型:汽油/柴油/纯电/混动', `price` decimal(10,2) DEFAULT NULL COMMENT '指导价', `params` json DEFAULT NULL COMMENT '详细参数JSON', PRIMARY KEY (`id`), KEY `idx_series_id` (`series_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='具体车型表';

这里有一个关键设计取舍:车型的详细参数(发动机、变速箱、百公里加速、车身尺寸等)非常多,如果每一个参数都建一列,表会非常宽,而且不同车型的参数还不一样,新能源车和燃油车差得更多。我最终把详细参数放进了params这个JSON字段里,但在car_model表上保留了energy_type、price这种高频筛选字段作为普通列,并建立索引。

这个设计的理由很朴素:需要经常查询和筛选的字段独立成列加索引,不经常筛选但详情页要展示的字段放JSON。MySQL 8.0的JSON字段可以配合函数索引,但复杂程度会高一些,对于我的场景,普通字段筛选已经完全够用了。

3.3 评论自关联与用户扩展

评论表我用了自关联设计,支持两层回复,也就是“评论-回复”的楼中楼结构:

CREATE TABLE `comment` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `article_id` bigint(20) NOT NULL COMMENT '文章ID', `user_id` bigint(20) NOT NULL, `parent_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '父评论ID,0表示一级评论', `reply_user_id` bigint(20) DEFAULT NULL COMMENT '回复的目标用户ID', `content` varchar(1000) NOT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_article_id` (`article_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论表';

为什么只做两层?因为超过两层的楼中楼对用户阅读体验并没有提升,反而让前端渲染和SQL查询变得异常复杂。很多论坛做了无限层级嵌套,最后管理成本很高,用户其实也并不会回复得那么深。产品上定好规则:一级评论下面只允许一层回复,这能省掉你至少三分之一的实现精力。

用户表这块我强烈建议不要自己造太多轮子。基础的用户表就放账号、密码(BCrypt加密)、昵称、头像、角色这几个字段。汽车资讯网站用户需要关注车型、收藏文章、记录浏览历史,这些不要堆在用户主表里,而是分别建关联表:user_follow_series、user_favorite_article、user_history。主表保持清爽,扩展表自己长自己的。

4. 核心功能实现:从资讯发布到车型搜索

这一章讲核心功能的落地。我不会贴完整的业务代码,而是把关键逻辑和思路讲透,代码能说明问题的部分给片段。

4.1 资讯发布的审核状态机

资讯发布不是一个简单的insert,它有状态流转。我定义的状态码是:0草稿、1待审核、2已发布、3已下线。状态流转路径是:

  • 编辑保存草稿:写入status=0。
  • 编辑提交审核:status从0变为1。
  • 审核通过:status从1变为2,同时写入publish_time。
  • 审核驳回:status从1回退为0。
  • 运营下线:status从2变为3。

这个状态机用int字段维护,配合一个状态变更的校验工具类,防止非法跳转。比如草稿状态不能直接变成已发布,必须经过待审核。审核通过的瞬间,还有一个联动动作:生成一条静态化页面,或者把文章ID写入Redis的待推送队列,让定时任务把新文章刷进首页推荐位。

这里有一个很值得注意的细节:publish_time一定要在审核通过的那一刻由代码显式写入,而不能用数据库的CURRENT_TIMESTAMP代替。因为定时发布的需求是“现在审核通过,但是明天早上八点才上线展示”,展示时间由定时任务控制,写入的publish_time就是用户看到的“发布时间”。

4.2 车型筛选的动态SQL写法

车型库是整个系统中查询条件最多的模块。用户筛车的角度五花八门:按品牌、按价格区间、按级别、按能源类型、按变速箱。这些条件每个都可以单独选,也可以任意组合。用MyBatis-Plus的QueryWrapper来拼动态条件非常合适:

public Page<CarModelVO> searchCarModels(CarSearchDTO dto, Page<CarModelVO> page) { LambdaQueryWrapper<CarModel> wrapper = Wrappers.lambdaQuery(); if (StringUtils.isNotBlank(dto.getBrandName())) { wrapper.eq(CarModel::getBrandName, dto.getBrandName()); } if (dto.getMinPrice() != null) { wrapper.ge(CarModel::getPrice, dto.getMinPrice()); } if (dto.getMaxPrice() != null) { wrapper.le(CarModel::getPrice, dto.getMaxPrice()); } if (StringUtils.isNotBlank(dto.getLevel())) { wrapper.eq(CarModel::getLevel, dto.getLevel()); } if (StringUtils.isNotBlank(dto.getEnergyType())) { wrapper.eq(CarModel::getEnergyType, dto.getEnergyType()); } wrapper.orderByDesc(CarModel::getPrice); IPage<CarModelVO> result = carModelMapper.selectPage(page, wrapper); return result; }

这里要注意的一点是:分页对象page里不要用offset超大值。比如用户翻到第100页,offset就是1980,MySQL需要扫描前面1980行再丢掉,效率很低。我的方案是限制单次查询最大返回页数,超过50页强制二次筛选。另外,如果用户筛选了具体品牌,建议优先走brand_id索引,这类高频查询可以在Redis里做结果缓存。

4.3 关键词全文搜索的取舍

资讯搜索我建议中小型项目直接用MySQL全文索引,不要一上来就上Elasticsearch。原因有两点:一是ES需要独立部署和维护,占用运维精力;二是对几十万篇资讯来说,MySQL全文索引配合ngram中文分词解析器,查询性能完全够用。

建索引的SQL是:

ALTER TABLE article ADD FULLTEXT INDEX ft_title_content (title, content) WITH PARSER ngram;

查询的时候用MATCH...AGAINST:

SELECT id, title, summary FROM article WHERE MATCH(title, content) AGAINST ('汽车评测' IN NATURAL LANGUAGE MODE) ORDER BY publish_time DESC LIMIT 20;

这个方案的缺点是没有中文分词,是ngram按字切分的,搜索“汽车评测”的时候会拆成相邻的二元组去匹配,召回率还不错,但精确度一般。如果后续资讯量突破百万且搜索需求变重,再考虑升级ES也不迟。我的原则依然是:业务量到哪,技术就配到哪,不要过度设计。

4.4 浏览量与热榜的实现

浏览量是所有资讯系统都会遇到的问题。如果每次用户访问文章详情都UPDATE article SET view_count = view_count + 1,这个行锁会成为整个系统最大的瓶颈。一篇热门文章一天可能被刷几十万次浏览量,每次都更新数据库,对数据库的压力太大。

我的方案是Redis计数 + 异步批量回写:

  • 用户访问详情页时,执行redisTemplate.opsForValue().increment("article:view:" + articleId)。
  • 定时任务每30秒读取一次Redis计数器,批量更新数据库,并删除已回写的Redis key。
  • 如果Redis挂了,降级为直接走数据库更新,保证计数不丢。

这个方案把对数据库的写压力稀释了60倍,实测下来效果明显。

热榜也不建议实时查数据库计算,我用Redis的ZSet,key是hot:articles:day,score按浏览量、评论数、点赞数加权计算,每隔一小时用定时任务重算一次。热榜接口直接读ZSet数据,毫秒级返回。

4.5 评论模块的防刷与展示

评论模块最怕的是两个问题:一个是垃圾评论刷屏,一个是评论查询慢。

垃圾评论我在后端做了两层过滤。第一层是基础敏感词库匹配,维护一个敏感词列表,命中直接拦截;第二层是同IP评论频控,同一个IP在5分钟内最多发10条评论,超限直接拒绝。这个频控用Redis的INCR+EXPIRE实现,非常简单,但效果很好。

评论的查询性能主要靠“一次性拉出两层评论”。我查一篇文章的评论时,先按article_id查出一级评论(parent_id = 0),再根据一级评论的id批量查出所有二级回复,最后在内存里组装成树。这个方案避免了对每一条一级评论都发一次SQL查询的N+1问题,实测下来一篇文章1000条评论也能在100毫秒内返回。

5. 踩坑实录:七个值得说的生产问题

这一章是这次项目里实打实踩过的坑。每个问题我都把根因和解决方案写出来,希望大家能绕开这些我花了不少时间才解决掉的坑。

5.1 Redis缓存穿透:热点数据被恶意刷烂

上线初期,我们的文章详情接口先查Redis,没有就查数据库再写回缓存。但后来发现一个严重问题:只要有人用不存在的文章ID大量请求,比如/article/99999999,Redis永远没有缓存,所有请求全部打到MySQL上,直接把数据库拖垮。

解决方案是缓存空值法:当查询一个不存在的ID时,在Redis里写入一个空占位符,过期时间设短一些(比如60秒)。这样同样的非法请求在60秒内不会再次穿透到数据库。更严谨的方案是布隆过滤器,在请求进来之前就判断ID是否存在,但布隆过滤器有误判率,实现成本也更高,所以前期我用空值缓存就解决了。

5.2 缓存一致性:先删还是先更新

资讯文章更新后要同步Redis缓存,这里有个著名的坑:先更新数据库再删缓存,如果删缓存那一下Redis出错了,缓存里还是旧数据,用户看到的就是脏数据。先删缓存再更新数据库也有问题,如果更新数据库失败,缓存就没了,流量全打到数据库上。

我最终的方案是“延迟双删”:先删缓存,再更新数据库,隔500毫秒再删一次缓存。这个方案的核心逻辑是让更新期间产生的旧缓存有机会在下一次删除时被清掉,最终保证缓存和数据库一致。这个方案不是绝对完美,但在绝大多数业务场景下已经足够,而且实现成本极低。

5.3 浏览量异步刷库丢数据

第一次做浏览量异步回写的时候,我的定时任务逻辑是:读取Redis key列表,回写数据库,然后删除key。结果有一次Redis内存被大流量打满,触发内存淘汰,还没来得及回写的浏览量直接丢了。

后来我改成了累加式回写:回写数据库时不是把Redis计数器清0,而是读取并保留原值,然后利用Redis的INCRBY做差值回写。简单说,就是每30秒把当前Redis计数器的值读出来,跟上次回写的值做差,把差值更新到数据库。这样即使某一次定时任务失败,下次还有机会补上,不会丢失数据。

5.4 MyBatis-Plus逻辑删除与唯一索引的冲突

这是一个特别隐蔽的坑。我们在user_favorite_article表上建了(user_id, article_id)唯一索引,用来防止用户重复收藏文章。后来为了支持“取消收藏”功能,加了逻辑删除字段deleted。结果发现:用户收藏一篇文章、取消收藏、再收藏,第二次收藏永远报唯一索引冲突。

原因是逻辑删除的记录还留在表里,唯一索引依然生效。解决办法是把逻辑删除字段放进唯一索引:(user_id, article_id, deleted),这样每次取消收藏时生成一条新的未删除记录,就不会和旧的已删除记录冲突。这个坑网上讨论的帖子不多,我查了很久才找到原因,建议做逻辑删除+唯一索引组合的朋友们都留心一下。

5.5 时区问题导致发布时间错乱

上线后运营反馈:定时发布的文章在后台看到的发布时间总是比预定时间早8个小时。排查下来是MySQL连接串没指定时区,Java进程用的Asia/Shanghai,MySQL会话用的却是系统默认的UTC。

解决方法是在JDBC连接串里显式指定:

spring.datasource.url=jdbc:mysql://localhost:3306/car_news?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

同时MySQL数据库的time_zone参数也设为+08:00。前后端时间统一用时间戳传输,展示层再按用户本地时区格式化。这个坑本身不大,但排查起来非常折腾。

5.6 JSON字段的部分更新陷阱

车型参数存在car_model.params字段里,更新的时候如果用MyBatis-Plus的updateById,传一个带部分参数的新JSON对象,会把整个JSON字段覆盖掉,旧的参数就丢了。

解决方法是:先查出完整的JSON,在内存中合并要修改的字段,再整体写回。或者用MySQL的JSON_SET函数做原子更新,但MyBatis-Plus对这块支持有限,需要写自定义SQL。我的建议是干脆用“读改写”三步走,毕竟车型参数不是高频并发修改的数据,读改写的方式简单可控。

5.7 深分页的性能坑

车型列表页翻到第50页以后,接口响应时间从200毫秒涨到3秒。原因就是LIMIT 990, 10这种深分页查询,MySQL需要扫描前1000行再丢弃。优化方案是“延迟关联”:先用覆盖索引快速查出主键ID集合,再关联回完整表查询数据。

改造后的SQL类似:

SELECT * FROM car_model WHERE id IN ( SELECT id FROM car_model WHERE series_id = 1001 ORDER BY price DESC LIMIT 990, 10 ) ORDER BY price DESC;

这样外层只要查10条完整数据,而不是扫描1000行。这个优化在某些数据集上性能提升能达到一个数量级。

6. 部署上线与后续演化

代码写完只是开始,部署上线和线上维护才是真正考验系统的环节。这一章分享我这次上线用到的一套还算顺手的方案。

6.1 环境区分与Docker Compose部署

我用了三套环境:本地开发环境、测试环境、生产环境。每套环境用不同的application-{profile}.yml配置,通过启动参数spring.profiles.active切换。生产环境的数据库密码等敏感信息不写在配置文件里,而是通过环境变量注入。

部署层面我用Docker Compose编排,一条命令解决MySQL、Redis、MinIO和应用本身的启动:

version: '3.8' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} TZ: Asia/Shanghai volumes: - ./mysql-data:/var/lib/mysql ports: - "3306:3306" redis: image: redis:7 command: redis-server --appendonly yes ports: - "6379:6379" app: build: . ports: - "8080:8080" depends_on: - mysql - redis

前端资源通过Nginx反代,同时开启静态资源缓存。Nginx配置里我把/static/路径的缓存时间设为7天,图片走MinIO的CDN域名,这样应用本身承受的请求主要是动态接口和Thymeleaf模板渲染。

6.2 上线后的关键监控指标

上线后我关注三个维度的指标:应用层、数据库层、业务层。

应用层主要看接口RT和QPS,通过Spring Boot Actuator暴露的/actuator/metrics端点,配合定时采集。数据库层重点看慢查询日志和连接池的活跃连接数。业务层看的是资讯发布成功率、浏览量回写延迟、评论审核积压数。

有一个很有效的监控指令我一直在用,就是查看线上最慢的10条SQL:

mysqldumpslow -s at -t 10 /var/log/mysql/slow-query.log

这个命令能直接告诉我哪个查询该优化了。每周跑一次,把Top10慢SQL逐个优化,是整个系统性能持续提升的关键。

6.3 后续还可以怎么扩展

这个版本跑稳定之后,后续有几个方向可以继续做。

搜索上面,如果资讯量突破百万且搜索需求变重,把全文搜索迁移到Elasticsearch,利用IK分词提升搜索精度。这个改造相对独立,因为我已经把搜索逻辑收敛在一个SearchService里,替换成本不高。

推荐系统方向,可以基于用户浏览历史、收藏车型打造一个简单的推荐服务。初期不需要机器学习,基于标签和车型级别的协同过滤就够用了。

微服务拆分方向,只有当团队规模变大、模块边界足够清晰之后,才建议把用户、内容、检索拆成独立服务。拆分的第一刀应该是“后台管理”和“前台展示”,因为这两个模块的访问量和变更频率差异最大。但前面说了,没到那个规模就别动。

最后再分享一个小技巧。如果你也在做类似的内容型网站,开工之前一定花时间把实体关系图画完整。我当时就是靠着一张完整的ER图,把品牌、车系、车型、资讯、评论、用户之间的关系画清楚后才动手的。把关系和边界理清楚,后面写代码的速度会比边写边想快很多。设计阶段多花的功夫,永远是回报率最高的投入。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询