soda docs v17.0.1

递归保护

事件驱动的架构有个经典病:A 的事件触发 B,B 的事件又触发 A。看起来合理,跑起来就是一个死循环,把线程和 CPU 烧干。

soda 用两层防护处理这件事。它们的区别是:一层管同步调用栈,一层管异步消息链路。


为什么需要两层

同步递归异步递归
形态A 的 CommandHandler 里直接 sendCommand 调 B,B 里又调回 AA 发事件 → B 消费 → B 发事件 → A 消费 → …
特点同一个线程,栈越来越深跨线程甚至跨服务,栈不深但消息无限循环
检测手段栈深度消息上携带的跳数

只防同步不防异步是不够的 —— 异步那层才是真正危险的(它可能跨服务,靠栈深度根本看不见)。只防异步不防同步也不行 —— 同步那层栈会真的溢出。


第一层:同步深度上限

BusFacade 用两个 ThreadLocal 记录同步调用深度:

java
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 三个方法进入时都检查:

java
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:

java
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 上的字段 —— 它跟着消息载荷走。

text
命令(hopCount=0)
   │  命令总线切面:检查 → +1 → hopCount=1
   ▼
CommandHandler 执行 → 聚合根挂事件
   │  injectContext + 仓储切面:事件继承 hopCount=1
   ▼
事件(hopCount=1) → 跨服务投递
   │  消费时切面从事件恢复 hopCount=1
   ▼
消费者里发新命令
   │  命令总线切面:读到 hopCount=1 → +1 → hopCount=2
   ▼
命令(hopCount=2) → ...

所以跨服务也断不了 —— 因为跳数写在消息里,服务 B 收到时它就在事件上了。

超限的表现

java
throw new IllegalStateException("Async recursion too deep! hopCount=" + currentHop);

注意阈值判断是 currentHop > 20,所以实际能跑 21 跳,第 22 跳被拦。


两层怎么配合

text
┌─────────────────────────────────────────────────┐
│  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->AAsync recursion too deep! hopCount=21
触发时机调用进入时发命令时

怎么设计才不会踩到

递归保护是兜底,不是设计。触发保护说明业务上有循环。下面是常见的合法与非法模式。

合法:单向链路

text
创建订单 → 发订单事件 → 扣库存(消费事件)

这是一条有向无环的链,不会循环。

合法:带条件的收敛

text
订单事件 → 审核通过 → 发审核事件 → 触发下游
审核事件 → 若状态已终结则忽略

关键是消费端要有终止条件。事件处理时检查聚合根当前状态,不满足就直接返回。

非法:互相触发

text
订单事件 → 触发订单修改 → 又发订单事件 → 触发订单修改 → …

这在框架层面无法区分"合法的连续变更"和"意外的循环"。soda 只能保证它停下来,而不是自动判断该不该停。

注意

如果你的业务确实需要"改一次触发多次",考虑用事件版本号或状态机做收敛,而不是放宽 hopCount 上限。放宽上限只会把问题从"20 跳后报错"变成"跑满 CPU 后 OOM"。


调优

两个上限都是编译期常量,不能通过配置改:

java
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,这是定位循环最有效的手段。参见 上下文传递。