高併發系統的分析和設計
任何系統都不是獨立於業務進行開發的,真正的系統是為了實現業務而開發的,所以開發高併發網站搶購時,都應該先分析業務需求和實際的場景,在完善這些需求之後才能進入系統開發階段。
沒有對業務進行分析就貿然開發系統是開發者的大忌。對於業務分析,首先是有效請求和無效請求,有效請求是指真實的需求,而無效請求則是虛假的搶購請求。
有效請求和無效請求
無效請求有很多種類,比如通過指令碼連續重新整理網站首頁,使得網站頻繁訪問資料庫和其他資源,造成效能持續下降,還有一些為了得到搶購商品,使用刷票軟體連續請求的行為。
鑑別有效請求和無效請求是獲取有效請求的高併發網站業務分析的第一步,我們現在來分析哪些是無效請求的場景,以及應對方法。
首先,一個賬號連續請求,對於一些懂技術或者使用作弊軟體的使用者,可以使用軟體對請求的服務介面連續請求,使得後臺壓力變大,甚至在一秒內傳送成百上千個請求到伺服器。
這樣的請求顯然可以認為是無效請求,應對它的方法很多,常見的做法是加入驗證碼。一般而言,首次無驗證碼以便使用者減少錄入,第二次請求開始加入驗證碼,可以是圖片驗證碼、等式運算等。
使用圖片驗證碼可能存在識別圖片作弊軟體的攻擊,所以在一些網際網路網站中,圖片驗證碼還會被加工成為東倒西歪的形式,這樣增加了圖片識別作弊軟體的辨別難度,以壓制作弊軟體的使用。簡單的等式運算,也會使圖片識別作弊軟體更加難以辨認。
其次,使用簡訊服務,把驗證碼傳送到簡訊平臺以規避部分作弊軟體。
在企業應用中,這類問題的邏輯判斷,不應該放在Web伺服器中實現,而應放在負載均衡器上完成,即在進入 Web 伺服器之前完成,做完這一步就能避免大量的無效請求,對保證高併發伺服器可用性很有效果。
僅僅做這一步或許還不夠,畢竟驗證碼或許還有其他作弊軟體可以快速讀取圖片或者簡訊資訊,從而傳送大量的請求。進一步的限制請求,比如限制使用者在單位時間的購買次數以壓制其請求量,使得這些請求排除在伺服器之外。判斷驗證碼邏輯,如圖 1 所示。
圖 1 判斷驗證碼邏輯
這裡的判斷是在負載均衡轉發給 Web 伺服器前,對驗證碼和單位時間單個賬號請求數量進行判斷。這裡使用了 C 語言和 Redis 進行判斷,那麼顯然這套方案會比 Java 語言和資料庫機制的效能要高得多,通過這套體系,基本能夠壓制一個使用者對系統的作弊,也提高了整個系統驗證的效能。
這是對一個賬號連續無效請求的壓制,有時候有些使用者可能申請多個賬號來迷惑伺服器,使得他可以避開對單個賬戶的驗證,從而獲得更多的伺服器資源。
一個人多個賬戶的場景還是比較好應付的,可以通過提高賬戶的等級來壓制多個請求,比如對於支付交易的網站,可以通過銀行卡驗證,實名制獲取相關證件號碼,從而使用證件號碼使得多個賬戶歸結為一人,通過這層關係來遮蔽多個賬號的頻繁請求,這樣就有效地規避了一個人多個賬號的頻繁請求。
對於有組織的請求,則不是那麼容易了,因為對於一些黃牛組織,可能通過多人的賬號來傳送請求,統一組織偽造有效請求,如圖 2 所示。
圖 2 統一組織偽造有效請求
對於這樣的請求,我們會考慮使用殭屍賬號排除法對可交易的賬號進行排除,所謂殭屍賬號,是指那些平時沒有任何交易的賬號,只是在特殊的日子交易,比如春運期間進行大批量搶購的賬號。
當請求達到伺服器,我們通過殭屍賬號,排除掉一些無效請求。當然還能使用 IP 封禁,尤其是通過同一 IP 或者網段頻繁請求的,但是這樣也許會誤傷有效請求,所以使用 IP 封禁還是要慎重一些。
系統設計
高併發系統往往需要分散式的系統分攤請求的壓力,這就需要使用負載均衡服務了,它進行簡易判斷後就會分發到具體 Web 伺服器。
我們要儘量根據 Web 伺服器的效能進行均衡分配請求,使得單個 Web 伺服器壓力不至於過大,導致服務癱瘓,這可以參考 Nginx 的請求分發,這樣使得請求能夠均衡釋出到伺服器中去,伺服器可以按業務劃分。
比如當前的購買分為產品維護、交易維護、資金維護、報表統計和使用者維護等模組,按照功能模組進行區分,使得它們相互隔離,就可以降低資料的複雜性,圖 3 就是一種典型的按業務劃分,或者稱為水平分法。
圖 3 按業務劃分
按照業務劃分的好處是:首先,一個服務管理一種業務,業務簡單了,提高了開發效率,其次,資料庫的設計也方便許多,畢竟各管各的東西。
但是,這也會帶來很多麻煩,比如由於各個系統業務存在著關聯,還要通過 RPC(Remote Procedure Call Protoco,遠端過程呼叫協議)處理這些關聯資訊。
比較流行的 RPC 有 Dubbo、Thrift 和 Hessian 等。其原理是,每一個服務都會暴露一些公共的介面給 RPC 服務,這樣對於任何一個伺服器都能夠通過 RPC 服務獲取其他伺服器對應的介面去排程各個伺服器的邏輯來完成功能,但是介面的相互呼叫也會造成一定的緩慢。
有了水平分法也會有垂直分法,所謂垂直分法就是將一個很大的請求量,不按子系統分,而是將它們按照互不相干的幾個同樣的系統分攤下去。
比如一臺伺服器的最大負荷為每秒 1 萬個請求,而測得系統高峰為每秒 2 萬個請求,如果我們把各個請求按照一定的演算法合理分配到 4 臺伺服器上,那麼 4 臺伺服器平均 5 千個請求就屬於正常服務了,這樣的路由演算法被稱為垂直分法,如圖 4 所示。
圖 4 垂直分法
垂直分法不按業務分,對於負載均衡器的演算法往往可以通過使用者編號把路由分發到對應的伺服器上。
每一個伺服器處理自己獨立的業務,互不干擾,但是每一個伺服器都包含所有的業務邏輯功能,會造成開發上的業務困難,對於資料庫設計而言也是如此。
對於大型網站還會有更細的分法,比如水平和垂直結合的分法,如圖 5 所示。
圖 5 水平和垂直結合分法
首先將系統按照業務區分為多個子系統,然後在每一個子系統下再分多個伺服器,通過每一個子系統的路由器找到對應的子系統伺服器提供服務。
分法是多樣性的,每一個企業都會根據自己的需要而進行不同的設計,但是無論系統如何分,秉承的原則是不變的。
首先,伺服器的負載均衡,要使得每一個伺服器都能比較平均地得到請求數量,從而提高系統的吞吐和效能。其次,業務簡化,按照模組劃分可以使得系統劃分為各個子系統,這樣開發者的業務單一化,就更容易理解和開發了。
資料庫設計
對於資料庫的設計而言,為了得到高效能,可以使用分表或分庫技術,從而提高系統的響應能力。
分表是指在一個資料庫內本來一張表可以儲存的資料,設計成多張表去儲存,比如交易表 t_transaction。
由於儲存資料多會造成查詢和統計的緩慢,這個時候可以使用多個表儲存,比如 2016 年的資料用表 t_transaction_2016 儲存,2017 年的資料使用表 t_transaction_2017 儲存,2018 年的資料則用表 t_transaction_2018 儲存,依此類推,開發者只要根據查詢的年份確定需要查詢哪張表就可以了,如圖 6 所示。
圖 6 通過年份路由分表
分庫則不一樣,它把表資料分配在不同的資料庫中,比如上述的交易表 t_transaction 可以存放在多個資料庫中,如圖 7 所示。
圖 7 分庫設計
分庫資料庫首先需要一個路由演算法確定資料在哪個資料庫上,然後才能進行查詢,比如我們可以把使用者和對應業務的資料庫的資訊快取到 Redis 中,這樣路由演算法就可以通過 Redis 讀取的資料來決定使用哪個資料庫進行查詢了。
一些會員很多的網站還可以區分活躍會員和非活躍會員。活躍會員可以通過資料遷徙的手段,也就是先記錄在某個時間段(比如一個月的月底)會員的活躍度,然後通過資料遷徙,將活躍會員較平均分攤到各個資料庫中,以避免某個庫過多的集中活躍會員,而導致個別資料庫被訪問過多,從而達到資料庫的負載均衡。
做完這些還可以考慮優化 SQL,建立索引等優化,提高資料庫的效能。效能低下的 SQL 對於高併發網站的影響是很大的,這些對開發者提出了更高的要求。
在開發網站中使用更新語句和複雜查詢語句要時刻記住更新是表鎖定還是行鎖定,比如 id 是主鍵,而 user_name 是使用者名稱稱,也是唯一索引,更新使用者的生日,可以使用以下兩條SQL中的任何一條:
update t_user set birthday = #{birthday} where id = #{id};
update t_user set birthday = #{birthday} where user_name = #{userName};
上述邏輯都是正確的,但是優選使用主鍵更新,其原因是在 MySQL 的執行過程中,第二句 SQL 會鎖表,即不僅鎖定更新的資料,而且鎖定其他表的資料,從而影響併發,而使用主鍵的更新則是行鎖定。
對於 SQL 的優化還有很多細節,比如可以使用連線查詢代替子查詢。查詢一個沒有分配角色的使用者 id,可能有人使用這樣的一個 SQL:
select u.id from t_user u
where u.id not in (select ur.user_id from t_user_role ur);
這是一個 not in 語句,效能低下,對於這樣的 not in 和 not exists 語句,應該全部修改為連線語句去執行,從而極大地提高 SQL 的效能,比如這條 not in 語句可以修改為:
select u.id from t_user u left join t_user_role ur
on u.id = ur.user_id
where ur.user_id is null;
not in 語句消失了,使用了連線查詢,大大提高了 SQL 的執行效能。
此外還可以通過讀/寫分離等技術,進行進一步的優化,這樣就可以有一臺主機主要負責寫業務,一臺或者多臺備機負責讀業務,有助於效能的提高。
對於分散式資料庫而言,還會有另外一個麻煩,就是事務的一致性,事務的一致性比較複雜,目前流行的有兩段提交協議,即 XA 協議、Paxos 協議。
動靜分離技術
動靜分離技術是目前網際網路的主流技術,對於網際網路而言大部分資料都是靜態資料,只有少數使用動態資料,動態資料的資料包很小,不會造成網路瓶頸。
而靜態的資料則不一樣,靜態資料包含圖片、CSS(樣式)、JavaScript(指令碼)和視訊等網際網路的應用,尤其是圖片和視訊佔據的流量很大,如果都從動態伺服器(比如 Tomcat、WildFly 和 WebLogic 等)獲取,那麼動態伺服器的頻寬壓力會很大,這個時候應該考慮使用動靜分離技術。
對於一些有條件的企業也可以考慮使用 CDN(Content Delivery Network,即內容分發網路)技術,它允許企業將自己的靜態資料快取到網路 CDN 的節點中。比如企業將資料快取在北京的節點上,當在天津的客戶傳送請求時,通過一定的演算法,會找到北京 CDN 節點,從而把 CDN 快取的資料傳送給天津的客戶,完成請求。
對於深圳的客戶,如果企業將資料快取到廣州 CDN 節點上,那麼它也可以從廣州的 CDN 節點上取出資料,由於就近取出快取節點的資料,所以速度會很快,如圖 8 所示。
圖 8 圖解CDN
一些企業也許需要自己的靜態 HTTP 伺服器,將靜態資料分離到靜態 HTTP 伺服器上。其原理大同小異,就是將資源分配到靜態伺服器上,這樣圖片、HTML、指令碼等資源都可以從靜態伺服器上獲取,儘量使用 Cookie 等技術,讓客戶端快取能夠快取資料,避免多次請求,降低伺服器的壓力。
對於動態資料,則需要根據會員登入來獲取後臺資料,這樣的動態資料是高併發網站關注的重點。
鎖和高併發
無論區分有效請求和無效請求,水平劃分和垂直劃分,動靜分離技術,還是資料庫分表、分庫等技術的應用,都無法避免動態資料,而動態資料的請求最終也會落在一臺 Web 伺服器上。
對於一臺 Web 伺服器而言,如果是 Java 伺服器,它極有可能採用 SSM 框架結合資料庫和 Redis 等技術提供服務,那麼它會面臨何種困難呢?高併發系統存在的一個麻煩是併發資料不一致問題。
以搶紅包為例,發放了一個總額為 20 萬元的紅包,它可以拆分為 2 萬個可搶的小紅包。假設每個小紅包都是 10 元,供給網站會員搶奪,網站同時存在 3 萬會員線上搶奪,這就是一個典型的高併發的場景。
以上會出現多個執行緒同時享有大紅包資料的場景,在高併發的場景中,由於執行緒每一步完成的順序不一樣,這樣會導致資料的一致性問題,比如在最後的一個紅包,就可能出現如表 1 所示的場景。
注意表 1 中加粗的文字,由此可見,在高併發的場景下可能出現錯扣紅包的情況,這樣就會導致資料錯誤。由於在一個瞬間產生很高的併發,因此除了保證資料一致性,我們還要儘可能地保證系統的效能,加鎖會影響併發,而不加鎖就難以保證資料的一致性,這就是高併發和鎖的矛盾。
時刻 | 執行緒一 | 執行緒二 | 備註 |
---|---|---|---|
T0 | —— | —— | 存在最後一個紅包可搶 |
T1 | 讀取大紅包資訊,存在最後一個紅包,可搶 | —— | —— |
T2 | —— | 讀取大紅包資訊,存在最後一個紅包,可搶 | —— |
T3 | 扣減最後一個紅包 | —— | 此時已經不存在紅包可搶 |
T4 | —— | 扣減紅包 | 錯誤發生了,超扣了 |
T5 | 記錄使用者獲取紅包資訊 | —— | —— |
T6 | —— | 記錄使用者獲取紅包資訊 | 因為錯誤扣減紅包而引發的錯誤 |
為了解決這對矛盾,在當前網際網路系統中,大部分企業提出了悲觀鎖和樂觀鎖的概念,而對於資料庫而言,如果在那麼短的時間內需要執行大量 SQL,對於伺服器的壓力可想而知,需要優化資料庫的表設計、索引、SQL 語句等。
有些企業提出了使用 Redis 事務和 Lua 語言所提供的原子性來取代現有的資料庫的技術,從而提高資料的儲存響應,以應對高併發場景,嚴格來說它也屬於樂觀鎖的概念。教程後面會討論關於資料不一致的方案,悲觀鎖、樂觀鎖和 Redis 實現的場景。
Redis悲觀鎖、樂觀鎖和呼叫Lua指令碼三種方式的優缺點
教程前面主要討論了 Java 網際網路的高併發應用,先談及了一些常用的系統設計理念,用以搭建高可用的網際網路應用系統,還討論了資料不一致的超發問題。
並且還論述了樂觀鎖、悲觀鎖和 Redis 如何消除資料不一致性的問題,也對它們的效能進行了探討。
悲觀鎖使用了資料庫的鎖機制,可以消除資料不一致性,對於開發者而言會十分簡單,但是,使用悲觀鎖後,資料庫的效能有所下降,因為大量的執行緒都會被阻塞,而且需要有大量的恢復過程,需要進一步改變演算法以提高系統的併發能力。
通過 CAS 原理和 ABA 問題的討論,我們更加明確了樂觀鎖的原理,使用樂觀鎖有助於提高併發效能,但是由於版本號衝突,樂觀鎖導致多次請求服務失敗的概率大大提高,而我們通過重入(按時間戳或者按次數限定)來提高成功的概率,這樣對於樂觀鎖而言實現的方式就相對複雜了,其效能也會隨著版本號衝突的概率提升而提升,並不穩定。
使用樂觀鎖的弊端在於,導致大量的 SQL 被執行,對於資料庫的效能要求較高,容易引起資料庫效能的瓶頸,而且對於開發還要考慮重入機制,從而導致開發難度加大。
使用 Redis 去實現高併發,通過 Redis 提供的 Lua 指令碼的原子性,消除了資料不一致性,並且在整個過程中只有最後一次涉及資料庫,而且是使用了新的執行緒。
在實際的操作中筆者更加傾向於使用 JMS 啟動另外的伺服器進行操作。但是這樣使用的風險在於 Redis 的不穩定性,因為其事務和儲存都存在不穩定的因素,所以更多的時候,筆者都建議使用獨立 Redis 伺服器做高併發業務,一方面可以提高 Redis 的效能,另一方面即使在高併發的場合,Redis 伺服器當機也不會影響現有的其他業務,同時也可以使用備機等裝置提高系統的高可用,保證網站的安全穩定。
以上討論了 3 種方式實現高併發業務技術的利弊,妥善規避風險,同時保證系統的高可用和高效是值得每一位開發者思考的問題。