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 決定如果已有事務要如何處理(預設是使用已有事務) 但沒有決定隔離級別的屬性
注意這個註解在以下場景有可能會失效
- 註解的函式不是 public 的
- 被繼承的基類的註解不會起作用
- propagation 配置導致如果已有事務會報錯,或是直接使用當前事務,而不會啟動新事務
- 同一個類中,方法 A 呼叫方法 B,但 A 沒註解而 B 有註解,這是因為 AOP 只對被當前類以外的程式碼呼叫的函式起作用
- 如果用 try...catch 捕獲異常但沒丟擲,同樣導致事務不起作用,必須讓事務自己處理異常並進行回滾操作
可以定義什麼異常需要 rollback 什麼異常不需要 rollback
事務內不要做其他事,最好單獨一個類處理
如果事務內做的事比較多,比如直接把註解加在 controller,可能會導致一些問題
- 事務可能太大,阻塞其他操作的時間可能比較久
- 事務內混合了其他業務操作,比如事務內發了個請求給其他服務修改資料,可能會導致這個事務被回滾的時候其他服務修改的資料沒被回滾,出現數據不一致
所以比較理想的做法,是有一個單獨的處理資料庫操作的類,這個類不做其他業務,並且只在需要的時候使用事務
鎖表 (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();
使用程式碼而不是註解可能靈活點但不夠方便