數據庫設計的 7 個常見錯誤

理論說得夠多了!通過實例來學習數據庫建模


爲何要討論錯誤?


優秀數據庫設計的藝術就像游泳。入手相對容易,精通則很困難。如果你想學習設計數據庫,一定得有一些理論背景,比如關於數據庫設計範式和事務隔離級別的知識。但你還應該儘可能地多加練習,因爲可悲的事實就是,我們在犯錯中學習得更多。


本文中,通過展示在設計數據庫時常犯的一些錯誤,我們嘗試把學習數據庫設計變得容易一點。


注意,我們假定讀者瞭解數據庫範式並知道一點關係數據庫的基礎知識,因而不會去討論數據庫規範化。只要有可能,文中所涵蓋的主題都將使用 Vertabelo 建模和實例來說明。


本文涵蓋了設計數據庫的各個方面,但着重於Web應用,因此有些例子可能是特定於web應用程序的。


模型設計


假設我們想要爲一個在線書城設計數據庫。該系統應當允許用戶執行以下活動:


  • 通過書名、描述和作者信息瀏覽與搜索書籍,


  • 閱讀後對書籍添加評論和評級,


  • 定購書籍,


  • 查看訂單處理的狀態。


那麼最開始的數據庫模型可能如下所示:



爲了測試該模型,我們使用Vertabelo爲其生成SQL,並且在PostgreSQL RDBMS中創建一個新的數據庫。


該數據庫有8張表,其中沒有數據。我們已經往裏面填充了一些人工生成的測試數據。現在數據庫裏包含了一些示範數據,準備好開始模型檢查了,包括識別那些現在不可見但將來在真實用戶使用時會出現的潛在問題。


1 —— 使用無效的名稱


你可以在上面的模型中看到我們用“order”命名了一張表。不過,或許你還記得,“order”在SQL中是保留字! 因此如果你試圖發起一個SQL查詢:


SELECT * FROM ORDER ORDER BY ID


數據庫管理系統將會抗議。很幸運,在PostgreSQL中用雙引號把表名包裹起來就行了,語句仍可以執行:


SELECT * FROM "order" ORDER BY ID


等等,可是這裏的“order”是小寫!


沒錯,這值得深究。如果你在SQL中用雙引號把什麼包了起來,它就變成分隔標識符,大多數數據庫將以區分大小寫的方式解釋它。由於“order” 是SQL中的保留字,Vertabelo生成SQL會自動把order用雙引號包起來:


CREATE TABLE "order" (

id int NOT NULL,

customer_id int NOT NULL,

order_status_id int NOT NULL,

CONSTRAINT order_pk PRIMARY KEY (id)

);


但是由於標識符被雙引號包裹且是小寫,表名仍然是小寫。現在如果你希望事情變得更復雜,我可以創建另一個表,這次把它名爲ORDER(大寫),PostgreSQL不會檢測到命名衝突:


CREATE TABLE "ORDER" (

id int NOT NULL,

customer_id int NOT NULL,

order_status_id int NOT NULL,

CONSTRAINT order_pk2 PRIMARY KEY (id)

);


如果一個標識符沒有被雙引號包裹,它就被稱作“普通標識符”,在被使用前自動被轉成大寫——這是SQL 92標準所要求的。但是標識符如果被雙引號包裹


——就被稱作“分隔標識符”——要求被保持原樣。


底線就是——不要使用關鍵字來當做對象名稱。永遠不要。


你知道Oracle中名稱長度上限是30個字符嗎?


關於給表以及數據庫其他元素命好名——這裏命好名的意思不僅是“不與SQL關鍵字衝突”,還包括是自解釋的且容易記住——這一點常常被嚴重低估。在一個小型數據庫中,比如我們這個,命名其實並不是件非常重要的事。但是當你的數據庫增長到100、200或者500張表,你就會知道在項目的生命週期中爲保證模型的可維護性,一致和直觀的命名至關重要。


記住你不光是給表和列命名,還包括索引、約束和外鍵。你應當建立命名約定來給這些數據庫對象命名。記住名字的長度也是有限制的。如果你給索引命名太長,數據庫也會抗議。


提示:


  • 讓你的數據庫中的名字:

    • 儘可能短,

    • 直觀,儘可能正確和具有描述性,

    • 保持一致性;


  • 避免使用SQL和數據庫引擎特定的關鍵字作爲名字;


  • 建立命名約定;


以下是把order表重命名爲purchase後的模型:


模型中的改變如下:



2 ——列的寬度不足


讓我們進一步來看這個模型。如我們所看到的,在book_comment表中,comment列的類型是1000個以內的字符。這意味着什麼?


假設這個字段將是GUI(用戶只能輸入非格式化的評論)中的純文本,那麼它簡單地意味着該字段可以存儲最多1000個文本字符。如果是這樣的話——這裏沒有錯誤。


但是如果這個字段允許一些格式化的動作,比如bbcode或者HTML,那麼用戶實際上輸入進去的字符數量是未知的。假如他們輸入一個簡單的評論,如下:


I like that book!


那麼它會只佔用17個字符。然而如果他們使用粗體格式化它,像這樣:

I <b>like</b> that book!


這就需要24個字符的存儲空間,而用戶在GUI上只會看到17個。


因此如果書城的用戶可以使用某種像所見即所得的編輯器來格式化評論內容,那麼限制”comment”字段的大小是存在潛在危險的。因爲當用戶超過了最大評論長度(1000個原始HTML字符),他們在GUI上所看到的仍然會低於1000。這種情況下就應當修改類型爲text而不要在數據庫中限制長度了。


然而,當設置了文本字段的限制,你應當始終謹記文本的編碼方式。


varchar(100)類型在PostgreSQL中代表100個字符,而在Oracle中代表100字節。


避免籠統地解釋,我們來看一個例子。在Oracle中,varchar類型被限制到4000個字節,那麼這就是一個強限制——沒有任何方法可以超過它。因此如果你定義了一個列是varchar(3000 char),那它意味着你可以存儲3000個字符,但只有在它不會使用到磁盤上超過4000個字節的情況下。爲何一個3000個字符的文本在磁盤上會超過4000個字節呢?英文字符的情況下是不會發生的,但是其它語言中就可能出現。舉個例子,如果你嘗試用中文的方式存儲”mother”——母親,且數據庫使用UTF-8的方式編碼,那麼這個字符串會佔用磁盤上2個字符但是6個字節。


BMP(Basic Multilingual Plane,基本多語言平面,Unicode零號平面)是一個字符集,支持用UTF-16讓每個字符用2個字節進行編碼。幸運地是,它覆蓋了世界上大多數使用的字符。


注意,不同數據庫對於可變長的字符和文本字段會有不同的限制。舉些例子:


  • 前面提到過,Oracle對varchar類型的列有4000個字節限制。


  • Oracle將低於4KB的CLOB直接存儲到表中,這種數據訪問起來如同任何varchar列一樣快。但大些的CLOB讀取時就會耗時變長,因爲它們存在表的外面。


  • PostgreSQL允許一個未限制長度的varchar列存儲甚至是千兆字節的字符串,且是默默地把字符串存到後臺表,不會降低整個表的性能。


提示:


  • 一般而言,考慮到安全和性能,數據庫中限制文本列的長度是好的,但有時這個做法可能沒有必要或者不方便;


  • 不同的數據庫對待文本限制可能會有差異;


  • 使用英語以外的語言時永遠記住編碼。


下面是把book_comment的評論類型修改爲text後的模型:



模型中修改的地方如下圖:



3 ——沒有恰當地添加索引


有一個說法是“偉大是實現的,而不是被贈與的”。這個說法同樣可以用在性能上——通過精心設計數據庫模型,優化數據庫參數以及優化數據庫應用查詢來實現。當然這裏我們關注的是模型設計。


在例子中,我們假定書城的GUI設計者決定在首頁顯示最新的30條評論。爲了查詢這些評論,我們將使用如下的語句:


select comment, send_ts from book_comment order by send_ts desc limit 30;


這個查詢運行起來有多快?在我的筆記本上花費不到70毫秒。但是如果我們希望應用能夠按比例變化(在高負載下快速運行),需要在更大的數據上檢測。所以我在book_comment表中插入了更多的記錄。爲此我將使用一個很長的單詞列表,然後使用一個簡單的Perl命令將其轉成SQL。


現在我要把這個SQL導入到PostgreSQL數據庫。一旦導入開始,我就會檢測之前那個查詢的執行時間。統計結果在如下的表格中:



如你所見,隨着 book_comment 中行數的增加,要獲取最新30行所花費的查詢時間也在成比例地增加。爲何耗費時間增長?我們看看這個查詢計劃:


db=# explain select comment, send_ts from book_comment order by send_ts desc limit 30;

QUERY PLAN

-------------------------------------------------------------------

Limit (cost=28244.01..28244.09 rows=30 width=17)

-> Sort (cost=28244.01..29751.62 rows=603044 width=17)

Sort Key: send_ts

-> Seq Scan on book_comment (cost=0.00..10433.44 rows=603044 width=17)


這個查詢計劃告訴我們數據庫如何處理查詢及計算結果的大致時間成本。這裏PostgreSQL告訴我們將進行“Seq Scan on book_comment”,這意味着它將逐個檢查 book_comment 表的所有記錄,以此對send_ts列的值進行排序。貌似PostgreSQL還沒有聰明到在不去對所有的600,000條進行排序的條件下查詢30個最新記錄。


幸運地是,我們可以通過告知PostgreSQL根據send_ts進行排序並保存結果來幫助它。爲此,我們先在該列上創建一個索引:


create index book_comment_send_ts_idx on book_comment(send_ts);


現在我們的查詢語句從600,000條記錄中查詢出最新30條所花費的時間又是67毫秒了。查詢計劃差別非常大:


db=# explain select comment, send_ts from book_comment order by send_ts desc limit 30;
QUERY PLAN
--------------------------------------------------------------------
Limit (cost=0.42..1.43 rows=30 width=17)
-> Index Scan Backward using book_comment_send_ts_idx on book_comment (cost=0.42..20465.77rows=610667 width=17)


“Index Scan”指不是逐行掃描book_comment表,而是數據庫會掃描我們剛剛創建的索引。估計查詢成本小於1.43,低於之前的2.8萬倍。


你遇到了性能問題?第一次嘗試解決就應當是找到運行時間最長的查詢,讓你的數據庫來解釋它們,並且尋找全表掃描。如果你找到了,也許增加一些索引可以快速提升速度。


不過,數據庫性能設計是一個龐大的主題,超出了本文的範圍。


我們在如下提示中列出一些重要的方面。


提示:


  • 經常檢查運行時間長的查詢,或許可以用上EXPLAIN功能;大多數現代數據庫都有該功能;


  • 在創建索引時:

    • 記住它們不會一直被用到;數據庫如果計算出使用索引所耗費的時間長於全表掃描或其它操作時,將不會使用索引;

    • 記住使用索引帶來的代價是——在被索引的表上INSERT和DELETE會變慢

    • 如果需要索引請考慮非默認類型的索引;如果你的索引工作得不是很好,請查閱數據庫手冊;


  • 有時候你需要優化查詢,而不是模型;


  • 不是每一個性能問題都可以通過創建一個索引來解決;有很多其它解決性能問題的方式;

    • 各個應用層的緩存,

    • 調優數據庫參數和緩衝區大小,

    • 調優數據庫連接池大小或者線程池大小,

    • 調整數據庫事務隔離級別,

    • 在夜間安排批量刪除,避免不必要的鎖表,

    • 其它等等。


在book_comment.send_ts列上帶有索引的模型如下:



4 ——沒有考慮到可能的數據量或流量


通常你可以得到有關可能的數據量的附加信息。如果你正在構建的系統是另一個已存在項目的迭代,你可以通過查看老系統的數據量來計算出系統數據的預期大小。


如果你的書城非常成功,purchase表的數據量可能會非常大。你賣得越多,purchase表裏的數據行數增加越多。假如你事先知道這一點,你可以把當前已處理的訂單與完成的訂單分開。你可以用兩個表:purchase表記錄當前的訂單,archived_purchase表記錄完成的訂單,而不是用一張單一的purchase表。因爲當前的訂單一直在被檢索:它們的狀態在被更新,由於客戶經常查看訂單的信息。另一方面,完成的訂單隻會被作爲歷史數據保存。它們很少被更新或者檢索,所以可以在這張表上安排更長的訪問時間。訂單分離之後,經常使用的表能保持比較小,但我們仍然保存着所有數據。


類似地,你應當優化頻繁更新的數據。想象一個系統的部分用戶信息經常由另一個外部系統(例如,該外部系統計算同一類的獎勵積分)更新。在我們的user表中也有其它信息,如他們的登陸賬號、密碼和全名。這些基本信息也經常被檢索。頻繁更新降低了獲取用戶基本信息的速度。最簡單的解決方案就是把這些數據分離到兩個表裏面:一個記錄基本信息(經常被讀取),另一個記錄獎勵積分相關的信息(頻繁被更新)。這樣更新操作不會減緩讀的操作。


分離頻繁和不頻繁使用的數據到多個表中不是處理大數據量的唯一方法。例如,如果你希望書的描述(description字段)非常長,你可以使用應用級別的緩存,這樣你不用經常檢索這個重量級的數據。書的描述很可能保持不變,所以這是一個很好的可被緩存的候選對象。


提示:


  • 你的客戶必須使用業務、領域特定的知識,預估預期你將處理的數據庫中的數據量。


  • 分離頻繁更新和頻繁讀取的數據。


  • 對重量級、更新少的數據考慮使用應用級別的緩存。


以下是修改後的書城模型:



5 ——忽略時區


如果書城是面向全世界的呢?客戶來自世界各地並且使用不同的時區。管理時區的date和datetime字段算是跨國系統中一個重要的問題。


系統必須始終爲用戶呈現準確的日期和時間,最好是以他們自己的時區。


舉例,特殊供應的過期時間(這是任何商城中最重要的功能)必須讓所有用戶理解一致。如果你只是說“促銷於12月24日結束”,他們會假定是在自己時區的12月24日半夜12點結束。如果你是指自己所在時區的聖誕前夜午夜12點,你必須說“12月24日,23.59 UTC”(即無論你的時區是什麼)。對於某些用戶,它將是“December 24, 19.59”,對另外一些用戶則是“December 25, 4.49”。用戶必須看到以他們所在時區爲準的促銷時間。


在一個跨時區系統中日期列類型是不會有效存在的。它應當一直是一個timestamp類型。


當登錄事件在跨時區系統中發生時,可以採取類似的方式。事件的時間應該總是以某個選中的時區爲準的標準化方式記錄的,例如UTC,因此你能夠毫無疑問地將時間從老到新排序。


數據庫必須與應用代碼合作以備處理時區問題。各種數據庫存儲日期和時間的數據類型有所不同。某些類型存儲時間時帶有時區信息,而有些則沒有。程序員應當在系統中開發標準化的組件來自動處理時區問題。

提示:


  • 檢查你的數據庫中日期和時間數據類型的細節。SQL Server中Timestamp與PostgreSQL的timestamp完全不同。


  • 用UTC的方式存儲日期與時間。


  • 處理好時區問題需要數據庫和應用代碼直接的合作。確保你理解了數據庫驅動的細節。這裏有相當多的陷阱。


6 ——缺少審計跟蹤


如果有人刪除或者修改了我們書城中的一些重要數據,可我們在3個月之後才發現,發生了什麼事情?我認爲我們遇到了嚴重的問題。


也許我們有3個月前的一個備份,所以可以恢復備份到一些新的數據庫以訪問到數據。此後我們將有一個契機來恢復這些數據避免損失。但是爲完成這個過程,必須滿足許多因素


  • 我們需要擁有那個合適的備份——哪一個纔是合適的?


  • 我們必須成功尋找到數據,


  • 我們必須能不費太大力氣就恢復數據。


當我們最終恢復了數據(但確定這就是最正確的版本嗎?),就面臨第二個問題——誰幹的?誰在三個月前毀掉了數據?他們的IP/用戶名是多少?我們如何覈實?爲了確定這一點,我們需要:


  • 至少保存3個月的系統訪問日誌——這不太有希望,它們或許可能已經被輪轉替換了。


  • 可以把刪除數據的情況與訪問日誌中的某些URL關聯起來。


這無疑會花費大量時間,而且沒有多大成功的勝算。


我們的模型所缺失的,就是某種意義上的審計跟蹤。有許多方式來達到這個目標:


  • 數據庫中的表可以有創建和更新時間戳,及所創建/修改行的用戶標示。 完整的審計日誌可以用觸發器或者其它對正在使用的數據庫管理系統有效的機制來實現。一些審計日誌可以存儲在單獨的數據庫以確保無法修改和刪除,


  • 數據能夠防止數據丟失,通過:

    • 不刪除它,而是打上一個被刪除的標記,

    • 版本化修改。


按照慣例,保持黃金分割是最好的方式。你應當在數據安全和模型簡易性中找到平衡。保存版本和記錄事件使得數據庫更復雜。忽略數據安全可能導致意外的數據丟失或者恢復丟失數據的高成本。


提示:


  • 考慮哪個數據重要到需要跟蹤修改/版本化,


  • 考慮風險和成本之間的平衡;記住帕雷託定律指出大約80%的影響來自20%的原因;不要在不太可能的事故場景中保護你的數據,關注那些可能發生的場景。


這是對purchase和archived_purchase表加了基本審計跟蹤功能的書城模型。



模型中的修改如下(以purchase表爲例):



7 ——忽略排序規則


最後的錯誤是一個棘手的問題,因爲它只出現在一些系統中,主要是在多語種系統裏。將它添加在這裏,是因爲我們經常遇到它,但它似乎並不廣爲人知。


通常來說,根據字母在字母表中的順序,我們假定在一種語言中對單詞排序與逐字排序一樣容易。但是這裏有兩種陷阱:


  • 首先,哪個字母表?如果我們的內容只有一種語言,那很顯然,但是如果內容中有15到30種語言,該由哪一個字母表來決定順序?


  • 其次,當重音起作用時,逐字排序有時會有錯誤。


我們將在這個法文的簡單SQL查詢中舉例說明:


db=# select title from book where id between 1 and 4 order by title collate "POSIX";

title

-------

cote

coté

côte

côté


這是逐字排序的結果,從左到右。


但是這些單詞是法語,所以這纔是正確的:


db=# select title from book where id between 1 and 4 order by title collate "en_GB";

title

-------

cote

côte

coté

côté


這兩個結果不同,因爲正確的單詞順序由排序規則決定——法語中的排序規則是在給定的單詞中最後一個重音決定順序。這是該特殊語言的一個特點。因此—— 語言的內容可以影響排序結果,而忽略語言會導致意想不到的排序結果。


提示:


在單一語言的應用中,初始化數據庫總是要用合適的區域設置,在多語言應用中,用默認的區域設置初始化數據庫,在每一個需要排序的地方決定在SQL查詢中該使用哪種排序規則:


也許你應當使用針對當前用戶的排序規則,有時你可能希望使用特定於被瀏覽數據的語言。

如果可以,應用排序規則到列和表。


這是我們的書城最終的版本:



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章