本文轉載自公衆號 Fastpay快付node
做者李豔鵬,阿里P8技術專家,小灰在Qcon大會上有幸結識,技術又好爲人又很謙和。程序員
互聯網平臺架構日益成爲互聯網發展的基石,對於 Java 開發者和架構師而言,只有在瞭解架構背後的原理後,才能寫出更高質量的代碼,才能設計出更好的方案,才能在錯綜複雜平臺架構下產出價值,才能在各類場景下快速發現問題、快速定位問題、快速解決問題。web
本場 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個分片會均衡的分佈在服務節點一、服務節點二、服務節點三、服務節點4上。
固然,這種方法須要把後臺的定時任務與前臺的服務相互隔離,不能影響正常的線上服務是底線。
—————END—————
公衆號 Fastpay快付,作第三方支付行業的精品公衆號,提供第三方支付的業務知識、架構規劃與實施、技術的核心要點和最佳實踐。
喜歡本文的朋友們,歡迎長按下圖關注訂閱號程序員小灰,收看更多精彩內容
本文分享自微信公衆號 - 程序員小灰(chengxuyuanxiaohui)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。