递归保护
事件驱动的架构有个经典病:A 的事件触发 B,B 的事件又触发 A。看起来合理,跑起来就是一个死循环,把线程和 CPU 烧干。
soda 用两层防护处理这件事。它们的区别是:一层管同步调用栈,一层管异步消息链路。
为什么需要两层
| 同步递归 | 异步递归 | |
|---|---|---|
| 形态 | A 的 CommandHandler 里直接 sendCommand 调 B,B 里又调回 A | A 发事件 → B 消费 → B 发事件 → A 消费 → … |
| 特点 | 同一个线程,栈越来越深 | 跨线程甚至跨服务,栈不深但消息无限循环 |
| 检测手段 | 栈深度 | 消息上携带的跳数 |
只防同步不防异步是不够的 —— 异步那层才是真正危险的(它可能跨服务,靠栈深度根本看不见)。只防异步不防同步也不行 —— 同步那层栈会真的溢出。
第一层:同步深度上限
BusFacade 用两个 ThreadLocal 记录同步调用深度:
private static final int MAX_RECURSION_DEPTH = 10;
private static final ThreadLocal<Integer> recursionDepth =
ThreadLocal.withInitial(() -> 0);
private static final ThreadLocal<StringBuilder> recursionTrace =
ThreadLocal.withInitial(StringBuilder::new);sendCommand / sendAsyncCommand / sendTransactCommand 三个方法进入时都检查:
int depth = recursionDepth.get();
if (depth > MAX_RECURSION_DEPTH) {
String trace = recursionTrace.get().toString();
logger.error("[BusFacade] Command recursion too deep! Trace: {}", trace);
throw new IllegalStateException("Command recursion too deep! Trace: " + trace);
}
recursionDepth.set(depth + 1);
recursionTrace.get().append("->").append(command.getClass().getSimpleName());
try {
return commandBus.send(command);
} finally {
recursionDepth.set(depth); // 退栈
StringBuilder trace = recursionTrace.get();
int idx = trace.lastIndexOf("->");
if (idx >= 0) trace.delete(idx, trace.length()); // 同时弹出调用路径
}三个细节让报错变得有用:
recursionTrace记录了完整的调用路径,形如->CreateOrderCommand->DeductStockCommand->CreateOrderCommand。报错时直接能看到在哪绕回去了。finally里退栈 —— 异常抛出时深度也会正确回退,不会污染同线程的后续请求。调用路径也同步弹出,所以 trace 永远反映当前的调用栈,而不是历史累积。- 判断是
depth > 10,也就是嵌套 11 层仍然放行,第 12 层被拦。
报错信息里带调用链,比只报一个"too deep"有用得多。排查时先看 trace 的尾部,那里就是循环的起点。
sendQuery 没有递归保护。 查询走的是 queryBus.send(query),不进深度检查也不记 trace。查询本应无副作用、不会互相触发,如果你的查询里发命令,那是要绕开整个保护机制的 —— 别这么设计。
第二层:异步 hopCount
异步那层靠消息上携带的 hopCount。
计数怎么涨
命令总线切面,每发一次命令就 +1:
Integer hopCount = DomainEventContext.getHopCount();
int currentHop = (hopCount != null) ? hopCount : 0;
if (currentHop > 20) {
logger.error("[CqrsAroundHandler] Async recursion too deep! hopCount={}, command={}",
currentHop, cmd.getClass().getSimpleName());
throw new IllegalStateException("Async recursion too deep! hopCount=" + currentHop);
}
cmd.setHopCount(currentHop + 1);计数怎么传
关键在于 hopCount 是 BaseCommand 和 AbstractDomainEvent 上的字段 —— 它跟着消息载荷走。
命令(hopCount=0)
│ 命令总线切面:检查 → +1 → hopCount=1
▼
CommandHandler 执行 → 聚合根挂事件
│ injectContext + 仓储切面:事件继承 hopCount=1
▼
事件(hopCount=1) → 跨服务投递
│ 消费时切面从事件恢复 hopCount=1
▼
消费者里发新命令
│ 命令总线切面:读到 hopCount=1 → +1 → hopCount=2
▼
命令(hopCount=2) → ...所以跨服务也断不了 —— 因为跳数写在消息里,服务 B 收到时它就在事件上了。
超限的表现
throw new IllegalStateException("Async recursion too deep! hopCount=" + currentHop);注意阈值判断是 currentHop > 20,所以实际能跑 21 跳,第 22 跳被拦。
两层怎么配合
┌─────────────────────────────────────────────────┐
│ A 的 CommandHandler │
│ ├─ sendCommand(B) ← 同步深度 +1 (1/10) │
│ │ └─ B 的 CommandHandler │
│ │ ├─ sendCommand(A) ← 同步深度 +1 │
│ │ │ └─ A 的 CommandHandler │
│ │ │ └─ sendCommand(B) ... │
│ │ │ ✗ 深度 10 → 抛异常 │
│ │ └─ (或)发事件 │
│ │ └─ 事件 hopCount=1 跨服务 │
│ │ └─ B 消费 → 发命令 │
│ │ hopCount=2 ... │
│ │ ✗ >20 抛异常 │
└─────────────────────────────────────────────────┘| 同步深度 | 异步 hopCount | |
|---|---|---|
| 上限 | 10(判断 depth > 10) | 20(判断 currentHop > 20) |
| 载体 | ThreadLocal(BusFacade) | 消息字段(BaseCommand / AbstractDomainEvent) |
| 作用域 | 同线程同调用栈 | 跨线程、跨服务 |
| 覆盖方法 | sendCommand / sendAsyncCommand / sendTransactCommand | 所有经命令总线的命令 |
| 报错信息 | Command recursion too deep! Trace: ->A->B->A | Async recursion too deep! hopCount=21 |
| 触发时机 | 调用进入时 | 发命令时 |
怎么设计才不会踩到
递归保护是兜底,不是设计。触发保护说明业务上有循环。下面是常见的合法与非法模式。
合法:单向链路
创建订单 → 发订单事件 → 扣库存(消费事件)这是一条有向无环的链,不会循环。
合法:带条件的收敛
订单事件 → 审核通过 → 发审核事件 → 触发下游
审核事件 → 若状态已终结则忽略关键是消费端要有终止条件。事件处理时检查聚合根当前状态,不满足就直接返回。
非法:互相触发
订单事件 → 触发订单修改 → 又发订单事件 → 触发订单修改 → …这在框架层面无法区分"合法的连续变更"和"意外的循环"。soda 只能保证它停下来,而不是自动判断该不该停。
如果你的业务确实需要"改一次触发多次",考虑用事件版本号或状态机做收敛,而不是放宽 hopCount 上限。放宽上限只会把问题从"20 跳后报错"变成"跑满 CPU 后 OOM"。
调优
两个上限都是编译期常量,不能通过配置改:
private static final int MAX_RECURSION_DEPTH = 10; // BusFacade
if (currentHop > 20) { ... } // CqrsAroundHandler要改得改框架源码。实践中一般不需要改 —— 真需要超过 10 层同步嵌套时,问题多半在设计而不是限制。
排查
| 现象 | 原因 |
|---|---|
Command recursion too deep! Trace: ->A->B->A->B | 同步递归,trace 尾部就是循环起点 |
Async recursion too deep! hopCount=21 | 异步循环,事件在服务间反复触发 |
| 偶发触发,别的环境正常 | 检查消费端有没有终止条件,是否依赖了外部状态 |
排查异步递归时,先在两个服务的日志里按 requestId 串起来看 —— 上下文传递保证了同一条链路的事件有同一个 requestId,这是定位循环最有效的手段。参见 上下文传递。