微服务架构与组件总览
最近在各個地方總是看到微服務、消息隊列、Redis、K8s等詞語,下面就對他們涉及的概念進行一個總體的介紹,具體的技術實現目前還未完全掌握,那就先從整體把握關系,更方便以后的深入學習。(參考知乎和CSDN資料)
全篇以電商服務千萬級流量為例,穿插著其他介紹
1.微服務架構演化
? ? 在互聯網早期的時候,單體架構就足以支撐起日常的業務需求,大家的所有業務服務都在一個項目里,部署在一臺物理機器上。所有的業務包括你的交易系統、會員信息、庫存、商品等等都夾雜在一起,當流量一旦起來之后,單體架構的問題就暴露出來了,某個機器掛了,那所有的業務全部無法使用了。
于是,集群架構的架構開始出現,單機無法抗住的壓力,最簡單的辦法就是水平拓展橫向擴容了,這樣,通過負載均衡把壓力流量分攤到不同的機器上,暫時是解決了單點導致服務不可用的問題。
但是隨著業務的發展,在一個項目里維護所有的業務場景使開發和代碼維護變得越來越困難,一個簡單的需求改動都需要發布整個服務,代碼的合并沖突也會變得越來越頻繁,同時線上故障出現的可能性越大。微服務的架構模式就誕生了。
把每個獨立的業務拆分開獨立部署,開發和維護的成本降低,集群能承受的壓力也提高了,再也不會出現一個小小的改動點需要牽一發而動全身了。
以具體例子來說明
//start demo
最初的需求
小明和小皮一起創業做網上超市。小明負責程序開發,小皮負責其他事宜。他們的需求很簡單,只需要一個網站掛在公網,用戶能夠在這個網站上瀏覽商品、購買商品;另外還需一個管理后臺,可以管理商品、用戶、以及訂單數據。
網站:
- 用戶注冊、登錄功能
- 商品展示
- 下單
管理后臺:
-
- 用戶管理
- 商品管理
- 訂單管理
管理后臺出于安全考慮,不和網站做在一起,小明右手左手慢動作重播,管理網站也做好了。總體架構圖如下:
小明買個服務器,云服務部署上去,網站就上線了。小明小皮美滋滋地開始躺著收錢。
隨著業務發展……
在競爭的壓力下,小明小皮決定開展一些營銷手段:
- 開展促銷活動。比如元旦全場打折,春節買二送一,情人節狗糧優惠券等等。
- 拓展渠道,新增移動端營銷。除了網站外,還需要開發移動端APP,微信小程序等。
- 精準營銷。利用歷史數據對用戶進行分析,提供個性化服務。
- ……
這些活動都需要程序開發的支持。小明拉了同學小紅加入團隊。小紅負責數據分析以及移動端相關開發。小明負責促銷活動相關功能的開發。
因為開發任務比較緊迫,小明小紅沒有好好規劃整個系統的架構,隨便拍了拍腦袋,決定把促銷管理和數據分析放在管理后臺里,微信和移動端APP另外搭建。通宵了幾天后,新功能和新應用基本完工。這時架構圖如下:
這一階段存在很多不合理的地方:
- 網站和移動端應用有很多相同業務邏輯的重復代碼。
- 數據有時候通過數據庫共享,有時候通過接口調用傳輸。接口調用關系雜亂。
- 單個應用為了給其他應用提供接口,漸漸地越改越大,包含了很多本來就不屬于它的邏輯。應用邊界模糊,功能歸屬混亂。
- 管理后臺在一開始的設計中保障級別較低。加入數據分析和促銷管理相關功能后出現性能瓶頸,影響了其他應用。
- 數據庫表結構被多個應用依賴,無法重構和優化。
- 所有應用都在一個數據庫上操作,數據庫出現性能瓶頸。特別是數據分析跑起來的時候,數據庫性能急劇下降。
- 開發、測試、部署、維護愈發困難。即使只改動一個小功能,也需要整個應用一起發布。有時候發布會不小心帶上了一些未經測試的代碼,或者修改了一個功能后,另一個意想不到的地方出錯了。為了減輕發布可能產生的問題的影響和線上業務停頓的影響,所有應用都要在凌晨三四點執行發布。發布后為了驗證應用正常運行,還得盯到第二天白天的用戶高峰期……
是時候做出改變了
幸好小明和小紅是有追求有理想的好青年。意識到問題后,小明和小紅從瑣碎的業務需求中騰出了一部分精力,開始梳理整體架構,針對問題準備著手改造。
要做改造,首先你需要有足夠的精力和資源。如果你的需求方(業務人員、項目經理、上司等)很強勢地一心追求需求進度,以致于你無法挪出額外的精力和資源的話,那么你可能無法做任何事……在編程的世界中,最重要的便是抽象能力。微服務改造的過程實際上也是個抽象的過程。小明和小紅整理了網上超市的業務邏輯,抽象出公用的業務能力,做成幾個公共服務:
- 用戶服務
- 商品服務
- 促銷服務
- 訂單服務
- 數據分析服務
各個應用后臺只需從這些服務獲取所需的數據,從而刪去了大量冗余的代碼,就剩個輕薄的控制層和前端。這一階段的架構如下:
這個階段只是將服務分開了,數據庫依然是共用的,所以一些煙囪式系統的缺點仍然存在:
如果一直保持共用數據庫的模式,則整個架構會越來越僵化,失去了微服務架構的意義。因此小明和小紅一鼓作氣,把數據庫也拆分了。所有持久化層相互隔離,由各個服務自己負責。另外,為了提高系統的實時性,加入了消息隊列機制。架構如下:
完全拆分后各個服務可以采用異構的技術。比如數據分析服務可以使用數據倉庫作為持久化層,以便于高效地做一些統計計算;商品服務和促銷服務訪問頻率比較大,因此加入了緩存機制等。
還有一種抽象出公共邏輯的方法是把這些公共邏輯做成公共的框架庫。這種方法可以減少服務調用的性能損耗。但是這種方法的管理成本非常高昂,很難保證所有應用版本的一致性。數據庫拆分也有一些問題和挑戰:比如說跨庫級聯的需求,通過服務查詢數據顆粒度的粗細問題等。但是這些問題可以通過合理的設計來解決。總體來說,數據庫拆分是一個利大于弊的。
微服務架構還有一個技術外的好處,它使整個系統的分工更加明確,責任更加清晰,每個人專心負責為其他人提供更好的服務。在單體應用的時代,公共的業務功能經常沒有明確的歸屬。最后要么各做各的,每個人都重新實現了一遍;要么是隨機一個人(一般是能力比較強或者比較熱心的人)做到他負責的應用里面。在后者的情況下,這個人在負責自己應用之外,還要額外負責給別人提供這些公共的功能——而這個功能本來是無人負責的,僅僅因為他能力較強/比較熱心,就莫名地背鍋(這種情況還被美其名曰能者多勞)。結果最后大家都不愿意提供公共的功能。長此以往,團隊里的人漸漸變得各自為政,不再關心全局的架構設計。
然而……
沒有銀彈
//具體故障部分先略過。。。
......
?
服務注冊于發現 - 動態擴容
前面的組件,都是旨在降低故障發生的可能性。然而故障總是會發生的,所以另一個需要研究的是如何降低故障產生的影響。
最粗暴的(也是最常用的)故障處理策略就是冗余。一般來說,一個服務都會部署多個實例,這樣一來能夠分擔壓力提高性能,二來即使一個實例掛了其他實例還能響應。
冗余的一個問題是使用幾個冗余?這個問題在時間軸上并沒有一個切確的答案。根據服務功能、時間段的不同,需要不同數量的實例。比如在平日里,可能4個實例已經夠用;而在促銷活動時,流量大增,可能需要40個實例。因此冗余數量并不是一個固定的值,而是根據需要實時調整的。
一般來說新增實例的操作為:
操作只有兩步,但如果注冊到負載均衡或DNS的操作為人工操作的話,那事情就不簡單了。想想新增40個實例后,要手工輸入40個IP的感覺……
解決這個問題的方案是服務自動注冊與發現。首先,需要部署一個服務發現服務,它提供所有已注冊服務的地址信息的服務。DNS也算是一種服務發現服務。然后各個應用服務在啟動時自動將自己注冊到服務發現服務上。并且應用服務啟動后會實時(定期)從服務發現服務同步各個應用服務的地址列表到本地。服務發現服務也會定期檢查應用服務的健康狀態,去掉不健康的實例地址。這樣新增實例時只需要部署新實例,實例下線時直接關停服務即可,服務發現會自動檢查服務實例的增減。
服務發現還會跟客戶端負載均衡配合使用。由于應用服務已經同步服務地址列表在本地了,所以訪問微服務時,可以自己決定負載策略。甚至可以在服務注冊時加入一些元數據(服務版本等信息),客戶端負載則根據這些元數據進行流量控制,實現A/B測試、藍綠發布等功能。
服務發現有很多組件可以選擇,比如說Zookeeper 、Eureka、Consul、Etcd等。不過小明覺得自己水平不錯,想炫技,于是基于Redis自己寫了一個……
......
//end demo? 鏈接
?
2. RPC
1.簡介
- RPC(Remote Procedure Call)遠程過程調用,簡單的理解是一個節點請求另一個節點提供的服務
- 本地過程調用:如果需要將本地student對象的age+1,可以實現一個addAge()方法,將student對象傳入,對年齡進行更新之后返回即可,本地方法調用的函數體通過函數指針來指定。
- 遠程過程調用:上述操作的過程中,如果addAge()這個方法在服務端,執行函數的函數體在遠程機器上,如何告訴機器需要調用這個方法呢?
步驟如下:
總結一下上述過程:
// Client端 // Student student = Call(ServerAddr, addAge, student) 1. 將這個調用映射為Call ID。 2. 將Call ID,student(params)序列化,以二進制形式打包 3. 把2中得到的數據包發送給ServerAddr,這需要使用網絡傳輸層 4. 等待服務器返回結果 5. 如果服務器調用成功,那么就將結果反序列化,并賦給student,年齡更新// Server端 1. 在本地維護一個Call ID到函數指針的映射call_id_map,可以用Map<String, Method> callIdMap 2. 等待服務端請求 3. 得到一個請求后,將其數據包反序列化,得到Call ID 4. 通過在callIdMap中查找,得到相應的函數指針 5. 將student(params)反序列化后,在本地調用addAge()函數,得到結果 6. 將student結果序列化后通過網絡返回給Client在微服務的設計中,一個服務A如果訪問另一個Module下的服務B,可以采用HTTP REST傳輸數據,并在兩個服務之間進行序列化和反序列化操作,服務B把執行結果返回過來。
由于HTTP在應用層中完成,整個通信的代價較高,遠程過程調用中直接基于TCP進行遠程調用,數據傳輸在傳輸層TCP層完成,更適合對效率要求比較高的場景,RPC主要依賴于客戶端和服務端之間建立Socket鏈接進行,底層實現比REST更復雜。
2. rpc demo
客戶端
public class RPCClient<T> {public static <T> T getRemoteProxyObj(final Class<?> serviceInterface, final InetSocketAddress addr) {// 1.將本地的接口調用轉換成JDK的動態代理,在動態代理中實現接口的遠程調用return (T) Proxy.newProxyInstance(serviceInterface.getClassLoader(), new Class<?>[]{serviceInterface},new InvocationHandler() {@Overridepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable {Socket socket = null;ObjectOutputStream output = null;ObjectInputStream input = null;try{// 2.創建Socket客戶端,根據指定地址連接遠程服務提供者socket = new Socket();socket.connect(addr);// 3.將遠程服務調用所需的接口類、方法名、參數列表等編碼后發送給服務提供者output = new ObjectOutputStream(socket.getOutputStream());output.writeUTF(serviceInterface.getName());output.writeUTF(method.getName());output.writeObject(method.getParameterTypes());output.writeObject(args);// 4.同步阻塞等待服務器返回應答,獲取應答后返回input = new ObjectInputStream(socket.getInputStream());return input.readObject();}finally {if (socket != null){socket.close();}if (output != null){output.close();}if (input != null){input.close();}}}});} }服務端:
public class ServiceCenter implements Server {private static ExecutorService executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());private static final HashMap<String, Class> serviceRegistry = new HashMap<String, Class>();private static boolean isRunning = false;private static int port;public ServiceCenter(int port){ServiceCenter.port = port;}@Overridepublic void start() throws IOException {ServerSocket server = new ServerSocket();server.bind(new InetSocketAddress(port));System.out.println("Server Start .....");try{while(true){executor.execute(new ServiceTask(server.accept()));}}finally {server.close();}}@Overridepublic void register(Class serviceInterface, Class impl) {serviceRegistry.put(serviceInterface.getName(), impl);}@Overridepublic boolean isRunning() {return isRunning;}@Overridepublic int getPort() {return port;}@Overridepublic void stop() {isRunning = false;executor.shutdown();}private static class ServiceTask implements Runnable {Socket client = null;public ServiceTask(Socket client) {this.client = client;}@Overridepublic void run() {ObjectInputStream input = null;ObjectOutputStream output = null;try{input = new ObjectInputStream(client.getInputStream());String serviceName = input.readUTF();String methodName = input.readUTF();Class<?>[] parameterTypes = (Class<?>[]) input.readObject();Object[] arguments = (Object[]) input.readObject();Class serviceClass = serviceRegistry.get(serviceName);if(serviceClass == null){throw new ClassNotFoundException(serviceName + "not found!");}Method method = serviceClass.getMethod(methodName, parameterTypes);Object result = method.invoke(serviceClass.newInstance(), arguments);output = new ObjectOutputStream(client.getOutputStream());output.writeObject(result);}catch (Exception e){e.printStackTrace();}finally {if(output!=null){try{output.close();}catch (IOException e){e.printStackTrace();}}if (input != null) {try {input.close();} catch (IOException e) {e.printStackTrace();}}if (client != null) {try {client.close();} catch (IOException e) {e.printStackTrace();}}}}} } public class ServiceProducerImpl implements ServiceProducer{@Overridepublic String sendData(String data) {return "I am service producer!!!, the data is "+ data;} } public class RPCTest {public static void main(String[] args) throws IOException {new Thread(new Runnable() {@Overridepublic void run() {try {Server serviceServer = new ServiceCenter(8088);serviceServer.register(ServiceProducer.class, ServiceProducerImpl.class);serviceServer.start();} catch (IOException e) {e.printStackTrace();}}}).start();ServiceProducer service = RPCClient.getRemoteProxyObj(ServiceProducer.class, new InetSocketAddress("localhost", 8088));System.out.println(service.sendData("test"));} }源碼? 參考這里
上述例子參考自簡書
?
2.Dubbo工作原理
服務啟動的時候,provider和consumer根據配置信息,連接到注冊中心register,分別向注冊中心注冊和訂閱服務
register根據服務訂閱關系,返回provider信息到consumer,同時consumer會把provider信息緩存到本地。如果信息有變更,consumer會收到來自register的推送
consumer生成代理對象,同時根據負載均衡策略,選擇一臺provider,同時定時向monitor記錄接口的調用次數和時間信息
拿到代理對象之后,consumer通過代理對象發起接口調用
provider收到請求后對數據進行反序列化,然后通過代理調用具體的接口實現
Dubbo負載均衡策略
加權隨機:假設我們有一組服務器 servers = [A, B, C],他們對應的權重為 weights = [5, 3, 2],權重總和為10。現在把這些權重值平鋪在一維坐標值上,[0, 5) 區間屬于服務器 A,[5, 8) 區間屬于服務器 B,[8, 10) 區間屬于服務器 C。接下來通過隨機數生成器生成一個范圍在 [0, 10) 之間的隨機數,然后計算這個隨機數會落到哪個區間上就可以了。
最小活躍數:每個服務提供者對應一個活躍數 active,初始情況下,所有服務提供者活躍數均為0。每收到一個請求,活躍數加1,完成請求后則將活躍數減1。在服務運行一段時間后,性能好的服務提供者處理請求的速度更快,因此活躍數下降的也越快,此時這樣的服務提供者能夠優先獲取到新的服務請求。
一致性hash:通過hash算法,把provider的invoke和隨機節點生成hash,并將這個 hash 投射到 [0, 2^32 - 1] 的圓環上,查詢的時候根據key進行md5然后進行hash,得到第一個節點的值大于等于當前hash的invoker。
加權輪詢:比如服務器 A、B、C 權重比為 5:2:1,那么在8次請求中,服務器 A 將收到其中的5次請求,服務器 B 會收到其中的2次請求,服務器 C 則收到其中的1次請求。
集群容錯
Failover Cluster失敗自動切換:dubbo的默認容錯方案,當調用失敗時自動切換到其他可用的節點,具體的重試次數和間隔時間可用通過引用服務的時候配置,默認重試次數為1也就是只調用一次。
Failback Cluster快速失敗:在調用失敗,記錄日志和調用信息,然后返回空結果給consumer,并且通過定時任務每隔5秒對失敗的調用進行重試
Failfast Cluster失敗自動恢復:只會調用一次,失敗后立刻拋出異常
Failsafe Cluster失敗安全:調用出現異常,記錄日志不拋出,返回空結果
Forking Cluster并行調用多個服務提供者:通過線程池創建多個線程,并發調用多個provider,結果保存到阻塞隊列,只要有一個provider成功返回了結果,就會立刻返回結果
Broadcast Cluster廣播模式:逐個調用每個provider,如果其中一臺報錯,在循環調用結束后,拋出異常。
3. 消息隊列
我們可以把消息隊列比作是一個存放消息的容器,當我們需要使用消息的時候可以取出消息供自己使用。消息隊列是分布式系統中重要的組件,使用消息隊列主要是為了通過異步處理提高系統性能和削峰、降低系統耦合性。目前使用較多的消息隊列有ActiveMQ,RabbitMQ,Kafka,RocketMQ,我們后面會一一對比這些消息隊列。
另外,我們知道隊列 Queue 是一種先進先出的數據結構,所以消費消息時也是按照順序來消費的。比如生產者發送消息1,2,3...對于消費者就會按照1,2,3...的順序來消費。但是偶爾也會出現消息被消費的順序不對的情況,比如某個消息消費失敗又或者一個 queue 多個consumer 也會導致消息被消費的順序不對,我們一定要保證消息被消費的順序正確。
除了上面說的消息消費順序的問題,使用消息隊列,我們還要考慮如何保證消息不被重復消費?如何保證消息的可靠性傳輸(如何處理消息丟失的問題)?......等等問題。所以說使用消息隊列也不是十全十美的,使用它也會讓系統可用性降低、復雜度提高,另外需要我們保障一致性等問題。
消息隊列主要有兩點好處:1.通過異步處理提高系統性能(削峰、減少響應所需時間);2.降低系統耦合性。
(1) 通過異步處理提高系統性能(削峰、減少響應所需時間)
如上圖,在不使用消息隊列服務器的時候,用戶的請求數據直接寫入數據庫,在高并發的情況下數據庫壓力劇增,使得響應速度變慢。但是在使用消息隊列之后,用戶的請求數據發送給消息隊列之后立即返回,再由消息隊列的消費者進程從消息隊列中獲取數據,異步寫入數據庫。由于消息隊列服務器處理速度快于數據庫(消息隊列也比數據庫有更好的伸縮性),因此響應速度得到大幅改善。
通過以上分析我們可以得出消息隊列具有很好的削峰作用的功能——即通過異步處理,將短時間高并發產生的事務消息存儲在消息隊列中,從而削平高峰期的并發事務。 舉例:在電子商務一些秒殺、促銷活動中,合理使用消息隊列可以有效抵御促銷活動剛開始大量訂單涌入對系統的沖擊。如下圖所示:
因為用戶請求數據寫入消息隊列之后就立即返回給用戶了,但是請求數據在后續的業務校驗、寫數據庫等操作中可能失敗。因此使用消息隊列進行異步處理之后,需要適當修改業務流程進行配合,比如用戶在提交訂單之后,訂單數據寫入消息隊列,不能立即返回用戶訂單提交成功,需要在消息隊列的訂單消費者進程真正處理完該訂單之后,甚至出庫后,再通過電子郵件或短信通知用戶訂單成功,以免交易糾紛。這就類似我們平時手機訂火車票和電影票。
(2) 降低系統耦合性
我們知道如果模塊之間不存在直接調用,那么新增模塊或者修改模塊就對其他模塊影響較小,這樣系統的可擴展性無疑更好一些。
我們最常見的事件驅動架構類似生產者消費者模式,在大型網站中通常用利用消息隊列實現事件驅動結構。如下圖所示:
消息隊列使利用發布-訂閱模式工作,消息發送者(生產者)發布消息,一個或多個消息接受者(消費者)訂閱消息。 從上圖可以看到消息發送者(生產者)和消息接受者(消費者)之間沒有直接耦合,消息發送者將消息發送至分布式消息隊列即結束對消息的處理,消息接受者從分布式消息隊列獲取該消息后進行后續處理,并不需要知道該消息從何而來。對新增業務,只要對該類消息感興趣,即可訂閱該消息,對原有系統和業務沒有任何影響,從而實現網站業務的可擴展性設計。
消息接受者對消息進行過濾、處理、包裝后,構造成一個新的消息類型,將消息繼續發送出去,等待其他消息接受者訂閱該消息。因此基于事件(消息對象)驅動的業務架構可以是一系列流程。
另外為了避免消息隊列服務器宕機造成消息丟失,會將成功發送到消息隊列的消息存儲在消息生產者服務器上,等消息真正被消費者服務器處理后才刪除消息。在消息隊列服務器宕機后,生產者服務器會選擇分布式消息隊列服務器集群中的其他服務器發布消息。
備注: 不要認為消息隊列只能利用發布-訂閱模式工作,只不過在解耦這個特定業務環境下是使用發布-訂閱模式的。除了發布-訂閱模式,還有點對點訂閱模式(一個消息只有一個消費者),我們比較常用的是發布-訂閱模式。 另外,這兩種消息模型是 JMS 提供的,AMQP 協議還提供了 5 種消息模型。
上述例子鏈接
//end介紹
?
對于MQ的作用大家都應該很了解了,削峰填谷、解耦。依賴消息隊列,同步轉異步的方式,可以降低微服務之間的耦合。
對于一些不需要同步執行的接口,可以通過引入消息隊列的方式異步執行以提高接口響應時間。在交易完成之后需要扣庫存,然后可能需要給會員發放積分,本質上,發積分的動作應該屬于履約服務,對實時性的要求也不高,我們只要保證最終一致性也就是能履約成功就行了。對于這種同類性質的請求就可以走MQ異步,也就提高了系統抗壓能力了。
對于消息隊列而言,怎么在使用的時候保證消息的可靠性、不丟失?
消息可靠性
消息丟失可能發生在生產者發送消息、MQ本身丟失消息、消費者丟失消息3個方面。
生產者丟失
生產者丟失消息的可能點在于程序發送失敗拋異常了沒有重試處理,或者發送的過程成功但是過程中網絡閃斷MQ沒收到,消息就丟失了。
由于同步發送的一般不會出現這樣使用方式,所以我們就不考慮同步發送的問題,我們基于異步發送的場景來說。
異步發送分為兩個方式:異步有回調和異步無回調,無回調的方式,生產者發送完后不管結果可能就會造成消息丟失,而通過異步發送+回調通知+本地消息表的形式我們就可以做出一個解決方案。以下單的場景舉例。
下單后先保存本地數據和MQ消息表,這時候消息的狀態是發送中,如果本地事務失敗,那么下單失敗,事務回滾。
下單成功,直接返回客戶端成功,異步發送MQ消息
MQ回調通知消息發送結果,對應更新數據庫MQ發送狀態
JOB輪詢超過一定時間(時間根據業務配置)還未發送成功的消息去重試
在監控平臺配置或者JOB程序處理超過一定次數一直發送不成功的消息,告警,人工介入。
一般而言,對于大部分場景來說異步回調的形式就可以了,只有那種需要完全保證不能丟失消息的場景我們做一套完整的解決方案。
MQ丟失
如果生產者保證消息發送到MQ,而MQ收到消息后還在內存中,這時候宕機了又沒來得及同步給從節點,就有可能導致消息丟失。
比如RocketMQ:
RocketMQ分為同步刷盤和異步刷盤兩種方式,默認的是異步刷盤,就有可能導致消息還未刷到硬盤上就丟失了,可以通過設置為同步刷盤的方式來保證消息可靠性,這樣即使MQ掛了,恢復的時候也可以從磁盤中去恢復消息。
比如Kafka也可以通過配置做到:
acks=all 只有參與復制的所有節點全部收到消息,才返回生產者成功。這樣的話除非所有的節點都掛了,消息才會丟失。 replication.factor=N,設置大于1的數,這會要求每個partion至少有2個副本 min.insync.replicas=N,設置大于1的數,這會要求leader至少感知到一個follower還保持著連接 retries=N,設置一個非常大的值,讓生產者發送失敗一直重試雖然我們可以通過配置的方式來達到MQ本身高可用的目的,但是都對性能有損耗,怎樣配置需要根據業務做出權衡。
消費者丟失
消費者丟失消息的場景:消費者剛收到消息,此時服務器宕機,MQ認為消費者已經消費,不會重復發送消息,消息丟失。
RocketMQ默認是需要消費者回復ack確認,而kafka需要手動開啟配置關閉自動offset。
消費方不返回ack確認,重發的機制根據MQ類型的不同發送時間間隔、次數都不盡相同,如果重試超過次數之后會進入死信隊列,需要手工來處理了。(Kafka沒有這些)
消息的最終一致性
事務消息可以達到分布式事務的最終一致性,事務消息就是MQ提供的類似XA的分布式事務能力。
半事務消息就是MQ收到了生產者的消息,但是沒有收到二次確認,不能投遞的消息。
實現原理如下:
生產者先發送一條半事務消息到MQ
MQ收到消息后返回ack確認
生產者開始執行本地事務
如果事務執行成功發送commit到MQ,失敗發送rollback
如果MQ長時間未收到生產者的二次確認commit或者rollback,MQ對生產者發起消息回查
生產者查詢事務執行最終狀態
根據查詢事務狀態再次提交二次確認
最終,如果MQ收到二次確認commit,就可以把消息投遞給消費者,反之如果是rollback,消息會保存下來并且在3天后被刪除。
4. 數據庫
對于整個系統而言,最終所有的流量的查詢和寫入都落在數據庫上,數據庫是支撐系統高并發能力的核心。怎么降低數據庫的壓力,提升數據庫的性能是支撐高并發的基石。主要的方式就是通過讀寫分離和分庫分表來解決這個問題。
對于整個系統而言,流量應該是一個漏斗的形式。比如我們的日活用戶DAU有20萬,實際可能每天來到提單頁的用戶只有3萬QPS,最終轉化到下單支付成功的QPS只有1萬。那么對于系統來說讀是大于寫的,這時候可以通過讀寫分離的方式來降低數據庫的壓力。
讀寫分離也就相當于數據庫集群的方式降低了單節點的壓力。而面對數據的急劇增長,原來的單庫單表的存儲方式已經無法支撐整個業務的發展,這時候就需要對數據庫進行分庫分表了。針對微服務而言垂直的分庫本身已經是做過的,剩下大部分都是分表的方案了。
水平分表
首先根據業務場景來決定使用什么字段作為分表字段(sharding_key),比如我們現在日訂單1000萬,我們大部分的場景來源于C端,我們可以用user_id作為sharding_key,數據查詢支持到最近3個月的訂單,超過3個月的做歸檔處理,那么3個月的數據量就是9億,可以分1024張表,那么每張表的數據大概就在100萬左右。
比如用戶id為100,那我們都經過hash(100),然后對1024取模,就可以落到對應的表上了。
分表后的ID唯一性
因為我們主鍵默認都是自增的,那么分表之后的主鍵在不同表就肯定會有沖突了。有幾個辦法考慮:
設定步長,比如1-1024張表我們分別設定1-1024的基礎步長,這樣主鍵落到不同的表就不會沖突了。
分布式ID,自己實現一套分布式ID生成算法或者使用開源的比如雪花算法這種
分表后不使用主鍵作為查詢依據,而是每張表單獨新增一個字段作為唯一主鍵使用,比如訂單表訂單號是唯一的,不管最終落在哪張表都基于訂單號作為查詢依據,更新也一樣。
主從同步原理
master提交完事務后,寫入binlog
slave連接到master,獲取binlog
master創建dump線程,推送binglog到slave
slave啟動一個IO線程讀取同步過來的master的binlog,記錄到relay log中繼日志中
slave再開啟一個sql線程讀取relay log事件并在slave執行,完成同步
slave記錄自己的binglog
由于mysql默認的復制方式是異步的,主庫把日志發送給從庫后不關心從庫是否已經處理,這樣會產生一個問題就是假設主庫掛了,從庫處理失敗了,這時候從庫升為主庫后,日志就丟失了。由此產生兩個概念。
全同步復制
主庫寫入binlog后強制同步日志到從庫,所有的從庫都執行完成后才返回給客戶端,但是很顯然這個方式的話性能會受到嚴重影響。
半同步復制
和全同步不同的是,半同步復制的邏輯是這樣,從庫寫入日志成功后返回ACK確認給主庫,主庫收到至少一個從庫的確認就認為寫操作完成。
5.緩存
緩存作為高性能的代表,在某些特殊業務可能承擔90%以上的熱點流量。對于一些活動比如秒殺這種并發QPS可能幾十萬的場景,引入緩存事先預熱可以大幅降低對數據庫的壓力,10萬的QPS對于單機的數據庫來說可能就掛了,但是對于如redis這樣的緩存來說就完全不是問題。
以秒殺系統舉例,活動預熱商品信息可以提前緩存提供查詢服務,活動庫存數據可以提前緩存,下單流程可以完全走緩存扣減,秒殺結束后再異步寫入數據庫,數據庫承擔的壓力就小的太多了。當然,引入緩存之后就還要考慮緩存擊穿、雪崩、熱點一系列的問題了。
熱key問題
所謂熱key問題就是,突然有幾十萬的請求去訪問redis上的某個特定key,那么這樣會造成流量過于集中,達到物理網卡上限,從而導致這臺redis的服務器宕機引發雪崩。
針對熱key的解決方案:
提前把熱key打散到不同的服務器,降低壓力
加入二級緩存,提前加載熱key數據到內存中,如果redis宕機,走內存查詢
緩存擊穿
緩存擊穿的概念就是單個key并發訪問過高,過期時導致所有請求直接打到db上,這個和熱key的問題比較類似,只是說的點在于過期導致請求全部打到DB上而已。
解決方案:
加鎖更新,比如請求查詢A,發現緩存中沒有,對A這個key加鎖,同時去數據庫查詢數據,寫入緩存,再返回給用戶,這樣后面的請求就可以從緩存中拿到數據了。
將過期時間組合寫在value中,通過異步的方式不斷的刷新過期時間,防止此類現象。
緩存穿透
緩存穿透是指查詢不存在緩存中的數據,每次請求都會打到DB,就像緩存不存在一樣。
針對這個問題,加一層布隆過濾器。布隆過濾器的原理是在你存入數據的時候,會通過散列函數將它映射為一個位數組中的K個點,同時把他們置為1。
這樣當用戶再次來查詢A,而A在布隆過濾器值為0,直接返回,就不會產生擊穿請求打到DB了。
顯然,使用布隆過濾器之后會有一個問題就是誤判,因為它本身是一個數組,可能會有多個值落到同一個位置,那么理論上來說只要我們的數組長度夠長,誤判的概率就會越低,這種問題就根據實際情況來就好了。
緩存雪崩
當某一時刻發生大規模的緩存失效的情況,比如你的緩存服務宕機了,會有大量的請求進來直接打到DB上,這樣可能導致整個系統的崩潰,稱為雪崩。雪崩和擊穿、熱key的問題不太一樣的是,他是指大規模的緩存都過期失效了。
針對雪崩幾個解決方案:
針對不同key設置不同的過期時間,避免同時過期
限流,如果redis宕機,可以限流,避免同時刻大量請求打崩DB
二級緩存,同熱key的方案。
6.穩定性
熔斷
比如營銷服務掛了或者接口大量超時的異常情況,不能影響下單的主鏈路,涉及到積分的扣減一些操作可以在事后做補救。
限流
對突發如大促秒殺類的高并發,如果一些接口不做限流處理,可能直接就把服務打掛了,針對每個接口的壓測性能的評估做出合適的限流尤為重要。
降級
熔斷之后實際上可以說就是降級的一種,以熔斷的舉例來說營銷接口熔斷之后降級方案就是短時間內不再調用營銷的服務,等到營銷恢復之后再調用。
預案
一般來說,就算是有統一配置中心,在業務的高峰期也是不允許做出任何的變更的,但是通過配置合理的預案可以在緊急的時候做一些修改。
核對
針對各種分布式系統產生的分布式事務一致性或者受到攻擊導致的數據異常,非常需要核對平臺來做最后的兜底的數據驗證。比如下游支付系統和訂單系統的金額做核對是否正確,如果收到中間人攻擊落庫的數據是否保證正確性。
總結
其實可以看到,怎么設計高并發系統這個問題本身他是不難的,無非是基于你知道的知識點,從物理硬件層面到軟件的架構、代碼層面的優化,使用什么中間件來不斷提高系統的抗壓能力。
但是這個問題本身會帶來更多的問題,微服務本身的拆分帶來了分布式事務的問題,http、RPC框架的使用帶來了通信效率、路由、容錯的問題,MQ的引入帶來了消息丟失、積壓、事務消息、順序消息的問題,緩存的引入又會帶來一致性、雪崩、擊穿的問題,數據庫的讀寫分離、分庫分表又會帶來主從同步延遲、分布式ID、事務一致性的問題,而為了解決這些問題我們又要不斷的加入各種措施熔斷、限流、降級、離線核對、預案處理等等來防止和追溯這些問題。
微服務不是架構演變的終點。往細走還有Serverless、FaaS等方向。另一方面也有人在唱合久必分分久必合,重新發現單體架構……
總結
以上是生活随笔為你收集整理的微服务架构与组件总览的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 使用QXDM Log 來分析LTE環境資
- 下一篇: MySQL修改、删除存储过程和函数