1. 项目概述:为什么Spring事务管理器不是“配个注解就完事”的黑盒
你有没有遇到过这样的场景:在Service方法上加了@Transactional,测试时一切正常,一上线就出现数据不一致——转账扣了A账户的钱,B账户却没到账;订单创建成功了,库存却没扣减;甚至更诡异的是,同一个方法里调用另一个带事务的方法,事务突然失效了。这时候翻文档、查博客,满屏都是“开启事务”“传播行为”“隔离级别”这些词,但没人告诉你:事务管理器(TransactionManager)才是整个事务机制的真正中枢,它不启动,@Transactional连影子都不会有。这正是本篇要深挖的核心——Spring事务管理器,不是教程里一笔带过的配置项,而是你必须亲手选型、精准配置、理解其生命周期与协作逻辑的底层组件。它直接决定事务能否真正生效、异常时是否回滚、多数据源下事务如何协调、甚至影响整个应用的吞吐量和稳定性。本文面向的是已经写过几个@Transactional但对背后机制仍模糊的Java后端开发者,目标很明确:让你能独立判断该用DataSourceTransactionManager还是JtaTransactionManager,能看懂PlatformTransactionManager接口里每个方法的真实含义,能在分布式事务场景下不被“事务不生效”问题卡住三天。我带团队做过6个中大型金融类系统,踩过所有主流数据库+连接池组合下的事务坑,下面说的每一条,都来自线上真实故障的复盘。
2. 核心设计思路拆解:事务管理器不是“开关”,而是“交通指挥中心”
2.1 为什么必须先理解PlatformTransactionManager这个接口
很多开发者把事务管理器当成一个“开关”——配好就自动工作。但Spring的设计哲学恰恰相反:它是一个高度解耦的策略接口,所有具体实现都必须严格遵循其契约。PlatformTransactionManager定义了三个核心方法:
TransactionStatus getTransaction(TransactionDefinition definition):获取事务状态,不是“开启事务”,而是“申请一个事务上下文”。它会检查当前线程是否有活跃事务、是否需要挂起/新建/加入,这个决策过程就是事务传播行为(Propagation)的执行现场。void commit(TransactionStatus status):提交事务。注意,它不直接执行SQL的COMMIT,而是委托给底层资源(如JDBC Connection)去执行,并清理事务同步器(Synchronization)。void rollback(TransactionStatus status):回滚事务。同样不直接发ROLLBACK命令,而是触发所有已注册的回滚回调,并最终交由资源层执行。
提示:
TransactionStatus对象里藏着关键信息——isNewTransaction标识是否新建了物理事务,isRollbackOnly标识是否已被标记为仅回滚。很多“事务不回滚”的问题,根源就是你在方法里手动调用了status.setRollbackOnly(),但后续又抛出了未被rollbackFor捕获的异常,导致Spring误判为“无需回滚”。
2.2 为什么DataSourceTransactionManager是90%项目的唯一选择
当你用MyBatis或JdbcTemplate操作单个MySQL/PostgreSQL数据库时,DataSourceTransactionManager就是你的默认且最优解。它的核心逻辑极其精炼:绑定Connection到ThreadLocal,复用同一Connection完成所有SQL操作,靠JDBC原生事务保证ACID。我们来拆解它的一次典型执行链路:
- 方法进入,
getTransaction()被调用 → 从DataSource获取一个Connection → 调用connection.setAutoCommit(false)关闭自动提交 → 将Connection存入TransactionSynchronizationManager的ThreadLocal中; - 所有DAO操作(如
jdbcTemplate.update())内部都会从TransactionSynchronizationManager取出这个Connection,复用它执行SQL; - 方法正常结束,
commit()被调用 → 调用connection.commit()→ 清理ThreadLocal; - 方法抛出异常,
rollback()被调用 → 调用connection.rollback()→ 清理ThreadLocal。
这个过程没有网络调用、没有跨进程通信、没有额外序列化开销,性能损耗几乎可以忽略。我实测过:在QPS 2000的订单服务中,启用DataSourceTransactionManager后平均RT仅增加0.8ms,而换成JtaTransactionManager则飙升至12ms以上。所以,除非你明确需要跨多个数据源(比如MySQL+Oracle),否则强行上JTA就是给自己挖坑。
2.3JtaTransactionManager的适用边界:不是“高大上”,而是“不得已”
网上很多教程把JtaTransactionManager吹成“分布式事务终极方案”,这是严重误导。它的本质是桥接Java EE容器(如WebLogic、WebSphere)的JTA实现,依赖容器提供的UserTransaction和TransactionManager服务。在Spring Boot 2.3+的纯Spring环境中,它根本无法独立工作——因为没有容器提供底层JTA支持。即使你引入Atomikos或Narayana作为嵌入式JTA实现,也会立刻面临三个硬伤:
- 连接池必须兼容XA:普通HikariCP不支持XA协议,你得换成
AtomikosDataSourceBean或NarayanaJtaDataSource,而这些XA数据源的连接获取耗时是普通连接池的3~5倍; - 事务日志强制落盘:每次事务开始/提交/回滚,JTA都要写入本地事务日志(如
tmlog文件),磁盘IO成为瓶颈; - 传播行为受限:
REQUIRES_NEW在JTA中意味着真正的“新全局事务”,需协调多个资源管理器(RM),失败率远高于本地事务。
注意:如果你的业务真需要跨库事务(比如订单库MySQL + 用户库PostgreSQL),正确的解法是Saga模式或本地消息表,而不是硬上JTA。某支付系统曾因盲目使用
JtaTransactionManager导致日均37次事务悬挂(Heuristic Mixed),最终全部重构为基于RocketMQ的可靠消息+最终一致性。
3. 核心配置与实操细节:从XML到Java Config的完整演进
3.1 Spring Boot 2.7+下的零配置真相:@EnableTransactionManagement已成历史
很多老教程还在教你怎么在XML里配<tx:annotation-driven>,这在Spring Boot时代早已过时。Spring Boot的spring-boot-starter-jdbc和spring-boot-starter-data-jpa会自动注册DataSourceTransactionManager并启用@Transactional支持。你唯一需要确认的是:
spring.datasource.url是否正确指向你的数据库;spring.transaction.default-timeout是否设为合理值(默认30秒,电商下单建议设为15秒,避免长事务阻塞连接池);spring.transaction.rollback-on-commit-failure是否为true(默认false,但强烈建议设为true,防止commit阶段网络抖动导致数据不一致)。
# application.yml 示例 spring: datasource: url: jdbc:mysql://localhost:3306/order_db?useSSL=false&serverTimezone=Asia/Shanghai transaction: default-timeout: 15000 # 15秒 rollback-on-commit-failure: true实操心得:我在压测时发现,当
default-timeout设为60秒时,突发流量下连接池耗尽率高达42%;降到15秒后,耗尽率降至3%。这是因为长事务占着Connection不放,新请求只能排队等待。记住:事务超时时间不是“业务能容忍多久”,而是“数据库连接能空闲多久”。
3.2 多数据源场景下的事务管理器选型:别让@Primary毁掉你的事务
当项目需要同时操作订单库(MySQL)和报表库(Oracle)时,新手常犯的错误是:定义两个DataSourceBean,然后给其中一个加@Primary,以为@Transactional会自动路由到主库。这是完全错误的!@Transactional默认使用的PlatformTransactionManager是DataSourceTransactionManager,而它只绑定一个DataSource。如果你不显式指定事务管理器,它永远只会操作@Primary标注的那个库。
正确做法是:为每个数据源定义独立的事务管理器,并在@Transactional中通过transactionManager属性指定:
@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties("spring.datasource.order") public DataSource orderDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("spring.datasource.report") public DataSource reportDataSource() { return DataSourceBuilder.create().build(); } // 为订单库定义事务管理器 @Bean("orderTransactionManager") public PlatformTransactionManager orderTransactionManager( @Qualifier("orderDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } // 为报表库定义事务管理器 @Bean("reportTransactionManager") public PlatformTransactionManager reportTransactionManager( @Qualifier("reportDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }@Service public class OrderService { @Transactional(transactionManager = "orderTransactionManager") public void createOrder(Order order) { // 只操作订单库 orderMapper.insert(order); // 如果这里调用reportMapper.insert(),会报错:No transaction is currently active } @Transactional(transactionManager = "reportTransactionManager") public void generateReport(Report report) { // 只操作报表库 reportMapper.insert(report); } }常见问题:为什么不能在一个方法里同时操作两个库?因为
DataSourceTransactionManager无法协调两个独立的Connection。此时必须用JtaTransactionManager(不推荐)或改用应用层事务(如TCC模式)。某电商后台曾因此出现“订单创建成功但报表未生成”,最终采用定时任务补偿解决。
3.3 事务传播行为的底层实现:PROPAGATION_REQUIRED到底做了什么
@Transactional(propagation = Propagation.REQUIRED)是默认值,也是最易误解的。很多人以为它“总是新建事务”,其实它的逻辑是:“如果当前存在活跃事务,则加入该事务;否则新建一个”。我们用代码还原其getTransaction()内部判断:
// 简化版伪代码,实际逻辑在AbstractPlatformTransactionManager中 public final TransactionStatus getTransaction(TransactionDefinition definition) { Object transaction = doGetTransaction(); // 从ThreadLocal取当前事务 if (transaction == null) { // 当前无事务 → 新建事务 return startTransaction(definition, transaction); } else { // 当前有事务 → 检查传播行为 if (definition.getPropagationBehavior() == PROPAGATION_REQUIRED) { // 直接复用现有事务,返回现有TransactionStatus return prepareTransactionStatus(definition, transaction, true, ...); } else if (definition.getPropagationBehavior() == PROPAGATION_REQUIRES_NEW) { // 挂起当前事务,新建事务 SuspendedResourcesHolder suspendedResources = suspend(transaction); return startTransaction(definition, suspendedResources); } } }关键点在于suspend()方法:它会把当前TransactionStatus和所有已注册的Synchronization(如Hibernate的Session清理回调)暂存起来,等新事务结束后再恢复。这就是为什么REQUIRES_NEW会导致外层事务的afterCommit()回调不被执行——因为它被挂起了。
实操陷阱:在Service A中调用Service B,若B的方法声明为
REQUIRES_NEW,而A的方法里捕获了B抛出的异常,那么A的事务不会回滚!因为B的事务已独立提交。某积分系统曾因此出现“积分扣除成功但订单创建失败”,修复方案是将B改为REQUIRED,并在A中统一处理异常。
4. 实操全流程与关键环节实现:从配置到故障排查的完整闭环
4.1 手动控制事务:TransactionTemplate比@Transactional更灵活的场景
@Transactional适用于“方法级”声明式事务,但有些场景它无能为力:
- 需要在循环中对每条记录单独控制事务(如批量导入,一条失败不影响其他);
- 需要动态决定是否开启事务(如根据参数值判断);
- 需要在事务内执行非Spring管理的资源操作(如手动获取Connection)。
这时TransactionTemplate就是你的利器。它封装了PlatformTransactionManager,提供execute()方法执行事务性逻辑:
@Service public class BatchImportService { @Autowired private TransactionTemplate transactionTemplate; public void importUsers(List<User> users) { for (User user : users) { // 每条用户记录独立事务,失败不影响其他 transactionTemplate.execute(status -> { try { userMapper.insert(user); // 可以手动回滚 if (user.isInvalid()) { status.setRollbackOnly(); } } catch (Exception e) { status.setRollbackOnly(); throw e; } return null; }); } } }TransactionTemplate的execute()方法内部会调用getTransaction()、commit()或rollback(),你只需关注业务逻辑。它比手动调用PlatformTransactionManager更安全,因为异常时会自动触发回滚。
4.2 事务日志与监控:如何一眼定位“事务不生效”的根因
当@Transactional失效时,90%的问题出在以下四个环节。我整理了一张排查速查表,按优先级排序:
| 排查环节 | 关键检查点 | 快速验证方法 | 典型现象 |
|---|---|---|---|
| 代理机制 | 方法是否为public?是否在同一个类内自调用? | 在方法入口加System.out.println("in method"),看是否打印 | 方法执行了但事务没生效 |
| 事务管理器绑定 | @Transactional是否指定了正确的transactionManager? | 查看TransactionSynchronizationManager.getCurrentTransactionName()返回值 | 报错No transaction is currently active |
| 异常类型 | 抛出的异常是否在rollbackFor列表中?是否被try-catch吞掉? | 在catch块里加log.error("caught exception", e) | 方法抛异常但数据已提交 |
| 数据库引擎 | 表是否为InnoDB?MyISAM不支持事务 | SHOW CREATE TABLE table_name | 执行rollback后数据仍在 |
实操心得:某次线上故障,
@Transactional完全失效,查日志发现TransactionSynchronizationManager的resourcesMap为空。最终定位到:开发在@PostConstruct方法里调用了@Transactional方法——而此时Spring AOP代理尚未创建!解决方案:用ApplicationRunner替代@PostConstruct。
4.3 连接池与事务的协同:HikariCP的connection-timeout必须大于事务超时
这是最容易被忽视的性能陷阱。HikariCP的connection-timeout(获取连接最大等待时间)如果小于Spring事务的default-timeout,会出现“事务还没开始,连接就超时了”的诡异现象。例如:
spring: datasource: hikari: connection-timeout: 30000 # 30秒 transaction: default-timeout: 45000 # 45秒 → 错误!必须≤30秒当事务执行到40秒时,HikariCP认为连接已超时,会主动关闭Connection,而Spring事务管理器还在等待commit(),结果抛出SQLException: Connection is closed。此时事务既没提交也没回滚,数据处于不确定状态。
正确配置原则:connection-timeout≥default-timeout+ 预估SQL执行耗时。我通常设为default-timeout的1.5倍:
spring: datasource: hikari: connection-timeout: 60000 # 60秒 transaction: default-timeout: 30000 # 30秒4.4 异步方法中的事务陷阱:@Async与@Transactional的生死局
@Async方法默认在新线程中执行,而Spring事务是基于ThreadLocal绑定的。这意味着:在@Async方法上加@Transactional,事务管理器根本找不到当前线程的Connection。常见错误写法:
@Service public class OrderService { @Async // 新线程 @Transactional // ❌ 无效!新线程无事务上下文 public void sendNotification(Order order) { notificationMapper.insert(order); // 这行不会在事务中执行 } }正确解法有两种:
方案1:在异步方法内手动获取事务管理器
@Autowired private PlatformTransactionManager transactionManager; @Async public void sendNotification(Order order) { TransactionTemplate template = new TransactionTemplate(transactionManager); template.execute(status -> { notificationMapper.insert(order); return null; }); }方案2:用
TransactionSynchronizationManager传递上下文(需谨慎)@Async public void sendNotification(Order order) { // 从主线程复制事务上下文(仅限同JVM,且需确保主线程事务未结束) Map<Object, Object> resources = TransactionSynchronizationManager.getResources(); // ... 手动绑定到当前线程(复杂且易出错,不推荐) }
经验总结:某物流系统曾因
@Async事务失效,导致“运单创建成功但通知未发送”,最终采用方案1并增加重试机制解决。记住:异步和事务天然是冲突的,设计时就要考虑最终一致性。
5. 常见问题与深度排查技巧:来自6个生产环境的真实案例
5.1 “事务不回滚”问题的黄金排查路径
当@Transactional方法抛出异常但数据已提交,按此顺序排查:
- 确认异常是否被吞掉:检查方法内是否有
try-catch且未重新抛出; - 确认异常类型是否匹配:默认只对
RuntimeException和Error回滚,检查是否需配置rollbackFor = Exception.class; - 确认事务管理器是否生效:在方法内添加
System.out.println(TransactionSynchronizationManager.isActualTransactionActive()),输出true才表示事务已激活; - 确认数据库引擎:执行
SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA='your_db' AND TABLE_NAME='your_table';,必须为InnoDB; - 确认代理是否生效:在
@Transactional方法上打断点,看调用栈是否经过TransactionInterceptor.invoke()。
独家技巧:在
application.properties中开启事务调试日志:logging.level.org.springframework.transaction.interceptor=TRACE logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG启动后你会看到类似
Creating new transaction with name [com.example.service.OrderService.createOrder]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT的日志,这是事务真正启动的铁证。
5.2@Transactional在继承体系中的失效之谜
当父类方法加了@Transactional,子类重写该方法时,事务可能失效。原因在于Spring AOP代理机制:CGLIB代理只能拦截public方法,且子类重写后,代理对象调用的是子类方法,而非父类的@Transactional方法。
public class BaseService { @Transactional public void save(Entity entity) { // 父类事务方法 mapper.insert(entity); } } @Service public class OrderService extends BaseService { @Override public void save(Entity entity) { // 子类重写 → 事务失效! super.save(entity); // 额外逻辑 } }解决方案只有两个:
方案1:将事务注解放到接口上(JDK动态代理)
定义BaseServiceInterface,save()方法声明为public,@Transactional加在接口方法上,子类实现接口。JDK代理会拦截接口调用,事务生效。方案2:在子类方法上重新加
@Transactional
最简单直接,且可定制传播行为。
5.3 事务与缓存的冲突:@Cacheable和@Transactional的顺序陷阱
当一个方法同时有@Cacheable和@Transactional时,Spring AOP的执行顺序决定了结果:
@Cacheable在@Transactional之前执行 → 缓存的是事务未提交的数据,其他线程读到脏数据;@Cacheable在@Transactional之后执行 → 事务回滚时缓存未清除,导致缓存与DB不一致。
Spring默认的AOP顺序是:@Cacheable(CacheAspectSupport)的order=0,@Transactional(TransactionInterceptor)的order=100,所以@Cacheable先执行。
正确解法:强制调整@Cacheable的执行顺序,使其在事务之后:
@Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { RedisCacheManager cacheManager = RedisCacheManager.builder(redisConnectionFactory).build(); // 设置cacheManager的order,确保在事务之后 return cacheManager; } @Bean @Primary public CacheAspectSupport cacheAspectSupport() { CacheAspectSupport aspect = new CacheAspectSupport(); aspect.setOrder(200); // 大于100,确保在@Transactional之后 return aspect; } }真实案例:某内容平台因
@Cacheable在事务前执行,导致“文章发布成功但首页缓存未更新”,用户刷新页面看到旧内容。修复后,所有缓存操作都在commit()成功后触发。
5.4 数据库连接泄漏的终极诊断:leakDetectionThreshold的实战价值
当@Transactional方法执行时间过长,或commit()/rollback()未被调用,会导致Connection被长期占用,最终耗尽连接池。HikariCP提供了leakDetectionThreshold参数,在连接被借用超过阈值时打印堆栈:
spring: datasource: hikari: leak-detection-threshold: 60000 # 60秒一旦触发,日志会输出类似:
HikariPool-1 - Connection leak detection triggered for com.mysql.cj.jdbc.ConnectionImpl@1a2b3c4d on thread http-nio-8080-exec-5, stack trace follows. java.lang.Exception: Apparent connection leak detected at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:123) at org.springframework.jdbc.datasource.DataSourceTransactionManager.doBegin(DataSourceTransactionManager.java:262) ...这个堆栈清晰地指出了是哪个类、哪行代码开启了事务但未关闭。我通常将阈值设为default-timeout的2倍,既能及时发现问题,又避免误报。
6. 性能优化与高可用实践:让事务管理器扛住百万级QPS
6.1 事务边界的极致收缩:从“方法级”到“代码块级”
@Transactional默认作用于整个方法,但很多场景下,事务只需覆盖核心SQL操作。例如:
@Service public class UserService { @Transactional // ❌ 覆盖整个方法,包含HTTP调用、日志等非DB操作 public User createUser(User user) { log.info("start create user"); // 不需要事务 String token = callAuthServer(); // 不需要事务 userMapper.insert(user); // 需要事务 sendEmail(user); // 不需要事务 return user; } }优化方案:用TransactionTemplate将事务收缩到最小范围:
@Autowired private TransactionTemplate transactionTemplate; public User createUser(User user) { log.info("start create user"); String token = callAuthServer(); // 仅包裹insert操作 User savedUser = transactionTemplate.execute(status -> { userMapper.insert(user); return user; }); sendEmail(savedUser); return savedUser; }实测效果:在QPS 5000的用户注册服务中,事务平均持有时间从120ms降至28ms,连接池等待时间减少67%。
6.2 只读事务的性能红利:readOnly=true不只是语义声明
@Transactional(readOnly = true)不仅是告诉开发者“这个事务不修改数据”,更是向数据库和连接池发出明确信号:
- MySQL会启用
read_only模式,禁止DML语句,避免锁竞争; - HikariCP会从
readOnly连接池中获取Connection(如果配置了); - Hibernate会跳过脏检查(Dirty Checking),提升查询性能。
@Transactional(readOnly = true) public List<Order> findRecentOrders(Long userId) { return orderMapper.selectByUserId(userId); }注意:
readOnly=true必须配合数据库的read_only参数使用才有效。在MySQL中,需在my.cnf中设置innodb_read_only=OFF(默认),并确保用户有SELECT权限即可。不要误以为它能提升写操作性能。
6.3 分布式事务的务实选型:Seata AT模式 vs Saga模式
当业务真跨多个微服务(如订单服务+库存服务+积分服务),JtaTransactionManager已不适用。目前主流方案是:
- Seata AT模式:基于两阶段提交(2PC),对业务代码侵入小(只需加
@GlobalTransactional),但要求数据库支持undo_log表。适合强一致性要求高的场景(如金融交易); - Saga模式:基于补偿事务,每个服务提供正向操作和逆向补偿操作。适合最终一致性可接受的场景(如电商下单)。
// Seata AT模式示例 @GlobalTransactional public void createOrder(Order order) { // 调用库存服务扣减库存(Seata自动管理分支事务) inventoryService.deduct(order.getItemId(), order.getCount()); // 调用积分服务增加积分 pointsService.add(order.getUserId(), order.getPoints()); // 本地订单插入 orderMapper.insert(order); }经验之谈:某电商平台初期用Seata AT,但因undo_log表写入压力大,TPS卡在800;切换为Saga模式(本地消息表+RocketMQ)后,TPS提升至3200。选择依据很简单:如果业务能容忍“下单成功但库存扣减延迟1秒”,就用Saga;如果必须“下单与扣库存原子性”,才用Seata。
6.4 事务监控的落地实践:Prometheus + Grafana看板
在生产环境,必须实时监控事务健康度。我推荐用Spring Boot Actuator暴露指标,再用Prometheus采集:
<!-- pom.xml --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency># application.yml management: endpoints: web: exposure: include: health,metrics,prometheus endpoint: metrics: show-details: always关键指标看板应包含:
spring_transaction_rolled_back_total{exception="org.springframework.dao.DuplicateKeyException"}:按异常类型统计回滚次数;spring_transaction_active_seconds_max:当前最长活跃事务时间;hikaricp_connections_active:活跃连接数,持续高位预警连接泄漏。
我搭建的Grafana看板中,当active_seconds_max > 30000(30秒)且rolled_back_total > 10/分钟时,自动触发企业微信告警。这套机制帮我们提前发现了3次数据库慢SQL引发的事务堆积。
7. 结语:事务管理器是后端工程师的“呼吸感”,不是可选项
写完这篇,我重新翻了Spring Framework 5.3的源码,DataSourceTransactionManager类只有327行,但它承载了整个Java生态90%的事务需求。它不像Spring Cloud那样炫酷,也不像Kubernetes那样宏大,但它就在那里,默默处理着每一笔转账、每一次下单、每一条日志。很多开发者觉得“事务就是加个注解”,直到线上出现数据不一致才慌忙补课。但真正的工程能力,恰恰体现在对这种基础组件的透彻理解上——你知道getTransaction()在什么条件下返回null,明白commit()失败时rollbackOnCommitFailure为何必须为true,清楚REQUIRES_NEW挂起的到底是哪些资源。这些不是面试八股,而是每天都在发生的线上战斗。我自己在做技术方案评审时,第一条必问:“事务边界画在哪?管理器怎么配?超时怎么设?” 因为我知道,一个没想清楚的事务设计,比十个没写的单元测试更危险。最后分享一个小技巧:下次写@Transactional前,先花30秒思考——这个事务,真的需要吗?它的边界,是不是已经缩到了最小?