ElasticSearch 性能調優
- 作者: 博學谷狂野架構師
- GitHub位址:GitHub位址 (有我們精心準備的130本電子書PDF)
概述
性能優化是個涉及面非常廣的問題,不同的環境,不同的業務場景可能會存在不同的優化方案,本文隻對一些相關的知識點做簡單的總結,具體方案可以根據場景自行嘗試。
配置檔案調優
通過elasticsearch.yml配置檔案調優
記憶體鎖定
允許 JVM 鎖住記憶體,禁止作業系統交換出去
由于JVM發生swap交換會導緻極大降低ES的性能,為了防止ES發生記憶體交換,我們可以通過鎖定記憶體來實作,這将極大提高查詢性能,但同時可能造成OOM,需要對應做好資源監控,必要的時候進行幹預。
修改ES配置
修改ES的配置檔案elasticsearch.yml,設定bootstrap.memory_lock為true
COPY#叢集名稱
cluster.name: elastic
#目前該節點的名稱
node.name: node-3
#是不是有資格競選主節點
node.master: true
#是否存儲資料
node.data: true
#最大叢集節點數
node.max_local_storage_nodes: 3
#給目前節點自定義屬性(可以省略)
#node.attr.rack: r1
#資料存檔位置
path.data: /usr/share/elasticsearch/data
#日志存放位置
path.logs: /usr/share/elasticsearch/log
#是否開啟時鎖定記憶體(預設為是)
#bootstrap.memory_lock: true
#設定網關位址,我是被這個坑死了,這個位址我原先填寫了自己的實際實體IP位址,
#然後啟動一直報無效的IP位址,無法注入9300端口,這裡隻需要填寫0.0.0.0
network.host: 0.0.0.0
#設定映射端口
http.port: 9200
#内部節點之間溝通端口
transport.tcp.port: 9300
#叢集發現預設值為127.0.0.1:9300,如果要在其他主機上形成包含節點的群集,如果搭建叢集則需要填寫
#es7.x 之後新增的配置,寫入候選主節點的裝置位址,在開啟服務後可以被選為主節點,也就是說把所有的節點都寫上
discovery.seed_hosts: ["node-1","node-2","node-3"]
#當你在搭建叢集的時候,選出合格的節點叢集,有些人說的太官方了,
#其實就是,讓你選擇比較好的幾個節點,在你節點啟動時,在這些節點中選一個做上司者,
#如果你不設定呢,elasticsearch就會自己選舉,這裡我們把三個節點都寫上
cluster.initial_master_nodes: ["node-1","node-2","node-3"]
#在群集完全重新啟動後阻止初始恢複,直到啟動N個節點
#簡單點說在叢集啟動後,至少複活多少個節點以上,那麼這個服務才可以被使用,否則不可以被使用,
gateway.recover_after_nodes: 2
#删除索引是是否需要顯示其名稱,預設為顯示
#action.destructive_requires_name: true
# 允許記憶體鎖定,提高ES性能
bootstrap.memory_lock: true
複制代碼
修改JVM配置
修改jvm.options,通常設定-Xms和-Xmx的的值為“實體記憶體大小的一半和32G的較小值”
這是因為,es核心使用lucene,lucene本身是單獨占用記憶體的,并且占用的還不少,官方建議設定es記憶體,大小為實體記憶體的一半,剩下的一半留給lucene
COPY-Xms2g
-Xmx2g
複制代碼
關閉作業系統的swap
臨時關閉
COPYsudo swapoff -a
複制代碼
永久關閉
注釋掉或删除所有swap相關的内容
COPYvi /etc/fstab
複制代碼
修改檔案描述符
修改/etc/security/limits.conf,設定memlock為unlimited
COPYelk hard memlock unlimited
elk soft memlock unlimited
複制代碼
修改系統配置
設定虛拟記憶體
修改/etc/systemd/system.conf,設定vm.max_map_count為一個較大的值
COPYvm.max_map_count=10240000
複制代碼
修改檔案上限
修改/etc/systemd/system.conf,設定DefaultLimitNOFILE,DefaultLimitNPROC,DefaultLimitMEMLOCK為一個較大值,或者不限定
COPYDefaultLimitNOFILE=100000
DefaultLimitNPROC=100000
DefaultLimitMEMLOCK=infinity
複制代碼
重新開機ES
服務發現優化
Elasticsearch 預設被配置為使用單點傳播發現,以防止節點無意中加入叢集
多點傳播發現應該永遠不被使用在生産環境了,否則你得到的結果就是一個節點意外的加入到了你的生産環境,僅僅是因為他們收到了一個錯誤的多點傳播信号,ES是一個P2P類型的分布式系統,使用gossip協定,叢集的任意請求都可以發送到叢集的任一節點,然後es内部會找到需要轉發的節點,并且與之進行通信,在es1.x的版本,es預設是開啟多點傳播,啟動es之後,可以快速将區域網路内叢集名稱,預設端口的相同執行個體加入到一個大的叢集,後續再es2.x之後,都調整成了單點傳播,避免安全問題和網絡風暴;
單點傳播discovery.zen.ping.unicast.hosts,建議寫入叢集内所有的節點及端口,如果新執行個體加入叢集,新執行個體隻需要寫入目前叢集的執行個體,即可自動加入到目前叢集,之後再處理原執行個體的配置即可,新執行個體加入叢集,不需要重新開機原有執行個體;
節點zen相關配置:discovery.zen.ping_timeout:判斷master選舉過程中,發現其他node存活的逾時設定,主要影響選舉的耗時,參數僅在加入或者選舉 master 主節點的時候才起作用discovery.zen.join_timeout:節點确定加入到叢集中,向主節點發送加入請求的逾時時間,預設為3sdiscovery.zen.minimum_master_nodes:參與master選舉的最小節點數,當叢集能夠被選為master的節點數量小于最小數量時,叢集将無法正常選舉。
故障檢測( fault detection )
故障檢測情況
以下兩種情況下回進行故障檢測
COPY* 第一種是由master向叢集的所有其他節點發起ping,驗證節點是否處于活動狀态
* 第二種是:叢集每個節點向master發起ping,判斷master是否存活,是否需要發起選舉
複制代碼
配置方式
故障檢測需要配置以下設定使用
- discovery.zen.fd.ping_interval :節點被ping的頻率,預設為1s。
- discovery.zen.fd.ping_timeout 等待ping響應的時間,預設為 30s,運作的叢集中,master 檢測所有節點,以及節點檢測 master 是否正常。
- discovery.zen.fd.ping_retries ping失敗/逾時多少導緻節點被視為失敗,預設為3。
隊列數量優化
不建議盲目加大es的隊列數量,要根據實際情況來進行調整
如果是偶發的因為資料突增,導緻隊列阻塞,加大隊列size可以使用記憶體來緩存資料,如果是持續性的資料阻塞在隊列,加大隊列size除了加大記憶體占用,并不能有效提高資料寫入速率,反而可能加大es當機時候,在記憶體中可能丢失的上資料量。
檢視線程池情況
通過以下可以檢視線程池的情況,哪些情況下,加大隊列size呢?
COPYGET /_cat/thread_pool
複制代碼
觀察api中傳回的queue和rejected,如果确實存在隊列拒絕或者是持續的queue,可以酌情調整隊列size。
記憶體使用
配置熔斷限額
設定indices的記憶體熔斷相關參數,根據實際情況進行調整,防止寫入或查詢壓力過高導緻OOM
- indices.breaker.total.limit: 50%,叢集級别的斷路器,預設為jvm堆的70%
- indices.breaker.request.limit: 10%,單個request的斷路器限制,預設為jvm堆的60%
- indices.breaker.fielddata.limit: 10%,fielddata breaker限制,預設為jvm堆的60%。
配置緩存
根據實際情況調整查詢占用cache,避免查詢cache占用過多的jvm記憶體,參數為靜态的,需要在每個資料節點配置
- indices.queries.cache.size: 5%,控制過濾器緩存的記憶體大小,預設為10%,接受百分比值,5%或者精确值,例如512mb。
建立分片優化
如果叢集規模較大,可以阻止建立shard時掃描叢集内全部shard的中繼資料,提升shard配置設定速度
- cluster.routing.allocation.disk.include_relocations: false,預設為true
系統層面調優
jdk版本
選用目前版本ES推薦使用的ES,或者使用ES自帶的JDK
jdk記憶體配置
首先,-Xms和-Xmx設定為相同的值,避免在運作過程中再進行記憶體配置設定,同時,如果系統記憶體小于64G,建議設定略小于機器記憶體的一半,剩餘留給系統使用,同時,jvm heap建議不要超過32G(不同jdk版本具體的值會略有不同),否則jvm會因為記憶體指針壓縮導緻記憶體浪費
關閉交換分區
關閉交換分區,防止記憶體發生交換導緻性能下降(部分情況下,甯死勿慢) swapoff -a
檔案句柄
Lucene 使用了 大量的 檔案,同時,Elasticsearch 在節點和 HTTP 用戶端之間進行通信也使用了大量的套接字,所有這一切都需要足夠的檔案描述符,預設情況下,linux預設運作單個程序打開1024個檔案句柄,這顯然是不夠的,故需要加大檔案句柄數 ulimit -n 65536
mmap
Elasticsearch 對各種檔案混合使用了 NioFs( 注:非阻塞檔案系統)和 MMapFs ( 注:記憶體映射檔案系統)。
請確定你配置的最大映射數量,以便有足夠的虛拟記憶體可用于 mmapped 檔案。這可以暫時設定:sysctl -w vm.max_map_count=262144 或者你可以在 /etc/sysctl.conf 通過修改 vm.max_map_count 永久設定它。
磁盤
如果你正在使用 SSDs,確定你的系統 I/O 排程程式是配置正确的
當你向硬碟寫資料,I/O 排程程式決定何時把資料實際發送到硬碟,大多數預設linux 發行版下的排程程式都叫做 cfq(完全公平隊列),但它是為旋轉媒體優化的:機械硬碟的固有特性意味着它寫入資料到基于實體布局的硬碟會更高效。
這對 SSD 來說是低效的,盡管這裡沒有涉及到機械硬碟,但是,deadline 或者 noop 應該被使用,deadline 排程程式基于寫入等待時間進行優化, noop 隻是一個簡單的 FIFO 隊列。
COPYecho noop > /sys/block/sd/queue/scheduler
複制代碼
磁盤挂載
COPYmount -o noatime,data=writeback,barrier=0,nobh /dev/sd* /esdata*
複制代碼
其中,noatime,禁止記錄通路時間戳;data=writeback,不記錄journal;barrier=0,因為關閉了journal,是以同步關閉barrier;nobh,關閉buffer_head,防止核心影響資料IO
磁盤其他注意事項
使用 RAID 0,條帶化 RAID 會提高磁盤I/O,代價顯然就是當一塊硬碟故障時整個就故障了,不要使用鏡像或者奇偶校驗 RAID 因為副本已經提供了這個功能。
另外,使用多塊硬碟,并允許 Elasticsearch 通過多個 path.data 目錄配置把資料條帶化配置設定到它們上面,不要使用遠端挂載的存儲,比如 NFS 或者 SMB/CIFS。這個引入的延遲對性能來說完全是背道而馳的。
使用方式調優
當elasticsearch本身的配置沒有明顯的問題之後,發現es使用還是非常慢,這個時候,就需要我們去定位es本身的問題了,首先祭出定位問題的第一個指令:
Index(寫)調優
副本數置0
如果是叢集首次灌入資料,可以将副本數設定為0,寫入完畢再調整回去,這樣副本分片隻需要拷貝,節省了索引過程
COPYPUT /my_temp_index/_settings
{
"number_of_replicas": 0
}
複制代碼
自動生成doc ID
通過Elasticsearch寫入流程可以看出,如果寫入doc時如果外部指定了id,則Elasticsearch會先嘗試讀取原來doc的版本号,以判斷是否需要更新,這會涉及一次讀取磁盤的操作,通過自動生成doc ID可以避免這個環節
合理設定mappings
将不需要建立索引的字段index屬性設定為not_analyzed或no。
- 對字段不分詞,或者不索引,可以減少很多運算操作,降低CPU占用,尤其是binary類型,預設情況下占用CPU非常高,而這種類型進行分詞通常沒有什麼意義。
- 減少字段内容長度,如果原始資料的大段内容無須全部建立 索引,則可以盡量減少不必要的内容。
- 使用不同的分析器(analyzer),不同的分析器在索引過程中 運算複雜度也有較大的差異。
調整_source字段
_source` 字段用于存儲 doc 原始資料,對于部分不需要存儲的字段,可以通過 includes excludes過濾,或者将`_source`禁用,一般用于索引和資料分離,這樣可以降低 I/O 的壓力,不過實際場景中大多不會禁用`_source
複制代碼
對analyzed的字段禁用norms
Norms用于在搜尋時計算doc的評分,如果不需要評分,則可以将其禁用
COPYtitle": {
"type": "string",
"norms": {
"enabled": false
}
複制代碼
調整索引的重新整理間隔
該參數預設是1s,強制ES每秒建立一個新segment,進而保證新寫入的資料近實時的可見、可被搜尋到,比如該參數被調整為30s,降低了重新整理的次數,把重新整理操作消耗的系統資源釋放出來給index操作使用
COPYPUT /my_index/_settings
{
"index" : {
"refresh_interval": "30s"
}
}
複制代碼
這種方案以犧牲可見性的方式,提高了index操作的性能。
批處理
批處理把多個index操作請求合并到一個batch中去處理,和mysql的jdbc的bacth有類似之處
比如每批1000個documents是一個性能比較好的size,每批中多少document條數合适,受很多因素影響而不同,如單個document的大小等,ES官網建議通過在單個node、單個shard做性能基準測試來确定這個參數的最優值
Document的路由處理
當對一批中的documents進行index操作時,該批index操作所需的線程的個數由要寫入的目的shard的個數決定
有2批documents寫入ES, 每批都需要寫入4個shard,是以總共需要8個線程,如果能減少shard的個數,那麼耗費的線程個數也會減少,例如下圖,兩批中每批的shard個數都隻有2個,總共線程消耗個數4個,減少一半。
預設的routing就是id,也可以在發送請求的時候,手動指定一個routing value,比如說put/index/doc/id?routing=user_id
值得注意的是線程數雖然降低了,但是單批的處理耗時可能增加了。和提高重新整理間隔方法類似,這有可能會延長資料不見的時間
Search(讀)調優
在存儲的Document條數超過10億條後,我們如何進行搜尋調優
資料分組
很多人拿ES用來存儲日志,日志的索引管理方式一般基于日期的,基于天、周、月、年建索引,如下圖,基于天建索引
當搜尋單天的資料,隻需要查詢一個索引的shards就可以,當需要查詢多天的資料時,需要查詢多個索引的shards,這種方案其實和資料庫的分表、分庫、分區查詢方案相比,思路類似,小資料範圍查詢而不是大海撈針。
開始的方案是建一個index,當資料量增大的時候,就擴容增加index的shard的個數,當shards增大時,要搜尋的shards個數也随之顯著上升,基于資料分組的思路,可以基于client進行資料分組,每一個client隻需依賴自己的index的資料shards進行搜尋,而不是所有的資料shards,大大提高了搜尋的性能,如下圖:
使用Filter替代Query
在搜尋時候使用Query,需要為Document的相關度打分,使用Filter,沒有打分環節處理,做的事情更少,而且filter理論上更快一些。
如果搜尋不需要打分,可以直接使用filter查詢,如果部分搜尋需要打分,建議使用’bool’查詢,這種方式可以把打分的查詢和不打分的查詢組合在一起使用,如
COPYGET /_search
{
"query": {
"bool": {
"must": {
"term": {
"user": "kimchy"
}
},
"filter": {
"term": {
"tag": "tech"
}
}
}
}
}
複制代碼
ID字段定義為keyword
一般情況,如果ID字段不會被用作Range 類型搜尋字段,都可以定義成keyword類型,這是因為keyword會被優化,以便進行terms查詢,Integers等數字類的mapping類型,會被優化來進行range類型搜尋,将integers改成keyword類型之後,搜尋性能大約能提升30%
hot_threads
可以使用以下指令,抓取30s區間内的節點上占用資源的熱線程,并通過排查占用資源最多的TOP線程來判斷對應的資源消耗是否正常
COPYGET /_nodes/hot_threads&interval=30s
複制代碼
一般情況下,bulk,search類的線程占用資源都可能是業務造成的,但是如果是merge線程占用了大量的資源,就應該考慮是不是建立index或者刷磁盤間隔太小,批量寫入size太小造成的。
pending_tasks
有一些任務隻能由主節點去處理,比如建立一個新的索引或者在叢集中移動分片,由于一個叢集中隻能有一個主節點,是以隻有這一master節點可以處理叢集級别的中繼資料變動
在99.9999%的時間裡,這不會有什麼問題,中繼資料變動的隊列基本上保持為零,在一些罕見的叢集裡,中繼資料變動的次數比主節點能處理的還快,這會導緻等待中的操作會累積成隊列,這個時候可以通過pending_tasks api分析目前什麼操作阻塞了es的隊列,比如,叢集異常時,會有大量的shard在recovery,如果叢集在大量建立新字段,會出現大量的put_mappings的操作,是以正常情況下,需要禁用動态mapping。
COPYGET /_cluster/pending_tasks
複制代碼
字段存儲
目前es主要有doc_values,fielddata,storefield三種類型,大部分情況下,并不需要三種類型都存儲,可根據實際場景進行調整:
目前用得最多的就是doc_values,列存儲,對于不需要進行分詞的字段,都可以開啟doc_values來進行存儲(且隻保留keyword字段),節約記憶體,當然,開啟doc_values會對查詢性能有一定的影響,但是,這個性能損耗是比較小的,而且是值得的;
fielddata建構和管理 100% 在記憶體中,常駐于 JVM 記憶體堆,是以可用于快速查詢,但是這也意味着它本質上是不可擴充的,有很多邊緣情況下要提防,如果對于字段沒有分析需求,可以關閉fielddata;
storefield主要用于_source字段,預設情況下,資料在寫入es的時候,es會将doc資料存儲為_source字段,查詢時可以通過_source字段快速擷取doc的原始結構,如果沒有update,reindex等需求,可以将_source字段disable;
_all,ES在6.x以前的版本,預設将寫入的字段拼接成一個大的字元串,并對該字段進行分詞,用于支援整個doc的全文檢索,在知道doc字段名稱的情況下,建議關閉掉該字段,節約存儲空間,也避免不帶字段key的全文檢索;
norms:搜尋時進行評分,日志場景一般不需要評分,建議關閉;
事務日志
Elasticsearch 2.0之後為了保證不丢資料,每次 index、bulk、delete、update 完成的時候,一定會觸發同步重新整理 translog 到磁盤上,才給請求傳回 200 OK
異步重新整理
采用異步重新整理,這個改變在提高資料安全性的同時當然也降低了一點性能,如果你不在意這點可能性,還是希望性能優先,可以在 index template 裡設定如下參數
COPY{
"index.translog.durability": "async"
}
複制代碼
其他參數
index.translog.sync_interval
對于一些大容量的偶爾丢失幾秒資料問題也并不嚴重的叢集,使用異步的 fsync 還是比較有益的,比如,寫入的資料被緩存到記憶體中,再每5秒執行一次 fsync ,預設為5s,小于的值100ms是不允許的。
index.translog.flush_threshold_size
translog存儲尚未安全儲存在Lucene中的所有操作,雖然這些操作可用于讀取,但如果要關閉并且必須恢複,則需要重新編制索引,此設定控制這些操作的最大總大小,以防止恢複時間過長,達到設定的最大size後,将發生重新整理,生成新的Lucene送出點,預設為512mb。
refresh_interval
執行重新整理操作的頻率,這會使索引的最近更改對搜尋可見,預設為1s,可以設定-1為禁用重新整理,對于寫入速率要求較高的場景,可以适當的加大對應的時長,減小磁盤io和segment的生成;
禁止動态mapping
動态mapping的缺點
- 造成叢集中繼資料一直變更,導緻 不穩定;
- 可能造成資料類型與實際類型不一緻;
- 對于一些異常字段或者是掃描類的字段,也會頻繁的修改mapping,導緻業務不可控。
映射配置
動态mapping配置的可選值及含義如下
- true:支援動态擴充,新增資料有新的字段屬性時,自動添加對于的mapping,資料寫入成功
- false:不支援動态擴充,新增資料有新的字段屬性時,直接忽略,資料寫入成功
- strict:不支援動态擴充,新增資料有新的字段時,報錯,資料寫入失敗
批量寫入
批量請求顯然會大大提升寫入速率,且這個速率是可以量化的,官方建議每次批量的資料實體位元組數5-15MB是一個比較不錯的起點,注意這裡說的是實體位元組數大小。
文檔計數對批量大小來說不是一個好名額,比如說,如果你每次批量索引 1000 個文檔,記住下面的事實:1000 個 1 KB 大小的文檔加起來是 1 MB 大,1000 個 100 KB 大小的文檔加起來是 100 MB 大。
這可是完完全全不一樣的批量大小了,批量請求需要在協調節點上加載進記憶體,是以批量請求的實體大小比文檔計數重要得多,從 5–15 MB 開始測試批量請求大小,緩慢增加這個數字,直到你看不到性能提升為止。
然後開始增加你的批量寫入的并發度(多線程等等辦法),用iostat 、 top 和 ps 等工具監控你的節點,觀察資源什麼時候達到瓶頸。如果你開始收到 EsRejectedExecutionException ,你的叢集沒辦法再繼續了:至少有一種資源到瓶頸了,或者減少并發數,或者提供更多的受限資源(比如從機械磁盤換成 SSD),或者添加更多節點。
索引和shard
es的索引,shard都會有對應的中繼資料,
因為es的中繼資料都是儲存在master節點,且中繼資料的更新是要hold住叢集向所有節點同步的,當es的建立字段或者建立索引的時候,都會要擷取叢集中繼資料,并對中繼資料進行變更及同步,此時會影響叢集的響應,是以需要關注叢集的index和shard數量,
使用建議
建議如下
- 使用shrink和rollover api,相對生成合适的資料shard數;
- 根據資料量級及對應的性能需求,選擇建立index的名稱,形如:按月生成索引:test-YYYYMM,按天生成索引:test-YYYYMMDD;
- 控制單個shard的size,正常情況下,日志場景,建議單個shard不大于50GB,線上業務場景,建議單個shard不超過20GB;
段合并
段合并的計算量龐大, 而且還要吃掉大量磁盤 I/O
合并在背景定期操作,因為他們可能要很長時間才能完成,尤其是比較大的段,這個通常來說都沒問題,因為大規模段合并的機率是很小的。
如果發現merge占用了大量的資源,可以設定:index.merge.scheduler.max_thread_count: 1 特别是機械磁盤在并發 I/O 支援方面比較差,是以我們需要降低每個索引并發通路磁盤的線程數,這個設定允許 max_thread_count + 2 個線程同時進行磁盤操作,也就是設定為 1 允許三個線程,對于 SSD,你可以忽略這個設定,預設是 Math.min(3, Runtime.getRuntime().availableProcessors() / 2) ,對 SSD 來說運作的很好。
業務低峰期通過force_merge強制合并segment,降低segment的數量,減小記憶體消耗;關閉冷索引,業務需要的時候再進行開啟,如果一直不使用的索引,可以定期删除,或者備份到hadoop叢集;
自動生成_id
當寫入端使用特定的id将資料寫入es時,es會去檢查對應的index下是否存在相同的id,這個操作會随着文檔數量的增加而消耗越來越大,是以如果業務上沒有強需求,建議使用es自動生成的id,加快寫入速率。
routing
對于資料量較大的業務查詢場景,es側一般會建立多個shard,并将shard配置設定到叢集中的多個執行個體來分攤壓力,正常情況下,一個查詢會周遊查詢所有的shard,然後将查詢到的結果進行merge之後,再傳回給查詢端。
此時,寫入的時候設定routing,可以避免每次查詢都周遊全量shard,而是查詢的時候也指定對應的routingkey,這種情況下,es會隻去查詢對應的shard,可以大幅度降低合并資料和排程全量shard的開銷。
使用alias
生産提供服務的索引,切記使用别名提供服務,而不是直接暴露索引名稱,避免後續因為業務變更或者索引資料需要reindex等情況造成業務中斷。
避免寬表
在索引中定義太多字段是一種可能導緻映射爆炸的情況,這可能導緻記憶體不足錯誤和難以恢複的情況,這個問題可能比預期更常見,index.mapping.total_fields.limit ,預設值是1000
避免稀疏索引
因為索引稀疏之後,對應的相鄰文檔id的delta值會很大,lucene基于文檔id做delta編碼壓縮導緻壓縮率降低,進而導緻索引檔案增大,同時,es的keyword,數組類型采用doc_values結構,每個文檔都會占用一定的空間,即使字段是空值,是以稀疏索引會造成磁盤size增大,導緻查詢和寫入效率降低。
最後說一句(求關注,别白嫖我)
如果這篇文章對您有所幫助,或者有所啟發的話,求一鍵三連:點贊、轉發、在看。您的支援是我堅持寫作最大的動力。
作者:博學谷_狂野架構師
連結:https://juejin.cn/post/7215842530838904892