Bug追踪

Web后台性能优化的方法论

2019-03-15  本文已影响156人  转角遇见一直熊

最近公司的网站被客户投诉很慢,需要优化一下。花了一段时间研究整理如下文档,文档主要记录一下解决问题的方法和过程,我本身认为提炼出方法才是一个人的核心素质。

本文主要包含:

  1. 制定工作计划,找到解决问题的步骤,时间紧但是不慌不乱。
  2. 分层解决性能问题,一个系统性的问题不是一个人解决的,也不是一个部门解决的,在技术上要分层,分配任务上也要分层。
  3. 寻求其他团队帮助。在公司里面,一个人往往是一个角色,只能在一定时间内完成一个特定任务。

项目背景

简单的来说,网站供用户查询数据,数据是从另外一方同步过来的。服务端使用的技术是spring boot + vue,前后端分离,数据库使用mysql,有三个服务三个数据库分别做负载。 接口数据在30s内有缓存,30s后由于同步数据过来,缓存会被清空保证数据正确。

性能调优的工作计划

不做充分调研埋头就干往往是挖坑的开始,所以不管时间多紧迫,老板叫上天了,都不要忽视调研和做计划这一步。制定的工作计划如下:

  1. 使用 JMeter 测试几个被吐槽的接口。这一块可以请测试组帮忙,也可以自己做。测试下来这几个接口
  2. 阅读代码,搞清楚框架设计,数据库设计,代码逻辑。这个一下子肯定看不完,只能抓重点。
  3. 在框架层面进行优化,这个叫开着飞机换引擎,风险大收益大,可以解决一些先天疾病。
  4. 在代码层面进行优化,比如接口逻辑的优化,sql优化,对一些被吐槽的接口需要仔细关注。
  5. 在前端优化,有一些接口可能返回数据太多,拆分几个接口后,前端在同一个界面可以分批加载,没加载好的loading。
  6. 每次优化都需要重新测试,比较前后结果。
  7. 出具优化报告,说明优化结果。
  8. 建立防护机制,防止接口被刷。
  9. 建立实时监控系统,对程序性能做实时掌控,不要等到客户投诉。

建立好了计划,老板让一星期干的活,你就可以有理有据的要求更多时间了。如果实在不能给更多时间,那就只能砍功能。

1. 使用JMeter测试被吐槽的接口

既然已经被吐槽了,肯定就是慢,为什么要测试一下呢?这是因为作为一个程序员不能相信别人的话,必须要有数据,如果是测试给你的性能报告,则可以不需要自己测了,但是老板和客户端口头描述,则要仔细斟酌。

因为我不是测试专家,下面测试步骤有啥不对的还请指出。

我希望测试的接口可以达到 800/s tps。所以参数设置成10s中启动8000个用户请求。这个设置要看你的机器配置,单台机器不一定能满足这个压测需求。

测试参数

下图是一个聚合报告,我们可以看到tps是317.8/sec,但是响应时间很慢,中位数都要6s,真是搞不懂这种响应时间怎么就有300的tps,还有一些错误返回可能耗时间比较短。我在测试环境把redis去掉了,tps降低到了8/m。这才是这个接口的真实情况。


Aggregate Report Response Time

我把压力调小一点,10s中启动800个用户,结果tps也还是 300/s,只是错误响应减少了。

2. 阅读代码,搞清楚代码大致的设计

请教了原来代码的作者,大致搞清楚了代码的如下情况:

  1. 程序框架,部署框架。
  2. 被吐槽接口的逻辑。
  3. 页面显示的逻辑。

这个接口之所以这么慢是因为干了很多事情:

  1. 返回交易列表
  2. 区块链的节点上查询余额,这一块消耗很大,后面我们再说找到消耗最大函数的方法。

3. 在框架层面优化代码

现有框架如下图。


现有框架

该设计有如下问题:

  1. 三个同步服务,插入到三个数据库,插入会影响数据库性能,并且容易导致数据不一致。
  2. 同步服务会每30s同步数据,会清空redis相关的数据,清空的粒度不够细。可以根据业务逻辑清空特定的数据。
  3. 数据库都是插入和查询都可以支持,遇到插入的时候查询性能低。
  4. 每个数据库的内容都是一样的,没有达到分库分表的目的。

那么我们可以做以下快速的改造

  1. 数据库做主从设置,读写分离,主数据库插入数据,从数据库读取数据。
  2. 当库大于1T,表中行数上千万以后可以考虑分库分表,虽然现在数据量还没有这么大,但是框架可以先搭好。

改进的框架如下:

改进后的框架

相比于原来的框架,改进的框架有如下优点:

  1. 数据库读写分离了,可以改进性能。
  2. 数据库可以水平分表(图中没有体现)。
  3. 客户端连接到proxy,方便dba操作数据库。
  4. Web API Service没有使用proxy,防止proxy成为性能瓶颈。另外一种选择是proxy搭建多个,防止性能瓶颈。
  5. 目前只有一主二从,但是可以很方便的扩展到多主多从,横向扩展性较好。

为什么要分库分表?
一般来讲,单一数据库实例的数据的阈值在1TB之内,是比较合理的范围。数据分片指按照某个维度将存放在单一数据库中的数据分散地存放至多个数据库或表中以达到提升性能瓶颈以及可用性的效果。 数据分片的有效手段是对关系型数据库进行分库和分表。分库和分表均可以有效的避免由数据量超过可承受阈值而产生的查询瓶颈。 在技术上主要是把锁的冲突降低了,数据库单点访问的压力降低。

什么是水平分片
数据分片的拆分方式又分为垂直分片和水平分片。按照业务拆分的方式称为垂直分片,又称为纵向拆分,它的核心理念是专库专用。 在拆分之前,一个数据库由多个数据表构成,每个表对应着不同的业务。而拆分之后,则是按照业务将表进行归类,分布到不同的数据库中,从而将压力分散至不同的数据库。水平分片又称为横向拆分。 相对于垂直分片,它不再将数据根据业务逻辑分类,而是通过某个字段(或某几个字段),根据某种规则将数据分散至多个库或表中,每个分片仅包含数据的一部分。水平分片从理论上突破了单机数据量处理的瓶颈,并且扩展相对自由,是分库分表的标准解决方案。

对于水平分表,是比较复杂的,可以利用现有框架实现。经过对比,推荐使用shardingsphere,他的3.0还没有apache发布,maven引用使用阿里云的仓库就好。

3.1 对比一下读写分离后的效果

去掉redis,用一个接口测试一下,tps:1518/s。

去掉redis,用两个从数据库读取一个数据的简单接口

生产环境tps: 100/s。


生产环境

但是上面数据是不可信的,因为生产环境部署在网上,我测试环境在本地,延迟大概差500~1000倍。真实的数据还是等测试部署了再看看。

3.2 ShardingSphere结合Druid

ShardingSphere整体构思非常好,有长远规划,下面是其架构图,感兴趣可以查看官方文档。

ShardingSphere

由于想用Druid的监控功能(Druid数据库连接池加上监控看起来不是很好的设计,可能会影响性能,但是好用实在),我使用java代码加载Sharding JDBCDruid

下面是数据库配置类:

@Slf4j
@Configuration
@EnableConfigurationProperties(ShardingMasterSlaveConfig.class)
//@ConditionalOnProperty({"sharding.jdbc.datasource.master.url", "sharding.jdbc.config.masterslave.master-data-source-name"})
public class ShardingDataSourceConfig {

    @Autowired(required = false)
    private ShardingMasterSlaveConfig shardingMasterSlaveConfig;

    @Bean("dataSource")
    public DataSource masterSlaveDataSource() throws SQLException {
        shardingMasterSlaveConfig.getDataSources().forEach((k, v) -> configDataSource(v));
        Map<String, DataSource> dataSourceMap = Maps.newHashMap();
        dataSourceMap.putAll(shardingMasterSlaveConfig.getDataSources());
        Map<String, Object> propertiesConstantMap = Maps.newHashMap();
        propertiesConstantMap.put(ShardingPropertiesConstant.SQL_SHOW.getKey(), "true");
        Properties properties = new Properties();
        properties.putAll(propertiesConstantMap);
        DataSource dataSource = MasterSlaveDataSourceFactory.createDataSource(dataSourceMap, shardingMasterSlaveConfig.getMasterSlaveRule(), propertiesConstantMap, properties);
        log.info("masterSlaveDataSource config complete");
        return dataSource;
    }

    private void configDataSource(DruidDataSource druidDataSource) {
        druidDataSource.setMaxActive(20);
        druidDataSource.setInitialSize(1);
        druidDataSource.setMaxWait(60000);
        druidDataSource.setMinIdle(1);
        druidDataSource.setTimeBetweenEvictionRunsMillis(60000);
        druidDataSource.setMinEvictableIdleTimeMillis(300000);
        druidDataSource.setValidationQuery("select 'x'");
        druidDataSource.setTestWhileIdle(true);
        druidDataSource.setTestOnBorrow(false);
        druidDataSource.setTestOnReturn(false);
        druidDataSource.setPoolPreparedStatements(true);
        druidDataSource.setMaxOpenPreparedStatements(20);
        druidDataSource.setUseGlobalDataSourceStat(true);
        try {
            druidDataSource.setFilters("stat,wall,slf4j");
        } catch (SQLException e) {
            log.error("druid configuration initialization filter", e);
        }
    }

    public ServletRegistrationBean getServletRegistrationBean(String username,  String password) throws Exception {
        ServletRegistrationBean reg = new ServletRegistrationBean();
        reg.setServlet(new StatViewServlet());
        reg.addUrlMappings("/druid/*");//配置访问URL
        reg.addInitParameter("loginUsername", username);  //配置用户名,这里使用数据库账号。
        reg.addInitParameter("loginPassword", password);  //配置用户名,这里使用数据库密码
        reg.addInitParameter("logSlowSql", "true");   //是否启用慢sql
        return reg;
    }


    public FilterRegistrationBean getFilterRegistrationBean() {
        FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
        filterRegistrationBean.setFilter(new WebStatFilter());
        filterRegistrationBean.addUrlPatterns("/*");
        filterRegistrationBean.addInitParameter("exclusions", "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*");  //配置那些资源不被拦截
        filterRegistrationBean.addInitParameter("profileEnable", "true");
        return filterRegistrationBean;
    }

    @Bean
    public ServletRegistrationBean druidServlet() throws Exception {
        return getServletRegistrationBean("yin",  "yin");
    }

    @Bean
    public FilterRegistrationBean filterRegistrationBean() {
        return getFilterRegistrationBean();
    }

}

下面是数据库配置文件读取类

@Data
@ConfigurationProperties(prefix = "sharding.jdbc")
public class ShardingMasterSlaveConfig {

    private Map<String, DruidDataSource> dataSources = new HashMap<>();

    private MasterSlaveRuleConfiguration masterSlaveRule;
}

下面是配置文件

server:
  port: 9090

sharding.jdbc:
  data-sources:
    ds_master:
      driver-class-name: com.mysql.jdbc.Driver
      url: jdbc:mysql://localhost:3306/explorer?useUnicode=true&characterEncoding=utf8&useSSL=false
      username: root
      password: explorertest
    ds_slave_0:
      driver-class-name: com.mysql.jdbc.Driver
      url: jdbc:mysql://localhost:3306/explorer?useUnicode=true&characterEncoding=utf8&useSSL=false
      username: root
      password: explorertest
    ds_slave_1:
      driver-class-name: com.mysql.jdbc.Driver
      url: jdbc:mysql://localhost:3306/explorer?useUnicode=true&characterEncoding=utf8&useSSL=false
      username: root
      password: explorertest
  master-slave-rule:
    name: ds_ms
    master-data-source-name: ds_master
    slave-data-source-names: [ds_slave_0,ds_slave_0]
    load-balance-algorithm-type: round_robin

这个加载方法还是不错的。

3.3 框架更进一步

在我心里,实际上完全可以使用云服务的nosql方案,比如Azure的Cusmos DB,AWS的Dynamo,可是采用这些方案对现有代码改动比上面方案要大,需要重写或者适配Repository层,所以没有采用。不过如果是全球范围内的项目,建议还是采用这些更未来的数据库,可以省很多事情,掏钱就可以把数据复制到世界各个地方。

另外服务端没有搭建微服务框架,只是一个单体服务,配置什么的到处copy,虽然服务不是很多,也可以搭建一个小型的注册中心,用来同步配置文件。

4. 代码层面进行优化

代码层面的优化需要知道哪里出了问题,问题主要来源于两方面:

  1. 用户和测试反馈
  2. 代码分析工具

因为我已经知道被吐槽的接口了,所以用工具跑一下代码,我用的工具是VisualVM,跑出来的结果如下:


老代码Profiler

很容易就找到了花时间大户。相关函数花了3s多。

这个接口经过调查,发现干了太多事情,原因是前端一个很复杂的页面,里面所有的数据都通过这个接口返回了,属于设计不合理,所以拆成了一个快速接口(访问本地数据库数据),一个慢速接口(需要访问第三方系统),前端可以先加载,然后慢速接口的数据增加一个局部loading。

拆分接口后

拆分后快速接口时间降低为70ms。慢速接口由于依赖外部,暂时没法优化。

5. 前端界面优化

既然接口已经拆分,前端也需要改一下。
可以看到下图,慢接口的页面会迟于快借口的界面显示,但是首屏的速度变快了。


慢接口loading

5.1 前端页面的性能检查工具

虽然这次主要优化后端,也还是看一下前端页面的优化方法。

下图是取消浏览器cache,第一次加载页面的情况,可以看到一个大文件加载4s,很长时间,说明打包的东西太多了需要优化。

没有浏览器cache的情况

下面查看一下打包情况,看看有没有不需要的东西被打包了


查看打包情况

下图是打包情况,我们可以看到element-ui,echarts,svg占据的空间比较大,可以酌情优化。一些文件也可以使用CDN,没必要放在自己服务器。


打包情况

6. 呼叫其他团队帮忙

剩下来出具测试报告,建立接口防护机制,监控系统则可以请测试运维团队的同事帮忙,主要是自己时间不多,还要做其他项目。

  1. 测试报告需要对比自己项目优化前后的接口有没有达到800tps,另外还要对比竞争对手的接口。
  2. 运维可以监控机器的负载情况,如果机器负载过高,可以横向扩容。
  3. 运维需要配置防护机制,对于相同IP的请求需要做频率限制,这个也可以由gateway实现。

6.1 测试的工作

下图是测试可以做的一些工作,可以看到还是很多内容,开发也可以介入帮忙


测试需要做的工作

7. 其他可能得问题

现在同步服务那边,还没有对大规模的数据插入做优化,只是每30s同步一下,现实中也遇到过数据更新的特别快,来不及同步的情况,后面应该做个缓存,一秒钟同步一次数据,数据插入到redis,再从redis读取数据,批量插入到数据库,这些时间参数,可以调校。

总结

本文并没有深入细节讨论,但是整个方法论应该可以帮助开发去优化系统,实际上不管什么任务,只要有条有理,就可以获得不错的产出。
下图是我在开始任务之前整理的脑图,脑子清楚了再做事绝对没有错。

下图看不清楚没关系,只要知道这个意思就可以了。

脑图

参考资料

ShardingSphere官方文档
基于Jmeter的性能测试框架搭建改进一
基于Jmeter的性能测试框架搭建改进二

上一篇下一篇

猜你喜欢

热点阅读