首页>>后端>>java->架构知识干货:带你了解高并发大对象处理!

架构知识干货:带你了解高并发大对象处理!

时间:2023-11-29 本站 点击:43

常年浸润在互联网高并发中的同学,在写代码时会有一些约定俗成的规则:宁可将请求拆分成10个1秒的,也不去做一个耗时5秒的请求;宁可将对象拆成1000个10KB的,也尽量避免生成一个1MB的对象。

为什么?这是对于“大”的恐惧。

“大对象”,是一个泛化的概念,它可能存放在JVM中,也可能正在网络上传输,也可能存在于数据库中。

为什么大对象会影响我们的应用性能呢?有三点原因。

大对象占用的资源多,垃圾回收器要花一部分精力去对它进行回收;

大对象在不同的设备之间交换,会耗费网络流量,以及昂贵的I/O;

对大对象的解析和处理操作是耗时的,对象职责不聚焦,就会承担额外的性能开销。

接下来,xjjdog将从数据的结构纬度和时间维度,来逐步看一下一些把对象变小,把操作聚焦的策略。

1. String的substring方法

我们都知道,String在Java中是不可变的,如果你改动了其中的内容,它就会生成一个新的字符串。

如果我们想要用到字符串中的一部分数据,就可以使用substring方法。

如图所示,当我们需要一个子字符串的时候。substring生成了一个新的字符串,这个字符串通过构造函数的Arrays.copyOfRange函数进行构造。

这个函数在JDK7之后是没有问题的,但在JDK6中,却有着内存泄漏的风险。我们可以学习一下这个案例,来看一下大对象复用可能会产生的问题。

这是我从JDK官方的一张截图。可以看到,它在创建子字符串的时候,并不只拷贝所需要的对象,而是把整个value引用了起来。如果原字符串比较大,即使不再使用,内存也不会释放。

比如,一篇文章内容可能有几MB,我们仅仅需要其中的摘要信息,也不得维持着整个的大对象。

Stringcontent=dao.getArticle(id);Stringsummary=content.substring(0,100);articles.put(id,summary);

这对我们的借鉴意义是。如果你创建了比较大的对象,并基于这个对象生成了一些其他的信息。这个时候,一定要记得去掉和这个大对象的引用关系。

2. 集合大对象扩容

对象扩容,在Java中是司空见惯的现象。比如StringBuilder、StringBuffer,HashMap,ArrayList等。概括来讲,Java的集合,包括List、Set、Queue、Map等,其中的数据都不可控。在容量不足的时候,都会有扩容操作。

我们先来看下StringBuilder的扩容代码。

voidexpandCapacity(intminimumCapacity){intnewCapacity=value.length*2+2;if(newCapacity-minimumCapacity<0)newCapacity=minimumCapacity;if(newCapacity<0){if(minimumCapacity<0)//overflowthrownewOutOfMemoryError();newCapacity=Integer.MAX_VALUE;}value=Arrays.copyOf(value,newCapacity);}

容量不够的时候,会将内存翻倍,并使用Arrays.copyOf复制源数据。

下面是HashMap的扩容代码,扩容后大小也是翻倍。它的扩容动作就复杂的多,除了有负载因子的影响,它还需要把原来的数据重新进行散列。由于无法使用nativeArrays.copy方法,速度就会很慢。

voidaddEntry(inthash,Kkey,Vvalue,intbucketIndex){if((size>=threshold)&&(null!=table[bucketIndex])){resize(2*table.length);hash=(null!=key)?hash(key):0;bucketIndex=indexFor(hash,table.length);}createEntry(hash,key,value,bucketIndex);}voidresize(intnewCapacity){Entry[]oldTable=table;intoldCapacity=oldTable.length;if(oldCapacity==MAXIMUM_CAPACITY){threshold=Integer.MAX_VALUE;return;}Entry[]newTable=newEntry[newCapacity];transfer(newTable,initHashSeedAsNeeded(newCapacity));table=newTable;threshold=(int)Math.min(newCapacity*loadFactor,MAXIMUM_CAPACITY+1);}

List的代码大家可自行查看,也是阻塞性的,扩容策略是原长度的1.5倍。

由于集合在代码中使用的频率非常高,如果你知道具体的数据项上限,那么不妨设置一个合理的初始化大小。比如,HashMap需要1024个元素,需要7次扩容,会影响应用的性能。

但是要注意,像HashMap这种有负载因子的集合(0.75),初始化大小=需要的个数/负载因子+1。如果你不是很清楚底层的结构,那就不妨保持默认。

3. 保持合适的对象粒度

曾经碰到一个并发量非常高的业务系统,需要频繁使用到用户的基本数据。由于用户的基本信息,都是存放在另外一个服务中,所以每次用到用户的基本信息,都需要有一次网络交互。更加让人无法接受的是,即使是只需要用户的性别属性,也需要把所有的用户信息查询,拉取一遍。

为了加快数据的查询速度,对数据进行了初步的缓存,放入到了redis中。查询性能有了大的改善,但每次还是要查询很多冗余数据。

原始的redis key是这样设计的。

type:stringkey:user_${userid}value:json

这样的设计有两个问题: (1)查询其中某个字段的值,需要把所有json数据查询出来,并自行解析。 (2)更新其中某个字段的值,需要更新整个json串,代价较高。

针对这种大粒度json信息,就可以采用打散的方式进行优化,使得每次更新和查询,都有聚焦的目标。

接下来对redis中的数据进行了以下设计,采用hash结构而不是json结构:

type:hashkey:user_${userid}value:{sex:f,id:1223,age:23}

这样,我们使用hget命令,或者hmget命令,就可以获取到想要的数据,加快信息流转的速度。

4. Bitmap把对象变小

还能再进一步优化么?比如,我们系统中就频繁用到了用户的性别数据,用来发放一些礼品,推荐一些异性的好友,定时循环用户做一些清理动作等。或者,存放一些用户的状态信息,比如是否在线,是否签到,最近是否发送信息等,统计一下活跃用户等。

对是、否这两个值的操作,就可以使用Bitmap这个结构进行压缩。

如代码所示,通过判断int中的每一位,它可以保存32个boolean值!

inta=0b0001_0001_1111_1101_1001_0001_1111_1101;

Bitmap就是使用Bit进行记录的数据结构,里面存放的数据不是0就是1。Java中的相关结构类,就是java.util.BitSet。BitSet底层是使用long数组实现的,所以它的最小容量是64。

10亿的boolean值,只需要128MB的内存。下面既是一个占用了256MB的用户性别的判断逻辑,可以涵盖长度为10亿的id。

staticBitSetmissSet=newBitSet(010_000_000_000);staticBitSetsexSet=newBitSet(010_000_000_000);StringgetSex(intuserId){booleannotMiss=missSet.get(userId);if(!notMiss){//lazyfetchStringlazySex=dao.getSex(userId);missSet.set(userId,true);sexSet.set(userId,"female".equals(lazySex));}returnsexSet.get(userId)?"female":"male";}

这些数据,放在堆内内存中,还是过大了。幸运的是,Redis也支持Bitmap结构,如果内存有压力,我们可以把这个结构放到redis中,判断逻辑也是类似的。

这样的问题还有很多:给出一个1GB内存的机器,提供60亿int数据,如何快速判断有哪些数据是重复的?大家可以类比思考一下。

Bitmap是一个比较底层的结构,在它之上还有一个叫做布隆过滤器的结构(Bloom Filter)。布隆过滤器可以判断一个值不存在,或者可能存在。

相比较Bitmap,它多了一层hash算法。既然是hash算法,就会有冲突,所以有可能有多个值落在同一个bit上。

Guava中有一个BloomFilter的类,可以方便的实现相关功能。

5. 数据的冷热分离

上面这种优化方式,本质上也是把大对象变成小对象的方式,在软件设计中有很多类似的思路。像一篇新发布的文章,频繁用到的是摘要数据,就不需要把整个文章内容都查询出来;用户的feed信息,也只需要保证可见信息的速度,而把完整信息存放在速度较慢的大型存储里。

数据除了横向的结构纬度,还有一个纵向的时间维度。对时间维度的优化,最有效的方式就是冷热分离。

所谓热数据,就是靠近用户的,被频繁使用的数据,而冷数据是那些访问频率非常低,年代非常久远的数据。同一句复杂的SQL,运行在几千万的数据表上,和运行在几百万的数据表上,前者的效果肯定是很差的。所以,虽然你的系统刚开始上线时速度很快,但随着时间的推移,数据量的增加,就会渐渐变得很慢。

冷热分离是把数据分成两份。如图,一般都会保持一份全量数据,用来做一些耗时的统计操作。

下面简单介绍一下冷热分离的三种方案。

(1)数据双写。 把对冷热库的插入、更新、删除操作,全部放在一个统一的事务里面。由于热库(比如MySQL)和冷库(比如Hbase)的类型不同,这个事务大概率会是分布式事务。在项目初期,这种方式是可行的,但如果是改造一些遗留系统,分布式事务基本上是改不动的。我通常会把这种方案直接废弃掉。

(2)写入MQ分发。 通过MQ的发布订阅功能,在进行数据操作的时候,先不落库,而是发送到MQ中。单独启动消费进程,将MQ中的数据分别落到冷库、热库中。使用这种方式改造的业务,逻辑非常清晰,结构也比较优雅。像订单这种结构比较清晰、对顺序性要求较低的系统,就可以采用MQ分发的方式。但如果你的数据库实体量非常的大,用这种方式就要考虑程序的复杂性了。

(3)使用binlog同步 针对于MySQL,就可以采用Binlog的方式进行同步。使用Canal组件,可持续获取最新的Binlog数据,结合MQ,可以将数据同步到其他的数据源中。

结尾

关于大对象,我们可以再举两个例子。

像我们常用的数据库索引,也是一种对数据的重新组织、加速。B+ tree可以有效的减少数据库与磁盘交互的次数,它通过类似B+ tree的数据结构,将最常用的数据进行索引,存储在有限的存储空间中。

还有在RPC中常用的序列化。有的服务是采用的SOAP协议的WebService,它是基于XML的一种协议,内容大传输慢,效率低下。现在的Web服务中,大多数是使用json数据进行交互的,json的效率相比SOAP就更高一些。另外,大家应该都听过google的protobuf,由于它是二进制协议,而且对数据进行了压缩,性能是非常优越的。protobuf对数据压缩后,大小只有json的1/10,xml的1/20,但是性能却提高了5-100倍。protobuf的设计是值得借鉴的,它通过tag|leng|value三段对数据进行了非常紧凑的处理,解析和传输速度都特别快。

针对于大对象,我们有结构纬度的优化和时间维度的优化两种方法。从结构纬度来说,通过把对象切分成合适的粒度,可以把操作集中在小数据结构上,减少时间处理成本;通过把对象进行压缩、转换,或者提取热点数据,就可以避免大对象的存储和传输成本。从时间纬度来说,就可以通过冷热分离的手段,将常用的数据存放在高速设备中,减少数据处理的集合,加快处理速度。

作者简介:小姐姐味道 (xjjdog),一个不允许程序员走弯路的公众号。聚焦基础架构和Linux。十年架构,日百亿流量,与你探讨高并发世界,给你不一样的味道。我的个人微信xjjdog0,欢迎添加好友,进一步交流。


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:/java/46.html