电商系统的高并发设计和挑战
聲明:本文為《程序員》原創文章,未經允許不得轉載,更多精彩文章請訂閱2016年《程序員》http://dingyue.programmer.com.cn
作者:陳康賢(專訪),阿里巴巴技術專家,花名龍隆,著有《大型分布式網站架構設計與實踐》一書,擅長Java Web程序設計,長期在淘寶分布式環境下耳濡目染,目前關注于Java高性能程序設計及性能優化,博客地址http://chenkangxian.iteye.com。
責編:錢曙光,關注架構和算法領域,尋求報道或者投稿請發郵件qianshg@csdn.net,另有「CSDN 高級架構師群」,內有諸多知名互聯網公司的大牛架構師,歡迎架構師加微信qshuguang2008申請入群,備注姓名+公司+職位。
相對于傳統商業模式來說,電子商務帶來的變革使人們足不出戶便能享受到購物的樂趣,十幾二十年前,很難想象幾億中國人能夠在雙十一一天產生幾百億的消費。同時,大流量帶來了高并發的問題,其中針對技術人員尤為突出的是高并發系統的設計,它與普通系統設計的區別在于既要保障系統的可用性、可擴展性,又要兼顧數據一致性,還要處理多線程同步的問題。任何細微問題,都有可能在高并發環境下被無限的放大,直至系統宕機。
操作原子性
原子操作是指不可分割的操作,它要么執行成功,要么執行失敗,不會產生中間狀態,在多線程程序中,原子操作是一個非常重要的概念,它常常用來實現一些數據同步機制,具體的例子如Java的原子變量、數據庫的事務等。同時,原子操作也是常見多線程程序Bug的源頭,并發相關的問題對于測試來說,并不是每次都能夠重現,因此處理起來十分棘手。比如,大部分站點都有數據count統計的需求,一種實現方式如下:
public class Count {public int count = 0;static class Job implements Runnable{private CountDownLatch countDown;private Count count;public Job(Count count,CountDownLatch countDown){this.count = count;this.countDown = countDown;}@Overridepublic void run() {count.count++;countDown.countDown();}}public static void main(String[] args) throws InterruptedException {CountDownLatch countDown = new CountDownLatch(1500);Count count = new Count();ExecutorService ex = Executors.newFixedThreadPool(5);for(int i = 0; i < 1500; i ++){ex.execute(new Job(count,countDown));}countDown.await();System.out.println(count.count);ex.shutdown();} }Count對象中有一個count的int類型屬性,Job負責每次給Count對象的count屬性做++操作,創建一個有包含5個線程的線程池,新建一個count共享對象,將對象的引用傳遞給每一個線程,線程負責給對象的count屬性++。乍看程序的邏輯沒啥問題,但運行的結果卻總是不正確,是由于問題出在count.count++上,這里邊涉及到多線程同步的問題,此外,其中很重要的一點是count.count++并不是原子操作,當Java代碼最終被編譯成字節碼時,run()方法會被編譯成這幾條指令[1]:
public void run();Code:0: aload_0 1: getfield #17; 4: dup 5: getfield #26; //獲取count. //count的值,并將其壓入棧頂8: iconst_1 //將int型1壓入棧頂9: iadd //將棧頂兩int型數值相加,并將結果壓入棧頂10: putfield #26; //將棧頂的結果 //賦值給count.count13: aload_014: getfield #19; 17: invokevirtual #31; 20: return }要完成count.count++操作,首先需要將count.count與1入棧,然后再相加,最后再將結果覆蓋count.count變量。而在多線程情況下,有可能執行完getfield指令之后,其他線程此時執行putfield指令,給count.count變量賦值,這樣,棧頂的count.count變量值與它實際值就存在不一致的情況,接著執行完iadd指令后,再將結果賦值回去,就會出現錯誤。
JDK5.0以后開始提供Atomic Class,支持CAS(CompareAndSet)等一系列原子操作,來幫助我們簡化多線程程序設計,要避免上述情況的發生,可以使用JDK提供的原子變量:
public class AtomicCount {public AtomicInteger count = new AtomicInteger(0);static class Job implements Runnable{private AtomicCount count;private CountDownLatch countDown;public Job(AtomicCount count,CountDownLatch countDown){this.count = count;this.countDown = countDown;}@Overridepublic void run() {boolean isSuccess = false;while(!isSuccess){int countValue = count.count.get();isSuccess = count.count.compareAndSet(countValue, countValue + 1);}countDown.countDown();}}public static void main(String[] args) throws InterruptedException {……} }通過AtomicInteger的compareAndSet方法,只有當假定的count值與實際的count值相同時,才將加1后的值賦值回去,避免多線程環境下變量值被并發修改而導致的數據紊亂。
通過查看AtomicInteger的compareAndSet方法的實現,可以發現它通過調用Unsafe對象的native方法compareAndSwapInt,來完成原子操作[2]:
public final boolean compareAndSet(int expect, int update) {return unsafe.compareAndSwapInt(this, valueOffset, expect, update); }而native方法compareAndSwapInt在Linux下的JDK的實現如下[3]:
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))UnsafeWrapper("Unsafe_CompareAndSwapInt");oop p = JNIHandles::resolve(obj);jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);return (jint)(Atomic::cmpxchg(x, addr, e)) == e; UNSAFE_ENDUnsafe_CompareAndSwapInt最終通過Atomic::cmpxchg(x, addr, e)來實現原子操作,而Atomic::cmpxchg在x86處理器架構下的Linux下的JDK實現如[4]:
inline jint Atomic::cmpxchg(jint exchange_value, volatile jint* dest, jint compare_value){int mp = os::is_MP();__asm__ volatile(LOCK_IF_MP(%4) "cmpxchgl %1,(%3)": "=a" (exchange_value): "r" (exchange_value),"a" (compare_value),"r" (dest),"r" (mp): "cc", "memory");return exchange_value; }通過os::is_MP()判斷當前系統是否為多核系統,如果是,在執行cmpxchgl指令前,先通過LOCK_IF_MP宏定義將CPU總線鎖定,這樣同一芯片上其他處理器就暫時不能通過總線訪問內存,保證了該指令在多處理器環境下的原子性。而cmpxchgl指令,則先判斷eax寄存器中的compare_value變量值是否與exchange_value變量的值相等,如果相等,執行exchange_value與dest的交換操作,并將exchange_value的值返回。其中,“=a”中=表示輸出,而“a”表示eax寄存器,變量前的“r”表示任意寄存器,“cc”表示告訴編譯器cmpxchgl指令的執行,將影響到標志寄存器,而“memory”則是告訴編譯器該指令需要重新從內存中讀取變量的最新值,而非使用寄存器中已經存在的拷貝。
最終,JDK通過CPU的cmpxchgl指令的支持,實現AtomicInteger的CAS操作的原子性。
另一種情況便是數據庫的事務操作,數據庫事務具有ACID屬性,即原子性(Atomic)、一致性(Consistency)、隔離性(Isolation)、持久性(Durability),為針對數據庫的一系列操作提供了一種從失敗狀態恢復到正常狀態的方法,使數據庫在異常狀態下也能夠保持數據的一致性,且面對并發訪問時,數據庫能夠提供一種隔離方法,避免彼此間的操作互相干擾。
數據庫事務由具體的DBMS系統來保障操作的原子性,同一個事務當中,如果有某個操作執行失敗,則事務當中的所有操作都需要進行回滾,回到事務執行前的狀態。導致事務失敗的原因有很多,可能是因為修改不符合表的約束規則,也有可能是網絡異常,甚至是存儲介質故障等,而一旦事務失敗,則需要對所有已作出的修改操作進行還原,使數據庫的狀態恢復到事務執行前的狀態,以保障數據的一致性,使修改操作要么全部成功、要么全部失敗,避免存在中間狀態[5]。
為了實現數據庫狀態的恢復,DBMS系統通常需要維護事務日志以追蹤事務中所有影響數據庫數據的操作,以便執行失敗時進行事務的回滾。以MySQL的innodb存儲引擎為例,innodb存儲引擎通過預寫事務日志[6]的方式,來保障事務的原子性、一致性以及持久性。它包含redo日志和undo日志,redo日志在系統需要的時候,對事務操作進行重做,如當系統宕機重啟后,能夠對內存中還沒有持久化到磁盤的數據進行恢復,而undo日志,則能夠在事務執行失敗的時候,利用這些undo信息,將數據還原到事務執行前的狀態。
事務日志可以提高事務執行的效率,存儲引擎只需要將修改行為持久到事務日志當中,便可以只對該數據在內存中的拷貝進行修改,而不需要每次修改都將數據回寫到磁盤。這樣做的好處是,日志寫入是一小塊區域的順序I/O,而數據庫數據的磁盤回寫則是隨機I/O,磁頭需要不停地移動來尋找需要更新數據的位置,無疑效率更低,通過事務日志的持久化,既保障了數據存儲的可靠性,又提高了數據寫入的效率。
多線程同步
同步的意思就是協同步調,按照預定的先后次序來執行,多線程同步指的是線程之間執行的順序,多個線程并發既訪問和操作同一數據,并且執行的結果與訪問或者操作的次序有關,表示線程間存在競爭關系,為了避免這種競爭導致錯誤發生,我們需要保證一段時間內只有一個線程能夠操作共享的變量或者數據,而為了實現這種保證,就需要進行一定形式的線程同步。對于線程中操作共享變量或者數據的那段代碼,我們稱為臨界代碼段。對于臨界代碼段來說,有一個簡單易用的工具——鎖,通過鎖的保護,可以避免線程間的競爭關系,即一個線程在進入臨界代碼段之前,必須先獲得鎖,而當其退出臨界代碼段的時候,則釋放鎖給其他線程。
還是前面Count計數的例子,通過在Java中使用synchronized關鍵字和鎖,實現線程間的同步:
public void run() {synchronized(count){count.count++;}…… }通過synchronized,能夠保證同一時刻只有一個線程修改count對象。synchronized關鍵字在進過編譯之后,會在同步塊的前后分別形成monitorenter和monitorexit這兩個字節碼指令:
…… 6: monitorenter 7: aload_0 8: getfield 11: dup 12: getfield 15: iconst_1 16: iadd 17: putfield 20: aload_1 21: monitorexit ……加入關鍵字后,run()方法反編譯成的字節碼如上所示,monitorenter和monitorexit這兩個字節碼都需要一個引用類型的參數,來指明鎖定和解鎖的對象,如果synchronized明確指定了對象參數,那鎖的對象便是這個傳入的參數,假如沒有明確指定,則根據synchronized修飾的是實例方法還是類方法,找到對應的對象實例或者對應類的Class對象來作為鎖對象。在執行monitorenter指令時,首先要嘗試獲取對象的鎖,如果這個對象沒有被鎖定,或者當前線程已經擁有了該對象的鎖,則將鎖的計數器加1,相應的,在執行monitorexit指令時,鎖的計數器將會減1,當計數器為0時,表示鎖被釋放。如果獲取對象的鎖失敗了,則當前線程需要阻塞等待,直到對象的鎖被釋放為止。
另一種方式是使用ReentrantLock鎖,來實現線程間的同步。在Count對象中加入ReentrantLock的實例:
private final ReentrantLock lock = new ReentrantLock();然后在count.count++之前加鎖,并且,++操作完成之后,釋放鎖給其他線程:
count.lock.lock(); count.count++; count.lock.unlock();這樣,對于count.count變量的操作便被串行化了,避免了線程間的競爭。相對于synchronized而言,使用ReentrantLock的好處是,ReentrantLock的等待是可以中斷的,通過tryLock(timeout, unit),可以嘗試獲得鎖,并且指定等待的時間。另一個特性是可以在構造ReentrantLock的時候使用公平鎖,公平鎖指的是多個線程在等待同一個鎖時,必須按照申請鎖的先后順序來依次獲得,synchronized中的鎖是非公平的,默認情況下ReentrantLock也是非公平的,但是可以在構造函數中指定使用公平鎖。
對于ReentrantLock來說,還有一個十分實用的特性,它可以同時綁定多個Condition條件,以實現更精細化的同步控制:
class BoundedBuffer {final Lock lock = new ReentrantLock();final Condition notFull = lock.newCondition(); final Condition notEmpty = lock.newCondition(); final Object[] items = new Object[100];int putptr, takeptr, count;public void put(Object x) throws InterruptedException {lock.lock();try {while (count == items.length)notFull.await();items[putptr] = x;if (++putptr == items.length) putptr = 0;++count;notEmpty.signal();} finally {lock.unlock();}}public Object take() throws InterruptedException {lock.lock();try {while (count == 0)notEmpty.await();Object x = items[takeptr];if (++takeptr == items.length) takeptr = 0;--count;notFull.signal();return x;} finally {lock.unlock();}} }這是Oracle官方文檔中所提供的關于Condition使用的一個經典案例—有界緩沖區[7]。notFull(非滿)和notEmpty(非空)兩個條件與鎖lock相關聯,當緩沖區當前處于已滿狀態的時候,notFull條件await,執行put操作的當前線程阻塞,并且釋放當前已獲得的鎖,直到take操作執行,notFull條件signal,等待的線程被喚醒,等待的線程需要重新獲得lock的鎖,才能從await返回,而當緩沖區為空的時候,notEmpty條件await,執行take操作的當前線程阻塞,并且釋放當前已經獲得的鎖,直到put操作執行,notEmpty條件signal,執行take操作的線程才能夠被喚醒,并且需要重新獲得lock的鎖,才能夠從await返回。
數據一致性
分布式系統常常通過數據的復制來提高系統的可靠性和容錯性,并且將數據的副本存放到不同的機器上,由于多個副本的存在,使得維護副本一致性的代價很高。因此,許多分布式系統都采用弱一致性或者是最終一致性,來提高系統的性能和吞吐能力,這樣不同的一致性模型也相繼被提出。
分布式系統中采用最終一致性的例子很多,如MySQL數據庫的主/從數據同步,ZooKeeper的Leader Election和Atomic Broadcas等。
系統可擴展性
系統的可擴展性也稱為可伸縮性,是一種對軟件系統計算處理能力的評價指標,高可擴展性意味著系統只要經過很少的改動,甚至只需要添加硬件設備,便能夠實現整個系統處理能力的線性增長。單臺機器硬件受制于科技水平的發展,短時間內的升級空間是有限的,因此很容易達到瓶頸,且隨著性能的提升,成本也呈指數級升高,因此可擴展性更加側重于系統的水平擴展。
大型分布式系統常常通過大量廉價的PC服務器,來達到原本需要小型機甚至大型機的同等處理能力。進行系統擴展的時候,只需要增加相應的機器,便能夠使性能線性平滑提升,達到硬件升級同等的效果,并且不會受制于硬件的技術水平。水平擴展相對于硬件的垂直擴展來說,對于軟件設計的能力要求更高,系統設計更復雜,但是卻能夠使系統處理能力幾乎可以無限制擴展。
系統的可擴展性也會受到一些因素的制約,CAP理論指出,系統的一致性、可用性和可擴展性這三個要素,對于分布式系統來說,很難同時滿足的,因此,在系統設計的時候,往往得做一些取舍。某些情況下,通過放寬對于一致性的嚴格要求,以使得系統更易于擴展,可靠性更高。
下面將介紹一個典型的案例,通過在數據一致性、系統可用性以及系統可擴展性之間找到平衡點,來完成瞬間高并發場景下的系統設計。
并發減庫存
大部分電商網站都會有這樣一個場景——減庫存。正常情況下,對于普通的商品售賣來說,同時參與購買的人數不是很多,因此,問題并不那么明顯,但是,對于像秒殺活動,低價爆款商品,抽獎活動這種并發數極高的場景來說,情況便顯得不同了。比如在活動開始的瞬間,用戶的下單和減庫存請求將呈爆炸式增長,瞬間的qps可達平時的幾千倍,這將對系統的設計和實現帶來極大的挑戰。
首先要解決的問題,便是杜絕網絡投機者使用工具參與秒殺導致的不公平競爭行為,讓競爭變得公平。而防止機器請求最原始最簡單也是最有效的方式,便是采用圖像驗證碼,用戶必須手工輸入圖片上的字符,才能夠進行后續操作,當然,隨著技術的發展,簡單圖像也能夠進行識別,因此,驗證碼技術也在不斷演進,為了防止圖像識別技術識別驗證碼字符,可以采用問答式的驗證碼,如“1+1=?”這樣,即便是識別驗證碼上的字符,也無法自動識別答案。當然,驗證碼并非是一個完美的解決方案,它會導致系統的易用性降低,用戶體驗因此而下降。
其次要解決的便是數據一致性的問題,對于高并發訪問的瀏覽型系統來說,單機數據庫如不進行擴展,往往很難支撐,因此,常常會采用分庫技術,來提高數據庫的并發能力,并且通過使用分布式緩存技術,將磁盤磁頭的機械運動,轉化為內存的高低電平,以降低數據庫的壓力,加快后端的響應速度,響應得越快,線程釋放也越快,能夠支持的單位時間內的查詢數也越高,并發的處理能力就越強。使用緩存和分庫技術,吞吐量的確是上去了,帶來的問題便是,跨數據庫或者是分布式緩存與數據庫之間,難以進行事務操作,由于下單和減庫存這兩個操作不在同一個事務當中,可能導致的問題便是,有可能下單成功,庫存減失敗,導致“超賣”的現象發生,或者是下單失敗,而減庫存成功,而導致“少賣”的現象,并且,在超高并發的情況下,導致這種失敗的概率較往常更高,如圖1所示。
圖1 “超賣”和“少賣”現象為了避免數據不一致的情況發生,并且,保證前端頁面能夠在高并發情況下正常瀏覽,可以采用實際庫存和瀏覽庫存分離的方式。由于前端頁面驗證碼以及下單系統的限流保護,因此,真正到達后端系統下單的流量并沒有前端瀏覽系統的流量大,因此,可以將真實的庫存保存在數據庫,而前端瀏覽的庫存信息存放于緩存,這樣,數據庫下單與減庫存兩個動作,可以在同一個事務當中執行,避免出現數據不一致的情況,庫存更新完畢以后,再將數據庫中數據同步到緩存。
實際庫存與瀏覽庫存分離之后,雖解決了數據不一致的問題,但這一措施將引入新的問題。商業數據庫如Oracle由于擴展成本太高,大部分互聯網企業轉而選用開源的MySQL數據庫,MySQL根據存儲引擎的不同,采用不同的鎖策略。MyISAM存儲引擎對寫操作采用的是表鎖策略,當一個用戶對表進行寫操作時,該用戶會獲得一個寫鎖,寫鎖會禁止其他用戶的寫入操作。InnoDB存儲引擎采用的則是行所策略,只有在對同一行進行寫入操作的時候,鎖機制才會生效。顯而易見,InnoDB更適合于高并發寫入的場景。
那么,采用InnoDB存儲引擎,對于高并發下單減庫存的場景,會帶來什么問題呢?每個用戶下單之后,需要對庫存信息進行更新,對于參與秒殺的熱門商品來說,大部分更新請求最終都會落到少量的幾條記錄上,而行鎖的存在,使得線程之間需要進行鎖的爭奪,一個線程獲得行鎖以后,其他并發線程就需要等待它處理完成,這樣系統將無法利用多線程并發執行的優勢,且隨著并發數的增加,等待的線程會越來越多,rt急劇飚升,最終導致可用連接數被占滿,數據庫拒絕服務。
既然記錄的行鎖會導致無法并發利用資源的問題,那么,可以通過將一行庫存拆分成多行,便可以解除行鎖導致的并發資源利用的問題。當然,下單減庫存操作最終路由到哪一條記錄,可以采用多種策略,如根據用戶ID取模、隨機等,總的庫存通過SUM函數進行匯總,再同步到緩存,給前端頁面做展現,以降低數據庫的壓力。
圖2 庫存記錄拆分,sum取總數當然,這樣也會導致另外一些問題,當總庫存大于0的時候,前端的下單請求,可能剛好被路由到一條庫存為0的記錄,導致減庫存失敗,而實際此時還有其他記錄的庫存不為0。
【參考資料】
總結
以上是生活随笔為你收集整理的电商系统的高并发设计和挑战的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 高可靠芯片搭配视觉演算法,影像式ADAS
- 下一篇: 聊天机器人中的深度学习技术(引言)