写在前面
大部分互联网产品,用户量很大,并发请求量也很大,数据也是不断变化,但每次展现给用户的数据量可能比较小,比如一条订单信息,一个产品信息等等,因此会大量使用后端缓存技术,分库分表,比如数据库缓存,Redis缓存等,精简请求数据来减少网络传输,前端懒加载用户需要的数据来改善用户体验。还有一些企业产品,展现数据量比较大,并发请求量相对于互联网产品小很多,使用的用户也比较固定,但用户对前端展现性能比较高,基本要做到零延时,比如,很多的实时控制系统基本属于这类,很多的model数据改变比较小,动态变化的是实时值,但用户需要在一个页面上监控到整个系统的运行状态,查看设备参数。这类产品之前基本都是Native程序为主,现在也都在转向网页版。因此,为了做到和Native程序一样的用户体验效果,除了后端缓存技术,也可以利用一些前端缓存技术来改善用户体验。还有一些企业产品,介于这两类产品之间,并发用户量不是很大,数据请求量也不算大,但数据也是不断变化,比如企业ERP系统。这类产品使用数据库技术基本就能满足需求,也可以结合一些Redis缓存来提供性能。发现问题
在我们的案例中,有一个页面是用来展现一个楼层的平面图上展现设备信息,设备相关参数以及设备的一些实时运行数据。这个数据量是非常大,而且是一个层级结构,在数据库中会涉及到很多张表的关联join查询。在分析系统性能过程中,发现这个页面展现比较慢,虽然前端做了很多的懒加载,但是整体给用户的体验还是比较差。分析问题
通过F12查看这个页面网络请求,最大的请求就是加载这个树状的设备层级结构图,数据量大概在3M左右,设备比较多的楼层,接近于5M的数据,仔细分析这些数据,发现基本都是必须数据,只有少数冗余数据,减少这些冗余数据,对系统性能不会有根本性的改善。请求全部花费的时间在20多秒,因此用户体验很不好。在后端,使用PostgreSQL的性能分析工具pg_stat_statements来查看慢查询。- 安装pg_stat_statements进入安装包的contrib/pg_stat_statements目录,执行编译和安装动作,详细的安装方法可以参考网上的很多文章,我的环境已经安装好了此组件,只需要配置相关参数。
- 配置参数在data/postgresql.conf中,进行配置:
//表示要在启动时导入pg_stat_statements 动态库
shared_preload_libraries = 'pg_stat_statements'
//表示监控的语句最多为1000句
pg_stat_statements.max = 1000
//表示监控所以的sql语句pg_stat_statements.track = all
- 重新启动 postgresql运行service postgresql restart命令重启postgresql
- 创建extension,并重置统计结果
create extension pg_stat_statements;
select pg_stat_statements_reset();
- 查询慢请求
SELECT query, calls, total_time, (total_time/calls) as average ,rows,通过上面的sql语句就能查看最慢的10个查询请求,通过查看查询结果,发现一个查询语句有8个join,性能非常低。
100.0 * shared_blks_hit /nullif(shared_blks_hit + shared_blks_read, 0) AS hit_percent
FROM pg_stat_statements
ORDER BY average DESC LIMIT 10;
所以,问题基本定位在下面几个方面:
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端现在也提供了这些丰富的存储能力,但是在使用中还是需要注意以下问题:
- 数据安全问题。通常可以使用XSS漏洞来获取到Cookie,然后用这个Cookie进行身份验证登录。后来为了防止通过XSS获取Cookie数据,浏览器支持了使用HTTPONLY来保护Cookie不被XSS攻击获取到。而目前localStorage存储没有对XSS攻击有任何抵御机制,一旦出现XSS漏洞,那么存储在localStorage里的数据就极易被获取到,通过遍历所有key或者通过JSON.STRINGFY方式都可以很轻易的获取到所有的localStorage里的内容。此外,localStorage里的数据大部分浏览器也是明文存储,少数浏览器使用了base64加密,但也不是安全加密算法,很容易破解。因此,不要在localStorage里存储敏感信息。
- 缓存更新问题。前端缓存里的数据都是从服务器获取到的,因此,需要设计合理的同步更新策略,来保证缓存的更新。Cookie有过期时间的设置,但是localStorage没有过期时间的设置方法,需要自己实现过期策略,比如为每个存储在localStorage里的item增加一个expiry属性,每次获取时去检测该Item是否过期。比如,下面的代码段,
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,去进行二级查询,效率就大幅度提高。
网友评论