---
# System prepended metadata

title: 可由使用者 “後定義” 的資料欄位在關聯式資料庫的 Schema 實作設計
tags: [關聯式資料庫, 技術選型]

---

# 可由使用者 “後定義” 的資料欄位在關聯式資料庫的 Schema 實作設計

> **RDB schema design of user after defined data columns**

* 共筆版本：https://hackmd.io/@effective-crud/User-After-Defined-RDB-Columns
* Git repo: https://github.com/Effective-CRUD/Continues-Protocol-Development

###### tags `關聯式資料庫`, `技術選型`

---

## 概觀

### 問題背景

所謂的 “後定義” 資料欄位，就是每筆紀錄的欄位數量是可變、不固定的。使用者可以依照需求，在每筆紀錄中儲存所需的欄位和資料。當我們在規劃一個支援使用者後設資料、後定義欄位的資料管理系統，與資料庫架構時，通常會選用 NoSQL 資料庫。

又或是在人力、時程 ... 資源有限的情況下，折衷使用原有的關聯式資料庫 (RDB)，和使用其 NoSQL 型別，以及一個特殊的欄位來處理這些 user defined 紀錄。 ([例如 PostgreSQL 的 JSON 型別](https://docs.postgresql.tw/the-sql-language/data-types/json-types))。

但是在某些條件下，例如系統複雜到改不動的時候，就必須沿用關聯式資料庫，與**不能亂改**的資料型別來實作 (不能亂改 = 應用層不能改、不想改)，就會遇到一種不得不繼續使用關連式資料庫，但是又得用各種反模式、欠下技術債來開發的情況。

### 技術債？反模式？

技術債指的是，為了實現新需求，和相容原有架構，因此 schema 得繼續採用 1NF (一階正規化) -- 大資料表的設計。而這個資料表的設計，看起來就像是在用試算表儲存資料一樣；而且大資料表還有個問題，就是資料表最後可能會長出 3000 個欄位，但是大部分的紀錄，又只有用到 20% 的欄位。

反模式指的則是，我們不得不使用關聯式資料庫，但是又不把它當成關聯式資料庫來使用。而是把它當成一個支援 SQL 語法的 “試算表”。


### 如何在 RDB 的 "精神" 上，設計 “使用者後定義” 的資料表

對於有經驗的工程師與 DBA 來說，這個問題並不複雜。我們只要將各種型別的**欄位**，通通規劃到獨立的資料表就好了，然後再關聯到所屬的**紀錄**資料表；紀錄資料表則會關連到主資料表。我們姑且將這個方案稱為小資料表。


### 技術與風險考量

:::warning
**大資料表**、**小資料表**並不是嚴謹的工程用詞，這邊僅用於解釋本文的使用案例，與遇到的技術 (債) 問題。
:::

當然了，沒有最好的萬用解決方法，只有適合的架構。原有的大資料表方案 -- 把資料庫當成試算表來存資料，真的有什麼不對嗎？正規化為小資料真的有比較好？比較好的定義是什麼？

這個問題，我們可以從系統資源需求、服務類型來交叉比較，做選擇。

|需求      |大資料表        |小資料表           |
|:--------|:--------------|:----------------|
|I/O bound|**讀取**資料量大 |**寫入**時間久     |
|CPU bound|**無用資料**多   |**搜尋**時間久     |
|分析型服務 |**後計算**成本高|**前處理**成本高    |
|交易型服務 |**一致性**維護成本高|**Transaction** 成本高|



---

## 使用案例與 Schema 設計

:::info
由於本文以 Markdown 撰寫，而且尚未找到好用的 ER model *繪圖*方式，因此以下先用 SQL 語法說明 schema 的設計。
:::


### 使用案例：可蒐集資料欄位未知的感測器紀錄系統

我們先設計一個簡單的使用案例，假設使用者有某種感測器模組，需要我們幫忙記錄感測資料。這個感測器特別的地方是，它可以擴充、開關感測功能，也就是說每個感測器回傳的資料種類，需要的欄位數量都是可變的，也可以由使用者定義。


### *感測器*與*感測器紀錄*主表設計

首先，每個感測器需要兩個資料表，一個是**感測器基本資料** (``sensor``)，另一個則是**感測器資料紀錄**的資料表 (``sensor_row``)。``sensor`` 資料表設計如下。

```sql
CREATE TABLE sensors
(
   name VARCHAR(36) NOT NULL, -- Display name or UUID
                              -- (32 hex digits + 4 dashes)
   id SERIAL PRIMARY KEY,
   UNIQUE(name),
   
   created_at TIMESTAMP NOT NULL
      DEFAULT CURRENT_TIMESTAMP, 
   updated_at TIMESTAMP DEFAULT NULL
);
```
            
``sensor_row`` 資料表的部分，如果這個需求的感測器資料欄位是固定的，那我們也只需要再一個 ``sensor_row`` 資料表就好了。但是在這個例子中，``sensor_row`` 只是用來關連感測紀錄 n 筆欄位的主表。

```sql
CREATE TABLE sensor_rows (
   sensor INT NOT NULL        -- 欄位定義紀錄屬於哪個 sensor
      REFERENCES sensors(id)
      ON DELETE CASCADE,
   UNIQUE(id, created_at),    -- 每個 sensor 在單位時間內，
                              -- 只能有一筆紀錄，避免 AP 層、
                              -- 手動亂改造成資料不一致
 
   id SERIAL PRIMARY KEY,
   created_at TIMESTAMP NOT NULL
      DEFAULT CURRENT_TIMESTAMP, 
   updated_at TIMESTAMP DEFAULT NULL
);
```

:::success
關聯圖：
```
sensor <-(belongs to)- sensor_row
```
:::


### *資料欄位*資料表設計

有了感測紀錄主表後，我們就需要實際儲存感測資料，而且資料欄位數量未知的資料表，來保存每個感測紀錄的 n 個欄位資料。

此外資料欄位有不同的資料型別，因此每種型別的欄位，也需要自己的資料表，用來儲存感測資料，並關聯到感測紀錄主表。

我們先從整數資料欄位開始。

```sql
CREATE TABLE sensor_row_int_columns (
   sensor_rows INT NOT NULL   -- 欄位定義紀錄屬於哪筆 "紀錄"
      REFERENCES sensor_rows(id)
         ON DELETE CASCADE,
   record INT NOT NULL,       -- 實際紀錄的數值；為了日後搜尋的
                              -- 效率問題，不允許為 null
   id SERIAL PRIMARY KEY,
      created_at TIMESTAMP NOT NULL
         DEFAULT CURRENT_TIMESTAMP, 
   updated_at TIMESTAMP DEFAULT NULL
);
```

接著就是依照需求，完成 ``float``、``varchar`` ... 型別的*資料欄位*資料表了。

:::success
關聯圖：
```
           sensor
             ^
             |
        (belongs to)
             |
         sensor_row 
             ^
             |
        (belongs to)
           /   \
          /     \
 sensor_row_    sensor_row_
   int_columns    *_columns
```
:::


---

## 還少了什麼？

### 可以想像得到的需求

**後設資料欄位的後設資料**

如何實作資料欄位資料表 (``sensor_row_*_columns``) 的欄位屬性 (名稱、功能定義、display name ...)。

**如何定義後設紀錄欄位的 *schema***

如何設計感測紀錄主資料表 (``sensor_rows``) 的預設欄位格式，例如要有哪些資料欄位、怎麼檢查資料是否合法。資料欄位在感測紀錄中的順序定義 (order) 問題，在不同型別的資料欄位 (資料表)，如何處理在感測紀錄中的順序。


### 別想了，勇敢面對真實世界吧

以上的問題，我們可依照需求，在 DB 層另外再設計一個 *attribute*、*metadata* 資料表，用**後設資料的後設資料**，去描述資料欄為、感測紀錄資料表的屬性。當然也可以透過應用層、第三方服務來實作。總之就是回到那句老話：看需求。

譬如在本文的案例中，這個現有系統的任務是要蒐集資料欄位可後定義、數量未知的感測器紀錄。因此他的主要任務就是把這些紀錄好好蒐集下來，並且可以讓使用者增減需要的欄位。但是後續有**想像得到的問題**的需求，其實就表示需求已經超過這個系統的邊界了。即使在現有的架構上實作對應的功能，也只會讓系統愈來愈複雜。

所以這時候就要**勇敢**的跨出單一系統 (或是 App) 的思維，捨棄熟悉的**高耦合、低內聚**反模式、**一大坨**的開發方式了。這部分會陸續在 *Effective CURD* 系列整理出來。


### 最後加映 -- *後設資料的後設資料*資料表

最後，關於前面**後設資料欄位的後設資料**的問題，我們需要再新增一個整數*資料欄位*資料表的後設資料表 (``sensor_row_int_column_metadata``)：

```sql
CREATE TABLE sensor_row_int_column_metadata (
   name VARCHAR(36) NOT NULL, -- 由 AP 層定義、紀錄欄位的屬性，
                              -- 建議用 UUID
                              -- (32 hex digits + 4 dashes)
   UNIQUE(name),
   id SERIAL PRIMARY KEY,
   created_at TIMESTAMP NOT NULL
      DEFAULT CURRENT_TIMESTAMP, 
   updated_at TIMESTAMP DEFAULT NULL
);
```

並且修改原有的*資料欄位*資料表 (``sensor_row_int_columns``)：

```sql
ALTER TABLE sensor_row_int_columns
-- 新增欄位的屬性，"後設資料的後設資料"，允許為 NULL
ADD COLUMN
   metadata INT DEFAULT NULL 
      REFERENCES sensor_row_int_column_metadata(id)
      ON DELETE SET NULL,
-- 每筆紀錄只允許有一筆同類型的資料欄位，
-- metadata，若為 NULL 則不限
ADD UNIQUE(sensor_rows, metadata)                   
;
```

:::success
關聯圖：
```
             sensor
               ^
               |
          (belongs to)
               |
           sensor_row 
               ^
               |
          (belongs to)
               |
     sensor_row_int_columns
               |
           (has one)
               |
               V
 sensor_row_int_column_metadata
```
:::
