golang 後端技術開發必備總結

風雲發表於2022-05-02

Golang

首先談談後端開發,常用的語言是Java、Golang、Python。Java語言型別的程式設計師是目前市面上最多的,也是很多公司都會選擇的。Java語言開發的整個後端專案有比較好的專案規範,適用於業務邏輯複雜的情況。本人從事的是Golang語言,Golang語言適用於開發微服務,特點是開發快、效率高等。大多數的公司(位元組主力語言 Go, B站主力Go, 騰訊偏向Go 等等),都開始選擇用Golang開發,因為這門語言相比於Java、Python最大的一個特點就是節省記憶體,支援高併發,簡潔高效,容易上手學習。

Golang語言的後端框架有很多,像 Gin(推薦使用)、Beego、Iris等,關係型資料庫的操作有gorm。這些是Golang後端開發掌握的基礎能力。
微服務框架有: go-zero kratos

Golang語言有一些特有的特性,比如協程goroutine,它比執行緒更加輕量級、高效。比如通道channel,是一種通過共享記憶體支援協程之間的通訊方式。在多個goroutine消費channel中的資料時,channel內部支援鎖機制,每一條訊息最終只會分配給一個goroutine消費。在Golang內部,有一整套goroutine排程機制GMP,其中G指的是Goroutine,M指的是Machine,P指的是Process。GMP的原理大致就是通過全域性Cache和各個執行緒Cache的方式儲存需要執行的Goroutine,通過Process的協調,將Goroutine分配在有限的Machine上執行。

Golang是支援GC的語言,內部使用三色標記垃圾回收演算法,原理大概的說就是通過可達性演算法,標記出那些被引用的物件,將剩下來沒有被標記也就是需要被釋放的物件進行記憶體回收。在之前比較老的版本,STW的影響比較大,所謂的STW就是在GC的時候,因為多執行緒訪問記憶體會出現不安全的問題,為了保證記憶體GC的準確性,在標記物件的時候,會通過屏障停止程式程式碼繼續執行下去,直到所有物件都被處理過後,再繼續執行程式,這個短暫的時間,就被成為STW(Stop The World)。由於STW,會導致在程式無法提供服務的問題。在Java中,也存在這種現象。但是目前隨著Golang版本的不斷更新,GC演算法也在不斷優化,STW的時間也慢慢越來越短。

大家需要注意的一點,在定義map的時候,儘量不要在value中存放指標,因為這樣會導致GC的時間過長。

另外一個知識點,就是Golang的map是一個無序map,如果需要從map中遍歷資料,需要用slice進行儲存,按照一定的順序進行排序,這樣才能保證每次查詢出來的資料順序一致。並且map是無鎖併發不安全的。在使用map進行記憶體快取的時候,需要考慮到多執行緒訪問快取帶來的安全問題。常見的兩種辦法,一種是加讀寫鎖RWLock,另一種是使用sync.Map。在寫多讀少的場景,推薦使用RWLock,因為sync.Map內部使用空間換時間的方法,內部有兩個map,一個支援讀操作一個支援寫操作,當寫操作過於頻繁,會導致map不斷更新,帶來的是頻繁GC操作,會帶來比較大的效能開銷。

Golang裡面開出來的Goroutine是無狀態的,如果需要主函式等待Goroutine執行完成或者終止Goroutine執行,通常有三種方法。第一種是使用sync中的waiteGroup,包含Add、Done、Wait方法。可以類比於Java中的CountDownLatch。第二種是使用Context包中Done方法,將主函式的context帶入到Goroutine中,同時在主函式中使用select監聽Goroutine接收的context發出的Done訊號。第三種是自定義一個channel,傳入Goroutine中,主函式等待讀取Goroutine中執行完成向channel傳送的終止資訊。

Golang沒有繼承的概念,只有組合的概念,每一個struct的定義,可以當做一個類,struct與struct之間可以組合巢狀。在軟體設計原則中,類的組合比類的繼承更能達到解耦的效果。Golang沒有明顯的介面實現邏輯,當一個struct實現了一個interface宣告的所有方法,這個struct就預設實現了這個interface。在函式呼叫的入參中,我們通常在呼叫方傳入具體實現了這個interface的struct,而在函式體的接收引數定義這個interface來接收,以此達到被呼叫函式的複用效果。這也是物件導向特性中多型思想的體現。

在Golang中,error的處理是最蛋疼的。基本上十個函式呼叫有九個會返回error,對於每一個error都需要進行處理或者向上拋。通常在業務邏輯中,我們都會自定義error,宣告error的型別。在Golang官方errors包中,error只是一個struct,它提供了New、Wrap、Error等方法,提供了建立error、向上丟擲error、輸出error資訊的功能。所以需要注意的是,我們不能用string的等值比較error是否相同,因為error是一個struct,是一個例項物件,儘管兩個error的值資訊一樣,但是物件在記憶體中只是一個存放地址值,兩者並不相同。通常我們在函式的第一行,使用defer的功能,對函式體中所有的error進行統一的處理。其中defer是延遲處理標誌,函式會在return前攔截處理defer匿名函式內的程式碼。(可以使用 pkg/errors 包解決)

Golang的專案結構在github有一個比較出名的example,大家可以參考或者模仿。大家需要注意的是,當外部專案需要呼叫該專案的程式碼時,只能呼叫internel包以外的函式或者物件方法。對於internel包內的程式碼,對外部呼叫專案來說,是不可用的。這也是一種程式碼保護機制。


MySQL

後端專案,離不開的就是資料的增刪改查。通常大家接觸到最多的就是MySQL了,推薦去看下 MySQL 45講

MySQL常用的版本有5.7和8.0,通常為了向前相容,大部分公司使用的MySQL版本都是5.7。在這個版本中,MySQL預設支援InnoDB儲存引擎,這個引擎的特點就是支援事務,也就是我們常說的ACID。

一般來說,如果需要對多張表進行增、改、刪等操作的時候,為了防止多階段操作的成功失敗不一致問題,需要用到事務特性。如果操作不完全失敗,就進行事務回滾,將所有操作都取消。

事務有四種隔離級別,分別是讀未提交、讀已提交、可重複讀和序列化。MySQL中InnoDB預設支援的事務隔離級別是可重複讀。

大家需要注意的是,對於事務的每一種隔離級別,儲存引擎內部都會提供對應的鎖機制實現。大家在對資料進行操作的平時,需要注意出現死鎖的情況。在資料讀取和操作中,支援讀寫鎖,讀鎖也就是共享鎖,多把讀鎖可以同時擁有。寫鎖也叫排它鎖,同一時刻只允許一把寫鎖對資料進行操作。不同的儲存引擎,有不同的鎖級別,有表鎖、行鎖、間隙鎖。大家注意在執行delete或者update操作的時候,最好帶上where條件,防止全表刪除或者更新的情況,或者因為觸發表鎖導致死鎖的情況。

資料的查詢通過索引查詢的方式和全表掃描的方式效率差距很大,本質的原因是在InnoDB引擎內部,會對新增了索引的表欄位建立B+樹以提高查詢效率。在查詢語句的編寫過程中,儘量表明需要查詢的欄位,這樣在查詢的欄位如果已經建立了聯合索引的情況下InnoDB查詢不需要進行回表。B+樹的葉子節點通常儲存的是表的主鍵,通過查詢條件在索引B+樹中查詢到對應主鍵,再到以主鍵為查詢條件建立的B+樹中查詢整行資料的方式我們稱為回表,回表會進行兩次B+樹查詢。

聯合索引的支援查詢方式是最左匹配原則,如果查詢語句中的where條件沒有按照聯合索引的最左匹配原則進行查詢,InnoDB將會全表掃描。索引優化使用 Explain 語句。

在表設計上,一張表的欄位不應設計過多,一般不超過20個欄位。每個欄位的欄位型別應該按照實際情況儘量縮減,比如uuid預設是32位,那麼定義varchar(32)即可,定義varchar(255)會造成空間浪費。

在分頁查詢中,limit支援的page和pageSize兩個欄位,當page越大,查詢的效率越低。因此儘量設計一個自動遞增的整型欄位,在page過大的時候,通過新增過濾自動遞增的整型欄位的where條件提高查詢效率。

MySQL預設是單機儲存,對於讀多寫少的業務場景,可以主從部署,支援讀寫分離,減輕寫伺服器的壓力。

MySQL最多隻能支援幾k的併發,對於大量的併發查詢資料的場景,建議在上游新增快取服務比如Redis、Memcached等。

MySQL在運算元據的時候會提供binlog日誌,通常會使用cancal等元件服務將資料進行匯出到訊息佇列,進行分析、特定搜尋、使用者推薦等其他場景。如果MySQL伺服器資料丟失,也可以使用binlog日誌進行資料恢復,但是因為資料操作會在一段時間記憶體在系統記憶體中,定期flush到硬碟,所以通過binlog日誌也不一定能完全恢復出所有資料。


Redis

當使用者量劇增,訪問頻繁的時候,在MySQL上游新增一個快取服務,同步一部分熱點資料,可以減輕資料庫的訪問壓力。常見的快取服務有Redis。

redis是由c語言編寫的記憶體型分散式快取元件。特點是支援大量讀寫場景,查詢資料高效。

雖然redis是分散式快取,但是為了防止服務當機,通常會使用持久化機制將資料儲存到硬碟中。redis支援的持久化機制包括AOF和RDB兩種。AOF通過記錄每一次寫、改、刪操作的日誌,在服務當機後,通過操作日誌進行命令重新執行的方式恢復資料。RDB通過記錄資料快照的方式,在服務當機後,通過資料快照恢復該時間段以前的所有資料。通常來說,兩者都有各自的缺點,AOF的缺點是資料恢復慢,RDB的缺點是資料快照是定時執行的,那麼在當機時刻與上一次資料快照記錄時刻的中間這一段時間的資料操作,將會丟失。所以我們會兩者兼用同步執行。建議RDB的時間間隔不要設定的太短,因為RDB快照的時候執行內部的bgsave命令會導致redis在短暫的時間內無法提供服務。

雖然redis能有效的減輕資料庫的訪問壓力,但是redis也不是銀彈。如果資料最終還是以資料庫中為準,那麼在對資料進行讀寫操作的時候,需要考慮快取與資料庫不一致的問題。

redis與mysql資料一致性的解決方案
讀取操作: 如果redis 某個資料過期了,直接從Mysql中查詢資料.
寫操作: 先更新Mysql, 然後再更新Redis即可;如果更新 redis失敗,可以考慮重試,

對於上述操作,如果還存在不一致的情況,考慮加一個兜底方案, 監聽 mysql binlog日誌,然後binlog 日誌傳送到 kafka 佇列中消費解決。

引入redis,除了資料不一致的問題之外,還有可能出現快取雪崩、快取穿透,快取擊穿的情況。在新增快取的時候,儘量設定不一樣的快取失效時間,防止同一時間內大量快取資料失效,資料訪問db造成db訪問壓力過大的問題; 快取穿透可以考慮布隆過濾器, 快取擊穿考慮分散式鎖解決。

redis之所以讀取效率快,是因為大量資料存在記憶體中,如果需要大量的快取資料儲存,單機記憶體容量有限,redis需要進行叢集部署。redis的叢集部署儲存方式是將拆分的一萬多個slot槽位均勻分佈在各個redis伺服器中,redis的key通過一致性雜湊,將資料儲存在某個slot槽位對應的redis伺服器中。redis的擴容和縮容操作會引起比較大資料遷移,這個時候儘量對外停止服務,否則可能會導致快取資料失效的問題。

redis通過哨兵機制發現服務上下線的問題。通常的部署模式是一主二從三哨兵。

redis的應用場景有很多,比如利用zsort實現排行榜,利用list實現輕量級訊息佇列,利用hash set實現微博點贊等等。

在redis儲存的時候需要注意,key值儘量不要使用中文,value值儘量不要過大。在設計key的時候,應該根據業務統一key的設計規範。

雖然redis有16個db庫,但是隻是邏輯隔離,快取資料都是儲存在一個地方,不同的db庫的讀寫是競爭關係。


Kafka

接下來談一談訊息佇列,所以在這裡只談一談Kafka。

訊息佇列的應用場景不用多講了,上下游 解耦、流量削峰、非同步處理等等大家根據實際場景去使用就好了。

先說一說訊息佇列會遇到的一些常見問題吧。比如訊息丟失、訊息重複傳送、訊息重試機制、訊息順序性、訊息重複消費等

在Kafka中訊息出現丟失的情況極低,因為Kafka是保證了至少一次的傳送機制。只要是在HW以內的offset,Kafka預設已經持久化到了硬碟中,所以在消費HW以內的offset訊息,不會出現訊息丟失的情況。

Kafka提供了訊息傳送的ACK機制,這個ACK機制有三個值可以選擇。

當ACK=0的時候,即訊息傳送到了leader即確認傳送成功,此時並不知道其他replica是否已經將訊息持久化了沒有,這種情況下極有可能出現訊息傳送了但是丟失的情況。因為如果此時leader節點當機,其他replica會競選leader,當某一個replica競選了leader以後,Kafka內部引入了leader epoach機制進行日誌截斷,此時如果該replica並沒有同步到leader接收到這一條訊息,那麼這條訊息就會丟失。

當ACK=1的時候,即訊息傳送到了該partition下的ISR集合內的所有replica內。當ISR集合中有多個replica存在,即使此時leader所在的節點當機,也不會存在訊息丟失的情況。因為partition下的leader預設是從ISR集合中產生的,而此時ISR集合內的所有replica已經儲存了該條訊息,所以丟失的可能性幾乎為零。

當ACK=-1的時候,即訊息傳送到了該partition下的所有replica內。不管leader所在的節點是否當機,也不管該ISR下的replica是否只有一個,只要該parition下的replica超過一個,那麼該訊息就不會丟失。

在日常情況下,我們預設ACK=1,因為ACK=0訊息極有可能丟失,ACK=-1訊息傳送確認時間太長,傳送效率太低。

對於訊息重複傳送的問題,我建議從消費端進行去重解決。因為對於producer端,如果出現了訊息傳送但是沒有接收到ACK,但實際上已經傳送成功卻判斷訊息傳送失敗,所以重複傳送一次的場景,Kafka也束手無策。不過可以開啟事務機制,確保只傳送一次,但是一旦開啟事務,Kafka的傳送消費能力將大打折扣,所以不建議開啟事務。

在Kafka中,producer端每傳送的一條訊息,都會存在對應topic下的partition中的某個offset上。訊息傳送必須指定topic,可以指定某個partition,也可以不指定。當partition不指定時候,某個topic下的訊息會通過負載均衡的方式分佈在各個partition下。因為只有同一個parititon下的訊息是有序的,所以在給有多個partition的topic傳送訊息的時候不指定partition,就會出現訊息亂序的情況。

Kafka的通過topic對訊息進行邏輯隔離,通過topic下的partition對訊息進行物理隔離,在topic下劃分多個partition是為了提高consumer端的消費能力。一個partition只能被一個consumer端消費,但是一個consumer端可以消費多個partition。每個consumer端都會被分配到一個consumer group中,如果該consumer group組中只有一個consumer端,那麼該consumer group訂閱的topic下的所有partition都會被這一個consumer端消費。如果consumer group組的consumer端個數小於等於topic下的partition數目,那麼consumer group中的consumer端會被均勻的分配到一定的partition數,有可能是一個partition,也有可能是多個partition。相反,如果consumer group組的consumer端個數大於topic下的partition數目,那麼consumer group中將會有consumer端分不到partition,消費不到資料。

在實際應用場景中,通常在consumer group中設定與partition數目對等的consumer端數。確保每個consumer端至少消費一個partition下的offset訊息。

Kafka叢集的每一個服務稱作broker,多個broker中會通過zookeeper選舉出一個controller處理內部請求和外部操作。但是資料真正的讀寫操作都發生在partition上,partition歸屬於某個topic下,為了防止資料丟失,partition一般會設定多個,每一個稱作replica。每個partition都會從多個replica中選舉出一個partition leader,負責處理資料的寫操作和讀操作。其他的replica負責於leader互動,進行資料的同步。同一個partition下的多個replica會均勻的分佈在不同的broker中。因此在設計上,我們可以發現,實際上Kafka的訊息處理是負載均衡的,基本上每個broker都會參與進來。partition的leader預設是從ISR集合中選舉產生的。ISR全名是In Sync Replica,意思是已經於leader的訊息保持一致的Replica。如果在一定時間內,或者一定數目的offset內,replica沒有與leader的offset保持一致,那麼就不能存在於ISR集合中,就算之前存在ISR集合中,也會被踢出去。等待一段時間後,訊息及時同步了,才有機會加入到ISR集合中。因此,從ISR集合中選舉leader在一定程度上是為了保證在leader重新選舉的時候訊息也能保證同步一致,不會丟失。

因為Kafka中引入了consumer group機制,所以能很大程度上提高consumer端的消費能力。但是也因為consumer group的rebalance機制,會讓consumer端的消費產生短暫性的不可用。問題是這樣的,因為consumer group中存在一個叫coordinate的均衡器,負責將partition均勻的分配到consumer group的每個consumer端中。如果consumer group中consumer端有新增或者減少,那麼partition就需要重新分配,這個時候,該consumer group下的所有consumer端都會停止消費,等待coordinate給他重新分配新的partition。consumer端和partition越多,這個等待時間就越長。因此,不建議topic下的partition設定的過多,一般在20個以內。

相關文章