让web程序和本地程序一样“飞”

2020-11-05  本文已影响0人  乱七八糟谈技术
最新有些空余时间分析一些项目中的性能瓶颈问题,通过一系列的性能分析工具找到了对应的症结并进行了优化,现总结出一些通用的分析性能和解决性能的方法,不会涉及到数据库调优,JVM调优,操作系统调优等这些高深内容,这些本人也不会,我们的系统也不是那种百万级,千万级流量的互联网产品。我介绍的内容都比较通用,主要涉及到前端优化,数据库性能,代码性能,网络传输,内存,缓存,等相关技术来优化系统性能,全部都是自己的经验总结,未必全面。

写在前面

大部分互联网产品,用户量很大,并发请求量也很大,数据也是不断变化,但每次展现给用户的数据量可能比较小,比如一条订单信息,一个产品信息等等,因此会大量使用后端缓存技术,分库分表,比如数据库缓存,Redis缓存等,精简请求数据来减少网络传输,前端懒加载用户需要的数据来改善用户体验。还有一些企业产品,展现数据量比较大,并发请求量相对于互联网产品小很多,使用的用户也比较固定,但用户对前端展现性能比较高,基本要做到零延时,比如,很多的实时控制系统基本属于这类,很多的model数据改变比较小,动态变化的是实时值,但用户需要在一个页面上监控到整个系统的运行状态,查看设备参数。这类产品之前基本都是Native程序为主,现在也都在转向网页版。因此,为了做到和Native程序一样的用户体验效果,除了后端缓存技术,也可以利用一些前端缓存技术来改善用户体验。还有一些企业产品,介于这两类产品之间,并发用户量不是很大,数据请求量也不算大,但数据也是不断变化,比如企业ERP系统。这类产品使用数据库技术基本就能满足需求,也可以结合一些Redis缓存来提供性能。

发现问题

在我们的案例中,有一个页面是用来展现一个楼层的平面图上展现设备信息,设备相关参数以及设备的一些实时运行数据。这个数据量是非常大,而且是一个层级结构,在数据库中会涉及到很多张表的关联join查询。在分析系统性能过程中,发现这个页面展现比较慢,虽然前端做了很多的懒加载,但是整体给用户的体验还是比较差。

分析问题

通过F12查看这个页面网络请求,最大的请求就是加载这个树状的设备层级结构图,数据量大概在3M左右,设备比较多的楼层,接近于5M的数据,仔细分析这些数据,发现基本都是必须数据,只有少数冗余数据,减少这些冗余数据,对系统性能不会有根本性的改善。请求全部花费的时间在20多秒,因此用户体验很不好。在后端,使用PostgreSQL的性能分析工具pg_stat_statements来查看慢查询。
//表示要在启动时导入pg_stat_statements 动态库
shared_preload_libraries = 'pg_stat_statements'
//表示监控的语句最多为1000句
pg_stat_statements.max = 1000
//表示监控所以的sql语句pg_stat_statements.track = all
 create extension pg_stat_statements;
select pg_stat_statements_reset();
 SELECT  query, calls, total_time, (total_time/calls) as average ,rows
        100.0 * shared_blks_hit /nullif(shared_blks_hit + shared_blks_read, 0AS hit_percent 
FROM    pg_stat_statements 
ORDER   BY average DESC LIMIT 10;
通过上面的sql语句就能查看最慢的10个查询请求,通过查看查询结果,发现一个查询语句有8个join,性能非常低。
所以,问题基本定位在下面几个方面:
1. 业务需求数据量大,需要同时展现很多数据
2. 请求返回的数据量大,业务数据大也就导致返回的数据量大
3. 数据库关联查询多导致查询慢,内容分布在不同的表中。
4. 数据量大导致网络传输慢

解决问题

问题:业务需求数据量大

这是一个需求和产品设计相关的问题,是不是所有的数据都是用户需要的,是不是需要在一个页面上展现全部数据,是否可以在设计上规避这种场景,通过渐进式或者延时加载的方式来加载数据,然后呈现给用户。根据需求,可以从技术上将一个大请求拆分成很多小的请求,前端再渐进式的加载用户需要的数据,或者对一些不是当前关键路径上的数据延时加载。
方案一:拆分大请求
如果一个请求数据量过大,请求时间过长,毫无疑问,这个请求有必要进行拆分,拆分的规则,我认为需要从技术和业务两个方面来考量,因为从微服务的角度来说,每个微服务接口应该是尽量的功能单一,满足单一职责原则。但从业务角度来说,满足一个业务需求需要使用到很多的微服务,需要聚合这些微服务。因此,需要平衡技术和业务两方面,来设计合适的接口,既避免大而全的接口,导致数据量比较大,请求时间比较长,也不能使用粒度过小的接口,导致前端会频繁的进行网络请求,也会导致页面加载慢,前端逻辑复杂。
方案二:渐进式加载数据
这也是常用的提高用户体验的方法,利用前端ajax的异步请求来渐进式的加载页面,比如,在我们的系统中,我们可以先加载平面图的底图,再加载地图上的图标,再加载图标绑定的设备等渐进式的加载数据。这样给用户的感受就是一个渐进的过程,不至于页面出现空白页。
方案三:延时加载
这也是一种提升用户体验的方法,对于一些不是关键展现路径上的数据,我们可以延时加载这类请求,因为这些数据不是用户第一关心的数据或者不是用户马上就要使用的数据,但80%他们可能会随后查看,比如,在我们的系统中,某个设备的运行相关的参数,不是页面加载完后就需要立即去查看,而是等页面加载完后,鼠标移到此设备图标上才查看此类数据,而且用户对延时比较敏感,不能鼠标放在设备图标上后有任何的延时,应该上在1秒之内就需要能查看到最新的数据。因此,对于这种场景,我们可以在页面关键信息呈现完成后,后台再异步去加载这些设备参数数据,当用户鼠标停留在某个设备图标上,只需要请求设备实时值就能展现设备的各种实时运行状态值,而不需要先去从数据库中获取设备相关的运行参数,再根据参数去获取实时状态值,能减少数据库请求,改善用户体验。
方案四:懒加载
懒加载类似于延时加载,只是这类请求是用户需要时才从服务器获取,相比如延时加载,这类请求可能是对用户不太关注的数据,而且对性能要求也不是特别高,3秒内的延时都能接受,比如,在我们的系统中,设备可能有大量的参数,但是用户未必都需要关注,所以会显示前10个参数,如果想查看更多的参数,需要点击“显示更多”,这就是一个典型的懒加载的方法。

问题:请求返回数据量大的问题

通过上面的请求拆分,已经减少了请求的返回数据量,但是如果数据量仍然是很大,首先,从需求层面上分析这些数据是不是都是必须的,根据业务需求返回必要数据,设计合适的dto对象,而不是将数据库里的所有字段完全的返回给前端。另外,根据产品的特性,来设计一些合理的前端缓存技术来减少从服务器请求的数据,减少网络数据传输。
方案一:设计合理的dto对象
这些可以根据具体的业务需求来设计dto对象,确保dto里定义的每个属性是业务必须的,很多时候开发者为了减少以后代码修改,往往会将数据库里查询出来的数据都作为dto对象,然后在java里使用MapStruct,.net下使用AutoMapper来将DAO对象转为DTO对象。如有有些属性是不需要前端使用的,也可以使用@JsonIgnore注解来忽略一些属性传给前端。
方案二:前端缓存
在HTML5之前,只有cookie能够存储数据,大小只有4kb。这严重限制了应用文件的存储,导致web开发的移动应用程序需要较长的加载时间。有了本地存储,让web移动应用能够更接近原生,大大提高了用户体验。html5提供了localStorage,sessionStorage和本地数据库来满足不同场景下的本地数据存储。localStorage所存储的数据是长期有效的,而sessionStorage所存储的信息当每个会话(session)关闭时就会销毁(通俗的说就是页面关闭后数据自动销毁)。
由于二者的特性不同,因此应用的场景也有很大区别。通常,当我们需要存储一些用户配置项等一些需要长时间存储的数据信息时,需要使用localStorgae进行保存,利用了其时效长的特点。相应的,当我们需要实现类似购物车等基于session的功能时,就需要使用sessionStorage。但这两种存储,存储的数据都比较简单,而且存储容量也有一定的限制。本地数据库,比如indexedDB,它是一个前端的nosql数据库,这些能存储更大量的复杂数据。虽然,web端现在也提供了这些丰富的存储能力,但是在使用中还是需要注意以下问题:
 function setWithExpiry(key, value, ttl{
    const now = new Date()

    // `item` is an object which contains the original value
    // as well as the time when it's supposed to expire
    const item = {
        value: value,
        expiry: now.getTime() + ttl,
    }
    localStorage.setItem(key, JSON.stringify(item))
}
function getWithExpiry(key{
    const itemStr = localStorage.getItem(key)
    // if the item doesn't exist, return null
    if (!itemStr) {
        return null
    }
    const item = JSON.parse(itemStr)
    const now = new Date()
    // compare the expiry time of the item with the current time
    if (now.getTime() > item.expiry) {
        // If the item is expired, delete the item from storage
        // and return null
        localStorage.removeItem(key)
        return null
    }
    return item.value
}

问题:数据库查询慢的问题

通过pg_stat_statements工具,分析出了慢的请求,大概花了10几秒,拷贝出查询语句,使用执行计划来查看查询的性能,发现很多的NestedLoop和hash join,这两种方式的区别从网上摘抄了一份解释,如下,
NESTED LOOP
对于被连接的数据子集较小的情况,嵌套循环连接是个较好的选择。在嵌套循环中,内表被外表驱动,外表返回的每一行都要在内表中检索找到与它匹配的行,因此整个查询返回的结果集不能太大(大于1 万不适合),要把返回子集较小表的作为外表(CBO 默认外表是驱动表),而且在内表的连接字段上一定要有索引。当然也可以用ORDERED 提示来改变CBO默认的驱动表,使用USE_NL(table_name1 table_name2)可强制CBO 执行嵌套循环连接。如果外部输入很小(<10000)而内部输入很大且预先创建了索引,则Nested Loops(嵌套循环联接)尤其有效。在许多小事务中(如那些只影响较小的一组行的事务),索引嵌套循环联接远比合并联接和哈希联接优越。但在大查询中,嵌套循环联接通常不是最佳选择。Nested loop一般用在连接的表中有索引,并且索引选择性较好的时候.
HASH JOIN
散列连接是CBO 做大数据集连接时的常用方式,优化器使用两个表中较小的表(或数据源)利用连接键在内存中建立散列表,然后扫描较大的表并探测散列表,找出与散列表匹配的行。这种方式适用于较小的表完全可以放于内存中的情况,这样总成本就是访问两个表的成本之和。但是在表很大的情况下并不能完全放入内存,这时优化器会将它分割成若干不同的分区,不能放入内存的部分就把该分区写入磁盘的临时段,此时要有较大的临时段从而尽量提高I/O 的性能。如果两个表的数据量差别很大,则使用Hash Match。但需要注意的是:如果HASH表太大,无法一次构造在内存中,则分成若干个partition,写入磁盘的temporary segment,则会多一个I/O的代价,会降低效率,此时需要有较大的temporary segment从而尽量提高I/O的性能。Hash join的主要资源消耗在于CPU(在内存中创建临时的HASH表,并进行HASH计算),而Merge join的资源消耗主要在于磁盘I/O(扫描表或索引)。
方案一:拆分查询语句
因为我们的案例中,很多表都是比较小的表(数据行数在100之内),有两张比较大的表(3w行之内),所有PG大多使用了nexted loop等方式优化执行,而且我们的表全部都是基于主键join和查询,因此都有主键索引。《阿里巴巴java开发手册》里规定:
超过三张表禁止join,需要join的字段数据类型必须绝对一致;多表关联查询时,保证被关联的字段需要有索引
我们的案例中,远远超过3张表的join,但从我们的业务实现角度上来说,确实需要这种关联查询。从阿里的角度而言,由于数据规模太大,不得不考虑分库分表+中间件的模型,在分库分表场景下,能在数据库层面做join的场景自然也不多,所以大家更多的是将数据库当成一个带多行事务能力的KV系统去用,这是轻DB重应用的思路。而我们的场景中,数据量规模很小,大部分数据都是使用数据库来存储,因此多表join还是不可避免,但是我们需要控制join表的数量,在我们这个案例中,出现了8张表的关联,经过分析发现,其实是5张表,区域表 && View表 && 子系统表 && 设备表 && 点位信息表,目的是查询当前view下关联的设备和点位信息,但某个view下又有可能有sub view,同样需要查询所有sub view下的设备及点位信息,所以是一个父子关系的多表join。基于这个原因,很容易拆分查询语句,分成两级查询。每次查询4张表join,然后根据第一次查询的结果中的ID,去进行二级查询,效率就大幅度提高。
方案二:使用Hibernate缓存
当使用Hibernate持久层框架,会导致数据库访问性能降低,因此Hibernate提供了缓存机制,当数据查询时,我们先在魂村里找,如果没有,我们再去数据库查找,这样就减少了与数据库的访问,从而提高了数据库访问性能,关于Hibernate缓存如何使用可以在网上找到很多的相关文档。Hibernate缓存分为两种,一级缓存:Hibernate默认的缓存机制,它属于Session级别的缓存机制,也就是说Session关闭,缓存数据消失。二级缓存:属于SessionFactory级别的缓存,二级缓存是全局性的,应用中的所有Session都共享这个二级缓存。二级缓存默认是关闭的,一旦开启,当我们需要查询数据时,会先在一级缓存查询,没有,去二级缓存,还没有,再去数据库查找,因此缓存机制大大提高了数据库的访问性能。
方案三:使用SpringBoot缓存
SpringBoot也提供了使用非常方便的缓存机制,可以很简单的使用各种后端缓存技术,包括in-memory,redis,ehcache,Couchbase等。关于这部分的使用,我会在后面详细介绍,特别是关于ehcache,因为网上国内大部分Springboot中cache的使用文章都比较老,大部分都是ehcache2的文章,在SpringBoot2和ehcache3环境下都不适用。SpringBoot的缓存的好处就是不仅仅是对数据库的查询结果进行缓存,可以根据业务需求缓存自己的数据,也可以缓存restful请求结果的数据,也提供了更新缓存的机制,因此使用起来比较方便。

写在最后

除了上面介绍的这些常见优化方案外,代码优化肯定是首先需要考虑的,这些就太多的可能了,不能一一列举,可以安装一些插件来帮忙优化代码,帮忙发现一些code smell和有问题的代码,比如sonarqube插件,阿里巴巴代码插件等。每种优化方案都有特定的使用场景,很多时候优化不是靠一种方案就能解决,需要结合实际的业务场景来合理设计优化方案,我们的系统在这些方案的优化下,基本上可以实现web程序像本地应用程序同样的效果,大大改善了用户体验。
上一篇 下一篇

猜你喜欢

热点阅读