帶你了解高并發(fā)大對(duì)象處理
本文轉(zhuǎn)載自微信公眾號(hào)「小姐姐味道」,作者姐養(yǎng)狗2號(hào)。轉(zhuǎn)載本文請(qǐng)聯(lián)系小姐姐味道公眾號(hào)。
常年浸潤(rùn)在互聯(lián)網(wǎng)高并發(fā)中的同學(xué),在寫(xiě)代碼時(shí)會(huì)有一些約定俗成的規(guī)則:寧可將請(qǐng)求拆分成10個(gè)1秒的,也不去做一個(gè)耗時(shí)5秒的請(qǐng)求;寧可將對(duì)象拆成1000個(gè)10KB的,也盡量避免生成一個(gè)1MB的對(duì)象。
為什么?這是對(duì)于“大”的恐懼。
“大對(duì)象”,是一個(gè)泛化的概念,它可能存放在JVM中,也可能正在網(wǎng)絡(luò)上傳輸,也可能存在于數(shù)據(jù)庫(kù)中。
為什么大對(duì)象會(huì)影響我們的應(yīng)用性能呢?有三點(diǎn)原因。
大對(duì)象占用的資源多,垃圾回收器要花一部分精力去對(duì)它進(jìn)行回收;
大對(duì)象在不同的設(shè)備之間交換,會(huì)耗費(fèi)網(wǎng)絡(luò)流量,以及昂貴的I/O;
對(duì)大對(duì)象的解析和處理操作是耗時(shí)的,對(duì)象職責(zé)不聚焦,就會(huì)承擔(dān)額外的性能開(kāi)銷(xiāo)。
接下來(lái),xjjdog將從數(shù)據(jù)的結(jié)構(gòu)緯度和時(shí)間維度,來(lái)逐步看一下一些把對(duì)象變小,把操作聚焦的策略。
1. String的substring方法
我們都知道,String在Java中是不可變的,如果你改動(dòng)了其中的內(nèi)容,它就會(huì)生成一個(gè)新的字符串。
如果我們想要用到字符串中的一部分?jǐn)?shù)據(jù),就可以使用substring方法。
如圖所示,當(dāng)我們需要一個(gè)子字符串的時(shí)候。substring生成了一個(gè)新的字符串,這個(gè)字符串通過(guò)構(gòu)造函數(shù)的Arrays.copyOfRange函數(shù)進(jìn)行構(gòu)造。
這個(gè)函數(shù)在JDK7之后是沒(méi)有問(wèn)題的,但在JDK6中,卻有著內(nèi)存泄漏的風(fēng)險(xiǎn)。我們可以學(xué)習(xí)一下這個(gè)案例,來(lái)看一下大對(duì)象復(fù)用可能會(huì)產(chǎn)生的問(wèn)題。
這是我從JDK官方的一張截圖??梢钥吹剑趧?chuàng)建子字符串的時(shí)候,并不只拷貝所需要的對(duì)象,而是把整個(gè)value引用了起來(lái)。如果原字符串比較大,即使不再使用,內(nèi)存也不會(huì)釋放。
比如,一篇文章內(nèi)容可能有幾MB,我們僅僅需要其中的摘要信息,也不得維持著整個(gè)的大對(duì)象。
- String content = dao.getArticle(id);
- String summary=content.substring(0,100);
- articles.put(id,summary);
這對(duì)我們的借鑒意義是。如果你創(chuàng)建了比較大的對(duì)象,并基于這個(gè)對(duì)象生成了一些其他的信息。這個(gè)時(shí)候,一定要記得去掉和這個(gè)大對(duì)象的引用關(guān)系。
2. 集合大對(duì)象擴(kuò)容
對(duì)象擴(kuò)容,在Java中是司空見(jiàn)慣的現(xiàn)象。比如StringBuilder、StringBuffer,HashMap,ArrayList等。概括來(lái)講,Java的集合,包括List、Set、Queue、Map等,其中的數(shù)據(jù)都不可控。在容量不足的時(shí)候,都會(huì)有擴(kuò)容操作。
我們先來(lái)看下StringBuilder的擴(kuò)容代碼。
- 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);
- }
容量不夠的時(shí)候,會(huì)將內(nèi)存翻倍,并使用Arrays.copyOf復(fù)制源數(shù)據(jù)。
下面是HashMap的擴(kuò)容代碼,擴(kuò)容后大小也是翻倍。它的擴(kuò)容動(dòng)作就復(fù)雜的多,除了有負(fù)載因子的影響,它還需要把原來(lái)的數(shù)據(jù)重新進(jìn)行散列。由于無(wú)法使用native的Arrays.copy方法,速度就會(huì)很慢。
- 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的代碼大家可自行查看,也是阻塞性的,擴(kuò)容策略是原長(zhǎng)度的1.5倍。
由于集合在代碼中使用的頻率非常高,如果你知道具體的數(shù)據(jù)項(xiàng)上限,那么不妨設(shè)置一個(gè)合理的初始化大小。比如,HashMap需要1024個(gè)元素,需要7次擴(kuò)容,會(huì)影響應(yīng)用的性能。
但是要注意,像HashMap這種有負(fù)載因子的集合(0.75),初始化大小=需要的個(gè)數(shù)/負(fù)載因子+1。如果你不是很清楚底層的結(jié)構(gòu),那就不妨保持默認(rèn)。
3. 保持合適的對(duì)象粒度
曾經(jīng)碰到一個(gè)并發(fā)量非常高的業(yè)務(wù)系統(tǒng),需要頻繁使用到用戶(hù)的基本數(shù)據(jù)。由于用戶(hù)的基本信息,都是存放在另外一個(gè)服務(wù)中,所以每次用到用戶(hù)的基本信息,都需要有一次網(wǎng)絡(luò)交互。更加讓人無(wú)法接受的是,即使是只需要用戶(hù)的性別屬性,也需要把所有的用戶(hù)信息查詢(xún),拉取一遍。
為了加快數(shù)據(jù)的查詢(xún)速度,對(duì)數(shù)據(jù)進(jìn)行了初步的緩存,放入到了redis中。查詢(xún)性能有了大的改善,但每次還是要查詢(xún)很多冗余數(shù)據(jù)。
原始的redis key是這樣設(shè)計(jì)的。
- type: string
- key: user_${userid}
- value: json
這樣的設(shè)計(jì)有兩個(gè)問(wèn)題:(1)查詢(xún)其中某個(gè)字段的值,需要把所有json數(shù)據(jù)查詢(xún)出來(lái),并自行解析。(2)更新其中某個(gè)字段的值,需要更新整個(gè)json串,代價(jià)較高。
針對(duì)這種大粒度json信息,就可以采用打散的方式進(jìn)行優(yōu)化,使得每次更新和查詢(xún),都有聚焦的目標(biāo)。
接下來(lái)對(duì)redis中的數(shù)據(jù)進(jìn)行了以下設(shè)計(jì),采用hash結(jié)構(gòu)而不是json結(jié)構(gòu):
- type: hash
- key: user_${userid}
- value: {sex:f, id:1223, age:23}
這樣,我們使用hget命令,或者h(yuǎn)mget命令,就可以獲取到想要的數(shù)據(jù),加快信息流轉(zhuǎn)的速度。
4. Bitmap把對(duì)象變小
還能再進(jìn)一步優(yōu)化么?比如,我們系統(tǒng)中就頻繁用到了用戶(hù)的性別數(shù)據(jù),用來(lái)發(fā)放一些禮品,推薦一些異性的好友,定時(shí)循環(huán)用戶(hù)做一些清理動(dòng)作等。或者,存放一些用戶(hù)的狀態(tài)信息,比如是否在線(xiàn),是否簽到,最近是否發(fā)送信息等,統(tǒng)計(jì)一下活躍用戶(hù)等。
對(duì)是、否這兩個(gè)值的操作,就可以使用Bitmap這個(gè)結(jié)構(gòu)進(jìn)行壓縮。
如代碼所示,通過(guò)判斷int中的每一位,它可以保存32個(gè)boolean值!
- int a= 0b0001_0001_1111_1101_1001_0001_1111_1101;
Bitmap就是使用Bit進(jìn)行記錄的數(shù)據(jù)結(jié)構(gòu),里面存放的數(shù)據(jù)不是0就是1。Java中的相關(guān)結(jié)構(gòu)類(lèi),就是java.util.BitSet。BitSet底層是使用long數(shù)組實(shí)現(xiàn)的,所以它的最小容量是64。
10億的boolean值,只需要128MB的內(nèi)存。下面既是一個(gè)占用了256MB的用戶(hù)性別的判斷邏輯,可以涵蓋長(zhǎng)度為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";
- }
這些數(shù)據(jù),放在堆內(nèi)內(nèi)存中,還是過(guò)大了。幸運(yùn)的是,Redis也支持Bitmap結(jié)構(gòu),如果內(nèi)存有壓力,我們可以把這個(gè)結(jié)構(gòu)放到redis中,判斷邏輯也是類(lèi)似的。
這樣的問(wèn)題還有很多:給出一個(gè)1GB內(nèi)存的機(jī)器,提供60億int數(shù)據(jù),如何快速判斷有哪些數(shù)據(jù)是重復(fù)的?大家可以類(lèi)比思考一下。
Bitmap是一個(gè)比較底層的結(jié)構(gòu),在它之上還有一個(gè)叫做布隆過(guò)濾器的結(jié)構(gòu)(Bloom Filter)。布隆過(guò)濾器可以判斷一個(gè)值不存在,或者可能存在。
相比較Bitmap,它多了一層hash算法。既然是hash算法,就會(huì)有沖突,所以有可能有多個(gè)值落在同一個(gè)bit上。
Guava中有一個(gè)BloomFilter的類(lèi),可以方便的實(shí)現(xiàn)相關(guān)功能。
5. 數(shù)據(jù)的冷熱分離
上面這種優(yōu)化方式,本質(zhì)上也是把大對(duì)象變成小對(duì)象的方式,在軟件設(shè)計(jì)中有很多類(lèi)似的思路。像一篇新發(fā)布的文章,頻繁用到的是摘要數(shù)據(jù),就不需要把整個(gè)文章內(nèi)容都查詢(xún)出來(lái);用戶(hù)的feed信息,也只需要保證可見(jiàn)信息的速度,而把完整信息存放在速度較慢的大型存儲(chǔ)里。
數(shù)據(jù)除了橫向的結(jié)構(gòu)緯度,還有一個(gè)縱向的時(shí)間維度。對(duì)時(shí)間維度的優(yōu)化,最有效的方式就是冷熱分離。
所謂熱數(shù)據(jù),就是靠近用戶(hù)的,被頻繁使用的數(shù)據(jù),而冷數(shù)據(jù)是那些訪(fǎng)問(wèn)頻率非常低,年代非常久遠(yuǎn)的數(shù)據(jù)。同一句復(fù)雜的SQL,運(yùn)行在幾千萬(wàn)的數(shù)據(jù)表上,和運(yùn)行在幾百萬(wàn)的數(shù)據(jù)表上,前者的效果肯定是很差的。所以,雖然你的系統(tǒng)剛開(kāi)始上線(xiàn)時(shí)速度很快,但隨著時(shí)間的推移,數(shù)據(jù)量的增加,就會(huì)漸漸變得很慢。
冷熱分離是把數(shù)據(jù)分成兩份。如圖,一般都會(huì)保持一份全量數(shù)據(jù),用來(lái)做一些耗時(shí)的統(tǒng)計(jì)操作。
下面簡(jiǎn)單介紹一下冷熱分離的三種方案。
(1)數(shù)據(jù)雙寫(xiě)。把對(duì)冷熱庫(kù)的插入、更新、刪除操作,全部放在一個(gè)統(tǒng)一的事務(wù)里面。由于熱庫(kù)(比如MySQL)和冷庫(kù)(比如Hbase)的類(lèi)型不同,這個(gè)事務(wù)大概率會(huì)是分布式事務(wù)。在項(xiàng)目初期,這種方式是可行的,但如果是改造一些遺留系統(tǒng),分布式事務(wù)基本上是改不動(dòng)的。我通常會(huì)把這種方案直接廢棄掉。
(2)寫(xiě)入MQ分發(fā)。通過(guò)MQ的發(fā)布訂閱功能,在進(jìn)行數(shù)據(jù)操作的時(shí)候,先不落庫(kù),而是發(fā)送到MQ中。單獨(dú)啟動(dòng)消費(fèi)進(jìn)程,將MQ中的數(shù)據(jù)分別落到冷庫(kù)、熱庫(kù)中。使用這種方式改造的業(yè)務(wù),邏輯非常清晰,結(jié)構(gòu)也比較優(yōu)雅。像訂單這種結(jié)構(gòu)比較清晰、對(duì)順序性要求較低的系統(tǒng),就可以采用MQ分發(fā)的方式。但如果你的數(shù)據(jù)庫(kù)實(shí)體量非常的大,用這種方式就要考慮程序的復(fù)雜性了。
(3)使用binlog同步 針對(duì)于MySQL,就可以采用Binlog的方式進(jìn)行同步。使用Canal組件,可持續(xù)獲取最新的Binlog數(shù)據(jù),結(jié)合MQ,可以將數(shù)據(jù)同步到其他的數(shù)據(jù)源中。
End
關(guān)于大對(duì)象,我們可以再舉兩個(gè)例子。
像我們常用的數(shù)據(jù)庫(kù)索引,也是一種對(duì)數(shù)據(jù)的重新組織、加速。B+ tree可以有效的減少數(shù)據(jù)庫(kù)與磁盤(pán)交互的次數(shù),它通過(guò)類(lèi)似B+ tree的數(shù)據(jù)結(jié)構(gòu),將最常用的數(shù)據(jù)進(jìn)行索引,存儲(chǔ)在有限的存儲(chǔ)空間中。
還有在RPC中常用的序列化。有的服務(wù)是采用的SOAP協(xié)議的WebService,它是基于XML的一種協(xié)議,內(nèi)容大傳輸慢,效率低下?,F(xiàn)在的Web服務(wù)中,大多數(shù)是使用json數(shù)據(jù)進(jìn)行交互的,json的效率相比SOAP就更高一些。另外,大家應(yīng)該都聽(tīng)過(guò)google的protobuf,由于它是二進(jìn)制協(xié)議,而且對(duì)數(shù)據(jù)進(jìn)行了壓縮,性能是非常優(yōu)越的。protobuf對(duì)數(shù)據(jù)壓縮后,大小只有json的1/10,xml的1/20,但是性能卻提高了5-100倍。protobuf的設(shè)計(jì)是值得借鑒的,它通過(guò)tag|leng|value三段對(duì)數(shù)據(jù)進(jìn)行了非常緊湊的處理,解析和傳輸速度都特別快。
針對(duì)于大對(duì)象,我們有結(jié)構(gòu)緯度的優(yōu)化和時(shí)間維度的優(yōu)化兩種方法。從結(jié)構(gòu)緯度來(lái)說(shuō),通過(guò)把對(duì)象切分成合適的粒度,可以把操作集中在小數(shù)據(jù)結(jié)構(gòu)上,減少時(shí)間處理成本;通過(guò)把對(duì)象進(jìn)行壓縮、轉(zhuǎn)換,或者提取熱點(diǎn)數(shù)據(jù),就可以避免大對(duì)象的存儲(chǔ)和傳輸成本。從時(shí)間緯度來(lái)說(shuō),就可以通過(guò)冷熱分離的手段,將常用的數(shù)據(jù)存放在高速設(shè)備中,減少數(shù)據(jù)處理的集合,加快處理速度。
作者簡(jiǎn)介:小姐姐味道 (xjjdog),一個(gè)不允許程序員走彎路的公眾號(hào)。聚焦基礎(chǔ)架構(gòu)和Linux。十年架構(gòu),日百億流量,與你探討高并發(fā)世界,給你不一樣的味道。我的個(gè)人微信xjjdog0,歡迎添加好友,進(jìn)一步交流。