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