【架构知识】长文干货!带你了解高并发大对象处理

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

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

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

开源一套以教学为目的系统,欢迎star:github.com/xjjdog/bcma…。它包含ToB复杂业务、互联网高并发业务、缓存应用;DDD、微服务指导。模型驱动、数据驱动。了解大型服务进化路线,编码技巧、学习Linux,性能调优。Docker/k8s助力、监控、日志收集、中间件学习。前端技术、后端实践等。主要技术:SpringBoot+JPA+Mybatis-plus+Antd+Vue3

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

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

  1. 大对象占用的资源多,垃圾回收器要花一部分精力去对它进行回收;
  2. 大对象在不同的设备之间交换,会耗费网络流量,以及昂贵的I/O;
  3. 对大对象的解析和处理操作是耗时的,对象职责不聚焦,就会承担额外的性能开销。

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

1. String的substring方法

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

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

【架构知识】长文干货!带你了解高并发大对象处理

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

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

【架构知识】长文干货!带你了解高并发大对象处理

这是我从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. 保持合适的对象粒度

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

【架构知识】长文干货!带你了解高并发大对象处理

为了加快数据的查询速度,对数据进行了初步的缓存,放入到了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)。布隆过滤器可以判断一个值不存在,或者可能存在。

【架构知识】长文干货!带你了解高并发大对象处理

相比较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三段对数据进行了非常紧凑的处理,解析和传输速度都特别快。

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

开源一套以教学为目的系统,欢迎star:github.com/xjjdog/bcma…。它包含ToB复杂业务、互联网高并发业务、缓存应用;DDD、微服务指导。模型驱动、数据驱动。了解大型服务进化路线,编码技巧、学习Linux,性能调优。Docker/k8s助力、监控、日志收集、中间件学习。前端技术、后端实践等。主要技术:SpringBoot+JPA+Mybatis-plus+Antd+Vue3


作者:小姐姐味道
链接:https://juejin.cn/post/6938336728683905055
来源:掘金
 

上一篇:深入理解之源码剖析Vector


下一篇:简单的ArrayList,一句话概括“初始化空数组,每次加元素右移一位”