Concurrency (P1/3): Các tầng bảo vệ và lock
Bài này trả lời một câu: khi nhiều thao tác chạy cùng lúc trên một mongod, cái gì chặn cái gì, và chặn ở tầng nào? Phần này dựng bản đồ các tầng và đọc lock thật trong currentOp; phần sau nói về latch và ticket, với lab cho nhiều client tranh ticket; phần cuối phân biệt hết ticket với CPU, lock, cache và connection pool.
Mấy bài trước để lại câu hỏi cho bài này. Bài Document Model nói dồn 10.000 lệnh ghi mỗi giây vào một document "tổng kho" thì nó thành nút thắt; phần về ticket đo lại câu đó. Bài Transactions: lỗi, retry và giới hạn thấy một lệnh DDL đứng chờ sau transaction dài làm transaction mới abort sau 5 ms. Write Conflicts, WiredTiger & Compression và WiredTiger MVCC đều nhắc "lock, latch, ticket" mà chưa giải thích.
Bài này nằm ở đâu
- Cần biết trước: WiredTiger MVCC (version, update chain, vì sao đơn vị xung đột là document), Write Conflicts, Retry & Idempotency (
WriteConflictnhìn từ ứng dụng), Transactions: lỗi, retry và giới hạn (transaction chờ lock 5 ms) - Giới thiệu: các tầng bảo vệ trong một mongod; lock manager (Global, Database, Collection, các mode
r,w,R,W); DDL cần exclusive lock; latch; ticket và hàng chờ của chúng; cách đọccurrentOp,serverStatusvà slow query log khi có chờ đợi; phân biệt ticket đang cạn với các nút thắt khác - Không dạy ở đây: ứng dụng phải retry gì khi gặp
WriteConflict(bài Write Conflicts); version, update chain và history store (bài MVCC); connection pool của driver (bài Connection Pool & Capacity) - Dẫn tới: Replica Set & Oplog, nơi Phần Phân tán bắt đầu; bài này khép lại Phần WiredTiger
Môi trường lab: MongoDB 8.3.11 trong Docker (image
mongo:8), mongosh 2.12.0. Server là một mongod standalone,--memory=2g --cpus=2, WiredTiger cache 1 GB, containermongo-conc24-main; client chạy trong container khác (mongo-conc24-client), kết nối qua mạng Docker. Host Apple M4 với Docker Desktop, chạy chung với lab của hai bài khác, nên thời gian dao động và mọi con số chỉ để so sánh tương đối. Script và output thô nằm tronglab24/kèmINDEX.md.Cần nói thẳng: để giữ một thao tác "đang chạy" đủ lâu mà quan sát được, lab dùng
$wheregọisleep()(JavaScript phía server, mặc định bật). Đó là cách giả lập một thao tác chậm, không phải mẫu truy vấn nên dùng.
Nhãn: [tài liệu] theo tài liệu chính thức (manual "current" lúc viết ghi 9.0, trong khi lab là 8.3.11; chỗ nào khác phiên bản thì có ghi); [quan sát] đo trong lab này (8.3.11); [suy luận] rút ra từ quan sát, không kiểm tra riêng; [chi tiết cài đặt] cách server đang làm, không phải cam kết API; [hình dung] mô hình để dễ nhớ.
Ý chính
"MongoDB không có lock" là sai. Đúng hơn: một lệnh CRUD thông thường không bị một lock toàn cục hay lock của cả collection chặn, vì các lock ở mức Global, Database và Collection thường chỉ là intent lock (lock "báo trước": tôi sắp đọc hoặc ghi ở mức thấp hơn). Hai intent lock cùng loại sống chung được, nên hai lệnh ghi vào hai document khác nhau của cùng collection không đợi nhau. Việc phân xử ai ghi document nào do WiredTiger làm ở mức document, bằng optimistic concurrency control (cứ chạy, nếu hai bên đụng nhau thì một bên làm lại).
Nhưng lock vẫn có, ở nhiều tầng. DDL như createIndex hay dropIndexes cần exclusive lock (lock không chung với bất kỳ lock nào khác) trên collection; khi nó đang đợi, các lệnh ghi đến sau cũng phải xếp hàng, dù chúng chỉ cần intent lock. Ở một tầng khác, ticket là giấy phép để một thao tác được chạy bên trong storage engine, và số ticket giới hạn bao nhiêu thao tác ở trong đó cùng lúc; còn latch là các khoá cực ngắn bên trong bảo vệ cấu trúc dữ liệu trong RAM. Ba thứ này chặn nhau theo ba cách khác nhau, và triệu chứng của chúng nhìn từ ngoài khá giống: "query chậm".
Hình dung trước: bếp của một nhà hàng
Một nhà hàng có một bếp chung với vài khu nấu. Phía cửa, khách xếp hàng gọi món. Trong bếp chỉ có một số bếp lửa nhất định, nên tại một thời điểm chỉ nấu được chừng đó món; món nào đến khi bếp lửa đều bận thì phiếu của nó nằm chờ. Đó là ticket: số bếp lửa quyết định bao nhiêu món được nấu cùng lúc, bất kể món đó là gì.
Mỗi khu nấu có một tấm biển. Đầu bếp nào bắt đầu nấu ở khu nào thì treo biển "đang có người nấu" ở cửa bếp, cửa khu và cuối cùng ở bàn nấu. Biển này không cản ai: nhiều đầu bếp cùng treo được. Nó chỉ cản một người muốn đóng cả khu để dọn hoặc sửa. Người đó phải chờ đến khi mọi biển được gỡ; và trong lúc anh ta đứng chờ, quản lý bếp không cho đầu bếp mới bước vào khu, nếu không việc dọn sẽ không bao giờ tới lượt. Đó là lock và hàng chờ của nó.
Ở mức từng đĩa món ăn, hai đầu bếp cùng sửa một đĩa thì một người phải làm lại: ai thấy mình sửa chậm hơn thì bỏ bản của mình và nấu lại từ đầu. Đó là write conflict và cách phân xử lạc quan của WiredTiger. Cuối cùng, mỗi lần đầu bếp ghi vào cuốn sổ order chung thì cầm bút một tích tắc, không ai thấy, không ai xếp hàng cho việc đó; đó là latch.
Nhà hàng mongod
────────────────────────────────── ──────────────────────────────────────────
số bếp lửa ticket (execution queue)
biển "đang có người nấu" ở cửa/khu/bàn intent lock ở mức Global, Database, Collection
đóng cả khu để dọn DDL: exclusive lock trên collection
đầu bếp mới phải chờ sau người muốn dọn thao tác ghi xếp hàng sau exclusive lock đang chờ
hai người sửa một đĩa, một người nấu lại write conflict ở mức document
cầm bút một tích tắc trên sổ order latch[hình dung] Đây chỉ là cách hình dung. Trong mongod, ticket được lấy cùng lúc với lock Global, trước lock của database và collection ([chi tiết cài đặt], xem phần về ticket), nên một thao tác đã giữ ticket vẫn có thể đang chờ lock collection; lab bên dưới thấy điều đó.
Ba chuyện hay bị gộp làm một
Chữ "concurrency" che ba câu hỏi khác nhau. Gộp chúng lại là nguồn của phần lớn hiểu lầm kiểu "có MVCC nên không có lock" hoặc "có lock nên là serializable".
| Logical isolation | Physical locking | Concurrency control | |
|---|---|---|---|
| Câu hỏi | Một transaction được thấy gì của transaction khác? | Ai phải đợi ai để dùng một tài nguyên? | Cơ chế nào cho nhiều thao tác chạy chồng nhau mà kết quả vẫn đúng? |
| Trong MongoDB | read concern, snapshot | lock manager (Global, Database, Collection), ticket, latch | WiredTiger: optimistic concurrency control trên MVCC; write conflict thì thử lại |
| Triệu chứng khi sai | đọc thấy dữ liệu "lạ" | thao tác đứng chờ, latency tăng | WriteConflict, retry |
| Bài dạy | Isolation & Snapshot | bài này | WiredTiger MVCC và bài này |
Ví dụ: hai lệnh ghi vào hai document khác nhau không chờ nhau (physical locking), vẫn chạy trong snapshot (isolation), và nếu cùng sửa một document thì một bên bị WriteConflict (concurrency control) chứ không đợi lock.
Từng tầng bảo vệ cái gì
client
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ ingress queue từ 8.0; mặc định 1.000.000 ticket, coi như vô hạn │
├──────────────────────────────────────────────────────────────────────┤
│ ticket bao nhiêu thao tác cùng ở trong storage engine │
│ (execution) pool read / write riêng, mỗi pool một hàng chờ │
├──────────────────────────────────────────────────────────────────────┤
│ lock manager Global (cả RSTL) ─ Database ─ Collection │
│ mode r, w (intent) · R, W (shared, exclusive) │
├──────────────────────────────────────────────────────────────────────┤
│ WiredTiger mức document: optimistic, write conflict │
├──────────────────────────────────────────────────────────────────────┤
│ latch khoá ngắn quanh cấu trúc trong RAM, ở mọi tầng │
└──────────────────────────────────────────────────────────────────────┘Mỗi tầng bảo vệ một thứ khác nhau:
- Ingress queue bảo vệ server khỏi bị nhận quá nhiều thao tác từ mạng. [tài liệu]
queues.ingresscó từ 8.0;ingressAdmissionControllerTicketPoolSize(số thao tác được nhận vào cùng lúc) mặc định 1.000.000, "tương đương không giới hạn", và manual dặn đừng đổi nếu kỹ sư MongoDB không bảo. - Ticket giới hạn số thao tác ở trong storage engine cùng lúc (xem Latch, ticket và lab tranh ticket).
- Lock manager bảo vệ cấu trúc logic: database còn hay đã bị drop, collection còn hay đã bị đổi tên, index đang được tạo. Lock chia thành các resource xếp thành cây (Global, rồi Database, rồi Collection), luôn khoá từ trên xuống; [chi tiết cài đặt] README của lock manager nói thứ tự cố định này để tránh deadlock, và
ReplicationStateTransition(RSTL, chặn việc đổi vai trò primary/secondary) cũng là một resource mức Global. - WiredTiger bảo vệ chính dữ liệu: hai lệnh ghi vào cùng document. [tài liệu] Manual viết WiredTiger "chỉ dùng intent lock ở mức global, database và collection" cho phần lớn lệnh đọc và ghi, và khi phát hiện xung đột thì "một thao tác chịu write conflict, và MongoDB tự thử lại một cách trong suốt". Vì sao đơn vị xung đột là document, chứ không phải field hay cả collection, là chuyện của bài MVCC.
- Latch bảo vệ cấu trúc dữ liệu trong RAM, ví dụ danh sách page trong cache, khỏi bị hai thread sửa cùng lúc.
Lock manager: mode, intent lock và hàng chờ
[tài liệu] MongoDB dùng multi-granularity locking với reader-writer lock. Mỗi lock có một mode:
| Mode | Ký hiệu trong currentOp và serverStatus | Nghĩa |
|---|---|---|
| IS (intent shared) | r | sắp đọc ở mức thấp hơn |
| IX (intent exclusive) | w | sắp ghi ở mức thấp hơn |
| S (shared) | R | đọc cả resource, chia sẻ được |
| X (exclusive) | W | không chung với bất kỳ mode nào |
Quy tắc sống chung (manual: "một database có thể bị khoá IS và IX cùng lúc, X không chung với mode nào, S chỉ chung với IS"; bảng đầy đủ trong README của lock manager): IS chung với mọi mode trừ X; IX chung với IS và IX; S chung với IS và S; X không chung với ai. Khoá một collection ở mode X thì Database và Global phía trên phải được khoá ở IX.
Vì phần lớn lệnh chỉ cần IS hoặc IX, và IX chung với IX, nhiều lệnh ghi cùng collection không chặn nhau ở tầng này. [tài liệu] Manual gọi lock là fair: request đọc và ghi xếp hàng theo thứ tự đến; khi một request được cấp, các request tương thích khác trong hàng cũng được cấp cùng lúc để tăng throughput (số thao tác xong mỗi giây), và theo manual không request nào bị bỏ đói. Hệ quả, thấy rõ ở lab 1b: một lock X đứng chờ thì request đến sau nó xếp sau nó, kể cả khi request đó tương thích với lock đang được giữ.
[tài liệu] Bảng "lệnh nào lấy lock nào" của manual (cho engine có document-level locking): query và aggregation lấy r ở Database và Collection; insert, update, delete lấy w ở cả hai; createIndexes lấy W ở collection nhưng "chỉ giữ exclusive lock ở đầu và cuối quá trình build index"; renameCollection khoá W trên collection nguồn và đích; convertToCapped và cloneCollectionAsCapped khoá W cả database trong thời gian dài.
Ngoại lệ đáng nhớ: [tài liệu] từ 5.0, find, count, distinct và aggregate là lock-free read: chúng không bị chặn kể cả khi một thao tác khác đang giữ lock X trên collection.
Lab 1: đọc lock thật
Lab gồm ba phần, đều qua currentOp và serverStatus (e1a-lock-modes.js, e1b-ddl-convoy.js, e1c-lock-counts.js, e1d-fsynclock.js).
1a. Mỗi lệnh giữ lock gì
Cho từng lệnh chạy chậm khoảng 1,5 giây ($where với sleep), đợi 0,5 giây rồi đọc $currentOp cho namespace đó:
| Lệnh | locks trong currentOp | waitingForLock | Giữ ticket |
|---|---|---|---|
find | Global: r | false | có |
aggregate | Global: r | false | có |
updateOne | ReplicationStateTransition: w, Global: w, Database: w, Collection: w | false | có |
deleteOne | như updateOne | false | có |
findOneAndUpdate | như updateOne | false | có |
[quan sát] Hai điều khác với bảng trong manual. Lệnh đọc chỉ hiện Global: r, không có Database: r hay Collection: r: khớp với lock-free read vừa nói ([suy luận]: nó không lấy lock ở mức database và collection). Lệnh ghi hiện thêm ReplicationStateTransition: w, lock dùng để chặn đổi vai trò primary/secondary khi đang ghi ([tài liệu] currentOp liệt kê nó trong danh sách lock type).
Bộ đếm serverStatus().locks xác nhận ở quy mô lớn hơn (e1c-lock-counts.out, mỗi hàng là hiệu số trước và sau):
| 1.000 lần | Lock manager ghi nhận |
|---|---|
findOne({ _id }) | Global.r +1.000 |
insertOne | ReplicationStateTransition.w, Global.w, Database.w, Collection.w, mỗi cái +1.000 |
updateOne({ _id }) | như insert |
1 lần createIndex | Collection.W +3, Database.w +9, Global.w +9, Collection.w +6 |
1 lần dropIndex | Collection.W +1, Collection.w +1, Database.w +2, Global.w +2 |
createIndex lấy Collection.W ba lần trong một lệnh: [suy luận] khớp với câu của manual rằng nó chỉ giữ exclusive lock "ở đầu và cuối", còn giữa chừng (lúc quét dữ liệu để dựng index) chỉ giữ intent lock.
1b. DDL đứng chờ và những ai xếp hàng sau nó
Đây là kịch bản của bài Transactions: lỗi, retry và giới hạn, lần này không dùng transaction. Một collection 10 document, thời gian tính từ lúc A bắt đầu:
- A (t = 0):
updateOnechạy chậm 5 giây, giữwở Database và Collection. - B (t = 1 s):
createIndex({ b: 1 }), cầnWở Collection. - Ở t = 2 s, bốn lệnh cùng bắt đầu: C
insertOnevào cùng collection; DfindOnecùng collection; EinsertOnevào collection khác; FupdateOnemột document khác (không phải document A đang sửa).
Chạy ba lượt "control" (không có B) và ba lượt có B (e1b-ddl-convoy.out). Thời gian hoàn thành, tính từ lúc A bắt đầu:
| Lệnh | Control (3 lượt) | Có createIndex (3 lượt) |
|---|---|---|
| C: insert cùng collection | 2.005 đến 2.008 ms (xong ngay) | 5.014, 5.143, 5.060 ms (chờ tới khi A xong) |
D: findOne cùng collection | 2.005 đến 2.008 ms | 2.005 đến 2.058 ms (không bị chặn) |
| E: insert collection khác | 2.005 đến 2.008 ms | 2.005 đến 2.050 ms (không bị chặn) |
| F: update document khác, cùng collection | 2.005 đến 2.012 ms | 5.014, 5.143, 5.060 ms (chờ tới khi A xong) |
B: createIndex | không có | 7.045, 5.200, 5.069 ms |
Đọc bảng này theo hai cột. Ở cột control, C và F, hai lệnh ghi cùng collection với A, xong vài ms sau khi bắt đầu: hai intent lock w sống chung, và F sửa document khác nên không đụng gì. Ở cột bên phải, chỉ thêm một createIndex đang chờ mà C và F, thứ vốn không liên quan tới index, chờ khoảng 3 giây. Còn D và E vẫn xong ngay.
Kết quả currentOp đọc ở giây thứ 3 của lượt có B (lượt 1):
update locks Collection: w waitingForLock: false (A, đang giữ)
createIndexes locks Collection: W waitingForLock: true (B)
insert locks Collection: w waitingForLock: true (C)
update locks Collection: w waitingForLock: true (F)
globalLock.currentQueue: { total: 3, readers: 0, writers: 3 }
write tickets: out 4, available 6, total 10, queueLength 0[quan sát] Ba điều đáng nhớ:
- Lệnh đến sau
createIndexđang chờ (C và F) chờ theo, dù chúng chỉ cầnw, thứ tương thích với A. Lock xếp hàng theo thứ tự đến: B đứng trước, nên C và F xếp sau B. Document-level concurrency sống ở dưới collection lock, nên nó không cứu được F. - Lệnh đọc không bị ảnh hưởng (lock-free read), và lệnh ghi collection khác cũng không. Tác động của DDL dừng ở collection của nó.
currentQueue.writerslà 3 vàwaitingForLocklàtrueở ba lệnh: đó là dấu hiệu của hàng chờ lock. Cả ba lệnh đang chờ vẫn giữ một write ticket (4 ticket đangout, cho A, B, C và F). Chuyện này quan trọng khi phân biệt các nguồn chờ.
Ở cả ba lượt, C và F xong gần như cùng lúc với A (trong vòng 1 ms), trước createIndex. Ở lượt 2 và 3, createIndex xong sau A 57 ms và 9 ms. [suy luận] Khớp với việc build index chỉ giữ exclusive lock ở đầu và cuối, nên C, F chen vào giữa. Lượt 1 thì createIndex xong sau A tới hai giây (7.045 ms) trên một collection chỉ 10 document; tôi không giải thích được chỗ chậm đó, nên không dùng con số này làm kết quả.
1c. Một lock thật sự toàn cục: fsyncLock
Lock mức Global không chỉ có intent lock. db.fsyncLock() khoá instance để backup (e1d-fsynclock.out); [chi tiết cài đặt] mã nguồn (fsync.cpp, bản master) giữ lock Global ở mode S (Lock::GlobalRead). S chung với IS nhưng không chung với IX, nên lệnh đọc vẫn qua còn mọi lệnh ghi, ở database nào cũng vậy, phải chờ. Với lock đang giữ 2 giây rồi mở:
| Lệnh khởi động trong lúc bị khoá | Hoàn thành sau |
|---|---|
findOne | 3 ms |
insertOne vào cùng database | 2.014 ms |
insertOne vào database khác | 2.029 ms |
createCollection | 2.026 ms |
[quan sát] currentOp cho thấy ba lệnh ghi đang waitingForLock: true với Global: w, vẫn giữ ticket, và globalLock.currentQueue.writers là 3. Lệnh đọc vẫn qua.
Còn 5 ms của transaction
Giờ có thể giải thích cái số 5 ms ở bài transaction. [tài liệu] maxTransactionLockRequestTimeoutMillis mặc định 5 ms: một transaction đa document không lấy được lock trong thời gian đó thì abort. [suy luận] Trong lab 1b, một transaction mới chạm vào collection sẽ giống C và F: nó cần w nhưng đứng sau createIndex đang chờ. Lệnh ghi thường chỉ chờ (3 giây); transaction thì thành LockTimeout sau 5 ms, và vì mọi transaction mới trên collection đó đều đứng cùng hàng, chúng abort hàng loạt.
Tăng giá trị này chỉ đổi abort ngay thành chờ lâu hơn, không làm A kết thúc sớm ([tài liệu] manual còn ghi nó "có thể làm chậm việc abort các transaction đang deadlock"). Chữa đúng chỗ là thứ đứng đầu hàng: giữ transaction ngắn, và đừng chạy DDL trên collection đang bận lúc tải cao.
Cột mốc: Bạn đã có thể nói vì sao CRUD thường không bị lock toàn cục chặn mà DDL vẫn làm lệnh ghi cùng collection xếp hàng, đọc mode
r/w/WtrongcurrentOp, và biếtfsyncLockchặn mọi lệnh ghi. Tiếp theo: Latch, ticket và lab tranh ticket.
Hỏi & đáp
Một lệnh updateOne chậm đang giữ intent lock w trên collection orders; ai đó gọi createIndex trên orders và nó phải đợi. Lúc đó một client gọi updateOne vào một document khác, cũng thuộc orders. Điều gì xảy ra?
Trong currentOp, một lệnh find đang chạy chỉ hiện Global: r, trong khi updateOne hiện Global, Database và Collection đều là w. Cách giải thích nào đúng cho lab này?