一不小心节约了 591 台机器!
前段時間,我在 B 站上看到一個技術(shù)視頻,題目叫做《機(jī)票報價高并發(fā)場景下的一些解決方案》。
up 主是 Qunar技術(shù)大本營,也就是我們耳熟能詳?shù)摹叭ツ膬骸薄?br />
視頻鏈接在這里:
https://www.bilibili.com/video/BV1eX4y1F7zJ?p=2
當(dāng)時其實我是被他的這個圖片給吸引到了(里面的 12 qps 應(yīng)該是 12k qps):
他介紹了兩個核心系統(tǒng)在經(jīng)過一個“數(shù)據(jù)壓縮”的操作之后,分別節(jié)約了 204C 和 2160C 的服務(wù)器資源。
共計就是 2364C 的服務(wù)器資源。
如果按照一般標(biāo)配的 4C8G 服務(wù)器,好家伙,這就是節(jié)約了 591 臺機(jī)器啊,你想想一年就節(jié)約了多大一筆開銷。
視頻中介紹了幾種數(shù)據(jù)壓縮的方案,其中方案之一就是用了高性能集合:
因為他們的系統(tǒng)設(shè)計中大量用到“本地緩存”,而本地緩存大多就是使用 HashMap 來幫忙。
所以他們把 HashMap 換成了性能更好的 IntObjectHashMap,這個類出自 Netty。
為什么換了一個類之后,就節(jié)約了這么多的資源呢?
換言之,IntObjectHashMap 性能更好的原因是什么呢?
我也不知道,所以我去研究了一下。
拉源碼
研究的第一步肯定是要找到對應(yīng)的源碼。
你可以去找個 Netty 依賴,然后找到里面的 IntObjectHashMap。
我這邊本地剛好有我之前拉下來的 Netty 源碼,只需要同步一下最新的代碼就行了。
但是我在 4.1 分支里面找這個類的時候并沒有找到,只看到了一個相關(guān)的 Benchmark 類:
點進(jìn)去一看,確實沒有 IntObjectHashMap 這個類:
很納悶啊,我反正也沒搞懂為啥,但是我直接就是一個不糾結(jié)了,反正我現(xiàn)在只是想找到一個 IntObjectHashMap 類而已。
4.1 分支如果沒有的話,那么就 4.0 上看看唄:
于是我切到了 4.0 分支里面去找了一下,很順利就找到了對應(yīng)的類和測試類:
能看到測試類,其實也是我喜歡把項目源碼拉下來的原因。如果你是通過引入 Netty 的 Maven 依賴的方式找到對應(yīng)類的,就看不到測試類了。
有時候配合著測試類看源碼,事半功倍,一個看源碼的小技巧,送給你。
而我要拉源碼的最重要的一個目的其實是這個:
可以看到這個類的提交記錄,觀察到這個類的演變過程,這個是很重要的。
因為一次提交絕大部分情況下對應(yīng)著一次 bug 修改或者性能優(yōu)化,都是我們應(yīng)該關(guān)注的地方。
比如,我們可以看到這個小哥針對 hashIndex 方法提交了三次:
在正式研究 IntObjectHashMap 源碼之前,我們先看看只關(guān)注 hashIndex 這個局部的方法。
首先,這個地方現(xiàn)在的代碼是這樣的:
我知道這個方法是獲取 int 類型的 key 在 keys 這個數(shù)組中的下標(biāo),支持 key 是負(fù)數(shù)的情況。
那么為啥這一行代碼就提交了三次呢?
我們先看第一次提交:
非常清晰,左邊是最原始的代碼,如果 key 是負(fù)數(shù)的話,那么返回的 index 就是負(fù)數(shù),很明顯不符合邏輯。
所以有人提交了右邊的代碼,在算出 hash 值為負(fù)數(shù)的時候,加上數(shù)組的長度,最終得到一個正數(shù)。
很快,提交代碼的哥們,發(fā)現(xiàn)了一個更好的寫法,進(jìn)行了一次優(yōu)化提交:
拿掉了小于零的判斷。不管 key%length 算出的值是正還是負(fù),都將結(jié)果加上一個數(shù)組的長度后再次對數(shù)組的長度進(jìn)行 % 運(yùn)行。
這樣保證算出來的 index 一定是一個正數(shù)。
第三次提交的代碼就很好理解了,代入變量:
所以,最終的代碼就是這樣的:
return (key % keys.length + keys.length) % keys.length;
這樣的寫法,不比判斷小于零優(yōu)雅的多且性能也好一點嗎?而且這也是一個常規(guī)的優(yōu)化方案。
如果你看不到代碼提交記錄,你就看不到這個方法的演變過程。我想表達(dá)的是:在代碼提交記錄中能挖掘到非常多比源碼更有價值的信息。
又是一個小技巧,送給你。
IntObjectHashMap
接下來我們一起探索一下 IntObjectHashMap 的奧秘。
關(guān)于這個 Map,其實有兩個相關(guān)的類:
其中 IntObjectMap 是個接口。
它們不依賴除了 JDK 之外的任何東西,所以你搞懂原理之后,如果發(fā)現(xiàn)自己的業(yè)務(wù)場景下有合適的場景,完全可以把這兩個類粘貼到自己的項目中去,一行代碼都不用改,拿來就用。
在研究了官方的測試用例和代碼提交記錄之后,我選擇先把這兩個類粘出來,自己寫個代碼調(diào)試一下,這樣的好處就是可以隨時修改其中的源碼,以便我們進(jìn)行研究。
在安排 IntObjectHashMap 源碼之前,我們先關(guān)注一下它 javadoc 里面的這幾句話:
第一句話就非常的關(guān)鍵,這里解釋了 IntObjectHashMap 針對 key 沖突時的解決方案:
它對于 key 使用的是 open addressing 策略,也就是開放尋址策略。
為什么使用開放尋址呢,而不是采用和 HashMap 一樣掛個鏈表呢?
這里也回答了這個問題:To minimize the memory footprint,也就是為了最小化內(nèi)存占用。
怎么就減少了內(nèi)存的占用呢?
這個問題下面看源碼的時候會說,但是這里提一句:你就想想如果用鏈表,是不是至少得有一個 next 指針,維護(hù)這個東西是不是又得占用空間?
不多說了,說回開放尋址。
開放尋址是一種策略,該策略也分為很多種實現(xiàn)方案,比如:
線性探測方法(Linear Probing)
二次探測(Quadratic probing)
雙重散列(Double hashing)
從上面劃線部分的最后一句話就可以知道,IntObjectHashMap 使用的就是 linear probing,即線性探測。
現(xiàn)在我們基本了解到 IntObjectHashMap 這個 map 針對 hash 沖突時使用的解決方案了。
接下來,我們搞個測試用例實操一把。代碼很簡單,就一個初始化,一個 put 方法:
就這么幾行代碼,一眼望去和 HashMap 好像沒啥區(qū)別。但是仔細(xì)一想,還是發(fā)現(xiàn)了一點端倪。
如果我們用 HashMap 的話,初始化應(yīng)該是這樣的:
HashMap<Integer,Object> hashMap = new HashMap<>(10);
你再看看 IntObjectHashMap 這個類定義是怎么樣的?
只有一個 Object:
這個 Object 代表的是 map 里面裝的 value。
那么 key 是什么,去哪兒了呢?是不是第一個疑問就產(chǎn)生了呢?
查看 put 方法之后,我發(fā)現(xiàn) key 竟然就是 int 類型的值:
也就是這個類已經(jīng)限制住了 key 就是 int 類型的值,所以不能在初始化的時候指定 key 的泛型了。
這個類從命名上也已經(jīng)明確說明這一點了:我是 IntObjectHashMap,key 是 int,value 是 Object 的 HashMap。
那么我為什么用了個“竟然”呢?
因為你看看 HashMap 的 key 是個啥玩意:
是個 Object 類型。
也就是說,如果我們想這樣初始化 HashMap 是不可以的:
ide 都會提醒你:老弟,別搞事啊,你這里不能放基本類型,你得搞個包裝類型進(jìn)來。
而我們平常編碼的時候能這樣把 int 類型放進(jìn)去,是因為有“裝箱”的操作被隱藏起來了:
所以才會有一道上古時期的八股文問題:HashMap 的 key 可以用基本類型嗎?
想也不用想,不可以!
key,從包裝類型變成了基本類型,這就是一個性能優(yōu)化的點。因為眾所周知,基本類型比包裝類型占用的空間更小。
接著,我們先從它的構(gòu)造方法入手,主要關(guān)注我框起來的部分:
首先進(jìn)來就是兩個 if 判斷,對參數(shù)合法性進(jìn)行了校驗。
接著看標(biāo)號為 ① 的地方,從方法名看是要做容量調(diào)整:
從代碼和方法上的注釋可以看出,這里是想把容量調(diào)整為一個奇數(shù),比如我給進(jìn)來 8 ,它會給我調(diào)整為 9:
至于容量為什么不能是偶數(shù),從注釋上給了一個解釋:
Even capacities can break probing.
意思是容量為偶數(shù)的時候會破壞 probing,即我們前面提到的線性探測。
額...
我并沒有考慮明白為什么偶數(shù)的容量會破壞線性探測,但是這不重要,先存疑,接著往下梳理主要流程。
從標(biāo)號為 ② 的地方可以看出這是在做數(shù)據(jù)初始化的操作。前面我們得到了 capacity 為 9,這里就是初始兩個數(shù)組,分別是 key[] 和 values[],且這兩個數(shù)組的容量是一樣的,都是 9:
兩個數(shù)組在構(gòu)造方法中完成初始化后,是這樣的:
構(gòu)造方法我們就主要關(guān)注容量的變化和 key[]、values[] 這兩個數(shù)組。
構(gòu)造方法給你鋪墊好了,接著我們再看 put 方法,就會比較絲滑了:
put 方法的代碼也沒幾行,分析起來非常的清晰。
首先是標(biāo)號為 ① 的地方,hashIndex 方法,就是獲取本次 put 的 key 對應(yīng)在 key[] 數(shù)組中的下標(biāo)。
這個方法文章開始的時候已經(jīng)分析過了,我們甚至知道這個方法的演變過程,不再多說。
然后就是進(jìn)入一個 for(;;) 循環(huán)。
先看標(biāo)號為 ② 的地方,你注意看,這個時候的判斷條件是?value[index] == null,是判斷算出來的 index 對應(yīng)的 value[] 數(shù)組對應(yīng)的下標(biāo)是否有值。
前面我專門強(qiáng)調(diào)了一句,還給你畫了一個圖:
key[] 和 values[] 這兩個數(shù)組的容量是一樣的。
為什么不先判斷該 index 在 key[] 中是否存在呢?
可以倒是可以,但是你想想如果 value[] 對應(yīng)下標(biāo)中的值是 null 的話,那么說明這個位置上并沒有維護(hù)過任何東西。key 和 value 的位置是一一對應(yīng)的,所以根本就不用去關(guān)心 key 是否存在。
如果?value[index] == null?為 true,那么說明這個 key 之前沒有被維護(hù)過,直接把對應(yīng)的值維護(hù)上,且 key[] 和 values[] 數(shù)組需要分別維護(hù)。
假設(shè)以我的演示代碼為例,第四次循環(huán)結(jié)束后是這樣的:
維護(hù)完成后,判斷一下當(dāng)前的容量是否需要觸發(fā)擴(kuò)容:
growSize 的代碼是這樣的:
在這個方法里面,我們可以看到 IntObjectHashMap 的擴(kuò)容機(jī)制是一次擴(kuò)大 2 倍。
額外說一句:這個地方就有點 low 了,源碼里面擴(kuò)大二倍肯定得上位運(yùn)算,用 length << 1 才對味兒嘛。
但是擴(kuò)容之前需要滿足一個條件:size > maxSize
size,我們知道是表示當(dāng)前 map 里面放了幾個 value 。
那么 maxSize 是啥玩意呢?
這個值在構(gòu)造函數(shù)里面進(jìn)行的初始化。比如在我的示例代碼中 maxSize 就等于 4:
也就是說,如果我再插入一個數(shù)據(jù),它就要擴(kuò)容了,比如我插入了第五個元素后,數(shù)組的長度就變成了 19:
前面我們討論的是?value[index] == null?為 true 的情況。那么如果是 false 呢?
就來到了標(biāo)號為 ③ 的地方。
判斷 key[] 數(shù)組 index 下標(biāo)處的值是否是當(dāng)前的這個 key。
如果是,說明要覆蓋。先把原來該位置上的值拿出來,然后直接做一個覆蓋的操作,并返回原值,這個邏輯很簡單。
但是,如果不是這個 key 呢?
說明什么情況?
是不是說明這個 key 想要放的 index 位置已經(jīng)被其他的 key 先給占領(lǐng)了?
這個情況是不是就是出現(xiàn)了 hash 沖突?
出現(xiàn)了 hash 沖突怎么辦?
那么就來到了標(biāo)號為 ③ 的地方,看這個地方的注釋:
Conflict, keep probing ...
沖突,繼續(xù)探測 ...
繼續(xù)探測就是看當(dāng)前發(fā)生沖突的 index 的下一個位置是啥。
如果讓我來寫,很簡單,下一個位置嘛,我閉著眼睛用腳都能敲出來,就是 index+1 嘛。
但是我們看看源碼是怎么寫的:
確實看到了 index+1,但是還有一個先決條件,即?index != values.length -1。
如果上述表達(dá)式成立,很簡單,采用 index+1。
如果上面的表達(dá)式不成立,說明當(dāng)前的 index 是 values[] 數(shù)組的最后一個位置,那么就返回 0,也就是返回數(shù)組的第一個下標(biāo)。
要觸發(fā)這個場景,就是要搞一個 hash 沖突的場景。我寫個代碼給你演示一下:
上面的代碼只有當(dāng)算出來的下標(biāo)為 8 的時候才會往 IntObjectHashMap 里面放東西,這樣在下標(biāo)為 8 的位置就出現(xiàn)了 hash 沖突。
比如 100 之內(nèi),下標(biāo)為 8 的數(shù)是這些:
第一次循環(huán)之后是這樣的:
而第二次循環(huán)的時候,key 是 17,它會發(fā)現(xiàn)下標(biāo)為 8 的地方已經(jīng)被占了:
所以,走到了這個判斷中:
返回 index=0,于是它落在了這個地方:
看起來就是一個環(huán),對不對?
是的,它就是一個環(huán)。
但是你再細(xì)細(xì)的看這個判斷:
每次計算完 index 后,還要判斷是否等于本次循環(huán)的 startIndex。如果相等,說明跑了一圈了,還沒找到空位子,那么就拋出 “Unable to insert” 異常。
有的朋友馬上就跳出來了:不對啊,不是會在用了一半空間以后,以 2 倍擴(kuò)容嗎?應(yīng)該早就在容量滿之前就擴(kuò)容了才對呀?
這位朋友,你很機(jī)智啊,你的疑問和我第一次看到這個地方的疑問是一樣的,我們都是心思縝密的好孩子。
但是注意看,在拋出異常的地方,源碼里面給了一個注釋:
Can only happen if the map was full at MAX_ARRAY_SIZE and couldn't grow.
這種情況只有 Map 已經(jīng)滿了,且無法繼續(xù)擴(kuò)容時才會發(fā)生。
擴(kuò)容,那肯定也是有一個上限才對,再看看擴(kuò)容的時候的源碼:
最大容量是 Integer.MAX_VALUE - 8,說明是有上限的。
但是,等等,Integer.MAX_VALUE 我懂,減 8 是什么情況?
誒,反正我是知道的,但是咱就是不說,不是本文重點。你要有興趣,自己去探索,我就給你截個圖完事:
如果我想要驗證一下 “Unable to insert” 怎么辦呢?
這還不簡單嗎?源碼都在我手上呢。
兩個方案,一個是修改 growSize() 方法的源碼,把最長的長度限制修改為指定值,比如 8。
第二個方案是直接嚴(yán)禁擴(kuò)容,把這行代碼給它注釋了:
然后把測試用例跑起來:
你會發(fā)現(xiàn)在插入第 10 個值的時候,拋出了 “Unable to insert” 異常。
第 10 個值,89,就是這樣似兒的,轉(zhuǎn)一圈,又走回了 startIndex:
滿足這個條件,所以拋出異常:
(index = probeNext(index)) == startIndex
到這里,put 方法就講完了。你也了解到了它的數(shù)據(jù)結(jié)構(gòu),也了解到了它的基本運(yùn)行原理。
那你還記得我寫這篇文章要追尋的問題是什么嗎?
IntObjectHashMap 性能更好的原因是什么呢?
前面提到了一個點是 key 可以使用原生的 int 類型而不用包裝的 Integer 類型。
現(xiàn)在我要揭示第二個點了:value 沒有一些亂七八糟的東西,value 就是一個純粹的 value。你放進(jìn)來是什么,就是什么。
你想想 HashMap 的結(jié)構(gòu),它里面有個 Node,封裝了 Hash、key、value、next 這四個屬性:
這部分東西也是 IntObjectHashMap 節(jié)約出來的,而這部分節(jié)約出來的,才是占大頭的地方。
你不要看不起這一點點內(nèi)存占用。在一個巨大的基數(shù)面前,任何一點小小的優(yōu)化,都能被放大無數(shù)倍。
不知道你還記不記得《深入理解Java虛擬機(jī)》一書里面的這個案例:
不恰當(dāng)?shù)臄?shù)據(jù)結(jié)構(gòu)導(dǎo)致內(nèi)存在占用過大。這個問題,就完全可以使用 Netty 的 LongObjectHashMap 數(shù)據(jù)結(jié)構(gòu)來解決,只需要換個類,就能節(jié)省非常多的資源。
道理,是同樣的道理。
額外一個點
最后,我再給你額外補(bǔ)充一個我看源碼時的意外收獲。
Deletions implement compaction, so cost of remove can approach O(N) for full maps, which makes a small loadFactor recommended.
刪除實現(xiàn)了 compaction,所以對于一個滿了的 map 來說,刪除的成本可能接近 O(N) ,所以我們推薦使用小一點的 loadFactor。
里面有兩個單詞,compaction 和 loadFactor。
先說 loadFactor 屬性,是在構(gòu)造方法里面初始化的:
為什么 loadFactor 必須是一個 (0,1] 之間的數(shù)呢?
首先要看一下 loadFactor 是在什么時候用的:
只會在計算 maxSize 的時候用到,是用當(dāng)前 capacity 乘以這個系數(shù)。
如果這個系數(shù)是大于 1 的,那么最終算出來的值,也就是 maxSize 會大于 capacity。
假設(shè)我們的 loadFactor 設(shè)置為 1.5,capacity 設(shè)置為 21,那么計算出來的 maxSize 就是 31,都已經(jīng)超過 capacity 了,沒啥意義。
總之:loadFactor 是用來計算 maxSize 的,而前面講了 maxSize 是用來控制擴(kuò)容條件的。也就是說 loadFactor 越小,那么 maxSize 也越小,就越容易觸發(fā)擴(kuò)容。反之,loadFactor 越大,越不容易擴(kuò)容。loadFactor 的默認(rèn)值是 0.5。
接下來我來解釋前面注釋中有個單詞 compaction,翻譯過來的話叫做這玩意:
可以理解為就是一種“壓縮”吧,但是“刪除實現(xiàn)了壓縮”這句話就很抽象。
不著急,我給你講。
我們先看看刪除方法:
刪除方法的邏輯有點復(fù)雜,如果要靠我的描述給你說清楚的話有點費(fèi)解。
所以,我決定只給你看結(jié)果,你拿著結(jié)果去反推源碼吧。
首先,前面的注釋中說了:哥們,我推薦你使用小一點的 loadFactor。
那么我就偏不聽,直接給你把 loadFactor 拉滿到 1。
也就是說當(dāng)這個 map 滿了之后,再往里面放東西才會觸發(fā)擴(kuò)容。
比如,我這樣去初始化:
new IntObjectHashMap<>(8,1);
是不是說,當(dāng)前這個 map 初始容量是可以放 9 個元素,當(dāng)你放第 10 個元素的時候才會觸發(fā)擴(kuò)容的操作。
誒,巧了,我就偏偏只想放 9 個元素,我不去觸發(fā)擴(kuò)容。且我這 9 個元素都是存在 hash 沖突的。
代碼如下:
這些 value 本來都應(yīng)該在下標(biāo)為 8 的位置放下,但是經(jīng)過線性探測之后,map 里面的數(shù)組應(yīng)該是這個情況:
此時我們移除 8 這個 key,正常來說應(yīng)該是這樣的:
但是實際上卻是這樣的:
會把前面因為 hash 沖突導(dǎo)致發(fā)生了位移的 value 全部往回移動。
這個過程,我理解就是注釋里面提到的“compaction”。
上面程序的實際輸出是這樣的:
符合我前面畫的圖片。
但是,我要說明的是,我的代碼進(jìn)行了微調(diào):
如果不做任何修改,輸出應(yīng)該是這樣的:
key=8 并不在最后一個,因為在這個過程里面涉及到 rehash 的操作,如果在解釋 “compaction” 的時候加上 reHash ,就復(fù)雜了,會影響你對于 “compaction” 的理解。
另外在 removeAt 方法的注釋里面提到了這個東西:
這個算法,其實就是我前面解釋的 “compaction”。
我全局搜索關(guān)鍵字,發(fā)現(xiàn)在 IdentityHashMap 和 ThreadLocal 里面都提到了:
但是,你注意這個但是啊。
在 ThreadLocal 里面,用的是“unlike”。
ThreadLocal 針對 hash 沖突也用的是線性探測,但是細(xì)節(jié)處還是有點不一樣。
不細(xì)說了,有興趣的同學(xué)自己去探索一下,我只是想表達(dá)這部分可以對比學(xué)習(xí)。
這一部分的標(biāo)題叫做“額外一個點”。因為我本來計劃中是沒有這部分內(nèi)容的,但是我在翻提交記錄的時候看到了這個:
https://github.com/netty/netty/issues/2659
這個 issues 里面有很多討論,基于這次討論,相當(dāng)于對 IntObjectHashMap 進(jìn)行了一次很大的改造。
比如從這次提交記錄我可以知道,在之前 IntObjectHashMap 針對 hash 沖突用的是“雙重散列(Double hashing)”策略,之后才改成線性探測的。
包括使用較小的 loadFactor 這個建議、removeAt 里面采用的算法,都是基于這次改造出來的:
引用這個 issues 里面的一個對話:
這個哥們說:I've got carried away,我對這段代碼進(jìn)行了重大改進(jìn)。
在我看來,這都不算是“重大改進(jìn)”了,這已經(jīng)算是推翻重寫了。
另外,這個“I've got carried away”啥意思?
英語教學(xué),雖遲但到:
這個短語要記住,托福口語考試的時候可能會考。
Netty 4.1
文章開始的地方,我說在 Netty 4.1 里面,我沒有找到 IntObjectHashMap 這個東西。
其實我是騙你的,我找到了,只是藏的有點深。
其實我這篇文章只寫了 int,但是其實基本類型都可以基于這個思想去改造,且它們的代碼都應(yīng)該是大同小異的。
所以在 4.1 里面用了一個騷操作,基于 groovy 封裝了一次:
要編譯這個模板之后:
才會在 target 目錄里面看到我們想找的東西:
但是,你仔細(xì)看編譯出來的 IntObjectHashMap,又會發(fā)現(xiàn)一點不一樣的地方。
比如構(gòu)造方法里面調(diào)整 capacity 的方法變成了這樣:
從方法名稱我們也知道這里是找一個當(dāng)前 value 的最近的 2 的倍數(shù)。
等等,2 的倍數(shù),不是一個偶數(shù)嗎?
在 4.0 分支的代碼里面,調(diào)整容量還非得要個奇數(shù):
還記得我前面提到的一個問題嗎:我并沒有考慮明白為什么偶數(shù)的容量會破壞線性探測?
但是從這里又可以看出其實偶數(shù)的容量也是可以的嘛。
這就把我給搞懵了。
要是在 4.0 分支的代碼中,adjustCapacity 方法上沒有這一行特意寫下的注釋:
Adjusts the given capacity value to ensure that it's odd. Even capacities can break probing.
我會毫不猶豫的覺得這個地方奇偶都可以。但是他刻意強(qiáng)調(diào)了要“奇數(shù)”,就讓我有點拿不住了。
算了,學(xué)不動了,存疑存疑!
好了,那本文的技術(shù)部分就到這里啦。
完
往期推薦
避開10個面試大坑,接offer成功率提升至99%
噢,老天爺! 屬于Java的協(xié)程終于來了!
6 個 Java 工具,輕松分析定位 JVM 問題!
有道無術(shù),術(shù)可成;有術(shù)無道,止于術(shù)
歡迎大家關(guān)注Java之道公眾號
好文章,我在看??
總結(jié)
以上是生活随笔為你收集整理的一不小心节约了 591 台机器!的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Git 各指令的本质,真是通俗易懂啊
- 下一篇: 抢了个票,还以为发现了12306的系统B