事件类型与分类:五类事件,五种思维

事件驱动设计里,最基础也最容易搞混的,就是事件到底分几类。

我见过不少团队,一开始把所有事件都扔进同一个队列,结果系统跑起来之后,调试像大海捞针。说白了,事件分类不是学术问题,是工程问题——分对了,架构清晰;分错了,后面全是坑。

我个人习惯把事件分成五类:用户交互事件、系统内部事件、业务领域事件、外部集成事件、定时/周期事件。这五类覆盖了我在项目中遇到过的 95% 的场景。

核心原则:事件分类的本质,是给事件打上「来源」和「用途」的标签。来源决定了谁触发,用途决定了谁消费。

事件类型五分类 事件总线 用户交互事件 系统内部事件 业务领域事件 外部集成事件 定时/周期事件 说明: 五类事件通过事件总线解耦。生产者只负责发布,消费者只负责订阅。互不感知,互不依赖。 实际项目中,一个业务场景往往混合 2-3 类事件。分类是为了管理,不是为了限制。

1. 用户交互事件:最直观的入口

用户交互事件,就是用户在产品界面上做的每一个操作。点击按钮、输入文字、拖拽文件、滑动页面——这些都是事件。

你想想看,用户交互事件最大的特点是什么?高频、无序、不可预测。用户可能一秒点十次,也可能十分钟不动一下。

我在项目中遇到过最典型的坑:用户疯狂点击「提交」按钮,后端收到了 20 个重复订单。嗯,这里要注意——用户交互事件必须做防抖和幂等处理。

实战建议:用户交互事件在客户端就做一次「去重」和「节流」。不要把所有原始点击都往服务端扔。前端过滤掉无效事件,后端才能专注处理有效业务。

// 前端用户交互事件示例(防抖处理)
const debounceSubmit = debounce(async (event) => {
  // event.payload 包含用户输入数据
  await eventBus.publish('order.submit', {
    type: 'USER_INTERACTION',
    source: 'checkout_page',
    payload: formData,
    timestamp: Date.now()
  });
}, 300); // 300ms 内重复点击只触发一次

2. 系统内部事件:架构的「神经信号」

系统内部事件,是服务与服务之间、模块与模块之间传递的信号。说白了,就是代码在「自言自语」。

比如:缓存失效了、数据库连接池满了、某个服务启动完成了、配置更新了。这些事件用户看不到,但系统自己需要知道。

我记得有一次做微服务改造,服务 A 调用服务 B 获取数据,B 挂了,A 直接报错。后来改成事件驱动:A 发布「数据请求事件」,B 消费后回复「数据就绪事件」。解耦之后,B 挂了 A 还能优雅降级。

事件类型 典型场景 消费方 优先级
服务状态变更 服务启动/停止/降级 监控中心、网关 高
缓存失效 Redis key 过期 数据加载模块 中
配置更新 动态配置中心推送 所有订阅服务 高
资源告警 CPU/内存超阈值 运维平台 紧急

避坑指南:我曾经把系统内部事件和业务领域事件混在一起,结果业务日志里全是「连接池满了」「缓存失效」这种噪音。后来强制分开 topic,内部事件用 sys.* 前缀,业务事件用 biz.* 前缀,日志瞬间清爽了。

3. 业务领域事件:核心价值的载体

业务领域事件,是产品最核心的事件。它描述的是「业务世界里发生了什么」。比如:订单已创建、支付已完成、库存已扣减、用户已注册。

这类事件的特点是:有明确的业务含义,有完整的上下文数据,有严格的顺序要求。

我个人习惯用「过去时态」命名业务领域事件。为什么?因为事件是已经发生的事实,不可更改。你想想看,「OrderCreated」比「CreateOrder」更准确——前者是事实,后者是命令。

// 业务领域事件示例(订单已创建)
{
  "eventId": "evt_20240301_001",
  "eventType": "biz.order.created",
  "version": "1.0",
  "timestamp": "2024-03-01T10:30:00Z",
  "source": "order-service",
  "payload": {
    "orderId": "ORD-20240301-001",
    "userId": "U-12345",
    "totalAmount": 299.00,
    "items": [
      { "sku": "BOOK-001", "quantity": 2, "price": 99.50 },
      { "sku": "TOY-002", "quantity": 1, "price": 100.00 }
    ],
    "shippingAddress": {
      "province": "Guangdong",
      "city": "Shenzhen"
    }
  }
}

关键原则:业务领域事件必须包含完整的业务上下文。消费者拿到事件后,不需要再去查数据库就能理解发生了什么。这叫「事件自包含」。

4. 外部集成事件:与外界对话的桥梁

外部集成事件,是系统和外部世界交互时产生的事件。比如:第三方支付回调、物流状态推送、短信发送结果、社交媒体 webhook。

这类事件最让人头疼的地方在于——你控制不了外部系统的行为。对方可能延迟、重复推送、甚至推送格式说变就变。

我曾经对接过一个海外支付网关,对方回调延迟最长达到 4 小时。如果按同步方式处理,用户得在页面等 4 小时。后来改成事件驱动:收到回调就发布「payment.callback.received」事件,异步处理,用户先看到「支付处理中」,等结果出来再更新状态。

外部系统 事件示例 常见问题 应对策略
支付网关 payment.success / payment.failed 重复回调、延迟 幂等校验 + 超时重试
物流平台 delivery.status.changed 状态跳跃、数据不一致 状态机校验 + 补偿机制
短信服务 sms.delivered / sms.bounced 送达率不稳定 降级为邮件/App 推送
社交媒体 webhook.post.created 签名验证、频率限制 签名校验 + 限流队列

实战技巧:外部集成事件进来后,先做「协议转换」再进入事件总线。把外部系统的格式统一成内部标准格式。这样即使对方改格式,你只需要改一个适配器。

5. 定时/周期事件:时间驱动的力量

定时/周期事件,就是和时间相关的事件。每天凌晨跑报表、每小时同步数据、每 5 分钟检查心跳、30 分钟后自动取消未支付订单。

这类事件的核心是「时间触发器」。它不是由用户操作触发的,也不是由系统状态变化触发的,而是由「时间到了」触发的。

我记得有个项目需要做「超时未支付自动取消」功能。一开始用轮询,每 10 秒扫一次数据库,结果数据库压力巨大。后来改成定时事件 + 延迟队列,性能提升了 10 倍。

// 定时事件示例(延迟队列实现超时取消)
// 订单创建后,发布一个延迟 30 分钟的事件
await eventBus.publishDelayed('biz.order.payment.timeout', {
  delay: 30 * 60 * 1000, // 30 分钟后触发
  payload: {
    orderId: "ORD-20240301-001",
    createdAt: "2024-03-01T10:30:00Z"
  }
});

// 30 分钟后,消费者检查订单状态
// 如果仍未支付,执行取消操作

避坑指南:我曾经用 Cron 表达式写定时任务,结果没考虑时区问题。凌晨 2 点跑的报表,在夏令时切换那天跑了两次。后来统一用 UTC 时间 + 业务时间偏移量,再也没出过问题。

五类事件的对比总结

最后,我用一张表把这五类事件的核心差异列出来。你以后设计事件时,对着这张表就能快速分类。

维度 用户交互事件 系统内部事件 业务领域事件 外部集成事件 定时/周期事件
触发者 用户 系统组件 业务服务 外部系统 时间调度器
频率 高、突发 中、稳定 中、有节奏 低、不可控 固定周期
可靠性要求 中(可丢部分) 高(不能丢) 极高(必须不丢不重) 高(需幂等) 高(需补偿)
典型处理方式 防抖、节流、去重 本地队列、广播 事务消息、Saga 适配器、重试、幂等 延迟队列、Cron
命名前缀建议 ui.* sys.* biz.* ext.* timer.*

嗯,五类事件讲完了。你可能会问:实际项目里真的分这么清楚吗?

说实话,不一定。有些事件天生就跨类别。比如「用户点击支付」——它既是用户交互事件,也是业务领域事件的触发源。我的建议是:按「主要用途」分类,而不是按「来源」分类。一个事件如果主要被业务逻辑消费,就归到业务领域事件;如果主要被 UI 层消费,就归到用户交互事件。

分类不是目的,让系统更清晰才是。