简介本资源是一套完整的基于SSMSpringSpringMVCMyBatis框架开发的Java图书管理系统实战项目面向Java初学者及Web开发进阶学习者旨在帮助开发者掌握企业级MVC架构设计、数据库交互与前后端协同开发全流程。压缩包共714个文件涵盖80个Java源码、66个XML配置含Spring/MyBatis核心配置、50个JSP页面、82个JS脚本、30个CSS/SCSS/LESS样式文件、27个Jar依赖库及1个SQL建库脚本完整呈现从数据库建表、实体映射、Service业务逻辑到Controller接口与前端视图的全链路实现。资源包大小为38.88MB结构清晰含典型控制器类如LendListController、ReaderInfoController、服务实现类LendListServiceImpl及分层配置便于逐层理解SSM整合机制。已有5242人学习下载适合用于课程设计、毕业设计参考或SSM框架专项实训可直接导入IntelliJ IDEAJDK1.8 MySQL 5.7运行调试。1. 为什么今天还要从零手写一个SSM图书管理系统你可能刚刷完某招聘平台的Java后端岗位JD里面清一色写着“熟悉SSM框架”——但点开具体要求又全是“有Spring Boot项目经验优先”“熟悉微服务架构者加分”。于是你心里打了个问号SSM是不是已经过时了学它还有没有实际价值我去年带三个实习生做毕设时就遇到过类似困惑。其中一位同学直接跳过SSM用Spring Boot搭了个图书管理后台功能跑通了但部署到学校实训服务器上时连登录接口都反复超时另一位同学老老实实按《Spring实战》第三版的节奏用XML配置MyBatis原生SQL手写DAO层结果在“借阅记录导出Excel”功能里卡了三天——不是逻辑错而是事务传播行为没搞清导致导出中途失败时已生成的临时文件没被清理磁盘空间悄无声息地满了。这恰恰说明SSM不是过时而是被“简化”得过于彻底了。Spring Boot自动装配把DataSource、SqlSessionFactory这些Bean藏得太深新手根本看不到事务边界在哪、连接池参数怎么调、SQL执行计划如何分析。而图书管理系统这个经典场景恰恰是检验你是否真正理解“框架之下发生了什么”的试金石——它不追求高并发但要求数据强一致性比如还书时必须同步更新库存和借阅状态、操作可追溯每条借阅记录要留操作人、时间、IP、权限清晰隔离管理员能删书普通用户只能查和借。这些需求放在SSM里每一处都要你亲手配置、亲手验证、亲手调试。它不炫技但逼你直面Java Web开发最底层的契约HTTP请求怎么变成Service方法调用SQL执行异常如何回滚JSP页面里的EL表达式到底从哪取值这种“看得见摸得着”的控制感是任何脚手架都给不了的。所以这篇不是教你怎么快速跑通Demo而是带你回到2014年那个还没有Spring Boot的年代用最原始的方式把SSM三层架构的每一根线头都理清楚。你会看到为什么Controller里不能直接new Service实例为什么MyBatis的Mapper XML里要用if testtitle ! null and title ! 而不是if testtitle ! null为什么Tomcat启动日志里那行Initializing Spring root WebApplicationContext之后紧接着就是Loading XML bean definitions from class path resource [applicationContext.xml]这些细节不是八股文而是你未来排查“事务不生效”“SQL注入漏洞”“N1查询”问题时真正能救命的线索。2. SSM三件套的真实分工不是简单拼凑而是责任契约很多人把SSM当成三个独立工具硬塞进一个项目Spring管BeanSpringMVC管页面跳转MyBatis管数据库。这就像说“汽车由发动机、方向盘和轮胎组成”——没错但没说清它们怎么协同让车跑起来。在图书管理系统里这三者的协作是一套精密的责任契约拆开看2.1 Spring不是“容器”而是“契约协调员”Spring的核心职责从来不是创建对象而是定义对象之间的依赖关系与生命周期规则。在applicationContext.xml里你写的不是“我要一个BookService”而是“当BookService被需要时请按以下规则准备它它的bookDao属性必须注入id为‘bookDao’的Bean它自己必须在Web应用启动时就初始化如果它抛出RuntimeException整个事务必须回滚”。这个契约体现在三个关键配置上事务管理器绑定tx:annotation-driven transaction-managertransactionManager/这行代码的潜台词是“所有加了Transactional的方法都必须走我指定的transactionManager来开启/提交/回滚事务”。而这个transactionManager本身又绑定了dataSource——也就是说事务的边界最终由数据库连接池决定。如果你换掉Druid连接池却忘了改transactionManager的dataSource引用事务就会静默失效。Bean作用域控制bean idbookService classcom.example.service.impl.BookServiceImpl scopesingleton/。这里singletion不是指“全局唯一”而是指“在Spring容器生命周期内只创建一次实例”。但要注意这个实例是线程不安全的。BookServiceImpl里如果有private ListBook cache new ArrayList();这样的成员变量多个HTTP请求并发调用时这个cache会被所有线程共享导致数据错乱。所以真正的线程安全靠的是“无状态设计”——Service类里只放方法不放可变状态。AOP切面织入点aop:config块里定义的aop:pointcut本质是在告诉Spring“当执行com.example.service.*.*包下所有public方法时请把事务切面插进去”。这个切点表达式必须精确匹配你的Service类路径。如果写成com.example.service..*多了两个点它会匹配到com.example.service.util.Helper这类工具类而Helper里根本没有事务注解反而引发代理失败。2.2 SpringMVC不是“控制器”而是“请求路由中枢”SpringMVC的DispatcherServlet远不止是个分发器。它是一条完整的HTTP请求处理流水线每个环节都可插拔。以图书管理系统中的“搜索图书”请求为例GET/book/search?titleJavaauthorHandlerMapping定位处理器根据RequestMapping(/book/search)找到BookController.search()方法HandlerAdapter适配参数把URL参数titleJava自动转换成String类型注入到search()方法的String title形参中同时它还会检查RequestParam(requiredfalse)注解决定author参数为空时是否报错HandlerInterceptor预处理如果你配置了登录拦截器它会在调用search()前执行preHandle()检查session里是否有user_id。这里有个坑preHandle()返回false时后续流程直接中断但afterCompletion()不会触发——所以清理资源的操作必须放在afterConcurrentHandlingStarted()里ViewResolver视图解析search()返回book/list字符串ViewResolver会把它拼成/WEB-INF/jsp/book/list.jsp路径。如果JSP文件实际放在/WEB-INF/views/book/list.jsp就必须改ViewResolver的prefix属性否则404。最关键的细节在于数据绑定。当用户提交表单新增图书时HTML里写input namepublishDate value2023-01-01SpringMVC默认用java.text.SimpleDateFormat解析但它的默认格式是yyyy-MM-dd HH:mm:ss。如果用户只输日期publishDate字段就会绑定失败返回400错误。解决方案不是改前端而是加InitBinder方法InitBinder public void initBinder(WebDataBinder binder) { SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd); sdf.setLenient(false); binder.registerCustomEditor(Date.class, new CustomDateEditor(sdf, true)); }这段代码的含义是“对所有Date类型的参数用yyyy-MM-dd格式解析且严格校验setLenient(false)”。没有它用户输入2023-02-30也会被强行转成2023-03-02数据就错了。2.3 MyBatis不是“ORM”而是“SQL执行引擎”MyBatis常被误认为是Hibernate那样的全自动ORM其实它是半自动的SQL执行引擎。它的核心价值在于让你完全掌控SQL同时避免JDBC模板代码的重复。在图书管理系统里这体现在三个层面动态SQL的边界意识where标签不是万能的。比如搜索条件里有price ? AND price ?如果两个价格都为空where会删掉整个WHERE子句结果查出全表。但如果你写成where if testminPrice ! nullAND price gt; #{minPrice}/if if testmaxPrice ! nullAND price lt; #{maxPrice}/if /where当minPricenull时if块不渲染AND就不会多出来。但注意#{}是预编译占位符$是字符串拼接——ORDER BY ${sortField}可以但WHERE title LIKE %${keyword}%就有SQL注入风险必须用CONCAT(%, #{keyword}, %)。ResultMap的嵌套映射图书表book和作者表author是多对一关系。如果用resultMap手动映射可以这样写resultMap idBookWithAuthor typeBook id propertyid columnbook_id/ result propertytitle columntitle/ association propertyauthor javaTypeAuthor id propertyid columnauthor_id/ result propertyname columnauthor_name/ /association /resultMap这里association的column必须是SQL查询里SELECT出来的别名如a.id as author_id而不是数据库原字段名。如果漏写as author_idMyBatis找不到对应列author对象就是null。一级缓存的陷阱同一个SqlSession里两次查同一本书第二次直接从缓存取不走数据库。这听起来很好但如果你在Service里手动sqlSession.close()再开新Session缓存就失效了。更危险的是缓存是基于SQL语句和参数的哈希值如果第一次查SELECT * FROM book WHERE id ?第二次查SELECT id,title FROM book WHERE id ?哪怕参数相同也是两个缓存键——数据不一致风险陡增。所以生产环境要么关掉一级缓存setting namelocalCacheScope valueSTATEMENT/要么确保所有查询都走二级缓存需配置cache/标签。3. 图书管理系统的核心业务闭环从借书到还书的七步链路一个合格的图书管理系统绝不是CRUD堆砌。它的灵魂在于业务状态的流转可控。以“学生张三借阅《深入理解Java虚拟机》”为例完整链路如下3.1 前置校验三道防火墙库存校验bookMapper.selectStockById(bookId)查当前库存。注意这里必须用SELECT stock FROM book WHERE id ? FOR UPDATE加行锁否则高并发时可能出现超卖。MyBatis里写成select idselectStockForUpdate resultTypeint parameterTypelong SELECT stock FROM book WHERE id #{id} FOR UPDATE /select用户资格校验查userMapper.selectByUserId(userId)确认该用户状态为ACTIVE且未被禁用借阅限额校验borrowRecordMapper.countByUserIdAndStatus(userId, BORROWING)统计当前未还数量。如果超过5本直接拒绝。这三步必须在一个数据库事务里完成否则校验通过后库存被别人抢走就出问题了。3.2 核心操作原子性更新事务内执行四条SQLUPDATE book SET stock stock - 1 WHERE id #{bookId} AND stock 0扣减库存AND stock 0是乐观锁防止超卖INSERT INTO borrow_record (book_id, user_id, borrow_time, status) VALUES (#{bookId}, #{userId}, NOW(), BORROWING)插入借阅记录UPDATE user SET borrow_count borrow_count 1 WHERE id #{userId}更新用户借阅计数INSERT INTO operation_log (operator_id, target_type, target_id, action, ip) VALUES (#{userId}, BOOK, #{bookId}, BORROW, #{ip})记录操作日志。关键点在于这四条SQL必须全部成功否则全部回滚。如果第三条UPDATE user因borrow_count字段长度不够比如定义为TINYINT最大127而失败前两条SQL也必须撤销——这就是Transactional的意义。3.3 状态同步跨表一致性保障还书时不仅要更新borrow_record.status RETURNED还要UPDATE book SET stock stock 1 WHERE id #{bookId}归还库存UPDATE user SET borrow_count borrow_count - 1 WHERE id #{userId}减少用户计数INSERT INTO notification (user_id, content, type) VALUES (#{userId}, 您的图书《XXX》已成功归还, RETURN)发通知。这里最容易出错的是通知延迟。如果通知表用的是MySQL而业务库用的是Oracle跨库事务无法保证原子性。解决方案是把通知内容写入本地notification表同库再由单独的定时任务扫描未发送的通知调用邮件/短信API。这样即使API挂了通知也不会丢。3.4 异常兜底事务回滚后的脏数据清理理想很丰满现实很骨感。假设在还书事务中UPDATE book成功UPDATE user也成功但最后INSERT INTO notification因网络超时失败。此时事务回滚book和user的变更都会撤销。但如果你在catch块里写了log.error(通知发送失败, e)却没做任何补偿用户就收不到还书成功提示。正确做法是try { // 执行事务内所有SQL notificationService.sendReturnNotice(userId, bookTitle); } catch (Exception e) { // 记录失败日志 log.warn(还书通知发送失败bookId{}, userId{}, bookId, userId, e); // 插入重试队列 retryQueue.insert(new RetryTask(NOTIFY_RETURN, bookId, userId, System.currentTimeMillis())); }这个retryQueue可以是Redis的List也可以是数据库的retry_task表。定时任务每5分钟扫一次最多重试3次失败则告警。4. 部署上线前的十二个致命检查点SSM项目本地跑通不等于能上线。我在某高校图书馆系统上线前就因为漏查一个检查点导致全校师生无法登录。以下是必须逐项核验的清单4.1 数据库连接池Druid的隐藏开关很多教程只教property nameurl valuejdbc:mysql://localhost:3306/bookdb/却忽略Druid的testWhileIdle和timeBetweenEvictionRunsMillis。如果这两个参数没配连接池里的空闲连接会因MySQL的wait_timeout默认8小时被服务端主动断开而Druid不知道继续把断开的连接分配给业务结果就是Communications link failure。正确配置property nametestWhileIdle valuetrue/ property nametimeBetweenEvictionRunsMillis value60000/ property namevalidationQuery valueSELECT 1/validationQuery必须是轻量级SQLSELECT 1比SELECT COUNT(*) FROM book快100倍。4.2 日志隔离Log4j的包冲突SSM项目常引入log4j-1.2.17.jar但Tomcat自带tomcat-juli.jar也含Log4j类。如果版本不一致会出现No appenders could be found for logger。解决方案在web.xml里强制指定日志实现context-param param-namelog4jConfigLocation/param-name param-valueclasspath:log4j.properties/param-value /context-param listener listener-classorg.springframework.web.util.Log4jConfigListener/listener-class /listener同时log4j.properties里必须关闭rootLoggerlog4j.rootLoggerOFF log4j.logger.com.exampleDEBUG, stdout否则所有第三方库的日志都会刷屏。4.3 JSP编译Tomcat的EL表达式开关JSP里写${book.title}如果Tomcat版本低于7.0.2需要在web.xml里显式启用ELjsp-config jsp-property-group url-pattern*.jsp/url-pattern el-enabledtrue/el-enabled /jsp-property-group /jsp-config否则页面显示空白控制台也没报错排查起来极耗时间。4.4 文件上传Commons FileUpload的阈值陷阱图书封面上传用input typefile namecover后端用MultipartHttpServletRequest接收。但如果不配置CommonsMultipartResolver的maxUploadSize默认只允许100KB大一点的图片就报SizeLimitExceededException。配置示例bean idmultipartResolver classorg.springframework.web.multipart.commons.CommonsMultipartResolver property namemaxUploadSize value10485760/ !-- 10MB -- property namedefaultEncoding valueUTF-8/ /bean注意maxUploadSize单位是字节不是MB。写成10*1024*1024比10485760更不易出错。4.5 编码统一三个地方的UTF-8数据库连接URLjdbc:mysql://localhost:3306/bookdb?useUnicodetruecharacterEncodingUTF-8JSP页面% page contentTypetext/html;charsetUTF-8 %Tomcat server.xmlConnector port8080 protocolHTTP/1.1 URIEncodingUTF-8 /漏掉任意一个中文就会变乱码。最隐蔽的是URI编码——用户在浏览器地址栏输入/book/search?title算法导论如果URIEncoding没设Tomcat收到的就是%E7%AE%97%E6%B3%95%E5%AF%BC%E8%AE%BASpringMVC默认用ISO-8859-1解码结果就是算法导论。4.6 权限控制Shiro的Session超时陷阱如果用Shiro做权限shiro.ini里配置sessionManager.globalSessionTimeout 180000030分钟但前端Ajax请求没处理401 Unauthorized用户操作时突然发现按钮失效还以为系统卡了。必须在JS里全局监听$(document).ajaxError(function(event, xhr, settings) { if (xhr.status 401) { alert(登录已过期请重新登录); window.location.href /login.jsp; } });4.7 SQL防注入MyBatis的#与$选择所有用户输入的参数必须用#{}。比如搜索框输入title OR 11用#{title}会变成WHERE title \ OR \1\\1安全用${title}就变成WHERE title OR 11直接绕过校验。唯一允许用$的场景是动态表名或列名如SELECT * FROM ${tableName}但此时tableName必须来自白名单枚举绝不能由用户输入。4.8 分页性能LIMIT OFFSET的百万级陷阱MyBatis分页插件常用LIMIT 10 OFFSET 10000但当OFFSET超过10万MySQL会先扫描10万行再取10行响应时间秒变几秒。优化方案用游标分页。比如按id升序第一页查SELECT * FROM book ORDER BY id LIMIT 10记住最后一条的id100第二页查SELECT * FROM book WHERE id 100 ORDER BY id LIMIT 10。这样永远只查10行性能恒定。4.9 静态资源Web.xml的default servlet覆盖CSS/JS文件放在/static/css/app.css但Tomcat的default servlet默认只处理/static/**如果web.xml里写了servlet-mappingurl-pattern//url-pattern会覆盖default servlet导致静态资源404。解决方案在web.xml里明确声明default servletservlet-mapping servlet-namedefault/servlet-name url-pattern*.css/url-pattern /servlet-mapping servlet-mapping servlet-namedefault/servlet-name url-pattern*.js/url-pattern /servlet-mapping4.10 错误页面HTTP状态码的精准映射web.xml里只配error-pageerror-code404/error-codelocation/404.jsp/location/error-page不够。还要配500、403甚至自定义error-pageexception-typejava.lang.NullPointerException/exception-typelocation/error.jsp/location/error-page。否则500错误直接显示Tomcat黄页暴露服务器信息。4.11 JVM参数堆内存与GC日志Tomcat启动脚本catalina.sh里必须设置JAVA_OPTS-Xms512m -Xmx1024m -XX:PrintGCDetails -XX:PrintGCTimeStamps -Xloggc:/opt/tomcat/logs/gc.log-Xms和-Xmx设成一样避免运行时扩容GC日志是排查内存泄漏的唯一依据。没它OOM时你只能干瞪眼。4.12 备份策略数据库与配置文件双备份数据库每天凌晨2点mysqldump -u root -p bookdb /backup/bookdb_$(date %Y%m%d).sqlapplicationContext.xml、web.xml等配置文件每次上线前cp *.xml /backup/config_$(date %Y%m%d)/曾有个项目因运维误删applicationContext.xml没备份只能从Git历史里翻耽误了3小时。从此我坚持配置即代码备份比代码还重要。5. 从SSM到Spring Boot迁移时必须重写的五个模块当你决定把SSM图书管理系统升级到Spring Boot别幻想“一键迁移”。我帮三个团队做过迁移发现以下模块必须重写不能简单复制5.1 数据源配置从XML到Properties的语义丢失SSM里applicationContext.xml写bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ property nameinitialSize value5/ /beanSpring Boot的application.yml看似简洁spring: datasource: url: ${jdbc.url} username: ${jdbc.username} password: ${jdbc.password} druid: initial-size: 5但问题在于druid.initial-size在Spring Boot Starter里默认是0而SSM里是5。如果没显式配置连接池启动时一个连接都不建首次请求就会卡住。必须补全所有Druid参数spring: datasource: druid: initial-size: 5 min-idle: 5 max-active: 20 test-while-idle: true time-between-eviction-runs-millis: 60000 validation-query: SELECT 15.2 事务管理从XML声明到注解的传播陷阱SSM里tx:annotation-driven/全局生效所有Transactional方法都走同一个事务管理器。Spring Boot里如果你用了多数据源Transactional默认只对主数据源生效。比如图书管理系统要连MySQL主和Redis辅Transactional不会管Redis操作。必须显式指定Transactional(transactionManager mysqlTransactionManager) public void borrowBook(Long bookId, Long userId) { // MySQL操作 redisTemplate.opsForValue().set(book: bookId, BORROWING); // 这行不在事务里 }Redis操作必须用RedisTransactionManager单独管理或者干脆放弃Redis事务用Lua脚本保证原子性。5.3 MyBatis映射从XML到注解的动态SQL退化SSM里BookMapper.xml的where、foreach用得飞起update idbatchUpdateStock UPDATE book set foreach collectionbooks itembook separator, stock CASE id foreach collectionbooks itemb WHEN #{b.id} THEN #{b.stock} /foreach END /set WHERE id IN foreach collectionbooks itembook open( close) separator, #{book.id} /foreach /updateSpring Boot里用UpdateProvider写动态SQL代码量翻倍可读性下降UpdateProvider(type BookSqlProvider.class, method batchUpdateStock) void batchUpdateStock(Param(books) ListBook books);而BookSqlProvider.batchUpdateStock()方法里要手拼SQL字符串。一旦拼错编译不报错运行时报SQLException。所以迁移时建议保留XML用MapperScan指定路径而不是强行转注解。5.4 JSP视图从Servlet容器到Thymeleaf的模板重构SSM的JSP里大量用c:forEach、fmt:formatDate迁移到Spring Boot后Thymeleaf语法完全不同JSP:c:forEach items${bookList} varbookThymeleaf:div th:eachbook : ${bookList}更麻烦的是EL表达式。JSP里${book.author.name}Thymeleaf里要写${book.author?.name}?表示安全导航防NPE。如果author为nullJSP会输出空字符串Thymeleaf会报错。必须全局加spring.thymeleaf.modeHTML并确保所有对象属性都有默认值。5.5 日志集成从Log4j到Logback的Appender重配SSM用Log4jSpring Boot默认Logback。log4j.properties里的log4j.appender.file.File/var/log/bookapp.log在Logback里要写成appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender file/var/log/bookapp.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern/var/log/bookapp.%d{yyyy-MM-dd}.%i.log/fileNamePattern /rollingPolicy /appender关键是TimeBasedRollingPolicy的fileNamePattern必须带%d{yyyy-MM-dd}和%i索引否则日志不会按天滚动。漏配%i第二天日志会覆盖第一天的文件。6. 我踩过的最深的三个坑血泪换来的经验最后分享三个让我连续熬了三个通宵才解决的坑都是线上真实事故现在想起来还头皮发麻。6.1 事务失效Service方法自调用的隐形杀手图书管理系统有个BookService.borrowBook()方法里面调用了this.updateStock()。代码看着没问题Service public class BookServiceImpl implements BookService { Transactional public void borrowBook(Long bookId, Long userId) { // 校验... this.updateStock(bookId); // 自调用 } Transactional public void updateStock(Long bookId) { // 扣库存SQL } }结果发现updateStock()里的SQL不走事务——因为Spring的事务代理是基于接口的JDK动态代理this.updateStock()是内部方法调用绕过了代理事务注解失效。解决方案只有两个一是把updateStock()提到另一个Service里用Autowired注入调用二是用AopContext.currentProxy()强制走代理((BookService) AopContext.currentProxy()).updateStock(bookId);但后者需要在EnableAspectJAutoProxy(exposeProxy true)且侵入性强。我后来统一改成第一种拆分Service让每个方法职责单一。6.2 N1查询MyBatis懒加载的温柔陷阱图书列表页要显示每本书的作者名Book实体里有private Author author;BookMapper.xml里写了resultMap idBookWithAuthor typeBook association propertyauthor javaTypeAuthor selectselectAuthorById columnauthor_id/ /resultMapselectAuthorById是另一个查询。表面看很优雅但一页查20本书就会触发21次SQL1次主查询20次关联查询。线上QPS一上去数据库CPU直接100%。解决方案是改用JOIN查询一次性查出所有数据resultMap idBookWithAuthor typeBook id propertyid columnbook_id/ result propertytitle columntitle/ association propertyauthor javaTypeAuthor id propertyid columnauthor_id/ result propertyname columnauthor_name/ /association /resultMap select idselectBooksWithAuthor resultMapBookWithAuthor SELECT b.id as book_id, b.title, a.id as author_id, a.name as author_name FROM book b LEFT JOIN author a ON b.author_id a.id /select虽然SQL变长了但性能提升十倍。6.3 Session共享集群部署下的登录态丢失系统上线后运维加了两台Tomcat做负载均衡。结果用户登录后点两下菜单就跳回登录页。抓包发现第一次请求JSESSIONIDABC123第二次变成JSESSIONIDDEF456。原因是Tomcat默认用内存存Session两台机器Session不共享。解决方案有三个粘性SessionNginx配置ip_hash同一IP总路由到同一台Tomcat。简单但单点故障Redis Session用spring-session-data-redis把Session存Redis。但要注意Book、User等实体类必须实现Serializable否则存不进去JWT Token彻底抛弃Session登录后发JWT前端每次请求带Authorization: Bearer xxx。这是终极方案但需要重写所有权限校验逻辑。我选了第二种但踩了个坑Redis序列化用的是JdkSerializationRedisSerializer体积大、性能差。换成GenericJackson2JsonRedisSerializer序列化后体积小50%QPS提升30%。这三个坑每一个都让我深刻体会到SSM不是过时的技术而是照妖镜——它把Java Web开发里所有隐性的契约、边界、陷阱赤裸裸地摊在你面前。学透它不是为了停留在过去而是为了在未来面对任何新框架时一眼看穿它的抽象之下究竟在替你做了什么又隐藏了什么。本文还有配套的精品资源点击获取