1. 项目概述
1.1 先说清楚这个项目是干嘛的
“rea”这个标题,第一眼看确实信息量不大,三个字母,既不是一个完整单词,也不是常见的缩写词,如果放在搜索引擎里,大概率会搜出一堆无关结果。但在做事后复盘和技术沉淀时,我习惯于给内部项目起一个够短、够好记、且能够持续演进的项目代号。“rea”就是这样一个代号。
这套东西的全称其实是Resource-Efficient Architecture,翻译过来是“资源高效架构”,但它本质上是一个面向实时事件流处理的轻量级服务框架。简单说,它在解决一个非常具体的问题:当你的系统需要接收大量实时上报的事件数据(比如用户行为埋点、设备状态变更、订单状态流转),并且需要在极短时间内完成清洗、聚合、判定、落库这一整套动作时,靠传统的“请求-响应”模式去扛,既浪费资源,又会把业务接口拖垮。rea 选择的是另一条路:把事件处理拆成“接入-缓冲-处理-分发”四个环节,用轻量级队列作为核心缓冲层,配合可插拔的处理节点,做到既能抗住突发流量,又能按业务需求灵活组合处理逻辑。
这套东西适合谁来参考?如果你正在做后端服务开发,尤其是涉及数据采集、消息处理、异步任务调度这类场景;或者你维护着一个中小型系统,每天要处理几百万条事件数据,但又不甘心为了这点流量就上全家桶级别的流处理框架,那这篇讲“rea”的文章应该能给你一个更轻量、更可控的思路。
1.2 为什么值得专门写一篇复盘
我见过不少团队在处理事件数据时,直接就上了大而全的流处理引擎,结果发现大部分功能根本用不上,反而引入了一套复杂的运维体系。也见过团队从头手写队列,结果在并发控制、持久化、重试机制上踩了一堆坑。rea 的价值就在于它找到了一条中间路线:用成熟稳定的开源组件做底座,把业务处理逻辑以插件方式编排进去,既不重复造轮子,又不被重型框架绑死。
这篇文章会把 rea 从需求拆解、技术选型、核心模块实现,到压测数据、线上问题排查,完整记录下来。所有代码片段和配置都是从实际项目中摘出来的,你可以直接拿过去改改就用。
提示:这篇复盘面向的读者,默认你具备基本的后端开发经验,了解消息队列、多线程、HTTP 接口这些概念。如果你刚入门,建议先补充一下这些基础,再回来看。
2. 整体设计与思路拆解
2.1 从需求出发:我们要解决什么问题
在做任何系统设计之前,最先要想清楚的永远是需求边界。rea 立项时,我们面对的核心场景是这样的:业务方有多个数据源,会以 HTTP 请求的方式向服务端推送事件数据,每条数据大约 0.5KB 到 2KB 不等,有 JSON 格式,也有少量的 XML。事件的峰值流量大约是每秒 3000 条,平峰时每秒大概 300 到 500 条。数据到达后需要经过几个固定步骤:格式校验、字段补全、规则过滤、按业务维度聚合、最后写入存储供下游消费。
这个需求看起来不复杂,但如果用最直观的方式去实现——直接写一个 POST 接口,在请求线程里做完全部处理再返回——会立刻遇到三个问题。
第一个问题是接口会变得非常慢。单条数据处理链路如果耗时 50 毫秒,每秒 3000 条并发进来,就需要同时有 150 个线程在跑,这还没算线程切换和 GC 的开销。第二个问题是突发流量会直接把服务打挂。业务方的推送是突发性的,可能前一分秒还是每秒几百条,下一秒就飙到每秒几千条,接口如果同步处理,响应时间会迅速恶化,进而触发超时重试,产生更多的流量。第三个问题是业务逻辑耦合。今天要加一个字段补全,明天要加一种过滤规则,后面还要对接新的存储介质,如果所有逻辑都堆在接口里,代码会变得完全不可维护。
所以 rea 的设计目标非常明确:接口只负责收数据,越快返回越好;处理逻辑全部异步化;处理链路可以按业务需求灵活编排;整套系统要在单机配置不高的前提下扛住峰值流量。
2.2 技术选型:为什么是队列加工作节点而不是流处理引擎
确定了“异步化、可编排、轻量”这三个目标之后,摆在面前的有两条技术路线。
一条是直接上成熟的流处理框架,比如 Flink、Kafka Streams,或者直接用 Kafka 做消息管道。这条路的优势是功能全面,分布式扩展能力强,但劣势也很明显:部署运维成本高,概念复杂,对于当前这个量级来说属于杀鸡用牛刀。我们团队当时只有两个人负责这个项目,没有专职运维,如果引进来一套 Kafka 集群,光是日常维护就要占掉不少精力。而且我们并不需要跨节点状态管理、窗口聚合这些高级特性,大部分处理逻辑其实都是无状态的,完全可以单机搞定。
另一条路就是 rea 最终选择的方案:用轻量级消息队列做缓冲层,用 Java 虚拟线程池做处理引擎,自己封装一层可编排的处理节点。我们选择了 RabbitMQ 作为核心队列,因为它在单机模式下就能扛住几千条每秒的吞吐量,支持手动 ACK、死信队列、延迟队列这些关键特性,而且配置非常简单,不需要专门的运维知识。处理端则用 Java 的线程池模型,封装了一套节点执行的框架,每个节点负责一种处理动作,节点之间通过数据上下文传递信息,形成一条处理链。
打个比方,这套架构就像一个加工流水线。RabbitMQ 就是流水线入口的传送带,数据到了就先放传送带上缓存着;线程池里的工作线程就是工位上的工人,每个工人只负责一道工序;处理节点就是一道道工序的作业指导书。这样设计的好处是,传送带可以很长(队列积压没关系),工位可以按需增减(动态调线程数),工序可以随时调整(增删节点)。
2.3 与现有系统的集成边界
rea 在设计之初就明确了一个边界:它不是一个万能的业务处理平台,它只负责事件数据的标准化接入与预处理。所有需要实时响应给业务方的结果,都不应该走 rea 这条链路;所有需要复杂事务保障的业务操作,也应该在 rea 之外完成。这个边界意识很重要,因为如果试图让事件处理框架去承担业务正确性的责任,后续的维护成本会呈指数级上升。
rea 对外暴露的窗口是一个统一的 HTTP 接入接口,业务方只需要把数据 POST 过来,rea 负责返回“收到”或者“拒收”。至于这条数据后续经历了什么、最终去了哪里,业务方不需要关心,也不需要等待处理结果。这种“即发即忘”的模型,其实非常契合事件上报这类场景,因为事件本身是客观发生的,不需要像 RPC 请求那样必须拿到一个业务层面的成功响应。
3. 核心模块解析与实操要点
3.1 接入层:统一收口的 HTTP 端点
接入层是整个 rea 的最前端,也是唯一对外暴露的部分。它的责任只有一个:以最快的速度接收数据,校验数据的最基本合法性,然后立刻写入队列并返回。
这里有几个实操上非常关键的点。
第一个点是线程模型。接入层不能直接用传统的 Servlet 阻塞模型,否则每个请求都会占用一个线程等待队列写入完成。更合理的做法是使用异步 Servlet 或者基于 Netty/WebFlux 的响应式模型,让线程从接收请求到写入队列的整个过程都保持非阻塞状态。我实际操作中用的是 Spring WebFlux 配合 Reactor RabbitMQ,这样整个链路由头到尾都是响应式的,单机可以支撑的并发连接数非常可观。
第二个点是数据校验的粒度。接入层的校验必须只做“语法级”校验,也就是确认 JSON/XML 结构合法、必填字段存在、字段类型正确。不要在这一层做任何业务规则校验,比如“某个状态是否允许流转到某个状态”,因为业务规则可能随版本变化,而接入层应该保持稳定。把业务校验放入后续的处理节点中,可以让接入层职责单一,也避免因为业务逻辑变动而频繁发布接入层代码。
第三个点是反压机制。当队列积压严重时,继续无节制地往里写数据只会让情况恶化。rea 的做法是在写入队列前检查队里积压量,如果积压超过阈值,就直接返回一个标准的 503 状态码,提示业务方稍后重试。这个机制看着简单,但实际非常有用,它给了整个系统一个熔断的信号,而不是让问题在队列内部默默发酵。
3.2 队列层:为什么 RabbitMQ 够用
队列层是 rea 的缓冲基座,我们的选型是 RabbitMQ,这个决策在项目运行了大半年后回头看,依然觉得是正确的。
采用 RabbitMQ 有四个技术理由。第一,它天然支持消息确认机制(ACK),消费者处理完成之后才向队列确认删除消息,如果消费者崩溃,消息会被重新投递,这在理论上保证了不丢消息。第二,它提供了死信队列(DLX)机制,处理失败的消息可以自动转入专门的队列,方便后续人工排查或重试。第三,它的管理界面友好,队列积压量、消费者状态、消息速率这些核心指标都能直接在界面上看到。第四,它的单机性能足够,在我们的压测环境中,单机 RabbitMQ 配合合理的消费者配置,稳定支撑了每秒 5000 条消息的写入和消费。
RabbitMQ 在 rea 中的拓扑结构也很清晰。我们定义了一个主队列用于存放待处理的数据,定义了三个死信队列分别存放格式错误但可修复的数据、处理失败需要重试的数据、以及重试多次仍然失败的数据。同时,我们开了一个延迟队列,用于处理需要延时执行业务(比如 30 秒后自动取消未支付的订单事件)。
在配置上需要特别留意的是队列参数。这里给出一个基本配置示例,声明主队列时要注意设置消息 TTL 和最大长度,防止极端情况下内存被堆满。
@Bean public Queue mainQueue() { Map<String, Object> args = new HashMap<>(); // 消息最长存活 5 分钟,超过则转入死信队列 args.put("x-message-ttl", 300000); // 队列最大积压 100 万条,超过则拒绝新消息 args.put("x-max-length", 1000000); // 指定死信交换机 args.put("x-dead-letter-exchange", "rea.dlx.exchange"); args.put("x-dead-letter-routing-key", "rea.dlx.main"); return new Queue("rea.main.queue", true, false, false, args); }这里的核心思想是“有界队列”。队列如果无界,在流量异常时会无限积压,最终可能拖垮整个服务。设定一个上限之后,超出部分会被直接丢弃并转入死信队列,相当于给系统加了一层流量整形保护。
3.3 处理层:节点编排与线程池调优
处理层是 rea 中最核心的部分,它由一个线程池和一条“处理链”组成。所谓处理链,就是把处理过程拆成一个个独立的节点,节点之间通过一个上下文对象传递数据。每条消息进入处理层后,会依次经过链路上的所有节点,每个节点完成自己的工序,再把数据交给下一个节点。
rea 最初设计了五个核心处理节点,从节点一到节点五,它们分别是格式标准化节点、业务字段补全节点、规则过滤节点、业务聚合节点、存储分发节点。
格式标准化节点负责把各种输入格式统一下,统一字段命名风格,统一时间格式、枚举值。业务字段补全节点根据业务上下文,从本地缓存或外部服务获取数据补充事件字段。规则过滤节点执行业务规则判断,比如过滤掉测试环境的垃圾数据、过滤掉超过合理范围的数据。业务聚合节点对连续事件做窗口内聚合,比如统计分钟内某个设备的上报次数。存储分发节点根据事件类型把数据写入不同的存储介质,默认支持 MySQL、Elasticsearch、以及下游 Kafka。
节点与节点之间是解耦的,任何一个节点都可以在配置中心里开启或关闭,也可以调整顺序。这让 rea 非常适用于业务规则频繁变化的场景。
线程池的调优是重头戏。我们线上环境的机器配置是 8 核 16G,线程池参数设置如下。
ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, // 核心线程数 32, // 最大线程数 60, TimeUnit.SECONDS, // 空闲回收时间 new ArrayBlockingQueue<>(1000), // 等待队列 new NamedThreadFactory("rea-worker"), new CallerRunsPolicy() // 拒绝策略:调用者执行 );核心线程数设为 CPU 核心数是有讲究的。因为处理链上的节点大多是 CPU 密集型的操作(数据解析、规则判断),少量操作涉及 RPC(字段补全),所以核心线程数设为 8 足够。最大线程数设为 32,是为了应对某些节点因为外部服务调用变慢而导致的线程阻塞——多出来的线程可以顶上去,避免整体吞吐量下降。等待队列只设置 1000,是为了让流控生效——当任务量超过线程池承载能力时,优先触发拒接,而不是无限堆积在内存中。拒绝策略选了 CallerRunsPolicy,这个策略的含义是当线程池满时,由提交任务的线程直接执行该任务——这起到了天然的降级作用,让消费端放慢拉取速度,队列积压增加,为处理争取时间。
这里要强调,在线程池参数这块,我最开始犯过一个错误:把最大线程数设为 256,觉得这样可以极限压榨机器性能。结果运行几天后发现,线程数过高时,上下文切换开销会显著增大,吞吐量反而下降了30%左右。后面调整到 32 之后,性能才稳定下来。这个教训就是,线程池不是越大越好,它存在一个“甜点值”区间,需要通过压测逐步逼近。
3.4 幂等与重试机制
在事件处理系统中,消息重复是一个必须正视的问题。RabbitMQ 的消息投递和消费者的 ACK 机制,在“至少一次”保证下,可能会因为网络抖动、消费者重启等原因,导致同一条消息被重复投递。如果处理节点是“写入一个计数表”这种操作,重复投递会导致计数结果翻倍,产生脏数据。
rea 在支付结果通知事件和用户签到事件这两个场景上都吃过重复数据的亏。后来我们在处理链的最前面加了一个去重节点,原理是用事件的唯一业务 ID 作为 Key,在本地缓存中做短时间去重。如果是重复事件,直接从链路上丢弃,不再进入后续节点。
去重节点的实现思路不复杂,核心是布隆过滤器加本地缓存的组合。布隆过滤器高效但可能存在小概率误判,本地缓存精确但内存占用高,组合起来用就能在性能和准确率之间找到平衡。这里给出一个简化版本的伪代码:
public class DeduplicateNode implements ProcessNode { private Cache<String, Boolean> recentCache = CacheBuilder.newBuilder() .maximumSize(100000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); @Override public boolean process(EventContext context) { String uniqueId = context.getEventId(); // 先走布隆过滤器快速过滤 if (bloomFilter.mightContain(uniqueId)) { // 再用缓存精确判断 Boolean exists = recentCache.getIfPresent(uniqueId); if (exists != null && exists) { return false; // 重复事件,丢弃 } } // 记录并放行 bloomFilter.put(uniqueId); recentCache.put(uniqueId, Boolean.TRUE); return true; } }这里的重点是“时间窗口”设定。去重不是永远生效的,一般只需要保护消息投递窗口内的重复,5 分钟足够覆盖大部分重复场景。窗口设太长了会有误杀风险——两个相同业务ID的事件在业务上本来就允许重复上报,窗口内去重会把第二个也杀掉,造成数据丢失。
重试机制方面,rea 采用的策略是分级重试。第一步是本地重试,某个节点处理失败时,在当前工作线程内快速重试 2 次,间隔 100 毫秒,这个策略主要应对临时性的数据库连接闪断。如果本地重试仍然失败,第二步是回队列重试,将消息重新投递到延迟队列,等待 30 秒、60 秒、180 秒后分三次重新入队处理。如果三次回队列重试都失败,第三步是进入死信队列,等待人工排查。
4. 实操过程与核心环节实现
4.1 搭建基础环境:Docker Compose 一键起全套
为了让 rea 的部署可复现,也为了让团队新成员能在十分钟内从零跑起整个环境,我写了一套 Docker Compose 配置文件。这套配置包含三个服务:RabbitMQ、MySQL、以及 rea 本体。
version: "3.8" services: rabbitmq: image: rabbitmq:3.12-management container_name: rea-rabbitmq ports: - "5672:5672" - "15672:15672" environment: RABBITMQ_DEFAULT_USER: rea_user RABBITMQ_DEFAULT_PASS: rea_pass volumes: - rabbitmq_data:/var/lib/rabbitmq healthcheck: test: ["CMD", "rabbitmq-diagnostics", "check_running"] interval: 15s timeout: 10s retries: 5 mysql: image: mysql:8.0 container_name: rea-mysql ports: - "3306:3306" environment: MYSQL_ROOT_PASSWORD: root_pass MYSQL_DATABASE: rea_db MYSQL_USER: rea_user MYSQL_PASSWORD: rea_pass volumes: - mysql_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 15s timeout: 10s retries: 5 rea: build: . container_name: rea-app depends_on: rabbitmq: condition: service_healthy mysql: condition: service_healthy ports: - "8080:8080" environment: SPRING_RABBITMQ_HOST: rabbitmq SPRING_RABBITMQ_PORT: 5672 SPRING_RABBITMQ_USERNAME: rea_user SPRING_RABBITMQ_PASSWORD: rea_pass SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/rea_db?useSSL=false&serverTimezone=Asia/Shanghai SPRING_DATASOURCE_USERNAME: rea_user SPRING_DATASOURCE_PASSWORD: rea_pass volumes: rabbitmq_data: mysql_data:这里面有两个细节值得说一下。第一,healthcheck 配置不是摆设,它保证 rea 容器只在 RabbitMQ 和 MySQL 都进入健康状态之后才开始启动,这避免了应用启动时连接不上依赖组件而崩溃的经典问题。第二,默认账号密码建议部署到生产环境前一定改掉,最好替换为密钥管理系统的注入值。
4.2 接入层实现:基于 Reactor 的响应式收口
接入层的代码实现上,我们用 Spring WebFlux 搭建了一套简单的响应式 REST 端点。核心代码如下:
@RestController @RequestMapping("/api/v1/rea") public class ReaEventController { private final ReactiveRabbitTemplate rabbitTemplate; public ReaEventController(ReactiveRabbitTemplate rabbitTemplate) { this.rabbitTemplate = rabbitTemplate; } @PostMapping("/events") public Mono<ResponseEntity<Map<String, Object>>> receiveEvent(@RequestBody String rawBody) { // 1. 基础合法性校验:非空、大小限制 if (rawBody.isEmpty() || rawBody.length() > 1024 * 1024) { return Mono.just(ResponseEntity.badRequest() .body(Map.of("status", "rejected", "reason", "invalid size"))); } // 2. 写入 RabbitMQ,整个操作是异步非阻塞的 return rabbitTemplate.convertAndSend("rea.main.exchange", "rea.main.routing", rawBody) .map(result -> ResponseEntity.ok() .body(Map.of("status", "accepted", "requestId", UUID.randomUUID().toString()))) .onErrorReturn(ResponseEntity.status(503) .body(Map.of("status", "service_unavailable", "reason", "queue full"))); } @PostMapping("/events/batch") public Mono<ResponseEntity<Map<String, Object>>> receiveBatch(@RequestBody List<String> batch) { // 批量接收模式,一次可以提交多条,降低业务方的 HTTP 请求数量 if (batch.isEmpty() || batch.size() > 500) { return Mono.just(ResponseEntity.badRequest() .body(Map.of("status", "rejected", "reason", "invalid batch size"))); } return Mono.when( batch.stream() .map(item -> rabbitTemplate.convertAndSend("rea.main.exchange", "rea.main.routing", item)) .collect(Collectors.toList()) ).map(empty -> ResponseEntity.ok() .body(Map.of("status", "accepted", "count", batch.size()))) .onErrorReturn(ResponseEntity.status(503) .body(Map.of("status", "service_unavailable", "reason", "queue full"))); } }接入层暴露的两个端点分别应对单条推送和批量推送场景。批量推送很重要,因为业务方很多场景下是攒了一批事件后统一上报的,如果只支持单条,业务方的调用次数会非常多,分布式环境下的网络开销也会很高。
这里有一个从实际项目中沉淀的点值得提示:批量接口要设置一个合理的上限,同时“收到”和“处理成功”是两个概念。接入层只保证消息进入了队列,业务方的数据不会丢,但并不保证数据能在处理链上正常走完。如果需要追踪具体批次的处理结果,可以通过 requestId 关联日志去查,我们的做法是在返回的 requestId 里关联消息头,后续排查问题非常方便。
4.3 处理链实现:节点编排的小型框架
处理链是 rea 的一个小型自研框架,核心类一个叫 ProcessNode(处理节点接口),一个叫 EventContext(事件上下文),一个叫 ProcessingChain(处理链)。这套框架总代码量不大,大约 300 行,但非常简洁实用。核心实现如下:
public interface ProcessNode { boolean process(EventContext context); } public class EventContext { private final String rawData; private String eventId; private String eventType; private Map<String, Object> parsedData; private boolean shouldDiscard; public void discard(String reason) { this.shouldDiscard = true; this.discardReason = reason; } // getters and setters omitted } public class ProcessingChain { private final List<ProcessNode> nodes; public ExecutionResult execute(EventContext context) { for (ProcessNode node : nodes) { boolean continueProcessing = node.process(context); if (!continueProcessing) { // 节点返回 false 表示终止链路,可能是去重丢弃、或者校验失败 return ExecutionResult.aborted(context); } if (context.isShouldDiscard()) { return ExecutionResult.discarded(context); } } return ExecutionResult.completed(context); } }这套接口设计的巧妙之处在于“约定大于配置”:每个节点只需要实现一个 process 方法,返回布尔值控制链路是否继续,完全不需要关心其他节点的存在。要扩展一个新功能时,写一个类实现 ProcessNode 接口,注册进链表中即可,不需要改动任何已有代码。
接下来我摘取处理链上一个典型节点的实现——规则过滤节点,这个节点在实际运行中也很容易踩坑。
public class RuleFilterNode implements ProcessNode { private final RuleEngine ruleEngine; @Override public boolean process(EventContext context) { String eventType = context.getEventType(); boolean allowed = ruleEngine.evaluate(eventType, context.getParsedData()); if (!allowed) { context.discard("rule_filter: " + eventType + " rejected"); } return true; // 这里返回 true,让链路继续,但会在下一节点检查 discard 状态 } }注意这里的一个细节:过滤节点返回的是 true,意味着链路继续执行,但数据已经标记了 discard,后续节点在读取时会检查 discard 状态并直接跳过处理。之所以不直接返回 false 终止链路,是为了保留统一的日志记录逻辑——链路的尾部有一个最终节点专门负责记录事发数据的丢弃原因,这能帮助你回头排查过滤规则是否误杀正常数据。
4.4 存储分发:按类型路由,不写死
存储分发节点是处理链的最后一环,它的职责是把处理完毕的事件写入对应的存储目标。在实际场景中,我们发现不同类型的事件,下游需求是完全不一样的:订单类事件需要写入 MySQL 做后续的事务型处理;日志事件需要写入 Elasticsearch 做检索分析;一些大文件的元数据则要发给下游 Kafka 做进一步处理。
所以存储分发节点的设计必须支持按类型路由。我们的实现方式是一个类型映射表:
@Component public class StorageRouter { private final Map<String, EventSink> sinkMap = new ConcurrentHashMap<>(); public void registerSink(String eventTypePrefix, EventSink sink) { sinkMap.put(eventTypePrefix, sink); } public EventSink route(String eventType) { // 支持类型前缀匹配,如 "order_" 开头的事件路由到 MySQL for (Map.Entry<String, EventSink> entry : sinkMap.entrySet()) { if (eventType.startsWith(entry.getKey())) { return entry.getValue(); } } return sinkMap.get("default"); } }采用前缀匹配而不是精确匹配,是为了应对业务类型持续扩展的问题。新增一个“order_refund”类型时,不需要改路由表,只要它仍像其他订单类事件一样写入 MySQL,直接命中“order_”前缀即可。这个设计让人省心,因为我们不需要在每次业务方加了一个子类型时就去更新代码。
存储分发节点有一个非常容易忽略的坑:批量写入的批次大小。如果每条事件都单独执行 SQL 插入,在高流量下数据库会被直接打满。我们最后调整为每累积 500 条事件或 1 秒时间窗口,批量刷一次 MySQL,这样既能控制数据库负载,也不会因为延迟太大而影响数据时效性。Elasticsearch 则是使用 Bulk API 批量提交,效果也很好。
5. 并发与性能测试实录
5.1 压测方案设计
任何系统上线前都要过一遍压测,rea 也不例外。我设计了一个分三档的压测方案。
- 第一档是平稳档:每秒 500 条事件,持续压 15 分钟,模拟平峰流量。
- 第二档是峰值档:每秒 3000 条事件,持续压 15 分钟,模拟业务高峰。
- 第三档是极限档:每秒逐步加压,从 3000 一直加到 10000,观察系统在哪个点开始“崩”,收集瓶颈数据。
压测工具我用的是 Locust,脚本模拟多个客户端同时向 rea 的接入层 POST 事件数据。事件数据是预先准备好的 JSON 样本,包含了多种业务类型,以保证压测结果符合真实流量特征。
5.2 吞吐量与资源占用数据
压测过程中我记录了关键指标,整理成一张表格,可以清楚地看到系统的表现趋势。
| 压测档位 | 接入层吞吐(条/秒) | 处理层吞吐(条/秒) | 队列积压峰值(条) | CPU 平均占用 | 内存占用 |
|---|---|---|---|---|---|
| 500 稳定档 | 512 | 498 | 约 800 | 35% | 4.2G |
| 3000 峰值档 | 2985 | 2860 | 约 15000 | 72% | 6.8G |
| 6000 极限档 | 5890 | 4230 | 约 40000 | 91% | 9.1G |
| 10000 超高尝试 | 7885 | 5300 | 约 120000 | 98% | 11.5G |
从数据上可以看到,接入层的吞吐能力比处理层更强,不过在 6000 条每秒左右的流量下,处理层已经接近天花板。此时 CPU 占用达 91%,意味着处理层的线程调度、数据解析、规则判断这些 CPU 密集操作已经成为瓶颈。再往上加压,接入层的吞吐量增长明显放缓,而队列积压迅速膨胀,堆积达到 12 万条,系统此时已经处于高负载但没有崩溃的状态。
这个压测结果其实很符合设计预期:接入层和处理层的吞吐不对等,是这类异步系统的正常现象。队列就像蓄水池,进水量大于出水量时水位就会上涨,但只要上涨速度可控、水位有上限,系统就能稳定运行。
5.3 瓶颈分析与调优过程
首次压测时,峰值档跑到 3000 条每秒,处理层的吞吐只有 1800 条每秒,队列积压飞速上涨。我第一反应是线程池太小了,于是把最大线程数从 32 调到 64,结果吞吐量只提升了大约 10%,CPU 占用率倒是涨了不少。这个反直觉的结果让我意识到,瓶颈不在线程数量,而在某些节点内部的串行操作上。
排查之后发现,问题出在存储分发节点的批量写入上。最初的实现是每 200 条或 2 秒批量刷一次 MySQL,但在 3000 条每秒的流量下,200 条的批次刷得太频繁,数据库连接池变成了争抢热点。我把批量阈值上调到 500 条,同时给写入操作单独分配了一个线程池,让存储写入与 CPU 密集型处理并行执行。调整之后,处理层吞吐直接提升到 2860 条每秒,效果非常明显。
这个经验让我明白了一件事:调优要先找到真正的瓶颈在哪里,单纯调大某个参数并不一定有用。如果参数调完没有明显改善,先停下来,观察指标,定位真实瓶颈,再动手,而不是继续“加量”。
6. 常见问题与排查技巧实录
6.1 消息积压不消费,重启后又能恢复
有一次线上环境报维护:消息队列积压持续上升,但处理层明明在运行。好奇心驱使下我去看监控图表,发现处理层的消费速率已经降到了一个很低的水平,几乎停滞。再往深处追,发现是下游 MySQL 的一张表出现了慢查询,单条 SQL 执行时间从平时的 20 毫秒涨到了 2 秒。
由于批量 INSERT 在等待数据库返回,存储分发节点所在的线程被长时间阻塞,而线程池中的可用线程被耗尽,后续的任务只能排队等待。看起来是处理层“卡住”了,但根因在数据库。重启应用之所以能恢复,是因为重启后线程池重新初始化,短时间内没有积压的旧任务,但只要你流量上来了,数据库慢查询还在,问题就会再次出现。
这个案例的启示是:当处理层出现消费停滞时,不要只盯处理层本身,要从整个链路的视角去排查。下游依赖组件(数据库、Redis、下游 HTTP 服务)变慢,是最常见的隐藏根因。后来我加了一层保护:存储分发节点如果连续 5 次写入超时,就触发熔断器,停止该类型事件的写入,转为写入死信队列,同时发出告警通知,避免线程池被耗尽拖垮整个处理层。
6.2 死信队列暴涨,原来是一条正则表达式的锅
还有一次,死信队列在半小时内从每天的几百条涨到了几万条。我查死信消息的内容,发现全是同一种事件类型,而异常原因全部相同,都是“字段校验失败”。瞧瞧具体字段,发现是一个手机号字段格式校验的正则表达式有问题。
那段正则原本是^1[3-9]\\d{9}$,看起来没问题,但实际业务量里有一部分是海外手机号,格式完全不匹配这个正则,按理说这个校验很久之前就有了,为什么偏偏这次死信暴涨?
追下去发现原因很“狗血”:业务方在某个版本里漏传了国家区号字段,把海外手机号直接当成中国手机号传了过来,导致正则校验全部失败。这其实是一个上游数据质量问题,但暴露出来的是 rea 的规则过于刚性——字段校验应该和数据源适配,不能一刀切。
这个问题最终的解决办法,是在字段补全节点之后再做校验。补全节点会根据业务上下文自动补齐国家区号,并且把手机号格式化处理成统一的标准形式。这样既保证了入库的数据质量,又不至于因为上游数据不完善导致大量误杀。
这里也引出一个排查技巧:死信队列是一个很好的数据质量探针。当死信量突然暴增时,优先看新增的死信类型和异常原因分布,把异常原因做一次 group by,你会发现很多原本根本没意识到的数据问题。
6.3 幂等去重误杀正常事件
去重节点上线后,出现了两个非常诡异的线上问题。一个是某个用户在同一天内领取了两次同一个活动奖励,但第二次被去重拦截了;另一个是业务方重复上传了同一批数据,但系统却完整地处理了多条。
这两个问题看起来矛盾,但根因是一样的:去重节点只用了“事件业务ID”作为去重 Key,而这个 ID 在同一业务场景下并不是全局唯一的。同一用户参加同一活动的多个事件,业务ID是相同的,因为它们的业务含义本来一样;但不同业务方、不同批次的数据之间,又有可能复用同一个 ID。
吃一堑之后,我调整了去重 Key 的生成策略:“去重上下文”由事件业务ID加接收时间窗组合出来。只有同一个业务ID且在 5 分钟时间窗口以内的重复事件才被拦截,超过时间窗口的重复事件允许通过。时间窗口的设定依据是 RabbitMQ 的重试和异常恢复周期,这个策略上线后,误杀问题就消失了。
6.4 排查工具与流程沉淀
排查上面这些问题时,我沉淀了一套排查流程,现在分享出来,希望能帮你少走弯路。
第一步看监控图表。重点看三个指标:接入层吞吐、处理层吞吐、队列积压量。如果接入层吞吐正常但处理层吞吐掉下来了,问题大概率出在处理层或者下游;如果接入层吞吐本身不正常,问题可能出在业务方或网络链路。
第二步进 RabbitMQ 管理界面。看队列积压量和消费速率,同时检查死信队列是否有新增消息。RabbitMQ 的管理界面非常直观,基本能解答“消息到底卡在哪了一环”的问题。
第三步查日志链路。用 requestId 跟踪一条消息从接入到处理完成的完整调用链,感觉就像孙悟空看穿白骨精的原形一样,一条消息每个节点耗时多少、是否被丢弃、丢弃原因是什么,一目了然。
第四步回放原始数据。如果是死信或丢弃问题,把原始事件和上下文信息从死信队列或日志中心捞出来,放到本地环境重新跑一遍处理链,可以更快定位是规则问题,还是数据问题。
7. 从 rea 到后续演进
rea 上线运行了大概大半年之后,业务量也涨了一些,达到每天千万级的事件量。在这个量级下,单机版的 RabbitMQ 和单实例处理层已经有些吃力了。我当时的处理思路是先在架构不变的框架下做横向扩展。
方法是:部署两个 rea 实例,共用一个 RabbitMQ 实例。两个实例消费同一个主队列,由于 RabbitMQ 原生的 Worker 模式天然支持多消费者并发消费同一队列,消息会在两个实例之间自动均衡分发。这个调整实施非常简单,只需要在 Docker Compose 中把 rea 服务复制一份,改一下端口,就实现了双实例并行处理。处理后端吞吐量直接翻倍,轻松扛住了之前的峰值流量。
再往后,如果 RabbitMQ 本身成为瓶颈,可以考虑把队列层升级为 Kafka。由于 rea 的处理链在设计时就做到了与消息队列解耦,替换队列层不需要改动处理节点代码,只需要修改接入层和消费者适配器即可。这个替换成本是比较低的,也是当初选型时的一个预期——上下层通过队列解耦的好处,会在架构演进的关键时刻体现出来。
另一个值得探索的方向是支持动态配置节点顺序。现在的处理链顺序在应用启动时确定,如果要调整节点顺序需要发布版本。后续可以引入一个轻量级规则引擎,把节点顺序和启停状态做成动态配置,这样业务调整响应会更及时,发布频率也可以降低。
以上就是 rea 从零搭建到稳定运行的全过程复盘。我个人在实际操作中体会最深的一件事是:架构设计不是选型最新最潮的框架,而是找到一套能解决当前问题且留有演进余地的方案。rea 的成功之处不在于它用了多高级的技术,而在于它把异步化、队列缓冲、可编排处理链、有界积压这几件事做对了,配合上合理的监控和排查体系,让整个系统在低成本运维的前提下,稳稳地扛住了业务增长。最后再分享一个小技巧:任何异步事件处理系统,上线之前一定要压测,而且要测到“系统崩溃”为止,知道极限在哪,线上出问题时你才会心里有数。