2021年12月8日 星期三

[PostgreSQL] 資料庫的Race Condition問題與交易隔離等級

資料庫的操作會併發處理,因此也會遇到Race Condition問題

此篇文章會說明各種問題情境與解法

這裡主要是考慮使用的是 PostgreSQL 資料庫的狀況,不一樣的資料庫可能會有不同結果


- 髒寫 (Dirty Write)

在前一個寫入還沒commit前,後面的寫入覆蓋前面的值,就是 Dirty Write

範例:

DB: X = 0

Transaction A:寫入 X = 10

Transaction B:寫入 X = 5

Transaction A:COMMIT

Transaction B:COMMIT

最後 X = 5,Transaction A 的寫入被蓋掉了


髒寫 (Dirty Write) 解法

幾乎所有 DB 的交易(Transaction)設計都可以防止 Dirty Write,這是因為交易要能rollback,若有dirty write的問題的話rollback沒辦法執行


資料庫如何防止 Dirty Write?

常見的方法是使用 Row-level Lock,當修改表的一筆數據需要先取得改row的鎖,直到交易提交才會釋放鎖,一次只有一個交易可持有特定筆數據的鎖

所以其實當在修改(update)表的某筆數據時,那筆列會被鎖住直到改筆交易Commit後釋放鎖,後進的交易如果也要修改同筆會被阻塞直到取得鎖


- 髒讀 (Dirty Read)

讀到未commit的值

範例:

DB: X = 0

Transaction A:寫入 X = 10

Transaction B:讀取 X 值,X = 10

Transaction A:rollback X,X 還原回 X = 0

問題:B讀到X = 10,但其實X=10從來沒有真的要寫入db裡(因為Transaction A最後沒 Commit )


髒讀 (Dirty Read) 解法

設定交易為 Read Commited 隔離等級

Read Commited 提供兩個保證:

1. 從資料庫讀時,只能看到已提交的資料(沒有髒讀)

2. 寫入資料庫時,只會覆蓋已經寫入的資料(沒有髒寫)

PostgreSQL 預設的交易隔離等級就是 Read Commited


- 不可重複讀 (Non-repeatable Read) (又稱讀取偏差,Read Skew)

在設定Read Committed的狀況,Dirty Read不會發生

因為Read Committed下讀DB只會讀到已經commited的值

但會變成有Non-repeatable read的問題

範例:

DB: X = 0

Transaction A:寫入 X = 10

Transaction B:讀取 X 值,X = 0

Transaction A:COMMIT

Transaction B:讀取 X 值,X = 10

問題:B兩次讀取A,讀到不同值


不可重複讀 (Non-repeatable Read) 解法

使用快照隔離(Snapshot Isolation),在 PostgreSQL 是稱作 Repeatable Read 隔離等級

快照隔離的交易只會看到在交易開始前已經被提交的資料;它永遠不會看見尚未提交的資料或者在交易期間被並行交易提交的變更

快照隔離下讀不會阻塞寫,寫也不會阻塞讀


- 更新丟失 (Lost Update)

兩個Transaction同時進行"讀取-修改-寫入序列",其中一個寫操作在沒有合併另一個寫入變更情況下,直接覆蓋了另一個寫操作的結果

範例:

DB: X = 0

Transaction A:讀 X,X = 0

Transaction B:讀 X,X = 0

Transaction A:寫入 X = X + 5

Transaction A:COMMIT

Transaction B:寫入 X = X + 10

Transaction B:COMMIT

最後 X = 10

問題:預期是A先將X更新成5,B有將A的更新考慮進去,B再將X更新成15才對

注意這跟 Dirty Write 不一樣,Transaction A 是已經 COMMIT 後才被蓋值,Dirty Write 則是在 COMMIT 前就被蓋值 (時序上有差)


更新丟失 (Lost Update) 解法

1. 使用資料庫提供的原子(Atomic)寫操作 

許多資料庫提供原子更新操作,像是:

UPDATE tb_x SET x = x + 1 WHERE id = 1;


2. 顯示鎖定

使用 FOR UPDATE SQL指令告訴資料庫應該對該查詢返回的所有行加鎖


3. 快照隔離自動檢查Lost Update

資料庫在交易是快照隔離等級會自動檢查是否有 Lost update 發生,一旦偵測到Lost update,會中止出問題的交易

範例:

import threading
from sqlalchemy import create_engine
from time import sleep
from sqlalchemy.orm import Session
from sqlalchemy import Column
from sqlalchemy.sql.sqltypes import BigInteger
from sqlalchemy.ext.declarative import declarative_base

engine = create_engine("postgresql://postgres:postgres@localhost:5432/mydb")

Base = declarative_base()


class Money(Base):
    __tablename__ = 'tb_money'
    id = Column(BigInteger, primary_key=True)
    user_id = Column(BigInteger)
    money = Column(BigInteger)


def update_op(name):
    try:
        print(f"start {name}")
        with Session(engine) as session:
            session.connection(execution_options={'isolation_level': 'REPEATABLE READ'})  # 設定交易的isolation level是repeatable read
            x = session.query(Money).filter(Money.user_id == 1).first()  # 讀取user_id=1的數據
            sleep(3)
            session.query(Money).filter(Money.user_id == 1).update({"money": x.money + 5})   # 更新user_id=1的數據
            print(f"finish {name}")
            session.commit()
    except Exception as ex:
        print(f"fail update {name} : {ex}")


t1 = threading.Thread(target=update_op, args=("thread 1",))
t2 = threading.Thread(target=update_op, args=("thread 2",))
t1.start()
t2.start()
t1.join()
t2.join()


結果:

其中一個交易會噴錯: (psycopg2.errors.SerializationFailure) could not serialize access due to concurrent update

噴錯的交易在重新執行一次就可以成功,在使用快照隔離等級以上的交易都要記得要處例這類conflict,進行retry


- 寫偏差 (Write Skew)

Write Skew 可視為 Lost Update 問題的一般化

一個交易讀取一些東西,根據它所看到的值作出決定,並將該決定寫入資料庫,但是,寫入時,該決定的前提不再是真實的

與 Lost Update 不同的點是 Write Skew 牽涉到多個物件,單物件的原子操作不起作用

範例:

DB: X = 1

Transaction A:讀 tb_user 與 X數量,是會員且X還有,可以借東西

Transaction B:讀 tb_user 與 X數量,是會員且X還有,可以借東西

Transaction A:X = X - 1

Transaction A:COMMIT

Transaction B:X = X - 1

Transaction B:COMMIT

最後 X = -1  (X 是東西數量,不應該會變負數)


寫偏差 (Write Skew) 解法

1. 序列執行

不要併發執行,資料庫只在單個執行緒上按順序一次只執行一個交易

效率差


2. 兩階段鎖定 (2PL,two-phase locking)

一種序列化演算法,透過在物件新增鎖阻塞讀寫來防止 Write Skew


鎖有兩種模式:共享模式 (shared mode) 與 排他模式 (exclusive mode)

(1) 若要讀取物件,須先以共享模式取得鎖,共享模式的鎖允許多個交易同時持有共享鎖,但如果另一個交易已經在物件上持有排它鎖,則這些交易必須等待

(2) 若交易要寫入,必須以排他模式取得鎖,沒有其他交易可以同時持有鎖(無論是共享模式還是排他模式)

(3) 如果先讀取再寫入物件,可以將共享鎖升級成排他鎖

(4) 直到交易Commit或rollback才會釋放鎖,兩階段指的是第一階段(當交易正在執行時)獲取鎖,第二階段(在交易結束時)釋放所有的鎖。

資料庫會自動檢測交易之間的死鎖,並中止其中一個,以便另一個繼續執行。被中止的交易需要由應用程式重試


鎖的設計還有謂詞鎖(predicate lock)索引範圍鎖(index-range locking),這類鎖不屬於特定的物件(例如,表中的一行),它屬於所有符合某些搜尋條件的物件,因為交易的讀寫不一定是只操作單列,會有區間,所以需要這類範圍鎖


3. 可序列化快照隔離(SSI, serializable snapshot isolation)

兩階段鎖定屬於悲觀併發控制機制(pessimistic),若有可能出錯,最好能等到安全後再做事

可序列化快照隔離則屬於樂觀的併發控制技術(opotimistic),如果有潛在危險也不會阻塞交易,而是繼續執行,當交易要Commit時資料庫再檢查是否有什麼不好的事情發生,如果是的話交易會被中止,並且需要重試


資料庫如何知道查詢結果是否可能已經改變?有兩種情況需要考慮:

- 檢測對舊MVCC物件版本的讀取(讀之前存在未提交的寫入)

- 檢測影響先前讀取的寫入(讀之後發生寫入)


from sqlalchemy import create_engine
import threading
from sqlalchemy.orm import Session
from sqlalchemy import Column, Integer
from sqlalchemy.sql.sqltypes import BigInteger
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import func

engine = create_engine("postgresql://postgres:postgres@localhost:5432/mydb")
Base = declarative_base()


class Foo(Base):
    __tablename__ = 'tb_foo'
    id = Column(BigInteger, primary_key=True)
    clas = Column(Integer)
    value = Column(Integer)

def update_op():
    try:
        with Session(engine) as session:
            session.connection(execution_options={'isolation_level': 'SERIALIZABLE'})
            x = session.query(func.sum(Foo.value)).filter(Foo.clas == 1).scalar()
            session.add(Foo(clas=1, value=x))
            session.commit()
            
    except Exception as ex:
        print(f"fail update : {ex}")

t1 = threading.Thread(target=update_op)
t2 = threading.Thread(target=update_op)
t1.start()
t2.start()
t1.join()
t2.join()


這在 isolation level 是 REPEATABLE READ 的狀況並不會有噴錯,但若換成 SERIALIZABLE 就會噴這樣的錯誤:


其中一個交易會噴錯:(psycopg2.errors.SerializationFailure) could not serialize access due to read/write dependencies among transactions

因為 SERIALIZABLE 要保證交易所做的動作都是有效的,像上例可能因為 thread 1 後來插入一筆數據,這就讓 thread 2 的第一個動作查詢結果變得無效不正確,因此噴錯


結論

交易隔離等級: Serializable > Repeatable read > Read committed

在交易要用到Repeatable read以上的狀況都要處理conflict retry


參考資料/推薦閱讀

1. http://ddia.vonng.com/#/zh-tw/ch7?id=%e7%ac%ac%e4%b8%83%e7%ab%a0%ef%bc%9a%e4%ba%8b%e5%8b%99



沒有留言:

張貼留言