带你了解高并发大对象处理

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

[[381736]]

本文转载自微信公众号「小姐姐味道」,作者姐养狗2号。转载本文请联系小姐姐味道公众号。  

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

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

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

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

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

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

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

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

1. String的substring方法

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

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

 

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

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

 

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

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

  1. String content = dao.getArticle(id); 
  2. String summary=content.substring(0,100); 
  3. articles.put(id,summary); 

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

2. 集合大对象扩容

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

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

  1. void expandCapacity(int minimumCapacity) { 
  2.         int newCapacity = value.length * 2 + 2; 
  3.         if (newCapacity - minimumCapacity < 0) 
  4.             newCapacity = minimumCapacity; 
  5.         if (newCapacity < 0) { 
  6.             if (minimumCapacity < 0) // overflow 
  7.                 throw new OutOfMemoryError(); 
  8.             newCapacity = Integer.MAX_VALUE; 
  9.         } 
  10.         value = Arrays.copyOf(value, newCapacity); 

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

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

  1. void addEntry(int hash, K key, V value, int bucketIndex) { 
  2.         if ((size >= threshold) && (null != table[bucketIndex])) { 
  3.             resize(2 * table.length); 
  4.             hash = (null != key) ? hash(key) : 0; 
  5.             bucketIndex = indexFor(hash, table.length); 
  6.         } 
  7.         createEntry(hash, key, value, bucketIndex); 
  8.  
  9. void resize(int newCapacity) { 
  10.         Entry[] oldTable = table
  11.         int oldCapacity = oldTable.length; 
  12.         if (oldCapacity == MAXIMUM_CAPACITY) { 
  13.             threshold = Integer.MAX_VALUE; 
  14.             return
  15.         } 
  16.         Entry[] newTable = new Entry[newCapacity]; 
  17.         transfer(newTable, initHashSeedAsNeeded(newCapacity)); 
  18.         table = newTable; 
  19.         threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); 

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

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

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

3. 保持合适的对象粒度

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

 

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

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

  1. type: string 
  2. key: user_${userid} 
  3. value: json 

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

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

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

  1. type: hash 
  2. key: user_${userid} 
  3. value: {sex:f, id:1223, age:23} 

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

4. Bitmap把对象变小

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

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

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

  1. int a= 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。

  1. static BitSet missSet = new BitSet(010_000_000_000); 
  2. static BitSet sexSet = new BitSet(010_000_000_000); 
  3. String getSex(int userId) { 
  4.     boolean notMiss = missSet.get(userId); 
  5.     if (!notMiss) { 
  6.         //lazy fetch 
  7.         String lazySex = dao.getSex(userId); 
  8.         missSet.set(userId, true); 
  9.         sexSet.set(userId, "female".equals(lazySex)); 
  10.     } 
  11.     return sexSet.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,可以将数据同步到其他的数据源中。

End

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

像我们常用的数据库索引,也是一种对数据的重新组织、加速。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,欢迎添加好友,进一步交流。

责任编辑:武晓燕 来源: 小姐姐味道
相关推荐

2019-03-26 10:50:22

Python面向对象编程语言

2018-06-08 08:50:35

编程语言并发编程

2021-08-05 17:59:45

Vue 3.0前端代码

2014-03-27 15:34:55

Android组件Activity

2012-05-30 15:40:16

大并发并发解决方案

2023-07-25 16:06:57

JavaScript对象

2023-08-27 15:18:17

JavaScriptRegExp

2021-07-02 10:00:50

JavaScriptObject 函数

2020-10-22 09:08:34

JavaScript

2023-07-06 14:40:38

2023-05-30 15:06:21

JavaScript属性开发

2023-10-23 11:40:44

SpringBootDisruptor

2020-08-27 08:17:05

缓存高并发系统

2023-01-05 07:52:36

高可用架构消息队列

2022-09-26 11:30:40

MQTT协议客户端协议

2010-07-05 16:20:32

NetBEUI协议

2019-09-27 09:40:06

ElvishShellLinux

2020-12-27 10:15:44

Go语言channel管道

2023-11-03 08:32:53

Flask高并发

2018-05-28 14:37:05

数据库NoSQL高并发
点赞
收藏

51CTO技术栈公众号