Concurrency (P1/3): Các tầng bảo vệ và lock

14 phút đọcSeries: MongoDB: từ gốc đến internals

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 (WriteConflict nhì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 đọc currentOp, serverStatus và 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, container mongo-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 trong lab24/ kèm INDEX.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 $where gọi sleep() (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 isolationPhysical lockingConcurrency control
Câu hỏiMộ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 MongoDBread concern, snapshotlock manager (Global, Database, Collection), ticket, latchWiredTiger: 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ăngWriteConflict, retry
Bài dạyIsolation & Snapshotbài nàyWiredTiger 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.ingress có 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:

ModeKý hiệu trong currentOp và serverStatusNghĩa
IS (intent shared)rsắp đọc ở mức thấp hơn
IX (intent exclusive)wsắp ghi ở mức thấp hơn
S (shared)Rđọc cả resource, chia sẻ được
X (exclusive)Wkhô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ệnhlocks trong currentOpwaitingForLockGiữ ticket
findGlobal: rfalsecó
aggregateGlobal: rfalsecó
updateOneReplicationStateTransition: w, Global: w, Database: w, Collection: wfalsecó
deleteOnenhư updateOnefalsecó
findOneAndUpdatenhư updateOnefalsecó

[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ầnLock manager ghi nhận
findOne({ _id })Global.r +1.000
insertOneReplicationStateTransition.w, Global.w, Database.w, Collection.w, mỗi cái +1.000
updateOne({ _id })như insert
1 lần createIndexCollection.W +3, Database.w +9, Global.w +9, Collection.w +6
1 lần dropIndexCollection.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): updateOne chạy chậm 5 giây, giữ w ở Database và Collection.
  • B (t = 1 s): createIndex({ b: 1 }), cần W ở Collection.
  • Ở t = 2 s, bốn lệnh cùng bắt đầu: C insertOne vào cùng collection; D findOne cùng collection; E insertOne vào collection khác; F updateOne mộ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ệnhControl (3 lượt)Có createIndex (3 lượt)
C: insert cùng collection2.005 đến 2.008 ms (xong ngay)5.014, 5.143, 5.060 ms (chờ tới khi A xong)
D: findOne cùng collection2.005 đến 2.008 ms2.005 đến 2.058 ms (không bị chặn)
E: insert collection khác2.005 đến 2.008 ms2.005 đến 2.050 ms (không bị chặn)
F: update document khác, cùng collection2.005 đến 2.012 ms5.014, 5.143, 5.060 ms (chờ tới khi A xong)
B: createIndexkhô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ần w, 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.writers là 3 và waitingForLock là 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 đang out, 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
findOne3 ms
insertOne vào cùng database2.014 ms
insertOne vào database khác2.029 ms
createCollection2.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/W trong currentOp, và biết fsyncLock chặ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?

  1. Lệnh updateOne thứ hai xong ngay, vì hai document khác nhau thì WiredTiger không bắt chúng đợi nhau

    Document-level concurrency chỉ phân xử ở dưới collection lock. Lock xếp hàng theo thứ tự đến: createIndex đã đứng trước, nên lệnh ghi đến sau xếp sau nó. Trong lab, F chờ khoảng 3 giây. Xem mục "1b. DDL đứng chờ".

  2. Nó bị WriteConflict, vì cả hai lệnh cùng ghi vào collection orders

    WriteConflict xảy ra khi hai lệnh ghi cùng một document, không phải cùng collection; lệnh này đang bị chặn ở lock, trước khi tới chỗ phát hiện xung đột. Xem mục "Ba chuyện hay bị gộp làm một".

  3. Nó đứng chờ sau createIndex cho tới khi lệnh chậm kết thúc, dù intent lock của nó tương thích

    Đúng: trong lab, update vào document khác xong lúc 5.014, 5.143 và 5.060 ms thay vì 2 giây như ở lượt không có createIndex. Xem mục "1b. DDL đứng chờ".

  4. Nó chạy ngay, vì createIndex chỉ cần exclusive lock ở đầu và cuối nên không bao giờ chặn lệnh ghi

    Cái đầu và cuối đó đủ để chặn: createIndex phải lấy Collection.W lần đầu, và lúc nó đang chờ lấy lock thì mọi lệnh ghi đến sau xếp hàng theo. Xem mục "1b. DDL đứng chờ".

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?

  1. find là lock-free read; updateOne lấy intent lock w ở cả ba mức

    Manual ghi lock-free read từ 5.0 cho find, count, distinct và aggregate; lab đếm 1.000 findOne chỉ tăng Global.r 1.000 lần, còn insert và update tăng cả ba mức. Xem mục "Lock manager" và "1a. Mỗi lệnh giữ lock gì".

  2. find không lấy lock vì MongoDB không có lock cho lệnh đọc, chỉ lệnh ghi mới cần

    Lab thấy find vẫn giữ Global: r và một ticket. Lệnh đọc có lấy lock, chỉ là ít hơn. Xem mục "1a. Mỗi lệnh giữ lock gì".

  3. find lấy lock r ở mức collection, nhưng currentOp chỉ liệt kê lock ở mức cao nhất

    currentOp liệt kê từng mức riêng (updateOne hiện cả ba), và bộ đếm serverStatus().locks cũng không có Collection.r cho 1.000 lần đọc. Xem mục "1a. Mỗi lệnh giữ lock gì".

  4. Vì find chạy trong một snapshot nên không cần lock, còn updateOne không có snapshot

    Cả hai đều chạy trong storage transaction với snapshot. Snapshot là chuyện của isolation, không quyết định ai phải đợi lock. Xem mục "Ba chuyện hay bị gộp làm một".