事件类型与分类:五类事件,五种思维
事件驱动设计里,最基础也最容易搞混的,就是事件到底分几类。
我见过不少团队,一开始把所有事件都扔进同一个队列,结果系统跑起来之后,调试像大海捞针。说白了,事件分类不是学术问题,是工程问题——分对了,架构清晰;分错了,后面全是坑。
我个人习惯把事件分成五类:用户交互事件、系统内部事件、业务领域事件、外部集成事件、定时/周期事件。这五类覆盖了我在项目中遇到过的 95% 的场景。
核心原则:事件分类的本质,是给事件打上「来源」和「用途」的标签。来源决定了谁触发,用途决定了谁消费。
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 层消费,就归到用户交互事件。
分类不是目的,让系统更清晰才是。