1. 程式人生 > 其它 >Postgres 的事務和鎖

Postgres 的事務和鎖

[loc]

事務命令

postgres=#
postgres=# \h begin
Command:     BEGIN
Description: start a transaction block
Syntax:
BEGIN [ WORK | TRANSACTION ] [ transaction_mode [, ...] ]

where transaction_mode is one of:

    ISOLATION LEVEL { SERIALIZABLE | REPEATABLE READ | READ COMMITTED | READ UNCOMMITTED }
    READ WRITE | READ ONLY
    [ NOT ] DEFERRABLE

postgres=#
postgres=# \h end
Command:     END
Description: commit the current transaction
Syntax:
END [ WORK | TRANSACTION ]

postgres=#
postgres=# \h commit
Command:     COMMIT
Description: commit the current transaction
Syntax:
COMMIT [ WORK | TRANSACTION ]

begin 和 end (或者 commit) 之間的所有 SQL 組成一個事務,資料庫保證同一個事務的所有操作或者都成功,或者都回滾,同時隔離不同的事務防止其互相干擾

最典型的例子就是轉賬,必須保證轉出和轉入都成功,如果轉出成功但轉入失敗,應該能回滾

事務隔離級別

事務之間有以下幾種不同的隔離級別,由低(寬鬆)到高(嚴格)分別是

  • READ UNCOMMITTED : 讀未提交,可以讀到其他會話未提交的資料,等於沒隔離,PG 不支援,但可以設定,只是會被當成 READ COMMITTED
  • READ COMMITTED : 讀已提交(預設),只能讀到其他會話已提交的資料,有寫鎖避免同時修改同一個資料,修改同一資料需要等待直到先做修改的事務提交
  • REPEATABLE READ : 可重複讀,事務開始後,不會讀到其他會話提交的資料,有寫鎖避免同時修改同一個資料,並且如果修改同一資料會報錯
  • SERIALIZABLE : 序列化,最嚴格,哪怕沒修改同一個資料,同樣可能會有衝突

可以 begin 的同時設定隔離級別

postgres=# begin transaction isolation level repeatable read;
BEGIN

也可以 begin 後再設定

postgres=# set transaction isolation level repeatable read;
SET

檢視隔離級別

postgres=# show transaction_isolation;
 transaction_isolation
-----------------------
 repeatable read
(1 row)

不設定預設就是 READ COMMITTED

READ COMMITTED 讀已提交 (預設)

在會話 1 開啟事務並檢視

postgres=#
postgres=# begin;
BEGIN
postgres=#
postgres=# select * from test;
 id |  name
----+--------
  1 | name_A
  3 | name_C
  4 | name_d
  2 | name_b
(4 rows)

postgres=#
postgres=# select * from test where id = 2;
 id |  name
----+--------
  2 | name_b
(1 row)

在會話 2 開啟事務並更新

postgres=# begin;
BEGIN
postgres=#
postgres=# update test set name = 'name_bb' where id = 2;
UPDATE 1

繼續在會話 1 檢視,可以看到查詢結果沒有改變

postgres=# select * from test where id = 2;
 id |  name
----+--------
  2 | name_b
(1 row)

postgres=#
postgres=# select * from test;
 id |  name
----+--------
  1 | name_A
  3 | name_C
  4 | name_d
  2 | name_b
(4 rows)

在會話 2 提交事務

postgres=# end;
COMMIT

繼續在會話 1 檢視,可以看到查詢結果變了

postgres=# select * from test where id = 2;
 id |  name
----+---------
  2 | name_bb
(1 row)

postgres=#
postgres=# select * from test;
 id |  name
----+---------
  1 | name_A
  3 | name_C
  4 | name_d
  2 | name_bb
(4 rows)

在會話 2 再啟動事務,改資料,但不提交

postgres=# begin;
BEGIN
postgres=#
postgres=#
postgres=# update test set name = 'name_a22' where id = 1;
UPDATE 1
postgres=#
postgres=#

在會話 1 修改同一條記錄,可以看到,無法執行,在等待

postgres=# update test set name = 'name_a11' where id = 1;

在會話 2 提交事務

postgres=# end;
COMMIT

這時會話 1 的修改才被執行

postgres=# update test set name = 'name_a11' where id = 1;
UPDATE 1

所以這種模式就是,不能檢視其他事務未提交的資料,可以檢視其他事務已提交的資料,並且有行寫鎖,不允許同時修改同一行資料,除非另一個事務提交了

REPEATABLE READ 可重複讀

在會話 1 開啟事務並檢視

postgres=# begin transaction isolation level repeatable read;
BEGIN
postgres=#
postgres=# select * from test where id = 2;
 id |  name
----+--------
  2 | name_b
(1 row)

postgres=#
postgres=# select * from test;
 id |  name
----+--------
  1 | name_A
  3 | name_C
  4 | name_d
  2 | name_b
(4 rows)

在會話 2 開啟事務並更新,然後提交事務

postgres=# begin;
BEGIN
postgres=#
postgres=# update test set name = 'name_bb' where id = 2;
UPDATE 1
postgres=#
postgres=# end;
COMMIT

繼續在會話 1 檢視,可以看到查詢結果沒有改變,哪怕事務 2 已經提交了,這樣保證事務 1 的查詢結果的一致性

postgres=# select * from test where id = 2;
 id |  name
----+--------
  2 | name_b
(1 row)

postgres=#
postgres=# select * from test;
 id |  name
----+--------
  1 | name_A
  3 | name_C
  4 | name_d
  2 | name_b
(4 rows)

在會話 2 再啟動事務,新增新資料,並提交事務

postgres=# begin;
BEGIN
postgres=#
postgres=# insert into test values(5, 'name_e');
INSERT 0 1
postgres=#
postgres=# end;
COMMIT

在會話 1 繼續檢視,可以看到資料還是沒有改變,哪怕事務 2 新增新資料並提交

postgres=# select * from test where id = 2;
 id |  name
----+--------
  2 | name_b
(1 row)

postgres=#
postgres=# select * from test;
 id |  name
----+--------
  1 | name_A
  3 | name_C
  4 | name_d
  2 | name_b
(4 rows)

在會話 1 提交事務再檢視,可以看到事務 2 修改的資料和新增的資料,都可以看到了

postgres=# end;
COMMIT
postgres=#
postgres=# select * from test;
 id |  name
----+---------
  1 | name_A
  3 | name_C
  4 | name_d
  2 | name_bb
  5 | name_e
(5 rows)

在會話 1 再啟動事務,這次不做 select all 只先檢視一條資料

postgres=# begin transaction isolation level repeatable read;
BEGIN
postgres=#
postgres=# select * from test where id = 2;
 id |  name
----+---------
  2 | name_bb
(1 row)

在會話 2 再啟動事務,修改新增資料,並提交事務

postgres=# begin;
BEGIN
postgres=#
postgres=# update test set name = 'name_b' where id = 2;
UPDATE 1
postgres=#
postgres=# update test set name = 'name_cc' where id = 3;
UPDATE 1
postgres=#
postgres=# insert into test values(6, 'name_f');
INSERT 0 1
postgres=#
postgres=# end;
COMMIT

在會話 1 繼續檢視,可以看到資料還是沒有改變,哪怕事務 2 修改新增的資料,事務 1 之前並沒有命中

postgres=# select * from test where id = 2;
 id |  name
----+---------
  2 | name_bb
(1 row)

postgres=#
postgres=# select * from test where id = 3;
 id |  name
----+--------
  3 | name_C
(1 row)

postgres=#
postgres=# select * from test;
 id |  name
----+---------
  1 | name_A
  3 | name_C
  4 | name_d
  2 | name_bb
  5 | name_e
(5 rows)

在會話 1 提交事務,再檢視資料,可以看到事務 2 的修改了

postgres=# end;
COMMIT
postgres=#
postgres=# select * from test;
 id |  name
----+---------
  1 | name_A
  4 | name_d
  5 | name_e
  2 | name_b
  3 | name_cc
  6 | name_f
(6 rows)

這種模式下同樣會有行寫鎖,如果修改同一行資料,需要等另一個事務先完成,但和 READ COMMITTED 不同的是,等另一個事務完成後,當前事務會失敗,報如下錯誤

postgres=# update test set name = 'name_A22' where id = 1;
ERROR:  could not serialize access due to concurrent update

使用 select ... for update 同樣會報這個錯誤

出現這個錯誤就需要退出事務,然後從頭開始重新執行事務

可以看到這個模式比 READ COMMITTED 更嚴格,它保證事務開始後,對資料的讀寫,完全不受其他事務的影響

如果兩個事務修改同一行資料,不僅會等待還會報錯,需要重新執行事務,或需要通過應用程式的鎖實現兩個事務的互斥

SERIALIZABLE 序列化

和 Repeatable Read 幾乎一樣的,只是更加嚴格地,保證兩個事務不衝突,保證序列化

在會話 1 啟動事務,執行下面命令

postgres=# begin transaction isolation level serializable;
BEGIN
postgres=#
postgres=# select count(*) from test where id = 3;
 count
-------
     1
(1 row)

postgres=#
postgres=# insert into test values(1, 'name_a1');
INSERT 0 1
postgres=#

在會話 2 啟動事務,執行下面命令,並提交

postgres=# begin transaction isolation level serializable;
BEGIN
postgres=#
postgres=# select count(*) from test where id = 1;
 count
-------
     1
(1 row)

postgres=#
postgres=# insert into test values(3, 'name_c2');
INSERT 0 1
postgres=#
postgres=# end;
COMMIT
postgres=#

在會話 1 提交事務,發現報錯了

postgres=# end;
ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
HINT:  The transaction might succeed if retried.
postgres=#

如果在 REPEATABLE READ 模式是不會報錯的,因為兩個事務沒有修改相同資料的衝突

但實際上,這兩個事務互相依賴,即事務 1 的 insert 命令影響事務 2 的 count 命令,而事務 2 的 insert 命令影響事務 1 的 count 命令,如果都允許提交,會導致資料不一致,所以後面提交的事務就報錯了

如果是 A 影響 B,而 B 影響 C,且 C 又影響 A 的迴圈,同樣會有這樣的問題

@Transactional 註解

@Transactional 註解可以用在類或函式上,使得進入函式的時候會啟動事務,離開函式的時候會提交事務

這個註解可以是 javax.transaction.Transactional
或是 org.springframework.transaction.annotation.Transactional (用於 springboot)

後者功能更強,並有 propagation 決定如果已有事務要如何處理(預設是使用已有事務) 和 isolation 決定隔離級別
前者有 value 決定如果已有事務要如何處理(預設是使用已有事務) 但沒有決定隔離級別的屬性

注意這個註解在以下場景有可能會失效

  1. 註解的函式不是 public 的
  2. 被繼承的基類的註解不會起作用
  3. propagation 配置導致如果已有事務會報錯,或是直接使用當前事務,而不會啟動新事務
  4. 同一個類中,方法 A 呼叫方法 B,但 A 沒註解而 B 有註解,這是因為 AOP 只對被當前類以外的程式碼呼叫的函式起作用
  5. 如果用 try...catch 捕獲異常但沒丟擲,同樣導致事務不起作用,必須讓事務自己處理異常並進行回滾操作

可以定義什麼異常需要 rollback 什麼異常不需要 rollback

事務內不要做其他事,最好單獨一個類處理

如果事務內做的事比較多,比如直接把註解加在 controller,可能會導致一些問題

  1. 事務可能太大,阻塞其他操作的時間可能比較久
  2. 事務內混合了其他業務操作,比如事務內發了個請求給其他服務修改資料,可能會導致這個事務被回滾的時候其他服務修改的資料沒被回滾,出現數據不一致

所以比較理想的做法,是有一個單獨的處理資料庫操作的類,這個類不做其他業務,並且只在需要的時候使用事務

鎖表 (lock 命令)

鎖表只能在事務中

postgres=# \h lock
Command:     LOCK
Description: lock a table
Syntax:
LOCK [ TABLE ] [ ONLY ] name [ * ] [, ...] [ IN lockmode MODE ] [ NOWAIT ]

where lockmode is one of:

    ACCESS SHARE | ROW SHARE | ROW EXCLUSIVE | SHARE UPDATE EXCLUSIVE
    | SHARE | SHARE ROW EXCLUSIVE | EXCLUSIVE | ACCESS EXCLUSIVE

  • ONLY 表示只鎖當前表,否則當前表及其後代表都會被鎖住
  • name 是表的名字
  • lockmode 指定哪些鎖會與當前鎖衝突,預設是 ACCESS EXCLUSIVE 即所有都衝突
  • NOWAIT 表示如果有另一個鎖鎖住了這個表,那 lock 命令是等待,還是直接報錯返回

鎖住表後,其他會話對這個表的所有操作,哪怕最簡單的 select 命令都會阻塞,直到擁有鎖的事務結束,鎖被釋放

鎖行 (排它鎖 select ... for update 和共享鎖 select ... for share)

鎖行不在事務內不會報錯,但不會起作用,所以還是要在事務內執行

SELECT ... FOR { UPDATE | SHARE } [ OF table_name [, ...] ] [ NOWAIT | SKIP LOCKED ] [...]
  • select for update 表示排他鎖,或者叫寫鎖,鎖住命中的行,不允許其他會話執行 select for update 或 select for share,但 select 可以
  • select for share 表示共享鎖,或者叫讀鎖,鎖住命中的行,不允許其他會話執行 select for update 但可以執行 select for share 或 select
  • of table_name 指定要鎖住的表 (select 語句可能涉及多個表)
  • nowait 表示如果無法獲取鎖,要等待,還是直接報錯
  • skip locked 表示要不要跳過無法獲取鎖的行並立刻返回 (select 的結果可能部分被鎖,部分沒被鎖,預設有被鎖的就等待或報錯)

依靠事務的隔離級別,需要真正修改資料的時候才鎖,或者要等到提交事務時才知道有衝突,甚至會報錯

使用 select for update/share 方便自己控制,而且能處理一些依靠事務不好處理的場景

頁級鎖

https://www.postgresql.org/docs/12/explicit-locking.html#LOCKING-PAGES

不瞭解,知道有這個就好,基本不會用到

死鎖

如果執行緒 A 先鎖資料 1 再鎖資料 2,而執行緒 B 先鎖資料 2 再鎖資料 1,並且沒有超時機制

這時如果兩個執行緒同時執行並且互相等待對方釋放鎖,就造成了死鎖

減少死鎖方法

  • 按相同順序鎖住資料
  • 超時機制
  • 事務儘可能簡單,減少鎖住的時間
  • 儘量使用較低隔離級別

遵循這種設計一般就不會有死鎖

@Lock 註解

@Lock 註解可以用在函式上,

注意 javax.ejb.Lock 不是資料庫的,而是給函式加讀鎖或者寫鎖的

資料庫的是 org.springframework.data.jpa.repository.Lock

@Lock(value = LockModeType.PESSIMISTIC_READ)
@Query(value = "select t from User t where t.name = :name")
User findByUserName(@Param("name") String name);

鎖模式

  • PESSIMISTIC_READ:悲觀讀鎖,或共享鎖,就是 select ... for share nowait
  • PESSIMISTIC_WRITE:悲觀寫鎖,或排他鎖,就是 select ... for update nowait
  • READ:樂觀讀鎖,實際上沒有鎖,要求表有 version 欄位,通過檢查 version 欄位在操作前後的一致性來保證不衝突
  • WRITE:樂觀寫鎖,在 READ 的基礎上,操作結束後不僅會檢查 version 欄位,還會對 version + 1
  • OPTIMISTIC:和 READ 一樣
  • OPTIMISTIC_FORCE_INCREMENT:和 WRITE 一樣
  • PESSIMISTIC_FORCE_INCREMENT:在 PESSIMISTIC_WRITE 基礎上操作後對 version + 1

READ 是操作前取 version (或者操作的第一步一起取了),操作後執行

select version from [table] where [key] =?

對 version 複查

WRITE 操作後執行的是

update [table] set version=[操作前的值+1] where [key]=? and version=[操作前的值]

不僅對 version 複查,還加 1

悲觀鎖和樂觀鎖比較

悲觀鎖,是先取鎖再操作,會減少併發能力,影響效能,優點是有真正的排他性,適合要求嚴格、衝突概率較大、併發要求不高的場景

樂觀鎖,先操作,提交時再檢查 version 欄位,不依賴資料庫機制,併發能力強,效能好,適合讀多寫少、大概率不衝突、要求高併發的場景,缺點是沒有真正的排他性,存在資料不一致的可能

JAP 如何不靠註解使用事務和鎖

    // 如果是用 @Autowired 或 @Inject 等方式注入 manager 的話,可能會報錯
    // Not allowed to create transaction on shared EntityManager - use Spring transactions or EJB CMT

    EntityManager entityManager = entityManagerFactory.createEntityManager();
    entityManager.getTransaction().begin();

    String sql = "select u from User as u where name = " + name;
    Query query = entityManager.createQuery(sql);

    query.setLockMode(LockModeType.PESSIMISTIC_WRITE);

    User user = (User) query.getResultList().get(0);
    System.out.println(user);

    entityManager.getTransaction().commit();

使用程式碼而不是註解可能靈活點但不夠方便