Concurrency (P2/3): Latch, ticket và lab tranh ticket
Ở phần trước: CRUD thường chỉ cần intent lock nên không chặn nhau, DDL cần exclusive lock và làm lệnh ghi đến sau xếp hàng, fsyncLock chặn mọi lệnh ghi. Một thao tác đang chờ lock vẫn giữ một write ticket.
- Cần đọc trước: Các tầng bảo vệ và lock
- Dẫn tới: Ticket pressure, chẩn đoán và so với PostgreSQL
Ý chính
Latch là khoá cực ngắn bảo vệ cấu trúc dữ liệu trong RAM; bạn không nhìn thấy nó từng cái một, chỉ thấy bộ đếm số lần lấy và thời gian chờ. Ticket là giấy phép để một thao tác chạy bên trong storage engine, và số ticket giới hạn bao nhiêu thao tác ở đó cùng lúc: hết ticket thì thao tác mới đứng chờ trong hàng (queue), dù không có lock nào bị giữ và dù CPU còn rảnh.
Ticket là chỗ dễ hiểu sai nhất vì hành vi của nó thay đổi theo phiên bản: từ 7.0 số ticket do một thuật toán động thay đổi theo tải, và cách đọc "có sự cố hay không" cũng đổi theo. Phần này nói rõ từng phiên bản, rồi chạy lab cho tới 500 client cùng tranh 10 ticket.
Latch: khoá ngắn bên trong
[tài liệu] Các trang manual dùng cho bài này không dùng chữ "latch"; tài liệu kiến trúc của WiredTiger (12.0.0) gọi chúng là mutex và read-write lock, thứ phải giữ khi truy cập vùng nhớ dùng chung giữa các thread. Trong từ vựng của database, đó là latch: một khoá giữ rất ngắn để hai thread không sửa cùng lúc một cấu trúc trong RAM (danh sách page trong cache, hàng đợi eviction, vùng đệm của journal). Phần server của mongod cũng có mutex riêng; bài này chỉ nhìn bộ đếm của WiredTiger. So với lock của lock manager:
| Lock (lock manager) | Latch | |
|---|---|---|
| Bảo vệ | tài nguyên logic: database, collection | cấu trúc dữ liệu trong RAM |
| Giữ bao lâu | cả thời gian một lệnh hoặc transaction | cỡ micro giây |
| Có hàng chờ nhìn thấy được | có (waitingForLock, globalLock.currentQueue) | không có thao tác nào "đang chờ latch" trong currentOp |
| Tránh deadlock | khoá từ trên xuống: Global, Database, Collection (README lock manager); transaction chờ lock tối đa 5 ms | luôn lấy theo một thứ tự cố định (Locks hierarchy) |
[tài liệu] Trang "Locks hierarchy" của WiredTiger liệt kê thứ tự lấy cố định, ví dụ checkpoint_lock rồi schema_lock rồi table_lock, hay các lock của eviction (evict_walk_lock, evict_queue_lock) và của journal (log_slot_lock): ai cũng lấy theo cùng một thứ tự thì không có deadlock.
[quan sát] Latch lộ ra qua serverStatus().wiredTiger.lock, nơi mỗi loại khoá có số lần lấy và tổng thời gian thread của ứng dụng phải chờ. Thử với ba loại việc, mỗi loại chạy 3 lượt (e2d-wt-locks.js, e2d-wt-locks.out, e2d-wt-locks-reps.out); tám client ghi 1.500 document mỗi client, tức 12.000 lệnh insert, hai việc sau chạy chồng lên chúng:
| Việc | btree page lock acquisitions | btree page lock application thread wait time (µs, 3 lượt) | schema lock acquisitions |
|---|---|---|---|
| Chỉ ghi | khoảng 13.600 | 1.659 / 34 / 500 | 0 |
Ghi, cùng lúc 5 lần createIndex rồi dropIndex | 16.343 đến 17.119 | 6.372 / 20.794 / 22.114 | 32 / 38 / 32 |
Ghi, cùng lúc 5 lần fsync (ép checkpoint) | khoảng 13.700 | 150.099 / 128.222 / 97.246 | 5 / 8 / 5 |
Tên bộ đếm là tên WiredTiger đặt: nó gọi chúng là "lock" dù đây là loại khoá ngắn. Con số cho thấy khoảng 13.600 lần lấy page lock mà tổng thời gian chờ chỉ cỡ 0,03 đến 1,7 ms khi chỉ có ghi, nhưng lên tới 97 đến 150 ms khi checkpoint chạy chồng lên. [suy luận] Checkpoint duyệt và ghi các page đang bị sửa nên tranh page lock với các lệnh ghi; tôi không kiểm chứng cơ chế chi tiết. Điều chắc chắn hơn: nếu latch có cạnh tranh thì bạn thấy nó ở đây, như một tổng thời gian chờ tăng lên, không phải như một thao tác cụ thể đang đứng chờ.
Vì thế latch không phải thứ để canh theo từng thao tác: nó là một phần nhỏ trong thời gian của mọi thao tác, chỉ thấy qua tổng. Hàng chờ cần canh là ticket.
Ticket là gì
Quay lại bếp nhà hàng: ticket là một cái bếp lửa. Thao tác nào muốn làm việc với storage engine (đọc hay ghi dữ liệu) phải có một ticket trong tay; hết ticket thì xếp hàng ở cửa.
[chi tiết cài đặt] README của execution control (mã nguồn bản master, tôi không đối chiếu riêng với 8.3.11): ticket được lấy khi thao tác xin lock Global, từ hai pool riêng: yêu cầu mode IS và S lấy từ pool đọc, mode IX từ pool ghi (một số thao tác nội bộ được miễn). Mỗi pool có hàng chờ riêng, và hàng chờ của pool normal priority không giữ thứ tự đến: ticket vừa trả có thể rơi vào một thao tác mới đến thay vì thao tác chờ lâu nhất. Lab về DDL đứng chờ và fsyncLock thấy điều tương ứng trên 8.3.11: thao tác đã giữ ticket vẫn có thể đang chờ lock.
[quan sát] Hai điều từ currentOp (e2a.js, một thao tác đang chờ ticket):
currentQueue: { name: "execution", timeQueuedMicros: 980729 }
queues.execution: { admissions: 0, totalTimeQueuedMicros: 980729, isHoldingTicket: false }
waitingForLock: false locks: {}Thao tác đang chờ ticket không có waitingForLock và chưa có lock nào; nó nằm ở hàng execution, chưa có ticket (isHoldingTicket: false) và đã chờ gần 1 giây. Phân biệt hai trường này là cách đầu tiên để biết một thao tác chờ lock hay chờ ticket.
Ticket cũng được trả lại giữa chừng. [tài liệu] Thao tác chạy lâu định kỳ yield (nhả lock để thao tác khác chen vào), và slow query log có trường queues.execution.admissions. [chi tiết cài đặt] README của execution control coi số admission là số lần thao tác lấy ticket, tức mỗi lần yield xong nó phải lấy lại. Một query chạy lâu và hay yield có thể phải xếp hàng lại nhiều lần.
Ticket theo từng phiên bản
Đây là chỗ cần cẩn thận nhất của cả bài: cùng một dấu hiệu ("ticket available bằng 0") có nghĩa khác nhau giữa các phiên bản.
| Phiên bản | Hành vi và cách đọc | Nguồn |
|---|---|---|
| 6.0 trở về trước | Mỗi loại 128 ticket cố định (mặc định của storageEngineConcurrentReadTransactions và ...WriteTransactions; trước 6.0 tên là wiredTigerConcurrent...). Đọc qua serverStatus().wiredTiger.concurrentTransactions. available bằng 0 lâu là dấu hiệu quá tải | manual 6.0, manual hiện hành |
| 7.0 | Thuật toán động đổi số ticket theo tải, "bắt đầu với số ticket thấp hơn nhiều", tối đa 128 mỗi loại. Vẫn đọc qua wiredTiger.concurrentTransactions; manual: giá trị thấp không nghĩa là quá tải, hãy dùng số thao tác đang xếp hàng | manual 7.0 |
| 8.0 | Số liệu chuyển sang serverStatus().queues.execution (và queues.ingress cho hàng chờ ở cửa vào) | manual 8.0 |
| 8.3 | queues.execution có thêm usesThroughputProbing, usesPrioritization, deprioritizationGate, pool lowPriority cho thao tác bị hạ priority, admissions (histogram số lần lấy ticket), các bộ đếm ...Delinquen... | manual "current" |
| 9.0 (manual "current") | Thêm tham số throughputProbingStallEscalationThreshold | manual "current" |
[quan sát] Trên 8.3.11 của lab: wiredTiger.concurrentTransactions không còn trong serverStatus (e0-probe.out), queues.execution có hai pool read và write, mỗi pool khởi động với totalTickets: 10 và available: 10, usesThroughputProbing: true, usesPrioritization: false. Tham số storageEngineConcurrencyAdjustmentAlgorithm là throughputProbing, và trần storageEngineConcurrentReadTransactions / ...WriteTransactions đọc ra 128. [chi tiết cài đặt] Mã nguồn bản master đặt tên chính là executionControlConcurrencyAdjustmentAlgorithm và executionControlConcurrent{Read,Write}Transactions, tên storageEngine... là tên cũ còn dùng được; trên 8.3.11 getParameter trả về cả hai tên cùng một giá trị. Trên trang tham số của manual tôi không tìm thấy tham số chọn thuật toán; manual chỉ nói rằng muốn tắt thuật toán động thì "liên hệ support".
Vì sao khởi động ở 10? [chi tiết cài đặt] Mã nguồn bản master ghi: throughputProbingInitialConcurrency mặc định 0 nghĩa là "hai lần số core logic", chia cho đọc và ghi theo tỉ lệ 0,5. [suy luận] Container của lab có --cpus=2, nhưng nproc bên trong vẫn báo 10 (e0-nproc.out): --cpus chỉ giới hạn tổng thời gian CPU container được dùng (CFS quota của cgroup), không giảm số core mà tiến trình nhìn thấy. Nếu mongod cũng đếm 10 core thì 2 × 10 chia đôi ra 10 đọc và 10 ghi, khớp với quan sát. Tôi không đọc mã của đúng bản 8.3.11 và không kiểm tra mongod đếm core thế nào, nên đừng hiểu thành "số ticket = 2 × số CPU của container".
[chi tiết cài đặt] Thuật toán (throughput probing), theo README của mã nguồn bản master: cứ mỗi 100 ms (throughputProbingConcurrencyAdjustmentIntervalMillis) nó thử một bước bằng 10% số ticket (throughputProbingStepMultiple): thử tăng nếu ticket đang hết, thử giảm nếu ticket chưa hết. Nếu throughput (số ticket được trả mỗi giây) tăng, mức ổn định dời dần về phía mức thử; không thì quay về mức cũ. Mỗi loại nằm trong khoảng 4 đến 128. Trên 8.3.11, getParameter đọc ra đúng các giá trị 100 ms, 0,1, 4 và 128. Ghi nhớ điều này, nó giải thích kết quả lab.
Đọc hàng chờ ticket
Các trường trong serverStatus().queues.execution.read và .write (8.3.11):
| Trường | Nghĩa | Đọc thế nào |
|---|---|---|
out | số ticket đang bị giữ | ≤ totalTickets |
available | số ticket còn lại | bằng 0 là hết ticket, nhưng chưa chắc có vấn đề |
totalTickets | dung lượng hiện tại của pool | từ 7.0 thay đổi theo thời gian |
normalPriority.queueLength | số thao tác đang xếp hàng chờ ticket | tín hiệu chính: lớn và kéo dài là quá tải |
normalPriority.totalTimeQueuedMicros | tổng thời gian chờ, cộng dồn | lấy hiệu hai lần đo, chia cho số thao tác |
normalPriority.canceled | số thao tác hết hạn trong hàng chờ | tăng khi maxTimeMS hết lúc còn đang xếp hàng |
monitor.timesIncreased, timesDecreased | số lần thuật toán đổi dung lượng pool | thuật toán có đang đổi số ticket không |
Trích từ manual: từ 7.0 "một số lớn thao tác xếp hàng kéo dài" là dấu hiệu quá tải, còn "ticket available bằng 0 lâu không chỉ ra quá tải". Còn ở 6.0 trở về trước, available bằng 0 lâu là dấu hiệu. Dùng cùng một cảnh báo cho cả hai phiên bản là cách báo động nhầm hoặc bỏ sót.
Lab 2: nhiều client tranh ticket
Thiết kế (e2a.js, e2a-all.sh, e2b-all.sh, e2c-maxtime.sh; 2a đến 2c dùng chung thiết kế, 2d có thiết kế riêng). Mỗi thao tác là find({ $where: "sleep(500)||true" }).limit(1): nó giữ một read ticket khoảng 500 ms mà gần như không tốn CPU. T thao tác bắt đầu cùng lúc từ một tiến trình mongosh (mở sẵn T connection trước khi đo, để thời gian tạo connection không lẫn vào), đo latency của từng thao tác và đọc queues.execution mỗi 200 ms. Mỗi cấu hình chạy 3 lượt, restart server trước mỗi lượt để số ticket về mức khởi động; bảng ghi trung vị của 3 lượt.
Một cảnh báo về giả lập: sleep trong $where là cách rẻ nhất để giữ ticket mà không tốn CPU. [suy luận] Thao tác thật giữ ticket lâu thường là đọc page từ đĩa hoặc chờ cache, giống "giữ mà không chạy" ở chỗ nó ngồi trong storage engine, nhưng không đồng nhất. Thao tác tốn CPU thật được thử ở phần về ticket pressure.
2a. Mặc định (throughputProbing): T = 8, 32, 128, 500
| T client | Latency p50 | p95 | Tổng thời gian | Hàng chờ ticket lớn nhất | Ticket tối đa thấy được |
|---|---|---|---|---|---|
| 8 | 510 ms | 521 ms | 521 ms | 0 | 10 |
| 32 | 1.021 ms | 1.529 ms | 1.529 ms | 21 | 11 |
| 128 | 3.527 ms | 5.613 ms | 6.042 ms | 117 | 11 đến 12 |
| 500 | 12.081 ms | 21.610 ms | 22.132 ms | 488 đến 489 | 12 đến 13 |
(e2a-default.out.) Ở T = 8 không ai chờ: p50 chỉ hơn 500 ms của thao tác một chút. Từ T = 32, p50 tăng gần tỉ lệ với T: mỗi thao tác chờ cho tới khi một trong khoảng 11 ticket được trả. Công thức thô khớp với số đo: tổng thời gian ≈ (T ÷ số ticket) × 0,5 s, ví dụ 500 ÷ 11 × 0,5 ≈ 22,7 s so với 21,1 đến 22,6 s đo được.
Cái đáng nhìn nhất: pool không lớn lên bao nhiêu. Trong 22 giây với 489 thao tác đang xếp hàng, totalTickets chỉ lên tới 12 hoặc 13, dù trần là 128, và monitor.timesIncreased chỉ tăng 3 đến 7 lần. [suy luận] Khớp với thuật toán vừa mô tả: một bước thử chỉ được giữ nếu throughput tăng ngay trong khoảng đo 100 ms, mà thao tác ở đây giữ ticket 500 ms, nên ticket vừa thêm chưa kịp làm có thêm thao tác xong và bước thử thường bị lùi lại. Lab 2b cho thấy thêm ticket thật ra có giúp với tải này. Tôi không theo dõi thuật toán chạy từng bước để xác nhận. Đây là hành vi của một tải giả lập đặc biệt (thao tác không tốn CPU); với thao tác tốn CPU thật, ở phần về ticket pressure, thêm ticket không còn rút ngắn thời gian.
2b. Cố định số ticket: cùng T = 200
Để thấy riêng tác dụng của số ticket, chuyển sang thuật toán cố định (fixedConcurrentTransactions) và đặt trần cho cả đọc và ghi; manual dặn liên hệ support trước khi tắt thuật toán động, nên đây chỉ là thí nghiệm trên container dùng một lần. Cùng 200 thao tác 500 ms:
| Số ticket | Latency p50 | p95 | Tổng thời gian | Hàng chờ lớn nhất | ⌈200 ÷ ticket⌉ × 0,5 s |
|---|---|---|---|---|---|
mặc định throughputProbing (khởi động 10, cao nhất thấy 12) | 5.059 ms | 9.049 ms | 9.132 ms | 189 | khoảng 9 s |
| 8 cố định | 6.631 ms | 12.134 ms | 12.664 ms | 185 đến 192 | 12,5 s |
| 16 cố định | 3.555 ms | 6.122 ms | 6.587 ms | 184 | 6,5 s |
| 32 cố định | 2.067 ms | 3.225 ms | 3.578 ms | 168 | 3,5 s |
| 128 cố định | 1.377 ms | 1.726 ms | 1.758 ms | 29 đến 59 | 1 s |
(e2b-fixed.out. Ở 128, chỉ có 72 thao tác vượt trần 128 nên xếp hàng; lượt 3 chậm bất thường, 3.435 ms, có lẽ do host bận; tôi không xác định được. Đặt giá trị dưới 5 bị từ chối: lần thử 4 báo Concurrent read transactions limit must be greater than or equal to 5, odd/e2b-fixed-nowarmup-config4-rejected.out.)
Latency tỉ lệ nghịch với số ticket. Với thao tác không tốn CPU, tăng ticket từ 8 lên 128 giảm tổng thời gian từ 12,7 s xuống 1,8 s. Hãy giữ kết luận đó ở đúng phạm vi: nó đúng vì thao tác sleep không cạnh tranh CPU. Thao tác tốn CPU thật được đo ở phần về ticket pressure.
2c. maxTimeMS và hàng chờ
100 thao tác 500 ms trên server mặc định (10 ticket), có và không có maxTimeMS: 1500 (e2c-maxtime.out, 2 lượt):
| Hoàn thành | Hết hạn (MaxTimeMSExpired) | Tổng thời gian | normalPriority.canceled | |
|---|---|---|---|---|
Không maxTimeMS | 100 / 100 | 0 | 5.043 và 5.101 ms | 0 |
maxTimeMS: 1500 | 28 và 31 | 72 và 69 | 1.602 và 1.611 ms | +58 và +58 |
Những thao tác thất bại hết hạn sau 1.521 đến 1.610 ms. [tài liệu] Manual định nghĩa canceled là "số thao tác hết thời gian trong hàng chờ"; [suy luận] lab khớp: 58 thao tác hết hạn khi còn xếp hàng, 14 và 11 thao tác còn lại hết hạn khi đã có ticket và đang chạy. Rút ra: thời hạn maxTimeMS tính cả thời gian chờ ticket, và nó cắt hàng chờ: hàng chờ rỗng sau 1,6 s thay vì 5 s, đổi lại 70% thao tác thất bại. Đó là cách giữ cho hàng chờ không phình mãi, nhưng nó biến độ trễ thành lỗi.
2d. Hot document: 32 client cùng $inc một document
Câu hỏi còn lại của bài Document Model: dồn lệnh ghi vào một document "tổng kho" thì phía engine chuyện gì xảy ra? 32 client, mỗi client lặp updateOne({ _id }, { $inc: { n: 1 } }) trong 6 giây; một chế độ cả 32 cùng một document, một chế độ mỗi client một document riêng. Đọc metrics.operation.writeConflicts của serverStatus trước và sau, và write ticket mỗi 100 ms (e2e-hot-document.js, e2e-hot-document.out, 3 lượt mỗi chế độ):
| Cập nhật mỗi giây | p50 / p95 | Write conflict phía server | Write ticket out lớn nhất | Hàng chờ ticket | |
|---|---|---|---|---|---|
| Cùng một document | 22.558; 23.803; 22.565 | 1 ms / 2 ms | 11.289; 13.013; 13.920 | 1 đến 2 | 0 |
| Mỗi client một document | 21.799; 22.705; 23.338 | 1 ms / 2 đến 3 ms | 0 | 0 đến 2 | 0 |
Giá trị cuối của n bằng đúng số lệnh đã nhận ack ở cả ba lượt (ví dụ 135.346), tức không mất lệnh nào. Cái chặn nhau ở đây là write conflict (số conflict bằng khoảng 8% đến 10% số lệnh), không phải lock hay ticket: server tự thử lại trong suốt, client không thấy lỗi, và ticket gần như không bị chiếm vì mỗi lệnh chỉ chạy cỡ 1 ms.
Cần đọc con số này cho đúng. Throughput hai chế độ gần bằng nhau, và [suy luận] trần 22 đến 24 nghìn lệnh mỗi giây nhiều khả năng là của chính tiến trình mongosh sinh tải (một thread JavaScript), nên lab không tìm ra giới hạn thật của một hot document. Điều nó cho thấy: ở cỡ hàng chục nghìn lệnh nhỏ mỗi giây trên một document, thứ xuất hiện là write conflict được server thử lại, không phải hàng chờ lock hay ticket. "Nút thắt" của một hot document nên đọc là các lệnh phải xếp thành chuỗi và tốn thêm các lần thử lại. [suy luận] Lệnh nào giữ document lâu hơn (đi kèm transaction, hoặc nhiều index phải sửa) thì tỉ lệ conflict và số lần thử lại tăng; tôi không đo điều đó. Cách giảm tranh chấp nằm ở Hot document và checklist production.
Cột mốc: Bạn đã có thể phân biệt latch với ticket, đọc
queues.executiontheo từng phiên bản, và tính được hàng chờ ticket dài bao nhiêu từ số ticket và thời gian giữ. Tiếp theo: Ticket pressure, chẩn đoán và so với PostgreSQL.
Hỏi & đáp
Trên mongod 8.3.11, serverStatus().queues.execution.read.available bằng 0 trong 5 phút, nhưng queueLength bằng 0. Nhận định nào đúng theo manual và lab?
Lab 2b: 200 thao tác, mỗi thao tác giữ ticket 500 ms. Với 16 ticket cố định, tổng thời gian đo được là 6.587 ms. Vì sao con số đó gần với 200 ÷ 16 × 0,5 s?
Một client đặt maxTimeMS: 1500 cho query bình thường chạy 500 ms. Khi server có 10 ticket và 100 query như vậy đến cùng lúc, điều gì xảy ra?