背景与挑战
我们项目在上线初期面临严重的性能瓶颈:
- 性能现状:单机播放控制场景QPS仅为15左右,远低于预期
- 技术特点:采用流式输出、大量长连接的架构
- 业务压力:计划将400-500万用户接入平台
- 核心风险:性能瓶颈成为平台上线的关键阻碍
通过系统性的性能优化,最终实现了单机QPS从15提升至70,平台可稳定支撑500万用户并发访问的显著改善。
优化方法论
分析工具与手段
- JMeter:压力测试与性能基准测试
- JProfiler:运行时性能监控与热点分析
- MAT (Memory Analyzer Tool) :内存泄漏检测与对象分析
- 系统监控:GC日志分析、线程状态监控
优化策略
采用多维度系统性优化策略,包括JVM调优、中间件优化、代码重构、架构调整等。
核心优化措施
一、缓存策略优化
问题分析:项目中存在大量高频查询场景未使用缓存,导致数据库压力过大,查询性能低下。
优化方案:
- 识别高频查询接口和数据热点
- 为核心查询场景添加多级缓存
- 实施缓存预热和失效策略
- 建立缓存命中率监控机制
效果:显著减少数据库查询压力,提升响应速度。
二、流式输出技术选型对比
SseEmitter vs Flux 技术对比
维度 | SseEmitter | Flux (WebFlux) |
---|---|---|
编程模型 | Servlet阻塞式 | 响应式编程 |
线程模型 | 每连接占用线程 | 非阻塞事件驱动 |
并发性能 | 中低并发场景适用 | 高并发表现优秀 |
学习成本 | 低,传统MVC风格 | 高,需响应式编程经验 |
兼容性 | 老项目友好 | 最适合WebFlux+Netty |
实践结论
通过构建对照实验,发现在固定文本返回场景下,WebFlux性能虽有提升但未达到数量级差异。考虑到:
- 代码重构成本较高
- 团队技术栈适配度
- 项目交付时间约束
决策:继续使用SseEmitter,通过其他维度优化达成性能目标。
三、工作流引擎优化
问题识别:MAT内存分析显示工作流执行链占用大量内存。
3.1 WorkflowId生成策略优化
原始方案:使用UUID生成唯一标识
ini
// 问题:每次生成全新ID,无法复用工作流结构
String workflowId = UUID.randomUUID().toString();
优化方案:基于EL表达式生成确定性ID
ini
// 解决:相同EL表达式生成相同ID,支持工作流复用
String workflowId = DigestUtils.md5Hex(elExpression);
优化效果:
- 内存优化:相同工作流结构复用,避免重复创建
- 性能提升:跳过重复解析和编译步骤
- 缓存友好:提高工作流引擎缓存效率
3.2 异步执行线程池优化
问题:LiteFlow默认线程池配置(核心16,最大64)在高并发场景下不足。
优化方案:
java
public class LiteFlowConfig {
// 定义自定义线程池
@Bean(name = "customLiteFlowExecutor")
public ExecutorService customLiteFlowExecutor() {
// 自定义线程工厂,设置线程名称前缀
ThreadFactory threadFactory = new ThreadFactory() {
private final AtomicInteger threadNumber = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r);
thread.setName("LiteFlow-Worker-" + threadNumber.getAndIncrement());
return thread;
}
};
// 配置线程池参数
return new ThreadPoolExecutor(
32, // 核心线程数
128, // 最大线程数
60L, TimeUnit.SECONDS, // 空闲线程存活时间
new ArrayBlockingQueue<>(200), // 有界队列,容量200
threadFactory, // 自定义线程工厂
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:回退到调用者线程
);
}
// 配置 LiteFlow 执行器,注入自定义线程池
@Bean
public LiteflowExecutor liteflowExecutor(ExecutorService customLiteFlowExecutor) {
LiteflowExecutor executor = LiteFlowProxyUtil.getInstance(LiteflowExecutor.class);
executor.setExecutorService(customLiteFlowExecutor);
return executor;
}
}
四、线程池与并发优化
4.1 系统架构与资源配置
- 硬件配置:4核8G云服务器
- 部署架构:EA(入口服务) + SA(核心处理服务),容器化部署
- 并发模式:大量SSE长连接 + 流式输出
4.2 串行处理改并行优化
问题:EA与SA任务串行执行,响应时间过长
优化方案:引入专用线程池实现任务并行
less
// 并行处理,提升响应速度
eaExecutor.execute(() ->{
......
});
saExecutor.execute(() ->{
......
}
4.3 关键线程池参数调优
组件 | 原始配置 | 优化配置 | 调优理由 |
---|---|---|---|
Tomcat最大线程 | 200 | 500 | SSE长连接需要更多线程支撑 |
任务队列大小 | 10000 | 100 | 减少任务堆积,避免超时(>10s) |
EA线程池 | 默认 | 核心100/最大200 | 匹配请求入口压力 |
SA线程池 | 默认 | 核心200/最大400 | 承载核心业务处理 |
HttpClient连接池 | DefaultMaxPerRoute=100 | 400 | 避免EA->SA调用瓶颈 |
五、内存泄漏排查与修复
5.1 ShutdownHook内存泄漏
问题发现:压测结束后内存持续占用4G+,MAT分析发现大量shutdownHooks对象堆积。
根因分析:
less
// 问题代码:每次请求都注册新的ShutdownHook
Runtime.getRuntime().addShutdownHook(new Thread(eventSource::cancel));
问题机制:
- 高并发下每次执行都创建新Thread对象
- Hook持有eventSource引用,阻止GC回收
- JVM关闭前不会清理,造成内存泄漏
解决方案:
less
// 删除ShutdownHook,在连接关闭时手动清理资源
try {
// 业务逻辑处理
} finally {
eventSource.cancel(); // 手动清理资源
}
@Override
public void onFailure(EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {
eventSource.cancel();
}
5.2 OkHttpClient线程泄漏
问题:JProfiler显示压测期间线程数达到2000+,其中1000+由OkHttpClient创建。
根因:
scss
// 问题:每次调用都创建新的OkHttpClient实例
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(timeout, TimeUnit.MILLISECONDS)
.readTimeout(timeout, TimeUnit.MILLISECONDS)
.writeTimeout(timeout, TimeUnit.MILLISECONDS)
.build();
解决方案:
scss
// 复用现有OkHttpClient实例,避免重复创建
OkHttpClient client = okHttpClient.newBuilder()
.connectTimeout(timeout, TimeUnit.MILLISECONDS)
.build();
优化效果:峰值线程数从2000+降至600。
六、中间件性能调优
6.1 Kafka参数优化
问题:JProfiler显示大量Kafka阻塞线程,影响整体性能。
优化配置:
ini
# 提升吞吐量,降低延迟
kafka.producer.acks=0 # 原值:-1,降低可靠性要求
kafka.producer.linger.ms=1000 # 原值:1,批量发送提升效率
拒绝策略调整:
- 原策略:CallerRunsPolicy(主线程执行)
- 新策略:DiscardPolicy(直接丢弃)
- 理由:避免影响主线程性能,数据重要性相对较低
6.2 ID生成策略优化
问题:MongoDB串行获取ID导致加锁排队,效率低下。
解决方案:引入雪花算法
- 避免数据库IO操作
- 消除串行生成ID的阻塞
- 保证ID全局唯一性
- 提升高并发场景下的ID生成效率
七、连接管理优化
7.1 连接泄漏修复
问题发现:使用netstat命令检查发现大量连接未正常关闭
perl
for %i in (ESTABLISHED LISTENING TIME_WAIT CLOSE_WAIT SYN_SENT) do @echo %i: & netstat -ano | findstr %i | find /c /v ""
根因:异常情况下SseEmitterUTF8连接未正确关闭。
解决方案:完善异常处理机制,确保连接在异常状态下也能正确释放。
压测实践经验
一、JMeter配置要点
关键参数设置
- 线程数设置:从50开始逐步调整,避免设置过大(如400)
- 原因分析:流式长连接项目响应较慢,过高并发可能导致测试不准确
- 建议策略:渐进式压力测试,找到系统真实瓶颈点
环境隔离优化
单机压测问题:JMeter本身资源消耗可能影响被测服务性能
解决方案:
- 测试机与服务机分离部署
- 独立的网络环境避免带宽竞争
- 监控JMeter自身资源使用情况
二、熔断机制处理
压测期间熔断策略
临时关闭熔断的必要性:
-
压测环境特殊性
- 请求量远超正常负载
- 服务响应时间自然增长
- 熔断机制可能误判服务不可用
-
熔断对测试的影响
- 拒绝部分请求,无法反映真实性能
- 无法测试系统极限承载能力
- 影响性能指标的准确性
-
临时关闭的好处
- 确保请求完整到达目标服务
- 避免测试结果偏差
- 准确测量系统真实性能表现
线程池使用规范
核心原则
- 优先自定义ThreadPoolExecutor:避免Executors默认(如newFixedThreadPool的无界队列易OOM)。
理由:灵活控制队列/拒绝策略。
- 设置有界队列
理由:防积压,早拒绝。
- 自定义ThreadFactory:线程名前缀(如"EA-Task-")。
理由:便于JProfiler追踪/调试。
- 拒绝策略选择:非关键任务用DiscardPolicy;关键用CallerRunsPolicy。
理由:平衡性能与可靠性。
- 指定线程池: 在使用 @Async 或其他异步方式(如 CompletableFuture、LiteFlow 等)时,最好显式指定线程池,而不是依赖默认线程池。
理由:显式指定线程池能精确控制资源、优化性能、便于监控,避免默认配置的"黑盒"风险。
总结与展望
优化成果
- 线程
- 性能指标:单机QPS从15提升至70,提升366%
- 用户支撑:平台稳定支撑500万用户并发访问
- 系统稳定性:解决内存泄漏、连接泄漏等关键问题
- 资源利用率:通过参数调优充分利用硬件资源
优化方法总结
- 系统性分析:结合多种工具进行全面性能诊断
- 分层优化:从JVM、框架、应用代码多层面优化
- 数据驱动:基于压测数据和监控指标进行决策
- 风险控制:保持系统稳定性的前提下进行优化