目錄
事件和狀態
什么是狀態?
狀態就是歷史事件的影響累加,在某一個時間點上的數據快照。
由於一系列的事件的觸發,這些事件按照序列(發生時間、先后順序)影響並改變了數據當前的結果(快照)。
這些事件對數據的影響可能是局部的(比如用戶的操作,可能影響范圍只被限定在這個玩家的數據邊界內),也有可能是全局的(比如系統的操作、規則的變化,影響范圍可能就是所有的玩家)。
無論如何,狀態不過是從初始值,不斷的應用事件,逐步演變到某一個事件序列的快照,他是那個序列點的計算結果。
一般我們談論狀態的時候,如果沒有特別指明,那個時間序列點就是現實世界中的現在進行時,比如當我打字寫到這段話的時候(2019年09月21日04:18:23),那么我目前所能觀察到的所有狀態,都是基於宇宙大爆炸到現在為止無數事件累計影響的結果。
歷史無法改變,一眨眼,瞬息之間,天地萬物已然與前一刻有所不同,無論如何反抗,都無法重來。
我時常在想,現實宇宙是多么的強大,能夠容納不計其數的原子、粒子進行不斷的組合和運動。
我並不是一名物理學家,如果把整個宇宙當做是一個容器,這個容器內的某一個(空間)點上,原子、粒子相互運動產生影響,隨着(時間)的推移,逐步影響擴散到整個容器范圍內的所有元素運動軌跡。
換句話來講,在上面的比方中,當宇宙中的某一點,在某一個時刻產生了一個事件,時間會把它的影響擴散到整個宇宙,更不用說,每時每刻,在每一個點上都在產生這種影響。
所以我們不可能預測到下一個時間點,它們的狀態和現在的狀態究竟有什么不同,短時間內的宏觀預測是相對准確的,這種准確的來源,其實就是短時間內,它所帶來的影響還沒有擴散到我們無法想象的程度,也是我們從小到大,成長給我們帶來的“經驗”,但是隨着時間范圍的拉大,更多的事件參與其中,我們便再也無法得知到那時究竟會怎樣?這就是所謂的蝴蝶效應。
一致性
在上面的比喻中,我講到,每個人都是從嬰兒、小孩逐步成長到現在,我們相信成長給我們帶來的“經驗”,我們相信這些經驗給我們帶來的感覺,那種感覺使我們能夠在我們的觀察中,從容應對,以及預測短時間內所要發生的一系列事情(狀態預測、基於目前的快照)。
想象一下,如果我們正在吃飯,一閉眼,一睜眼,發現自己突然出現在了珠穆朗瑪峰的峰頂,物理精神病患者(腦部物理損傷,傻子)可能會吹着刺骨的寒風,遠眺喜馬拉雅山脈。但作為正常人的我們來講,可能都會認為自己是在做夢,畢竟他不符合我們的感覺,也就是不符合我們的認知(一致性)。
所以究竟什么是一致性?它是對歷史的總結,對經驗的累積,對訓練的結果產生一致的期待。
一致性使我們的生活更加的平滑、簡單。
就好比是做UI交互設計,為什么風格、元素以及交互的模式都要保持相對的統一?首先它減少了重復的工作量,最為重要的是,用戶在使用一段時間過后,它就能夠適應你的模式,對你的模式產生了一致性的認知,無論我們進行怎樣的組合,哪怕沒有教用戶怎么使用一個新的UI界面,他們也能從之前的經驗中得到可能的答案。
所以,一致性使人們能夠對這些模式產生總結,使人們能夠更加輕松的,而更加高效的使用我們的產品。
再來講狀態的一致性,我們就知道它所要求的具體是什么了,那就是某一個時間點的狀態(快照),它必須符合我們的預期,這個預期的來源是我們相對於上一個時間點的狀態觀察,執行了新的操作(事件),所預測的合理結果。
在這里,時間序列是其中非常重要的一個因素,當然我們也可以理解為先后順序,我們不希望在下一秒突然站在珠穆朗瑪峰的峰頂,自然也不希望,我的錢在下一秒就突然消失不見了。
一致性級別
大多數時候,我們並不是在造火箭,出於性能的考慮以及業務上天然的規則和要求,對於一致性的要求也是不盡相同。
我們可以簡單的將一致性級別划分為兩個大類,“強一致”和“最終一致”,那么它們之間有什么區別呢?
強一致
我們首先說強一致,很多用過關系型數據庫系統(RDBMS)的同學都知道,基本上絕大多數(如MySQL,取決於存儲引擎)數據庫都會提供事務來保證ACID,我們單獨來看一下ACID的說明:
- (Atomicity)原子性:一個事務內,可能修改了多張表的多行數據,在事務提交的時候,要么全部成功,要么全部失敗,不存在中間狀態(數據被破壞)。
- (Consistency)一致性:這東西一般用於數據庫校驗數據的狀態,比如外鍵、約束(唯一、主鍵),但是數據是否一致,基本上是依賴於我們的代碼是否正確的應用了數據所代表的業務規則,也可以理解為,一致性就是我們需要在應用程序內需要進行保障的,數據庫的一致性僅僅只適用於數據庫系統本身對數據的一些校驗。
- (Isolation)隔離性:隔離多個事務之間相互的影響,主要是為了進行並發控制,將每一個事務所訪問的資源進行隔離,不同的隔離級別說明了業務規則所需要的並發控制策略。聽起來有點暈對不對?沒關系,我們只需要知道,隔離性一般用於讀寫競爭資源的時候,數據庫如何進行處理。而且,隔離級別的使用,取決於我們是否希望操作到其他事務也可能操作的數據,也就是說,這玩意兒跟業務有關系,業務規則如果清晰,我們就能進行良好的定義,費這么多事是因為不同的隔離級別對於性能的影響,對於業務的需要是存在差異的。如果仍然感覺有點頭暈,簡單點,那就是一句話,隔離性用於並發控制。
- (Durability)持久性:這個沒啥好說的,事務一旦提交,除非后續事務再次提交,否則對當前的狀態快照永久有效。
可能很多人瞟一眼上面的ACID說明列表,就直接關掉了,哈哈。
不過說實在的,這東西每隔一段時間看一遍也確實煩人,關鍵是看了這么多遍,如果還沒有理解ACID,那就會越來越煩,這樣,當我們又一次看到這種東西的時候,直接扭頭關瀏覽器了,非常影響心情。
言歸正傳,ACID當中,個人認為最重要的就是原子性和隔離性。
原子性保證了狀態在任何時候,任何一個快照中,都是正確的。這個正確,是指狀態在被提交的時候,經過了應用程序業務代碼的校驗,是符合業務規則預期的,也就是一致的。
試想一下,如果沒有原子性,當事務提交后,一部分數據修改了,另一部分數據還沒有修改,接下來的事務如果訪問了還未修改的數據,並以此產生了新的錯誤的數據,那就直接破壞了數據的一致性要求。
隔離性則保證了,在事務對這些狀態進行操作(讀或寫)的時候,確保不會讀取了我們不希望讀取的數據。
什么意思呢?舉個例子,當並發的時候,兩個事務對同一個狀態進行訪問、修改,如何確保一個事務的訪問和修改不會影響到另一個事務?
答案並不是統一的,這取決於設置的隔離級別,一些業務場景可能允許訪問另一個事務已修改,但是還未提交的狀態,這就是讀未提交(Read uncommitted),一般應用於日志、或者應用程序的設計確保了數據的邊界(數據被天然的區分開來了)不會發生此類問題。
而大多數情況下,我們都需要確保在事務的操作過程中,不會被其他的事務所影響,比如可重復讀(Repeatable reads),或者讀已提交(Read committed)的隔離級別更能保證數據在事務操作過程中,不會引入不一致的數據,從而造成當前事務的錯亂。
另一個比較極端的隔離級別是串行化,也有稱序列化(Serializable),一般是用悲觀鎖,好的系統設計不會使用這個隔離級別,原因就是並發能力不行,鎖的開銷比較大,一旦檢測到多個事務存在寫沖突,那么只有一個事務能允許被提交。
我們來總結一下,強一致是為了解決什么問題而存在的?
強一致為我們提供了一種通用的技術模型,那就是事務,無論我們是什么業務,終究離不開對狀態的管理,也就是數據,RDBMS中的ACID事務是一種通用的解決方案,它對大多數的應用場景提供了不同的隔離級別,用以控制並發的時候,怎么樣才能保證數據的一致性。
隔離級別的選取,關系到並發能力,現如今的硬件發展已經足以應對大規模的並發挑戰,但終究單機的容量有限的,是存在天花板的。或許你可以說,我們可以使用分布式事務啊,比如JTA,它一樣提供了ACID的保證。
朋友,且聽我慢慢道來,為什么不推薦使用分布式事務?
我時常在一些技術群里,見到很多同學在問,如何在兩個數據庫之間使用事務,我也是從新手一步一步走過來的,因此非常能夠理解,這個問題背后所隱含的知識面,究竟有多么的寬廣,如果沒有對這些知識面有一個大致的了解,出現這個問題也很常見,當然也很頻繁。
如果我們能夠換一個角度來看待這個問題,就能夠發現,究竟是哪里出問題了?
首先,當我們使用單機ACID事務的時候,很Happy,不用擔心業務的變化而導致數據一致性的問題,這是因為這項技術是通用的,可以適用於任何的業務。
當我們從單機變為多機的時候,也就是跨數據庫的時候,為什么問題就出現了呢?此時我們仍然可以使用支持XA接口的數據庫來進行分布式事務的管理(JTA),久而久之,我們可能會發現,事務並發能力並沒有因為多個數據庫存在而提高了效率,反而比單機還要低,這讓我們終於發現了,並發能力依賴的並不是你有多少個數據庫。
問題在於,數據庫通信需要耗時,網絡也是不穩定的,多個數據庫協調所占據的網絡IO消耗了大量的時間。
那么,有沒有其他的辦法可以解決呢?
朋友,還沒有意識到問題所在嗎?我們嘗試把一切都交給數據庫,期望數據庫能夠達到我們的預期,期望能夠使用一套通用的技術模型來實現我們的要求,但是老鐵,數據庫哪有這么聰明?
對於數據的存儲和結構關系的設計,如果我們沒有進行規划,沒有進行業務上的分區,沒有一個全面的掌握……如果我們自己都不了解我們的數據有哪些特征和要求,我們又如何期望數據庫能夠理解這些數據的存取要求並達到最大化的利用呢?
當然,在開發期間,業務原型還沒有確定的時候,我們想怎么玩都可以,不進行設計也是很正常的。
我們不能把所有的東西都交給數據庫,讓數據庫來解決一切事情,至少在產品已經相對穩定,數據規模已經開始有所上升的時候,我們就得思考一下這些該怎么存儲這些數據才能最大化的保障業務的發展。
以上就是強一致描述,接下來,再來看另一類一致性,那就是最終一致,由我們自己來設計數據的一致性,並實現它的要求。
最終一致
讓我們來思考一下,狀態一致性它究竟解決的是什么問題? 我們在上面討論過關於一致性它到底是什么,但是在咱們日常編程進行狀態維護的過程中,它究竟有什么用?
如果說一致性它本質上是為了保證符合預期,那么這個預期我們可以分為兩個層面:
- 邏輯層面:對於業務邏輯,我們編寫代碼對輸入(請求)進行一系列的校驗、轉換。成功之后,我們就會進行輸出(狀態存儲),並且狀態的完整性和一致性都符合咱們的預期。
- 技術層面:對於需要修改的狀態,我們需要保證這些狀態不會因為並發問題而造成數據的不一致。比如兩個輸入同時對一個狀態進行操作,造成了資源競爭,其中一個輸入肯定是要失敗的,因為提交的時候只有一個會成功。
我們所面臨的問題就在於並發,並發對同一個資源進行修改,注意,是“同一個資源”,無論如何結局肯定是只有一個會成功,其余的都將會失敗。
有點像咱們輸出日志的時候,如果同時輸出多個日志內容到同一個文件或者標准輸出,那么日志里面的內容就有可能是混亂的,這造成了不一致。
事情總有個先后順序,我們可以“同時對不同的”資源進行修改,但是卻不能“同時對一個”資源進行修改。
現實生活中好像我們並不會遇到這種問題,例如,我可以跟我女朋友同時吃一塊蛋糕。
“這完全是兩碼子事!”你的內心或許是這樣想的,但是請讓我打個比喻。
這里的一塊蛋糕只是我們邏輯上認為的它是一塊,但是我可以把它分為兩塊,當我把它分為兩塊之后,之前的那一塊還存在嗎?換句話來說,這個世界是連續的還是離散的?時間是連續的還是離散的?我們根本無法得知。
或許當我咬下去的那一瞬間,我的女朋友還沒有咬下去,如何測量我跟我女朋友是同時咬下去的?要精確到哪個小數點后才能認定?“同時咬下去”這個問題對於我女朋友來講,當然是無關緊要的,她在乎的是有沒有吃到蛋糕,而不是誰先吃的。
再來,假如我是一個超人,我在我女朋友咬下去的那一瞬間,先把她的那一塊給吃了,那么她當然只能吃空氣了,這個時候她會認為不一致了。
所以不可能我吃了之后她還能吃到原本她能吃到的那一塊蛋糕,只是我動作太快了,她的預期應該是可以吃到的。這個時候實際上就是並發沖突了,后果當然是等着被挨打,但是宇宙並不會因為這個沖突而崩潰。
所以在業務邏輯上認為的一致性,並不會去糾結這些小細節。
我們認定發生了不一致,只有在我們發現不符合我們的預期的時候,才能給它下一個定義,說它不一致了。
不一致反映出來的問題是什么?有可能是業務邏輯設計上的缺陷,也有可能是我們的編碼問題而造成的,這是在兩個層面上看待問題。
所以不同層面反映出的問題,它的解決方法是不一樣的,我們沒有辦法通過技術去解決原本在邏輯上就有缺陷的問題。
在之前強一致小節中我曾說過,強一致試圖使用一個統一的抽象概念“事務”來解決一切問題,但是具體應用到不同的場景上,並發性能並不樂觀。
但是使用事務本身並沒有錯,錯的是我們沒有經過思考,沒有經過設計就試圖靠它來解決一切問題,它或許可以解決問題,但並不是最好的解決方案。
最好的解決方案往往取決於你的系統設計和業務模式是否做到了良好的匹配,就像我上面那個例子,如果發生了不一致該怎么辦?如果我的女朋友性格好的話,她或許不會深究,我也會補償給她一塊新的蛋糕。
這就是最終一致,宇宙並不會因為她不願意就崩潰,她的反應早已被上帝設計好,出現任何情況都能得到良好的補償,只是世間萬物發展的一個小插曲而已。
至於影響力有多大?可能由於這個事件以后,她跟我分道揚鑣,在一段時間過后我也許也會跟她重新復合,但這誰又能知曉呢?
現實世界我們無法預測和預先設計,但是對於我們的業務系統來講,我們是可以做到預先的設計和補償處理的。接下來我們就來講一講,什么情況下?我們才會認為發生了不一致。
什么是不一致?
注意,我們討論什么是不一致的時候,討論的層面是在技術層面,而非業務層面。
業務層面的邏輯是否合理取決於各自的應用場景,是需要根據各自領域規則來進行抉擇的,我在其中會舉幾個簡單的例子,但是請勿對號入座,將它們直接拿來用。
在討論什么是不一致的時候,我們需要牢記,這些問題都是處於並發情況下才會出現的,因為一致性問題主要就是由於並發操作所產生的狀態錯亂。
並發讀取和寫入之間的時間窗口重疊關系
現在,假設我有100塊錢(好古典的例子),讓我們來根據事件的先后順序梳理一下發生了什么:
- A問我有多少錢,我告訴他我有100塊,A走開了。
- B問我有多少錢,我告訴他我有100塊,B也走開了。
- A走過來跟我說,就在剛才,我幫你辦理了一個全套大寶劍業務,這是發票,你要付60塊錢(我有答應嗎?這么便宜的確定沒問題嗎?發票都有??),好的吧,現在我只剩下40塊錢了。
- B走過來跟我說,就在剛才,我幫你辦理了一個全套大寶劍業務,這是發票,你要付60塊錢,我告訴他我堅決不會掏這60塊錢,因為我只剩下了40塊錢,怎么辦?B只能拿着發票去辦理退標手續,邊走還編發牢騷,你明明剛才就有100塊,搞得我現在回扣都拿不了。
其中1和2的步驟,我們可以理解為同時發生的,但是3和4,A早來一步,B只能去退標了。
這看起來沒有什么問題,因為按照順序,40塊錢確實沒有辦法支付60塊的賬單,因為按照世界的運轉規則,我不能負債(業務規則,錢不能小於0),所以B失敗了,回滾了剛剛辦理的業務操作,世界仍然是一致的,這很美好。
但是我們在編程的過程中,一般使用的是賦值,而不是現實生活中這么簡單,比如A知道我有100塊(做錢余額的校驗)然后直接賦值給我40塊,B也知道我有100塊(做錢余額的校驗,但在A賦值之前)然后也給我直接賦值了40塊,這樣就會產生一個問題,我只花了60塊錢就辦理了兩個全套大寶劍業務,但實際上兩個賬單加起來應該要付120塊的,類似下面的代碼:
1 function process(my) { 2 if (my.money < 60) return; 3 var receipt = dbj(my); 4 try 5 my.money = my.money - 60; 6 } catch (error) { 7 rollbackForDbj(receipt); 8 } 9 } 10 11 var my = {money: 100}; 12 // 定義setter,實現賦值邏輯。 13 defineSetter(my, 'money', function(newValue){ 14 if (newValue < 0) throw error('value cannot less than 0') 15 this.value = newValue 16 }); 17 A.ask(() => process(my)); // A 18 B.ask(() => process(my)); // B
代碼是什么語言就不要去糾結了哈,僅僅表明意圖,其中的A和B我們可以理解為兩個不同的主體(線程),並發執行了。
當執行到第5行的時候,問題就出現了,這是由於讀和寫的過程當中並不是按照順序來的,也就是說,B不是等A執行完之后才去執行。
理想狀態下,我們可能想要的是,當B讀取的時候,能夠得知我只有40塊,那樣的話就直接返回了,有的同學可能會說,我們可以加鎖。
但是我們不可能涉及到讀取的時候就加鎖,因為也有可能會有C、D來讀取從而去辦理其他的什么鬼業務去了,如果每一次讀取的時候就加鎖阻塞的話,效率未免也太低了。
這種問題出現的原因就是:並發讀取和寫入之間存在一個時間窗口,這個時間窗口可能每次都不一樣,有時候能夠一致,有時候不一致,當不一致出現的時候,實際上就重疊了。
也有同學可能會說,我們先把錢拿走,然后再去辦理業務,在邏輯上這樣處理可能是一種更好的辦法,但是細想這樣也會存在問題。
現實中我只能把錢交給一個人,因為錢只有一張。但是編寫程序並發執行的時候,A和B都可以拿到100塊這個狀態,然后給我進行“賦值”。
關鍵就在於並發寫的時候造成了沖突,實際上讀取的時候,我確實有100塊,但這並不代表,你去辦理業務或者找我拿錢的時候,我還有100塊。
既然加鎖並不是一個好主意(悲觀鎖),並且我們知道,只有當寫入的時候我們才需要保證一致性,強一致的解決方案有更好的做法,那就是使用版本號來保證(樂觀鎖)。
大體思路就是,我需要有一個方法,當你賦值的時候,你必須提供你讀取時候我給你的版本號,這樣我會對比我目前版本號和你提供的是否一致,如果是一致的,那么才允許賦值,並且更新我的版本號。
請注意,在這里,賦值和更新版本號是一個原子操作,要么同時成功,要么同時失敗,不存在賦值過后版本號還沒有更新的問題。
那么代碼就會變為如下的形式:
1 function process(my) { 2 var read = my.moneyWithVersion 3 if (read.value < 60) return; 4 var receipt = dbj(my); 5 try { 6 my.moneyWithVersion = {value: read.value - 60, version: read.version}; 7 } catch (error) { 8 rollbackForDbj(receipt); 9 } 10 } 11 12 var my = {moneyWithVersion: {value: 100, version: 0}}; 13 // 定義setter,實現賦值邏輯。 14 defineSetter(my, 'moneyWithVersion', function(newValue){ 15 if (newValue.value < 0) throw error('value cannot less than 0') 16 // 原子操作 17 atom(() => { 18 if (newValue.version == this.version) { 19 this.version = this.version + 1; 20 this.value = newValue.value; 21 } else { 22 throw error('Version is not match'); 23 } 24 }); 25 }); 26 27 A.ask(() => process(my)) // A 28 B.ask(() => process(my)) // B
由於atom這個神奇的函數確保執行的時候是原子操作,我先不去關心如何實現atom函數,當我們這樣做了之后,無論有多少個ABCD,當他們嘗試寫入狀態的時候,我們都會進行版本的確認,防止錯誤的寫入造成數據的不一致。
讀取的時候大家盡管讀取,因為我們關注的點在於並發寫入的時候可能會造成不一致的問題,所以這種做法被稱之為樂觀鎖,我們樂觀的認為,讀取不會對狀態有任何影響,只有當寫入的時候才做必要的校驗。
注意到我們的代碼拋出了一個錯誤,這個錯誤就是由於並發寫造成的,我們檢測到版本號不一致了,就可以確定目前的寫操作是沖突的。
我們必須要通知賦值者,你不能這樣做,好讓他妥善處理接下來的事宜(拿着發票憑證去退標,回滾操作)。
樂觀鎖適用的場景請根據自己的需要來進行適用,如果頻繁的讀取總是會修改數據,那么樂觀鎖可能反而比悲觀鎖的效率還要低。
事實就是如此,業務的場景決定了我們到底該使用什么樣的技術,這也是為什么老生常談,沒有最好的,只有最合適的。
面對現實而不要去鑽牛角尖才是處理問題的良好心態,如果業務允許,我們或許會進行延遲扣款,或許會有其他的業務流程來保證就算你錢不夠,仍然可以通過后續的還款來保證業務邏輯的一致性,這種最終一致的處理流程可能更加靈活,但是也更加的復雜。
多版本並發控制(MVCC)
這個技術一般被用於各種數據庫的並發控制實現,能夠提高數據庫系統的存取效率,它的思想就是我們並發讀取和修改都不會被加鎖,只有當我們提交的時候才會進行必要的檢查。
MVCC不會修改已有的狀態,相反,它創建一個新版本的狀態來替代舊版本的狀態,這樣舊版本的事務仍然讀取的是之前的狀態,實現了可重復讀的隔離級別。
有時間我會寫一篇專門的文章來討論MVCC的具體細節,實際上它並沒有一個標准,它只是一種控制並發情況下狀態變更的方法,每種實現都有可能根據自己系統的特性進行適配和概念轉換。
它跟樂觀鎖有異曲同工之妙,只不過它對應的還有一個事務的概念。
這里提一下是因為我們如果需要自己去實現軟內存事務(STM)的話,使用MVCC來實現就能達到一個很好的效果。
因為在游戲場景類(終於提到游戲了),很多時候我們對狀態的變更,並不會直接影響到底層存儲,而是對其狀態進行緩存,避免由於IO耗時而造成請求響應處理不及時。
而游戲的各模塊實現上,為了實現跨模塊調用鏈上的某一個模塊由於校驗失敗而造成這個鏈之前的調用已經對部分狀態產生的變更的情況下進行回滾,我們就需要使用事務來進行管理。
打個比方,假如說現在有一個業務操作,需要對模塊A,B,C進行調用,例如扣除100金幣(錢包模塊),然后在玩家的背包內增加一個道具庫存項(背包庫存模塊),最后增加玩家角色10點經驗值(角色模塊)。
對ABC三個模塊的調用,產生了3個狀態的變更,在變更之前,肯定是會有一系列的校驗操作的,比如錢包里面的錢夠不夠,背包庫存有沒有滿。
假如說在增加背包庫存的時候,由於玩家的背包已經滿了,無法再容納更多的物品,此時背包模塊就會拋出一個異常,但是我們已經扣了100個金幣了(錢包校驗通過,正常執行)。
這個業務操作我們可以看做為一次事務操作,事務需要保證原子性,那么B模塊失敗后,已經對A進行的操作應該被回滾(返還100金幣)。
可能有的小伙伴會說,我們可以在業務A里面先去判斷所有的校驗條件是否滿足,然后再去執行相應的操作,比如先判斷錢包余額夠不夠,在判斷背包是否還有庫存容量。
我們先不講這樣做了之后,會不會存在什么問題,就單單為每一個業務去判斷每一個模塊是否滿足特定的條件就已經很傷腦筋了,會存在大量的校驗代碼和額外的模塊方法,破壞了模塊本身自己的業務邏輯封裝。
而且,對於稍微大一點的業務操作,比如十連抽這種循環操作,進行十次我們上面講到的業務,如果在第九次由於校驗失敗而終止了,這種情況我們是不是也需要提前將循環次數代入我們的校驗計算呢?
最致命的是,如果存在並發,那么這種提前校驗是沒有任何意義的,有可能成功,也有可能失敗,原因是因為並發本身就是不確定的一件事情。
如果使用鎖來實現,那么又回到了我上面所講的那個例子的情況了,而如果這次你學到了,使用樂觀鎖,那么將會存在大量的回滾代碼,對於每一個狀態操作,可能都會存在一個對應的undo(撤銷)操作,而且在撤銷操作期間,也有可能會存在並發情況,導致中間狀態被讀取,從而產生了不一致的數據。
面對無法確定的、大量業務交叉調用多個模塊且在並發的情況下,為了保證一致性,我們只能夠使用事務來實現,而如果使用數據庫的事務的話,就會存在網絡IO。
所以面對這種高規格一致性和速度響應比較敏感的場景,我們別無選擇,只能夠采用軟內存事務(STM),將狀態保持在進程內才能達到我們的響應需求。
我們這里假設的需求,規格級別算是很高的了,也有點極端,如果是非實時交互或者只是簡單的,對一致性要求並不是那么嚴格的場景,可以省略這一步。
順便提一下,很多游戲后端采用Redis的事務也能很好的工作,速度都還不錯,而且支持持久化,只是還是會存在網絡IO、序列化/反序列化等操作,不過這已經足夠滿足需求了,當然這取決於游戲類型。
你看我講了這么多,看來好像很復雜,難道就沒有一個比較簡單但可以用的框架可以給我使用嗎?
據我所知,函數式編程在這方面有天然的優勢,由於不變性,狀態是無法被修改,這樣我們管理狀態的時候就更加的方便直觀,例如Erlang,甚至有一個內建的分布式數據庫。
但這並不代表他們不需要關心此類問題,量變導致質變,事實上對於咱們做技術的來講,除了實現需求以外,我們始終也要去關心業務需求,因為只有我們自己弄清楚業務以后,我們才能給出更好的建議以及更好的軟件適配。
這里沒有銀彈。
一致性級別對應的場景
最后,總結一下游戲內不同的狀態類型對應於一致性級別所使用的場景,可能列出的具體場景並不是很全面,取決於業務需求:
- 資產狀態:這種類型的狀態,如果發生不一致,玩家可以明顯的察覺到,比如背包內的物品,錢包里面的貨幣數量,任務的完成條件,角色的等級與技能等等,對於這種狀態,我們必須要保證他們的一致性,否則出現問題可能會面臨業務投訴。
- 場景狀態:這種類型的狀態,比如MMORPG游戲一般都會有地圖場景,狀態的變更相對頻繁。比如玩家的位置坐標、BUFF、NPC、怪物等等,一般取決於最后一個事件的影響,狀態的時效性也比較短。這種狀態一般短暫的不一致是可以容忍的。
- 日志狀態:這種類型的狀態,比如聊天、公告,玩家的操作日志等等,就算丟失也沒什么太大的問題,幾乎不存在一致性的要求,只需要如實的進行持久和廣播同步。
我們可以發現,對於不同的狀態類型,他們的一致性級別需求是不一樣的,有的不允許出現不一致,有的卻對此沒有特別要求。
我們討論了這么多,對於一致性總算是有一個比較深入的認識了,要知道,一致性是為了解決並發問題,只要不存在並發,我們的任務就會輕松很多。
但是很明顯,為了系統可用性,並發又不得不存在。幸運的是,我們可以進一步去分析,並發與狀態之間的關系。
通過分析,我們能夠更加深入的了解需求,從而在某些方面給予反饋,通過合作溝通的方式,我們可以在保證需求得以實現的同時,提高系統一致性和並發能力。
接下來,我們就來討論一下並發和狀態之間的關系,只有當我們梳理好狀態,並對其進行隔離,才能最大化的提升並發能力。
並發以及隔離
並發,更准確一點的說應該叫並行,只不過前者表示邏輯上的(1人做多件任務),后者表示物理上的(多人做多件任務)。
我並不想去爭論它們的區別是否有意義,當我們編程的時候,可能會在多核CPU上面跑,也可能只在單核CPU上來跑。
當我們編寫使用多線程、多進程甚至是不同主機上運行的程序的時候,它們都有一個共同點,那就是它們的運行順序並不受我們的控制,或者說,我們並不關心先后順序,它們都是分布式的。
我們無法說A一定要運行在B之前,B也一定要運行在C之前,除非我們有明確的意圖需要這么做,才需要引入額外的控制機制去編排執行流程。
但大多數時候,我們創建了一個線程去處理一個任務,是希望即將要處理的任務可以被分離出去,而不必阻塞當前的流程,這樣我們就可以繼續處理其他的任務。
為什么需要並發?
為什么要有並發?本質上是為了提高系統的可用性,合理的分配資源,利用多核CPU甚至多個服務器來水平拓展我們的處理能力。
如果我們的程序只有一個主線程,把所有的任務按順序依次執行,也就是說,同一時刻只能處理一件事情,那么我們的處理能力取決於單核CPU的核心頻率到底有多快。
不僅如此,當我們在任務中訪問了IO資源,比如磁盤、網絡,那么處理能力還將會受到這些因素的影響,也就是說有相當多的時間耗費在了阻塞等待的過程中。
必須要阻塞等待的原因是因為我們的代碼書寫都是按照先后順序同步執行的,接下來的代碼依賴於前面執行的上下文(變量、狀態)。
比如訪問數據庫,只有當我們拿到數據之后,接下來的代碼才能夠去處理,所以必須等待數據庫的響應。
在單線程模型中,這是最為致命的,我們有可能計算並沒有占用多少CPU資源,但是等待IO資源卻耗費了絕大多數的時間。
並發有什么問題?
如果我們的程序只運行在單核CPU上,並發的優勢並沒有那么明顯,但也絕對比單線程模型要好得多。
當我們等待阻塞的時候,當前線程的上下文得以保存,從而可以把時間用於執行其他的線程,等外部資源響應的時候,再切換回來,加載之前的上下文,繼續處理。
但是線程的切換並非沒有開銷,如果在單核CPU上面頻繁的切換線程,在進行不涉及IO阻塞的程序上,使用單線程反而速度要更快,操作系統在進程級別的管理上可以減少此類的切換開銷。
當然了,現如今硬件的提升,我們實際上很少會遇到這種情況。
在20年前,硬件資源匱乏的年代,一塊小小的硬盤能夠存儲幾個T的數據,在那個時候是想都不敢想的事情。
以前的內存也少的可憐,我們在進行開發時候必須要規划好內存的使用,考慮各種不同的情況,因為內存很容易被填滿而導致意外的錯誤。
操作系統抽象出來的虛擬內存可以緩解一部分問題,將一部分內存交換到空間更大的磁盤上,使我們的編程任務更加簡單,但這也是沒有辦法的事情,因為程序要求的內存使用量已經無法再精簡了。
而現如今,如果出現了內存磁盤交換的情況,我們會把它當做為一個異常,考慮是不是程序出現了內存泄露的問題?因為這樣會導致訪問速度變得很慢,而內存也不是非常珍貴的資源了。
雲主機的出現再一次降低了我們的成本,可以根據使用量來計費,用多少花多少,沒有浪費,進一步提高了資源的利用。
回歸主題,當我們將程序代碼並行運行,進行分布式開發的時候,會遇到哪些問題?
由於我們在進行代碼編寫的時候,往往是按照先后順序的思維模式來組織的,常常忽略了在分布式開發中可能會遇到的一些問題。
這些問題帶來了進一步的挑戰,往往使我們的代碼變得異常復雜,也無法很容易的像以前一樣從上看到下就能在腦海里模擬出大致的結果。
以前我們使用單線程進行編碼的編程模型被稱之為同步編程,而多線程則被稱之為異步編程(在異步編程中,使用聲明式的編程模型可能會更直觀)。
所以異步編程如果沒有一個良好的編程模型來組織我們的代碼的話,就會很容易發生各種各樣的錯誤,異步編程不僅使我們的代碼復雜度變高,最關鍵的,也讓我們的心智活動變得更加的復雜,所以往往會遺漏。
那么具體有哪些問題需要我們注意呢,我列出了一個列表:
- 狀態管理:由於並發,多個線程、進程試圖對同一個狀態進行修改,從而導致的一致性問題。
- 任務編排:由於並發,多個線程、進程可能會同時處理同一個任務,導致狀態的沖突,因此我們需要對任務進行編排,使其同時只能在一個線程、進程上執行。由於不同場景的需求存在差異,所以任務編排取決於業務上的需要。
- 負載分布:為了資源能夠被合理的利用,使其分布節點的負載差異減少,不至於出現某些服務負載高,而某些服務負載小,我們要對不同任務的資源進行合理的調度。這是因為資源如果沒有被合理的使用,就失去了並發的好處,我們始終希望能夠最大化的利用好資源,在成本范圍內提升處理能力,避免浪費,在分布式程序架構中,有不同維度的策略來組織資源的規划。
- 編程模型:我們需要一種清晰簡單的編程模型來應對異步編程的挑戰,需要組織代碼結構保持項目的可維護性。
- 架構調整:我不建議在項目之初,就把架構搞得很復雜,近幾年微服務備受關注,但是我見過的很多項目就是因為在項目之初就引入了大量沒有用到的(或者說沒有必要用到)技術,從而造成了項目依賴和復雜程度變得很高,開發和調試都是舉步維艱,最后不得不合並為單體架構,重新從業務角度出發去審視項目的需求規格。在我看來,如果僅僅只是技術層面“微服務”了,那只能算是一種部署架構,沒有匹配到業務的組織划分,帶來的結果可能就是返工。
在本文中,我只會討論“狀態管理”的問題,其他的問題超出了本文的范疇,有興趣的小伙伴可自行搜索相關資料文獻,以后有機會我可能也會寫幾篇文章對應不同的主題進行討論。
並發與狀態的關系
如果說支持並發是為了提高系統的可用性(處理能力),那么在並發處理期間,可能會對某一個狀態同時進行修改,這完全取決於用戶或者前端的操作。
對於不同的狀態,並發進行操作是相互不受影響的,假如有100個用戶同時請求操作自己的某些狀態,這是完全沒有問題的,因為他們的狀態本身就是被隔離開的,不存在競爭關系。
但如果這些用戶請求的操作,還涉及到另一個相同狀態的修改,那么就會產生競爭關系。
例如用戶創建了一個訂單,我們要將對應商品的庫存減少,以避免超賣。假如這是因為一個需求,當創建訂單的時候,如果庫存不足,訂單無法被創建。
我們該怎么來處理這個需求呢?如何控制並發對商品的修改不會導致一致性問題?
我們可以分析一下:
- 首先用戶創建訂單操作的是自己的數據,每一個用戶都是隔離開的,相互不受影響。
- 其次用戶創建訂單這個操作,可能還操作了另一個相同的數據,那就是某一個商品的庫存,這一部分數據可能會有多個用戶同時並發進行請求,可能存在並發問題。注意我說了好幾遍“可能”,可能存在,可能不存在,例如某些商品它的訂單量比較低,在同一時刻幾乎不會超過一個用戶同時創建訂單,這就為我們用於控制並發提供了不同級別的實現策略。
- 要求是,當訂單的庫存不足,用戶創建訂單這個操作就會失敗。
最最簡單的,當然是把對商品的修改這個操作也參與進訂單的創建事務中,這樣當我們發現商品的庫存不足時,拋出異常,回滾事務,訂單也將不會被創建。
在項目前期,為了快速實現需求原型,這樣做是最為簡單的方便的,將用戶的訂單表、商品表、可能還有賬戶表(錢包)全部划分到一個數據庫中。
當項目后期,運營的影響以及用戶量和商品數量上升,可能會導致這個系統的可用性達不到要求,這是非常常見的,項目不會一成不變,為了快速的適應市場的需求,我們必須要想辦法來提高系統的可用性。
請注意,如何觀察系統已經達到瓶頸,以及如何解決瓶頸取決於業務的需要和規模的性質。
當系統達到瓶頸之后,最明顯的現象就是會存在大量的請求阻塞,原因可能來自於數據庫(IO),也有可能來自於CPU的負載(計算)。
我們會分析是由於IO出現問題還是計算出現問題,一般最先出現問題的是IO,我們可以通過觀察系統的資源,來分析和判斷具體的原因。
如果是由於IO過高,增加處理節點其實並不會提高系統的可用性,此時我們應該解決的單點問題在於數據庫。
當然最簡單的辦法仍然是增加數據庫的資源,比如購買更好的硬件來提升數據庫的處理能力,在應急的時候此方案是首選,以最小的成本以及影響來提升系統的可用性。
在出現幾次這樣的提升后,我們就需要根據運營數據的分析,得到系統增長的周期指數報表。我們觀察這個周期指數報表,就能夠相對准確的來預測未來在某一個時間點可能會再次出現可用性的問題。
當發現硬件已經無法滿足擴充能力的時候(大多數項目還沒有到達這一步就消失了),我們就需要提前對整個系統的架構設計進行一次優化了,同時也恭喜你們的產品已經相當成功了。
當單體架構已經無法滿足需求和規模的時候,我們就必須得進行拆分,可以是純技術層面的優化,當然前提是業務層面已經相對的穩定定型,否則有可能出現的情況就是,可用性問題仍然無法得到解決。
不要低估硬件的能力,除非確實是因為成本的問題或者硬件已經達到極限,比如系統規模上升的速度超過了摩爾定律,我們才需要換一種手段來進行優化。
最簡單的拆分,不牽扯業務層面的調整,那就是對數據庫進行分庫分表水平擴容,唯一需要注意的就是數據的負載分布問題,考驗我們拆分的是否有效,起決定因素的就是數據的分布。
只要我們掌握了數據的分布特征,等於就掌握了水平擴容的核心因素,是可以做到無限擴容的。
我們來分析一下在上述例子中,數據的分布特征有哪些:
- 用戶訂單:我們分析過,每一個用戶的訂單相對於其他的用戶都是隔離開的,相互不受影響的,因此我們可以把用戶標識當做分布特征之一。
- 商品庫存:我們分析過,一個商品可能會被所有的用戶購買,如果我們已經根據用戶進行分布了,理論上來講,商品庫存也需要一起被分布,這樣才能保證他們在同一個事務管理中,不會發生一致性的問題。
看起來我們遇到了一個問題,由於用戶訂單可能存在於多個數據庫中的多個表中,他們被物理隔離了。但是可能會購買同一個商品,這些商品是唯一的,就會出現跨庫的問題,跨庫之后由於事務管理不在同一個作用域內,提交和回滾操作都無法得到一致的保證。
那么如何解決這個問題呢?如果我們使用分布式事務,那么瓶頸仍然會存在,由於商品是單點的,如果某一個商品很暢銷,導致大量的用戶購買,仍然需要面對性能低下的問題,但相比之前的架構,情況已經好很多,至少用戶訂單已經被均勻的負載分布到了不同的資源上。
如果我們使用分布式事務,那么商品庫存表就沒有必要和用戶訂單表擠在一個數據庫之內了,他們是兩個不相關的主體,我們已經根據用戶主體將訂單進行了分布,那么商品庫存表無論放到哪一個分布節點內好像都不合適。
此時我們的應用程序仍然是單體架構,只不過是將數據進行了分布,用戶創建訂單的時候,根據用戶主體標識路由到不同的數據庫和表上面,然后使用分布式事務同步操作另一個商品庫存數據庫,在這里,商品庫存數據庫是否需要分庫分表取決於以下兩個因素:
- 如果按照數據量來看,由於商品數量一般比用戶數量少,一個數據庫足以容納所有的商品庫存信息。
- 如果按照修改數據的頻繁程度來看,由於所有的用戶在創建訂單的時候都會訪問和修改商品庫存,所以一個數據庫可能無法承受過高的請求負載。
問題已經逐步清晰了,我們發現始終繞不過去的一個點就是商品的庫存修改,因為所有的用戶創建訂單都需要去修改它,除非我們將商品庫存也進行分庫分表(因為第2個因素),這樣一個訂單創建的操作就會被分布到不同的資源上面。
現在又出現了另一個問題,我們已經假設使用了分布式事務,所以先不用考慮分布式事務的可靠性問題,重點在於,我們的商品數量還遠遠沒有達到需要分庫分表的程度,一個數據庫完全可以存儲和索引所有的商品庫存,但是由於修改的並發負載,我們又不得不進行這樣的分割,造成了資源使用上的浪費。那么有沒有其他比較好的方法來解決這個問題呢?
我列出了幾個方案:
- 強一致:商品庫存預分配,我們可以引入商品庫存預分配機制,根據后台運營數據,實時調整商品的庫存分配到用戶訂單數據庫內。
- 最終一致:引入訂單創建狀態,使用額外的中間步驟來達成最終一致。
還有很多其他的方案,實現方式可能有幾十種,但終究繞不開一致性並發問題,所有的解決方案都是圍繞一致性進行的。
我們先說第一個方案,我們可以在用戶創建訂單的時候,先在同一個庫內查找該商品的庫存,這些庫存是被預先分配的,這樣做的好處在於,我們不需要分布式事務就能完成創建訂單的操作。
如果該商品庫存不存在,那么動態請求系統的另一個分配服務,將該商品的剩余庫存分配一下,然后嘗試創建訂單的邏輯,這種是被動式的分配,主動式的就策略就很多了,有可能是上架商品的時候就預先規划了,也有可能是根據以往用戶創建訂單的運營分析,利用一系列算法,將商品的庫存進行預先分配,由於各自業務流程的偏好不同,實現方案千差萬別,我不打算嘗試詳述,因為這超出了本文的范圍。
這種實現方式有點類似於令牌桶,它限制每一個節點的並發程度,當然我們的目的不一樣,在這里的作用恰恰相反,是用於提升系統的可用性。
如果該商品庫存不夠,也會去動態請求分配服務,分配服務可能會拋出一個異常,或者返回一個響應,告訴你該商品已經賣完了,那么此時就可以據此提醒用戶創建訂單失敗,因為已經斷貨了。
因為商品如果已經被分配完,不涉及修改操作,我們就可以把該數據完全交給分配服務進行緩存控制,當商品發生變更,只需要確保分配服務處於流程上。
我們再來看最終一致的方案,這種方案的實現的可選項就更多了,因為是異步的,用戶創建訂單可能會在一個LandingPage(着陸頁、中間頁,一般用於耗時操作的狀態變更監控通知)等待異步通知,也可以離開等到系統發出結果通知。
在這里,由於用戶可以接受耗時等待,所以我們的系統設計相對靈活,實時性要求可能就沒有那么高,可以隊列依次進行處理。
但是這樣做唯一的問題就是需要提前和業務方進行溝通,如果得到業務方理解,在不影響用戶體驗(仁者見仁、智者見智)的情況下,比如耗時一般控制在5秒內等等,我們就能夠得到比較大的空間去設計系統。
最終一致保證數據一致性所用到的技術一般有:
- 事務關聯ID串聯不同的資源處理步驟。
- 重試/冪等用於保證數據不會被重復處理。
- 補償/回滾用於保證某些不可變規則因素導致的事務失敗,需要回滾其他步驟所產生的影響。
- 定時校驗事務的處理情況,在適當的時候發出重試、補償等操作,恢復意外中斷的步驟。
當我們決定使用哪一種方案的時候,無論是強一致還是最終一致,最好最好事先將我們面臨的情況如實與業務方進行溝通,只有業務方同意調整涉及到流程問題的解決方案,我們才能進行下一步的落實,否則由於業務的變更,最終實現出來的產品並不符合業務方的需求,從而導致返工就得不償失了。
我們再進一步,為了將已經穩定的業務模塊分離出來,單獨作為一個獨立的服務存在,這樣我們在部署和修改服務而不影響外部調用的情況下可能更加方便,也更加通用和便於管理,當然前提仍然是,業務已經經受考驗,相對穩定,且抽象出來的接口相對精簡。
“容器”管理
就像上節我們講述的那個例子一樣,用戶和商品分別是倆個不同主體,這些不同的主體通過訂單進行關聯存在,而主體本身就像是一個個容器,無論這些容器有多少,我們都可以對其進行管理和水平擴容。
不同的容器代表了他們之間存在業務邊界,只要我們嚴格遵守這些邊界,就能夠針對性的對其進行調整,合理的利用資源來提升系統的可用性。
回憶一下之前我們講的那句話:我們可以在同時修改不同的狀態(容器),但是無法在同時修改同一個狀態。
容器可以包含多個狀態,狀態與狀態之間的依賴必須要通過容器來進行隔離,如果隨意使用的話可能會造成難以管理的復雜度。
如果說你會某一樣技術,證明你擁有這項技能,但是如果你會思考,會從業務角度出發去觀察技術所要服務的通用模式,我個人覺得這樣的能力更難得可貴。
技術只是工具,用於實現業務目的,但如果能夠了解到現如今的基礎底層架構和業務之間的關系映射,會讓我們的能力和學習的方向更有針對性。
總結
本文斷斷續續寫了將近十天左右的時間,因為牽扯的知識面太過於廣闊,一開始,我本來只想寫一下關於游戲相關的內容,但是寫到后面我發現,不僅僅是游戲,就算是企業級的開發,仍然也會遵循相同的原理,區別只在於使用場景以及業務的抽象程度所造成的差異到底有多大。
只不過游戲的開發在某些場景中更加極端,所以我仍想借此標題來寫一寫隱藏在問題背后的那些本質原因。
我常常在想,游戲客戶端有很多的引擎和框架技術來幫助人們創造難以置信的體驗和效果,這些技術大大減少了開發游戲所要面臨的基礎工作,但是在后端卻很少有相關的資料和通用的技術來實現游戲服務端,不過在寫完本文之后我似乎有一些明白了,和前端不同的是,后端只要能夠提供這些看不見的能力,實現方法可能數千種,每一個團隊喜歡用的技術都可以滿足他們的需要。
但是我們仍然需要抽象出通用的模式,來幫助實現各種各樣的需求場景,這樣當我們面臨一個已經被解決的問題的時候,就能夠減少寶貴的研發時間,從而將更多的時間放在用戶的反饋體驗、改進產品的服務上面。
一定要站在巨人的肩膀上,我們遇到的問題99%都已經被解決了,只是我們沒有找到,沒有組織好我們的需求,難以進行檢索。
最后,本文之中所講述的很多地方肯定會存在很多的疑點,甚至是錯誤的,我不能保證我目前所理解的都是正確的,但我會想辦法去改進,在這一方面離不開各位看官的反饋,本人在此先謝謝諸位。