拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SSM框架微博系统全流程复盘:从表设计到部署压测的完整实践

SSM框架微博系统全流程复盘:从表设计到部署压测的完整实践

前一阵整理工作目录,翻出一个老项目,代号 m137——一个基于 SSM 框架的微博系统。这个项目放在今天看,技术栈确实旧了不少,但应用场景非常典型:注册登录、发微博、关注/粉丝、评论点赞转发,几乎把社交产品的基础玩法都覆盖了一遍。它尤其适合用来理解 Java Web 应用的完整链路,从表结构到接口设计,从登录态到部署上线,中间那些坑我到现在都记得挺清楚。

这篇文章不是要把代码逐行贴一遍,而是以复盘的方式讲清楚:为什么当时选 SSM 而不是更“新”的框架,数据模型怎么设计才能支撑微博这种关系链产品,时间线接口为什么越翻越慢,发布微博的事务边界怎么划,以及部署 Tomcat 时会遇到哪些问题。如果你正在做一个课程设计、毕业设计,或者接手了一个老的 SSM 维护项目,应该能直接拿来参考。

1. 微博系统选型:为什么 m137 用了 SSM 而非直接上 Spring Boot

1.1 先把微博系统的需求拆成一张清单

很多人在动手写代码之前,根本没把需求想清楚。微博系统看着简单,但“关注驱动内容”这个模型一旦漏掉一个环节,后期返工成本非常高。我当时把功能拆成四层:

第一层是账号体系,包括注册、登录、退出、个人资料修改。登录状态是所有操作的前提,发微博、评论、点赞都要先知道“当前用户是谁”。

第二层是内容生产与消费,注册用户能发文字微博,也能带图片;首页要按时间倒序展示自己关注的人的微博,还要支持点进某个人的主页看他发过的全部微博。

第三层是社交关系,关注、取关、粉丝列表、关注列表。注意这里有一个容易被忽略的点:查看某个用户主页时,要显示“当前登录用户是否已经关注他”,这决定了页面上按钮是“关注”还是“已关注”。

第四层是互动行为,评论、点赞、转发。互动数据要能实时展示在每条微博下面。至于私信、话题、热搜这些,我当时只做了最基础的版本,属于额外加分项。

把这些列完之后,问题就变成了技术选型。为什么是 SSM?原因并不是 SSM 比其他方案好,而是当时的约束条件决定的:项目成员对 Spring + SpringMVC + MyBatis 这套组合最熟,参考资料最多,环境里跑得最稳的就是 JDK8、Tomcat8、MySQL5.7 的组合。Spring Boot 当时虽然有,但团队里没人真正在生产项目里用过,与其边学新框架边调 bug,不如用已知能跑通的技术一路做下去。

1.2 SSM 框架分工与版本搭配的一个稳妥建议

SSM 三件套的分工,一句话就能讲清:Spring 负责对象生命周期和依赖注入;SpringMVC 负责 HTTP 请求的分发,从 Controller 到 Service 再到页面或 JSON;MyBatis 负责 SQL 与 Java 对象的映射。微博系统里几乎所有接口都遵循这个链路:前端发请求,Controller 接收参数,Service 里写业务逻辑,Mapper 接口对应 XML 里的 SQL 语句。

版本搭配上,我踩过一次很痛苦的坑:JDK 版本和 Spring 版本不兼容,导致项目一启动就报NoSuchMethodError。后来固定下来这套组合,稳定跑完了整个项目:

组件版本备注
JDK1.8不要用 9 以上跑老项目
Tomcat8.5兼容性最稳
Spring / SpringMVC4.3.18这个版本对注解支持很完整
MyBatis3.4.6配合 mybatis-spring 1.3.2
MySQL5.7建表用 InnoDB,utf8mb4
Maven3.5+管理依赖和打包

包结构建议按照职责分层,不要塞成一团。我当时用的是com.m137.weibo.controller、com.m137.weibo.service、com.m137.weibo.mapper、com.m137.weibo.entity、com.m137.weibo.common。common 里放统一返回结果、分页参数、拦截器、工具类。这个结构的好处是,出现问题时能快速定位:接口报错了去 controller 看,SQL 错了去 mapper 看。

Maven 依赖里最需要注意的是三个容易版本冲突的坐标:spring-webmvc、mybatis-spring、commons-dbcp2。尤其mybatis-spring,版本太老会和 Spring 4.3 的 bean 扫描机制冲突,导致 Mapper 无法注入。现在已经很少有人手写 spring 配置文件了,但如果是维护老项目,applicationContext.xml和spring-mvc.xml里的<context:component-scan>一定要检查基础包,漏一层就会出现诡异的不识别 Controller 问题。

2. 数据模型设计:从用户表、微博表到关注关系

2.1 用户表和微博表的关键字段,先定主键、编码和删除策略

微博系统的表不多,但每一张都值得认真设计。用户表的第一版我用的是自增主键,用户名做了唯一索引。密码字段一定不能存明文,我用的方式是把BCrypt生成的哈希存进去,长度预留到 64 位。头像、简介、性别这些字段都很常规。比较关键的是状态字段:用户被禁用后不能登录,所以加了status字段而非物理删除,避免关联数据悬空。

微博表是另一个核心。标题里特别注意:微博内容不能太长,但也不能把字段设得太小。我定了content VARCHAR(500),界面层限制 200 字,给后续扩展留了余量。图片不是单条微博一张,所以没有在微博表里写死一个image_url字段,而是拆分了一张微博图片表。这样一条微博发九张图也没什么问题,查询时按weibo_id批量取出即可。

关键的删除策略问题:用户删微博,不能真的把数据库行删掉。原因很实际,评论、点赞、转发都关联了这条微博的 ID,物理删掉之后这些关联表会变成“孤儿数据”,统计互动数时各种空指针。所以微博表里加了一个delete_flag字段,默认 0;查询时所有列表 SQL 都带and delete_flag = 0,靠索引覆盖来保证性能。当然,如果数据量巨大,这种软删除要小心统计任务误把已删数据算进去,但项目阶段完全够用。

建表时还要加一条通用原则:MySQL 统一utf8mb4,不要用utf8。utf8存真正的四字节 emoji 时会报Incorrect string value错误,而微博这种产品里用户发个 emoji 太常见了。当时上线当天就遇到一个有 emoji 的注册昵称导致整条插入失败,那个教训很直观。

2.2 关注关系表:唯一索引比想象中更重要

关注关系是微博场景的命脉。用户 A 关注用户 B,A 的首页就要看到 B 发的微博;B 的粉丝列表里要有 A。反过来说,A 取关 B 之后,首页就不能再出现 B 的内容。这里最忌讳的就是在一张表里堆字段,比如user_id、follow_user_id、follow_time、is_mutual之类的,关系冗余越多,维护成本越高。

我的关注表设计成最简形式:

CREATE TABLE follow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, follow_user_id BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_follow (user_id, follow_user_id), KEY idx_follow_user (follow_user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有两个容易被忽略的点。第一,唯一索引(user_id, follow_user_id)是为了防止重复关注。如果你只在业务代码里查一次再插入,并发场景下两个请求同时到达,依然会插入两条相同的关注记录。这是典型的“并发下唯一索引兜底,而不是靠代码判断”的应用。第二,查询粉丝列表时,条件是follow_user_id = 某个用户,而联合索引的最左前缀规则决定了(user_id, follow_user_id)不能直接服务于这个条件。所以我单独为follow_user_id建了一个索引。否则用户粉丝量稍微涨一点,这个查询就会全表扫描。

判断“我是否关注了他”这个动作,最适合放在关注列表查询里一起做。比如我打开别人的主页,只需要执行一条select count(*) from follow where user_id = 当前用户 and follow_user_id = 被查看用户,结果大于 0 就显示已关注。数据量小时不用做任何缓存,数据库能扛得住。

2.3 评论、点赞与转发的表设计取舍

互动模块的表设计里,我觉得最值得聊的是点赞。点赞的实时性要求很高,用户点了之后立刻要亮,再点一下要取消,但点赞记录本身要保留“谁赞过”。最直接的表设计就是这样:

CREATE TABLE like_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, weibo_id BIGINT NOT NULL, user_id BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_weibo_user (weibo_id, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

查询某条微博的点赞总数,最准确的方式是select count(*),但同一时间可能有多条微博在展示,每条微博都做一次 count,N+1 问题就来了。我当时用了冗余字段方案:在微博表里加like_count字段,点赞时执行update weibo set like_count = like_count + 1,取消时减 1。这样首页列表只要查一条微博记录,就能拿到点赞数。代价是如果数据库更新和点赞记录插入不在同一个事务里,可能出现点赞记录插进去了、计数没加,或者反过来。这个一致性问题要和发布微博的事务一起考虑,后面会细讲。

评论表字段不复杂:weibo_id、user_id、content、create_time,再加一个parent_id支持楼中楼。转发我当时的做法比较偷懒,没有单独建表,在微博表里加了一个forward_weibo_id字段,转发行为本质上是插入一条新的微博,内容用户可以自己写一句也可以完全留空,forward_weibo_id指向原微博。查询时再做一次 join 把原微博取出来。这种设计对普通规模的项目够用。

3. 微博时间线的拉取实现:不急着上缓存,先把分页 SQL 做好

3.1 最直接的关注 feed 查询与它的明显短板

用户登录后首页要展示自己关注的人发布的微博,这是整个系统里访问量最高的接口。第一版查询思路很直接,就是“查我关注的所有用户,把它们发的微博倒序排出来”。

SELECT w.id, w.user_id, w.content, w.create_time FROM weibo w WHERE w.delete_flag = 0 AND w.user_id IN ( SELECT follow_user_id FROM follow WHERE user_id = #{currentUserId} ) ORDER BY w.create_time DESC LIMIT #{offset}, #{pageSize}

这段 SQL 在用户关注人数少、微博总量小的时候完全没问题。但数据稍微多起来,问题就暴露了:子查询会产生一个关注者 ID 集合,如果这个集合很大,IN 条件里的值很多,索引使用会变得不理想。更关键的是LIMIT offset, pageSize这种深分页模式,越往后翻,MySQL 需要扫描并丢弃前面 offset 行记录才能定位到下一页数据,性能会直线下降。

我在压测时模拟了 1000 万条微博数据、单用户关注 500 人的情况,翻到第 100 页时接口响应时间从 30 毫秒飙到了 1 秒多。这就是典型的深分页问题。解决方式不是加 Redis 缓存,缓存只能扛住“大量用户看前几页”,但每个用户刷到后面时,慢查询依然会存在。

3.2 用游标分页替代 OFFSET,时间线不再越翻越慢

正确做法是放弃“页数”概念,改成“下一页基于上次最后一条记录的位置”。前端每次请求时带上上一页最后一条微博的 ID,后端把它作为查询条件:

SELECT w.id, w.user_id, w.content, w.create_time FROM weibo w WHERE w.delete_flag = 0 AND w.user_id IN ( SELECT follow_user_id FROM follow WHERE user_id = #{currentUserId} ) AND w.id < #{lastWeiboId} ORDER BY w.id DESC LIMIT #{pageSize}

这里把排序从create_time改成了id,因为微博 ID 本身就是随着插入时间递增的,倒序取 ID 基本等价于按时间倒序,但主键索引PRIMARY KEY天然支持这种排序,不需要额外的排序文件。id < lastWeiboId这种条件直接走主键索引,每页查询只需要扫描 20 条左右的记录,响应时间基本稳定。

要注意的一个小细节是:如果允许用户修改微博时间,或者做的是“置顶微博”这类功能,id就不能代表时间。但微博系统里普通用户没有置顶能力,这个方案足够简洁。另外,首页第一次进入时没有lastWeiboId,SQL 就不加这个条件,直接取第一页。这个接口从 1 秒多降到 20 毫秒左右,几乎没增加任何复杂度。

3.3 什么时候才值得给时间线加一层 Redis 缓存

看到很多博客一谈时间线就是“推拉结合”“Timeline Cache”,看起来很高级,但微博系统在早期数据量下真的需要吗?我的体会是:不要为了用缓存而用缓存。

Redis 缓存适合的场景是“同一份数据被高频读取,而读取成本高于缓存维护成本”。首页时间线有个特殊性:每个用户看到的内容都不一样,缓存没法全量复用。给每个用户的首页建一份缓存列表,意味着用户每关注一个人、每刷新一次,都要更新他的缓存,这个一致性成本远大于直接查一次数据库。

我当时只对热度最高的几条公共内容做缓存,比如首页顶部推荐位。核心时间线还是直接用 MySQL 查,配合主键分页已经足够稳。如果你确实要用缓存,最简单的模式是 Cache Aside:先查缓存,没有则查数据库再回填,更新时先更新数据库再删除缓存,而不是立即写缓存。这个模式看起来很基础,但能避免脏数据。需要新增用户关注、新增微博时,把相关用户的首页缓存键删除,等下一次请求再重建。即便如此,我还是建议先把数据库查询优化到能接受的程度,再考虑缓存。

4. 发布微博与登录态:事务边界和重复提交最容易被忽略

4.1 登录态:Session 拦截器与静态资源的坑

SSM 项目里登录态最常用的方案是 Session。用户登录成功后,把用户对象放进 Session;每次请求进 Controller 前,拦截器检查 Session 是否为空。因为微博系统的接口很多,每个接口里都手写判断登录状态会很痛苦,所以一定要抽一个拦截器。

SpringMVC 的拦截器要实现HandlerInterceptor接口,重写preHandle方法。注意拦截器会拦截所有路径,包括 CSS、JS、图片这些静态资源,如果不对这些路径放行,页面会直接变成裸样式。当时很多人会在这里卡住,明明页面 HTML 出来了,但所有静态资源 404,排查半天发现是拦截器把所有请求都拦截了。解决方式是在 spring-mvc.xml 里配置<mvc:exclude-mapping path="/static/**"/>,或者在拦截器注册时指定不拦截静态资源路径。

登录态判断还有一个细节:Ajax 请求和普通页面请求的处理方式不同。普通页面跳转可以直接重定向到登录页,但前端如果用的是 Ajax 调接口,重定向返回的 HTML 就会变成“接口返回登录页 HTML”,前端解析 JSON 直接报错。我当时的做法是拦截器里判断请求头X-Requested-With: XMLHttpRequest,如果是 Ajax 请求,就返回一个 401 状态码,前端收到 401 后统一跳登录页。这个做法后来看其实非常必要。

4.2 发布微博的事务边界:数据库和缓存不能在一个事务里处理

发布微博这个接口,看起来就是插一条记录,实际涉及好几步:

  1. 校验用户登录状态和内容长度;
  2. 插入微博主记录;
  3. 如果带了图片,插入图片关联记录;
  4. 更新该用户的微博总数;
  5. 如果首页有时间线缓存,删除对应用户的缓存键。

数据库相关操作(插入微博、插入图片、更新用户微博数)应该放在同一个事务里,任何一步失败都要回滚。在@Transactional注解上,默认的传播行为是REQUIRED,也就是同一个事务方法里所有 mapper 调用共享一个事务。这里有一个经典坑:同一个 Service 类里的方法互相调用时,@Transactional会失效。原因是 Spring 的事务是基于 AOP 代理实现的,this.doPublish()调用走的是对象本身,而不是被增强过的代理对象。解决办法是把涉及事务的方法拆分到另一个 Service,或者通过AopContext.currentProxy()获取代理对象再调内部方法。

缓存删除这一步要特别留意:Redis 操作不在数据库事务范围内。如果先删缓存、再更新数据库,万一数据库更新失败,缓存被删但数据库没变,下次查询会重新加载旧数据,导致和真实数据不一致;如果先更新数据库、再删缓存,删缓存失败的话,旧缓存还会存在一段时间。我当时选择的是先更新数据库,再删除缓存。为了处理删缓存失败,可以把缓存键名设计成包含微博版本号,每次更新时直接用新版本号覆盖,避免“删不掉”的问题。

4.3 防重复提交:幂等键解决“双击发了两条微博”的尴尬

发布微博最容易出现的问题,不是 SQL 写错,而是用户手快点了两次“发布”按钮。前端确实可以做“提交后按钮置灰”,但用户刷新页面、或者客户端超时重试,还是会重复提交。我最早不知道这个坑,上线后第一周就收到反馈:有人连续发了几条一模一样的微博,内容还是自己没注意到的。

服务端防重比较省事的方法是幂等键。用户进入写微博页面时,后端生成一个唯一的requestId(UUID),前端把这个 ID 隐藏起来,提交时一起带上。后端收到请求后,先检查这个requestId有没有处理过。最简单的方式是在数据库建一张幂等表:

CREATE TABLE request_idempotency ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request_id (request_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

发布微博时,先尝试插入request_id,如果插入成功,说明这是第一次请求,可以继续执行发布逻辑;如果插入失败,说明同样的请求已经处理过,直接返回重复提交提示。这里把“幂等检查”和“数据插入”放在同一个数据库事务里,能保证并发场景下只有一个请求真正生效。这个方案的缺点是多了一张表,但换来的稳定性非常直观,而且思路可以复用到评论、点赞等所有写操作。

5. 文本过滤与图片上传:内容安全模块不能拖到最后再补

5.1 先防 XSS 再防 SQL 注入,顺序别搞反

微博系统的一大特点是“用户生成内容”,意味着用户输入什么,系统就要展示什么。如果不做任何处理,会出现两类严重问题。

第一类是 XSS 跨站脚本攻击。用户在微博内容里输入一段包含<script>的文本,如果前端直接把这个内容渲染进 HTML,浏览器就会执行这段脚本。这可能导致用户 Cookie 被窃取,甚至冒充用户发微博。解决办法是在后端统一做 HTML 转义,把<、>、&、"等字符转换成&lt;、&gt;、&amp;、&quot;。如果用的是 JSP,可以用 JSTL 的<c:out>标签输出内容,它会默认转义,不要用${content}直接输出。前端如果用的是模板引擎,也要留意是否有默认转义机制,避免二次注入。

第二类是 SQL 注入。MyBatis 的#{}参数会被自动转义成 PreparedStatement 的占位符,能有效防注入。但如果你偷懒写了${},它只是简单字符串替换,用户输入就可能被拼进 SQL。我在这个项目里定了一条规则:排序字段、字段名这些动态部分可以走白名单校验,但业务参数一律用#{}。一条评论内容如果用${}拼接,用户输入'; drop table weibo; --,后果不用想也知道。

5.2 一个简单实用的敏感词过滤方案

UGC 产品上线前必须做内容过滤,微博这种公开平台尤其如此。不指望过滤完美,但至少要挡住明显的垃圾内容。我实现的是一个很小的敏感词服务,核心结构是前缀树(Trie)。

词库里主要放的是广告导流词、色情低俗词、人身攻击词和明显不合规的营销内容。把这些词构建成树形结构后,遍历用户输入时,从第一个字符开始,沿着树往下匹配,匹配到叶子节点就说明命中了一个敏感词。命中后直接把命中的部分替换成*,然后继续向后扫描。这个方案的时间复杂度和词库长度无关,只和用户输入长度有关,性能很高。

构建 Trie 的代码在 Java 里不复杂:

public class SensitiveFilter { private final Map<Character, TrieNode> root = new HashMap<>(); // 加载词库后把每个词插入字典树 public String filter(String text) { int length = text.length(); char[] chars = text.toCharArray(); StringBuilder result = new StringBuilder(length); for (int i = 0; i < length; i++) { Map<Character, TrieNode> nodeMap = root; int maxMatch = 0; for (int j = i; j < length; j++) { TrieNode node = nodeMap.get(chars[j]); if (node == null) break; if (node.isEnd()) maxMatch = j - i + 1; nodeMap = node.children; } if (maxMatch > 0) { for (int k = 0; k < maxMatch; k++) { result.append('*'); } i += maxMatch - 1; } else { result.append(chars[i]); } } return result.toString(); } }

入库前过滤一次,展示时再过滤一次。入库前过滤是为了保证存储层干净,后续做统计不会被脏词干扰;展示时过滤是为了防止新增词库后,历史数据自己还没清洗掉。有个小教训:不要只在入库时过滤,否则用户编辑微博、评论时,新旧内容可能会不一致。

5.3 图片上传:本地存储的类型白名单与路径规划

在 SSM 项目里做图片上传,最常规的方案是把图片存到服务器的某个目录,然后通过一个 URL 映射出来。这个方案在单机部署、数据量不大时完全可以跑,注意以下几点就不会出大问题。

上传接口一定不能相信扩展名,因为攻击者可以把恶意文件改成.jpg然后传上来。我在后端做了两层校验:第一层是校验 Content-Type 是不是image/jpeg、image/png、image/gif;第二层是读取文件头字节,也就是所谓的 magic number 校验,比如 JPEG 的文件头是FF D8 FF,PNG 是89 50 4E 47。只校验扩展名和 Content-Type 都不可靠,因为这两者都能被伪造。文件大小也要限制,我定的是单张不超过 5MB。

存储路径规划上,不要把所有图片平铺在一个目录下,文件系统对目录下文件数量有限制。我用的路径格式是/upload/{yyyyMM}/{userId}/{uuid}.jpg,按月份和用户 ID 分目录,方便管理也分散了 IO。文件名用 UUID 重新生成,避免用户原始文件名中的中文和特殊字符导致 URL 编码问题。SpringMVC 需要配置 multipart 解析器,maxUploadSize设置成5 * 1024 * 1024,同时确认 Tomcat 默认的maxSwallowSize足够大,否则上传请求会被 Tomcat 提前打断。

6. 部署到 Tomcat 与压测:这几个坑值得记一下

6.1 从 IDEA 到 Tomcat 的部署经历

SSM 项目最常见的是打成 WAR 包部署到 Tomcat 的 webapps 目录。我最初在 IDEA 里直接配置了 Tomcat 本地启动,开发调试很方便,但真正要部署到服务器时,才发现开发环境的“能跑”和部署环境的“能跑”是两回事。

第一个坑是项目访问路径。如果应用名为m137,Tomcat 的默认访问地址是http://ip:8080/m137/。前端里所有资源路径、表单提交路径都要带上/m137这个 context path,不能写死成绝对路径/login,否则部署到别的环境时一致报 404。我后来统一用 JSTL 的<c:url>生成路径,或者在 JS 里读取一个全局的contextPath变量,彻底解决这个问题。

第二个坑是编码。Windows 本地开发时,控制台和 MySQL 连接串里的编码配置各管各的,只有部署到 Linux 服务器时才暴露出来。页面数据乱码、插入数据库乱码,基本三个地方排查:JSP 页面pageEncoding是否设为 UTF-8、web.xml里 SpringMVC 的 CharacterEncodingFilter 是否配置且排在所有过滤器最前面、JDBC 连接串是否加了characterEncoding=utf-8。这三个缺一不可,顺序也有影响,我之前漏了过滤器顺序,导致表单提交的中文进了数据库就成问号。

6.2 连接池与慢查询:压测暴露出典型的 N+1 问题

用户量上来后,数据库连接池配置不合理,会导致接口偶发性超时。我最早用的是 DBCP 默认配置,逻辑上没问题,但高并发下连接会被占满,新的请求只能等待连接释放。后来换了 Druid,并显式配置了初始连接数和最大连接数:

<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="initialSize" value="5" /> <property name="minIdle" value="5" /> <property name="maxActive" value="50" /> <property name="maxWait" value="10000" /> </bean>

maxWait设为 10 秒,意思是拿不到连接时最多等 10 秒,超过就抛异常而不是无限等待。这个参数很重要,默认无限等待会让用户请求卡死,页面像崩溃了一样,但其实只是连接池满了。

压测时发现的另一个典型问题是 N+1 查询。首页展示微博列表时,每条微博都要查一次发布者的昵称和头像,20 条微博就要查 21 次数据库。数据量小没问题,但并发一起来,这个放大效应非常明显。MyBatis 里可以用 resultMap 的 collection 标签做嵌套查询,但更高效的还是写完主查询后,在 Service 里收集所有需要的用户 ID,再用select * from user where id in (...)一次查出这批用户,然后在内存里组装。我把这个思路叫“批量聚合”,在微博系统里尤其好用。评论列表、点赞头像列表都是同样的问题,别急着加缓存,先把 N+1 干掉,响应时间通常能降一个量级。

6.3 上线后的基础监控项:日常够用的清单

SSM 项目部署完成后,不能只看“页面能打开就行”。我后来总结了几个必看的监控点:

第一是 Tomcat 的 JVM 堆内存。jstat -gc可以看 GC 情况,如果老年代持续增长,多半是某个对象被缓存引用没法回收,内存泄漏的早期信号就是这样。SSM 项目里最容易泄漏 Session 里的对象,所以 Session 超时时间别设太长。

第二是数据库连接池使用率。如果 Druid 监控页面里 active 连接数长期逼近 maxActive,说明连接释放有问题。常见原因是事务方法里某个 Mapper 抛了异常,但连接因为事务未回滚,没能及时归还。

第三是慢 SQL。MySQL 开启慢查询日志,或者定期从 general log 里捞一遍耗时长、扫描行数大的 SQL。我记得时间线接口最开始慢,就是通过慢查询日志定位出来的。日志里的 SQL 不一定写得多差,但扫描行数达到几十万时,接口响应就会明显变慢。

第四是接口错误率。我用最简单的方式:在 SpringMVC 的全局异常处理器里记录错误日志,每天看一眼 ERROR 级别日志量。一个接口如果总是在同一个错误类型上报错,往往就是某个边界条件没考虑,及时修复比事后排查省力得多。

最后一件事,也是我个人在这个项目里体会最深的一点:不要试图把 SSM 替换成某个更“高级”的架构,再回头写这个微博系统。恰恰是这种老框架,要求你手动处理事务、拦截器、连接池、分页这些底层环节,你才能把一个 Web 应用真正的运行机制摸明白。现在做新项目当然可以上 Spring Boot,但如果你手里恰好有一个带编号的老项目叫 m137,别急着删,把它拆开重写一遍,可能比我写这篇复盘收获更大。

返回列表