一、ES原理
1、索引結構ES是面向文件的
各種文字內容以文件的形式儲存到ES中,文件可以是一封郵件、一條日誌,或者一個網頁的內容。一般使用 JSON 作為文件的序列化格式,文件可以有很多欄位,在建立索引的時候,我們需要描述文件中每個欄位的資料型別,並且可能需要指定不同的分析器,就像在關係型資料中“CREATE TABLE”一樣。在儲存結構上,由_index、_type和_id唯一標識一個文件。
_index指向一個或多個物理分片的邏輯名稱空間。_id文件標記符由系統自動生成或使用者提供。刪除過期老化的資料時,最好以索引為單位,而不是_id。由於_type在實際應用中容易引起概念混淆,在ES 6.x版本中,一個索引只允許存在一個_type,7.x版本將完全刪除_type的概念。
2、分片(shard)
在分散式系統中,單機無法儲存規模巨大的資料,要依靠大規模叢集處理和儲存這些資料,一般通過增加機器數量來提高系統水平擴充套件能力。因此,需要將資料分成若干小塊分配到各個機器上。然後通過某種路由策略找到某個資料塊所在的位置。除了將資料分片以提高水平擴充套件能力,分散式儲存中還會把資料複製成多個副本,放置到不同的機器中,這樣一來可以增加系統可用性,同時資料副本還可以使讀操作併發執行,分擔叢集壓力。但是多資料副本也帶來了一致性的問題:部分副本寫成功,部分副本寫失敗。為了應對併發更新問題,ES將資料副本分為主從兩部分,即主分片(primary shard)和副分片(replica shard)。主資料作為權威資料,寫過程中先寫主分片,成功後再寫副分片,恢復階段以主分片為準。資料分片和資料副本的關係如下圖所示。
分片(shard)是底層的基本讀寫單元,分片的目的是分割巨大索引,讓讀寫可以並行操作,由多臺機器共同完成。讀寫請求最終落到某個分片上,分片可以獨立執行讀寫工作。ES利用分片將資料分發到叢集內各處。分片是資料的容器,文件儲存在分片內,不會跨分片儲存。分片又被分配到叢集內的各個節點裡。當叢集規模擴大或縮小時,ES 會自動在各節點中遷移分片,使資料仍然均勻分佈在叢集裡。
一個ES索引包含很多分片,一個分片是一個Lucene的索引,它本身就是一個完整的搜尋引擎,可以獨立執行建立索引和搜尋任務。Lucene索引又由很多分段組成,每個分段都是一個倒排索引。ES每次“refresh”都會生成一個新的分段,其中包含若干文件的資料。在每個分段內部,文件的不同欄位被單獨建立索引。每個欄位的值由若干詞(Term)組成,Term是原文字內容經過分詞器處理和語言處理後的最終結果(例如,去除標點符號和轉換為詞根)。
索引建立的時候就需要確定好主分片數,在較老的版本中(5.x 版本之前),主分片數量不可以修改,副分片數可以隨時修改。現在(5.x~6.x 版本之後),ES 已經支援在一定條件的限制下,對某個索引的主分片進行拆分(Split)或縮小(Shrink)。但是,我們仍然需要在一開始就儘量規劃好主分片數量:先依據硬體情況定好單個分片容量,然後依據業務場景預估資料量和增長量,再除以單個分片容量。分片數不夠時,可以考慮新建索引,搜尋1個有著50個分片的索引與搜尋50個每個都有1個分片的索引完全等價,或者使用_split API來拆分索引(6.1版本開始支援)。
在實際應用中,我們不應該向單個索引持續寫資料,直到它的分片巨大無比。巨大的索引會在資料老化後難以刪除,以_id 為單位刪除文件不會立刻釋放空間,刪除的 doc 只在 Lucene分段合併時才會真正從磁碟中刪除。即使手工觸發分段合併,仍然會引起較高的 I/O 壓力,並且可能因為分段巨大導致在合併過程中磁碟空間不足(分段大小大於磁碟可用空間的一半)。因此,我們建議週期性地建立新索引。例如,每天建立一個。假如有一個索引website,可以將它命名為website_20180319。然後建立一個名為website的索引別名來關聯這些索引。這樣,對於業務方來說,讀取時使用的名稱不變,當需要刪除資料的時候,可以直接刪除整個索引。索引別名就像一個快捷方式或軟連結,不同的是它可以指向一個或多個索引。可以用於實現索引分組,或者索引間的無縫切換。現在我們已經確定好了主分片數量,並且保證單個索引的資料量不會太大,週期性建立新索引帶來的一個新問題是叢集整體分片數量較多,叢集管理的總分片數越多壓力就越大。在每天生成一個新索引的場景中,可能某天產生的資料量很小,實際上不需要這麼多分片,甚至一個就夠。這時,可以使用_shrink API來縮減主分片數量,降低叢集負載。
3、動態更新索引
為文件建立索引,使其每個欄位都可以被搜尋,通過關鍵詞檢索文件內容,會使用倒排索引的資料結構。倒排索引一旦被寫入檔案後就具有不變性,不變性具有許多好處:對檔案的訪問不需要加鎖,讀取索引時可以被檔案系統快取等。那麼索引如何更新,讓新新增的文件可以被搜尋到?答案是使用更多的索引,新增內容並寫到一個新的倒排索引中,查詢時,每個倒排索引都被輪流查詢,查詢完再對結果進行合併。每次記憶體緩衝的資料被寫入檔案時,會產生一個新的Lucene段,每個段都是一個倒排索引。在一個記錄元資訊的檔案中描述了當前Lucene索引都含有哪些分段。由於分段的不變性,更新、刪除等操作實際上是將資料標記為刪除,記錄到單獨的位置,這種方式稱為標記刪除。因此刪除部分資料不會釋放磁碟空間。
4、 近實時搜尋
在寫操作中,一般會先在記憶體中緩衝一段資料,再將這些資料寫入硬碟,每次寫入硬碟的這批資料稱為一個分段,如同任何寫操作一樣。一般情況下(direct方式除外),通過作業系統write介面寫到磁碟的資料先到達系統快取(記憶體),write函式返回成功時,資料未必被刷到磁碟。通過手工呼叫flush,或者作業系統通過一定策略將系統快取刷到磁碟。這種策略大幅提升了寫入效率。從write函式返回成功開始,無論資料有沒有被刷到磁碟,該資料已經對讀取可見。ES正是利用這種特性實現了近實時搜尋。每秒產生一個新分段,新段先寫入檔案系統快取,但稍後再執行flush刷盤操作,寫操作很快會執行完,一旦寫成功,就可以像其他檔案一樣被開啟和讀取了。由於系統先緩衝一段資料才寫,且新段不會立即刷入磁碟,這兩個過程中如果出現某些意外情況(如主機斷電),則會存在丟失資料的風險。通用的做法是記錄事務日誌,每次對ES進行操作時均記錄事務日誌,當ES啟動的時候,重放translog中所有在最後一次提交後發生的變更操作。比如HBase等都有自己的事務日誌。
5、段合併
在ES中,每秒清空一次寫緩衝,將這些資料寫入檔案,這個過程稱為refresh,每次refresh會建立一個新的Lucene 段。但是分段數量太多會帶來較大的麻煩,每個段都會消耗檔案控制程式碼、記憶體。每個搜尋請求都需要輪流檢查每個段,查詢完再對結果進行合併;所以段越多,搜尋也就越慢。因此需要通過一定的策略將這些較小的段合併為大的段,常用的方案是選擇大小相似的分段進行合併。在合併過程中,標記為刪除的資料不會寫入新分段,當合並過程結束,舊的分段資料被刪除,標記刪除的資料才從磁碟刪除。HBase、Cassandra等系統都有類似的分段機制,寫過程中先在記憶體緩衝一批資料,不時地將這些資料寫入檔案作為一個分段,分段具有不變性,再通過一些策略合併分段。分段合併過程中,新段的產生需要一定的磁碟空間,我們要保證系統有足夠的剩餘可用空間。Cassandra系統在段合併過程中的一個問題就是,當持續地向一個表中寫入資料,如果段檔案大小沒有上限,當巨大的段達到磁碟空間的一半時,剩餘空間不足以進行新的段合併過程。如果段檔案設定一定上限不再合併,則對錶中部分資料無法實現真正的物理刪除。ES存在同樣的問題。
6、節點
在Elasticsearch中,每個節點可以有多個角色,節點既可以是候選主節點,也可以是資料節點。其中,資料節點負責資料的儲存相關的操作,如對資料進行增、刪、改、查和聚合等。正因為如此,資料節點往往對伺服器的配置要求比較高,特別是對CPU、記憶體和I/O的需求很大。此外,資料節點梳理通常隨著叢集的擴大而彈性增加,以便保持Elasticsearch服務的高效能和高可用。候選主節點是被選舉為主節點的節點,在叢集中,只有候選主節點才有選舉權和被選舉權,其他節點不參與選舉工作。一旦候選主節點被選舉為主節點,則主節點就要負責建立索引、刪除索引、追蹤叢集中節點的狀態,以及跟蹤哪些節點是群集的一部分,並決定將哪些分片分配給相關的節點等。
二、叢集
2.4)叢集(cluster)
(1)叢集由一個或多個節點組成,對外提供服務,對外提供索引和搜尋功能。在所有節點,一個叢集有一個唯一的名稱預設為“Elasticsearch”。此名稱是很重要的,因為每個節點只能是叢集的一部分,當該節點被設定為相同的叢集名稱時,就會自動加入叢集(開啟廣播模式)。當需要有多個叢集的時候,要確保每個叢集的名稱不能重複,否則,節點可能會加入錯誤的叢集。
注意:
- 一個節點只能加入一個叢集。此外,還可以擁有多個獨立的叢集,每個叢集都有其不同的叢集名稱。例如,在開發過程中,可以建立開發叢集庫和測試叢集庫。
- 當擴容叢集、新增節點時,分片會均衡地分配到叢集的各個節點,從而對索引和搜尋過程進行負載均衡,這些都是系統自動完成的。
- 分散式系統中難免出現故障,當節點異常時,ES會自動處理節點異常。當主節點異常時,叢集會重新選舉主節點。當某個主分片異常時,會將副分片提升為主分片。
eg: 擁有三個節點的叢集——為了分散負載而對分片進行重新分配
Node 1
和 Node 2
上各有一個分片被遷移到了新的 Node 3
節點,現在每個節點上都擁有2個分片,而不是之前的3個。 這表示每個節點的硬體資源(CPU, RAM, I/O)將被更少的分片所共享,每個分片的效能將會得到提升。
分片是一個功能完整的搜尋引擎,它擁有使用一個節點上的所有資源的能力。 以上這個擁有6個分片(3個主分片和3個副本分片)的索引可以最大擴容到6個節點,每個節點上存在一個分片,並且每個分片擁有所在節點的全部資源。
(2)分片策略
分片分配過程是分片到節點的一個處理過程,它可能發生在初始恢復過程中,副本分配中,再平衡過程中,或當節點被新增或刪除時。
(2.1)分片分配設定
下面的動態設定可以用來控制分片的分配和回收。
□cluster.routing.allocation.enable:禁用或啟用哪種型別的分片,可選的引數有:
·all——允許所有的分片被重新分配。
·primaries——只允許主結點分片被重新分配。
·new_primaries——只允許新的主結點索引的分片被重新分配。
·none——不對任何分片進行重新分配。
□cluster.routing.allocation.node_concurrent_recoveries:允許在一個節點上同時併發多少個分片分配,預設為2。
□cluster.routing.allocation.node_initial_primaries_recoveries:當副本分片加入叢集的時候,在一個節點上並行發生分片分配的數量,預設是4個。 □cluster.routing.allocation.same_shard.host:在一個主機上的當有多個相同的叢集名稱的分片分配時,是否進行檢查,檢查主機名和主機ip地址。預設為false,此設定僅適用於在同一臺機器上啟動多個節點時配置。
- indices.recovery.concurrent_streams:從一個節點恢復的時候,同時開啟的網路流量的數量,預設為3。
- indices.recovery.concurrent_small_file_streams:從同伴的分片恢復時開啟每個節點的小檔案(小於5M)流的數目,預設為2。
(2.2)分片平衡設定
下面的動態設定可以用來控制整個叢集的碎片再平衡,配置有:
□cluster.routing.rebalance.enable:啟用或禁用特定種類的分片重新平衡,可選的引數有:
·all——允許所有的分片進行分片平衡,預設配置。
·primaries——只允許主分片進行平衡。
·replicas——只允許從分片進行平衡。
·none——不允許任何分片進行平衡。
□cluster.routing.allocation.allow_rebalance:當分片再平衡時允許的操作,可選的引數有:
·always——總是允許再平衡。
·indices_primaries_active——只有主節點索引允許再平衡。
·indices_all_active——所有的分片允許再平衡,預設引數。
·cluster.routing.allocation.cluster_concurrent_rebalance:重新平衡時允許多少個併發的分片同時操作,預設為2。
(2.3)啟發式分片平衡
以下設定用於確定在何處放置每個碎片的資料:
□cluster.routing.allocation.balance.shard:在節點上分配每個分片的權重,預設是0.45。
□cluster.routing.allocation.balance.index:在特定節點上,每個索引分配的分片的數量,預設0.55。
□cluster.routing.allocation.balance.threshold:操作的最小最優化的值。預設為1。
2.5).節點(node)
(1)一個節點是一個邏輯上獨立的服務,它是叢集的一部分,可以儲存資料,並參與叢集的索引和搜尋功能。節點也有唯一的名字,在啟動的時候分配。該名稱是在啟動時分配給節點的隨機通用唯一識別符號(UUID),支援自定義名稱。這個名字在管理中很重要,在網路中Elasticsearch叢集通過節點名稱進行管理和通訊。一個節點可以被配置加入一個特定的叢集。預設情況下,每個節點會加入名為Elasticsearch的叢集中,這意味著如果你在網路上啟動多個節點,如果網路暢通,他們能彼此發現並自動加入一個名為Elasticsearch的叢集中。當網路沒有叢集執行的時候,只要啟動任何一個節點,這個節點會預設生成一個新的叢集,這個叢集會有一個節點。
(2)節點型別
主(master)節點:在一個節點上當node.master設定為true(預設)的時候,它有資格被選作為主節點,控制整個叢集。它將負責管理:叢集範圍內的所有變更,例如增加、刪除索引,或者增加、刪除節點等。
資料(data)節點:在一個節點上node.data設定為true(預設)的時候。該節點儲存資料和執行資料相關的操作,如增刪改查、搜尋和聚合。預設情況下,節點同時是主節點和資料節點,這是非常方便的小叢集,但隨著叢集的發展,分離主節點和資料節點將變得非常重要。
客戶端節點:當一個節點的node.master和node.data都設定為false的時候,它既不能保持資料也不能成為主節點,該節點可以作為客戶端節點,可以響應使用者的請求,並把相關操作傳送到其他節點。
部落節點:當一個節點配置tribe.*的時候,它是一個特殊的客戶端,它可以連線多個叢集,在所有連線的叢集上執行搜尋和其他操作。
客戶端節點在搜尋請求或批量增加索引請求等可能涉及在不同資料節點上的操作。這些請求會分成兩個階段,一是接收客戶端的請求,二是協調節點執行相關操作。當資料分散在不同的節點上時,協調節點將請求轉發到資料節點,每個資料節點在本地執行請求並把結果傳輸給協調節點,然後協調節點收集各個資料節點的結果轉換成單個請求結果返回。所以需要客戶端有足夠的記憶體和CPU來處理各個節點的返回結果。
2.6)路由(routing)
當儲存一個文件的時候,它會儲存在唯一的主分片中,具體哪個分片是通過雜湊值進行選擇。預設情況下,這個值是由文件的ID生成。如果文件有一個指定的父文件,則從父文件ID中生成,該值可以在儲存文件的時候進行修改。
2.7)分片(shard)
分片是單個Lucene例項,這是Elasticsearch管理的比較底層的功能。索引是指向主分片和副本分片的邏輯空間。對於使用,只需要指定分片的數量,其他不需要做過多的事情。在開發使用的過程中,我們對應的物件都是索引,Elasticsearch會自動管理叢集中所有的分片,當發生故障的時候,Elasticsearch會把分片移動到不同的節點或者新增新的節點。一個索引可以儲存很大的資料,這些空間可以超過一個節點的物理儲存的限制。例如,十億個文件佔用磁碟空間為1TB。僅從單個節點搜尋可能會很慢,還有一臺物理機器也不一定能儲存這麼多的資料。為了解決這一問題,Elasticsearch將索引分解成多個分片。當你建立一個索引,你可以簡單地定義你想要的分片數量。每個分片本身是一個全功能的、獨立的單元,可以託管在叢集中的任何節點。
2.8)主分片(primary shard)
每個文件都儲存在一個分片中,當你儲存一個文件的時候,系統會首先儲存在主分片中,然後會複製到不同的副本中。預設情況下,一個索引有5個主分片。你可以事先制定分片的數量,當分片一旦建立,則分片的數量不能修改。
2.9)副本分片(replica shard)
每一個分片有零個或多個副本。副本主要是主分片的複製,其中有兩個目的:
增加高可用性:當主分片失敗的時候,可以從副本分片中選擇一個作為主分片。
提高效能:當查詢的時候可以到主分片或者副本分片中進行查詢。
預設情況下,一個主分片配有一個副本,但副本的數量可以在後面動態地配置增加。副本分片必須部署在不同的節點上,不能部署在和主分片相同的節點上。分片主要有兩個很重要的原因是:
允許水平分割擴充套件資料。
允許分配和並行操作(可能在多個節點上)從而提高效能和吞吐量。
這些很強大的功能對使用者來說是透明的,你不需要做什麼操作,系統會自動處理。
2.10)複製(replica)
複製是一個非常有用的功能,不然會有單點問題。當網路中的某個節點出現問題的時候,複製可以對故障進行轉移,保證系統的高可用。因此,Elasticsearch允許你建立一個或多個拷貝,你的索引分片就形成了所謂的副本或副本分片。複製是重要的,主要的原因有:□它提供了高可用性,當節點失敗的時候不受影響。需要注意的是,一個複製的分片不會儲存在同一個節點中。□它允許你擴充套件搜尋量,提高併發量,因為搜尋可以在所有副本上並行執行。每個索引可以拆分成多個分片。索引可以複製零個或者多個分片。一旦複製,每個索引就有了主分片和副本分片。分片的數量和副本的數量可以在建立索引時定義。當建立索引後,你可以隨時改變副本的數量,但你不能改變分片的數量。預設情況下,每個索引分配5個分片和一個副本,這意味著你的叢集節點至少要有兩個節點,你將擁有5個主要的分片和5個副本分片共計10個分片。[插圖]注意 每個Elasticsearch分片是一個Lucene的索引。有文件儲存數量限制,你可以在一個單一的Lucene索引中儲存的最大值為lucene-5843,極限是2147483519(=integer.max_value-128)個文件。你可以使用_cat/shards API監控分片的大小。
三、es結構
1、es模組結構圖
Gateway: 代表ES的持久化儲存方式,包含索引資訊,ClusterState(叢集資訊),mapping,索引碎片資訊,以及transaction log等;
- 對於分散式叢集來說,當一個或多個節點down掉了,能夠保證我們的資料不能丟,最通用的解決方案就是對失敗節點的資料進行復制,通過控制複製的份數可以保證叢集有很高的可用性,複製這個方案的精髓主要是保證操作的時候沒有單點,對一個節點的操作會同步到其他的複製節點上去。
- ES一個索引會拆分成多個碎片,每個碎片可以擁有一個或多個副本(建立索引的時候可以配置),如下:每個索引分成3個碎片,每個碎片有2個副本
$ curl -XPUT http://localhost:9200/twitter/ -d ' index : number_of_shards : 3 number_of_replicas : 2
-
每個操作會自動路由主碎片所在的節點,在上面執行操作,並且同步到其他複製節點,通過使用“non blocking IO”模式所有複製的操作都是並行執行的,也就是說如果你的節點的副本越多,你網路上的流量消耗也會越大。複製節點同樣接受來自外面的讀操作,意義就是你的複製節點越多,你的索引的可用性就越強,對搜尋的可伸縮行就更好,能夠承載更多的操作。
- 第一次啟動的時候,它會去持久化裝置讀取叢集的狀態資訊(建立的索引,配置等)然後執行應用它們(建立索引,建立mapping對映等),每一次shard節點第一次例項化加入複製組,它都會從長持久化儲存裡面恢復它的狀態資訊。
Discovery
Discovery模組負責發現叢集中的節點,以及選擇主節點。ES支援多種不同Discovery型別選擇,內建的實現稱為Zen Discovery,其他的包括公有云平臺亞馬遜的EC2、谷歌的GCE等。
-
節點啟動後先ping(這裡的ping是 Elasticsearch 的一個RPC命令。如果 discovery.zen.ping.unicast.hosts 有設定,則ping設定中的host,否則嘗試ping localhost 的幾個埠, Elasticsearch 支援同一個主機啟動多個節點);
-
Ping的response會包含該節點的基本資訊以及該節點認為的master節點;
-
選舉開始,先從各節點認為的master中選,規則很簡單,按照id的字典序排序,取第一個;
-
如果各節點都沒有認為的master,則從所有節點中選擇,規則同上。這裡有個限制條件就是 discovery.zen.minimum_master_nodes,如果節點數達不到最小值的限制,則迴圈上述過程,直到節點數足夠可以開始選舉;
-
最後選舉結果是肯定能選舉出一個master,如果只有一個local節點那就選出的是自己;
-
如果當前節點是master,則開始等待節點數達到 minimum_master_nodes,然後提供服務, 如果當前節點不是master,則嘗試加入master;
-
ES支援任意數目的叢集(1-N),通過一個規則,只要所有的節點都遵循同樣的規則,得到的資訊都是對等的,選出來的主節點肯定是一致的。但分散式系統的問題就出在資訊不對等的情況,這時候很容易出現腦裂(Split-Brain)的問題,大多數解決方案就是設定一個quorum值,要求可用節點必須大於quorum(一般是超過半數節點,(master_eligible_nodes / 2) + 1,例如,如果有3個具備Master資格的節點,則這個值至少應該設定為(3/2)+ 1 = 2),才能對外提供服務。而 Elasticsearch 中,這個quorum的配置就是 discovery.zen.minimum_master_nodes ;
memcached
-
通過memecached協議來訪問ES的介面,支援二進位制和文字兩種協議.通過一個名為transport-memcached外掛提供
-
Memcached命令會被對映到REST介面,並且會被同樣的REST層處理,memcached命令列表包括:get/set/delete/quit
2、es儲存結構
四、es優化方式
1、寫入速度優化
在 ES 的預設設定下,是綜合考慮資料可靠性、搜尋實時性、寫入速度等因素的。有時候,業務上對資料可靠性和搜尋實時性要求並不高,反而對寫入速度要求很高,此時可以調整一些策略,可以犧牲可靠性和搜尋實時性為代價,最大化寫入速度。
從以下幾方面入手:
- 加大translog flush間隔,目的是降低iops、writeblock。
- 加大index refresh間隔,除了降低I/O,更重要的是降低了segment merge頻率。
- 調整bulk請求。
- 優化磁碟間的任務均勻情況,將shard儘量均勻分佈到物理主機的各個磁碟。
- 優化節點間的任務分佈,將任務儘量均勻地發到各節點。
- 優化Lucene層建立索引的過程,目的是降低CPU佔用率及I/O,例如,禁用_all欄位。
2、搜尋速度的優化
1)有些欄位的內容是數值,但並不意味著其總是應該被對映為數值型別,例如,一些識別符號,將它們對映為keyword可能會比integer或long更好。
2)預熱檔案系統cache
如果ES主機重啟,則檔案系統快取將為空,此時搜尋會比較慢。可以使用index.store.preload設定,通過指定副檔名,顯式地告訴作業系統應該將哪些檔案載入到記憶體中,在索引建立時設定:
PUT /my_index{"settings": {"index.store.preload": ["nvd", "dvd"]}}
注意:如果檔案系統快取不夠大,則無法儲存所有資料,那麼為太多檔案預載入資料到檔案系統快取中會使搜尋速度變慢,應謹慎使用。
3)一個搜尋請求涉及的分片數量越多,協調節點的CPU和記憶體壓力就越大。預設情況下,ES會拒絕超過1000個分片的搜尋請求。因此應該更好地組織資料,讓搜尋請求的分片數更少。如果想調節這個值,則可以通過action.search.shard_count配置項進行修改。雖然限制搜尋的分片數並不能直接提升單個搜尋請求的速度,但協調節點的壓力會間接影響搜尋速度,例如,佔用更多記憶體會產生更多的GC壓力,可能導致更多的stop-the-world時間等,因此間接影響了協調節點的效能。
官放文件地址:https://www.elastic.co/guide/en/elasticsearch/reference/7.4/elasticsearch-intro.html
感謝閱讀,借鑑了不少大佬資料,整合了一個相對系統、簡潔、針對專案研發較為實用的版本,如需轉載,請註明出處,謝謝!https://www.cnblogs.com/huyangshu-fs/p/12181057.html
-i