做了几年消息类后端服务,微信机器人API接口算是我踩坑最多的一类项目。公众号、企业微信这种场景表面上就是收发消息,但一旦用户量上来,活动推送一发起,消息回调就像潮水一样涌进来,系统瞬间从轻松惬意变成极限求生。Java做这类系统,绕不开三个关键词:高并发、连接池、消息异步处理。这篇我把自己在一套微信机器人管理后台里的完整设计思路、关键代码和调优过程整理出来,给同样在做API高并发设计的同学一个可以直接落地的参考。
这套方案解决的核心问题可以概括成一句话:在有限的服务器资源下,让API接口扛住突发流量,同时保证消息不丢、响应不慢、进程不崩。适合正在做IM类、Webhook回调类、机器人平台类后端的Java开发者,也适合准备面试想系统梳理高并发知识的同学。
1. 微信机器人API的高并发需求,到底“高”在哪里
1.1 拆解一下真实的业务场景
先别急着写代码,把场景搞清楚比什么都重要。我做的这个微信机器人管理后台,核心业务是聚合管理多个公众号和企微应用,提供统一的API接口给上层业务系统调用。实际跑起来之后,流量压力主要来自三个方向:
第一是回调风暴。微信服务器的消息推送机制是:用户一发消息,微信立刻把事件POST到你的回调地址上。平时流量平稳还好,但一旦赶上活动、推文、或者某个H5页面爆了,回调请求会在几秒内从每秒几十个飙到每秒几千个。而且微信的回调有重试机制——你的接口只要慢一点或者返回非2xx,微信就会按指数退避重试,这会导致流量进一步放大。这就是最典型的“流量放大器”效应,我见过太多系统倒在这一环。
第二是主动推送。运营人员时不时要群发消息、发模板通知,一次群发可能上万条,每一条都要调微信API发送,还要处理发送结果。这个场景的特点是:不要求实时性,但量大,而且每条发送都涉及一次HTTP调用。
第三是外部依赖慢调用。机器人收到一条消息,往往不只是回复一句“收到”,而是要经过NLP解析、查用户画像、查订单数据、调AI接口生成回复。这些外部接口的耗时从几十毫秒到几秒钟不等,完全不可控。
这三个场景叠加在一起,就是典型的IO密集型+突发流量负载模型。服务器CPU往往闲得很,但线程和连接资源却被外部慢调用占满了,用户看到的现象就是接口超时、消息延迟、服务假死。
1.2 高并发设计的三个核心目标
这个项目在设计之初,我和团队定了三个必须达成的目标,后面所有技术选型都围绕这三个目标展开:
一是快速响应。回调接口必须在微信超时时间内(一般是5秒)返回结果,否则会触发微信的重试机制。我们内部定的目标是P99响应时间不超过800ms。
二是稳定不崩。外部依赖再慢,也不能拖垮核心链路。某个AI接口挂了,不能让整个机器人系统瘫痪,要能做到“带病运行”。
三是消息不丢。异步处理之后,消息从收到到处理完成的链路变长了,任何一环出问题都可能导致消息丢失。业务上可以接受延迟,但不能接受丢消息。
想清楚这三个目标之后,方案其实就呼之欲出了:入口收敛+连接池兜底+异步剥离。入口收敛是控制器只做最轻量的校验和落地,连接池保证数据库、Redis、HTTP三层资源在高并发下不被打穿,异步剥离把耗时的业务逻辑从API线程中移出去。下面逐个展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 连接池设计:高并发系统的第一道闸门
2.1 连接池到底在扛什么
很多人对连接池的理解就是“复用连接少创建”,但真正的高并发场景下,连接池的意义远不止省资源这么简单,它其实是一道流量闸门。
打个比方:食堂打饭,窗口就是连接,每个窗口每次只能服务一个人。如果没有限流机制,1000个人同时挤到窗口前,整个食堂就乱了。连接池做的事情就是一个食堂管理员——维护固定数量的窗口,让打饭的人排队、等待、按顺序来。
在高并发场景下,连接池能防止慢请求拖垮整个系统。比如数据库突然变慢,单次查询从10ms变成2秒。如果没有连接池或者连接池开得太大,所有线程都会卡在等待数据库连接上,线程耗尽,服务直接假死。连接池大小是有限的,这反而是一种保护:最多只有池子里那么多请求在访问数据库,其他请求在池外排队,系统不会因为某个下游服务变慢而整体崩溃。
具体到微信机器人这类系统,需要关注三层连接池:数据库连接池、Redis连接池、HTTP连接池。每一层都有自己的配置逻辑和坑。
2.2 数据库连接池:从公式到实战参数
先看数据库连接池。我用的是Spring Boot默认的HikariCP,被称作“最快的Java连接池”,性能上没什么争议。关键是参数怎么配。
先理解一个基本公式:连接池的理论并发数 = 系统QPS × 单个请求数据库耗时。假设系统目标是支撑1000 TPS,每个请求平均要查2次数据库,单次查询耗时10ms,那么需要的连接数是:1000 × 0.02 = 20个。
但这个公式不能直接照搬,因为还有一个约束:CPU核心数。HikariCP官方文档给出的经验公式是 maximumPoolSize = (core_count × 2) + effective_spindle_count,其中spindle_count指的是磁盘数量,SSD环境下基本是1。按照这个公式,一台2核4G的服务器建议的池大小是5。这个值看起来很保守,但它背后的逻辑是:每个活跃连接都会占用一个线程,而线程切换是有成本的,连接数超过CPU核心数的合理倍数后,再多只会增加上下文切换开销,不会带来吞吐提升。
我实际的项目配置是这样的,一台2核4G的云服务器,跑着Spring Boot应用,数据库是单独的MySQL实例:
yaml复制spring:
datasource:
hikari:
# 核心连接数:维持最小存活连接
minimum-idle: 5
# 最大连接数:2核CPU的合理上限
maximum-pool-size: 20
# 获取连接的超时时间:宁可快速失败,也不无限等待
connection-timeout: 3000
# 连接最大存活时间:避免DB端主动断开后产生无效连接
max-lifetime: 1800000
# 空闲连接回收时间
idle-timeout: 600000
# 检测连接是否有效的超时时间
validation-timeout: 1000
# 泄漏检测阈值:超过5秒未归还连接就告警
leak-detection-threshold: 5000
这几个参数里,我要重点强调connection-timeout和leak-detection-threshold。
connection-timeout设成3000ms的意思是:如果3秒内拿不到连接,直接抛异常快速失败,而不是让请求无限期排队。在回调接口这种场景下,快速失败的价值远大于死等——微信收到非2xx响应后会重试,总比重试超时强。
leak-detection-threshold是HikariCP的独门利器,只要设置的连接占用时间超过阈值,日志里就会打出详细的泄漏堆栈。我后面排查生产事故时,这个参数帮了大忙,后面常见问题部分细说。
另外注意max-lifetime必须小于数据库侧wait_timeout,否则连接会被数据库服务端偷偷断开,而客户端还不知道,用的时候就报连接失效。MySQL默认wait_timeout是8小时,max-lifetime设30分钟是安全的选择。
2.3 Redis连接池:别让缓存层成为瓶颈
消息类系统几乎必然用到Redis,存会话状态、存用户上下文、做分布式锁、做计数器。Redis本身单机就能扛10万+QPS,但如果你用的客户端姿势不对,Redis会成为最先顶不住的瓶颈。
Spring Boot 2.x之后默认的Redis客户端是Lettuce。这里有个重要的历史坑:Lettuce在默认配置下是单连接共享的,也就是所有线程共用一条连接到Redis。这么做的好处是省资源,坏处是高并发下链路利用率打满,延迟飙升。在遭遇一次线上延迟告警之后,我把Lettuce改成了连接池模式。配置如下:
yaml复制spring:
redis:
lettuce:
pool:
# 最大连接数
max-active: 16
# 最大空闲连接
max-idle: 8
# 最小空闲连接
min-idle: 2
# 获取连接最大等待时间:100ms
max-wait: 100ms
shutdown-timeout: 100ms
max-active设16是基于压测结果来的。我们单台服务器Redis操作的峰值QPS大约在3000左右,单次操作耗时不到1ms,16条连接已经足够。max-wait设100ms是为了避免Redis故障时请求全部堆积等待,快速失败比无限等待对系统整体更友好。
这里还要提醒一个细节:不要在循环里频繁获取和释放连接。有的同学写代码习惯每操作一次就getConnection()再close(),这在高并发下会产生巨大的池分配开销。正确做法是注入Spring封装的StringRedisTemplate或RedisTemplate,它们底层会自动管理连接的借用和归还,线程安全。
还有一个常见坑:用了Redis连接池,但把所有业务线共享一个池子。比如机器人消息处理逻辑里,既要读缓存又要用分布式锁,锁等待和缓存读取互相抢连接,极端情况下会形成循环等待。我后来把核心链路的Redis操作和辅助业务的Redis操作拆成两个不同max-total的连接池,才彻底解决了这个问题。
2.4 HTTP连接池:第三方API调用的命门
到了最容易被忽略、实战中最容易出事的一层:HTTP连接池。
微信机器人系统里,HTTP调用到处都是:调微信API发消息、调AI接口做语义理解、调内部服务查数据。如果每次请求都新建HTTP连接,性能有多差呢?一次完整的TLS握手要2~4次网络往返,加上TCP三次握手,一次新连接建立至少需要几十毫秒,甚至上百毫秒。高并发下,光建立连接就能把线程耗死。
而且微信API有频率限制,调用太快会被限流,调用太慢又会积压任务。所以HTTP连接池不仅要管好连接数量,还要配合超时和限流策略。
我用的OkHttp,配置供参考:
java复制@Configuration
public class OkHttpConfig {
@Bean
public OkHttpClient okHttpClient() {
ConnectionPool connectionPool = new ConnectionPool(
50, // 最大空闲连接数
5, TimeUnit.MINUTES, // 空闲连接存活时间
new NamedRunnable("okhttp-connection-monitor")
);
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(200); // 整个客户端最大并发请求数
dispatcher.setMaxRequestsPerHost(20); // 单个域名最大并发请求数
return new OkHttpClient.Builder()
.connectionPool(connectionPool)
.dispatcher(dispatcher)
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.build();
}
}
maxRequests和maxRequestsPerHost这两个参数是OkHttp高并发设计的精髓。maxRequestsPerHost设为20,等于给每个目标域名(比如wx-api的域名)设置了一个并发上限。这么做有两个作用:一是防止单个业务方把整个HTTP客户端占满,影响其他业务;二是微信API本身有频率限制(语音消息、模板消息各不相同),我们通过连接池配置从源头做了限流,比在业务代码里加锁优雅得多。
retryOnConnectionFailure我设置的是false。这又是一个反直觉的选择。OkHttp默认在连接失败时会自动重试,但在微信API这种场景下,重试会放大流量,微信的回调机制本来就会重试,我们这边再重试一次,等于双倍流量。更稳妥的做法是连接失败后直接返回错误,由上层来决定是否进入重试队列。
还要注意HttpClient必须是单例。我看到很多项目把OkHttpClient写在方法里new一个,等于完全没用连接池。Spring的@Bean默认是单例的,用上面的配置就不会出错。
3. 消息异步处理:把耗时操作从API线程里“剥离”
3.1 同步调用模式的问题
连接池解决的是资源复用和限流问题,但即使连接池配置得再完美,同步调用的架构天花板依然很低。
我举个例子来说明。假设一条用户消息进来,处理流程是:接收消息 → 查用户信息(10ms) → 查历史会话(20ms) → 调AI接口生成回复(2000ms) → 调用微信API发送(100ms)。总耗时大约2130ms。
如果在Servlet线程池(默认200个线程)里同步跑这个流程,系统能支撑的最大并发是 200 ÷ 2.13 ≈ 94 QPS。这个数字对很多业务来说远远不够。更致命的是,在2130ms的耗时里,有1900ms是纯等待——线程在等AI接口返回,什么正事都没干,白白占着线程资源。
线程一旦被这些慢请求占满,后面进来的正常请求只能排队等着,整个系统的RT曲线直接起飞。这就是我在前面说的,要从架构层面把耗时操作从API线程里剥离出去。
3.2 线程池设计:隔离、命名、拒绝策略一个都不能少
异步处理最基础的形态是用线程池。但直接Executors.newFixedThreadPool(10)这种写法在生产环境是要出事的,因为newFixedThreadPool用的是无界队列,任务只会无限堆积,不会触发拒绝策略,内存迟早被打爆。
我的习惯是自定义ThreadPoolExecutor,把参数全部显式控制住。而且不仅仅是一个线程池,我会按业务特性拆成多个线程池,彼此隔离:
java复制@Configuration
public class ThreadPoolConfig {
// 核心消息处理线程池:负责机器人消息的接收和快速处理
@Bean("messageHandleExecutor")
public ThreadPoolExecutor messageHandleExecutor() {
return buildExecutor("msg-handle", 10, 50, 1000);
}
// 外部API调用线程池:负责调用AI接口、微信API等慢速外部依赖
@Bean("externalApiExecutor")
public ThreadPoolExecutor externalApiExecutor() {
return buildExecutor("ext-api", 20, 100, 2000);
}
// 数据写入线程池:负责日志、埋点、异步落库等低优先级任务
@Bean("dataWriteExecutor")
public ThreadPoolExecutor dataWriteExecutor() {
return buildExecutor("data-write", 5, 20, 2000);
}
private ThreadPoolExecutor buildExecutor(String poolName,
int coreSize,
int maxSize,
int queueCapacity) {
ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setNameFormat(poolName + "-%d")
.build();
return new ThreadPoolExecutor(
coreSize,
maxSize,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(queueCapacity),
threadFactory,
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
}
这里有几个设计考量值得多说几句。
为什么要拆多个线程池:消息处理线程池里的任务如果调用了AI接口,而AI接口超时长达几秒,那整个消息处理线程池都会被拖住,进而是进来的消息全都延迟。拆线程池后,消息处理线程只做轻量逻辑,把真正的外部调用丢给externalApiExecutor,两边互不干扰——你不拆池,就可能出现“核弹级”的故障传导。
为什么核心线程和最大线程差这么多:ThreadPoolExecutor的工作机制是,核心线程满之后,新任务先进队列,队列满了才创建新线程到最大线程数。我设的核心线程偏小、队列适中、最大线程大,是为了应对“平时流量平稳、突发流量顶上来”的场景。平时10个线程够用,突发时最多扩到50个。
CallerRunsPolicy是双刃剑:这个策略的意思是线程池满了,任务不丢弃,而是由提交任务的线程(也就是API请求线程)自己来执行。好处是不丢任务,坏处是API线程会被拖住,增加响应时间。我选它是因为在回调场景下,丢消息比延迟更不可接受。如果你扛不住延迟,可以考虑DiscardOldestPolicy+报警。总之务必使用自定义的ThreadPoolExecutor,不要用Executors工具类创建,面试常考,线上也常出事。
3.3 用CompletableFuture编排异步链路,把串行改成并行
拆完线程池,还有一个非常值得做的优化:把消息处理链路中的串行调用改成并行调用。
回到前面那个例子:查用户信息、查历史会话、调AI接口,这三个调用之间其实没有依赖关系,但同步代码只能一个个挨着执行,总耗时是三者之和。用CompletableFuture可以同时发起这三个调用,总耗时变成三者最大耗时。
我封装了一个并行聚合的方法,在消息处理链路中处理“多路查询+一次回复”的场景:
java复制public CompletableFuture<ReplyResult> buildReply(String userId, String message) {
// 三个并行调用
CompletableFuture<UserInfo> userFuture = CompletableFuture
.supplyAsync(() -> userService.getUser(userId), externalApiExecutor)
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(ex -> UserInfo.unknown());
CompletableFuture<List<HistoryMsg>> historyFuture = CompletableFuture
.supplyAsync(() -> historyService.getRecentHistory(userId, 10), externalApiExecutor)
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(ex -> Collections.emptyList());
CompletableFuture<String> aiFuture = CompletableFuture
.supplyAsync(() -> aiService.generateReply(message), externalApiExecutor)
.orTimeout(2000, TimeUnit.MILLISECONDS)
.exceptionally(ex -> "抱歉,我暂时无法理解你的意思。");
// 聚合等待
return CompletableFuture
.allOf(userFuture, historyFuture, aiFuture)
.thenApplyAsync(v -> ReplyBuilder.build(
userFuture.join(),
historyFuture.join(),
aiFuture.join()
), messageHandleExecutor);
}
几个关键点:
orTimeout是Java 9+提供的超时控制,任何一个下游调用超时了,整个链路直接走exceptionally分支返回兜底结果。这个兜底机制非常重要——外部接口挂了的时候,系统返回预设文案而不是直接报错,用户体验不至于崩。
thenApplyAsync指定了后续聚合操作在messageHandleExecutor执行,而不是在调用方线程执行。如果你不指定线程池,这个后续操作会跑在上一个任务完成的线程上,也就是externalApiExecutor里,可能会导致外部调用线程池被打满。
最后调用方拿到ReplyResult之后,把回复消息也异步发出去,整条链路就完成了同步收包 → 异步处理 → 异步回包的改造。
3.4 什么时候上消息队列
线程池+CompletableFuture的组合已经能解决大多数场景,但有一个问题它解决不了:应用重启会丢内存队列里的消息。LinkedBlockingQueue是JVM内存里的东西,进程一挂,队列里的任务灰飞烟灭。
当业务量继续上升,或者对消息可靠性要求更高时,就该引入消息队列了。我是在单机异步扛不住、需要横向扩展的时候上的RabbitMQ,模式就是经典的生产者-消费者:
- 生产者:回调接口收到消息后,把消息体+消息唯一ID发到MQ,接口立刻返回成功。整个接口耗时控制在20ms以内,对外表现为纯异步。
- 消费者:独立部署的消费服务从MQ拉消息,做业务处理、调微信API、回写结果。
- 重试机制:消费失败的消息进入死信队列,由定时任务重新投递。
- 削峰填谷:MQ天然有削峰能力。回调风暴来临时,MQ积压消息,消费端按固定速率处理,系统不会被打爆。
引入MQ之后,架构从“进程内异步”升级成了“跨进程异步”,可靠性、扩展性和伸缩性都上了一个台阶。但不要一上来就上MQ,这是很多架构师常犯的过度设计毛病。我见过不少项目,消息量一天才几千条,业务逻辑也不复杂,非要上Kafka建集群,最后开发和运维成本比省下的那点性能高得多。先线程池,后MQ,是一个比较合理的技术演进节奏。
4. 完整落地:一个可参考的微信机器人API高并发骨架
4.1 项目整体结构与依赖
前面讲了原理和关键代码片段,这一节给一个完整的、能跑起来的骨架结构。技术栈:Spring Boot 2.7 + MyBatis-Plus + Redis(Lettuce) + OkHttp + RabbitMQ。包结构按功能分层:
code复制com.example.robot
├── controller
│ └── CallbackController.java // 微信回调入口
├── service
│ ├── MessageHandleService.java // 消息处理服务
│ ├── WeChatApiService.java // 微信API调用封装
│ ├── AiService.java // 外部AI接口封装
│ └── AsyncMessageQueue.java // 异步消息队列封装
├── config
│ ├── OkHttpConfig.java // HTTP连接池配置
│ ├── ThreadPoolConfig.java // 线程池配置
│ └── RabbitMqConfig.java // MQ配置
├── entity
│ ├── InboundMessage.java // 入站消息实体
│ └── OutboundMessage.java // 出站消息实体
├── mapper
│ └── MessageLogMapper.java // 消息日志表操作
└── common
└── IdGenerator.java // 分布式ID生成(雪花算法)
依赖上按需引入就好,核心就这些:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.11.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
4.2 回调入口:只做轻量校验和快速投递
回调接口是整个系统的流量入口,它只有一个任务:把消息快速放进异步链路,然后立刻返回。任何重操作都不允许出现在这里,这是这条接口绝对不能破的底线。
java复制@RestController
@Slf4j
public class CallbackController {
@Resource
private AsyncMessageQueue asyncMessageQueue;
@PostMapping("/wechat/callback")
public ResponseEntity<String> callback(@RequestBody String xmlBody) {
// 1. 解析消息体,提取消息类型、用户ID、内容、消息ID
InboundMessage message = MessageParser.parse(xmlBody);
// 2. 根据消息ID做幂等校验,防止微信重试导致重复处理
if (messageLogService.isDuplicated(message.getMsgId())) {
return ResponseEntity.ok("success");
}
// 3. 异步投递到消息队列,立即返回
asyncMessageQueue.publish(message);
// 4. 返回成功,告诉微信不用重试
return ResponseEntity.ok("success");
}
}
幂等校验放在入口还是消费端?我的建议是两边都放。入口做一次粗粒度校验,防止同一个消息ID被重复投递到MQ;消费端再做一次细粒度校验(比如数据库唯一索引),防止极端情况下MQ重投导致重复处理。
4.3 异步消息队列的轻量实现
在还没上MQ的阶段,这个AsyncMessageQueue其实是对线程池的一层封装,同时承担了“缓冲+排队+落库”三个职责:
java复制@Component
@Slf4j
public class AsyncMessageQueue {
private static final int RETRY_MAX_TIMES = 3;
@Resource
private MessageHandleService messageHandleService;
@Resource(name = "messageHandleExecutor")
private ThreadPoolExecutor messageHandleExecutor;
public void publish(InboundMessage message) {
// 先落库,保证消息不丢
messageLogService.save(InboundMessageLog.from(message));
// 再提交到线程池处理
messageHandleExecutor.submit(() -> {
try {
messageHandleService.handle(message);
} catch (Exception e) {
log.error("handle message error, msgId={}", message.getMsgId(), e);
retryLater(message);
}
});
}
private void retryLater(InboundMessage message) {
if (message.getRetryCount() >= RETRY_MAX_TIMES) {
log.error("message retry exhausted, msgId={}", message.getMsgId());
messageLogService.markFailed(message.getMsgId());
return;
}
message.setRetryCount(message.getRetryCount() + 1);
// 延迟重试:先放到一个延迟队列,稍后再投递
delayedRetryQueue.offer(message, 10, TimeUnit.SECONDS);
}
}
重点说下“落库”。在消息进入异步处理前先写日志表,状态是“已接收”。消费端处理完成后更新状态为“已处理”。这个日志表有几个作用:一是重启后可以捞出来重新投递,实现不丢消息;二是可以追踪每条消息的完整生命周期;三是排查问题时能直接看到消息卡在哪一环。
4.4 压测结果与调优前后对比
骨架搭完之后,我用JMeter做了三轮压测,压测目标是单机(2核4G)撑住每秒500条消息回调,成功率99.9%以上。
第一轮压测是最原始的同步实现:Controller直接调AI接口再返回。结果惨不忍睹,线程池直接被打满,P99响应时间超过10秒,大量超时,成功率只有81%。
第二轮是加了线程池异步、CompletableFuture并行调用,没有上MQ。效果立竿见影,接口响应时间降到80ms以内,吞吐提升到400 QPS左右。但继续加压到600 QPS时,线程池队列开始积压,内存占用上升,GC变频繁。
第三轮是完整方案:入口落库 + MQ削峰 + 多消费者并行处理。压测结果显示:
| 指标 | 同步实现 | 线程池异步 | 线程池 + MQ |
|---|---|---|---|
| 接口P99响应时间 | >10000ms | 80ms | 25ms |
| 最大吞吐(QPS) | 94 | 430 | 800+ |
| 消息丢失率 | 有(线程池满) | 无 | 无 |
| 重启场景 | 丢消息 | 丢内存消息 | 不丢(MQ持久化) |
| 扩展性 | 单机 | 单机 | 可横向扩展 |
可以看到,线程池异步解决的性能问题,MQ解决的是可靠性+扩展性问题。两者配合才是完整的高并发方案。
5. 常见问题与排查技巧实录
这一节是我在实际上线之后频繁踩到、也帮不少人排过的典型问题,整理成速查表,方便对照排查。
5.1 连接池连接泄漏,接口大面积超时
现象:上线几天后发现,每到业务高峰期,接口RT从几十毫秒飙升到几秒,日志里大量 HikariPool-1 - Connection is not available, request timed out after 3000ms。
排查:打开HikariCP的leak-detection-threshold: 5000,跑了半小时后在日志里抓到了泄漏堆栈,定位到某段代码里手动开了数据库事务,但异常分支没有回滚和释放连接。真凶找到了。
code复制2024-xx-xx 12:00:01.123 WARN [http-nio-8080-exec-3] com.zaxxer.hikari.pool.ProxyLeakTask
- Connection leak detection triggered, stack trace:
at com.example.robot.service.OrderService.query(OrderService.java:45)
...
这类问题的根因基本都集中在三个地方:事务注解没生效、自定义切面里没释放连接、ResultSet/Statement没关闭。高并发下连接池资源紧张,一点点泄漏都会指数级放大。
经验:涉及数据库的代码,优先用Spring的@Transactional管理事务,不要手动getConnection();必须在finally里释放连接。leak-detection-threshold一定要开,线上环境的救命稻草。
5.2 线程池遇到RejectedExecutionException
现象:高峰期日志里突然大量出现 java.util.concurrent.RejectedExecutionException: Task rejected from java.util.concurrent.ThreadPoolExecutor,紧随其后的是接口报错和消息丢失。
排查:线程池满了,队列也满了,任务提交被拒绝。看监控发现消息线程池的活跃线程数持续打满在最大值50,队列一直保持满状态,说明消费能力远低于生产速度。
解决思路是三步:一是确认外部依赖是否变慢,比如AI接口RT从1秒涨到5秒,拖住了消费线程;二是根据实际流量重新估算线程池大小;三是把AbortPolicy改成CallerRunsPolicy,不丢任务,代价是接口RT上涨。
注意CallerRunsPolicy不能滥用,如果生产速度长期大于消费速度,这个策略会把所有API线程拖去处理异步任务,导致正常请求无法响应。它只是应急手段,长期解法是扩容消费者,也就是上MQ增加消费实例。
5.3 异步消息处理丢了,到底谁干的
现象:用户发消息了,但机器人没回复,查日志发现消息在“已接收”状态,一直没有变成“已处理”。
排查:第一件事看是不是应用重启了。进程内LinkedBlockingQueue里的任务在JVM退出时全没了,这是线程池方案最大的弱点。我之前踩过一次:发版的时候用kill -9强杀进程,队列里几百条消息全军覆没。
解法:入口处先落库再入队,重启后启动一个补偿任务,扫描数据库中状态为“已接收”且超过一定时间仍未“已处理”的消息,重新投递。上了MQ之后,只要消息进了MQ,即使消费端重启,消息也不会丢,autoAck设为false,处理成功后才确认消息。
另一个隐蔽的丢消息场景是消费端处理失败但没抛异常。有人喜欢在消费逻辑里try-catch包住所有异常,然后什么也不做,消息被吞了。正确做法是:捕获异常后走重试,重试N次后进入死信队列并告警。
5.4 压测不达标,从哪一层开始排查
现象:压测QPS始终上不去,低于预期值,CPU却还有大量空闲。
排查路径我总结了一个顺序,从下往上查:
- 连接池配置:连接池太小会导致大量请求在等待连接,超过
connection-timeout直接报错。看监控里“等待连接时间”这个指标。 - 线程池配置:核心线程太小、队列太小,任务被拒绝。看“活跃线程数”指标是否触顶。
- GC情况:频繁Full GC会直接拖垮吞吐量。如果同时看到CPU飙高和GC频繁,优先检查堆内存大小和被缓存的对象是否有泄漏。
- 锁竞争:排查代码中的
synchronized、ReentrantLock、redis分布式锁。压测时用jstack抓线程dump,看哪些线程处于BLOCKED或WAITING状态。 - 慢SQL:数据库CPU飙高、慢查询日志刷屏,往往是被某条糟糕的SQL拖的。用
EXPLAIN分析执行计划是否走了索引。
绝大多数“压测上不去”的问题,按这条线路都能找到根因。我遇到的最离奇的一次是排查到第四层才发现,是一个同事在公共工具类里写了synchronized方法,所有请求进入时都在抢同一把锁。
回顾这次从连接池到消息异步处理的完整改造,我个人最大的体会是:高并发不是靠某一个“神器”解决的,而是靠一整套分层设计——连接池控制资源、线程池隔离故障、异步化剥离慢操作、MQ保证可靠,每一层解决一个问题,层层配合才扛住了流量洪峰。踩过最深的一个坑是先追求性能优化而忽略了可靠性设计,结果一次发版丢了几百条消息,被业务方追着问了一周。所以如果你的项目也打算做类似的高并发改造,我给的建议是:先落实消息不丢的机制,再谈性能优化;先小流量验证,再全量放开;任何一步改动都要有监控和数据支撑,别凭感觉调参。这套思路不仅在微信机器人场景适用,任何高并发API服务都可以借鉴,希望对你有用。
