news 2026/10/20 12:51:38

事件驱动架构实战:用轻量级框架替代定时任务扫描,解决系统性能瓶颈

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
事件驱动架构实战:用轻量级框架替代定时任务扫描,解决系统性能瓶颈

1. 项目缘起:从一次接口延迟事故说起

做后端开发的人大多经历过这样的深夜:系统在线率指标飘红,监控面板上接口平均响应时间从 80ms 一路爬升到 1800ms,数据库连接池被打满,缓存命中率断崖式下跌。我遇到的那次事故,根因最后定位到一个不起眼的定时任务——它每 30 秒全量扫描一次业务表,把符合条件的订单状态批量刷新。业务量小的时候这方案没什么问题,但订单量涨到每天几十万单之后,这个定时任务就成了一个看不见的扫把星:每次扫描要扫过上百万行数据,哪怕绝大多数行根本不需要更新,数据库也要老老实实走一遍索引、加载一遍数据页。

那次事故之后,我给自己定了一条规矩:凡是需要"定期检查某个状态然后做点什么"的逻辑,先问问自己能不能改成事件驱动。于是就有了这个小项目,内部代号就叫"rea"——Rapid Event Analytics,一套轻量级的事件驱动处理框架。它解决的问题非常具体:当系统中发生某个业务事件(比如订单创建、支付回调、库存变更)时,如何可靠地捕获这个事件、把事件转成标准结构、再分发给下游处理器去做对应的业务动作,同时保证不丢事件、不重复处理、不拖垮主链路。

这篇文章就把这个项目的完整思路、关键设计和落地过程中踩过的坑分享出来。如果你是后端开发、架构师,或者正在被定时任务和状态扫描折磨,这篇内容应该能给你一些参考。

2. 整体设计思路:为什么要把"轮询"换成"事件"

2.1 定时任务的问题不只是"慢"

很多人觉得定时任务的缺点是延迟高——数据变了要等下一个周期才能被处理。其实延迟只是表面问题,更深层的问题有三个。

第一,无效计算浪费资源。一个订单表里面,99% 的记录状态是不变的,定时任务每次都要把这 99% 的数据重新读一遍。读数据要消耗 IO、要占连接、要传网络,这就是纯浪费。就像你每天把所有抽屉翻一遍去找那一个可能出现的纸条,而不是在抽屉里装一个铃铛,纸条放进去就响。

第二,峰值放大效应。定时任务在固定时刻启动,同一时间所有实例一起扫描,数据库瞬间被打满。业务本身的流量是有峰谷的,定时任务等于在业务波峰之上再叠加一个人工波峰,系统容易雪崩。

第三,业务语义被扭曲。很多业务逻辑本质上是对"发生了某件事"的响应,比如"订单支付成功之后要通知仓库发货"。用定时任务去做,你就得把"支付成功"这个语义转换成"查出所有已支付但未通知仓库的订单",这种状态轮询式的表达非常绕,而且容易出边界 bug——查漏了、重复查了、状态刚好在查询窗口内变化了,都是问题。

2.2 事件驱动解决什么问题

事件驱动的核心思路是:把"主动遍历找变化"变成"被动接收通知"。上游业务在关键节点产生事件,下游对这些事件做响应。这样做的好处很直接:

  • 有事件才处理,没有事件零开销,资源利用率大幅提升。
  • 事件实时产生实时分发,延迟从分钟级降到了毫秒级。
  • 上下游通过事件的语义解耦,上游不需要知道下游有哪些系统在处理,下游也不需要知道事件是怎么产生的。

我在设计 "rea" 时定了几个原则。第一,事件捕获必须嵌入业务本地事务,保证事件不丢。第二,事件分发必须和业务主链路异步,不能因为发事件把接口变慢。第三,同一事件可以被多个处理器订阅,互不干扰。第四,处理失败可以重试,但不能无限重试。

这套思路并不是新的,业界有很多成熟的事件总线、消息队列方案。但 "rea" 这个项目的定位是轻量、嵌入、低依赖——不引入独立的消息中间件,在应用内部用一张事件表和一组分发线程完成事件持久化与路由。适合中小型项目,或者不想为了一个简单场景把 Kafka 引进来把运维复杂度拉高的团队。

3. 核心实现:从事件表设计到分发器的完整流程

3.1 依赖选型:低成本接入优先

先说一下技术选型。项目基于 Java 21 开发,Spring Boot 3.2,持久层用的是 MyBatis-Plus,数据库 MySQL 8.0。没有引入额外的消息中间件,事件表存在业务库里,通过本地事务保证一致性。分发器用 JDK 自带的线程池和定时调度器实现。

选择这套组合的原因很简单:大部分 Java 后端团队对这个技术栈最熟,出了问题也好排查。如果一开始就上 Kafka 或者 RabbitMQ,部署、监控、运维的成本都要增加,对于很多中小项目来说是不必要的负担。等到事件量确实大到单库扛不住的时候,再考虑引入独立消息队列,这是演进式架构的思路,而不是一开始就上重武器。

提示:如果你的项目已经有现成的消息中间件,那么不需要照搬这个方案,只要理解事件建模的思路即可。"rea" 的目标是给你一套可以嵌入式使用的轻量实现。

3.2 事件表结构设计

事件表是整个项目的基石。表结构设计时重点考虑了几个方面:事件怎么唯一定位、事件状态怎么流转、事件内容怎么存、索引怎么建。

CREATE TABLE `t_event_record` ( `event_id` varchar(64) NOT NULL COMMENT '事件全局唯一ID', `event_type` varchar(64) NOT NULL COMMENT '事件类型,如 ORDER_CREATED', `biz_id` varchar(64) NOT NULL COMMENT '业务主键ID,如订单号', `payload` json NOT NULL COMMENT '事件载荷,业务自定义内容', `status` tinyint NOT NULL DEFAULT 0 COMMENT '事件状态:0=待分发 1=分发中 2=分发成功 3=分发失败', `retry_count` int NOT NULL DEFAULT 0 COMMENT '已重试次数', `next_retry_time` datetime NOT NULL COMMENT '下次重试时间', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`event_id`), KEY `idx_status_next_retry` (`status`, `next_retry_time`), KEY `idx_event_type` (`event_type`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='事件记录表';

几个设计要点说明一下。

事件 ID 用业务侧生成的方式,不用数据库自增。因为事件写入和业务事务是同一个事务,如果在事务里先通过数据库生成 ID,再写业务表,连接池压力会更大,而且分布式场景下自增 ID 也没法做到全局唯一。我用的方案是 UUID 精简版,去掉横线转成 32 位字符串,单库单表足够用。

payload 字段用 JSON 类型。这是 MySQL 5.7 之后的新特性,好处是业务扩展字段不需要改表结构,下游拿到 JSON 按需解析。缺点是查询里面不能对 payload 里的字段建索引,但这本来也不是它的使用场景——我们是按 event_type 和 biz_id 查,不是按 payload 查。

status 字段设计成 0/1/2/3 四态,而不是简单的"未处理/已处理"两态。中间区分出一个"分发中"的状态,这是为了避免并发情况下两个分发线程同时捞同一个事件。配合唯一索引和条件更新,可以做到分布式环境下的并发安全。

3.3 事件发布:事务、幂等、性能的三方博弈

事件发布是第一步:业务代码在一个本地事务里既修改业务数据,又插入一条事件记录。这一步最核心的原则就是:业务操作和事件写入必须同生共死,一起成功一起失败。

@Service public class OrderService { @Resource private OrderMapper orderMapper; @Resource private EventRecordMapper eventRecordMapper; @Transactional(rollbackFor = Exception.class) public void createOrder(OrderCreateCmd cmd) { // 1. 业务主操作:插入订单 Order order = new Order(); order.setOrderNo(generateOrderNo(cmd)); order.setStatus(OrderStatus.CREATED.getValue()); order.setAmount(cmd.getAmount()); orderMapper.insert(order); // 2. 同一事务中写入事件 EventRecord event = EventRecordBuilder.build( EventType.ORDER_CREATED, order.getOrderNo(), JsonUtil.toJson(Map.of("orderNo", order.getOrderNo(), "amount", cmd.getAmount())) ); eventRecordMapper.insert(event); } }

注意第 2 步。如果事务提交之后再去发消息或者写事件表,中间隔着一个网络调用或者一个线程切换的窗口,一旦这一步失败,业务已经提交了,事件却丢了——下游永远感知不到这笔新订单。所以必须在同一个数据库事务里写事件,用数据库的 ACID 来保证"业务成功 = 事件必然落库"。

这个方案牺牲了什么?性能。每个业务操作多一条 insert,多占一次数据库写盘。实测下来,单条 insert 大概多花 0.3ms 左右,对绝大多数业务接口来说完全可接受。如果你的系统单接口 TPS 上万,那可能需要考虑批量写事件或者引入中间件,这是后话。

还有个容易被忽略的细节:事件表和业务表如果不在同一个库里,事务就不成立了。一些团队把事件表放到 Redis 或者独立库里,看起来也能用,但一旦跨库就无法保证原子性。我在项目里强制要求事件表必须在业务库内,这是底线。

3.4 事件分发器:线程模型与调度策略

事件写入事件表之后,谁来把它捞出来、发给对应的处理器?这就是分发器的职责。分发器本质上是一个轮询线程,它定时去事件表里捞"待分发"的事件,然后交给线程池去执行具体的分发逻辑。

先画一下分发器的工作流程:

  1. 启动一个定时任务,每隔 1 秒扫描事件表。
  2. 每次扫描捞出一批事件,条件是 status = 0(待分发)且 next_retry_time <= 当前时间。
  3. 捞出来的事件先通过条件更新把 status 从 0 改成 1(分发中),防止其他线程重复消费。
  4. 根据 event_type 路由到对应的处理器,提交到线程池执行。
  5. 处理器执行完成后,把 status 改成 2(分发成功);失败则把 retry_count + 1,计算 next_retry_time,把 status 改回 0 或者 3。
  6. 扫描线程继续下一轮,循环往复。

代码里最核心的就是那个"抢占"逻辑:

@Component public class EventDispatcher { private static final int BATCH_SIZE = 100; @Resource private EventRecordMapper eventRecordMapper; @Resource private EventHandlerRegistry handlerRegistry; private final ThreadPoolExecutor executor = new ThreadPoolExecutor( 4, 8, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), new ThreadPoolExecutor.CallerRunsPolicy() ); @Scheduled(fixedDelay = 1000) public void dispatch() { List<EventRecord> pendingEvents = eventRecordMapper.selectPending(BATCH_SIZE); for (EventRecord event : pendingEvents) { // 抢占:条件更新,返回影响行数为1说明抢到了 boolean claimed = eventRecordMapper.claimEvent(event.getEventId()); if (claimed) { executor.execute(() -> processEvent(event)); } } } private void processEvent(EventRecord event) { EventHandler handler = handlerRegistry.getHandler(event.getEventType()); if (handler == null) { // 没有处理器的事件,直接标记成功,避免队列积压 eventRecordMapper.markSuccess(event.getEventId()); return; } try { handler.handle(event.toPayload()); eventRecordMapper.markSuccess(event.getEventId()); } catch (Exception e) { // 重试策略:最多重试5次,退避时间指数增长 int newRetryCount = event.getRetryCount() + 1; if (newRetryCount > 5) { eventRecordMapper.markDead(event.getEventId()); } else { eventRecordMapper.reschedule(event.getEventId(), newRetryCount, calculateNextRetryTime(newRetryCount)); } } } }

claimEvent 对应的 SQL:

UPDATE t_event_record SET status = 1, updated_at = NOW() WHERE event_id = #{eventId} AND status = 0

这里用了一个非常经典的条件更新。两个分发线程如果同时捞到了同一个事件,第一个执行更新会返回影响行数 1,第二个由于 status 已经不是 0 了,返回影响行数 0,自然就放弃了。这就避免了并发消费的重复问题。

线程池那边我用了 CallerRunsPolicy 而不是默认的 AbortPolicy。原因是:如果线程池队列满了,默认策略会直接抛 RejectedExecutionException,导致这次事件分发链路中断。CallerRunsPolicy 会把任务退回给调用线程执行,虽然会阻塞扫描线程,但保证了事件不丢。在分布式部署下,多个实例各跑各的调度,单个实例短暂阻塞问题不大,因为其他实例还在捞事件。

3.5 重试退避策略:别把所有失败事件压在同一时间点

失败重试如果处理不好,会导致一种非常尴尬的运行效果:某个下游服务故障了,然后所有事件在同一个重试时间点一起涌过来,把下游打死,然后又是集体失败,又是集体重试。这就像一个班级里所有学生都在同一分钟去问老师同一个问题,老师直接崩溃。

"rea" 用的是指数退避加随机抖动:第一次重试延迟 5 秒,第二次 25 秒,第三次 125 秒,每次乘以 5,然后加 0 到 30 秒的随机值。之所以乘 5 而不是乘 2,是因为事件处理场景里很多失败是下游瞬时故障,快速重试几次往往能恢复,但如果几次都失败,说明故障比较持久,需要等更久才能再试。加上随机抖动,可以避免所有失败事件的时间戳算出来完全一致,把请求打散。

还有个细节:next_retry_time 在更新时有两种选择,"从现在开始算退避"还是"从事件创建时间开始算退避"。如果事件创建了很久、一开始就失败了,然后业务恢复正常,这时候用创建时间来算退避会导致该事件一直排不上队,永远赶不上重试窗口。我选择的是"从上次处理失败开始算",也就是每次重试后重新计时,这样事件不会因为历史失败被无限推迟。

3.6 事件处理器注册机制

事件分发器只负责把事件捞出来、找到对应的处理器、调用它。处理器的注册和管理需要一个统一的机制。我的方案非常简单:定义一个 EventHandler 接口,在 Spring 容器中自动收集所有实现类,通过注解声明它处理什么事件类型。

public interface EventHandler { void handle(Map<String, Object> payload); }
@Component @EventTypeBinding(EventType.ORDER_CREATED) public class OrderCreatedWarehouseNotifyHandler implements EventHandler { @Override public void handle(Map<String, Object> payload) { String orderNo = (String) payload.get("orderNo"); // 调用仓储系统接口,通知发货 warehouseClient.notifyShipment(orderNo); } }

EventHandlerRegistry 在启动时扫描所有带有 @EventTypeBinding 注解的 Handler,记录下来。多个 Handler 可以绑定同一个事件类型,分发时逐个调用。如果一个事件类型没有对应的处理器,按前面代码的逻辑直接标记成功,避免事件越积越多,最后把事件表撑爆。

注意:一个处理器 handle 方法里如果有多个步骤,建议把每个步骤拆成独立的事务或者独立的子事件,不要在一个方法里串行做多个不可分割的操作。比如"通知仓库"和"通知用户短信",如果放到一个处理器里,通知仓库成功了,通知短信失败了,重试整个处理器会导致仓库收到两次通知。

4. 落地过程中的典型问题与排查实录

4.1 事件重复消费:怎么保证下游"恰好一次"处理

事件系统做起来之后,最先遇到的就是重复问题。背景是:业务订单创建后,需要异步发送一条短信通知用户。短信服务在某个时段不稳定,超时了。我的处理器里调短信接口抛了 TimeoutException,事件被标记为失败、进入重试队列;但实际上短信平台那边已经把这条短信发出去了,只是响应没有及时返回。重试等于用户收到了两条一模一样的短信。

重复的本质是"处理结果不确定"——你无法判断是"真的没处理"还是"处理了但响应丢了"。解决方案是让下游业务自己具备幂等能力。

我的做法是引入一个消费记录表,处理器的 handle 方法里,先用 eventId 去查消费记录,查到了就直接返回,查不到就执行业务逻辑,执行完插入消费记录。事件 ID 是全局唯一的,所以天然可以作为幂等键。

CREATE TABLE `t_event_consume_record` ( `event_id` varchar(64) NOT NULL, `handler_name` varchar(128) NOT NULL, `consumed_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`event_id`, `handler_name`) ) ENGINE=InnoDB;

这里有个细节:主键是 event_id 和 handler_name 的联合主键。如果同一个事件被多个处理器订阅,每个处理器都应该有自己独立的消费记录,避免一个处理器消费了,另一个处理器因为这个 eventId 已存在而被跳过。

插入消费记录和执行业务逻辑也要在同一个本地事务里。可以理解为:处理器内部创建一个新的事务,先检查消费记录,没有就执行业务并插入记录,一切在事务里完成,否则并发重试可能会出现两个线程同时查出"没有消费记录",然后各自执行业务逻辑。

4.2 扫描性能优化:为什么事件表越查越慢

项目上线跑了一周,发现扫描事件的定时任务越来越慢,一次要好几秒。排查下来是表数据量涨到了几十万行,where 条件里 status = 0 的索引区分度不够了。MySQL 优化器发现通过这个索引扫出来的行很多,干脆走了全表扫描。

这是事件表设计里一个非常隐蔽的坑。status 字段只有 4 个值,区分度极低,跟 next_retry_time 联合建索引,也只能在数据量小的时候管用。一旦表大了,这个索引基本就废了。

我在项目里用了两个方案叠加来解决。

第一,事件表按天分表。每天一张表,表名带日期。扫描只扫今天的表和昨天的表(防止极慢的重试事件跨天执行)。查历史事件直接走归档表,不影响在线库。

第二,把待分发事件从主表中剥离,单独放一个 pending 小表。分发成功后删除 pending 记录或者移动一张 done 表,pending 表数据量始终很小,索引效率极高。

第二个方案更优雅,但改动大一点。如果你们不想分表,一个简单的土办法是定时把 status != 0 的数据归档到历史表,只保留最近几天的数据在事件表里,这也是实际用得最多的方案。毕竟事件表的数据是"消费完就没有价值"的,没必要长期留在在线库里。

4.3 死信处理:处理不掉的事件怎么办

重试超过上限的事件不能就扔在那里不管,不然业务出问题你看不见。我设计了一个死信表和一个告警逻辑:超过 5 次重试的事件,状态改成 3(分发失败),同时把这条事件插入死信表。死信表里保留完整的 payload 和失败原因。每天凌晨跑一个统计任务,如果死信表里有新数据,就推到企业微信群,提醒值班同事去处理。

处理死信的方式通常是两种:一是人工介入,排查下游系统问题,修复之后手动重放事件;二是写一个管理接口,把死信重新放回事件表,status 重置为 0。这里我建议做成提供接口而不是直接改数据表,不然哪天手滑把状态改错了,又是一起线上事故。

死信表的设计跟事件表差不多的结构,只是多了一个 failure_reason 字段,记录最后一次异常的堆栈摘要。你可以把死信表看作一个"事故现场保留区",后续做数据补偿审计都靠它。

4.4 多实例部署:调度任务会不会重复执行

项目后续要做高可用部署,从单实例扩到多实例。遇到一个经典问题:每个实例都会跑 @Scheduled 定时任务,多个实例同一时间都会去捞事件表,虽然前面说了 claim_event 靠条件更新保证并发安全,不会重复处理同一个事件,但这会导致每个实例都在空循环拉数据,白白消耗数据库连接。

这个问题我用了两个方案处理。轻量方案:设置一个 Redis 分布式锁,只有拿到锁的实例才执行扫描动作;不用 Redis 的项目也可以用数据库表来做分布式锁。锁的 key 是 "rea:dispatcher:lock",锁过期时间 5 秒,每次扫描结束续期。因为扫描动作本身很快(毫秒级),锁竞争也不是很激烈,实现简单可靠。

更重的方案是用 xxl-job 这类分布式调度框架,把扫描任务注册成一个分片任务,每个实例处理不同的事件 ID 区间。但引入额外组件对轻量项目来说有点划不来,我最终选择了 Redis 分布式锁方案,代码量不到二十行。

有个细节点要注意:分布式锁不要用 SETNX 加 EXPIRE 两条命令在代码里先后执行,因为中间如果进程崩溃,锁就永远不释放。要用 SET 命令的扩展语法,一条命令同时设置值和过期时间:

boolean locked = redisTemplate.opsForValue() .setIfAbsent("rea:dispatcher:lock", instanceId, Duration.ofSeconds(5));

4.5 事件链路追踪:出问题怎么查

事件驱动架构有个天然的劣势:调用链路变长,一个问题可能要跨多个表、多个服务去追。业务侧传一个订单号进来,你想知道这条链路目前走到哪了,处理了什么事件,报了什么错,没有一个全局唯一的链路标识根本没法查。

我的做法是:事件表里加了一个 trace_id 字段。业务入口处生成一个 trace_id(可以用 UUID),在创建事件时传进去,事件表记录它。分发器在处理事件时,把 trace_id 塞进日志上下文(MDC),这样所有和该事件相关的日志都会自动带上这个 trace_id。排查问题时,用订单号查事件表,拿到 trace_id,再去日志平台按 trace_id 搜,一条链路从头到尾清清楚楚。

这个方案成本极低,但收益很大。建议在项目一开始就加这个字段,不要等到出事故了再加,到时候存量数据没法补,新老事件格式不一致,改起来非常痛苦。

5. 实际应用效果与优化对比

5.1 改造前:定时任务方式的开销

用一个简化数字描述改造前的状态。假设一天有 10 万个订单,订单从创建到最终完成状态平均需要经过 5 个节点的状态变更,定时任务每 30 秒扫一次订单表,每次扫 80 万行有效数据。一天的无效扫描量大概是 80 万行乘以 2880 次扫描,约 23 亿行数据被无谓地读了一遍。这个量放到任何数据库上都是巨大的浪费。

更麻烦的是一天中有几个尖峰时段:每 30 秒的定时任务在各个时段均匀跑,但业务流量不均匀,高峰期订单表膨胀得快,扫描任务也跟着变慢,反过来拖累主接口。

5.2 改造后:事件驱动方式的效果

改造后,每个订单创建只多写一条事件记录,分发器每 1 秒捞一次事件,每次几百条,线程池处理后立即下发到下游处理器。数据库的读写压力大幅下降,主接口的平均响应时间从 180ms 降到了 60ms 左右。事件处理的最大延迟基本在 2 秒以内,相比定时任务 30 秒的扫描周期,实时性也提升了。

最关键的是系统稳定性。定时任务造成的峰值压力消除之后,数据库连接池的使用率从经常 80% 以上降到了 30% 左右,高峰期不再出现连接等待的情况。之前每隔一段时间就要人工调整扫描周期、错峰执行,现在完全不用管了。

5.3 什么时候不要用事件驱动

虽然事件驱动方案效果很好,我也得说几句公道话:并非所有场景都适合改造。如果你项目的数据量很小,一天就几百条订单,定时任务 1 分钟扫一次完全没压力,那没必要引入这套复杂度。事件驱动本身增加了表结构、线程调度、重试机制、幂等设计,理解成本不小。

另外一个不适合的场景是:你只是需要周期性汇总统计数据,并没有具体事件需要响应。比如"每天凌晨统计昨天的销售额",这本质上是定时聚合,不是事件驱动能解决的问题。事件驱动适合的是"因为发生了某件事,所以要去做另一件事"的场景,而不是"每隔一段时间算一次账"的场景。

事件驱动也解决不了所有一致性问题。比如你的事件已经分发成功,但下游处理器逻辑执行到一半失败了,消费记录已经插入,重试也拉不起来。这种语义上的"部分成功"还是需要业务侧自己做补偿。事件框架只是帮你保证"事件不丢、有重试",至于业务侧的最终一致性闭环,仍然要靠业务代码收尾。

6. 进阶扩展:这个项目还能往哪些方向走

6.1 批量事件聚合处理

现在的事件分发模型是一个事件一个处理器。某些场景下,比如用户每产生一条操作日志,下游的推荐系统不需要实时处理每一条,而是希望每 5 分钟拉一次增量数据,批量更新特征。这个需求在现有模型下需要写一个定时任务。更合理的做法是在事件框架上支持"事件聚合"能力:同一类型的事件先攒着,达到一定数量或者时间窗口后,一次性打包发给下游。

这块实现不算复杂,核心是一个内存队列加定时 flush,flush 时把事件列表序列化成一个大 payload 发出去。需要注意的是聚合窗口内的数据如果服务重启会丢失,要权衡可接受的丢事件程度。

6.2 事件回放与审计

事件表天然是一个"业务操作流水账"。如果你的事件 payload 里记录了关键的 before 和 after 状态,你就可以基于事件表做很多高级的事情:回放某个业务对象的完整状态变迁史、审计某个操作是谁在什么时间发起的、定位某个脏数据是什么时候产生的。基于这个思路,我在事件表里专门加了 create_by 和 event_version 两个字段,为未来的审计和回放功能留了空间。

事件溯源(Event Sourcing)是另一个有趣的延伸方向:用事件序列代替当前状态作为唯一事实来源,数据库表变成事件的投影。这个模式在一些金融系统中用得比较多,但复杂度很高,不建议在小项目里贸然使用,可以作为学习方向。

6.3 从单库到消息队列的平滑迁移

如果某天事件量真的涨到了单库扛不住的程度,这套轻量方案可以平滑迁移到独立消息队列。核心思路是:业务本地事务仍然写事件表,但新增一个"事件搬运工"组件,定时把事件表里的数据转发到 Kafka 或者 RabbitMQ,下游消费消息中间件,事件表只做可靠的存储缓冲。这样业务的"写事件表"逻辑不变,下游改造从轮询事件表变成消费消息,迁移过程对业务代码的影响面很小。

这种演进路径是我比较推崇的:不要一开始就上你根本用不上的中间件,先用简单的方案跑起来,等规模到了再换,换的时候基于事件表这个统一入口做搬运,风险可控。

最后分享两个小技巧

第一个技巧是关于事件表的清理策略。我见过有些项目的事件表一年不清理,最后几千万行数据把磁盘撑爆。如果你不想做复杂的分表归档,至少要建一个定时任务,把 status = 2(分发成功)且 created_at 超过 7 天的数据转移到一张独立的归档表,在线事件表只保留近期的数据。归档表可以按季度再分表,平时基本不用查。

第二个技巧是处理器的异常捕获一定要非常宽。我在 processEvent 里 try-catch 的是 Exception,但实际有些框架异常是 Error 子类,比如 OutOfMemoryError 或者更隐蔽的 StackOverflowError,这些不是 Exception,不会被 catch 住,会让线程直接死掉。处理 EventHandler 里抛出的一切 Throwable,才能保证分发线程的生命周期是可控的。别问我是怎么知道的,问就是线程池里安静少了一个线程,排查了半天。

做事件驱动这套东西,本质上是在"实时性"和"可靠性"之间找一个适合自己系统的平衡点。rea 这套轻量框架不能让你一夜之间成为架构大师,但有了事件表、分发器、重试机制、幂等记录这四个核心模块,你会发现自己对系统的掌控力明显提升了——出了问题有迹可循,业务扩展不用再头疼地加定时任务,链路追踪也更顺畅。这就值了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 12:51:25

九宫格判定如何避坑?三阶幻方校验的数学原理与C语言实现

大家看到“L1-104 九宫格”这个题号时&#xff0c;第一反应多半是“这有什么好讲的&#xff0c;不就是三行三列九个格子嘛”。说实话&#xff0c;我第一次拿到这道题也是这么想的&#xff0c;结果在判题平台上连续交了三次才过。九宫格这道题本身不难&#xff0c;但它把所有入门…

作者头像 李华
网站建设 2026/10/20 12:51:16

开放平台、双核处理器与数字电视:技术开放背后的共性逻辑

如果你对技术史有兴趣&#xff0c;翻到9月8日这一页&#xff0c;会发现三个平时不太会被放在一起的条目&#xff1a;某家电商公司对外宣布要做开放平台、某家半导体企业发布双核处理器、还有一支国内团队把全数字高清晰度电视系统从实验室做了出来。这三件事发生在同一天的概率…

作者头像 李华
网站建设 2026/10/20 12:50:02

BLE低功耗设计-第7章第3题-如何优化深度睡眠模式的唤醒响应时间

蓝牙面试题解析:如何优化深度睡眠模式的唤醒响应时间? 难度:⭐⭐⭐⭐ 较难 | 场景:社招二面、唤醒响应 | 高频:🔥🔥🔥 标准答案 优化深睡唤醒响应靠硬件(快速唤醒电路、边沿检测、缩短时钟稳定时间)+软件(简化中断服务、合理中断优先级、提前准备唤醒逻辑),在响应…

作者头像 李华
网站建设 2026/10/20 12:49:52

基于YOLOv11的安全帽检测系统:从数据集训练到界面集成全流程

最近在整理一套完整的深度学习视觉落地项目&#xff0c;把从数据处理、模型训练到界面集成的整个链路走了一遍。基于YOLOv11的安全帽检测系统&#xff0c;配套了完整的Python源码、训练好的模型权重、数据集处理脚本&#xff0c;以及带登录注册功能的可视化界面&#xff0c;算是…

作者头像 李华
网站建设 2026/10/20 12:49:38

基于PyTorch的核桃好坏识别:从数据采集到ResNet-18训练全流程

1. 项目背景与选型思路做了这么多年深度学习相关的项目&#xff0c;有一个感触特别深&#xff1a;很多同学做毕业设计&#xff0c;不是卡在模型不会写&#xff0c;而是卡在“不知道一个正经的深度学习项目到底应该长什么样”。核桃好坏识别这个题目&#xff0c;听起来好像就是“…

作者头像 李华
网站建设 2026/10/20 12:49:18

大模型服务化:构建可计量、可治理的生产级API

1. 项目概述&#xff1a;当大模型从“玩具”变成“水电煤”&#xff0c;API 就是它的水表和电闸你有没有遇到过这样的场景&#xff1a;团队花三个月调好了个效果惊艳的 LLaMA-3 微调模型&#xff0c;本地跑 demo 时 everyone is clapping&#xff0c;结果一到生产环境就卡壳——…

作者头像 李华