
最近在维护 daisy-framework 的时候我重新把 event、listener 这套事件监听机制从源码到业务用整个过了一遍。说实话事件监听在任何一个服务端框架里都不算新东西但真正能把“事件怎么定义、监听器怎么注册、发布之后是同步还是异步、异常怎么处理”讲清楚的文章并不多。daisy-framework 把事件监听作为一个独立模块放进核心包初衷很简单把业务主流程里的横切动作拆出去让下单只操心下单库存、通知、日志这些事由事件驱动去完成。这篇文章我会从设计思路开始依次讲清楚事件对象的定义规范、监听器的三种注册方式、事件发布全流程、异步监听和事务绑定的取舍最后附上我实际踩坑整理的问题排查表。适合正在用 daisy-framework 做业务开发的伙伴也适合任何想搞懂事件驱动设计的人。文章里的代码都来自一个我称之为“模拟项目X”的真实改造过程很直接不绕弯。1. 为什么 daisy-framework 要内置一套事件监听机制1.1 事件监听解决的是哪个层面的问题业务代码里最难缠的不是算法而是各种“顺便处理”的业务。订单支付成功要减库存、发优惠券、更新会员等级、写审计日志、通知运营群——如果这些代码全部写在支付回调里一个方法几百行是常态。更要命的是它们之间没有先后逻辑但因为写在同一个方法里被强制串行执行哪天通知服务变慢了支付主流程也跟着慢。事件监听就是把这堆“顺便处理”从主流程里剥离的手段。支付回调只做一件事发布一个订单支付成功事件。至于谁关心这个事件由 daisy-framework 的事件调度器去分发。事件发布方和被通知方之间没有直接依赖这正是事件驱动最核心的价值。我在实际项目里最直观的感受是没有事件监听时加一个新动作要改老代码有了事件监听后加一个新动作只是新增一个监听器主流程代码一行不动。这个差别在项目中期特别明显功能越多优势越大。1.2 daisy 事件机制的几个关键设计约束daisy-framework 的事件模块在基础包里实现API 不大但约束很明确。事件默认在进程内分发不依赖外部存储。发布事件是内存操作性能好但同时也意味着事件不会持久化进程重启后未处理的事件就没了。这决定了 daisy 的事件适合做业务编排和状态通知不适合做需要保证不丢数据的消息传递。监听器默认同步执行。同步的好处是事务、日志上下文、用户会话都能天然传递。需要异步时用显式注解声明异步是例外而不是默认。这个设计我非常认可它强迫你先思考“这个监听器真的需要异步吗”而不是一股脑把问题丢给线程池。支持优先级排序和条件过滤。同一个事件可以被多个监听器按优先级从小到大依次处理条件过滤则通过注解的 condition 属性实现避免在监听器方法内部写一堆 if 判断。1.3 和消息队列的区别先分清楚再动手很多初学者把框架事件监听和消息队列混在一起我见过不止一次。daisy 的事件监听强调的是“进程内解耦”消息队列强调的是“跨进程可靠传递”。举个例子订单支付后给用户发送 App 推送这件事可以走 daisy 监听器因为推送逻辑在同一个服务进程内失败了大不了记录日志稍后重试或者接受偶发失败。但如果订单数据要同步到大数据平台那就不能只靠事件监听了应该把订单支付成功事件转成消息投递到消息中间件由下游服务消费。说白了事件监听解决的是代码结构问题消息队列解决的是系统架构问题。判断用哪套机制有个很简单的标准监听器和发布者会不会部署在两个不同的进程里如果会就不要用 daisy 自带事件如果不会优先用自带事件。别一上来就上消息队列成本和复杂度完全不同。2. 事件定义与监听器声明动手前的关键设计2.1 先写事件类好的事件对象长什么样事件类看起来简单其实设计不好很影响后续维护。先看我常用的写法以订单支付成功事件为例public class OrderPaidEvent implements DaisyEvent { private final String orderNo; private final Long userId; private final BigDecimal paidAmount; private final LocalDateTime paidAt; public OrderPaidEvent(String orderNo, Long userId, BigDecimal paidAmount, LocalDateTime paidAt) { this.orderNo orderNo; this.userId userId; this.paidAmount paidAmount; this.paidAt paidAt; } public String getOrderNo() { return orderNo; } public Long getUserId() { return userId; } public BigDecimal getPaidAmount() { return paidAmount; } public LocalDateTime getPaidAt() { return paidAt; } }这里有三条经验都是我在真实项目里付出过代价换来的。第一事件对象建议做成不可变字段全用 final只提供 getter。原因很简单事件在发布后可能被多个监听器读取如果有人中途改了某个字段后面的监听器拿到的数据就是不一致的这种 bug 极难定位。事件发布出去之后就把它当成一个只读快照谁都不该碰它的内部状态。第二事件里传 ID 而不是传整个实体。传 Order 对象虽然方便但监听器处理时如果去读 Order 的某个属性而这个对象已经脱离了数据库状态甚至已经被后续流程修改它并不代表真实数据。传 orderNo、userId 这类 ID监听器在需要完整数据时自己按 ID 去查询能保证拿到的数据是最新的也能避免事件对象被无意改坏。第三事件命名用过去时。OrderPaidEvent、StockDeductedEvent 这类完成时态名字暗示“已经发生”而 OrderPayEvent 这种命令式勉强能用但语义不清晰。命令式事件容易让人把监听器写成“替发布者执行任务”这就和命令模式混淆了事件模型就变味了。2.2 用注解声明监听器最常用的方式我在模拟项目X里90% 的监听器都用注解方式写。代码看起来是这样的Component DaisyEventListener public class OrderPaidListener { DaisySubscribe(priority 10) public void deductStock(OrderPaidEvent event) { // 库存扣减逻辑 } DaisySubscribe(priority 20) public void grantCoupon(OrderPaidEvent event) { // 发券逻辑 } }解释一下 DaisySubscribe 的 priority。值越小越先执行默认值是 100。业务上如果“先扣库存后发券”有严格顺序就把优先级拉开。但我一般不建议把太多顺序依赖放到优先级上。如果监听器之间存在强顺序依赖更好的办法是合并成一个监听器或者把这个顺序用注释写清楚放在文档里。DaisySubscribe 可以直接加在普通方法上框架通过反射拿到方法参数列表的第一个参数作为事件类型。方法可以额外接收事件上下文参数拿到发布源、时间戳等信息。这个细节很多文档没写我替大家踩过了后面排查问题时会用到。还有一个值得提的点一个监听器类可以定义多个方法分别监听不同类型的事件。这在实际开发中很方便比如同一个类里处理订单相关的所有通知动作不用拆得七零八落。2.3 接口方式注册监听器的适用场景注解不是唯一的注册方式daisy 也保留了接口式注册例如Component public class StockChangeListener implements DaisyEventListenerStockChangedEvent { Override public void onEvent(StockChangedEvent event) { // 库存变化后的处理 } }这种写法的好处是事件类型通过泛型直接确定不需要反射解析参数排查起来更加直白。缺点是每个监听器类只能处理一种事件一个类要监听多个事件就得写多个类。我看到有些团队把接口方式和注解方式混用这本身没问题。但建议在项目规范里约定同一个模块尽量统一用一种方式。我这里的基本约定是单个事件的复杂处理逻辑用接口方式一个监听器里承载多个相似事件、处理方法很短的用注解方式。两种方式生成的监听器对象在注册表里是共存的不会有冲突。2.4 监听器的扫描与注册机制daisy 在应用启动时会做两件事扫描加了 DaisyEventListener 的类以及扫描实现 DaisyEventListener 接口的类。扫描到的监听器实例统一以单例保存在一个注册表里key 是事件类型value 是该事件对应的一组监听器方法包装对象。每次 publish 一个事件调度器先从注册表按事件类型找到候选监听器集合如果事件类型是某个父类的子类也会匹配到监听父类的监听器。这个“父子事件匹配”特性很有用比如所有业务事件都继承同一个基类事件那只需要写一个监听基类的监听器就能统一做埋点或审计。注意监听器注册发生在应用启动阶段。如果你在运行期手动 new 了一个监听器但没注册进容器那它是永远不会被触发的。这也是排查“监听器不生效”问题时要先确认的一点。3. 从发布到触发事件监听的完整执行链路3.1 业务代码里如何发布事件先给出最直观的调用方式RestController public class OrderController { private final EventDispatcher dispatcher; public OrderController(EventDispatcher dispatcher) { this.dispatcher dispatcher; } PostMapping(/pay) public Result pay(RequestBody PayRequest request) { // 1. 支付相关业务处理 OrderPaidResult paidResult paymentService.pay(request); // 2. 发布事件 dispatcher.publish(new OrderPaidEvent( paidResult.getOrderNo(), request.getUserId(), paidResult.getPaidAmount(), LocalDateTime.now() )); return Result.success(); } }发布接口很简单但有几个执行细节值得注意。EventDispatcher.publish 默认是同步的它会在当前线程里依次调用所有匹配的监听器方法全部执行完 publish 方法才返回。如果你的监听器里有耗时操作发布这一行也会跟着变慢。我见过有人因此误判“事件发布很慢”实际上慢的是监听器里的业务逻辑。另外发布事件时传入的事件对象不要重复使用。publish 之后如果还在同一个方法里对它做改动监听器看到的可能是改到一半的状态。最保险的做法是发布后立即丢弃引用或者坚持使用不可变对象。3.2 一个订单支付事件实际执行顺序还原我在模拟项目X里做过一个完整测试当时注册了三类监听器第一个优先级是 10负责扣减库存第二个优先级是 20负责发送 App 推送第三个优先级是 30负责写审计日志。它们的执行顺序是严格按 priority 从小到大在同一线程里逐个执行。具体流程可以这样理解入口是订单支付接口支付成功后在当前线程调用 dispatcher.publish(event)框架根据 event 的实际类型找到候选监听器列表框架按 priority 排序依次执行扣库存、发推送、写日志所有监听器返回后publish 方法返回控制器继续执行。这条链路里没有创建新线程也没有切换线程上下文。正因为都是在调用线程里跑监听器里的事务注解、traceId、当前登录用户都能直接用。这也是同步监听的天然优势。如果你在监听器方法上加了异步注解情况就变了框架会把这个方法包装成任务提交到线程池publish 方法不等它执行完马上返回真正的执行发生在线程池的工作线程里。这里必须提醒异步监听器里不要再依赖主线程的事务和用户上下文除非你做了显式传递。3.3 监听器抛异常了后面怎么走这个点每种框架设计都不一样daisy 的默认策略是同步监听器执行过程中如果抛出异常异常会沿调用栈向上抛给 publish 调用方后续监听器不再执行。也就是说如果扣库存的监听器挂了订单支付接口会拿到异常发推送的监听器也不会执行。这个设计在某些场景下合理在某些场景下就很不近人情。比如你只是写个审计日志它失败了却把订单支付接口给打挂了这显然不合理。解决办法有两个。方案一在监听器方法内部捕获异常并自行处理适合对异常不敏感的逻辑方案二使用框架提供的忽略异常参数让框架捕获异常并记录日志不中断后续监听器。我的建议是核心业务监听器用默认的异常上抛策略日志、通知类监听器显式打开忽略异常别让辅助逻辑拖垮主流程。4. 异步监听与事务绑定使用频率最高也最容易出错的两块4.1 异步监听器正确写法与线程池配置先看代码Component DaisyEventListener public class NotificationListener { DaisyAsync DaisySubscribe(priority 20) public void sendPush(OrderPaidEvent event) { // 发送 App 推送 } }异步注解放在 DaisySubscribe 方法上框架检测到这个注解后会把监听器方法和异步任务绑定。对于异步监听我踩过三个坑逐个说。第一个坑是线程池默认参数太小。daisy 默认的异步线程池核心线程数是 CPU 核数队列容量 1024。轻量场景没问题但如果有大量异步监听任务一旦队列满了后续任务就会被拒绝。我通常会在配置里显式调大daisy.event.async.core-pool-size8 daisy.event.async.max-pool-size32 daisy.event.async.queue-capacity2048 daisy.event.async.keep-alive-seconds60第二个坑是异步监听器里的异常不会被调用方感知。主流程早就返回了监听器执行异常只能靠框架日志和监控告警发现。所以异步监听器内部一定要有完善的日志和异常捕获否则出了问题就是黑盒等用户来投诉你才知道。第三个坑是上下文丢失。异步线程里没有主线程的 ThreadLocaltraceId、当前用户 ID 可能都是空的。我的解法是在发布前把上下文信息塞进事件对象里比如在事件里加一个 operatorId 字段或者在异步线程池的任务包装中显式传递 ThreadLocal。我更推荐前者代码里清清楚楚出了问题也好查。4.2 事务提交后再触发事件的必要性这个点特别值得单独说。假设你的监听器做的是“支付成功后给用户发权益”如果监听器在事务提交前执行会发生什么看一个真实场景主服务方法开启了事务业务逻辑更新订单状态发布订单支付成功事件随后事务提交。发布时监听器同步执行它去查询订单状态发现订单还是“待支付”因为事务还没提交。这就是典型的“事件先于事务提交”问题。daisy 提供的解法是事务同步事件让事件在事务提交之后才真正触发Transactional public void payOrder(String orderNo, Long userId, BigDecimal amount) { // 更新订单状态、扣减余额 orderDao.updateStatus(orderNo, PAID); // 注册一个事务同步事件真正触发是在提交成功后 EventDispatcher.publishAfterTransactionCommit( new OrderPaidEvent(orderNo, userId, amount, LocalDateTime.now()) ); }publishAfterTransactionCommit 的底层是注册一个事务同步回调事务提交成功后由框架回调触发监听器如果事务回滚了这个事件根本不会发布。这对一致性非常有价值状态变更真正落库后才通知下游做后续动作。注意publishAfterTransactionCommit 依赖当前线程存在活跃事务。如果方法没有事务daisy 会抛出异常提示。这是框架刻意为之防止你误以为事件一定在提交后触发结果却立刻被同步执行了。4.3 事务与异步组合时的执行时序有人会问事务提交后再触发监听器方法上又加了异步注解这两个能组合吗当然可以。组合后的语义是事务提交成功后再把监听器任务丢到异步线程池执行。这个组合很适合一个场景事务提交后发通知但不想阻塞主线程。比如支付成功发推送既要保证订单状态落库后才触发又不想推送这种耗时操作拖住接口响应。代码大致长这样DaisyAsync DaisySubscribe(priority 20) public void sendPush(OrderPaidEvent event) { // 事务提交后异步发送推送 }执行顺序要理清楚主事务提交 - 事务同步回调触发 - 框架把 sendPush 放入异步线程池 - 主线程继续返回 - 线程池执行 sendPush。这三个环节是串行叠加的不是又异步又同步理解这一点就不会搞混。不过我不建议一个项目里大量滥用这种组合。每一次异步都是一次追踪成本异步加事务组合更是双重成本。让费用花在刀刃上就行大部分场景用默认同步就够了。5. 实战问题排查监听器不触发、重复触发与性能瓶颈5.1 监听器不触发的排查清单我在使用过程中收集了一些典型问题整理成速查表现象可能原因排查方向监听器完全没执行事件类型不匹配检查发布的事件类型与监听器参数类型是否一致监听器完全没执行监听器未注册确认类上有注解且被组件扫描监听器完全没执行监听器实例是手动 new 的确认监听器由框架容器管理监听器执行顺序不对优先级没调检查注解的 priority 属性事件没触发发布代码在子线程确认 publish 调用线程与事务线程一致事务事件没触发事务回滚查看数据库是否真的提交成功异步监听没执行线程池被拒绝查看异步线程池拒绝策略与日志第一条“事件类型不匹配”是最高频问题。很多人发布一个子类事件监听器却写成监听父类事件如果父子匹配逻辑没有正确开启那自然触发不了。daisy 默认支持子类事件匹配但前提是发布时传入的事件对象实际类型是子类不能把子类对象强转成父类后再发布。“监听器未注册”也经常出现。排查办法很简单启动日志里会打印注册过的监听器列表先确认你的监听器类有没有出现在里面。没出现就是扫描路径的问题出现了但不触发再去查事件类型和优先级。5.2 事件重复触发三种常见姿势事件重复触发比不触发更难排查因为日志看起来一切正常只是下游状态乱了。我归纳为三类。第一类是自发布循环。监听器处理完业务后又发布了一个同类事件自己监听自己形成无限循环。这种问题通常表现为某个监听器被反复执行甚至打爆线程池。解决方法是约定监听器里不允许发布它正在监听的那一类事件如果必须发布要在事件对象里带一个来源标记自己判断后跳过。第二类是事务重试导致的重复发布。业务方法里先发布事件事务失败后框架重试同一个事件就被发布了两三次。这种问题最佳解是事件发布放在事务提交成功后也就是用发布事务同步事件同时事件对象里带上唯一标识监听器消费时做幂等判断。第三类是监听器注册重复。比如同一个类既加了 DaisyEventListener又在配置里被手工注册了一次注册表里就有两个相同的监听器副本。排查时注意看启动日志里是否有重复注册的警告。5.3 同步链路太长怎么优化如果你的某个监听器特别慢慢到主接口超时别急着把它改成异步。先定位慢的原因可能是它在查询大量数据可能是它在调外部接口也可能是它在循环里做复杂计算。我的优化顺序是先给监听器加好日志和耗时统计确认瓶颈所在能并行处理的多个监听器可以考虑用框架提供的并行支持允许多个同级监听器并发执行最后才考虑异步化。为什么最后才异步因为异步引入了线程池、上下文传递、异常隔离三层复杂度能不用就不用。并行执行适用于那些彼此之间没有顺序依赖、可以同时跑的逻辑。但要注意并发执行时不要共享同一个可变对象事件对象必须是只读的。否则两个线程同时读一个集合一个改一个读很容易出并发问题。6. 用事件监听重构一段支付链路的真实记录6.1 重构前一把梭的支付回调模拟项目X早期是一段支付回调写完所有动作的。核心方法大约 180 行里面依次是更新订单状态、扣库存、给用户加积分、写订单操作日志、调用消息服务发通知、调用问卷服务发调查邀请。每次上线加需求都要改这个方法而且必须小心翼翼不破坏原有顺序。那段时间最痛苦的是日常问题排查用户反馈说支付成功但没收到优惠券我得打开这段 180 行的老代码从头到尾捋还要看是不是前一步异常导致后面的逻辑没执行。问题定位一次可能花一小时。后来我们把整段逻辑改成事件驱动效果非常明显。6.2 重构后发布一条事件各取所需重构后的支付回调只有三件事支付服务处理真实支付、更新本地订单状态、发布订单支付成功事件。后面所有动作都变成独立的监听器分别监听订单支付事件。下面是简化后的监听器规划监听器职责优先级是否异步库存监听器扣减真实库存10否积分监听器增加用户积分20否订单日志监听器写订单操作日志30否通知监听器发送 App 推送40是问卷监听器发送问卷邀请50是扣库存、加积分这些和资金资产相关的动作保持同步因为它们需要强一致性发推送、发问卷这些及时性要求不高的动作改成异步。这样主接口的响应时间立刻降下来而且后续增加一个新动作只需要新增监听器支付回调一行不用改。6.3 重构过程中的三个经验细节第一事件字段不要塞太多。最初我把整个 Order 对象放进事件后来发现监听器多了某些字段被偷偷修改定位起来非常麻烦。改成只放订单号、用户 ID、金额这几个关键字段后数据一致性问题大幅减少。第二监听器之间严禁直接调用。我曾看到有同事在通知监听器里直接调用库存监听器的某个公共方法理由是“复用扣减逻辑”。这等于变相把监听器串成了链路事件的解耦优势全没了。正确做法是把公共逻辑抽到 Service 层监听器只做业务流程编排。第三一定要给事件加发生时间字段。之前在事件里没加支付时间调试一个历史订单时根本无法判断监听器执行时点。后来统一在事件基类里加上 occurredAt用框架的时钟填充排查问题方便多了。这个字段也可以用来做链路耗时统计一举两得。7. 一些值得坚持的实操习惯7.1 给事件和监听器建立统一规范我在团队里定过三条约定事件类统一放在 events 包下命名以 Event 结尾用过去时监听器统一放在 listeners 包下一个监听器类只处理同领域的事件监听器方法命名用动词开头能一眼看出动作含义。这个看似简单的约定让跨模块协作的沟通成本下降了很多。代码结构上我习惯把同一个领域的事件、监听器、发布入口放在同一个模块里。比如订单模块内部有一个 events 子包包含订单领域的所有事件和监听器。这样后来的人接手时不需要在整个项目里到处找事件定义直接翻订单模块就能看全。7.2 幂等设计要提前做事件监听是异步分散的天然会面临“同一个事件被处理两次”的可能。哪怕你现在用的场景没有重试也要在监听器入口做幂等判断。最常见的做法是维护一张消费记录表以事件唯一 ID 作为去重键。这个放到重构后再补会非常痛苦所以建议项目一开始就带上。幂等不仅是为了防消息中间件重投本地事件也可能因事务重试、手动补偿、定时任务兜底而重复触发。我在实际项目中因为没做幂等出现过用户被发放两次优惠券的事故。从那以后所有涉及资金、权益、库存的监听器一律先查消费记录再执行。7.3 监控和日志别等出问题再补事件链路比普通方法调用难追踪得多。我踩过多次“用户说没收到通知但不知道是事件没发、还是监听器没执行、还是执行失败”的坑。后来我在事件基类里加了 eventId在发布和每个监听器执行时打印结构化日志再配合一个简单的指标统计事件发布数和监听器执行结果问题定位时间从小时级降到了分钟级。日志格式我用的是一行 JSON包含 eventId、eventType、listenerClass、method、costMs、result 这几个字段。排查问题时直接按 eventId 过滤日志能看到一个事件从发布到所有监听器处理完的完整链路。这个习惯在线上排查时帮了大忙强烈建议从项目初期就建立。最后分享一点我个人的体会。事件监听这套机制真正难的不是 API 怎么调而是设计者有没有克制力。你能很容易地把所有逻辑都塞进事件和监听器让代码看起来解耦实际却把复杂度转移到了事件链路上。我在实际使用中最受益的一条原则是如果你说不清楚某个动作为什么是事件驱动而不是直接调用那它大概率就不应该写成事件。只有那些真正“发生在某个事实之后、且和主流程不强耦合”的动作才值得放进监听器。这个判断标准听起来很虚但它帮我挡住了很多过度设计。