6

长文干货!带你了解高并发大对象处理

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzA4MTc4NTUxNQ%3D%3D&%3Bmid=2650523400&%3Bidx=1&%3Bsn=f885f5be130f5b4fb894af5bdccde3d5
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

e6JBVrU.gif!mobile

原创:小姐姐味道(微信公众号ID:xjjdog),欢迎分享,转载请保留出处。任何不保留此声明的转载都是抄袭。

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

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

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

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

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

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

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

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

1. String的substring方法

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

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

QBfUBfN.png!mobile

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

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

jiIFziR.png!mobile

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

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

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

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

2. 集合大对象扩容

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

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

void expandCapacity(int minimumCapacity) {
        int newCapacity = value.length * 2 + 2;
        if (newCapacity - minimumCapacity < 0)
            newCapacity = minimumCapacity;
        if (newCapacity < 0) {
            if (minimumCapacity < 0) // overflow
                throw new OutOfMemoryError();
            newCapacity = Integer.MAX_VALUE;
        }
        value = Arrays.copyOf(value, newCapacity);
}

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

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

void addEntry(int hash, K key, V value, int bucketIndex) {
        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);
}

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        Entry[] newTable = new Entry[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. 保持合适的对象粒度

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

raaU3eE.png!mobile

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

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

type: string
key: user_${userid}
value: json

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

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

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

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

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

4. Bitmap把对象变小

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

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

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

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。

static BitSet missSet = new BitSet(010_000_000_000);
static BitSet sexSet = new BitSet(010_000_000_000);
String getSex(int userId) {
    boolean notMiss = missSet.get(userId);
    if (!notMiss) {
        //lazy fetch
        String lazySex = dao.getSex(userId);
        missSet.set(userId, true);
        sexSet.set(userId, "female".equals(lazySex));
    }
    return sexSet.get(userId) ? "female" : "male";
}

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

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

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

BfUJVzv.png!mobile

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

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

5. 数据的冷热分离

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

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

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

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

iEjeeyz.png!mobile

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

(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,欢迎添加好友,进一步交流。

推荐阅读:

一图解千愁,jvm内存从来没有这么简单过!

失联的架构师,只留下一段脚本

架构师写的BUG,非比寻常

nginx工程师,需要上承天命,下召九幽

实力解剖一枚挖矿脚本,风骚操作亮瞎双眼

又一P1故障,锅比脸圆

传统企业的人才们,先别忙着跳“互联网”!

面试官很牛,逼我尿遁

又一批长事务,P0故障谁来背锅?

一天有24个小时?别开玩笑了!

《程序人生》杀机!

可怕的“浏览器指纹”,让你在互联网上,无处可藏

2w字长文,让你瞬间拥有「调用链」开发经验

996的乐趣,你是无法想象的

作为高级Java,你应该了解的Linux知识(非广告)

必看!java后端,亮剑诛仙(最全知识点)

学完这100多技术,能当架构师么?(非广告)

Linux上,最常用的一批命令解析(10年精选)

数百篇「原创」文章,助你完成技术「体系化」

EnUneen.gif!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK