寫給開發者看的關系型數據庫設計
數據庫設計,一個軟件項目成功的基石。很多從業人員都認為,數據庫設計其實不那么重要。現實中的情景也相當雷同,開發人員的數量是數據庫設計人員的數倍。多數人使用數據庫中的一部分,所以也會把數據庫設計想的如此簡單。其實不然,數據庫設計也是門學問。
從筆者的經歷看來,筆者更贊成在項目早期由開發者進行數據庫設計(后期調優需要DBA)。根據筆者的項目經驗,一個精通OOP和ORM的開發者,設計的數據庫往往更為合理,更能適應需求的變化,如果追其原因,筆者個人猜測是因為數據庫的規范化,與OO的部分思想雷同(如內聚)。而DBA,設計的數據庫的優勢是能將DBMS的能力發揮到極致,能夠使用SQL和DBMS實現很多程序實現的邏輯,與開發者相比,DBA優化過的數據庫更為高效和穩定。如標題所示,本文旨在分享一名開發者的數據庫設計經驗,并不涉及復雜的SQL語句或 DBMS使用,因此也不會局限到某種DBMS產品上。真切地希望這篇文章對開發者能有所幫助,也希望讀者能幫助筆者查漏補缺。
一、Codd的RDBMS12法則——RDBMS的起源
Edgar Frank Codd(埃德加·弗蘭克·科德)被譽為“關系數據庫之父”,并因為在數據庫管理系統的理論和實踐方面的杰出貢獻于1981年獲圖靈獎。在1985 年,Codd 博士發布了12條規則,這些規則簡明的定義出一個關系型數據庫的理念,它們被作為所有關系數據庫系統的設計指導性方針。
1、信息法則:關系數據庫中的所有信息都用唯一的一種方式表示——表中的值。
2、保證訪問法則:依靠表名、主鍵值和列名的組合,保證能訪問每個數據項。
3、空值的系統化處理:支持空值(NULL),以系統化的方式處理空值,空值不依賴于數據類型。
4、基于關系模型的動態聯機目錄:數據庫的描述應該是自描述的,在邏輯級別上和普通數據采用同樣的表示方式,即數據庫必須含有描述該數據庫結構的系統表或者數據庫描述信息應該包含在用戶可以訪問的表中。
5、統一的數據子語言法則:一個關系數據庫系統可以支持幾種語言和多種終端使用方式,但必須至少有一種語言,它的語句能夠一某種定義良好的語法表示為字符串,并能全面地支持以下所有規則:數據定義、視圖定義、數據操作、約束、授權以及事務。(這種語言就是SQL)
6、視圖更新法則:所有理論上可以更新的視圖也可以由系統更新。
7、高級的插入、更新和刪除操作:把一個基礎關系或派生關系作為單個操作對象處理的能力不僅適應于數據的檢索,還適用于數據的插入、修改個刪除,即在插入、修改和刪除操作中數據行被視作集合。
8、數據的物理獨立性:不管數據庫的數據在存儲表示或訪問方式上怎么變化,應用程序和終端活動都保持著邏輯上的不變性。
9、數據的邏輯獨立性:當對表做了理論上不會損害信息的改變時,應用程序和終端活動都會保持邏輯上的不變性。
10、數據完整性的獨立性:專用于某個關系型數據庫的完整性約束必須可以用關系數據庫子語言定義,而且可以存儲在數據目錄中,而非程序中。
11、分布獨立性:不管數據在物理是否分布式存儲,或者任何時候改變分布策略,RDBMS的數據操縱子語言必須能使應用程序和終端活動保持邏輯上的不變性。
12、非破壞性法則:如果一個關系數據庫系統支持某種低級(一次處理單個記錄)語言,那么這個低級語言不能違反或繞過更高級語言(一次處理多個記錄)規定的完整性法則或約束,即用戶不能以任何方式違反數據庫的約束。
二、關系型數據庫設計階段
(一)規劃階段
規劃階段的主要工作是對數據庫的必要性和可行性進行分析。確定是否需要使用數據庫,使用哪種類型的數據庫,使用哪個數據庫產品。
(二)概念階段
概念階段的主要工作是收集并分析需求。識別需求,主要是識別數據實體和業務規則。對于一個系統來說,數據庫的主要包括業務數據和非業務數據,而業務數據的定義,則依賴于在此階段對用戶需求的分析。需要盡量識別業務實體和業務規則,對系統的整體有初步的認識,并理解數據的流動過程。理論上,該階段將參考或產出多種文檔,比如“用例圖”,“數據流圖”以及其他一些項目文檔。如果能夠在該階段產出這些成果,無疑將會對后期進行莫大的幫助。當然,很多文檔已超出數據庫設計者的考慮范圍。而且,如果你并不精通該領域以及用戶的業務,那么請放棄自己獨立完成用戶需求分析的想法。用戶并不是技術專家,而當你自身不能扮演“業務顧問”的角色時,請你選擇與項目組的相關人員合作,或者將其視為風險呈報給PM。再次強調,大多數情況,用戶只是行業從業者,而非職業技術人員,我們僅僅從用戶那里收集需求,而非依賴于用戶的知識。
記錄用戶需求時,可以使用一些技巧,當然這部分內容有些可能會超出數據庫設計人員的職責:
● 努力維護一系列包含了系統設計和規格說明信息的文檔,如會議記錄、訪談記錄、關鍵用戶期望、功能規格、技術規格、測試規格等。
● 頻繁與干系人溝通并收集反饋。
● 標記出你自己添加的,不屬于客戶要求的,未決內容。
● 與所有關鍵干系人盡快確認項目范圍,并力求凍結需求。
此外,必須嚴謹處理業務規則,并詳細記錄。在之后的階段,將會根據這些業務規則進行設計。
當該階段結束時,你應該能夠回答以下問題:
● 需要哪些數據?
● 數據該被怎樣使用?
● 哪些規則控制著數據的使用?
● 誰會使用何種數據?
● 客戶想在核心功能界面或者報表上看到哪些內容?
● 數據現在在哪里?
● 數據是否與其他系統有交互、集成或同步?
● 主題數據有哪些?
● 核心數據價值幾何,對可靠性的要求程度?
并且得到如下信息:
● 實體和關系
● 屬性和域
● 可以在數據庫中強制執行的業務規則
● 需要使用數據庫的業務過程
(三)邏輯階段
邏輯階段的主要工作是繪制E-R圖,或者說是建模。建模工具很多,有不同的圖形表示方法和軟件。這些工具和軟件的使用并非關鍵,筆者也不建議讀者花大量時間在建模方法的選擇上。對于大多數應用來說,E-R圖足以描述實體間的關系。建模關鍵是思想而不是工具,軟件只是起到輔助作用,識別實體關系才是本階段的重點。
除了實體關系,我們還應該考慮屬性的域(值類型、范圍、約束)
(四)實現階段
實現階段主要針對選擇的RDBMS定義E-R圖對應的表,考慮屬性類型和范圍以及約束。
(五)物理階段
物理階段是一個驗證并調優的階段,是在實際物理設備上部署數據庫,并進行測試和調優。
三、設計原則
(一)降低對數據庫功能的依賴
功能應該由程序實現,而非DB實現。原因在于,如果功能由DB實現時,一旦更換的DBMS不如之前的系統強大,不能實現某些功能,這時我們將不得不去修改代碼。所以,為了杜絕此類情況的發生,功能應該有程序實現,數據庫僅僅負責數據的存儲,以達到最低的耦合。
(二)定義實體關系的原則
當定義一個實體與其他實體之間的關系時,需要考量如下:
● 牽涉到的實體:識別出關系所涉及的所有實體。
● 所有權:考慮一個實體“擁有”另一個實體的情況。
● 基數:考量一個實體的實例和另一個實體實例關聯的數量。
關系與表數量
● 描述1:1關系最少需要1張表。
● 描述1:n關系最少需要2張表。
● 描述n:n關系最少需要3張表。
(三)列意味著唯一的值
如果表示坐標(0,0),應該使用兩列表示,而不是將“0,0”放在1個列中。
(四)列的順序
列的順序對于表來說無關緊要,但是從習慣上來說,采用“主鍵+外鍵+實體數據+非實體數據”這樣的順序對列進行排序顯然能得到比較好的可讀性。
(五)定義主鍵和外鍵
數據表必須定義主鍵和外鍵(如果有外鍵)。定義主鍵和外鍵不僅是RDBMS的要求,同時也是開發的要求。幾乎所有的代碼生成器都需要這些信息來生成常用方法的代碼(包括SQL文和引用),所以,定義主鍵和外鍵在開發階段是必須的。之所以說在開發階段是必須的是因為,有不少團隊出于性能考慮會在進行大量測試后,在保證參照完整性不會出現大的缺陷后,會刪除掉DB的所有外鍵,以達到最優性能。筆者認為,在性能沒有出現問題時應該保留外鍵,而即便性能真的出現問題,也應該對SQL文進行優化,而非放棄外鍵約束。
(六)選擇鍵
1、人工鍵與自然鍵
人工健——實體的非自然屬性,根據需要由人強加的,如GUID,其對實體毫無意義;自然健——實體的自然屬性,如身份證編號。
人工鍵的好處:
● 鍵值永遠不變
● 永遠是單列存儲
人工鍵的缺點:
● 因為人工鍵是沒有實際意義的唯一值,所以不能通過人工鍵來避免重復行。
筆者建議全部使用人工鍵。原因如下:
● 在設計階段我們無法預測到代碼真正需要的值,所以干脆放棄猜測鍵,而使用人工鍵。
● 人工鍵復雜處理實體關系,而不負責任何屬性描述,這樣的設計使得實體關系與實體內容得到高度解耦,這樣做的設計思路更加清晰。
筆者的另一個建議是——每張表都需要有一個對用戶而言有意義的自然鍵,在特殊情況下也許找不到這樣一個項,此時可以使用復合鍵。這個鍵我在程序中并不會使用其作為唯一標識,但是卻可以在對數據庫直接進行查詢時使用。
使用人工鍵的另一根弊端,主要源自對查詢性能的考量,因此選擇人工鍵的形式(列的類型)很重要:
● 自增值類型 由于類型輕巧查詢效率更好,但取值有限。
● GUID 查詢效率不如值類型,但是取值無限,且對開發人員更加親切。
2、智能健與非智能鍵
智能鍵——鍵值包含額外信息,其根據某種約定好的編碼規范進行編碼,從鍵值本身可以獲取某些信息;非智能鍵,單純的無意義鍵值,如自增的數字或GUID。
智能鍵是一把雙刃劍,開發人員偏愛這種包含信息的鍵值,程序盼望著其中潛在的數據;數據庫管理員或者設計者則討厭這種智能鍵,原因也是很顯然的,智能鍵對數據庫是潛在的風險。前面提到,數據庫設計的原則之一是不要把具有獨立意義的值的組合實現到一個單一的列中,應該使用多個獨立的列。數據庫設計者,更希望開發人員通過拼接多個列來得到智能鍵,即以復合主鍵的形式給開發人員使用,而不是將一個列的值分解后使用。開發人員應該接受這種數據庫設計,但是很多開發者卻想不明白兩者的優略。筆者認為,使用單一列實現智能鍵存在這樣一個風險,就是我們可能在設計階段無法預期到編碼規則可能會在后期發生變化。比如,構成智能鍵的局部鍵的值用完而引起規則變化或者長度變化,這種編碼規則的變化對于程序的有效性驗證與智能鍵解析是破壞性的,這是系統運維人員最不希望看到的。所以筆者建議如果需要智能鍵,請在業務邏輯層封裝(使用只讀屬性),不要再持久化層實現,以避免上述問題。
(七)是否允許NULL
關于NULL我們需要了解它的幾個特性:
● 任何值和NULL拼接后都為NULL。
● 所有與NULL進行的數學操作都返回NULL。
● 引入NULL后,邏輯不易處理。
那么我們是否應該允許列為空呢?筆者認為這個問題的答案受到我們的開發語言的影響。以C#為例,因為引入了可空類型來處理數據庫值類型為NULL的情形,所以是否允許為空對開發者來說意義并不大。但有一點必須注意,就是驗證非空必須要在程序集進行處理,而不該依賴于DBMS的非空約束,必須確保完整數據(所有必須的屬性均被賦值)到達DB(所謂的“安全區”,我們必須定義在多層系統中那些區域得到的數據是安全而純凈的)。
(八)屬性切割
一種錯誤想法是,屬性與列是1:1的關系。對于開發者,我們公開屬性而非字段。舉個例子來說,對于實體“員工”有“名字”這一屬性,“名字”可以再被分解為“姓”和“名”,對于開發人員來說,顯然第二種數據結構更受青睞(“姓” 和“名”作為兩個字段)。所以,在設計時我們也應該根據需要考慮是否切割屬性。
(九)規范化——范式
當筆者還在大學時,范式是學習關系型數據庫時最頭疼的問題。我想也許會有讀者仍然不理解范式的價值,簡單來說——范式將幫助我們來保證數據的有效性和完整性。規范化的目的如下:
● 消滅重復數據。
● 避免編寫不必要的,用來使重復數據同步的代碼。
● 保持表的瘦身,以及減從一張表中讀取數據時需要進行的讀操作數量。
● 最大化聚集索引的使用,從而可以進行更優化的數據訪問和聯結。
● 減少每張表使用的索引數量,因為維護索引的成本很高。
規范化旨在——挑出復雜的實體,從中抽取出簡單的實體。這個過程一直持續下去,直到數據庫中每個表都只代表一件事物,并且表中每個描述的都是這件事物為止。
1、規范化實體和屬性(去除冗余)
1NF:每個屬性都只應表示一個單一的值,而非多個值。
需要考慮幾點:
● 屬性是原子性的 需要考慮熟悉是否分解的足夠徹底,使得每個屬性都表示一個單一的值。(和“(三)列意味著唯一的值”描述的原則相同。)分解原則為——當你需要分開處理每個部分時才分解值,并且分解到足夠用就行。(即使當前不需要徹底分解屬性,也應該考慮未來可能的需求變更。)
● 屬性的所有實例必須包含相同數量的值 實體有固定數量的屬性(表有固定數量的列)。設計實體時,要讓每個屬性只有固定數量的值與其相關聯。
● 實體中出現的所有實體類型都必須不同
當前設計不符合1NF的“臭味”:
● 包含分隔符類字符的字符串數據。
● 名字尾端有數字的屬性。
● 沒有定義鍵或鍵定義不好的表。
2、屬性間的關系(去除冗余)
2NF-實體必須符合1NF,每個屬性描述的東西都必須針對整個鍵(可以理解為oop中類型屬性的內聚性)。
當前設計不符合2NF的“臭味”:
● 重復的鍵屬性名字前綴(設計之外的數據冗余) 表明這些值可能描述了某些額外的實體。
● 有重復的數據組(設計之外的數據冗余) 這標志著屬性間有函數依賴型。
● 沒有外鍵的復合主鍵 這標志著鍵中的鍵值可能標識了多種事物,而不是一種事物。
3NF-實體必須符合2NF,非鍵屬性不能描述其他非鍵屬性。(與2NF不同,3NF處理的是非鍵屬性和非鍵屬性之間的關系,而不是和鍵屬性之間的關系。
當前設計不符合3NF的“臭味”:
● 多個屬性有同樣的前綴。
● 重復的數據組。
● 匯總的數據,所引用的數據在一個完全不同的實體中。(有些人傾向于使用視圖,我更傾向于使用對象集合,即由程序來完成。)
BCNF-實體滿足第一范式,所有屬性完全依賴于某個鍵,如果所有的判定都是一個鍵,則實體滿足BCNF。(BCNF簡單地擴展了以前的范式,它說的是:一個實體可能有若干個鍵,所有屬性都必須依賴于這些鍵中的一個,也可以理解為“每個鍵必須唯一標識實體,每個非鍵熟悉必須描述實體。”
3、去除實體組合鍵中的冗余
4NF-實體必須滿足BCNF,在一個屬性與實體的鍵之間,多值依賴(一條記錄在整個表的唯一性由多個值組合起來決定的)不能超過一個。
當前設計不符合4NF的“臭味”:
● 三元關系(實體:實體:實體)。
● 潛伏的多值屬性。(如多個手機號。)
● 臨時數據或歷史值。(需要將歷史數據的主體提出,否則將存在大量冗余。)
4、盡量將所有關系分解為二元關系
5NF-實體必須滿足4NF,當分解的信息無損的時候,確保所有關系都被分解為二元關系。
5NF保證在第四范式中存在的任何可以分解為實體的三元關系都被分解。有的三元關系可以在不丟失信息的前提下被分解為二元關系,當分解為兩個二元關系的過程要丟失信息時,關系被宣稱為處于第四范式中。所以,第五范式建議是,最好把現有的三元關系都分解為3個二元關系。
需要注意的是,規范化的結果可能是更多的表,更復雜的查詢。因此,處理到何種程度,取決于性能和數據架構的多方考量。建議規范化到第四范式,原因是5NF的判斷太過隱晦。例如:表X(老師,學生,課程)是一個三元關系,可以分解為表A(老師,學生),表B(學生,課程),表C(老師,課程)。表X表示某個老師是上某個學生的某個課程的老師;表A表示老師教學生;表B表示學生上課;表C表示老師教課。單獨看是無法發現問題的,但是從數據出發,"表X=表A+表B+表C"并不一定成立,即不能通過連接構建分解前的數據。因為可能有多種組合,喪失了表X反饋出的業務規則。這種現象,容易在設計階段被忽略,但好在在開放階段會被顯現,而且并不經常發生。
推薦做法:
● 盡可能地遵守上述規范化原則。
● 所有屬性描述的都應該是體現被建模實體的本質的內容。
● 至少必須有一個鍵,它唯一地標識和描述了所建實體的本質。
● 主鍵要謹慎選擇。
● 在邏輯階段能做多少規范化就做多少(性能不是邏輯階段考慮的范疇)。
(十)選擇數據類型(MS SQL 2008)
MS SQL的常用類型:
精確數字 | 不會發生精度損失 | bit tinyint smallint int bigint decimal |
近似數字 | 對于極值可能發生精度損失 | float(N) real |
日期和時間 |
| date time smalldatetime datetime datetime2 datetimeoffset |
二進制數據 |
| bingary(N) varbinary(N) varbinary(max) |
字符(串)數據 |
| char(N) varchar(N) varchar(max) nchar(N) nvarchar(N) nvarchar(max) |
存儲任意數據 |
| sql_variant |
時間戳 |
| timestamp |
GUID |
| uniqueidentifier |
XML | 不要試圖使用該類型規避1NF | xml |
空間數據 |
| geometry geography |
層次數據 |
| heirarchyid |
MS SQL中不在支持的或糟糕的類型選擇
● image:被varbinary(max)取代。
● text和ntext:被varchar(max)和nvarchar(max)取代。
● money和smallmoney:開發過程中不好用,建議使用decimal。
常用類型選擇:
類型選擇的最基本規則是選擇滿足需要的最輕的類型,因為這樣查詢更快。
bool | 建議使用bit而非char(1),因為開發語言對其支持覺好,可以直接映射為bool或bool?。 |
大值數據 | 使用所有備選類型中最小的那種,類型越大,查詢越慢,當字節大于8000時,應使用max。 |
主鍵 | 自增主鍵根據預期范圍選擇int或bigint,GUID使用uniqueidentifier而非varchar(N)。 |
(十一)優化并行
設計DB時就應該考慮到對并行進行優化,比如,MS SQL中的timestamp類型就是極好的選擇。
四、命名規則
表——“模塊名_表名”。表名最好不要用復數,原因是在使用ORM框架開發時,代碼生成器根據DB生成類定義,表生成了某個實例的類型定義,而不是實例集合。表名不要太長。
● 原因之一,某些軟件對表名最大長度有限制;原因之二,使用代碼生成器往往會根據表名生產類型名稱,之后懶人會直接使用這一名稱,如果將太長的名稱跨網絡邊界顯然不是明智之舉。
● 字段——bool類型用“Is”、“Can”、“Has”等表示;日期類型命名必須包含“Date”;時間類型必須包含“Time”。
● 存儲過程——使用“proc_”前綴。
● 視圖——使用“view_”前綴。
● 觸發器——使用“trig_”前綴。