数据库和事务设计这件事,我一直觉得是后端实战里最容易“会了但没完全会”的部分。写个增删改查谁都会,但一旦涉及资源竞争、并发写入、数据一致性,很多人就开始靠猜了。尤其是“最小后端服务”——听着简单,实际上它把关键问题暴露得特别彻底:没有中间层帮你兜底,没有复杂的分布式框架,你面对的就是数据库本身的能力边界。这篇文章就把我在工程里摸爬滚打积累的东西拆开讲,覆盖数据库选型、连接管理、事务设计、并发控制、死锁排查,最后给一套能直接落地的最小事务实现方案。
1. 项目概述与核心设计拆解
1.1 到底什么是“最小后端服务”
先把这个概念说清楚。最小后端服务不是简陋,而是克制——它只包含一个业务闭环里最必要的东西:一个 API 入口、一个业务逻辑层、一个数据访问层、一种持久化存储。没有消息队列,没有缓存集群,没有微服务治理,甚至可能只有一个进程。但“最小”不等于“可以糊弄”,相反,正因为组件少,每一个组件的质量直接决定系统能不能跑稳。
我见过不少团队把一个“最小服务”做成了事故现场:数据库连接不复用、事务边界随意乱开、隔离级别全靠默认、SQL 一把梭。这些问题放在大系统里可能被中间件和监控掩盖,但在最小服务里,它们是直接暴露给用户的故障点。所以这个项目的定位,不是教你搭一个能跑的东西,而是教你怎么把一个能跑的东西搭得经得起并发、数据和故障的考验。
1.2 为什么事务设计在这种项目里是核心难点
事务是我们唯一能用来保证数据一致性的工程手段。在最小服务里,没有分布式事务协调器,没有对账系统,没有事件回放机制,你手里能用的牌就是单机事务的隔离性、原子性和锁机制。如果你的事务设计是错的,数据错误往往不是立刻出现,而是延迟爆发——比如订单超卖、重复扣款、库存负数。这些问题的共同点:在测试环境完全正常,上生产才在某个流量尖峰时刻暴露。
所以这篇文章的整个价值链是这样:先决定用什么数据库、怎么管理连接(基础设施层),然后决定事务怎么开、多长、什么隔离级别(设计层),再落到代码里怎么组织事务边界和异常处理(实现层),最后是遇到死锁和超时的排查方法(运维层)。四层都做对了,这个最小服务才算真正工程级。
1.3 你从这篇能带走什么
这套设计不是某种语言专属的。我下面的例子用 Java + Spring + MySQL 来写,因为当前后端主流栈里这套最典型,但它映射到 Node.js、Python FastAPI、Go 也完全成立——事务的概念和数据库的行为是跨语言的。适合谁看:刚起步写后端但被并发问题困扰的同学,需要独立搭建服务却担心数据一致性的全栈工程师,以及想系统梳理事务细节的候选人。我不打算堆理论知识,我尽量还原实际决策过程:为什么选它、不选什么、参数怎么定、踩了什么坑。
2. 数据库选型与最小服务的基础设施
2.1 选型第一原则:别让存储成为瓶颈
最小后端服务最常见的数据存储选项就是这三类:嵌入式 SQLite、单机关系型数据库(MySQL/PostgreSQL)、托管数据库服务。我不讨论大数据量或者高并发,因为那超出了“最小服务”的定位,但我必须说清楚它们的适用边界,否则容易选错。
SQLite 做原型或者纯本地工具是最快的。零配置,单文件,事务完整支持 ACID,性能在低并发场景下足够。但它有个致命限制:写入并发差,因为粒度是整个数据库的写锁。而且它不适合跑在共享存储上——网络文件系统上你用 SQLite 就是自找死锁。
MySQL / PostgreSQL 是生产环境最小服务应该考虑的起点。它们有成熟的连接池生态、丰富的监控手段、可调的隔离级别,而且支持行级锁,这意味着高并发下多个事务可以同时改不同行。PostgreSQL 在事务一致性上更严格,MySQL 在运维侧更普及——两条路都是对的,看你团队熟悉哪个。
托管数据库服务的价值是它们帮你把备份、高可用、自动故障切换都做了。对于一个“最小服务”,这能省掉大量运维成本。我个人的经验:如果你没有专职 DBA,生产环境优先考虑托管实例,而不是自己搭数据库容器。省下的时间来写业务逻辑,比折腾主从复制有价值。
2.2 连接管理:连接池是并发与性能的第一道闸门
数据库连接不是免费的。每条连接都是一个 TCP 连接,外加数据库端分配的内存和会话上下文。如果每个请求都新建连接,你会发现响应时间里一半都在握手,高并发下直接连爆数据库的最大连接数。连接池就是把这些宝贵的连接循环利用的机制。
以 Java 体系最常用的 HikariCP 为例,我实际项目里常用的最小配置是这样:
spring.datasource.hikari.maximum-pool-size=20 spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.connection-timeout=30000 spring.datasource.hikari.idle-timeout=600000 spring.datasource.hikari.max-lifetime=1800000每个参数都有讲究。maximum-pool-size 是核心:它决定了应用可以积压多少并发数据库操作。不需要盲求大,因为数据库端每多一条连接都在消耗 CPU 和内存,池太大反而让锁竞争升温。一个粗略的经验公式:核心业务并发数 × 每个请求平均数据库操作耗时 ÷ 1000ms,就是你需要的池大小下限。比如 100 并发、每次操作 20ms,那就是 100×20/1000 = 2,20 已经是很宽裕的数字。
connection-timeout 是拿不到连接时的等待上限,30 秒是保守值,如果你发现请求经常卡满这个数,说明池太小或者有连接泄漏。max-lifetime 一定要小于数据库端的 wait_timeout,否则数据库回收了连接,应用还想继续用,就会出现偶发性的 “Communications link failure” 随机报错。
2.3 连接泄漏:最小服务最容易被忽视的故障源
这是我在实战里踩过最深的一个坑。所谓连接泄漏,就是代码里拿了一个连接但没归还给池,连接池被慢慢掏空,最终每个请求都在等连接,整个服务雪崩。这个故障特别隐蔽,因为它宕机前毫无预兆,而且应用日志几乎不报错,只有超时。
怎么杜绝连接泄漏?三条线并行。第一,代码风格上坚持“谁申请谁释放”,Java 里就是 try-with-resources,Python 里就是 context manager,确保任何异常路径都能归还连接。第二,连接池设置泄漏检测阈值,HikariCP 的 leak-detection-threshold 设为比如 60000 毫秒,一旦发现连接持有时间超过阈值就有告警日志。第三,监控池的等待线程数和活跃连接数,这两条指标一旦持续走高,基本就是泄漏或者 SQL 慢查询的双重信号。
3. 事务设计:从理论到工程落地
3.1 事务的四个特性在工程里的真实映射
先别急着背 ACID 的定义,把它映射到实际场景才有意义。原子性(Atomicity)是说一个事务内的所有操作要么全成功要么全不生效——这个特性在转账场景里体现得最直观:扣款成功但入账失败,数据就对不上了。一致性(Consistency)是指数据库的约束(唯一键、外键、检查约束)不因为并发操作而被破坏。隔离性(Isolation)解决的是“多个事务同时跑的时候彼此能看到什么”的问题,这是事务设计里最需要花心思的部分,后面我展开讲。持久性(Durability)则是最容易被当成“自带属性”的特性——事务一旦提交,即使系统瞬间断电,已提交的数据也必须还在。要做到这点,数据库需要用 redo log 先落盘,如果你用了某些“加速”配置(比如关掉 redo log),本质上就是在拿数据安全换写入速度,生产环境这么干就是要出事的。
我之前排查过一类奇怪的数据丢失案:某服务在正常提交事务之后,遇到主机断电重启,最近几秒内的几百条记录消失了。最后定位到原因,是这个实例为了写性能把innodb_flush_log_at_trx_commit设成了 0 而非默认的 1。这个参数设为 0 意味着 log buffer 每秒才刷一次盘,崩溃时这一秒内的提交记录全丢。工程教训:所谓“优化”参数,很多时候是把未来某一天的数据灾难提前预定。
3.2 隔离级别实战对比:别再用“默认”糊弄自己
隔离级别是事务设计里最容易出交叉问题的区域。关系型数据库通常有四级:读未提交、读已提交、可重复读、串行化。级别越高,隔离越强,并发度越低。问题是,“默认级别”不是一个放之四海而皆准的答案——它的行为在不同数据库里还不一样。
以最常用的两个数据库为例:
| 隔离级别 | MySQL InnoDB 默认 | PostgreSQL 默认 | 解决的问题 | 残留的问题 |
|---|---|---|---|---|
| 读未提交 | 否 | 否 | 只有原子性,几乎不用 | 脏读 |
| 读已提交 | 否 | 是 | 防脏读 | 不可重复读、幻读 |
| 可重复读 | 是 | 否 | 防脏读、不可重复读 | 幻读(MySQL 通过间隙锁部分解决) |
| 串行化 | 否 | 否 | 全部 | 并发度极低 |
能看出问题吗?同样的“默认隔离级别”,在 MySQL 里是可重复读,在 PostgreSQL 里是读已提交。如果你从 MySQL 迁到 PostgreSQL,代码不改,并发行为已经不一样了。所以隔离级别在设计时就必须明确写出来,而不是依赖“默认”。
工程上怎么选?我的做法是分场景。绝大多数的读取,读已提交就够了——业务上不关心同一个事务里两次读必须一致。但涉及“先查后写”并且结果受并发影响的场景,比如库存扣减、生成唯一编号、防止重复提交,要么把隔离级别升到可重复读(MySQL 因为间隙锁机制在这一级下能挡住幻读),要么干脆在写操作上加悲观锁或乐观锁控制。串行化只在极少数强一致场景用,我不会把它当默认,因为锁竞争带来的性能下降立竿见影。
3.3 事务边界的正确姿势:短、平、快
事务设计里最重要的原则其实是“能不开就不开,开了就快点关”。为什么?因为一个事务无论是否提交,它持有的锁都是数据库资源的占用,而锁的等待是会传染的——A 事务锁住了行,B 等 A,C 等 B,很快就形成一个阻塞链。我看过太多代码把事务开在 Controller 层,一个请求进了方法就开始事务,直到所有远程调用、文件处理全部结束才提交。这种“长事务”是性能灾难的源头。
判断事务边界是否合理,看三条:事务里有没有不必要的网络调用?(有就该把事务缩小)事务里有没有分批慢查询?(有就该优化 SQL 或拆事务)事务里有没有用户交互等待?(用户思考和填表单的时间全都占着事务锁,其他请求全部排队——这是绝对不可以的)。
我习惯把事务控制在“单次业务操作的最小必要范围”。比如“下订单”这个操作:创建订单、扣减库存、写账户流水,这三步必须在一个事务里——它们属于同一个业务原子操作。但“下订单后发送短信通知”“下订单后调用风控接口”这些,就绝对不该在事务内执行。它们失败不该导致订单回滚,应该通过异步任务补偿。
3.4 传播行为:嵌套事务是逻辑陷阱的重灾区
只要用了 Spring 这样的声明式事务框架,“事务传播行为”就是一个躲不掉的设计点。最常用的是REQUIRED——如果当前没有事务就新建一个,如果有就加入当前事务。这个行为符合大多数场景:内层方法要么跟着外层一起提交,要么一起回滚。
但麻烦在于REQUIRES_NEW,它表示“不管外层有没有事务,我都新起一个独立事务”。这是我在实战里见过的最容易造成数据不一致的设置。比如某个内层方法写日志,外层业务失败回滚了,但日志却因为REQUIRES_NEW留下来了。这可能正是你想要的——但如果你没意识到这种行为,一旦拿它包住真正的业务写入,就会出现“外层失败回滚了,内层却提交了”的诡异状态,数据对不上的时候超级难查。
顺带提醒一个经典陷阱:同类内部方法调用,事务注解是不生效的。Spring 事务通过 AOP 代理实现,它只拦截代理对象的外部调用。你写一个方法,同一类里另一个方法调它,事务注解完全被忽略。很多人在这里栽跟头:外层没开事务,内层加了@Transactional,实际执行时根本没有事务保护。解决办法要么把内层方法拆到独立的 Bean 里注入调用,要么自己注入代理对象,要么把事务边界上提。总之,声明式事务的生效范围,必须靠测试来验证,而不是靠“我觉得它生效了”。
4. 核心实操:三种典型并发场景的事务实现
4.1 场景一:精确扣减库存(并发写入不超卖)
电商、票务、秒杀系统里最经典的问题:库存表只有一条记录,多个并发请求同时扣减,怎么保证不扣成负数?最直观的错误写法是:先 SELECT 查库存,判断大于零,再 UPDATE 扣减。这个写法在并发下必挂。你查的时候库存还是 1,另一个请求也查到了 1,两个人都判断大于零,都执行扣减,结果就扣成 -1 了。
正确方案之一是“安全 UPDATE”。也就是用一条带条件的 SQL 原子完成判断和扣减:
UPDATE inventory SET stock = stock - 1 WHERE product_id = ? AND stock > 0;执行后检查影响行数(affected rows)——如果为 1,说明扣减成功;如果为 0,说明库存不足,业务抛异常并回滚。关键点:stock > 0这个条件让数据库在行锁内部完成了“检查和修改”的原子操作,不需要额外的应用层锁。
为什么它在并发下是安全的?因为 UPDATE 语句执行时,InnoDB 会对命中的行加排他锁,第二个请求的 UPDATE 会阻塞,等第一个事务提交后,它会重新评估stock > 0的过滤条件。所以即使同一毫秒有 100 个请求进来,最后也只有库存量那么多个请求能成功。这是“悲观锁思想”在数据库层面的最简洁表达。
补充一点:如果业务要求不只扣库存,还要记录扣减流水、更新销量,这些要放进同一个事务。事务可以这样组织:
@Transactional public void deductStock(Long productId, int quantity) { int affected = inventoryMapper.deduct(productId, quantity); if (affected == 0) { throw new BusinessException("库存不足"); } stockLogMapper.insert(new StockLog(productId, quantity)); }不要把affected == 0的判断放在调用方的事务外层。如果事务方法内部抛出异常,Spring 会回滚整个事务,安全。但如果你在方法外判断、别处去 throw,事务边界就乱了。这个细节我可以很肯定地说:边界混乱的地方,Bug 就会在那里等着你。
4.2 场景二:防重复提交与唯一性兜底
重复提交在前端后端的攻防里是个永恒话题。按钮没禁用、用户在弱网下多点了两次、重试机制重复投递——任何一种情况都可能导致同一笔请求被执行两次。我们的目标:同一业务标识,只允许成功一次。
最可靠的兜底不是搞一个分布式锁,而是在数据库层面加“唯一键”。拿支付回调举例:支付平台会异步通知业务系统“订单已支付”,如果网络抖动,通知可能会来两三次。你就要在支付结果表上建一个唯一索引,比如(order_id, payment_channel),然后用“插入即成功”的思路:
INSERT INTO payment_result (order_id, payment_channel, status, paid_amount) VALUES (?, ?, 'SUCCESS', ?);当重复通知到来时,这条 INSERT 因为唯一键冲突直接报错。你捕获这个 DuplicateKeyException,不做回滚,直接返回成功响应——因为数据已经写进去了,这个通知就是多余的。
这么做比“先 SELECT 再判断再 INSERT”优雅得多,因为它不需要事务内额外加锁,也不需要应用层的同步锁,全靠数据库的唯一约束做原子去重。数据库的约束是最后一道防线,前端防重、后端判重都只是概率性拦截。
4.3 场景三:批量记账与部分失败的原子性
批量操作是另一个需要认真对待的事务场景。举个具体例子:财务系统里每月给一百个用户批量记账,每个用户一条流水,任何一条失败,整批都不应该生效。最基础的做法是把整个批量循环包进一个大事务。但问题是,如果真的有一百条,耗时可能很长,锁的范围也很大。这里面要做的第一件事是评估:这整个批量是否真的是一个原子业务?
如果确认是原子的,那就用一个大事务,循环里的每一条写入都使用同一连接、同一个事务上下文,单条失败抛出异常,整个事务回滚。注意一个大坑:JDBC 批量处理时,如果某条 SQL 执行异常而你没有及时退出,后续的 SQL 可能在一个已经被标记为 rollback-only 的事务里继续执行,最终即使你想正常提交,Spring 也会报UnexpectedRollbackException。正确姿势:任何一条失败立刻抛异常中断循环,由事务拦截器统一回滚。
如果你决定“每条流水应该独立生效,失败的单条单独记录”,那就不该用大事务,而应该循环开小事务,失败的单条落入失败队列做重试。这里的关键是明确业务语义:要么全有,要么全无是强一致需求;但很多“批量导入”场景其实允许部分成功,这时候强行用大事务反而会让整个导入因为一行脏数据而全部失败。工程上的成熟做法是:先批量校验数据合法性,再做事务性写入,把真正的环境性失败(比如某条唯一键冲突)隔离进错误明细。
4.4 乐观锁 vs 悲观锁:什么时候用哪个
锁选型是事务设计里绕不开的取舍。悲观锁最简单——SELECT ... FOR UPDATE,把行锁住,其他人想动这行就只能等。它的缺点是并发度低,而且如果事务忘了提交或卡住,后续请求全部排队。乐观锁则是在更新时判断版本号:
UPDATE account SET balance = balance - 100, version = version + 1 WHERE id = ? AND version = ?;如果影响行数为 0,说明版本号对不上,数据被别的请求改过了,应用层选择重试或者提示失败。
我个人的经验法则:写冲突概率低,用乐观锁,代码简单且不阻塞;写冲突概率高(比如热门库存字段被频繁扣减),悲观锁更合适,因为乐观锁的失败重试本身也消耗资源。两个都可以,最怕的是不锁。很多线上数据错乱事故,本质上就是没有做任何并发控制,然后在日志里看到同一行数据被两个线程先后覆盖——几乎无法排查,因为错误的数据长得很像正确的数据。
5. 死锁与故障排查:实战中的诊断全流程
5.1 死锁是怎么产生的,它为什么“看似随机”
死锁的经典定义是:两个事务各自持有一把锁,同时在等待对方手里的锁,谁都不让,互相阻塞。在最小服务里最典型的是“反序加锁”:事务 A 先锁行 1 再锁行 2,事务 B 先锁行 2 再锁行 1。两个事务并发跑到中间时,A 等 B 的行 2,B 等 A 的行 1,数据库检测到死锁会强制回滚其中一个事务,让另一个继续。
它的“随机性”让很多人头大,因为死锁不是每次都发生,只有两个事务恰好同时跑到那个时间窗口才有问题。这就导致测试环境基本难复现,而生产环境偶发报错。MySQL 默认会立刻检测到死锁并抛Deadlock found when trying to get lock; try restarting transaction,这个错误对应用来说是可控的——它是被设计出来让应用层重试的。关键是把“死锁必然发生”这件事当成设计输入的一部分:所有事务里的加锁顺序必须全局统一,这是降低死锁概率最有效的手段。如果让两个事务都以“先操作账户表,再操作订单表”的顺序执行,反序死锁就消失了。
5.2 一条 SQL 排查出死锁和阻塞源头
生产环境一旦出现死锁,第一步不是改代码,是拿到证据。MySQL 提供了现成的排查工具。执行SHOW ENGINE INNODB STATUS\G,输出里能看到最新一次死锁的详细信息——哪些事务、持有哪把锁、等待哪把锁、哪条 SQL 是凶手。特别注意其中的LATEST DETECTED DEADLOCK段落,里面会直接列出两个事务的 SQL 语句和加锁顺序。
如果问题不是死锁,而是“请求一直卡住,像是死锁但没有报错”,那要排查的是长时间阻塞。用下面这条 SQL 能看到当前有哪些事务正在执行、每条执行了多久:
SELECT * FROM information_schema.innodb_trx WHERE trx_state = 'RUNNING' ORDER BY trx_started;如果发现有事务已经跑了十几秒还没结束,它持有的锁会让后面所有想动同一行的请求全部排队。这时候需要快速定位到执行中的 SQL:
SELECT * FROM sys.innodb_lock_waits;它能直接告诉你:谁在等待谁、等待的时长、被阻塞的线程信息。我排查线上卡顿的基本流程就是这三条命令:先看 innodb_trx 有没有长事务,再查 lock_waits 的等待关系,最后用SHOW PROCESSLIST找到具体正在执行的 SQL 去分析。这套动作我已经用了很多年,几乎没有失手过。
5.3 锁等待超时与事务超时的工程化配置
死锁虽然会自动检测,但“锁等待”不会自动消失。如果一个事务持有的锁迟迟不释放,其他事务会一直等,默认情况下 MySQL 的innodb_lock_wait_timeout是 50 秒——如果这个值不改,你的应用会为一个等待买 50 秒的单。对在线服务来说,50 秒的等待重则拖垮整个接口,轻则让用户直接放弃请求。我通常会把innodb_lock_wait_timeout调小到 5 到 10 秒,让失败的请求快速失败,然后通过重试机制或者提示用户“稍后再试”来处理,而不是让线程池被阻塞累趴下。
同时要在应用层配置事务超时。Spring 可以指定事务的 timeout(秒级),超时后事务自动回滚,抛TransactionTimedOutException。这个参数是保护措施,防止极端情况下一个事务占住锁不放。数据库层和应用层双重超时是工程级服务的基本素养,少了任何一层都会留下死角。
5.4 死锁之后应用层该怎么办:有限重试是正确姿势
死锁发生后数据库会回滚其中一个事务,但你的应用如果直接把这个异常抛给用户,体验就很差。正确做法是捕获死锁异常后,做有限次数的重试。因为死锁源于并发瞬时碰撞,重试时往往能成功。关键是“有限次数”——我一般设 3 次,每次重试之间做很小的随机退避(比如 20ms 到 50ms),避免所有重试请求扎堆再来一次碰撞。
@Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) { accountDao.deduct(fromId, amount); accountDao.add(toId, amount); }这个简单转账方法在并发下就可能发生死锁——两个账户互相转账时加锁顺序相反。为了保证成功率,在调用方包一层重试:
public void transferWithRetry(Long fromId, Long toId, BigDecimal amount) { int retryCount = 0; while (retryCount < 3) { try { transfer(fromId, toId, amount); return; } catch (DeadlockLoserDataAccessException e) { retryCount++; Thread.sleep(20L * retryCount); } } }注意:DeadlockLoserDataAccessException是 Spring 对数据库死锁错误的统一包装,捕获它才能覆盖不同数据库的差异。重试方法的另一个要点:它本身不能有@Transactional注解,否则重试调用的是同一个已经回滚的事务上下文,第二、三次尝试根本“没有事务可开”。
6. 面向工程的细节完善与个人经验
6.1 数据库 Schema 变更要纳入版本管理
最小服务很容易犯“手动改数据库”的毛病。今天加一个字段,明天删一个索引,全靠人肉执行 SQL。时间久了,你的测试环境、生产环境、开发机器 Diff 越来越大,最终失去可复制部署的能力。工程级方案是引入 migrations 工具——Java 侧可以用 Flyway 或 Liquibase,运行时自动执行脚本,把 Schema 版本和代码版本绑定在一起。这样每一次代码发布,对应的表结构变更也会跟着自动应用,多环境保持一致性。
我在项目里养成的习惯是:每条 migration 只做一件事,且必须是向后兼容的——先加字段,再改代码,最后删旧字段。这保证了发布窗口内新旧代码可以共存,不会因为 Schema 变更导致正在运行的老版本代码报错。
6.2 读写分离和索引设计要不要现在做
最小服务的读写分离,我建议看规模。如果读多写少,且读流量已经导致主库 CPU 飙高,那是值得做的。但如果业务根本没到那个量级,强行引入主从复制,换来的是主从延迟带来的一堆一拍脑袋的 Bug——刚写完就读到了旧数据,这在事务设计里属于精力浪费。一般我会优先做索引优化,也就是看慢查询日志,把缺失的索引补上,再看是否需要读写分离。
索引设计上有个容易踩的点:不要在低区分度字段上建索引(例如性别、状态),它会导致优化器干脆放弃使用索引做全表扫描。另一个点是联合索引的最左前缀原则:建 (a, b, c) 索引可以同时覆盖 a、(a, b)、(a, b, c) 三种查询,但单独查 b 或 c 用不到这个索引。设计索引时先看你的核心查询长什么样,再决定字段顺序。
6.3 事务代码写成什么样才算“好读”
代码可读性对事务尤其重要,因为别人维护你的事务代码时,最容易产生“加了点逻辑但没意识到它破坏了事务边界”的二次事故。我自己偏好的结构是:事务方法体里只做这个原子业务必须做的事,所有非事务性操作(发通知、写审计日志的异步部分、外部调用)放在事务方法外面。辅以清晰的命名:方法名直接叫deductStock、transferWithRetry、createOrderAtomically,让读代码的人第一眼就知道这是个不能拆分的事务边界。
异常处理也要特别干净。不要在事务方法内部 try-catch 吞异常后返回正常结果——你一旦把异常吞掉,事务框架就不会收到错误信号,不会回滚,数据就错进去了。如果确实需要捕获异常做额外逻辑(比如库存不足时记录错误),在 catch 块里抛一个新的业务异常,保证事务仍然回滚。这个写法和很多初学者的直觉相反,但它是防止“事务失效”的保命细节。事务设计的关键不只是“用对注解”,而是理解它背后的锁、隔离、边界、异常传播机制,然后在一个具体业务里知道什么时候用、什么时候不该用、遇到锁竞争怎么快速逃生。我把自己踩过的坑和沉淀下来的方法都写在这里了,希望能让你少走几次弯路。