SSM模拟ATM实战:Spring事务、MyBatis动态SQL与拦截器全解析
简介这是一份面向 Java Web 初学者的 SSM 模拟 ATM 教学项目资源以 Spring SpringMVC MyBatis 三大框架整合为核心围绕 ATM 场景搭建了用户登录、余额查询、存款、取款、转账等完整功能并涉及用户模块、交易模块、安全校验、日志记录和异常处理等实际开发环节适合通过完整案例学习 SSM 集成与分层开发的开发者。压缩包共 83 个文件大小约 15.98MB主要包含 39 个 jar 依赖包、16 个 xml 配置文件、6 个 java 源码、6 个 class 文件、5 个 jsp 页面以及 properties、classpath、project 等工程配置文件jar 包提供运行依赖xml 和 properties 负责 Spring、MyBatis 与 Web 容器配置java 源码和 class 对应项目核心逻辑jsp 页面覆盖登录、存取款与信息展示界面目录结构完整便于按模块拆解学习。已有 386 人学习下载。配套内容还提供数据库建表 SQL、Eclipse 工程配置和 JSP 视图示例可帮助读者理清 MVC 各层职责掌握从 Mapper 编写、Service 业务处理到 Controller 接收请求的完整调用链。项目既是 SSM 入门练手案例也可作为课程设计或毕业设计的参考模板。1. SSM模拟ATM能跑通事务、能看清框架原理的 Java Web 练手项目你先别因为“模拟 ATM”这个标题就把它当课程设计交差用的花架子。这个项目真正值得拆的是它把 ATM 的核心链路做成了完整闭环登录、查询余额、取款、转账、流水查询而且三个框架各自守的位置非常正——Spring 管事务和组件装配SpringMVC 管请求分发和登录拦截MyBatis 管参数映射和动态 SQL。我第一次拿到这套 SSM 模拟 ATM 代码时第一反应是找它有没有上分布式锁那种花架子结果没有反而老老实实用数据库行锁 声明式事务把并发扣款问题兜住了这恰是大多数企业后台的真实写法。它适合刚学完 SSM 整合、想靠断点把一次请求从进入 Tomcat 到 SQL 返回全程看清的人也适合准备课程设计前想找一个不注水、不大而全的实战范本的人。2. 架构与技术选型为什么 SSM 到 2024 年还是值得手写一遍先泼一盆冷水给只玩过 Spring Boot 的人Boot 并不是比 SSM 多多少功能它只是把配置收编成 starter 和自动装配。你直接从 Boot 入手碰到“事务为什么没回滚”“Mapper 为什么没扫到”这类问题定位起来会非常吃力因为底层机制被自动配置挡了两层壳。这个项目刻意不引 Boot用 applicationContext.xml、spring-mvc.xml、mybatis-config.xml 手工装配反而给你留了一面能看清五脏六腑的后视镜。如果你打算把这个项目作为简历里的练手项目别急着加功能先把这条架构链路理顺后面改起来会快很多。2.1 技术栈分析Spring、SpringMVC、MyBatis 各管哪一段项目整体分成四层表现层、业务层、持久层、基础设施。表现层用 SpringMVC核心入口是 DispatcherServletController 返回结果分两类——登录页、主菜单这类走 JSP 视图渲染查询余额、取款结果这类走 ResponseBody 返回 JSON。业务层是 Spring 的天下所有 Bean 的声明周期和依赖注入都交给容器同时基于 AOP 的声明式事务也在这层生效。持久层用 MyBatisMapper 接口加 XML 文件把 SQL 和 Java 方法解耦动态 SQL 都在 XML 里拼。各层依赖方向是单向的controller 调 serviceservice 调 mapper反过来不允许。我注意到这个项目里 service 接口单独拆出来impl 包放实现类目的有两个一是方便 Spring 生成代理对象二是写单元测试时能用 Mockito 直接替换实现。很多学生项目图省事把接口和实现类合并表面看没什么但一旦要加事务切面或者做远程调用替换就会开始后悔。用一张表把职责边界列清楚层次使用技术核心职责表现层SpringMVC请求映射、参数绑定、拦截器、JSON 序列化业务层Spring组件装配、事务边界、业务规则校验持久层MyBatisSQL 执行、动态条件拼装、结果集映射基础设施Maven MySQL Tomcat依赖管理、数据存储、容器运行现在你打开代码应该能明确知道某个 SQL 写错了去 resources/mapper 目录找 XML某个 Bean 没有注入去 service 和 controller 的注解上找请求进不来去 web.xml 和 spring-mvc.xml 找 DispatcherServlet 的映射配置。这就是手写 SSM 相比 Boot 最大的好处——每一层都看得见摸得着。2.2 工程目录结构从 controller 到 mapper 的排查路径这是一个标准的 Maven 单模块工程打 war 包。包名以 com.example.atm 开头按职责分包而不是按页面分包找人最快。完整的结构大致如下ssm-atm/ ├── pom.xml ├── src/main/java/com/example/atm/ │ ├── controller/ │ │ ├── LoginController.java │ │ ├── AccountController.java │ │ └── TransactionController.java │ ├── service/ │ │ ├── AccountService.java │ │ └── impl/AccountServiceImpl.java │ ├── mapper/ │ │ ├── CardMapper.java │ │ └── TransactionMapper.java │ ├── entity/ │ │ ├── CardInfo.java │ │ ├── Transaction.java │ │ └── UserInfo.java │ ├── common/ │ │ └── Result.java │ ├── exception/ │ │ └── BizException.java │ └── interceptor/ │ └── LoginInterceptor.java ├── src/main/resources/ │ ├── applicationContext.xml │ ├── spring-mvc.xml │ ├── mybatis-config.xml │ ├── jdbc.properties │ └── mapper/ │ ├── CardMapper.xml │ └── TransactionMapper.xml ├── src/main/webapp/ │ ├── WEB-INF/ │ │ ├── web.xml │ │ └── views/ │ └── static/ │ ├── css/ │ └── js/ └── sql/ └── atm.sql你在 IDE 里排查 bug 时一条请求通常按这样的路径走controller - service - mapper - 数据库。如果参数绑定不对问题多半在 Controller 层如果业务规则没生效去 service 层看事务和判断逻辑如果 SQL 本身报错直接打开对应的 Mapper XML。多数的后端 500 沿着这条链路走一两遍就能定位到具体代码行而不是在黑匣子里瞎猜。common 包里放的是 Result 统一返回结构定义 code、message、data 三个字段前端拿到后能统一判断成功失败而不是直接散落各种异常堆栈。exception 包里放 BizException业务规则不满足时由它向上抛Controller 层再统一接住转成 JSON。很多学生项目要么把 SQL 异常直接抛给前端要么在 Controller 里写一堆 try-catch 把业务异常吞掉导致前端拿到一堆乱七八糟的错误文案。这套统一异常处理的写法值得直接抄。2.3 数据库设计三张表覆盖 ATM 所有核心单据数据库这边用了三张表分别是卡片信息表、用户信息表、交易流水表。我把建表 SQL 稍微整理了一下MySQL 8 下可以直接执行create database if not exists atm default charset utf8mb4; create table user_info ( id int primary key auto_increment, user_name varchar(32) not null ) engineinnodb; create table card_info ( id int primary key auto_increment, card_no varchar(19) not null unique, password varchar(64) not null, balance decimal(12,2) not null default 0.00, status tinyint not null default 1, user_id int not null, index idx_user_id (user_id) ) engineinnodb; create table t_transaction ( id int primary key auto_increment, card_no varchar(19) not null, type varchar(10) not null, amount decimal(12,2) not null, balance_after decimal(12,2), create_time datetime default current_timestamp, index idx_card_time (card_no, create_time) ) engineinnodb;card_info 存的是银行卡的静态信息一个卡号对应一条记录密码字段按项目设定存的是 MD5更规范一点应该加盐。t_transaction 存每一笔交易流水取款、存款、转账、查询都会插一条记录。注意流水表里有个 balance_after 字段记录交易后的余额配合 create_time 能很方便做对账。为什么不用一张表把卡和流水都塞进去因为卡片和流水是一对多关系如果只留一张表取款次数一多数据量就会爆炸。type 字段存的是字符串枚举WITHDRAW、DEPOSIT、TRANSFER 这类值后期改造时可以换成 int 和字典表关联。这里有个值得留意的细节card_info 的 card_no 建了唯一索引流水表的 (card_no, create_time) 建了普通索引因为查询流水的场景基本都是按卡号倒序查最近记录。很多课设项目跑得慢不是 MySQL 有问题而是压根没建索引。3. 核心模块拆解一个取款请求在系统里转了几个来回这一章我们把一条真实请求从进入 Tomcat 到数据库返回的完整链路讲清楚。我会拆三个核心片段登录拦截、取款业务、流水查询。每段代码后面都说明它承担什么任务以及哪些参数不建议乱调。3.1 登录拦截器HandlerInterceptor 到底拦住什么登录的校验没有写在每个 Controller 方法里而是抽成了一个 LoginInterceptor这是这个项目比较成熟的地方。核心代码就十来行public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); String cardNo (String) session.getAttribute(cardNo); if (cardNo null) { // 未登录用户直接打回登录页 response.sendRedirect(request.getContextPath() /login); return false; } return true; } }逻辑说明很简单preHandle 在请求进入 Controller 之前执行从 Session 里拿登录时写入的 cardNo拿不到就说明没登录重定向到登录页并返回 false后面的流程直接中断。你可能会问为什么不用 Filter 做这件事因为拦截器能拿到 SpringMVC 的 Handler 对象可以针对某个具体方法做精细化控制而 Filter 只在 Servlet 层面工作拿不到哪个 Controller 哪个方法在处理请求。拦截器还得在 spring-mvc.xml 里注册并配置白名单不然登录页自己也会被拦mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/static/**/ bean classcom.example.atm.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptorsmapping 的 /** 表示拦截所有路径exclude-mapping 放行指定路径。这里容易踩的坑是如果你把 /static/** 遗漏页面上的 CSS 和 JS 请求会被一起拦掉表现为登录后页面没有样式。后半部分避坑章节会再展开一次这里你先记住拦截器负责的是会话校验不是资源管理。3.2 取款业务声明式事务和 MyBatis 更新语句怎么配合取款是这个项目里最能打的核心逻辑直接看 Service 层实现Service public class AccountServiceImpl implements AccountService { Autowired private CardMapper cardMapper; Autowired private TransactionMapper transactionMapper; Override Transactional(rollbackFor Exception.class) public void withdraw(String cardNo, BigDecimal amount) { // 1. 锁住卡片行避免并发扣款 CardInfo card cardMapper.selectByCardNoForUpdate(cardNo); if (card.getBalance().compareTo(amount) 0) { throw new BizException(余额不足); } // 2. 更新余额 BigDecimal newBalance card.getBalance().subtract(amount); int rows cardMapper.decreaseBalance(cardNo, amount); if (rows ! 1) { throw new BizException(扣款失败请重试); } // 3. 写流水 transactionMapper.insert(cardNo, WITHDRAW, amount, newBalance); } }拆开来讲第一步的 selectByCardNoForUpdate 是关键。它对应 SQL 里的select ... for update会锁定 card_info 里这张卡对应的那一行记录。为什么要加锁因为并发场景下两个请求同时读到余额 10000然后各自扣 3000如果不加锁最后余额会变成 7000 而不是 4000这就是教科书里说的“丢失更新”。加锁之后第二个请求必须等第一个事务提交或回滚才能继续。第二步用compareTo(amount) 0判断余额而不是用或。BigDecimal 是对象如果用比较的是引用地址必错用 compareTo 才能正确比较数值大小。后面 int rows cardMapper.decreaseBalance(cardNo, amount) 的返回值代表受影响行数如果这行数据刚好被删了或状态异常返回值不是 1就应该抛异常这也是乐观锁思想的一种体现——以更新结果来判断冲突。第三步写流水必须在同一个事务里通过方法上的 Transactional 保证扣款和写流水要么都成功要么都回滚。这里特别要说明 rollbackFor Exception.class 这个属性Spring 默认只对 RuntimeException 回滚如果你抛的是 IOException 这类受检异常事务不会回滚账就平不了。这个细节网上很多写 Spring 的教程都没强调实际写业务代码时特别重要。Controller 层则简化了不少Controller RequestMapping(/atm) public class AccountController { Autowired private AccountService accountService; PostMapping(/withdraw) ResponseBody public Result withdraw(RequestParam String cardNo, RequestParam BigDecimal amount) { try { accountService.withdraw(cardNo, amount); return Result.ok(取款成功); } catch (BizException e) { return Result.fail(e.getMessage()); } } }ResponseBody 让方法直接返回 Result 对象序列化后的 JSON不走 JSP 视图解析。RequestParam BigDecimal amount 由 Spring 自动转换字符串参数如果前端传了1,000或带空格的值转换会抛 NumberFormatException直接落进 500。实际项目里要么前端先做格式清洗要么后端用自定义 Converter 处理金额格式这个项目里暂时保持最简写法。3.3 流水查询多条件动态 SQL 与分页参数传递流水查询是典型的 MyBatis 多条件动态 SQL 场景TransactionMapper.xml 里核心 select 是这样select idlistByCondition resultTypecom.example.atm.entity.Transaction select * from t_transaction where if testcardNo ! null and cardNo ! and card_no #{cardNo} /if if testtype ! null and type ! and type #{type} /if /where order by create_time desc limit #{offset}, #{pageSize} /select逻辑说明这里的where标签会智能处理条件前多余的 and比如 cardNo 为空但 type 不为空时生成的是where type WITHDRAW不会多出一个悬空的 and 导致 SQL 报错。if标签在参数为 null 或空串时不拼这个条件实现按需查询。#{cardNo}、#{offset}、#{pageSize}都是预编译占位符MyBatis 会把它翻译成 PreparedStatement 的?这个比字符串拼接安全得多直接挡住 SQL 注入。分页参数 offset 是偏移量pageSize 是每页条数调用方算好传进来比如第二页就是 limit 10, 10。这个写法在数据量小的时候完全没问题但超过几万条流水后MySQL 的 limit 深分页会越来越慢因为数据库要扫描并丢弃前面的 offset 行。后面想优化可以换游标分页或者用 create_time 加主键做二次排序作为二次开发方向之一。4. 本地跑通从初始化数据库到完成第一笔取款写这一章是为了让你能把别人的代码变成你能跑起来、能断点调试的代码。除了把 war 包丢进 Tomcat我建议你把配置改坏几个地方再启动一次这样才知道哪些参数不能动。4.1 环境准备与数据库初始化脚本环境方面一句话说清JDK 8、Maven 3.6、Tomcat 8.5 或 9、MySQL 8。项目是基于 JDK 8 写的用高版本 JDK 跑可能会遇到字节码版本不一致的问题不建议一上来就换 JDK 17。数据库我建议直接执行项目里 sql 目录下的 atm.sql或者手工执行前面那张建表脚本。命令行方式如下mysql -uroot -p sql/atm.sql导入后可以顺手初始化一条测试数据卡号和密码要跟项目里预置的保持一致insert into user_info(id, user_name) values (1, 测试用户); insert into card_info(card_no, password, balance, status, user_id) values (623066000000001, md5(123456), 10000.00, 1, 1);密码字段项目里存的是 MD5所以插入时要用 mysql 的 md5 函数或者后端工具先加密直接存明文进去会导致登录永远验证不过。初始余额 10000.00 足够你测试几十次取款逻辑。4.2 核心配置文件改造要点SSM 整合最容易翻车的地方就在配置文件尤其是下面这几处。顺着 applicationContext.xml 来看context:property-placeholder locationclasspath:jdbc.properties/ 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/ property namemaxActive value20/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nameconfigLocation valueclasspath:mybatis-config.xml/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/四件事值得抠一抠一是 jdbc.properties 里 MySQL 8 的驱动类名必须是com.mysql.cj.jdbc.DriverMySQL 5 时代是com.mysql.jdbc.Driver两者不一样。url 后面建议拼上?useUnicodetruecharacterEncodingutf8不然中文写流水时大概率会乱码。二是 DruidDataSource 的 initialSize 和 maxActive 是连接池的初始连接数和最大连接数。这个参数影响并发能力但不影响功能单机跑 5 和 20 都行。如果启动时报获取连接失败先看日志里有没有真实的 url 和用户信息很多提问不带日志根本没法查。三是 SqlSessionFactoryBean 的 mapperLocations 配置成classpath:mapper/*.xml注意这是 Mapper XML 文件的位置要和 src/main/resources/mapper 目录对应。如果以后把 Mapper 接口和 XML 拆到单独的 jar 包这个路径就要换成classpath*:mapper/*.xml加星号的形式。四是tx:annotation-driven/是 Transactional 生效的开关漏了它所有事务注解都变成普通注释表面上项目还能跑但扣款和写流水不再具备原子性出了问题非常难排查。4.3 部署与验证清单部署命令没什么特别的标准 Maven 打包加 Tomcat 启动mvn clean package -DskipTests cp target/ssm-atm.war $TOMCAT_HOME/webapps/ $TOMCAT_HOME/bin/startup.sh这里我强调一下 clean 的作用。如果你之前编译过target 下会残留旧的 class 和资源文件不 clean 可能出现改了代码但浏览器里行为不变的情况玄学问题往往就是这么来的。war 包拷贝到 webapps 后Tomcat 会自动解压访问路径就是 http://localhost:8080/ssm-atm/。验证清单我直接列成表格你按顺序走一遍步骤操作预期结果1打开 /ssm-atm/login看到登录页2输入测试卡号和 123456跳转至主菜单3点击余额查询展示 10000.004输入金额 500 点取款返回成功5查看流水列表出现一条 WITHDRAW 500 的记录如果第 2 步就卡住先确认你插入的密码是不是 md5 后的值。如果第 4 步报错看两个方向一是 Tomcat 日志$TOMCAT_HOME/logs/catalina.out里有没有 SQL 异常二是页面有没有把 BizException 的 message 正常显示出来。很多 500 错误本质是数据库里的数据状态不对比如卡号不存在或者余额两个字没对上。5. SSM 避坑指南事务自调用、静态资源拦截与参数绑定翻车实录这一章的每一条都是我实际踩过、或者帮别人调过的坑。我把最常见的四个问题按“现象、原因、解决”写透你跑这个项目时大概率至少碰到其中一个。5.1 事务不生效同一个类里方法自己调用自己现象在 AccountServiceImpl 里有个 transfer 方法上面打了 Transactional里面先调 withdraw 再调 deposit转账中间故意让 deposit 抛异常结果只有后面这一步没入库前面转出的钱已经提交了怎么都回滚不掉。原因Spring 的声明式事务基于 AOP 代理外部调用走的是代理对象事务切面才会生效。但 transfer 里写的是this.withdraw()这个 this 是原始对象方法内部自己调自己根本没经过代理层Transactional 在这里等同于不存在。这就是经典的事务自调用失效问题。解决不要在一个类的内部依赖方法互相调用来做事务边界。正确做法是把扣款和入账两个动作抽到一个独立的 TransferService 里由外部直接调 TransferService 的方法Service public class TransferService { Autowired private AccountService accountService; Transactional(rollbackFor Exception.class) public void transfer(String fromCard, String toCard, BigDecimal amount) { accountService.withdraw(fromCard, amount); accountService.deposit(toCard, amount); } }这样每个方法都是通过代理对象调用事务各自能生效。如果暂时不想改类结构也可以手动注入自身或用 TransactionTemplate 明确事务边界但最省心的还是拆一个独立的服务类出来。5.2 静态资源 404DispatcherServlet 把 css/js 一并拦了现象Tomcat 正常启动登录页能打开但页面没有任何样式F12 里所有 /static/js/main.js 请求全部 404路径看起来完全正确就是想不通偏偏后端接口是通的。原因web.xml 里 DispatcherServlet 的 url-pattern 配置成/它把 Servlet 默认的静态资源处理也拦过来了。SpringMVC 默认不会主动读取静态资源需要显式配置资源映射器。解决在 spring-mvc.xml 中加两条配置mvc:default-servlet-handler/ mvc:resources mapping/static/** location/static//default-servlet-handler 把未被 SpringMVC 处理的请求交回 Tomcat 默认 Servletresources 标签把 /static/** 前缀的请求映射到项目的静态资源目录。同时还要注意 LoginInterceptor 的 exclude-mapping 里放行/static/**否则静态资源请求也会被拦成重定向到登录页表现是路径没错但一直跳页面。5.3 中文乱码请求编码和响应编码一路排查到底现象登录后页面显示正常但写入流水的中文类型名比如“取款”存到数据库变成问号或者前端传来的中文字段被 Controller 接到时已经乱成一团。原因Tomcat 8 及以上对 URI 默认按 UTF-8 解析但对请求体 POST 表单并没有强制编码。响应端如果用 ResponseBody 输出 JSONSpringMVC 默认编码也不保证是 UTF-8。数据库那一侧如果 jdbc url 没带 characterEncodingutf8MySQL 会按数据库默认字符集解释连接三个环节只要有一个没对齐就是乱码。解决先在 web.xml 挂一个 CharacterEncodingFilter并且 forceEncoding 设成 truefilter filter-nameencoding/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencoding/filter-name url-pattern/*/url-pattern /filter-mapping同时确认 jdbc.properties 的 url 拼了characterEncodingutf8JSP 顶部的 pageEncoding 和 contentType 都写 UTF-8。从请求到响应到数据库三层编码统一后乱码基本消失。5.4 数据查不出来驼峰映射和 resultMap 的差距现象实体类属性叫 createTime表字段叫 create_time查询结果其它字段都有值只有这个属性永远是 null。前端拿到 JSON 后看到 createTime 是 0 或 null很容易误以为接口写错。原因MyBatis 默认不开启自动驼峰转换create_time 这个列名在实体类里找不到对应属性值为 null。这个坑在字段少时不容易暴露字段一多就特别明显。解决在 mybatis-config.xml 里打开全局开关settings setting namemapUnderscoreToCamelCase valuetrue/ /settings打开后create_time 会自动映射到 createTimecard_no 映射到 cardNo。但要注意如果个别查询用了自定义 resultMap那 resultMap 里的映射就是手动指定的全局开关不会帮你自动补充。遇到 resultMap 里字段不全的情况老老实实把 column 和 property 写完整这是最稳的。6. 二次开发把模拟 ATM 改成带验证码和 Redis 会话的真实服务从课程设计到能往简历上写的项目我通常做三件事登录加固、会话脱离容器、并发边界验证。6.1 改造方向第一件事把 Session 里的登录信息换成 Redis 管理的 token。登录成功后用 UUID 生成一个随机 token作为 key 存进 Redisvalue 放卡号和登录时间设置 30 分钟过期之后每次请求在拦截器里先取请求头里的 token查 Redis 判断是否有效。改造逻辑不复杂核心是用 RedisTemplate 的 set 和 get 替换 session 操作却能帮你脱离 Tomcat 的 Session 机制分布式的方向也就出来了。第二件事登录页加图形验证码。用 kaptcha 生成验证码图片把验证码文本也存 Redis60 秒过期连续输错 5 次锁定卡号 15 分钟。锁定的实现可以用 Redis 的 INCR 加 EXPIRE非常简洁。这两处改动对 ATM 主流程没有侵入但对项目说服力的提升非常大。6.2 验证方式改造完别只在页面手点两下就收工。我建议先写一个 AccountServiceTest用 JUnit 加 Mockito 把 Mapper mock 掉覆盖余额不足、正常扣款、事务回滚三条核心路径。然后把项目跑起来在 Postman 里带 token 走一遍登录、取成功一次、取超余额一次、查流水。最后用 JMeter 开 100 个线程同时取同一个卡观察事务锁有没有把扣款兜住这一步才能验证 for update 是不是真的生效。从那以后我每次动这种 SSM 项目都会先在心里过一遍事务边界在哪个方法上、锁的是哪一行、索引够不够支撑。项目可以小但这些习惯会一直跟着你走。希望帮到你。本文还有配套的精品资源点击获取