科技

支付平臺架構設計評稽核心要點與最佳實踐【完整版】

網際網路平臺架構日益成為網際網路發展的基石,對於 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)

悲觀鎖是在資料庫引擎層次實現的,它能夠阻止所有的資料庫操作。但是為了更新一條資料,需要提前對這條資料上鎖,直到這條資料處理完成,事務提交,別的請求才能更新資料,因此,悲觀鎖的效能比較低下,但是由於它能夠保證更新資料的強一致性,是最安全的處理資料庫的方式,因此,有些賬戶、資金處理系統仍然使用這種方式,犧牲了效能,但是獲得了安全,規避了資金風險。

行級鎖

不是所有更新操作都要加顯示鎖的,資料庫引擎本身有行級別的鎖,本身在更新行資料的時候是有同步和互斥操作的,我們可以利用這個行級別的鎖,控制鎖的時間視窗最小,一次來保證高併發的場景下更新資料的有效性。

行級鎖是資料庫引擎中對記錄更新的時候引擎本身上的鎖,是資料庫引擎的一部分,在資料庫引擎更新一條資料的時候,本身就會對記錄上鎖,這時候即使有多個請求更新,也不會產生髒資料,行級鎖的粒度非常細,上鎖的時間視窗也最少,只有更新資料記錄的那一刻,才會對記錄上鎖,因此,能大大減少資料庫操作的衝突,發生鎖衝突的概率最低,併發度也最高。

通常在扣減庫存的場景下使用行級鎖,這樣可以通過資料庫引擎本身對記錄加鎖的控制,保證資料庫更新的安全性,並且通過 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 ONLY

SELECT id FROM( SELECT ROW_NUMBER() OVER() AS num,id FROM TABLE ) A WHERE A.num>=1 AND A.num

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上。

當然,這種方法需要把後臺的定時任務與前臺的服務相互隔離,不能影響正常的線上服務是底線。

Reference:科技日報

看更多!請加入我們的粉絲團

轉載請附文章網址

不可錯過的話題