写在前面
业务代码里最容易长出"屎山"的两类逻辑——横切关注点(cross-cutting concerns)和副作用副流程:
- 日志、权限、审计、缓存、限流、监控埋点 → 横切关注点
- 用户注册后送积分、下单后通知库存、修改后清缓存 → 副流程
一旦把这些事写进业务方法,主线逻辑就被淹没:
| |
业务和"基础设施 + 副流程"完全混在一起。
Spring 提供了两种让业务代码保持纯粹的武器——AOP 和 事件监听。本文把它们的适用场景、边界、最佳实践讲清楚。
一、AOP:解决"横切关注点"
AOP 在解决什么
AOP 把"和业务逻辑交叉但本身不属于业务"的代码抽出来,让业务方法只剩"业务":
| |
业务代码变得极简,所有横切逻辑由 AOP 统一处理。
一个完整例子:方法耗时日志
| |
业务方法加 @LogTime 即可。
AOP 的典型适用场景
| 场景 | 注解风格 |
|---|---|
| 方法耗时日志 | @LogTime |
| 接口幂等 | @Idempotent |
| 限流 | @RateLimit(qps = 100) |
| 缓存 | @Cached(ttl = 5m) |
| 数据权限 | @DataScope |
| 重试 | @Retry(times = 3) |
| 分布式锁 | @DistributedLock(key) |
| 操作日志 | @OperationLog("创建订单") |
共同特征:和业务无关、跨多个方法、可以"声明式开关"。
AOP 的限制和坑
1. Spring AOP 是基于代理的——同类调用不生效
| |
代理只在外部调用入口生效,类内部 this.method() 调用绕过了代理。解决:
| |
或者把 b 拆到另一个 Service。
2. private 方法不能被切
代理基于继承/接口,private 方法直接绕过。
3. AOP 调试链路长
stack trace 里多了 CglibProxy、JdkDynamicAopProxy 这些层,新人看不懂。
4. 滥用会让代码难追
业务方法签名上看到一堆 @LogTime @Cached @Idempotent @RateLimit @OperationLog @DistributedLock——不熟悉项目的人根本不知道每个注解干啥,行为变成"看不见的魔法"。
经验上:业务方法上的 AOP 注解多于 3 个就值得复盘,多于 5 个往往是过度设计的信号。
二、事件监听:解决"副流程解耦"
Spring 事件机制
业务做完主线后,需要触发一系列副流程——传统写法是直接调用:
| |
每加一个副流程都要改这个方法,代码越来越臃肿。事件机制的解法——业务方法只发一个事件,副流程自己订阅:
| |
每个副流程独立、可单独测试、加新流程不动主业务。
同步 vs 异步
@EventListener 默认是同步的——和业务在同一个线程、同一个事务。这意味着:
- 任何 listener 抛异常,主业务回滚
- listener 慢,主业务也慢
要改成异步,加 @Async:
| |
这时 listener 在另一个线程跑,和主业务事务无关——常用于"发短信、发 MQ"这类不重要、可重试的副流程。
@TransactionalEventListener:等事务提交后再触发
最容易踩的坑——publishEvent 是在主业务事务提交之前调用的(在业务方法内)。所以无论同步还是异步监听器,listener 看到的都是"事务还没提交"的状态——副流程里去查 DB 可能查不到刚写入的数据:
| |
注意:这与"是否异步"无关——事件触发点本身就在事务提交之前。
正确写法:
| |
AFTER_COMMIT 是最常用——只有主事务提交后才触发。生产场景里只要副流程要读 DB 数据,几乎都要用它。
事件机制的典型适用场景
- 领域事件:用户注册、订单创建、支付完成
- 副流程触发:积分、消息推送、缓存清理
- 解耦不同模块:订单模块发事件,营销模块、库存模块、积分模块各自订阅
- 审计 / 操作记录
事件机制的限制和坑
1. 事件不要变成"远程过程调用"
| |
事件是单向通知,不是"调用"。需要返回值的逻辑应该走普通 Service 调用。
2. 异步事件 + 事务
@Async + @EventListener 跨线程后默认丢失主线程的事务上下文——副流程内部的 DB 操作处于"自己开新事务"模式。要小心数据一致性。
3. 事件溯源能力弱
Spring 事件是进程内的——重启就没了,没有"事件回放"能力。要做事件溯源(Event Sourcing)需要更专业的方案(Axon、自己写 outbox)。
4. 链式事件失控
A 事件触发 B listener,B listener 又发 C 事件,C 又发 D……复杂业务里事件链能绕成一团乱麻。控制好事件层级,建议不超过 2 跳。
三、AOP vs 事件:怎么选
| AOP | 事件监听 | |
|---|---|---|
| 目标 | 横切关注点 | 副流程解耦 |
| 与主业务关系 | “包"主业务(before/after/around) | 主业务发出,副流程自己订阅 |
| 多对一? | 单切面 → 多方法 | 单事件 → 多 listener |
| 跨事务? | 同事务 | 可配置(同步/异步/事务后) |
| 业务感知度 | 业务方法加注解或不加 | 业务方法显式 publishEvent |
| 可见性 | “魔法” | 比 AOP 显式 |
简单判断:
- 跟业务逻辑无关、想做声明式开关 → AOP
- 业务做完后需要触发若干副流程 → 事件监听
- 既不是横切又不是副流程 → 直接调 Service
四、典型场景示例对比
场景:下单后清缓存
❌ 反例:写在业务里
| |
✅ 用 AOP(声明式缓存)
| |
适合"创建/修改/删除都要清同样的 cache”——纯横切。
✅ 用事件(多个副流程都要做)
| |
副流程多、需要解耦时用事件。
五、组合用法
实际项目里两者经常配合:
| |
- AOP 处理"操作日志"这种横切——业务无感
- 事件处理"积分/通知/MQ"这种副流程——主业务只发事件
六、几条工程惯例
1. 注解上限
业务方法上的 AOP 注解不要超过 3 个。多了说明:
- 你需要把多个注解合并成业务相关的 meta 注解(
@OperationLog包含审计 + 时间 + 权限) - 或者你滥用了 AOP,把本该是业务的逻辑抽离了
2. 事件命名
事件名永远是**「实体 + 动作过去时」**:OrderCreatedEvent、UserRegisteredEvent、PaymentCompletedEvent。不要叫 CreateOrderEvent——前者表示"已发生",后者像"指令"。
3. 事件的字段最小化
事件载荷只放"必要的标识 + 关键字段",不要塞整个对象。listener 自己再去查 DB——避免事件载荷过大、同时减少耦合。
4. AOP 注解必须有"业务含义"
@LogTime、@RateLimit 一看就懂;不要写 @MyAspect1 这种含糊命名——业务方法签名应该自解释。
5. 事件不要用于强一致性场景
事件 = 异步 / 解耦 / 最终一致。强一致用普通调用 + 事务,事件不能替代。
6. 测试覆盖到 AOP 和事件
切面和监听器很容易在测试中被忽略——主业务跑得过、但 AOP/事件没生效,单测不一定能发现。要专门写集成测试覆盖。
小结
把全文压一句:
AOP 解决『跟业务无关但跨方法都要做的事』,事件机制解决『主业务做完之后触发的副流程』——前者是『包』,后者是『发』。
工程上几条建议:
- 业务方法专注业务,横切交给 AOP,副流程交给事件
- AOP 注解 ≤ 3 个,命名要业务化
- 事件名 = 实体 + 过去时
- 事务后副流程一定用
@TransactionalEventListener(AFTER_COMMIT) - 事件不要做"返回值调用",那是 Service 的事
把这两个工具用对,业务代码会一下子清爽下来——这才是"代码优雅"的真正姿势。