Race condition: rất dễ hiểu qua một lần thiết kế game

Jasper TXVJasper TXV
8 min read

Hôm nay là 1 kiến thức khá thú vị, dễ hiểu và không hề khô khan nhé :v, cũng có thể thay đổi tư duy của các bạn Backend junior và thậm chí Middle nếu chưa thực sự hiểu đấy.

Race condition là gì vậy?

Oke, nói về race condition nào? Hmm, một thuật ngữ khá quen thuộc trong lập trình đúng không nào? Từ khái niệm trong ngôn ngữ lập trình, cách quản lý bộ nhớ cho đến những hệ thống người dùng đều sẽ phải gặp vấn đề này.

Hiểu đơn giản thì như thế này:

  • Anh A và anh B cùng là admin của 1 Fanpage Facebook

  • Anh A thì thiết kế Fanpage sao cho đẹp

  • Anh B thì thiết kế Fanpage sao cho hầm hố, ngầu

Và bùm, kết quả là Fanpage nó ra 1 kiểu “đẹp + hầm hố” → “đẹp hố” ha :D.

Nói chung, race condition là trạng thái mà 1 bản ghi, 1 ô nhớ, 1 giá trị được thay đổi bởi nhiều bên không kiểm soát. (Đây là mình giải thích theo ý của mình hiểu, không phải định nghĩa chuẩn nhé :v)

Ôi!! Nói luyên thuyên giông dài rồi, đáng lẽ tôi nên đặt nó vào bài toán cụ thể mà tôi đã gặp phải - thiết kế Game

Case thực tế trong game chuồng bò 🐮

Yeah, công ty tôi đang làm game đó, game telegram mini app (🤫 suỵt, giữ bí mật vì game chưa ra nha :D).

Tôi đã gặp 1 case khá thú vị để viết ra đây, mong mọi người có thể đọc được và cho 1 like

Bài toán như sau:

Chuồng bò

Game này giống game nông trại, nhưng chúng ta chỉ nói xung quanh chuồng bò nhé (đáng yêu nhỉ)

Chuồng bò thì có từng con bò, nhiệm vụ cho bò ăn, thu hoạch → đơn giản đúng không ?

Tôi đã tạo 2 bảng trong database: UserShelter (chuồng động vật của user) và AnimalInstance (các thực thể động vật) và có mối quan hệ 1 - N (1 chuồng nhiều con).

Hình ảnh trên là mô phỏng đơn giản để các bạn dễ dàng hình dung về relationship

Ý tưởng của game là như sau:

  • Bò thì ăn cỏ, UI sẽ để user kéo thả cỏ để cho bò ăn

  • Sau khi đợi 1 thời gian khi hoàn thành ăn cỏ, bò sẽ cho ra sữa, mình chỉ cần claim về thôi

Việc của chúng ta là hãy tập trung viết hàm FeedAnimal nhé.

Bây giờ, hãy pha 1 cốc cà phê trong 10s để cho các bạn hình dung viết API thế nào để làm feature này nhé :D

☕️☕️☕️

Chắc hẳn các bạn hình dung ra rồi, đơn giản đúng không?

Flow sẽ như sau:

-- FindAnimalInstanceById 
SELECT * FROM animal_instances 
INNER JOIN user_shelters ON ...
INNER JOIN shelters ON ... 
WHERE id = $1; // $1 = animalInstanceId, lấy ra được animal_instance

-- Kiểm tra nếu animal instance tìm được đã được cho ăn chưa? 
-- Nếu đã cho ăn rồi, hoặc ăn xong rồi nhưng chưa claim thì trả ra error: can't feed at this time
if animal_instance.last_feed_at != null && ... {
    return err 
} // đoạn này phụ thuộc xử lý logic của bạn

-- Nếu thoả mãn điều kiên, let's feed this animal
BEGIN transaction;
-- UpdateAnimalInstance
UPDATE animal_instances SET last_feed_at = now() WHERE id = $1;

-- UpdateUserItemBalance
UPDATE user_item_balance SET total = $1 WHERE user_id = $2 AND item_id = $3;
COMMIT;

Có cần tôi giải thích không :D

  • Vì chúng ta cho 1 con bò ăn nên sẽ có id của nó

  • Fetch con bò đó từ database, có thể join các bảng để lấy thông tin cần thiết

  • Check điều kiện xem bò này có thể cho ăn được vào thời điểm này không?

  • Mở transaction và thực hiện update

    • Update field last_feed_at cho animal_instance

    • Vì cỏ cho bò ăn là 1 item trong game, nên sau khi cho ăn phải trừ đi 1 trong kho, hàm UpdateUserItemBalance ý nghĩa là vậy

Còn ở đây ai hỏi transaction là gì và vì sao cần thiết thì Google nhé :D

(bên trên là tôi mô phỏng lại thôi, thực tế hàm rất nhiều thứ để xử lý, vì là game mà)

Xong rồi, đơn giản đúng không :)). End ở đây nhé 😂.

Vấn đề ở đâu nhỉ?

Nếu chỉ dừng ở bên trên thôi thì chắc trình độ Fresher hoặc Junior, maybe viết CRUD thôi.

Vậy đoạn code bên trên vấn đề nằm ở đâu nhỉ?

Thật may là tôi có 1 người đồng hành cùng trong dự án này và làm Frontend (dự án có 3 người :)) )

Nhìn trên hình thì các bạn sẽ thấy, UI sẽ là kéo nhưng không lắng nghe sự kiện thả mà là bôi cả 1 hàng, cỏ đi đến đâu thì bò ăn đến đó (ý tưởng ông nghĩ ra cái game này T-T ).

Tuy nhiên nó lại trở thành cái may mắn vì tôi có thể dùng nó để viết ra bài này, mở ra cho chúng ta cơ hội nói về Race condition

Vấn đề ở chỗ khi bôi, người anh em frontend đã check khi cỏ vào miệng con bò nào thì sẽ call API feedAnimal, và vô hình chung nó bắn đâu đó tầm 3 - 4 request cùng 1 lúc với time rất nhỏ, khoảng vài milliseconds, và yebb, chúng ta cùng đi phân tích thôi.

Đoạn Logic xử lý chúng ta bàn ở trên về cơ bản không sai, nếu chỉ có 1 request tới độc lập, nhưng sẽ sai khi có nhiều request đến gần như đồng thời.

Vậy thì, nó sai ở đâu ? Yes, đó là đoạn check điều kiện để được cho ăn

“Tưởng tượng rằng 2 request đến đồng thời, cùng select ra animal instance trong trạng thái chưa ăn, và cùng check điều kiện hợp lệ để cho ăn, cùng cập nhật field last_feed_at, tuy nhiên, user đã mất không phải 1 mà là 2 item cỏ” - đây là hiện tượng bị duplicate nếu không biết cách xử lý - rất dễ gặp trong các hệ thống thanh toán hoặc xử lý transactions

Ha ha, dễ hiểu đúng không ?

Giải pháp thôiiii!!!

Keyword ở đây là Lock, và tất nhiên rất nhiều loại, tuy nhiên tôi sẽ phân tích các loại mà tôi đã áp dụng thôi, không áp dụng thì không nói đến

Có 2 loại Lock mà tôi đã sử dụng trong hệ thống backend này:

  • Application lock: Lock ở tầng ứng dụng, sử dụng mutex trong hệ thống distrubuted cache

  • Database lock: Lock ở tầng database

Database lock

Về lock ở tầng database, có vẻ nó sẽ dễ dàng và thuận tiện hơn, ở đây tôi sẽ sử dụng kĩ thuật row locking

Row locking cho phép ta khoá 1 row lại cho request thứ nhất đến, cập nhật xong rồi request thứ 2 mới được Select và update

Sẽ chỉnh lại câu lệnh Select như sau

BEGIN TRANSACTION;

SELECT * FROM animal_instances 
INNER JOIN user_shelters ON ...
INNER JOIN shelters ON ... 
WHERE id = $1
FOR UPDATE animal_instances;
...

COMMIT;

Keyword FOR UPDATE là gì?

Request nào đến trước và call For Update, nó sẽ lock row đó lại và khi request thứ 2 đến, sẽ phải chờ cho request đầu tiên hoặc là transaction rollback, hoặc là commit, nó mới mở lại.

Tất nhiên ngoài For Update ra chúng ta còn nhiều loại khoá khác như For Share, For Key Share,… các bạn có thể tự tìm hiểu

Hoặc ngoài cách này ra có 1 cách là thêm 1 column flag version cho animal instance để Update theo điều kiện, thì không cần phải Lock Row (nhưng như thế lại phải thêm column đúng không :v)

Về phần Database Locking này thì còn rất nhiều cái để bàn nhé, nhưng hẹn các bạn bài sau.

Application lock

Tại sao lại dùng Application lock?

Như tôi có nói thì hàm thực tế sẽ rất dài và nặng logic, cập nhật các bản ghi trên các table khác nhau rất nhiều chứ không chỉ có 2 bảng như chúng ta đang bàn. Và tôi thấy là khi lock column như Database locking, transaction sẽ mở khá lâu, dẫn đến có thể row sẽ bị lock lâu => app slow

Giải pháp: Tôi dùng Mutex của redis để lock request. Đây là 1 kĩ thuật khi dùng với distributed cache mà tôi đã dùng trong dự án này ( tôi dùng redis-cluster )

lockKey := fmt.Sprintf("lock_feed_animal_instance:%s", animalInstanceId);
mutex := redSync.NewMutex(lockKey, time.Second*20);
if err := mutex.TryLock(); err != nil {
    return err;
}

-- FindAnimalInstanceById
Select như bình thường, không cần lock

Và tôi chỉ cần mở transaction cuối hàm để update các data theo ý muốn thôi.

Summarize

Tóm lại, đây là 1 kiến thức khá thú vị nhưng cũng hết sức cơ bản trên con đường trở thành Senior Software Engineer. Tuy nhiên cách nào cũng có tradeoff cả và chúng ta cần suy nghĩ kĩ lưỡng để lựa chọn phương án sao cho phù hợp với quy mô cũng như là business của mình

Thôi kết lại ở đây nhé. Nếu thấy hữu ích thì tôi thấy vui và mừng cho bạn rồi :D.

0
Subscribe to my newsletter

Read articles from Jasper TXV directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jasper TXV
Jasper TXV