並發事務死鎖問題排查
業務系統上線后,服務日志報錯:
Jul 20 15:10:30 xxx: {"level":"error","error":"Error 1213: Deadlock found when trying to get lock; try restarting transaction","time":"2021-07-20T15:10:35.845197649+08:00","message":"error delete entities before insert"}
上游業務系統監聽多個topic,但不同topic有交集,交集為共同更新我們系統的某一張表。服務雖然一直在報錯,但是數據並沒有出現重復及丟失的情況。針對這個問題現象進行排查。
1 排查思路:
1.1 首先調研下mysql InnoDB鎖的詳細說明:
概念:
共享鎖(S Lock):允許事務讀一行數據,多個事務可以並發對某一行數據加S Lock
排他鎖(X Lock): 允許事務刪除或更新一行數據,只有行數據沒有任何鎖才可以獲取X Lock
共享鎖和排他鎖,就是我們日常見到的讀鎖和寫鎖。一個線程加了讀鎖,其他線程如果是讀取數據,也可以加讀鎖繼續讀取。而一旦有一個線程需要加寫鎖,前提是該數據沒有加鎖,如果當前數據已經加了讀鎖或者寫鎖,當前線程必須等到鎖釋放,才可以加寫鎖。
共享鎖和排他鎖,在InnoDB中對應的是行級別鎖。但是InnoDB除了支持共享鎖(S Lock)和排他鎖(X Lock),還支持表級別的兩把鎖,意向共享鎖(IS Lock)和意向排他鎖(IX Lock),意向共享鎖和意向排他鎖雖然是表級別的鎖實際應用在行級鎖之中,用來鎖定一個小范圍。IS Lock
事務想要獲得一張表中某幾行
的共享鎖; IX Lock
事務想要獲得一張表中某幾行
的排他鎖
- 行鎖 :鎖定一行數據,即我們常見的共享鎖和排查鎖
- 間隙鎖:鎖定一個范圍,但不包含記錄本身。例如數據庫中id為3,8,11,那么鎖定的區間可能為(-∞, 3), (3, 8), (8, 11), (11, +∞)。假如插入的數據id為6,那么此時鎖定的區間為(3, 6), (6, 8)被鎖定,不包括要插入的6
- 行鎖 + 間隙鎖:鎖定一個范圍,包括記錄本身。例如數據庫中id為3,8,11,那么鎖定的區間可能為(-∞, 3], (3, 8], (8, 11], (11, +∞]。那么假如插入id為6的數據,此時鎖定的區間為(3, 6], (6, 8]兩個部分,可以看到,6也被鎖定了。
1.2 間隙鎖有什么用?
我們了解了MySQL的InnoDB的常見鎖,了解了表級別間隙鎖會應用在行級別的范圍之中。那么間隙鎖有什么好處。
我們應該聽說過幻讀,即在同一事務下,連續執行兩次同樣的SQL
語句可能導致不同的結果,第二次的SQL
語句可能返回之前不存在的行。InnoDB
使用行鎖 + 間隙鎖的方式解決這個問題。當然,InnoDB
存儲引擎在查詢數據時是不存在鎖的,這是因為查詢的數據來自於快照版本,即歷史數據。
1.3 MySQL常見操作對鎖的應用
- Insert操作:數據庫插入一行數據時,需要獲取行鎖
- Update操作:更新一條記錄時,如果記錄存在,需要行鎖,如果不存在,需要行鎖+間隙鎖。
- Delete操作:刪除一條記錄時,如果記錄存在,需要行鎖;如果記錄不存在,行鎖+間隙鎖。
- Select操作:不會加鎖,因為查詢的數據主要來自於快照版本,即歷史數據。除非顯示的調用lock share mode或for update。
-- 顯示的為查詢添加共享鎖S Lock
select * from a where id = 1 lock in share mode ;
-- 顯示的為查詢添加排他鎖X Lock
select * from a where id = 1 for update ;
1.4 服務為啥會Deadlock
通過前期對Mysql InnoDB鎖相關資料的了解,分析我們系統為啥會出現大量的deadlock日志報錯。
Jul 20 15:10:30 xxx: {"level":"error","error":"Error 1213: Deadlock found when trying to get lock; try restarting transaction","time":"2021-07-20T15:10:35.845197649+08:00","message":"error delete entities before insert"}
造成死鎖競爭狀態后,mysql會將優先的事務提交,另一個事務釋放鎖,然后拋出報錯信息。
2 解決思路
並發情況下減少delete-insert事務操作
可以回避這種在事務中,delete-insert多線程操作的問題,例如我們可以先查數據是否存在,不存在不執行delete操作,避免不存在執行delete操作,觸發mysql的行鎖+間隙鎖機制。如果存在我們delete,只會用到mysql的行鎖。這就一定程度上避免了鎖競爭無法釋放的問題。但是這樣操作也會存在一定的風險,是否可以軟刪除,避免高並發情況下,出現數據已經被刪除,而其他事物正在刪除不存在的數據問題。
單進程下可考慮在事務上加鎖
sessionA和sessionB兩個事務,在競爭的情況下,刪除了不存在的記錄,會觸發mysql的行鎖+間隙鎖。主要出發點在於,與其在mysql競爭間隙鎖的過程中報錯,然后事務回滾,資源大量浪費,不如在進入事務之前進行並發控制。雖然鎖的粒度有點粗,但是相對於事務一直回滾,服務端不停打印錯誤日志,是更能接受的。
多進程高可用的情況
對於高可用多進程情況,可以通過分布式鎖結局。如果不想借助非mysql的外部鎖結局,那么也可以考慮對delete-insert事務進行排序,加入有序隊列中,挨個消化。這實質上也是變相做了同步操作。
思考方向:盡可能避免觸發mysql的間隙鎖。
3 最終解決辦法
單進程加了一個鎖,對多線程的delete-insert事務,同步處理。
// 對線程並發調用的方法
func (ei entitiesImpl) UpsertEntitis(ctx context.Context, id string, entities model.Entities) error {
conn, err := DB.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
// 對delete-insert做同步處理
entityMux.Lock()
defer entityMux.Unlock()
tx, err := conn.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return nil
}
res, err := tx.ExecContext(ctx, "delete from entities where id = ?", id)
if err != nil {
tx.Rollback()
return err
}
_, _ = res.RowsAffected()
for _, v := range entities {
_, err := tx.ExecContext(ctx, "INSERT INTO entities (`id`) VALUES (?)",v.id)
if err != nil {
tx.Rollback()
return err
}
}
tx.Commit()
huskar.Debug(ctx).Int("entities_size", len(entities)).Msg("insert new entities")
return nil
}
REF: https://blog.csdn.net/qq_40174198/article/details/111835482