Concurrency (P3/3): Ticket pressure, chẩn đoán và so với PostgreSQL
Ở phần trước: Ticket giới hạn số thao tác trong storage engine. Với 500 client tranh 10 ticket, thời gian chờ tỉ lệ với T ÷ số ticket, và maxTimeMS cắt hàng chờ. Từ 7.0, available bằng 0 chưa phải quá tải; hàng chờ dài kéo dài mới là.
- Cần đọc trước: Latch, ticket và lab tranh ticket
- Dẫn tới: Replica Set & Oplog. Với bài này, Phần WiredTiger khép lại tại đây.
Ý chính
Hàng chờ ticket dài là triệu chứng, không phải nguyên nhân. Nó xuất hiện khi các thao tác giữ ticket lâu, và có nhiều lý do để chúng giữ lâu: chính chúng chậm, chúng đứng chờ một lock, CPU đã đầy, hoặc chúng chờ I/O. Cũng có những lúc server rảnh mà ứng dụng vẫn chậm vì hàng chờ nằm ở client. Chữa nhầm chỗ (thường là tăng ticket) có thể không đổi gì, hoặc làm tệ hơn.
Phần này đo bốn nguồn chờ trên cùng một server, rồi gom thành bảng chẩn đoán dùng được: nhìn trường nào trong currentOp, serverStatus và slow query log để biết hàng chờ đến từ đâu.
Môi trường: như các phần trước: MongoDB 8.3.11, container
mongo-conc24-main(--cpus=2, 2 GB RAM, WiredTiger cache 1 GB), client trong container khác, host dùng chung nên số đo chỉ để so sánh tương đối; Lab 3a đến 3c chạy 3 lượt, bảng ghi trung vị hoặc khoảng, restart server trước mỗi lượt; Lab 3d chạy một lượt cho mỗi cấu hình. Script và output thô nằm tronglab24/.
Lab 3a: lock đang chờ kéo theo hết ticket
Kịch bản của phần về lock, nhưng thêm 30 lệnh ghi vào cùng collection và một lệnh ghi vào collection khác (e3a-lock-to-ticket.js, e3a-lock-to-ticket.out). A là updateOne chậm 5 giây; ở giây thứ 1, B là createIndex trên cùng collection; ở giây thứ 2, 30 lệnh insertOne vào cùng collection; ở giây thứ 3 ta đo: một insertOne vào collection khác và một findOne vào collection của A. Mặc định, 10 write ticket. Lượt control: cùng kịch bản, không có B.
| Lúc giây thứ 3 | Không có createIndex | Có createIndex |
|---|---|---|
Write ticket out / available / totalTickets | 1 / 9 / 10 | 11 / 0 / 11 |
Hàng chờ ticket (queueLength của pool ghi) | 0 | 21 |
Thao tác waitingForLock: true | 0 | 10 |
globalLock.currentQueue.writers | 0 | 31 |
| 30 lệnh ghi vào cùng collection | 11 đến 17 ms | khoảng 3.020 ms (xong cùng lúc với A) |
insertOne vào collection khác | 1 ms | 1.954, 1.989, 1.991 ms |
findOne cùng collection | 1 ms | 2, 3, 4 ms |
Cơ chế, đọc từ bảng: B và chín insertOne đang chờ lock vẫn giữ write ticket (cộng với A là 11 ticket out), nên 21 lệnh ghi còn lại, và cả lệnh ghi vào collection khác, đứng chờ ở hàng ticket. Một lock đang chờ ở một collection đã làm hết write ticket của cả server cho mọi lệnh ghi. Lệnh đọc vẫn qua vì nó dùng read ticket, pool khác, và không cần lock của collection.
[quan sát] Thứ phân biệt hai hàng chờ: waitingForLock: true chỉ về lock; currentQueue.name: "execution" và queueLength chỉ về ticket. Còn globalLock.currentQueue.writers là 31 trong khi chỉ 10 thao tác waitingForLock: [suy luận] con số này gộp cả 21 thao tác đang chờ ticket, nên một mình nó không tách được hai hàng chờ. Trong lab này cả hai hàng cùng lên, và nguyên nhân gốc là lock. Chỉ nhìn queueLength sẽ dẫn tới việc đi chữa ticket.
Lab 3b: CPU đã đầy, tăng ticket không giúp được
Hết ticket mà thao tác thật sự đang tốn CPU. Một collection 200.000 document (134 MB, vừa cache), mỗi thao tác là một lần quét toàn collection không có index: countDocuments({ note: { $regex: "zzzzzqqq" } }). 64 thao tác chạy cùng lúc (e3b-cpu.js, e3b-cpu.out), với ba cấu hình ticket:
| Cấu hình | Throughput | Latency p50 | p95 | Hàng chờ ticket lớn nhất | Tổng thời gian chờ ticket | Tổng thời gian giữ ticket |
|---|---|---|---|---|---|---|
Mặc định throughputProbing (khởi động 10, lên tới 12 đến 13) | 7,57 lần quét/s | 7.239 ms | 8.433 ms | 53 | 301 đến 343 s | 89 đến 96 s |
| 128 cố định | 7,79 lần quét/s | 7.067 ms | 8.159 ms | 0 | 0 | 357 đến 388 s |
| 8 cố định | 8,56 lần quét/s | 6.357 ms | 7.383 ms | 56 | 299 đến 306 s | 59 đến 60 s |
docker stats cho thấy mongod dùng khoảng 200% CPU (các mẫu giữa lượt từ 190% đến 215%), tức gần như đúng 2 CPU của container, suốt lúc chạy ở cả ba cấu hình (cpu-*.txt, dòng cuối mỗi lượt trong e3b-cpu.out).
[quan sát] Với 128 ticket không còn ai xếp hàng, nhưng throughput (số lần quét xong mỗi giây) không tăng: 7,79 so với 7,57. Cái đổi là chỗ chờ: tổng thời gian chờ ticket về 0 và tổng thời gian giữ ticket nhảy từ khoảng 90 s lên 360 s, vì 64 thao tác cùng chạy trên 2 CPU, mỗi thao tác kéo dài ra. Với 8 ticket, hàng chờ vẫn dài mà throughput cao hơn một chút (8,56) và p50 thấp hơn (6.357 ms): ít thao tác chạy cùng lúc thì ít tranh CPU hơn. Khoảng chênh khoảng 10%, nhỏ so với dao động của host dùng chung, nên đừng đọc nó như một khuyến nghị; điều chắc chắn là ba cấu hình cho throughput gần nhau.
[suy luận] Khi thứ đang thiếu là CPU, ticket chỉ quyết định hàng chờ nằm ở đâu: trước cửa storage engine (queue) hay bên trong (cùng giữ ticket mà chạy chậm). Kết luận này khớp với cách thuật toán động chỉ giữ thêm ticket khi throughput tăng, và với cảnh báo của manual rằng sửa tham số ticket có thể gây vấn đề hiệu năng.
Lab 3c: hàng chờ nằm ở client
Hàng chờ có thể không nằm ở server. Giả lập một connection pool nhỏ: 200 thao tác 100 ms, nhưng client chỉ cho LIMIT thao tác chạy cùng lúc (e3c-pool-sim.js, e3c-pool-sim.out). Latency của ứng dụng tính từ lúc yêu cầu được tạo, còn thời gian một lệnh tính từ lúc nó được gửi đi tới khi có kết quả:
| LIMIT ở client | Một lệnh p50 | Latency ứng dụng p50 | Ứng dụng p95 | Server: ticket out lớn nhất | Server: hàng chờ ticket |
|---|---|---|---|---|---|
| 4 | 106 ms | 2.755 ms | 5.093 ms | 4 | 0 |
| 64 | 387 đến 419 ms | 1.030 đến 1.068 ms | 1.750 đến 1.882 ms | 12 | 53 |
(Ba lượt cho mỗi dòng: dòng LIMIT 4 ghi trung vị, dòng LIMIT 64 ghi khoảng của 3 lượt.) Với LIMIT = 4, server không bận: lệnh nào cũng xong trong khoảng 106 ms, không có hàng chờ ticket, currentOp chỉ thấy 4 thao tác; nhưng ứng dụng thấy p50 gần 2,8 giây, vì yêu cầu xếp hàng trong client trước khi được gửi đi.
[suy luận] Ví dụ này cũng cho thấy giới hạn phía client không miễn phí: LIMIT = 4 bảo vệ server nhưng đẩy hàng chờ về ứng dụng. Với tải toàn là thao tác 100 ms không tốn CPU, giới hạn đó là quá nhỏ, và LIMIT = 64 cho latency ứng dụng thấp hơn (p50 khoảng 1 giây) đổi lại hàng chờ ticket ở server. Kích thước đúng phụ thuộc thao tác tốn CPU hay chờ I/O; xem bài Connection Pool & Capacity.
Lab 3d: hàng chờ ticket không làm thao tác thành "chậm" trong slow query log
64 thao tác mỗi cái làm việc khoảng 30 ms, với 8 ticket cố định, chạy cùng lúc (e3d-all.sh, e3d-slowlog.out). Latency phía client: p50 158 ms, p95 257 ms. Đếm dòng Slow query cho collection đó:
slowms | Dòng Slow query | Ghi chú |
|---|---|---|
| 100 | 0 | client thấy p95 = 257 ms, vượt 100 ms |
| 20 | 69 (64 thao tác, phần dư có lẽ từ lượt khởi động của script) | dòng mẫu ngay sau bảng |
durationMillis: 268 workingMillis: 34
planningTimeMicros: 234583
queues.execution: { admissions: 3, totalTimeQueuedMicros: 234442 }[tài liệu] Từ 8.0, manual nói slow operation được ghi theo thời gian MongoDB làm việc trên thao tác (workingMillis), không phải tổng latency (durationMillis). [quan sát] Đúng như vậy: ở slowms: 100 không có dòng nào dù client thấy 257 ms, vì thao tác chỉ làm việc 30 ms. Khi slowms: 20, dòng log cho thấy phần chênh: durationMillis 268, workingMillis 34, và queues.execution.totalTimeQueuedMicros 234.442 µs, gần bằng durationMillis trừ workingMillis. Đáng chú ý: planningTimeMicros là 234.583 µs, gần như trùng thời gian chờ ticket; [suy luận] thời gian xếp hàng nằm trong khoảng tính là "planning", nên đừng đọc trường này một mình như chi phí của query planner.
Một lần chạy cùng kịch bản với 128 ticket (slowms: 100) cho 58 dòng slow, nhưng nó lẫn một yếu tố khác: dòng mẫu có workingMillis 143, [suy luận] vì 64 thao tác JavaScript chạy cùng lúc trên 2 CPU. Tôi không dùng nó làm lượt control (xem e3d-slowlog.out).
Hệ quả thực tế: nếu bạn chỉ canh slow query log, một hàng chờ ticket nặng có thể không để lại dấu vết nào. Cần canh queues.execution trong serverStatus (hoặc queues trong $currentOp) bên cạnh log.
Bảng chẩn đoán
| Nguồn chờ | Dấu hiệu trong server | Số đo của lab | Cách chữa trước tiên |
|---|---|---|---|
| Thao tác giữ ticket lâu (chậm, chờ I/O) | queueLength lớn kéo dài; out bằng totalTickets; waitingForLock false; CPU chưa đầy | 500 client tranh 10 ticket: p50 12 s; thời gian chờ ≈ T ÷ ticket × thời gian giữ | sửa chính thao tác (index, query), rút ngắn thời gian giữ |
| CPU đã đầy | như trên, kèm CPU của mongod bằng số CPU được cấp | 64 lần quét: khoảng 200% CPU; 7,6 đến 8,6 lần quét/s với 8, 12 đến 13 hay 128 ticket | giảm công việc mỗi thao tác hoặc thêm CPU; tăng ticket không giúp |
| Lock đang chờ | waitingForLock: true, locks có W đang chờ; hết ticket theo | DDL chờ + 30 lệnh ghi: queueLength 21, lệnh ghi vào collection khác chờ 1,95 đến 1,99 s | tìm thao tác đang giữ lock (A) và DDL đang chờ; tránh DDL lúc cao tải |
| Hàng chờ ở client / connection pool | server rảnh: queueLength 0, currentOp thấy ít thao tác; chỉ ứng dụng thấy chậm | LIMIT 4: server 106 ms, ứng dụng p50 2,8 s | chỉnh pool, đo ở phía ứng dụng |
| Cache thiếu chỗ, eviction nhiều | [tài liệu] application thread time waiting for cache (usecs) trong serverStatus().wiredTiger.thread-yield | chỉ theo tài liệu, chưa đo ở đây; xem bài Cache, Eviction & Working Set | xem bài đó |
| Write conflict trên hot document | metrics.operation.writeConflicts tăng (và writeConflicts của từng thao tác trong currentOp); không có hàng chờ ticket | Lab 2d: 11.289 đến 13.920 conflict trong 6 s, hàng chờ ticket 0 | giảm tranh chấp (Hot document); phía ứng dụng: Write Conflicts |
Cách dùng bảng: bốn câu hỏi, theo thứ tự.
- Có
waitingForLock: truekhông? Nếu có, lock; tìm thao tác đang giữ (locksvàsecs_running) và DDL đang chờ. queues.execution.*.normalPriority.queueLengthcó lớn và kéo dài không? Nếu có, hàng chờ ticket (dù nguyên nhân có thể là lock, câu 1 đã loại).- CPU của mongod có bằng số CPU được cấp không? Nếu có, tăng ticket không giúp.
- Server có rảnh mà ứng dụng chậm không? Nếu có, nhìn pool và client.
$currentOp giúp trả lời từng câu: waitingForLock, currentQueue.name, queues.execution.isHoldingTicket, totalTimeQueuedMicros, locks, và queryShapeHash (cùng mã cho cùng dạng query, có cả trong slow query log) để gom các thao tác chậm theo dạng.
Chỉnh gì, và đừng chỉnh gì
- Chữa query và index trước. Thời gian chờ ticket tỉ lệ với thời gian giữ: rút ngắn thao tác là cách duy nhất làm giảm cả hàng chờ lẫn CPU.
- Đừng tăng ticket theo phản xạ. Manual (7.0 trở đi) nói số ticket do thuật toán động quyết định và muốn tắt thì liên hệ support; lab 3b cho thấy tăng ticket chỉ chuyển hàng chờ vào trong. Cũng đừng tắt thuật toán động chỉ vì thấy
availablebằng 0. - Giới hạn ở client khi cần bảo vệ server, và hiểu nó chỉ chuyển hàng chờ về ứng dụng (Lab 3c).
- Đặt
maxTimeMScho thao tác của người dùng: hàng chờ không phình mãi (Lab 2c ở phần về ticket), đổi lại một phần thao tác thất bại. - DDL và transaction: chạy DDL ngoài giờ cao tải, giữ transaction ngắn (phần về lock), và đừng nâng
maxTransactionLockRequestTimeoutMillisđể che vấn đề. - Giám sát: cảnh báo trên
queueLengthkéo dài và mức tăng củatotalTimeQueuedMicros, không cảnh báo trênavailablebằng 0 (từ 7.0). Cảnh báowaitingForLockriêng. - [chi tiết cài đặt] README của mã nguồn (bản master) nói có thể bật
executionControlDeprioritizationGate(trên 8.3.11 tham số này có, mặc địnhfalse) để thao tác hay yield và tác vụ nền (index build, TTL) đi vào low-priority pool; chỉ nên cân nhắc khi tải nặng kéo dài trộn thao tác ngắn với thao tác dài. Lab không thử.
So với PostgreSQL
[tài liệu] PostgreSQL chạy mỗi connection bằng một process riêng (một backend process do postmaster sinh ra), nên số connection là giới hạn tự nhiên: max_connections mặc định "thường là 100". MongoDB cũng giới hạn số connection (xem bài Connection Pool & Capacity), nhưng mức song song bên trong storage engine do ticket quyết định, và từ 7.0 số ticket do thuật toán động thay đổi theo tải. Hai thứ không tương đương: max_connections giới hạn số connection đang mở, còn ticket giới hạn số thao tác đang ở trong storage engine.
Về lock, PostgreSQL có hai tầng đều hiện trong pg_stat_activity.wait_event_type: heavyweight lock (Lock, liệt kê trong pg_locks), bảo vệ các đối tượng SQL như bảng, và lightweight lock (LWLock), "phần lớn bảo vệ một cấu trúc dữ liệu trong shared memory". Cặp này gần với lock manager và latch của MongoDB về vai trò, tôi không khẳng định tương đương về chi tiết. Hai khác biệt đáng chú ý theo tài liệu:
- Đọc: một
SELECTlấyACCESS SHARE, chỉ xung đột vớiACCESS EXCLUSIVE; màACCESS EXCLUSIVEdoDROP TABLE,TRUNCATE,VACUUM FULL,REINDEXvà nhiều dạngALTER TABLElấy, và "xung đột với mọi mode", nên nó chặn cả lệnh đọc. MongoDB chofindvàaggregatelà lock-free read từ 5.0, nên không bị lock X trên collection chặn (lab ở phần về lock). - Ghi: PostgreSQL có row-level lock, "chỉ chặn writer và locker của cùng hàng", còn MongoDB phát hiện xung đột ở mức document và bắt bên đến sau thử lại (
WriteConflict) thay vì chờ.
[hình dung] Gần đúng: PostgreSQL xếp hàng ở lock, MongoDB lạc quan hơn ở mức document nhưng có thêm hàng chờ ticket. Cả hai đều có chuyện DDL mạnh chặn người khác; chỉ khác ai bị chặn.
Những lỗi thường gặp
- "MongoDB không có lock": có, ở nhiều tầng; lab đo được hàng chờ của lock.
- "
availablebằng 0 là sự cố" ở 7.0 trở đi: dấu hiệu là hàng chờ dài kéo dài. - Tăng ticket để chữa CPU đầy: throughput không đổi (Lab 3b).
- Chỉ canh slow query log: hàng chờ ticket không làm thao tác "chậm" theo
workingMillis(Lab 3d). - Chạy
createIndexhaydropIndexestrên collection đang bận và ngạc nhiên vì lệnh ghi không liên quan chờ theo (Lab 3a, và phần về lock). - Nâng
maxTransactionLockRequestTimeoutMillisthay vì sửa transaction dài. - Gộp isolation, locking và concurrency control làm một: ba câu hỏi, ba cơ chế.
- Rút ra kết luận production từ lab giả lập bằng
sleephay từ container 2 CPU; số trong bài chỉ để so sánh tương đối.
Cột mốc: Bạn đã có thể phân biệt hàng chờ lock, ticket, CPU, client và slow query log, và biết khi nào đừng chạm vào tham số ticket. Bài này, và Phần WiredTiger, khép lại tại đây.
Hỏi & đáp
Một mongod 8.3 báo queueLength lớn kéo dài ở pool đọc, các query đều là quét toàn collection; mongod dùng gần đủ số CPU được cấp, waitingForLock luôn false. Đồng nghiệp đề nghị nâng số read ticket lên 128. Lab 3b cho điều gì?
Ứng dụng báo query p95 là 257 ms, nhưng slow query log với slowms 100 không có dòng nào, dù mongod đang dùng 8 ticket cố định. Nguyên nhân hợp lý nhất theo lab?
Một bếp có 10 bếp lửa và đông khách; 100 phiếu đang xếp hàng, phần lớn đầu bếp đứng cạnh bếp nhưng đang chờ hàng về chứ chưa nấu. Quản lý định mua thêm 100 bếp lửa. Nên làm gì trước?
Nếu bỏ hết thuật ngữ: một quán ăn có số bếp cố định và một cửa ra vào. Món chậm là vì có ba lý do khác nhau mà nhìn từ ngoài giống hệt nhau: bếp lửa nào cũng bận (người nấu đang chờ nguyên liệu hoặc đang nấu món quá lâu), có người đứng chặn trước cửa khu bếp để dọn dẹp làm những người đi sau phải xếp hàng, hoặc khách xếp hàng dài ngay ngoài phố mà trong bếp vẫn rảnh. Thêm bếp lửa chỉ chữa được lý do đầu, và chỉ khi bếp lửa thật sự là chỗ nghẽn; nếu cả hai người nấu đang tranh một cái chảo thì thêm bếp không làm món ra nhanh hơn. Đứng ở cửa đếm xem ai đang chờ cái gì (nguyên liệu, người dọn dẹp, hay chỉ đứng xếp hàng) rẻ hơn nhiều so với xây thêm bếp.
Bài tiếp theo
Cả Phần WiredTiger nói về một mongod: cái gì nằm trong RAM, cái gì xuống đĩa, version nào ai thấy, và ai phải chờ ai. Nhưng một mongod chết thì dữ liệu chỉ còn trong journal và file của nó; production luôn chạy nhiều bản sao.
Replica Set & Oplog mở Phần Phân tán: primary và secondary, oplog là gì và nó khác journal ra sao, secondary áp dụng oplog thế nào, replication lag, và oplog window quyết định secondary có thể tụt lại bao lâu.
Tài liệu tham khảo
- MongoDB: FAQ Concurrency (lock mode, lock-free read, lệnh nào lấy lock nào)
- MongoDB: WiredTiger Storage Engine (Transaction Read and Write Concurrency, document-level concurrency)
- MongoDB 7.0: WiredTiger Storage Engine (
wiredTiger.concurrentTransactions) - MongoDB 6.0: Parameters (
wiredTigerConcurrentReadTransactionsmặc định 128) - MongoDB:
serverStatus(queues.execution,queues.ingress,locks,globalLock) - MongoDB: Parameters (
storageEngineConcurrentReadTransactions,maxTransactionLockRequestTimeoutMillis,ingressAdmissionControllerTicketPoolSize,throughputProbingStallEscalationThreshold) - MongoDB:
currentOp(locks,waitingForLock,lockStats,queues) - MongoDB: Log Messages (slow operation,
workingMillis,queues) - MongoDB source: Execution Control README (ticket pools, throughput probing, parameters)
- MongoDB source: Lock Manager README (resource hierarchy, modes, compatibility)
- WiredTiger: Concurrency management and Locks hierarchy
- PostgreSQL: Explicit Locking
- PostgreSQL: Monitoring Database Activity (wait event types
LockandLWLock) - PostgreSQL: Connections and Authentication (
max_connections)