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

爲什麼要討論錯誤?

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

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

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

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

模型設計

假設咱們想要爲一個在線書城設計數據庫。該系統應當容許用戶執行如下活動:緩存

  • 經過書名、描述和做者信息瀏覽與搜索書籍,
  • 閱讀後對書籍添加評論和評級,
  • 定購書籍,
  • 查看訂單處理的狀態。

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

 

爲了測試該模型,咱們使用Vertabelo爲其生成SQL,而且在PostgreSQL RDBMS中建立一個新的數據庫。數據庫設計

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

1 —— 使用無效的名稱

你能夠在上面的模型中看到咱們用「order」命名了一張表。不過,或許你還記得,「order」在SQL中是保留字! 所以若是你試圖發起一個SQL查詢:post

MySQL性能

SELECT * FROM ORDER ORDER BY ID

1

SELECT * FROM ORDER ORDER BY ID

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

MySQL

SELECT * FROM "order" ORDER BY ID

1

SELECT * FROM "order" ORDER BY ID

等等,但是這裏的「order」是小寫!

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

MySQL

CREATE TABLE "order" ( id int NOT NULL, customer_id int NOT NULL, order_status_id int NOT NULL, CONSTRAINT order_pk PRIMARY KEY (id) );

1

2

3

4

5

6

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不會檢測到命名衝突:

MySQL

CREATE TABLE "ORDER" ( id int NOT NULL, customer_id int NOT NULL, order_status_id int NOT NULL, CONSTRAINT order_pk2 PRIMARY KEY (id) );

1

2

3

4

5

6

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,那麼用戶實際上輸入進去的字符數量是未知的。假如他們輸入一個簡單的評論,以下:

XHTML

I like that book!

1

I like that book!

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

XHTML

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

1

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條評論。爲了查詢這些評論,咱們將使用以下的語句:

MySQL

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

1

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

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

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

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

MySQL

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)

1

2

3

4

5

6

7

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進行排序並保存結果來幫助它。爲此,咱們先在該列上建立一個索引

MySQL

create index book_comment_send_ts_idx on book_comment(send_ts);

1

create index book_comment_send_ts_idx on book_comment(send_ts);

如今咱們的查詢語句從600,000條記錄中查詢出最新30條所花費的時間又是67毫秒了。查詢計劃差異很是大:

MySQL

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.77 rows=610667 width=17)

1

2

3

4

5

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.77 rows=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%的緣由;不要在不太可能的事故場景中保護你的數據,關注那些可能發生的場景。

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

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

7  ——忽略排序規則

最後的錯誤是一個棘手的問題,由於它只出如今一些系統中,主要是在多語種系統裏。將它添加在這裏,是由於咱們常常遇到它,但它彷佛並不廣爲人知。

一般來講,根據字母在字母表中的順序,咱們假定在一種語言中對單詞排序與逐字排序同樣容易。可是這裏有兩種陷阱:

  • 首先,哪一個字母表?若是咱們的內容只有一種語言,那很顯然,可是若是內容中有15到30種語言,該由哪個字母表來決定順序?
  • 其次,當重音起做用時,逐字排序有時會有錯誤。

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

MySQL

db=# select title from book where id between 1 and 4 order by title collate "POSIX"; title ------- cote coté côte côté

1

2

3

4

5

6

7

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

title

-------

cote

coté

côte

côté

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

可是這些單詞是法語,因此這纔是正確的

MySQL

db=# select title from book where id between 1 and 4 order by title collate "en_GB"; title ------- cote côte coté côté

1

2

3

4

5

6

7

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查詢中該使用哪一種排序規則:
也許你應當使用針對當前用戶的排序規則,
有時你可能但願使用特定於被瀏覽數據的語言。
若是能夠,應用排序規則到列和表——看文章瞭解更多。

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

相關文章
相關標籤/搜索