谈谈高并发下的处理经验

        最近,公司对主打产品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全表扫描,勤用执行计划

五、压测感想

        压测其实重在开发的过程,我们平时在设计和编写代码的时候,就应该注重压测性能,而不是产品要定版了,就风风火火地安排性能优化。

猜你喜欢

转载自blog.csdn.net/weixin_38316944/article/details/118942053