索引
提起數據庫的設計要點,我們首先要說的就是數據庫索引的使用,在線上的服務中,任何數據庫的查詢都要走索引,這個是底線,不能因為數據量暫時較小就不使用索引,久而久之可能數據量增大就導致了性能問題,一般每個開發者都有建立索引和使用索引的意識,然而,問題出現在開發者使用索引的方法上。我們要保證建立的索引的有效性,一定要確保線上的查詢最后走到了索引,曾經就出現過這樣的一個低級錯誤,某個場景需要根據 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上。
當然,這種方法需要把后台的定時任務與前台的服務相互隔離,不能影響正常的線上服務是底線。