線上併發事務死鎖問題排查

x1aoda1發表於2021-07-23

併發事務死鎖問題排查

業務系統上線後,服務日誌報錯:

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"}

deadlock原因

造成死鎖競爭狀態後,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
}

相關文章