支付平臺的架構設計
本文轉載自公眾號 Fastpay快付
作者李艷鵬,阿裏P8技術專家,小灰在Qcon大會上有幸結識,技術又好為人又很謙和。
互聯網平臺架構日益成為互聯網發展的基石,對於 Java 開發者和架構師而言,只有在了解架構背後的原理後,才能寫出更高質量的代碼,才能設計出更好的方案,才能在錯綜復雜平臺架構下產出價值,才能在各種場景下快速發現問題、快速定位問題、快速解決問題。
本場 Chat 會帶領大家從支付平臺架構設計評審入手,講解設計評審的核心要點,為讀者帶去現實中的案例,幫助讀者理解設計評審的重要性、核心要點和最佳實現。在這場 Chat 中將學到如下內容:
-
揭秘支付系統中數據庫鎖的應用實踐。
-
如何科學的設置線程池。
-
緩存使用的最佳實踐。
-
數據庫設計要點。
-
一行代碼引起的“血案”。
-
冪等和防重。
-
實現分布式任務調度的多種方法。
揭秘支付系統中數據庫鎖的應用實踐
鎖通常應用在多個線程對一個共享資源進行同時操作,用來保證操作的有序性和正確性的同步設施。在筆者看來,鎖的本質其實是排隊,不同的鎖排隊的空間和時間不同而已,例如,Java 的 Synchronized 的鎖是在應用處理業務邏輯的時候在對象頭上進行排隊,數據庫的鎖是在數據庫上進行數據庫操作的時候進行排隊,而分布式鎖是在處理業務邏輯的時候在一個公用的存儲服務上排隊。
樂觀鎖
樂觀鎖是基於一種具有“樂觀”的思想,假設數據庫操作的並發非常少,多數情況下是沒有並發的,更新是按照順序執行的,少有的一些並發通過版本控制來防止臟數據的產生。具體過程為,在操作數據庫數據的時候,對數據不加顯式的鎖,而是通過對數據的版本或者時間戳的對比來保證操作的有序性和正確性。一般是在更新數據之前,先獲取這條記錄的版本或者時間戳,在更新數據的時候,對比記錄的版本或者時間戳,如果版本或者時間戳一樣,則繼續更新,如果不一樣,則停止更新數據記錄,這說明數據已經被其他線程或者其他客戶端更新過了。這時候需要獲取最新版本的數據,進行業務邏輯的操作,再次進行更新。
其偽代碼如下。
int version = executeSql("select version from... where id = $id");
// process business logic
boolean succ = executeSql("update ... where id = $id and version = $version");
if (!succ) {
// try again
}
樂觀鎖在同一時刻,只有一個更新請求會成功,其他的更新請求會失敗,因此,適用於並發不高的場景,通常是在傳統的行業裏應用在 ERP 系統,防止多個操作員並發修改同一份數據。在某些互聯網公司裏,使用樂觀鎖在失敗的時候再嘗試多次更新,導致並發量始終上不去,是一個反模式。而且這種模式是應用層實現的,阻止不了其他程序對數據庫數據的直接更新。
悲觀鎖
悲觀鎖是基於一種具有“悲觀”的思想,假設數據庫操作的並發很多,多數情況下是有並發的,在更新數據之前對數據上鎖,更新過程中防止任何其他的請求更新數據而產生臟數據,更新完成之後,再釋放鎖,這裏的鎖是數據庫級別的鎖。
通常使用數據庫的 for update 語句來實現,代碼如下。
executeSql("select ... where id = $id for update");
try {
// process business logic
commit();
} catch (Exception e) {
rollback();
}
悲觀鎖是在數據庫引擎層次實現的,它能夠阻止所有的數據庫操作。但是為了更新一條數據,需要提前對這條數據上鎖,直到這條數據處理完成,事務提交,別的請求才能更新數據,因此,悲觀鎖的性能比較低下,但是由於它能夠保證更新數據的強一致性,是最安全的處理數據庫的方式,因此,有些賬戶、資金處理系統仍然使用這種方式,犧牲了性能,但是獲得了安全,規避了資金風險。
行級鎖
不是所有更新操作都要加顯示鎖的,數據庫引擎本身有行級別的鎖,本身在更新行數據的時候是有同步和互斥操作的,我們可以利用這個行級別的鎖,控制鎖的時間窗口最小,一次來保證高並發的場景下更新數據的有效性。
行級鎖是數據庫引擎中對記錄更新的時候引擎本身上的鎖,是數據庫引擎的一部分,在數據庫引擎更新一條數據的時候,本身就會對記錄上鎖,這時候即使有多個請求更新,也不會產生臟數據,行級鎖的粒度非常細,上鎖的時間窗口也最少,只有更新數據記錄的那一刻,才會對記錄上鎖,因此,能大大減少數據庫操作的沖突,發生鎖沖突的概率最低,並發度也最高。
通常在扣減庫存的場景下使用行級鎖,這樣可以通過數據庫引擎本身對記錄加鎖的控制,保證數據庫更新的安全性,並且通過 where 語句的條件,保證庫存不會被減到0以下,也就是能夠有效的控制超賣的場景,如下代碼。
boolean result = executeSql("update ... set amount = amount - 1 where id = $id and amount > 1");if (result) { // process sucessful logic} else { // process failure logic}
另外一種場景是在狀態轉換的時候使用行級鎖,例如交易引擎中,狀態只能從 init 流轉到 doing 狀態,任何重復的從 init 到 doing 的流轉,或者從 init 到 finished 等其他狀態的流轉都會失敗,代碼如下。
boolean result = executeSql("update ... set status = ‘doing‘ where id = $id and status = ‘init‘");
if (result) {
// process sucessful logic
} else {
// process failure logic
}
行級鎖的並發性較高,性能是最好的,適用於高並發下扣減庫存和控制狀態流轉的方向的場景。
但是,有人說這種方法是不能保證冪等的,比如說,在扣減余額場景,多次提交可能會扣減多次,這確實是實際存在的,但是,我們是有應對方案的,我們可以記錄扣減的歷史,如果有非冪等的場景出現,通過記錄的扣減歷史來核對並矯正,這種方法也適用於賬務歷史等場景,代碼如下。
boolean result = executeSql("update ... set amount = amount - 1 where id = $id and amount > 1");
if (result) {
int amount = executeSql("select amount ... where id = $id");
executeSql("insert into hist (pre_amount, post_amount) values ($amount + 1, $amount)");
// process successful logic
} else {
// process failure logic
}
在支付平臺架構設計評審中,通常對交易和支付系統的流水表的狀態流轉的控制、對賬戶系統的狀態控制,分賬和退款余額的更新等,都推薦使用行級鎖,而單獨使用樂觀鎖和悲觀鎖是不推薦的。
如何科學的設置線程池
線上高並發的服務就像默默的屹立在大江大河旁邊的大堤一樣,隨時準備著應對洪水帶來了沖擊,線上高並發服務的線程池導致的問題也頗多,例如:線程池漲滿、CPU 利用率高、服務線程掛死等,這些都是因為線程池的使用不當,或者沒有做好保護、降級的工作而導致的。
當然,有些小夥伴是有保護線程池的想法的,但是,大家是不是有過這樣的經驗和印象,線程池的線程有時候設置多了性能低,設置少了還是性能低,到底應該怎麽設置線程池呢?
在經歷過這些年對小夥伴的設計評審,得知小夥伴們都是憑經驗、憑直覺來設置線程池的線程數的,然後根據線上的情況調整數量多少,最後找到一個最合適的值,這是通過經驗的,有時候管用,有時候不管用,有時候雖然管用但是犧牲了很大的代價才找到最佳的設置數量。
其實,線程池的設置是有據可依的,可以根據理論計算來設置的。
首先,我們看一下理想的情況,也就是所有要處理的任務都是計算任務,這時,線程數應該等於 CPU 核數,讓每個 CPU 運行一個線程,不需要線程切換,效率是最高的,當然這是理想情況。
這種情況下,如果我們要達到某個數量的 QPS,我們使用如下的計算公式。
設置的線程數 = 目標 QPS/(1/任務實際處理時間)
舉例說明,假設目標 QPS=100,任務實際處理時間 0.2s,100 * 0.2 = 20個線程,這裏的20個線程必須對應物理的20個 CPU 核心,否則將不能達到預估的 QPS 指標。
但實際上我們的線上服務除了做內存計算,更多的是訪問數據庫、緩存和外部服務,大部分的時間都是在等待 IO 任務。
如果 IO 任務較多,我們使用阿姆達爾定律來計算。
設置的線程數 = CPU 核數 * (1 + io/computing)
舉例說明,假設4核 CPU,每個任務中的 IO 任務占總任務的80%,4 * (1 + 4) = 20個線程,這裏的20個線程對應的是4核心的 CPU。
線程中除了線程數的設置,線程隊列大小的設置也很重要,這也是可以通過理論計算得出,規則為按照目標響應時間計算隊列大小。
隊列大小 = 線程數 * (目標相應時間/任務實際處理時間)
舉例說明,假設目標相應時間為0.4s,計算阻塞隊列的長度為20 * (0.4 / 0.2) = 40。
另外,在設置線程池數量的時候,我們有如下最佳實踐。
-
線程池的使用要考慮線程最大數量和最小數最小數量。
-
對於單部的服務,線程的最大數量應該等於線程的最小數量,而混布的服務,適當的拉開最大最小數量的差距,能夠整體調整 CPU 內核的利用率。
-
線程隊列大小一定要設置有界隊列,否則壓力過大就會拖垮整個服務。
-
必要時才使用線程池,須進行設計性能評估和壓測。
-
須考慮線程池的失敗策略,失敗後的補償。
-
後臺批處理服務須與線上面向用戶的服務進行分離。
緩存使用的最佳實踐
筆者在做設計評審的過程中,總結了一些開發人員在設計緩存系統時的優秀實踐。
最佳實踐1
緩存系統主要消耗的是服務器的內存,因此,在使用緩存時必須先對應用需要緩存的數據大小進行評估,包括緩存的數據結構、緩存大小、緩存數量、緩存的失效時間,然後根據業務情況自行推算未來一定時間的容量的使用情況,根據容量評估的結果來申請和分配緩存資源,否則會造成資源浪費或者緩存空間不夠。
最佳實踐2
建議將使用緩存的業務進行分離,核心業務和非核心業務使用不同的緩存實例,從物理上進行隔離,如果有條件,則請對每個業務使用單獨的實例或者集群,以減少應用之間互相影響的可能性。筆者經常聽說有的公司應用了共享緩存,造成緩存數據被覆蓋,以及緩存數據錯亂的線上事故。
最佳實踐3
根據緩存實例提供的內存大小推送應用需要使用的緩存實例數量,一般在公司裏會成立一個緩存管理的運維團隊,這個團隊會將緩存資源虛擬成多個相同內存大小的緩存實例,例如,一個實例有 4GB 內存,在應用申請時可以按需申請足夠的實例數量來使用,對這樣的應用需要進行分片。這裏需要註意,如果我們使用了 RDB 備份機制,每個實例使用 4GB 內存,則我們的系統需要大於 8GB 內存,因為 RDB 備份時使用 copy-on-write 機制,需要 fork 出一個子進程,並且復制一份內存,因此需要雙份的內存存儲大小。
最佳實踐4
緩存一般是用來加速數據庫的讀操作的,一般先訪問緩存,後訪問數據庫,所以緩存的超時時間的設置是很重要的。筆者曾經在一家互聯網公司遇到過由於運維操作失誤導致緩存超時設置得較長,從而拖垮服務的線程池,最終導致服務雪崩的情況。
最佳實踐5
所有的緩存實例都需要添加監控,這是非常重要的,我們需要對慢查詢、大對象、內存使用情況做可靠的監控。
最佳實踐6
如果多個業務共享一個緩存實例,當然我們不推薦這種情況,但是由於成本控制的原因,這種情況經常出現,我們需要通過規範來限制各個應用使用的 key 一定要有唯一的前綴,並進行隔離設計,避免緩存互相覆蓋的問題產生。
最佳實踐7
任何緩存的 key 都必須設定緩存失效時間,且失效時間不能集中在某一點,否則會導致緩存占滿內存或者緩存穿透。
最佳實踐8
低頻訪問的數據不要放在緩存中,如我們前面所說的,我們使用緩存的主要目的是提高讀取性能,曾經有個小夥伴設計了一套定時的批處理系統,由於批處理系統需要對一個大的數據模型進行計算,所以該小夥伴把這個數據模型保存在每個節點的本地緩存中,並通過消息隊列接收更新的消息來維護本地緩存中模型的實時性,但是這個模型每個月只用了一次,所以這樣使用緩存是很浪費的,既然是批處理任務,就需要把任務進行分割,進行批量處理,采用分而治之、逐步計算的方法,得出最終的結果即可。
最佳實踐9
緩存的數據不易過大,尤其是 Redis,因為 Redis 使用的是單線程模型,單個緩存 key 的數據過大時,會阻塞其他請求的處理。
最佳實踐10
對於存儲較多 value 的 key,盡量不要使用 HGETALL 等集合操作,該操作會造成請求阻塞,影響其他應用的訪問。
最佳實踐11
緩存一般用於交易系統中加速查詢的場景,有大量的更新數據時,尤其是批量處理,請使用批量模式,但是這種場景較少。
最佳實踐12
如果對性能的要求不是非常高,則盡量使用分布式緩存,而不要使用本地緩存,因為本地緩存在服務的各個節點之間復制,在某一時刻副本之間是不一致的,如果這個緩存代表的是開關,而且分布式系統中的請求有可能會重復,就會導致重復的請求走到兩個節點,一個節點的開關是開,一個節點的開關是關,如果請求處理沒有做到冪等,就會造成處理重復,在嚴重情況下會造成資金損失。
最佳實踐13
寫緩存時一定寫入完全正確的數據,如果緩存數據的一部分有效,一部分無效,則寧可放棄緩存,也不要把部分數據寫入緩存,否則會造成空指針、程序異常等。
最佳實踐14
在通常情況下,讀的順序是先緩存,後數據庫;寫的順序是先數據庫,後緩存。
最佳實踐15
當使用本地緩存(如 Ehcache)時,一定要嚴格控制緩存對象的個數及生命周期。由於 JVM 的特性,過多的緩存對象會極大影響 JVM 的性能,甚至導致內存溢出等問題出現。
最佳實踐16
在使用緩存時,一定要有降級處理,尤其是對關鍵的業務環節,緩存有問題或者失效時也要能回源到數據庫進行處理。
關於緩存使用的最佳實踐和線上案例,請參考《可伸縮服務架構:框架與中間件》一書的第4章的內容,預計在2018年3月份上市。
數據庫設計要點
索引
提起數據庫的設計要點,我們首先要說的就是數據庫索引的使用,在線上的服務中,任何數據庫的查詢都要走索引,這個是底線,不能因為數據量暫時較小就不使用索引,久而久之可能數據量增大就導致了性能問題,一般每個開發者都有建立索引和使用索引的意識,然而,問題出現在開發者使用索引的方法上。我們要保證建立的索引的有效性,一定要確保線上的查詢最後走到了索引,曾經就出現過這樣的一個低級錯誤,某個場景需要根據 A、B、C 三個字段聯合查詢,開發者分別在 A、B 和 C 上建立了3個索引,看似也符合規範,但是實際上只用了 A 這個索引,B 和 C 的都沒有用上,後來由於產生了性能問題,代碼走查的時候才發現。
我們建議每個開發者對使用的 SQL 都要查看執行計劃,另外,SQL 和索引要經過 DBA 的審閱才能上線。
另外,對於一般的數據庫,>=、BETWEEN、IN、LIKE 等都可以走索引,而 NOT IN 不能走索引,如果匹配的字符以 % 開頭,是不能走索引的,這些必須記住了。
範圍查詢
任何針對數據庫的範圍查詢,都要有最大結果集條數的限制,然後進行分頁處理,不能因為暫時數據量小而采用開發式的 SQL 語句,如果這樣的話,在數據上量以後,會導致結果集太大,而讓應用 OOM。
下面是主流數據庫限制結果集大小的方法。
DB2
FETCH FIRST 100 ROWS ONLYSELECT id FROM( SELECT ROW_NUMBER() OVER() AS num,id FROM TABLE ) A WHERE A.num>=1 AND A.num<= 100
MySQL
limit 1, 100
Oracle
rownum
Schema 變更
對於數據庫的 Schema 變更,我們推薦只能增加字段,而不要修改字段,也不要刪除字段,修改和刪除字段的風險太高了,尤其是在應用比較復雜,數據庫和應用的設計都是做加法加上來的,對於使用數據庫的應用了解不清楚,不要輕易更改原有的數據結構,修改字段就有可能導致代碼和數據庫不兼容的情況。
即使是只允許添加字段,我們也做如下的規定。
新代碼要兼容老數據,老代碼要兼容新數據。
要盡量讓新老代碼和新老數據庫 Schema 完全兼容,這在數據庫升級前、中、後都不會產生問題。
字段枚舉值的增加,或者數據庫字段的含義、格式、限制的改變,必須考慮準生產和線上導致的不一致的行為或者上線過程中新老版本的不一致的行為。曾經就出現過,版本更新的時候增加了枚舉值,由於 Boss 後臺先上線,產生了新的枚舉值,結果交易程序沒有更新,不認識新的枚舉值就出現了處理異常,因此枚舉值要慎用。
事務
經常會出現在數據庫事務中調用遠程服務,由於遠程服務超時而拉長事務,導致數據庫癱瘓的情況,因此,在事務處理過程中,禁止執行可能產生線程阻塞的調用,例如:鎖等待、遠程調用等。
另外,事務要盡可能保持短事務,一個事務中不要有太多的操作,或者做太多的事情,長時間操作事務會影響或堵塞其他的請求,累積可造成數據庫故障,同一事務中大量的數據操作會引起鎖的範圍和影響擴大,易造成數據庫的其他操作阻塞而導致短暫的不可用。
因此,如果業務允許,要盡可能用短事務來代替長事務,降低事務執行時間,減少鎖的時長,使用最終一致性來保證數據的一致性原則。
我們推薦下圖中的這種結構。
一定不能使用如下圖中的這種結構。
SQL 安全
所有的 SQL 必須使用參數化的 SQL,防止 SQL 註入,這是一條不能妥協的底線原則。
一行代碼引起的“血案”
在做支付平臺的設計評審的時候,我們一定要格外仔細,因為一不註意可能就會出現問題,甚至導致資金損失,筆者就經歷一次增加一行打印日誌的代碼導致的“血案”。
在一次查問題的過程中,發現缺少一個日誌,於是,增加了一行日誌。
log.info(... + obj);
很不巧,上線以後應用就全面出現問題,交易出現失敗,查看代碼發現不時的有 NullPointerException,分析代碼發現,出現 NullPointerException 的代碼在 obj.toString() 方法裏。
object.toString() 方法代碼如下所示。
private Object fld1;
......public String toString() { return ... + this.fld1;
}
我們看見,在 obj.toString() 方法裏面,直接使用了本地的變量 fld1,由於返回值是 String 類型,所以,Java 會試圖將 fld1 轉化成字符串,但是這個時候發生了 NullPointerException,那麽,fld1就一定為 null,查明原因發現,這個對象是從緩存中反序列化而來的,反序列化的時候這個字段就為 null。
因此,我們看到線上的代碼和環境是十分復雜的,在做設計評審的時候,一定要考慮到所有的情況,盡可能的將影響想得全面些,充分的降低代碼變更帶來的降低可用性的風險。
冪等和防重
冪等和防重雖然說起來挺復雜,但是實現起來很簡單,這也就應了筆者的一句話:凡是能夠有效解決問題的方法都是看起來很挫的方法”。
冪等是一個特性,一個操作執行多次,產生的結果是一樣的,就成為冪等,用數學公式表達如下。
f(f(x)) = f(x)
對於某些業務具有的特點,操作本身就是冪等的,例如:刪除一個資源、增加一個資源、獲得一個資源等。
防重是實現冪等的一種方法,防重有多種方法。
-
使用數據庫表的唯一鍵進行濾重,拒絕重復的請求,這通常用在增加記錄上,只要記錄有唯一的主鍵,這種方法失蹤奏效。
-
使用狀態流轉的方向性來濾重,通常使用上面的行級鎖來實現,這通常是在接受到回調消息的時候,要對記錄的狀態進行更新,可以使用行級鎖來更新數據庫的狀態,然後根據更新的成功與否來判斷繼續處理的業務邏輯,例如,收到支付成功消息,會先把支付記錄從 init 更新成 pay_finished,如果有重復的請求,第二個更新的請求會失敗。
-
使用分布式存儲對請求進行濾重,這個實現起來成本比較高。
實現分布式任務調度的多種方法
使用成熟的框架
可以使用成熟的開源分布式任務調用系統,例如 TBSchedule、ElasticJob 等等。
詳細內容,請參考《可伸縮服務架構:框架與中間件》的第6章的內容。
代碼自行實現
如果不喜歡使用成熟的框架,喜歡重復發明輪子,或者平臺有要求,不準引入外部的開源項目,那麽這個時候就是我們大顯身手的時候了,我們可以自己開發一套分布式任務調度系統。
其實,分布式任務調度系統的核心就是任務的搶占,這和操作系統的任務調度類似,只不過應用的場景不同而已,操作系統處理各個應用進程提交的任務,而我們的分布式任務調度系統處理服務化系統中的後臺定時任務。
假設,我們有4個後臺定時的服務節點,以及4個任務存儲在數據庫的任務表中,如下圖所示,所有的任務都處於空閑狀態,擁有者為空,4臺服務器都沒有工作可做。
到了某個時間點,激活服務節點的定時任務,服務節點開始搶占任務,搶占任務需要更新數據庫裏面的記錄狀態字段和擁有者,一般會使用數據庫的行級別鎖,代碼如下。
boolean result = executeSql("update ... set status = ‘occupied‘ and owner = $node_no where id = $id and status = ‘FREE‘ limit 1");if (result) {
Task t = executeSql("select ... where status = ‘occupied‘ and owner = $node_no");
// process task t
executeSql("update ... set status = ‘finished‘ and owner = null where id = $t.id and status = ‘occupied‘);
}
假設服務節點1搶占了任務號1,服務節點2搶占了任務號2,服務節點3搶占了任務號3,服務節點4搶占了任務號4,如下圖所示,這樣各自開始處理自己的任務,處理後,將任務狀態設置成 finished,其他服務節點就不會搶占這個任務了。
當然,這裏描述的只是核心思想,具體實現的時候需要詳細的設計,要考慮到任務如何調度、任務超時如何處理等等。
利用 Dubbo 服務化或者具有負載均衡的服務化平臺來實現
假如說平臺規定不能使用第三方開源組件,自己開發又比較耗時耗力,那麽還有一種辦法,這種辦法雖然看起來不是最佳的,但是能夠幫助你快速實現任務的分片。
我們可以借助 Dubbo 服務化或者具有負載均衡的服務來實現,我們在服務節點上開發兩個服務,一個總控服務,用來接受分布式定時的觸發事件,總控服務從數據庫裏面撈取任務,然後分發任務,分發任務利用 Dubbo 服務化或者具有負載均衡的服務化平臺來實現,也就是調用服務節點的任務處理服務,通過服務化的負載均衡來實現。
例如,下圖中分布式定時調用服務節點2的主控服務,主控服務從數據庫裏面撈取任務,並且分成4個分片,然後通過服務化調用任務處理接口,由於服務化具有負載均衡的功能,因此,4個分片會均衡的分布在服務節點1、服務節點2、服務節點3、服務節點4上。
當然,這種方法需要把後臺的定時任務與前臺的服務相互隔離,不能影響正常的線上服務是底線。
—————END—————
公眾號 Fastpay快付,做第三方支付行業的精品公眾號,提供第三方支付的業務知識、架構規劃與實施、技術的核心要點和最佳實踐。
支付平臺的架構設計