如果各位在網路上尋找MongoDB Transaction教學、MongoDB事務流程、MongoDB交易等關鍵字,很容易翻到早期的教學會說辦不到,要使用db.collection.findAndModify()來進行尋找合併修改的元子操作,但是這樣只能針對單個Document;若是要跨Document甚至跨Collection需要透過一種Two Phase Commit來模擬交易行為,但這很麻煩也不是真正的事務交易。好消息是從MongoDB 4.0開始增加了Transaction機制,MongoDB 5.0、6.0又更完善了此功能。本文教大家如何在Mongo shell上面透過JavaScript來使用Transaction。

註:Transaction是資料庫術語,有人稱為交易,但包含星馬地區的簡體中文朋友們可能會稱為事務。但我比較習慣稱為交易,而且本篇文章會用轉帳流程來講解,因此會使用Transaction與交易兩個詞彙。

基本介紹

什麼是Transaction?

資料庫的Transaction是指一系列的操作行為必須是一個不可分割的單元。它具有ACID特性,即原子性、一致性、隔離性和持久性,確保資料的完整性和可靠性。當一個Transaction執行時,要麼全部執行成功,要麼全部回滾(Rollback)到起始狀態,以確保資料庫的一致性。

首先要介紹交易是什麼,但這不是本篇文章的重點,因此交給ChatGPT幫忙回答。

在資料庫管理系統中,Transaction(交易)是指一系列的操作步驟,被視為一個不可分割的單元。Transaction 的目的是確保資料庫的一致性和完整性。

Transaction 應該滿足 ACID 屬性:

  1. 原子性(Atomicity):一個交易中的所有操作要麼全部執行,要麼全部不執行。如果任何一個操作失敗,整個交易都將回滾(Rollback),恢復到交易開始前的狀態。
  2. 一致性(Consistency):交易在執行前和執行後,資料庫必須保持一致的狀態。這意味著交易必須遵守預定義的規則和約束,以確保資料庫中的數據不會違反完整性規則。
  3. 隔離性(Isolation):每個交易都應該獨立運行,彼此互不干擾。並發執行的多個交易之間應該互相隔離,避免交叉影響,確保交易之間的執行是相互獨立的。
  4. 持久性(Durability):一旦交易成功完成,其所做的改變應該永久保存在資料庫中,即使在系統發生故障或重啟後也是如此。

Transaction 在應用程式中通常用於處理對資料庫的讀取和寫入操作。當應用程式需要執行一系列相關的數據操作時,可以將這些操作組合成一個交易,確保這些操作在資料庫層面上是原子性的,要麼全部成功執行,要麼全部回滾,以確保資料的一致性和完整性。

Transaction 的使用可以保護資料庫免受不完整或不一致的狀態,同時確保並發執行的多個操作之間的正確性。

為什麼需要Transaction?

例子一,以銀行轉帳為例

當A要將500元轉帳給B,我們可以假設為三個動作。

  1. 從A的餘額扣除500元
  2. 將B的餘額增加500元
  3. 新增A轉帳給B的紀錄(包含時間與金額)

我們設想可能有以下幾種狀況。

  • 如果在第一步完成就因為任何原因停止了,這樣A會少500元,B的餘額卻沒有改變。
  • 如果在第二步完成就停止了,雖然AB雙方的餘額正確了,日後卻查不到相關紀錄無法對帳。
  • 如果第一步第二步都失敗,第三步卻完成了,就會出現一筆糊塗

例子二,以搶購演唱會門票為例

搶購演唱會門票也可以假設為三個步驟。

  1. 將門票庫存減少一張。
  2. 新增一張使用者的轉帳繳費單。
  3. 將使用者的待繳費預留門票增加一張。

這個例子中沒弄好就會發生門票超賣,尤其是要對號入座的門票更不能接受這種事情。

從這兩個例子可以看出第一、第二、第三步必須全部都執行成功,不然就是全部都不要執行,否則會出現算不清的帳目。這個觀念對傳統關聯式資料庫(如MySQL)與NoSQL(如本文主角MongoDB)都是一樣的。

雖然我們可以透過寫更多程式、增加更多判斷來避免步驟執行失敗出現亂象,但如果打從資料庫管理系統就提供ACID是最好的。這也是早期MongoDB的弱項之一,因為MongoDB的原子性的範圍只有一個Document而已,沒辦法涉及多個Document。

如何讓MongoDB能夠交易?

在MySQL或是MariaDB上只要選擇預設的InnoDB引擎就可以使用交易功能,在大部分關聯式資料庫中都是預設啟動,可惜在MongoDB中不是。MongoDB的交易功能仰賴內建的分片與副本集機制,至少要啟動其中一個功能才能讓MongoDB擁有交易機制,至少到目前6.0版本都是如此。

這邊簡單介紹分片與副本集,兩者都是MongoDB叢集使用的功能,前者可以讓多台MongoDB主機合在一起增加容量與效能;後者可以讓多台MongoDB主機合在一起增加資料備份與安全。用磁碟陣列來比喻的話分片是RAID 0,副本集是RAID 1。

好在只有一台MongoDB主機也可以做成單節點的副本集,雖然這樣沒有資料備份與保護能力,但可以啟動MongoDB的交易,而且之後隨時可以增加主機節點變成真正的叢集。

設定MongoDB副本集

啟動副本集不困難,可以透過MongoDB啟動參數來設定,也可以透過CFG設定檔來設定。

第一種方法是找到啟動MongoDB服務的地方,加上啟動參數。

mongod --replSet rs0

第二種方式是找到MongoDB的設定檔案,在裡面加上副本集設定。在Windows中這可能會放在「C:\Program Files\MongoDB\Server\6.0\bin\mongod.cfg」,在Linux中可能會放在「/etc/mongod.conf」。

replication:
  replSetName: rs0

如果你使用Docker,可以用以下方式建立Docker Container。

# 只建立
docker create --name mongo_rs0 -p 27017:27017 mongo mongod --replSet rs0
# 建立與啟動
docker run --name mongo_rs0 -p 27017:27017 mongo mongod --replSet rs0

如果你使用Docker Compose,可以在docker-compose.yml輸入以下內容。

version: '3'
services:
  mongodb:
    image: mongo
    command: mongod --replSet rs0
    container_name: mongo_rs0
    ports:
      - 27017:27017

每種方式中都出現了「rs0」,這是整個副本集的名稱,可以取其他名字。

初始化副本集

選擇其中一種方式設定後重新啟動MongoDB服務,然後登入MongoDB進行副本集的初始化。

需要注意MongoDB 4.0的時候登入MongoDB的指令是mongo,從MongoDB 5.0開始要另外安裝mongosh來登入MongoDB。但是MongoDB的Docker Image有內建mongosh

如果你使用Docker,可以透過以下方式進入Docker Container中的MongoDB。

docker exec -it mongo_rs0 mongosh

進入MongoDB Shell輸入以下內容即可初始化。

rs.initiate(
    {
        _id: "rs0",
        members: [
            { _id: 0, host: "localhost:27017" }
        ]
    }
)

因為我們設定的是單節點MongoDB副本集,因此設定中的member只有localhost。

然後會發現MongoDB Shell的介面上多了PRIMARY的字樣,這代表我們現在位於副本集的主節點上(也是唯一的節點)。可以輸入rs.status()看到MongoDB的副本集狀態。


MongoDB交易教學

我們以轉帳為例,有一個儲存使用者名字與餘額的collection,稱為user。還有一個紀錄轉帳紀錄的collection,稱為log。

首先進入MongoDB Shell初始化一下測試用的資料。

db.user.insertMany([
    {
        _id: 'A',
        balance: 1000
    },
    {
        _id: 'B',
        balance: 1000
    },
    {
        _id: 'C',
        balance: '錯誤的資料型態'
    }
])

再來我們模擬A轉帳給B,轉帳200元。因此需要使用$inc減掉A的balance,然後增加B的balance,最後留下一筆log。

// 建立Session,等下才能建立交易
var session = db.getMongo().startSession()

// 預先建立我們需要的資料
var from = 'A'
var to = 'B'
var money = 200

// 開始Transaction
session.startTransaction()
// 使用Session來讀取db,此處命名為ssdb,以區隔原本的db
var ssdb = session.getDatabase('test')

// 扣除A的餘額
ssdb.user.updateOne({ _id: from }, { $inc: { balance: -money }})
// 增加B的餘額
ssdb.user.updateOne({ _id: to }, { $inc: { balance: money }})
// 儲存Log
ssdb.log.insertOne({ from: from, to: to, money: money })

// 在這邊先告一段落,你可以測試以下兩行程式碼看到的資料是不一樣的
db.user.find()   // 這邊看到的還是各1000元
ssdb.user.find() // 這邊看到的是已經變更過的餘額

// 以下兩行只能選一行來執行

// 完成交易,把一切資料真實儲存下來
session.commitTransaction()
// 也可以撤銷交易,一切都會回到原本的狀態
session.abortTransaction()

還記得我們上面有一個C帳號,裡面的餘額不是數字,因此各位可以試試看把接受轉帳的帳號改成C,也就是第六行改成var to = 'C',然後進行一模一樣的操作。會看到MongoDB拋出錯誤。

MongoServerError: Cannot apply $inc to a value of non-numeric type. {_id: "C"} has the field 'balance' of non-numeric type string

這時候再繼續session.commitTransaction()或是其他操作,會出現以下訊息告訴你Transaction已經因為Error回滾(Rollback)到初始狀態了。

MongoServerError: Transaction with { txnNumber: 4 } has been aborted.

再用db.user.find({_id: 'A'})可以看見A的餘額回到原本狀態,沒有因為轉帳失敗讓錢被系統吃掉了。