AI & Data Dreamers練功房
寶箱片場規則
特輯・延伸主題

補充 D:ACID 與 CAP — 從 MySQL 到 MongoDB

手冊05 的大局觀比過 RDBMS vs NoSQL,那張表裡有兩個沒展開的縮寫:ACID 和 CAP。這份補充說明這兩個概念,然後動手跑一個真的 NoSQL——用跟 MySQL 版完全同款的 Celery 爬蟲,把股價寫進 MongoDB,比較兩個世界的差異。


1. ACID — 交易的四條保證

交易(補充C 第 5 節操作過的 START TRANSACTION / ROLLBACK)背後就是這四個字母:

字母 名稱 白話 課程對應
A Atomicity 原子性 一組操作要嘛全做完、要嘛全沒發生 補充C 第 5 節交易實驗:改成 0 → ROLLBACK → 變回 1000.65
C Consistency 一致性 交易前後,資料都符合所有規則(主鍵、外鍵、約束) 補充C 第 2、3 節:NOT NULL 缺值(1048)、主鍵重複(1062)、外鍵指向不存在的資料(1452),都會被拒收
I Isolation 隔離性 多人同時操作,彼此不互相干擾(像各自在隔間工作) 多 worker 同時寫入時由 MySQL 處理衝突
D Durability 持久性 COMMIT 成功的資料,斷電也不會消失(已落盤) volume 掛載 + MySQL 的交易日誌

ACID 是資料庫對「交易」的保證:資料正確、不遺失。 它本身跟單機或分散式無關,但在單機上最容易完整實現——MySQL、PostgreSQL 這類 RDBMS 都完整支援。一旦資料分散到多台機器,要維持同樣的保證就必須跨機器協調、成本大增——這正是下一節 CAP 要處理的問題。

2. CAP — 多機器資料庫的「三選二」

資料一多,一台機器放不下,要多台——這時有一個取捨定理。三個目標:

  • Consistency 一致性:任何時刻、問任何一台,答案都一樣
  • Availability 可用性:任何時刻都有人回答你(不會「服務暫停」)
  • Partition tolerance 分區容忍:機器之間斷線時系統還能運作

CAP 定理:三個最多同時滿足兩個。 其中 P 沒有商量餘地——機器之間的網路無法保證永不中斷,系統必須在斷線時仍能運作。所以實際的選擇是:斷線的當下,要 C 還是 A?

注意:CAP 講的是分散式系統,不是 NoSQL 專屬——MySQL 做主從複寫或集群時同樣受 CAP 約束;單機一台 MySQL 沒有「機器之間斷線」的問題,所以不在 CAP 討論範圍。只是多數 NoSQL 天生為分散式設計,才更常跟 CAP 一起被提起。

用連鎖餐廳想像:台北、高雄兩家分店共用會員點數,兩店之間網路斷了,這時客人來扣點——

  • 選 C(一致優先):「系統連不上總部,暫停服務」——寧可不做生意,也不能讓兩邊點數對不上(MySQL 集群、金融系統的選擇)
  • 選 A(可用優先):「先扣,等連線恢復再對帳」——生意照做,允許短暫的不一致,事後同步(很多 NoSQL 的預設,稱最終一致性)

三種組合各舉實例:

組合 意思 系統例子 商業情境
CA 不面對分區——只有一台機器,沒有「機器之間斷線」的問題 單機 MySQL(本課的 mydb 就是) 單一門市的 POS、公司內部 ERP
CP 斷線時保一致、犧牲可用:連不上多數節點的那一邊停止服務 etcd(Kubernetes 存設定用)、HBase 銀行轉帳、演唱會票券庫存——寧可暫停交易,也不能同一張票賣兩次
AP 斷線時保可用、事後補同步(最終一致) Cassandra、DynamoDB 社群按讚、購物車、會員點數——先讓用戶操作成功,恢復後再對帳

ACID 的對面:BASE

CAP 選了 A 的系統,工程上遵循一套跟 ACID 刻意對仗的設計哲學,叫 BASE(命名是化學梗:ACID 酸、BASE 鹼):

字母 名稱 意思 連鎖餐廳例子的對應
BA Basically Available 基本可用 系統保證能回應,但回的可能是過時資料或降級後的服務 斷線時照常讓客人扣點
S Soft state 軟狀態 允許中間態:即使沒有新寫入,資料也可能因背景同步而變化 兩家分店帳目還沒對完的期間
E Eventually consistent 最終一致 不保證「現在」一致,保證停止寫入後各副本「終將」收斂一致 連線恢復後對帳,兩邊點數對平

ACID、CAP、BASE 三者是一條線,不是三個平行概念:

ACID ──「交易全都要保證」──► 強一致陣營(CAP 選 C)
                │
               CAP:分散式之後,C 和 A 只能選一個(定理)
                │
BASE ──「我選可用性」────► 最終一致陣營(CAP 選 A 的工程答案)

也就是說:CAP 是考題,ACID 和 BASE 是兩份不同的答卷——交 ACID 卷的是銀行轉帳,交 BASE 卷的是社群按讚。

兩個容易誤會的點:

  • BASE 的三個字母不是三選二(結構跟 CAP 不同),也不是三條獨立保證(跟 ACID 不同)——它是同一個選擇的三個面向:因為要基本可用,所以允許軟狀態,所以只能最終一致。
  • 「NoSQL = BASE」是簡化說法:BASE 對應的是 CAP 選 A 的系統(上表的 Cassandra、DynamoDB);MongoDB 預設寫入走單一主節點、一致性可調,不是每個 NoSQL 都站在 BASE 那一端(下一節分工表的「可調」指的就是這件事)。

3. 所以 SQL 和 NoSQL 是這樣分工的

MySQL(RDBMS 代表) MongoDB(NoSQL 代表)
資料模型 表格+固定 schema 文件(document)= dict,欄位自由
一致性 ACID 強一致 預設偏可用性、最終一致(可調)
擴展 垂直為主,水平擴展成本高 天生為水平擴展設計
適合 交易系統、需要 JOIN 與強約束 結構多變、量大、高流量寫入
課程對應 手冊05-06 的股價落地 本補充的動手段

(手冊05 大局觀那張 RDBMS vs NoSQL 表的「ACID」「最終一致性」,對應的就是上面兩節。)


4. 認識 MongoDB — 動手前先知道它是什麼

MongoDB 是什麼

MongoDB 是目前使用最廣的文件式(Document)資料庫:資料以「文件」為單位儲存,一份文件就是一個 JSON 格式的物件(內部以 BSON 二進位格式存放,額外支援日期、二進位等型別)——對 Python 來說,就是一個 dict。這跟爬蟲的資料形態直接對應:FinMind API 回來的就是 dict 的 list,寫入 MongoDB 不需要先定義表格結構,dict 進去就是一份文件。

名詞跟 MySQL 一一對應:

MySQL MongoDB 說明
database 資料庫 database 資料庫 名稱相同,都是最上層容器
table 表 collection 集合 一群文件的容器,不用先定義欄位
row 列 document 文件 一筆資料;每份文件的欄位可以不同
column 欄位 field 欄位 不用預先宣告,寫入時自帶
primary key 主鍵 _id 每份文件都有 _id,不給就自動產生
index 索引 index 索引 概念相同,也支援 unique

查詢與擴展的機制:

  • 查詢用 JSON 條件描述,例如 find({stock_id: "2330"});聚合分析用 aggregation pipeline($match、$group,對應 SQL 的 WHERE、GROUP BY)——動手段和 example/pymongo.ipynb 都會操作到
  • 擴展靠兩個內建機制:replica set(複本集,多台存同一份資料,提供容錯與讀取分流)和 sharding(分片,資料切段分散到多台)。第 3 節說 MongoDB「天生為水平擴展設計」,指的就是這兩個機制;本課用單機一台,不涉及這部分

NoSQL 不是只有 MongoDB — 四大類型

手冊05 大局觀那張表的 NoSQL 欄位列了四個名字,各代表一類:

類型 資料長相 代表系統 適合場景
文件式 Document JSON 文件(dict) MongoDB、CouchDB 結構多變的半結構化資料:爬蟲結果、商品目錄、log
鍵值式 Key-Value key → value,整個資料庫像一個大 dict Redis、DynamoDB 快取、session、排行榜——用 key 直取最快,但只能用 key 查
寬欄式 Wide-Column 表格外形,但每列欄位可不同、按欄儲存 Cassandra、HBase 寫入量極大的時序資料:IoT 感測、訊息紀錄
圖形式 Graph 節點+邊,關係本身就是資料 Neo4j 關係查詢:社群網絡、推薦系統、金流追蹤

四類的共同點:各自放棄「固定 schema + JOIN + 強約束」的一部分,換取特定場景的擴展性或速度。選型時先問「資料長什麼樣、怎麼查」,再對表挑類型——不是「NoSQL 比較新所以比較好」。

本課的動手段選 MongoDB:文件式跟爬蟲回來的 dict 直接對應、不用轉換,最能呈現「同一份資料、兩種落地」的差異。


5. 動手:同一支爬蟲,落地改 MongoDB

啟動(跟其他服務完全同款的管理方式)

docker compose -f docker-compose-local.yml up -d mongodb mongo-express
服務 是什麼 入口
mongodb MongoDB 本體(帳密 root / 1234,跟 MySQL 同慣例) port 27017
mongo-express Web 管理介面——MongoDB 界的 phpMyAdmin http://localhost:8082(root / 1234)

✅ 8082 不帶帳密回 401、帶 root/1234 回 200。port 用 8082 避開 phpMyAdmin(8080) 和 Airflow(8081)。

對照組程式碼:跟 MySQL 版差在哪

crawler/tasks_crawler_finmind_mongo.py——前半段(打 FinMind API)跟 MySQL 版一模一樣,只換落地:

def upload_data_to_mongo(data: list):
    client = MongoClient(host=MONGO_HOST, port=MONGO_PORT,
                         username=MONGO_ACCOUNT, password=MONGO_PASSWORD)
    collection = client["mydb"]["TaiwanStockPrice"]   # 資料庫和集合不存在時,第一次寫入自動建立
    for doc in data:
        collection.update_one(
            {"stock_id": doc["stock_id"], "date": doc["date"]},  # 同股同日 = 同一份文件
            {"$set": doc},
            upsert=True,        # 存在就更新、不存在就新增 —— Mongo 版的冪等寫入
        )
MySQL 版(手冊05/06) MongoDB 版(本補充)
寫入前要做什麼 建表、定欄位型別、設主鍵 不需要——dict 直接寫入
資料單位 一列(row,欄位固定) 一份文件(dict,欄位自由)
防重複 複合主鍵 + on_duplicate_key_update update_one(filter, $set, upsert=True)
型別 DB 層強制(DECIMAL/BIGINT…) 寫進去是什麼就是什麼
任務派送 .delay()(同手冊06 的單一 worker 方式) 完全同款(worker 是同一批)

跑起來

另開一個終端機,啟動本機 worker——不帶 -Q、收預設佇列,跟手冊06 同一個方式(pymongo 已在 uv 環境裡,uv sync 會裝):

uv run celery -A crawler.worker worker --loglevel=info

派送 5 支股票(.delay() 不指定佇列,單一 worker 逐一執行):

uv run crawler/producer_crawler_finmind_mongo_single.py

想搭配第 3 章的多佇列分流跑,用 producer_crawler_finmind_mongo.py;那條路的 worker 在 docker 裡,要先 up -d --build worker_twse worker_tpex 重建 image 才有 pymongo。

執行結果:

docker exec mongodb mongosh -u root -p 1234 --quiet --eval \
  'db.getSiblingDB("mydb").TaiwanStockPrice.countDocuments()'
# → 回傳文件總數(5 支股票的歷史日線資料)

# 查看其中一份文件——就是 FinMind 回來的 dict 原樣:
# { stock_id: '2330', date: '2024-01-02', Trading_Volume: 27997826, ... }

再跑一次 producer,countDocuments 的數字不變——重跑不重複,upsert=True 就是 Mongo 版的冪等(跟手冊06 同一個觀念、不同語法)。

用 mongo-express 看資料

http://localhost:8082 → 資料庫 mydb → collection TaiwanStockPrice → 直接瀏覽文件、可以搜尋 {"stock_id": "2330"}——跟 phpMyAdmin 看表是同一件事。

mongosh 基本操作(SQL 對照)

Web 介面的角色由 mongo-express 擔任,命令列的角色則是 mongosh。進入方式:

docker exec -it mongodb mongosh -u root -p 1234

不想用 docker exec?下面「同樣的操作」兩節用 pymongo(Python) 和 mongo-express(網頁) 做一模一樣的事,配套 notebook example/pymongo.ipynb 可以直接跑。

你想做 SQL(MySQL) MongoDB(mongosh)
切換資料庫 USE mydb use mydb
查前幾筆 SELECT * FROM t LIMIT 5 db.t.find().limit(5)
條件查詢 WHERE stock_id='2330' .find({stock_id: "2330"})
只挑欄位 SELECT date, close .find({...}, {_id: 0, date: 1, close: 1})
排序 ORDER BY date DESC .sort({date: -1})
計數 SELECT COUNT(*) .countDocuments()
不重複值 SELECT DISTINCT stock_id .distinct("stock_id")
範圍條件 WHERE close > 1000 .find({close: {$gt: 1000}})
更新一筆 UPDATE ... WHERE ... .updateOne(filter, {$set: {...}})
刪除一筆 DELETE ... WHERE ... .deleteOne(filter)
分組統計 GROUP BY .aggregate([{$group: ...}])

逐條操作(都在 use mydb 之後執行):

// 2330 最近三天的收盤價:條件 + 挑欄位 + 排序 + 限量
db.TaiwanStockPrice.find({stock_id: "2330"}, {_id: 0, date: 1, close: 1}).sort({date: -1}).limit(3)
// [{date: '2026-07-09', close: 2415}, {date: '2026-07-08', close: 2465}, ...]

// 集合裡有哪些股票
db.TaiwanStockPrice.distinct("stock_id")
// ['0050', '0056', '00713', '2317', '2330']

// 條件計數:2330 收盤價超過 1000 的天數($gt = greater than;同族還有 $gte / $lt / $lte / $ne)
db.TaiwanStockPrice.countDocuments({stock_id: "2330", close: {$gt: 1000}})

// 分組統計:每支股票的平均收盤價與筆數(SQL 的 GROUP BY + AVG + COUNT)
db.TaiwanStockPrice.aggregate([
  {$group: {_id: "$stock_id", avg_close: {$avg: "$close"}, days: {$sum: 1}}},
  {$sort: {_id: 1}}
])
// {_id: '0050', avg_close: 130.55, days: 603}
// {_id: '2330', avg_close: 1234.27, days: 608} ...

$gt、$set、$group、$avg 這些帶 $ 的都是 MongoDB 的運算子——條件用巢狀 dict 組出來,這是它和 SQL 語法差異最大的地方。

schema-free 的另一面:寫入一份欄位完全不同的文件

db.TaiwanStockPrice.insertOne({stock_id: "TEST", note: "欄位跟股價完全不同", anything: [1, 2, 3]})
// { acknowledged: true, insertedId: ObjectId('...') }   ← 照樣收下,沒有任何檢查

db.TaiwanStockPrice.find({stock_id: "TEST"}, {_id: 0})
// {stock_id: 'TEST', note: '欄位跟股價完全不同', anything: [1, 2, 3]}

db.TaiwanStockPrice.deleteOne({stock_id: "TEST"})   // 清掉實驗文件

同一張表這樣寫,MySQL 會報 1054(欄位不存在)直接拒絕;MongoDB 照單全收——同一個集合裡可以放結構完全不同的文件。方便,但髒資料的檢查責任全部落到程式碼層(想一想 Q3 的取捨)。

順帶認識 _id:每份文件都有一個自動產生的 ObjectId,這是 MongoDB 內建的代理鍵(補充C「自然鍵 vs 代理鍵」的 Mongo 版)。

MongoDB 怎麼防重複:unique index

MySQL 用主鍵擋重複;MongoDB 對應的機制是唯一索引:

db.TaiwanStockPrice.createIndex({stock_id: 1, date: 1}, {unique: true})
// 'stock_id_1_date_1'

db.TaiwanStockPrice.getIndexes()
// [{key: {_id: 1}}, {key: {stock_id: 1, date: 1}, unique: true}]

// 之後 insert 同股同日的第二份文件會被擋下:
db.TaiwanStockPrice.insertOne({stock_id: "2330", date: "2024-01-02", close: 1})
// E11000 duplicate key error ... dup key: { stock_id: "2330", date: "2024-01-02" }

E11000 就是 MongoDB 版的 1062。我們的任務用 update_one(upsert=True),本來就不會製造重複;unique index 的價值是保底——即使有程式用 insert 亂寫,資料庫層也擋得住。對應補充C 的原則:規則放在資料庫層,任何程式來寫都會被管到。

同樣的操作①:pymongo(不用 docker exec)

我們的爬蟲任務(tasks_crawler_finmind_mongo.py)用的就是 pymongo——所以你已經用過它的 update_one(upsert=True)。連線方式也一樣:

from pymongo import MongoClient

client = MongoClient(host="127.0.0.1", port=27017, username="root", password="1234")
col = client["mydb"]["TaiwanStockPrice"]

三欄對照——同一件事的兩種寫法:

你想做 mongosh(上面) pymongo
計數 .countDocuments() col.count_documents({})
條件查詢 .find({stock_id: "2330"}) col.find({"stock_id": "2330"})
只挑欄位 .find({...}, {_id: 0, close: 1}) col.find({...}, {"_id": 0, "close": 1})
排序 .sort({date: -1}) .sort("date", -1)
不重複值 .distinct("stock_id") col.distinct("stock_id")
範圍條件 {close: {$gt: 1000}} {"close": {"$gt": 1000}}
分組統計 .aggregate([...]) col.aggregate([...])
寫入一筆 .insertOne({...}) col.insert_one({...})
刪除一筆 .deleteOne({...}) col.delete_one({...})
建唯一索引 .createIndex({...}, {unique: true}) col.create_index([("stock_id", 1), ("date", 1)], unique=True)

三個規律看完就會轉換:

  1. dict 的 key 要加引號——mongosh 是 JavaScript({stock_id: ...}),Python 的 dict key 必須是字串({"stock_id": ...})
  2. 方法名從駝峰變蛇形——countDocuments → count_documents、insertOne → insert_one
  3. 運算子完全相同——$gt、$set、$group、$avg 原封不動,只是包在字串裡

動手跑(跟 pandas.ipynb 同一個開法):

uv run --with jupyter jupyter lab
# 開 example/pymongo.ipynb,由上往下執行

notebook 內容依序是:連線與計數 → 查詢(條件/挑欄位/排序/範圍/分組統計)→ 寫入與刪除(schema-free 驗證)→ unique index 防重複(含撞 DuplicateKeyError 的驗證)→ 收尾還原,每段都對照 SQL 和 mongosh 的等價寫法。

同樣的操作②:mongo-express(不寫程式)

http://localhost:8082 → 資料庫 mydb → 集合 TaiwanStockPrice。介面做得到的事:

操作 在哪裡做
瀏覽文件、分頁 集合頁面主畫面,上方顯示文件總數
條件查詢 上方查詢框:Simple 分頁填 key/value;Advanced 分頁直接填 JSON,例如 {"stock_id": "2330"}
挑欄位、排序 Advanced 分頁的 Projection 欄({"_id": 0, "date": 1, "close": 1})與 Sort 欄({"date": -1})
分組統計 Advanced 分頁勾選 Aggregate query,查詢框改填管線陣列:[{"$group": {"_id": "$stock_id", "avg_close": {"$avg": "$close"}}}]
新增文件 New Document 按鈕,直接編輯 JSON
編輯/刪除單筆 每份文件旁的編輯/刪除圖示
看索引、刪索引、建一般索引 集合頁面下方 Indexes 區塊(Add Indexes 填索引鍵 JSON:{"stock_id": 1, "date": 1})

介面做不到、要靠 pymongo 或 mongosh 的兩件事:distinct(沒有對應介面)、unique 索引(Add Indexes 只能填索引鍵,沒有 unique 選項)。

跟 phpMyAdmin 的對位:

phpMyAdmin(MySQL) mongo-express(MongoDB)
瀏覽資料 Browse 頁籤 集合頁面
下查詢 SQL 頁籤寫 SQL Advanced 分頁填 JSON
改資料 列上的編輯/刪除 文件上的編輯/刪除
管索引 Structure → Indexes Indexes 區塊

三條路怎麼選

情境 用哪條
看一眼資料長怎樣、改一兩筆 mongo-express(零門檻)
分析、批次處理、要進 pipeline pymongo(能跟 pandas、Celery 銜接)
臨時管理操作、跟著官方文件做事 mongosh(官方工具,文件範例都用它)

收工

docker compose -f docker-compose-local.yml down    # mongodb volume 保留資料;要清掉加 -v

6. 什麼時候選誰

情境 選 原因
交易、金流、庫存 MySQL 錢的事沒有「最終一致」,要 ACID
欄位固定、要 JOIN 分析 MySQL 關聯與約束是它的主場
資料結構常變(每支爬蟲欄位都不同) MongoDB 免 schema,改欄位不用 ALTER
超高流量寫入、要水平擴展 MongoDB 天生分散式
我們的股價表 MySQL 為主 欄位固定、要給 Metabase/BigQuery 做關聯查詢

想一想(確認你懂了)

Q1:ACID 的 A 和手冊06 的「冪等」是同一件事嗎?

不是。A(原子性)保證「一組操作全做或全不做」;冪等保證「同一個操作重做幾次結果一樣」。to_sql 一批寫入靠交易保 A,但重跑會翻倍(不冪等)——兩個都要有才安全。

Q2:為什麼說 CAP 的 P 不是選項?

因為網路斷線是物理現實,不是你能拒絕的。所以設計分散式系統時,真正要決定的是「斷線的當下選一致還是選可用」。

Q3:MongoDB 免建表很方便,代價是什麼?

沒有 DB 層的入場檢查——欄位打錯字、型別不對它照收(補充C 的 1048/1062 都不會發生),檢查責任全部落到程式碼層。方便和保護是一組取捨。

Q4:ACID、CAP、BASE 三個縮寫,哪一個是「定理」?另外兩個是什麼?

CAP 是定理——被證明的限制,逼你在斷線時的一致與可用之間選邊。ACID 和 BASE 都是「選完之後的承諾」:ACID 是強一致陣營的交易保證,BASE 是選了可用性之後的設計哲學。一句話:CAP 是考題,ACID 和 BASE 是兩份不同的答卷。

速查

我想… 指令
起 MongoDB + 介面 docker compose -f docker-compose-local.yml up -d mongodb mongo-express
進 mongo shell docker exec -it mongodb mongosh -u root -p 1234
用 Python 做同樣操作 uv run --with jupyter jupyter lab → 開 example/pymongo.ipynb
數文件 db.getSiblingDB("mydb").TaiwanStockPrice.countDocuments()
條件查詢 db.TaiwanStockPrice.find({stock_id: "2330"})(先 use mydb)
防重複保底(唯一索引) db.TaiwanStockPrice.createIndex({stock_id: 1, date: 1}, {unique: true})
派送 mongo 版爬蟲(單一 worker) uv run crawler/producer_crawler_finmind_mongo_single.py
派送 mongo 版爬蟲(多佇列分流) uv run crawler/producer_crawler_finmind_mongo.py
Web 介面 http://localhost:8082(root / 1234)