一句话导读:在服务方法上打一个
@Transactional,然后在方法里先调一次远程接口拿数据、再落库,是微服务里最常见也最容易被放过的一种写法。它有两个问题:事务会一直占着数据库连接,直到那次远程调用返回;而且@Transactional很可能压根没生效。本文讲清事务到底占住了什么、为什么必须"先远程后事务"、TransactionTemplate与注解的取舍、并发下的状态守卫,以及为什么"跨服务只读"这条原则能让整个系统绕开分布式事务。
一段看起来很正常的代码
消息服务的发布动作需要三类数据才能落库:这条消息的协会范围(来自协会服务)、接收对象的候选账号(来自认证服务)、以及协会下的归属账号(来自协会服务)。业务上这些是同一个动作的一部分,于是最自然的写法就是把它们放进一个事务里。
/** 问题形态:远程解析与落库被同一个事务包住 */
@Transactional
public Long create(MessageSaveRequest request, String token) {
// 1. 远程取参照数据与数据权限范围
RefsContext refs = loadRefs(token);
// 2. 远程取发送人
AccountBriefDTO sender = authRefClient.currentUser(token);
message.setSenderId(sender.getId());
// 3. 远程解析收件人(两次远程调用:候选账号 + 协会归属账号)
List<Long> recipientIds = resolveRecipientIds(message, assocIds, token);
// 4. 落库
messageMapper.insert(message);
messageAssocMapper.batchInsert(message.getId(), assocIds);
messageRecipientMapper.batchInsert(message.getId(), RECEIVER_ADMIN, recipientIds);
return message.getId();
}
单机压测时它跑得很好,因为远程调用在同一个内网里只要几毫秒。问题要到并发上来、或者对端偶发变慢的时候才暴露——而那时候暴露出来的现象是"本服务接口大面积超时",看起来和远程服务毫无关系,排查方向很容易跑偏。
这里其实叠了两个独立的坑:事务把远程调用包了进去,以及 @Transactional 本身可能没生效。分开说。
事务占住的不只是数据,还有连接
事务开启的那一刻,它从连接池借走了一条数据库连接,直到事务提交或回滚才还回去。这是理解整件事的起点:事务的持续时间,等于这条连接被独占的时间。
连接池的容量是有限的。本项目里持有数据库的三个服务(认证、协会、消息)统一配的是 Hikari,maximum-pool-size 是 10。于是可以写出一个粗略但够用的关系。
吞吐 ≈ 可用连接数 ÷ 单次持有连接的时长
把两种写法代进去。纯落库动作,假设一次事务 2 毫秒。
10 ÷ 0.002 秒 = 5000 次/秒
如果在事务里加一次 200 毫秒的远程调用。
10 ÷ 0.2 秒 = 50 次/秒
同样的连接池,吞吐差了一百倍。而且这个 200 毫秒完全不受本服务控制——对端一次慢查询、一次 Full GC、对端自己的连接池排队,都会如实传导过来。本服务的可用性就此绑在了对端的响应时间上。
flowchart TB
subgraph bad["远程调用在事务内"]
direction TB
B1["开启事务<br/>借走 1 条连接"] --> B2["远程调用<br/>连接被占住空等"]
B2 --> B3["落库"]
B3 --> B4["提交<br/>归还连接"]
end
subgraph good["先远程、后事务"]
direction TB
G1["远程调用<br/>不占连接"] --> G2["开启事务<br/>借走 1 条连接"]
G2 --> G3["落库"]
G3 --> G4["提交<br/>归还连接"]
end
%% 不可见连线:把两个子图拉到同一层左右并排,便于逐行对比
bad ~~~ good
两种写法的落库动作完全相同,差别只在于那条连接被占住的时长。
对于 PostgreSQL,还有一个容易被忽略的连带影响:长事务会拖住 autovacuum。数据库要保留最老那个活跃事务还能看见的版本,只要事务不结束,期间产生的死元组就无法回收,表会持续膨胀;同时事务里持有的行锁也会一直不释放,锁等待随之上升。这两件事都不会立刻报错,只是在负载上来之后慢慢恶化。
还有一种情况会把问题放大:如果远程调用带了超时重试,事务会一直开着等重试跑完。三次重试、每次超时 3 秒,就是 9 秒的连接占用——一条连接在这 9 秒里什么都做不了。
@Transactional 的静默失效
第二个坑更隐蔽:那个注解可能根本没有生效。
Spring 的 @Transactional 依赖代理实现。外部调用先经过代理,代理负责开启事务、执行目标方法、提交或回滚。但同类内部的方法互相调用,走的是 this,不经过代理,事务逻辑也就不会被触发。
@Service
public class MessageService {
@Transactional
public void create(MessageSaveRequest request, String token) {
// ...
}
/** 批量新建:循环里调用同类方法 */
public void batchCreate(List<MessageSaveRequest> requests, String token) {
for (MessageSaveRequest request : requests) {
create(request, token); // 自调用,@Transactional 不生效
}
}
}
这段代码不会报错,日志正常,接口返回正常,事务就是不生效。一旦中间某一步失败,前面已经写入的数据会留在库里。这是最需要警惕的一类问题:失效是静默的,只能靠人工审查或者专门的测试发现。
规避它有两条路。一是把带事务的方法拆到另一个 Bean 里,通过注入调用,让它必然经过代理。二是干脆不用注解,改用 TransactionTemplate 显式划定边界。
本项目选了第二条,理由和下一节有关:事务边界需要和"编排逻辑"共处一个方法时,模板写法比注解更直白,也更不容易被后续重构改坏。
private final TransactionTemplate transactionTemplate;
public MessageServiceImpl(/* ... */ TransactionTemplate transactionTemplate) {
this.transactionTemplate = transactionTemplate;
}
注入 TransactionTemplate 之后,事务的范围在代码里是可见的——你能一眼看出哪几行在事务内,而不是靠一个注解去推断。
正确形态:先远程,后事务
把编排和落库分开,让事务只包住纯落库动作。
@Override
public Long create(MessageSaveRequest request, String token) {
// 1. 参数校验(纯本地,无需事务)
String title = Strings.trimToNull(request.getTitle());
String content = Strings.trimToNull(request.getContent());
if (title == null || content == null || title.length() > 200) {
throw new BizException(ErrorCode.PARAM_ILLEGAL);
}
// 2. 远程解析:参照数据、发送人、收件人,全部在事务外完成
RefsContext refs = loadRefs(token);
List<Long> assocIds = normalizeAssocIds(request.getAssocIds());
assertAssocScope(assocIds, refs);
AccountBriefDTO sender = authRefClient.currentUser(token);
message.setSenderId(sender.getId());
message.setSenderName(displayName(sender));
boolean publish = Boolean.TRUE.equals(request.getPublish());
List<Long> recipientIds = publish ? resolveRecipientIds(message, assocIds, token) : List.of();
// 3. 落库:事务只包住这一段
Long id = transactionTemplate.execute(tx -> {
messageMapper.insert(message);
if (!assocIds.isEmpty()) {
messageAssocMapper.batchInsert(message.getId(), assocIds);
}
if (publish) {
messageMapper.updatePublish(message.getId(), recipientIds.size(), OffsetDateTime.now());
if (!recipientIds.isEmpty()) {
messageRecipientMapper.batchInsert(message.getId(), RECEIVER_ADMIN, recipientIds);
}
}
return message.getId();
});
log.info("新建消息:id={}, msgType={}, targetType={}, 协会数={}, 发布={}",
id, msgType, message.getTargetType(), assocIds.size(), publish);
return id;
}
类上的注释把这个约定写了下来,避免后续维护时被无意改回去。
/**
* <p><b>事务边界</b>:跨服务调用(参照数据、候选账号、协会归属账号)一律在事务外完成,
* 事务只包住纯落库动作,避免远程调用占住数据库连接。事务用 {@link TransactionTemplate}
* 显式划定,避免同类内自调用绕过代理导致注解失效。</p>
*/
用日志的位置也能看出来:log.info 放在事务外。日志写盘、上报、序列化这些动作本身可能阻塞,没必要让它们占着连接。
三个方法都按同一把尺子改过。删除动作里,远程校验和落库被明确分开。
@Override
public void delete(Long id, String token) {
// 与详情同一把尺子:范围外的消息按不存在处理,避免越权删除
RefsContext refs = loadRefs(token);
VisibleMessage visible = loadVisibleMessage(id, refs);
transactionTemplate.executeWithoutResult(tx -> {
messageMapper.softDelete(id);
messageAssocMapper.softDeleteByMessageId(id);
messageRecipientMapper.softDeleteByMessageId(id);
});
log.info("删除消息:id={}, title={}", id, visible.message().getTitle());
}
拆边界不等于拆提交
把远程调用移出事务,很容易顺手做过头:既然事务要短,那就每步落库各自提交。
// 反例:三次独立提交
messageMapper.insert(message); // 提交 1
messageAssocMapper.batchInsert(id, assocIds); // 提交 2
messageRecipientMapper.batchInsert(id, recipientIds); // 提交 3
中间任何一步失败,库里就留下一个半成品:消息建好了、协会范围没落、收件人是空的。这类数据不会自己修复,会以"列表里有这条消息,但点进去受众为空"的形式长期存在,而且极难归因。
正确做法是:事务的边界可以移动,但一次业务动作的原子性不能拆。 新建一条消息要落的四件事——消息本体、协会关系、发布状态、收件人——必须在同一个事务里。
Long id = transactionTemplate.execute(tx -> {
messageMapper.insert(message);
if (!assocIds.isEmpty()) {
messageAssocMapper.batchInsert(message.getId(), assocIds);
}
if (publish) {
messageMapper.updatePublish(message.getId(), recipientIds.size(), OffsetDateTime.now());
if (!recipientIds.isEmpty()) {
messageRecipientMapper.batchInsert(message.getId(), RECEIVER_ADMIN, recipientIds);
}
}
return message.getId();
});
区别在于:远程调用移出事务,事务只包住落库;但落库内部不再切分。前者省下的是连接占用时间,后者保证的是数据一致性,两件事互不冲突。
远程失败不进事务
先远程、后事务还有一个附带好处:远程调用失败时,事务还没有开启,不产生任何需要回滚的脏写。
// 远程调用失败会在这里抛出,此时尚未开启事务
List<Long> recipientIds = resolveRecipientIds(message, assocIds, token);
AuthRefClient 对远程异常做了统一翻译,认证服务不可用时抛业务异常,不静默返回空集合。
/** 统一异常翻译:401 视为未登录,连接失败视为认证服务不可用 */
private <T> Result<T> call(java.util.function.Supplier<Result<T>> action) {
try {
return action.get();
} catch (RestClientResponseException ex) {
if (ex.getStatusCode() == HttpStatus.UNAUTHORIZED) {
throw new BizException(ErrorCode.NO_LOGIN);
}
throw new BizException(ErrorCode.BIZ_AUTH_UNAVAILABLE);
} catch (RestClientException ex) {
throw new BizException(ErrorCode.BIZ_AUTH_UNAVAILABLE);
}
}
这里刻意选择了"失败即拒绝",而不是"远程挂了就返回空列表"。后者会让一次远程故障变成一条收件人为空的消息——数据看着正常,实际没有送到任何人手上,比直接报错危险得多。
不过"先远程后事务"能解决的是本地事务的问题,它不解决分布式一致性问题。所以还有一条更根本的设计原则需要说明。
一条让系统绕开分布式事务的原则
看本项目的跨服务调用清单,会发现一个共同点:它们全是读。
| 调用 | 方向 | 语义 |
|---|---|---|
| 取角色分组参照 | message → auth | 读 |
| 解析接收对象候选账号 | message → auth | 读 |
| 取协会与数据权限范围 | message → assoc | 读 |
| 取协会下归属账号 | message → assoc | 读 |
| 校验会话是否有效 | portal / message / assoc → auth | 读 |
没有一次是"让远程服务替我写点什么"。这条原则的价值在于:跨服务的读可以随便调,跨服务的写才是分布式事务的入口。
读操作是幂等的,失败重试没有副作用,慢一点也只是慢,不会让两边数据对不上。而一旦出现"本地写 + 远程写"要一起成功或一起失败的需求,就进入了分布式事务的地界:两阶段提交、TCC、Saga、本地消息表,每一种都要付出可观的复杂度,而且都有各自的失败模式。
把这些需求在设计阶段就排除掉,整个系统就只需要处理本地事务。具体做法是把"写"限制在单个服务内:需要跨服务协作时,让数据的归属方自己去写,其它服务只读它的结果。本项目里"账号归属 auth、协会归属 assoc、消息归属 message"这条边界,正是为了让每个写动作都能在一个服务的本地事务里闭环。
如果将来确实出现了无法回避的跨服务写,正确的方向也不是引入分布式事务框架,而是"本地事务 + 可靠事件 + 最终一致":本地事务提交时把事件写进同一张表,再由异步投递保证送达,让下游重试到成功为止。这条路的代价是可接受的,而分布式事务框架的代价往往不可接受。
并发下的状态流转:先查后改是事故温床
事务边界处理完了,还有一类并发问题会在同一个方法里出现:带状态机的更新。
消息从草稿变成已发布,业务上只能发生一次。最容易写出的实现是先查状态、再更新。
// 反例:check-then-act
Message message = messageMapper.selectById(id);
if (STATUS_PUBLISHED.equals(message.getStatus())) {
throw new BizException(ErrorCode.BIZ_MESSAGE_NOT_EDITABLE);
}
// 两个并发请求都可能通过上面这个判断
messageMapper.updateStatus(id, STATUS_PUBLISHED);
messageRecipientMapper.batchInsert(id, RECEIVER_ADMIN, recipientIds); // 收件人被落两遍
这是典型的 check-then-act:检查和使用之间存在一个窗口,两个请求可以同时看到"还是草稿"。用户双击发布按钮、或者两个人同时点,就会命中这个窗口。结果是收件人被写入两遍,如果表上有唯一约束则直接报错,没有约束则产生重复数据。
正确做法是把前置状态交给数据库判断,也就是放进 UPDATE 的 WHERE 里。
<!-- 状态守卫:仅草稿可发布,并发重复发布只有一次能命中(影响行数 0 即已被抢先) -->
<update id="updatePublish">
UPDATE tb_message
SET status = 'PUBLISHED',
published_at = #{publishedAt},
recipient_count = #{recipientCount},
updated_at = now()
WHERE id = #{id} AND deleted_at IS NULL AND status = 'DRAFT'
</update>
服务层检查影响行数,为 0 表示已经被别人抢先发布了。
transactionTemplate.executeWithoutResult(tx -> {
// 状态守卫:DRAFT → PUBLISHED 只有一次能抢到,并发重复发布不会重复落收件人
int updated = messageMapper.updatePublish(id, recipientCount, OffsetDateTime.now());
if (updated == 0) {
log.info("发布已被并发请求抢先,按幂等返回:id={}", id);
return;
}
if (!recipientIds.isEmpty()) {
messageRecipientMapper.batchInsert(id, RECEIVER_ADMIN, recipientIds);
}
log.info("发布消息:id={}, msgType={}, 收件人数={}", id, message.getMsgType(), recipientCount);
});
这里的判断逻辑值得展开三点。
影响行数为 0 不是失败。 它的语义是"这个动作已经被完成过了"。对于发布这种幂等动作,正确的响应是正常返回,而不是抛异常。把它当成错误处理,用户双击之后就会看到一个莫名其妙的报错。
不要用唯一索引报错来兜底。 有人会把并发控制交给数据库约束,靠捕获 DuplicateKeyException 来判断"是不是重复了"。这样做有两个问题:并发这个正常行为被当成了异常,日志里会出现大量误导性的错误;而且异常会中断整个事务,后续逻辑没有机会做幂等处理。
这个技巧适用于所有带状态机的流转。 草稿到已发布、待审到通过、未支付到已支付、待发货到已发货——凡是"某个状态只能进入一次"的场景,都可以把前置状态写进 WHERE,用影响行数来裁决。
需要留意的是,服务层在事务外的那次状态检查(if (STATUS_PUBLISHED.equals(message.getStatus())))只是为了让常规请求早一点拿到明确的错误提示,它不承担并发控制。真正的裁决在 UPDATE 的 WHERE 里。两者不是重复,前者负责体验,后者负责正确。
几个容易混淆的边界
事务里能不能发消息、写日志、调缓存。 原则上都不建议。它们都会让事务变长,而且它们本身不是数据库操作,不参与回滚——事务回滚了,已经发出去的消息和已经写下的日志不会跟着消失。发消息尤其危险,一旦放在事务内,事务回滚后消息已经发出去了,下游会按一条不存在的数据继续处理。正确做法是把这些动作放到事务提交之后。
事务里能不能做远程读。 这是本文的核心红线,不能。远程读虽然不写数据,但它占用的时间和本文第一节说的一样长。
事务里能不能重试。 不能。重试会把连接的占用时间乘以重试次数,而且事务内的重试通常意味着捕获异常后继续,这会破坏事务的回滚语义。
事务里能不能有耗时的本地计算。 可以但要警惕。参数校验、对象组装这类微秒级动作无所谓;如果出现循环处理几万条数据、或者生成一份报表,同样应该移到事务外。
事务方法能不能返回远程调用的结果。 这里要小心的是"在事务内发起远程调用并把结果作为返回值",它和上面说的是同一个问题。返回值本身没有影响,发起调用的位置才是关键。
小结
远程调用和事务边界这件事,可以压缩成一句话:先远程、后事务,事务只包住纯落库,且一次业务动作的落库不切分。
支撑这句话的是两个机制上的事实。事务会独占一条数据库连接直到它结束,所以事务里任何不受控的耗时(远程调用、重试、大量计算)都会直接折算成本服务的吞吐下降。而 @Transactional 依赖代理,同类自调用会静默失效,所以事务边界更适合用 TransactionTemplate 写成可见的代码。
在这之上还有两层约定。一层是并发控制:带状态机的更新必须把前置状态写进 WHERE,用影响行数裁决,而不是先查后改。另一层是架构层面的:跨服务只读,写动作限制在数据归属方内部闭环——这条原则让整个系统不需要分布式事务。
四件事合起来,才构成一个可以长期维护的边界。
评论 (0)
还没有评论,来抢沙发。