解決使用者連續 Call API 產生 Deadlock

Sandi Wu
iCHEF
7 min readJun 8, 2023

--

之前在試圖解決使用者連續 Call API 會發生 Deadlock 的問題,過程中連帶發現了一些問題,改善後讓 API 的執行時間更少,我把這個過程記錄下來,主要包含了加速 API 過程、以及試圖解決 Deadlock,希望可以對你有一點點啟發。

DeadLock 是什麼

A deadlock is a situation where different transactions are unable to proceed because each holds a lock that the other needs. Because both transactions are waiting for a resource to become available, neither ever release the locks it holds.

以上是 MySQL 官方文件針對 Deadlock 的解釋,舉例來說,Transaction A 拿到一個 Lock X,Transaction B 拿到一個 Lock Y,而互相都需要彼此的 Lock,A 等 B、B 等 A,都等不到彼此釋放,進而產生 Deadlock。

什麼時候會發生 Deadlock

A deadlock can occur when transactions lock rows in multiple tables (through statements such as UPDATE or SELECT ... FOR UPDATE), but in the opposite order. A deadlock can also occur when such statements lock ranges of index records and gaps, with each transaction acquiring some locks but not others due to a timing issue.

以這次的例子來說,也是因為 Call 同一個 API 產生兩個 Transaction,想要針對相同的資料進行更改,但沒有限定 Order 所以造成 Deadlock。

先看成果

經過優化後,API 時間 50% 的人只需要 3 秒、最多資料需要下載的店家只要 30 秒,相比優化前 50% 的人需要 7.5 秒、99% 的人需要 77 秒大幅下降了許多,並透過 Redis Lock 避免 Deadlock。

第一步:用 Decorator 記錄每個 Function 所花的時間

為了更清楚是哪個 Function 在慢,所以先寫了一個 Decorator 紀錄 Function 所花費的時間,再把所有的登入會走過的 Function 都加上這個 Decorator。

def log_time(func):
@wraps(func)
def func_wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
spent_seconds = round((end_time - start_time), 2)
logging.info(f"{func.__qualname__}: Spent {spent_seconds} s")
return result

return func_wrapper

第二步:加速最花時間的地方

透過上面的 Log 追蹤,挑出發現最花時間的地方,是某一兩個行為,因為使用 ORM 的關係,所以第一步會先把 ORM 真正執行的 SQL 印出來看,果然有 N+1 的問題,先透過優化 Query 來加速整體的時間。

第三步:用 Redis lock 避免使用者 Deadlock

為了避免 Deadlock 造成使用者必須等更久的問題,我用了 Redis Lock 來處理。

cache_redis = Redis.from_url(settings.CACHE)
try:
with cache_redis.lock(
name="redis_lock_user_123", timeout=60, blocking_timeout=1
):
do_important_things()
except LockError as e:
if isinstance(e, LockNotOwnedError):
pass
else:
raise Exception("Please wait for a bit")

可以看到我在會發生 Deadlock 的邏輯中,多加了 Cache lock,當 User 123 第一個 Transaction 還沒執行完,第二個 Transaction 來的時候,會試圖想要拿 Redis lock 但是拿不到就會等待。

Timeout 是 Lock 的時間,這個時間的選擇,會是 do_important_things 這個 function 可以完成的時間,例如這隻 API 完成時間不會超過 1 分鐘,那 timeout 就可以設定成 1 分鐘,如果 1 分鐘內完成,就會順利走下去;就算超過 1 分鐘才完成,因為 Lock 已經不再了,會 raise LockNotOwnedError,但以我們的情境來說,我們會接住這個錯誤不會 raise,也是讓 API 順利完成。另外,設定 Timeout 也可以避免 do_important_things 這個 function 中有無預期的 crash 導致 Lock 遲遲未被釋放。

blocking_timeout 是嘗試的時間 (Code 的範例是 1 秒),等到 1 秒後,還是拿不到 Redis lock 的話,就會 Raise LockError,這邊把這錯誤接住後,可以做需要的處理,像是請使用者稍等。

其他可能的嘗試

To reduce the possibility of deadlocks, use transactions rather than LOCK TABLES statements; keep transactions that insert or update data small enough that they do not stay open for long periods of time; when different transactions update multiple tables or large ranges of rows, use the same order of operations (such as SELECT ... FOR UPDATE) in each transaction; create indexes on the columns used in SELECT ... FOR UPDATE and UPDATE ... WHERE statements.

參考 MySQL 的文件,要減少 Deadlock 的產生,可以盡量不要 Lock Table、讓更新的資料越少越好、照順序 update 等等。以這次的嘗試來說,有考慮過要照順序 update,但因為 Code 會變得相較複雜難維護,所以最終選擇用 Redis Lock 來防止使用者的多次連續 request。因為使用 ORM,所以要使用 DB 層級的 Lock timeout 就會需要寫 Raw SQL,比較難維護,過程中也考慮要移除不必要的 Transaction,但因為發生 Deadlock 的邏輯是很重要的使用者流程,比較難縮小縮短,影響的層面也比較廣、驗收會花費比較多的心力,希望盡可能不要改動原有的行為,所以最後選擇用比較少影響的方式處理 Deadlock。

References

--

--

Sandi Wu
iCHEF
Editor for

iCHEF Backend Engineer. Graduated from NTU Finance. Used to be an analyst at AppWorks.