最近,公司对主打产品7.0版本进行了性能压测工作。压测工具采用了jmeter,中间件Tomcat,数据库oracle,服务器linux,负载均衡nginx,缓存redis。这里提一下我们的产品,采用SpringBoot架构,功能十分强大,主动集成几乎所有功能;作为一个开发业务程序的程序员,不到10分钟便可开发一个业务程序,功能包括主子表CRUD、走流程、附件、收藏等等,实现了完全的零代码开发程序。
压测要求也很严格。首先是接口压测,1000人并发(加同步定时器),加压5分钟,保持平均响应时间在1s内;然后是场景压测,1000人并发(加?固定定时器),加压10分钟,保持90%请求的响应时间在1s内。数据量保持在每张表100万左右。
作为这次性能压测工作的主力,全程参与了该项工作,在压测过程中也总结了一些经验和技巧,在这里记录下,希望能帮助到大家。
一、性能压测做了什么?
1、数据索引优化
流程表、业务主子表索引精简
2、慢SQL优化
优化SQL语句、使用hint语法
3、数据库事务
业务查询接口不开启事务
4、上下文信息复用
开发流程引擎上下文注解器,复用请求链路中的数据流,减少数据库查询
5、业务逻辑优化
工作流操作命令查询、区域查询逻辑优化、操作日志、编码规则等异步批量入库
6、服务拆分
维护页面分页查询与导航条功能拆分、书签功能拆分
7、环境配置
数据库连接池参数调整、tomcat参数配置、Linux系统优化
8、缓存优化
对接口中所有可以使用缓存的场景查漏补缺,服务启动预加载
二、性能压测该如何进行?
优化过程三板斧:SQL优化、业务逻辑梳理、JVM监控
1、Druid监控定位效率低下的SQL,通过执行计划、AWR(ADDM)报告逐步调优
2、通过梳理全链路业务逻辑,并追踪类方法或代码片段运行情况,定位代码性能瓶颈
3、监控系统资源和JVM运行状况,寻找性能隐患,提升代码执行效率
SQL优化:
Druid => explain plan => AWR(ADDM)
首先,在Druid中主要看SQL的最慢执行时间(找出性能差的SQL)、SQL的执行次数(看是否重复查询、是否可以使用缓存)、SQL的总执行时间(查看是否可以去掉该SQL)和SQL的执行时间分布情况(越靠左越好);
然后,将SQL在plSql中调试,查看执行计划是否合理、能否进一步优化、更合理利用索引;
最后,在导出的Oracle运行报告中查看该SQL的执行计划是否与预期相同(根据PlanHash查看实际的执行计划,如何压测过程中的执行计划与预期的执行计划不符合,那可能要使用oracle的hint语法,如“select /* +INDEX(WF****MST NO)*/ * from WF****MST where NO = ?”)。报告里主要看Top SQL,它会告诉你哪个SQL执行需要优化,占用CPU、IO、内存的情况。
--压测前后创建快照
exec dbms_workload_repository.create_snapshot();
--压测结束生成报告
@?/rdbms/admin/awrrpt.sql
业务逻辑梳理:
梳理接口实现逻辑:
安全拦截器 = 权限拦截器 = DD拦截器 = 业务框架 = 业务扩展 = 接口响应拦截器
案例:
1、自定义上下文注解 @DiyContect(name="BpmPermission");
如果一个请求中,走了一套非常复杂的业务处理逻辑,需要走相同的很复杂的方法两次以上,而调用该方法的逻辑间隔很远,无法合并该如何办呢?我们采用的自定义上下文注解的方式,将第一次查询的结果放入request的attribute中,等第二次用的时候再取用。
//上下文注解
@DiyContext(name="BpmPermission")
public BpmPermission buildBpmPermissionResult(ProcessCheckParam pcp) {
BpmPermission permission = new BpmPermission();
//业务逻辑
return permission;
}
//取用
BpmPermission bpmPermission = (BpmPermission) ContextHolder.getContextObj("BpmPermission");
2、流程引擎操作命令查询、区域逻辑优化;
这部分优化与业务相关,从业务角度出发,省去中间的繁杂的步骤。
3、操作记录和编码规则异步、批量入库处理。
//异步处理-事件发布器
public static ApplicationEventPublisher publisher;
/**
* 静态变量通过set方法注入
*/
@Autowired
public void setSdkService(ApplicationEventPublisher publisher){
\tSqlExecutor.publisher = publisher;
}
//发布
publisher.publishEvent(new AsynWriteToDbEvent(Object));
@Component
public class AsynWriteToDbListener {
/**
* 监听入口
*/
@Async("bpmTaskExecutor")
@EventListener(AsynWriteToDbEvent.class)
public void listener(AsynWriteToDbEvent event) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Log.error("线程睡眠1秒钟异常", e);
}
//取出实例号,删除流程参与人表重复数据
if(event.getSource() != null && event.getSource() instanceof String) {
event.getSource();
}
}
}
/**
* 批量写入数据库
*/
private void writeToDB(Queue<Object[]> queue, String sql) {
List<Object[]> batchParams = new ArrayList<>();
for(int i = 0; i < AsynSQL.BATCH_SIZE; i++) {
Object[] param = queue.poll();
if(param == null) {
break;
}else {
batchParams.add(param);
}
}
if(batchParams.size() == 0) {
return;
}
Database.execute(db -> {
try {
if(batchParams.size() == 1) {
db.update(sql, batchParams.get(0));
}else if(batchParams.size() > 1) {
db.batch(sql, batchParams);
}
} catch (Exception e) {
Log.error("[流程异常], e);
return true;
});
}
/*
* 异步线程配置
*/
@Configuration
public class TaskExecutorConfig {
/**
* 流程异步执行SQL的线程,控制在单个线程,匀速执行
*/
@Bean("bpmTaskExecutor")
public TaskExecutor bpmTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setThreadNamePrefix("BpmThread-");
executor.initialize();
return executor;
}
}
JVM监控
针对资源占用比较高的进程进行stack跟踪分析,定位问题代码并进行优化
top
jstack -l 2222 stack.stack
"http-nio-8080-exec-1085" #1451 daemon prio=5 os_prio=0 tid=0x00007fd5cc0d9800 nid=0x844e in Object.wait() [0x00007fd785cdc000]
java.lang.Thread.State: BLOCKED (on object monitor)
at java.lang.Object.wait(Native Method)
at java.lang.Object.wait(Object.java:502)
at net.sf.ehcache.store.FrontEndCacheTier$Fault.get(FrontEndCacheTier.java:782)
- locked <0x0000000747cc8668> (a net.sf.ehcache.store.FrontEndCacheTier$Fault)
at net.sf.ehcache.store.FrontEndCacheTier.getQuiet(FrontEndCacheTier.java:234)
at net.sf.ehcache.Cache.searchInStoreWithoutStats(Cache.java:2101)
at net.sf.ehcache.Cache.get(Cache.java:1624)
at org.springframework.cache.ehcache.EhCacheCache.lookup(EhCacheCache.java:151)
at org.springframework.cache.ehcache.EhCacheCache.get(EhCacheCache.java:71)
at net.*****.core.components.cache.service.CacheService.getCache(CacheService.java:161)
三、典型场景
流程:
1、异步处理
问题背景-数据库资源监测正常,druid中SQL执行很慢
2、业务逻辑梳理
很多张表同时在查询和插入,流程同步与异步SQL
3、异步线程数量控制
业务查询接口不开启数据库事务
4、队列大小
队列中复杂对象
5、批处理数量
批量插入Oracle最有数量值
6、线程延迟处理
延迟睡眠、匀速执行
表单引擎:
1、问题背景-执行表达式慢
我们在流程、表单、润乾等模块加入了表达式解析的功能,这是我们可以配置的表达式样例
请审批:【getString("DOCQBD_ID")】getString("DOCQBD_TITLE")
我们采用的是开源的Fel解析,但在压测是发现fel在创建引擎对象特别耗时,有时候能到达3s左右;即便是在非压测情况下,循环体内1000次创建fel对象,也能高达500ms左右。
FelEngine fel = FelBuilder.bigNumberEngine();
2、业务逻辑梳理
在一次请求中,可能要解析两次表达式,那么就相应地创建2个fel对象,这在压测环境下影响非常大。
3、预加载引擎对象
首先,我们想到可以在产品启动的时候,预先加载对象至队列中,然后用的时候再从队列中取;
/**
* 解析引擎队列
*/
private static ConcurrentLinkedQueue<FelEngine> engines = new ConcurrentLinkedQueue<>();
/**
* 初始化队列
*/
public void init() {
for (int i = 0; i < QUEUE_SIZE; i++) {
engines.add(FelBuilder.bigNumberEngine());
}
}
/**
* 获取解析引擎
*/
private FelEngine getEngine() {
//省略了在取出fel对象之前,先开启一个异步线程,在这个子线程中创建对象放入该队列的代码
return engines.poll();
}
初始化了500个,但是压测的时候根本不够用;比如500并发,吞吐量在300左右,那么一秒钟就需要600个fel对象,但是jvm不可能在1s内生成这么多fel对象;随后我们增大了异步线程数为100,去生成fel对象,然后放入队列,但是仍然跟不上消耗的速度。即生产者始终供应不了消费者的需求。
4、对象复用
这时候只能分析源码了,贴上代码
//这是生成fel对象的方法
FelEngine fel = FelBuilder.bigNumberEngine();
//其内部慢在new FelEngineImpl()方法
public static FelEngine bigNumberEngine(int setPrecision) {
FelEngine engine = new FelEngineImpl();
FunMgr funMgr = engine.getFunMgr();
engine.setParser(new AntlrParser(engine, new NodeAdaptor() {
//略
}));
funMgr.add(new BigAdd());
funMgr.add(new BigSub());
funMgr.add(new BigMul());
funMgr.add(new BigDiv(setPrecision));
funMgr.add(new BigMod());
funMgr.add(new BigGreaterThan());
funMgr.add(new BigGreaterThanEqual());
funMgr.add(new BigLessThan());
funMgr.add(new BigLessThanEqual());
return engine;
}
//继续往下追,发现是创建新的解析器对象慢this.newCompiler(name)
public CompileService() {
String name = this.getCompilerClassName();
FelCompiler comp = this.newCompiler(name);
this.complier = comp;
}
//获取到编译器的名字
private String getCompilerClassName() {
String version = System.getProperty("java.version");
String compileClassName = FelCompiler.class.getName();
if (version != null && version.startsWith("1.5";)) {
compileClassName = compileClassName + "15";
} else {
compileClassName = compileClassName + "16";
} return compileClassName;
}
//终于发现了慢的罪魁祸首==>cls.newInstance()
private FelCompiler newCompiler(String name) {
FelCompiler comp = null;
try {
Class<FelCompiler> cls = Class.forName(name);
comp = (FelCompiler)cls.newInstance();
} catch (ClassNotFoundException var4) {
var4.printStackTrace();
} catch (InstantiationException var5) {
var5.printStackTrace();
} catch (IllegalAccessException var6) {
var6.printStackTrace();
}
return comp;
}
可以看到最终发现了创建fel对象效率低下的罪魁祸首==>cls.newInstance() ,看源码知道,原来fel的团队为了适配Java版本,当jdk1.5时创建FelCompile15,当高于jdk1.5时则采用加载FelCompile16。其实大可不必,采用固定写类名的方法一样可行。我们的jdk版本很早都是jdk1.8了,所以很容易改造下源码就能满足我们的压测需求。
但是我当初却采用另外一种思路,即对象复用。fel对象虽然有上下文(context),虽然有自定义的方法(funmgr),但经过重置,还是可以复用的。而且复用总比创建对象好,进一步节省资源的消耗。
/**
* 初始化fel引擎
*/
private FelEngine initEngineFun(FuncContext context, String expression, List<ExpFunConfig> expfunInfoList) {
// 获取引擎对象
FelEngine fel = getEngine();
// 非空,则是复用对象,需重设属性
if(fel != null) {
fel.setContext(getMapContext(context));
fel.setFunMgr(getFunMgr());
}else {
fel = FelBuilder.bigNumberEngine();
}
// 添加打印函数用于调试
fel.addFun(new PrintFunction());
// 将平台配置的fun加入到引擎
while(true){
fel.addFun(function);
}
return fel;
}
/**
* 获取新的 funMgr 对象
* @return
*/
public FunMgr getFunMgr() {
FunMgr funMgr = new FunMgr();
funMgr.add(new BigAdd());
//中间略
return funMgr;
}
/**
* 获取新的 FuncContext 对象(fel引擎对象传入上下文( 将运行模式,添加到Fel的上下文中,以便Fel的四则运算的函数分场景处理。这里封装,转化到jar包中。))
*/
public MapContext getMapContext(FuncContext context) {
MapContext mapContext = new MapContext();
mapContext.set("Exp_RunMode", RunModeEnmu.get(context.getRunMode().getValue()));
return mapContext;
}
四、编码规范
1、接口响应时间必须在50ms以内,原则上不超过100ms;
2、循环体不要处理高耗能事务,比如查库、文件流、网络请求、对象创建、抛异常;
3、service层、dao层、utils工具类复用;
4、非强关联事物启用异步线程、消息调用方式。异步线程采用线程池;
5、尽量使用开源工具,少造轮子。比如网络流、文件读写流等;
6、尽量使用jdk最新特性方法,比如list的遍历推荐使用新特性中的流式读写;
7、热门配置数据、基础数据缓存;
8、避免db全表扫描,勤用执行计划
五、压测感想
压测其实重在开发的过程,我们平时在设计和编写代码的时候,就应该注重压测性能,而不是产品要定版了,就风风火火地安排性能优化。