RDBMS - 不同的 Isolation Level Race Condition 示範

今天試著使用 PostgreSQL 搭配各種 Transaction Isolation Level 加上 JMeter 模擬同時多個 Request 造成 Race Condition 的情況,只有自己親手試過才知道 Race Condition 的可怕!!

背景準備

首先要先知道的一件事情是 PostgreSQL 預設的 Transaction Isolation Level 是什麼呢?

可以透過以下指令得知:

1
SHOW TRANSACTION ISOLATION LEVEL;

會得到以下內容:

沒錯,預設的交易隔離層級是 read committed

儘管 PostgreSQL 官方文件有說它有四種交易隔離層級,分別是:

read uncommittedread committedrepeatable readserializable,但事實上,官方有說儘管將交易隔離層級設在 read uncommitted,預設還是會變成 read committed 的層級。因此,沒有辦法模擬 Dirty Read 的 Race Condition 的情況。

畢竟 Dirty Read,是無法接受的層級,會導致讀取到的資料可能是尚未 Commit 的並行交易寫入的。此種的 Race Condition 我想任何的系統都難以承受吧 XD

因此在這邊會示範 Non-repeatable read、Phantom read 這兩種 Race Condition 情況,所對應的交易層級就是 read committedrepeatable read

在這邊沿用前面幾篇文章提到的日子,分別是使用者購買機票的情境,因此這邊先建立 users、flights、flight_misc_cost 這三個 Table。

建立 Table

  • user

    1
    2
    3
    4
    5
    6
    7
    CREATE TABLE users (
    id uuid,
    name varchar(20) not null,
    balance integer not null,

    CONSTRAINT "users_pk" PRIMARY KEY (id)
    )
  • flight

    1
    2
    3
    4
    5
    6
    7
    CREATE TABLE flights (
    id uuid,
    flight_name varchar(100) not null,
    price integer not null,

    CONSTRAINT "flights_pk" PRIMARY KEY (id)
    )
  • flight_misc_cost

    1
    2
    3
    4
    5
    6
    7
    CREATE TABLE flight_misc_cost (
    id bigserial
    flight_name varchar(100),
    item_name varchar(100) not null,
    cost integer not null,
    CONSTRAINT "flight_misc_cost_pk" PRIMARY KEY (id)
    )

插入數據

  • users

    1
    2
    3
    INSERT INTO users (id, name, balance) VALUES('1c90d0ea-5789-43c4-9d4d-f046f0fe7fd2', 'Kenny', 300)
    INSERT INTO users (id, name, balance) VALUES('2e16f733-2ad2-4f4d-bbd5-a87bf18b9908', 'Jack', 300)
    INSERT INTO users (id, name, balance) VALUES('6c7ce96a-d13f-4b86-9dfd-3dda2121130e', 'Nicole', 300)
  • flights

    1
    INSERT INTO flights (id, flight_name, price) VALUES('76f8d083-a96c-4a18-b204-aebcefb1e982', 'HKG-->BKK', 200)
  • flight_misc_cost

    1
    2
    3
    INSERT INTO flight_misc_cost(flight_name,
    item_name, cost) VALUES
    ( 'HKG-->BKK', '機票費', 50)

Non-repeatable read 之 Race Condition 情況示範

在這邊,透過 JMeter 可以直接連接 DB 進行 SQL 語句的測試,而不需要額外撰寫 RESTful API 來測試。

建立 JDBC 連線設定

首先在測試計畫下面新增一個 JDBC 連線設定,其裡面的設定內容如下:

設定內容講解:

  • 變數名稱

    資料庫連接池的名稱,這個名稱是為了等等要建立 JDBC Request 的時候,要填入相同的名稱,藉此拿到該 DB 的連接才能對 DB 進行操作。

  • 資料庫連線池設定

    這邊裡面依照裡面的預設值即可,不需做更動,可以注意到的時候可以設定是否自動 Commit、Transaction Isolation 設定等等。這邊採取自動 Commit 即可,而 Transaction Isolation 就讓它拿取資料庫的預設層級即可。

  • 資料庫連線設定

    裡面的這些設定才是比較重要的,填了之後才能連到 DB。

    1. 資料庫 URL

      依據不同的資料庫,其 URL 也不一樣,這邊是採用 PostgreSQL 來做示範。

    2. JDBC 驅動程式

      選擇 org.postgresql.Driver。這邊特別要注意的是,這樣並不是說 JMeter 會幫你下載 Driver 的 Jar Library,而是你需要另外去下載此 Library。這個部分等等在講解怎麼操作~

    3. 使用者

      也就是連接資料庫的使用者帳號。

    4. 密碼

      也就是連接資料庫的使用者密碼

JMeter 載入 Jar 檔

首先,可以先到這個網址去下載 PostgreSQL Driver Jar 檔:https://jdbc.postgresql.org/download.html

下載 Jar 檔後,要讓 JMeter 能夠載入這個 Jar 檔,有兩種方式:

在測試計畫上,最下面有個 Add Jar 的方式:

在下面瀏覽那邊點集之後,選的路徑就是 Jar 檔的路徑就可以了。但是這樣的方式比較麻煩,只是對測試計畫下的所有 Request 有作用而已。

因此,可以採用第二種全局的方式:

在 JMeter 的安裝目錄下,有一個 lib 資料夾,將 PostgreSQL Driver Jar 丟進去就可以了,不需要做其他額外的設定,JMeter 會自動到該資料夾內找尋需要的 Jar 檔。

建立執行緒群組並在裡面新增 JDBC Request

建立執行性群組:

建立 JDBC Request:

首先注意,變數名稱,這邊就是剛剛說的,要填入你在 JDBC 連線設定那邊填入的變數名稱一模一樣才行,JMeter 才能找到。

而這邊執行的 SQL 語句流程就是,顧客 Kenny 查詢機票錢

因此在 Query Type 選擇 Select Statement。

並在下方打出 SQL 語句:

1
2
SELECT price FROM flights
WHERE flight_name = 'HKG-->BKK';

到這邊就完成了。

此時,我們在新增一個 JDBC Request:

這個流程叫做,顧客 Kenny 更新自己的錢包。因為我們要示範的例子是,當顧客查詢當前的機票錢後,發現可以買該機票,因此下一步就是買機票,也就是扣掉自己錢包裡面的錢。

所以一個使用者的操作是分為兩個操作:查詢 + 更新

還記得前面我們插入三筆顧客的資料,因此這邊就做出三組執行緒群組及群組下面有兩個 JDBC Request 進行模擬。

因此目錄就會變成這樣:

建立公司的 JDBC Request

最後建立公司的執行緒群組及 JDBC Request:

這個流程叫做航空公司更改價錢,也就是更改機票的價錢。

所以到這邊可以理解整個模擬的流程是:三個使用者各自查詢機票價錢,下一步購買機票因此錢包的錢會減少,而公司在同時間也會進行機票價錢的更改。

開始模擬測試!

此時開始模擬測試,究竟哪位顧客會被發生 Race Condition 呢?也就是說顧客當下查到的機票價錢是 200 塊錢,然而公司卻在之後更改的機票價錢,而顧客最後就被扣了 300 塊錢!

我們來執行三次看看,看有怎樣的結果

第一次測試

這個是執行 SQL 語句順序,要記住的是每個顧客查詢機票動作一定會在更新動作之前,但是哪個顧客的動作會先發生是隨機的,航空公司更改價錢也會是隨機,JMeter 相當於就是幫我們模擬隨機的情況,看哪個動作率先被執行或被完成而定。

這個結果是最好的結果:

代表並沒有發生 Race Condition,因為航空公司更改價錢的結果被放在最後面,代表前面顧客查到的價錢都是原有的價錢,扣的錢也是原有的價錢,並沒有誤扣款的問題發生!航空公司也沒有因此而詐騙獲取了更多的錢。所以此時去看資料庫的話,可以發現每位顧客的錢包都剩下一百元,而不是剩下零元,如果剩下零元代表扣了三百元,而三百元就代表航空公司更改機票後的價錢,原本機票的價錢是兩百元。

第二次測試

從這個結果可以看出,只能說顧客 Nicole 最慘,因為它購買機票的動作是在航空公司更改價錢的動作之後,因此會被扣三百元,但這樣其實也不是 Race Condition 發生!因為預設的情境是使用者會先查詢機票錢,看價錢在決定購買,因此查詢動作還是在航空公司更改價錢後,所以 Nicole 還是先知道更改後的價錢了,可以選擇買不買的!

第三次測試

沒錯!發生了 Race Condition 的情況,為什麼呢?因為仔細看 Jack 跟 Nicole 查詢機票錢的動作皆是在航空公司更改價錢前,而更新自己的錢包卻是在航空公司更改價錢後。代表原本使用者查詢到的是兩百元,結果最後錢包就被扣了三百元,變成零元了!

發生這種事情還不天下大亂?之後航空公司就要跟消保官解釋囉~~

總結

從這邊的三次測試我們可以知道,Race Condition 並不是一定會發生,而是有機率會發生。尤其現在才三位使用者,Request 的數量並不多,有可能發生 Race Condition 的機率很低,所以有可能用 JMeter 測試也跑個幾次才會出現 Race Condition 的情況。

但是現實是,航空公司買票系統會同時那麼少人買嗎?更何況如果出現搶票的情況呢?所以這種低級的 Race Condition 是絕對要避免的。

再次理解 Non-repeatable read 現象是:

在 Read Committed 交易隔離層級下,會出現 Non-repeatable read 現象,也就是會讓同一 Record 在不同的 select 中顯示不同的數值。

Phantom read 之 Race Condition 情況示範

在測試之前,要先將 PostgreSQL 的交易隔離層級設為 repeatable read,也就是在 JDBC 連線設定那邊將 Transaction level 設為 repeatable read,這邊要注意的是它這個設定是針對當下的 Transaction 的意思,因此並不是將資料庫全局設定的 Transaction level 都改為 repeatable read 的意思。

建立 JDBC 連線設定

要注意的就是將 Transaction Isolation 改為 Repeatable read。

建立執行緒群組並在裡面新增 JDBC Request

這個步驟是,顧客查詢該機票的總價錢。

這個步驟是顧客更新自己的錢包,也就是扣掉機票的總成本價錢,購買機票的意思!

所以可以注意的是,這邊採用的是拿取總成本,因此會用到 SUM 語法。這邊一樣模擬三位顧客的方式,每個顧客會有兩個 JDBC Request。而為了之後測是好看出來 Race Condition,建議先將資料庫的三位使用者的 balance 都設為 60 元。因為如果沒遇到 Race Condition,則查到的總成本是 50 元,因此最後顧客會剩下 10 元,然而如果遇到 Race Condition,顧客最後會剩下 0 元,因為中途航空公司多插入了一筆費用。

建立公司的 JDBC Request

也就是航空公司對同樣的航班多插入一筆費用。

開始模擬測試!

釐清整個測試流程是:

三位顧客會先有查詢機場費的動作,在來更新自己的錢包,而航空公司會有對同樣的機場費多插入一筆費用的動作。

第一次測試

第一次測試就遇到了 Race Condition!注意看 Jack 查詢機場費是在航空公司徵收燃油附加費之前,而更新自己的錢包卻是在之後,造成 Jack 的錢包多扣了 10 元!因為機場費用被多插入了一筆!

因此可以看到 Kenny、Nicole 皆剩下 10 元,而 Jack 則變成 0 元!航空公司又要去找消保官解釋了 XD

第二次測試

還是發生了 Race Condition,Nicole 被多扣了十元,Jack 則是查詢價錢的時候就知道價錢不一樣了,因此在這情境下可以選擇買或不買。

第三次測試

這次沒發生了 Race Condition,Kenny 被扣了 50 元,Nicole、Jack 則是在航空公司多插入一筆費用後才查詢價錢,因此可以決定買或不買,並沒有航空公司詐騙的嫌疑。

總結

可以發現 Race Condition 機率好像提高了,然而這樣的 Race Condition 還是機率的問題,而同樣的如果使用者數量一堆同時發送 Request,就更容易造成 Race Condition 的問題。

總結何謂 Phantom Read:

Phantom read 會讓第二次的 query 出現了新的 Record。

總結

這次利用 JMeter 模擬了 Non-repeatable 及 Phantom Read 兩種 Race Condition 的讀取現象。你可能會想說:既然要解決這兩種讀取現象提高交易隔離層級不就好了?沒有錯,在 Non-repeatable 測試計畫那邊將交易隔離層級改為 Repeatable Read,就不會發生 Non-repeatable,而 Phantom Read 測試計畫將交易隔離層級改為 Serializable 就不會發生 Phantom Read。

但是,之前說過當交易隔離層級越往上面,其效能就會越來越低落,但像對安全性就會越高,但如果是搶票系統如果你採取那麼高的交易隔離層級,你覺得搶票系統撐得了嗎?

因此,其實是有辦法在 Read Committed 交易隔離層級下,解決 Non-repeatable 及 Phantom Read 兩種讀取現象的,只要利用一些小技巧就可以達成!如此並不會使資料庫效能過於低落!

這個就等下篇文章,在來解釋是那些技巧,以及在用 JMeter 測試看看是否會有 Race Condition 吧!

這邊提供 JMeter 的測試計畫的檔案,方便給大家實際測試看看~

Non-repeatable

/download/Phantom read.jmx