
前一阵整理工作目录翻出一个老项目代号 m137——一个基于 SSM 框架的微博系统。这个项目放在今天看技术栈确实旧了不少但应用场景非常典型注册登录、发微博、关注/粉丝、评论点赞转发几乎把社交产品的基础玩法都覆盖了一遍。它尤其适合用来理解 Java Web 应用的完整链路从表结构到接口设计从登录态到部署上线中间那些坑我到现在都记得挺清楚。这篇文章不是要把代码逐行贴一遍而是以复盘的方式讲清楚为什么当时选 SSM 而不是更“新”的框架数据模型怎么设计才能支撑微博这种关系链产品时间线接口为什么越翻越慢发布微博的事务边界怎么划以及部署 Tomcat 时会遇到哪些问题。如果你正在做一个课程设计、毕业设计或者接手了一个老的 SSM 维护项目应该能直接拿来参考。1. 微博系统选型为什么 m137 用了 SSM 而非直接上 Spring Boot1.1 先把微博系统的需求拆成一张清单很多人在动手写代码之前根本没把需求想清楚。微博系统看着简单但“关注驱动内容”这个模型一旦漏掉一个环节后期返工成本非常高。我当时把功能拆成四层第一层是账号体系包括注册、登录、退出、个人资料修改。登录状态是所有操作的前提发微博、评论、点赞都要先知道“当前用户是谁”。第二层是内容生产与消费注册用户能发文字微博也能带图片首页要按时间倒序展示自己关注的人的微博还要支持点进某个人的主页看他发过的全部微博。第三层是社交关系关注、取关、粉丝列表、关注列表。注意这里有一个容易被忽略的点查看某个用户主页时要显示“当前登录用户是否已经关注他”这决定了页面上按钮是“关注”还是“已关注”。第四层是互动行为评论、点赞、转发。互动数据要能实时展示在每条微博下面。至于私信、话题、热搜这些我当时只做了最基础的版本属于额外加分项。把这些列完之后问题就变成了技术选型。为什么是 SSM原因并不是 SSM 比其他方案好而是当时的约束条件决定的项目成员对 Spring SpringMVC MyBatis 这套组合最熟参考资料最多环境里跑得最稳的就是 JDK8、Tomcat8、MySQL5.7 的组合。Spring Boot 当时虽然有但团队里没人真正在生产项目里用过与其边学新框架边调 bug不如用已知能跑通的技术一路做下去。1.2 SSM 框架分工与版本搭配的一个稳妥建议SSM 三件套的分工一句话就能讲清Spring 负责对象生命周期和依赖注入SpringMVC 负责 HTTP 请求的分发从 Controller 到 Service 再到页面或 JSONMyBatis 负责 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.2MySQL5.7建表用 InnoDButf8mb4Maven3.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 关注用户 BA 的首页就要看到 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个容易被忽略的点。第一唯一索引(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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;查询某条微博的点赞总数最准确的方式是select count(*)但同一时间可能有多条微博在展示每条微博都做一次 countN1 问题就来了。我当时用了冗余字段方案在微博表里加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就不能代表时间。但微博系统里普通用户没有置顶能力这个方案足够简洁。另外首页第一次进入时没有lastWeiboIdSQL 就不加这个条件直接取第一页。这个接口从 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 发布微博的事务边界数据库和缓存不能在一个事务里处理发布微博这个接口看起来就是插一条记录实际涉及好几步校验用户登录状态和内容长度插入微博主记录如果带了图片插入图片关联记录更新该用户的微博总数如果首页有时间线缓存删除对应用户的缓存键。数据库相关操作插入微博、插入图片、更新用户微博数应该放在同一个事务里任何一步失败都要回滚。在Transactional注解上默认的传播行为是REQUIRED也就是同一个事务方法里所有 mapper 调用共享一个事务。这里有一个经典坑同一个 Service 类里的方法互相调用时Transactional会失效。原因是 Spring 的事务是基于 AOP 代理实现的this.doPublish()调用走的是对象本身而不是被增强过的代理对象。解决办法是把涉及事务的方法拆分到另一个 Service或者通过AopContext.currentProxy()获取代理对象再调内部方法。缓存删除这一步要特别留意Redis 操作不在数据库事务范围内。如果先删缓存、再更新数据库万一数据库更新失败缓存被删但数据库没变下次查询会重新加载旧数据导致和真实数据不一致如果先更新数据库、再删缓存删缓存失败的话旧缓存还会存在一段时间。我当时选择的是先更新数据库再删除缓存。为了处理删缓存失败可以把缓存键名设计成包含微博版本号每次更新时直接用新版本号覆盖避免“删不掉”的问题。4.3 防重复提交幂等键解决“双击发了两条微博”的尴尬发布微博最容易出现的问题不是 SQL 写错而是用户手快点了两次“发布”按钮。前端确实可以做“提交后按钮置灰”但用户刷新页面、或者客户端超时重试还是会重复提交。我最早不知道这个坑上线后第一周就收到反馈有人连续发了几条一模一样的微博内容还是自己没注意到的。服务端防重比较省事的方法是幂等键。用户进入写微博页面时后端生成一个唯一的requestIdUUID前端把这个 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;发布微博时先尝试插入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 MapCharacter, 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) { MapCharacter, 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 FFPNG 是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 本地启动开发调试很方便但真正要部署到服务器时才发现开发环境的“能跑”和部署环境的“能跑”是两回事。第一个坑是项目访问路径。如果应用名为m137Tomcat 的默认访问地址是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 连接串是否加了characterEncodingutf-8。这三个缺一不可顺序也有影响我之前漏了过滤器顺序导致表单提交的中文进了数据库就成问号。6.2 连接池与慢查询压测暴露出典型的 N1 问题用户量上来后数据库连接池配置不合理会导致接口偶发性超时。我最早用的是 DBCP 默认配置逻辑上没问题但高并发下连接会被占满新的请求只能等待连接释放。后来换了 Druid并显式配置了初始连接数和最大连接数bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property nameinitialSize value5 / property nameminIdle value5 / property namemaxActive value50 / property namemaxWait value10000 / /beanmaxWait设为 10 秒意思是拿不到连接时最多等 10 秒超过就抛异常而不是无限等待。这个参数很重要默认无限等待会让用户请求卡死页面像崩溃了一样但其实只是连接池满了。压测时发现的另一个典型问题是 N1 查询。首页展示微博列表时每条微博都要查一次发布者的昵称和头像20 条微博就要查 21 次数据库。数据量小没问题但并发一起来这个放大效应非常明显。MyBatis 里可以用 resultMap 的 collection 标签做嵌套查询但更高效的还是写完主查询后在 Service 里收集所有需要的用户 ID再用select * from user where id in (...)一次查出这批用户然后在内存里组装。我把这个思路叫“批量聚合”在微博系统里尤其好用。评论列表、点赞头像列表都是同样的问题别急着加缓存先把 N1 干掉响应时间通常能降一个量级。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别急着删把它拆开重写一遍可能比我写这篇复盘收获更大。