Concurrency (P3/3): Ticket pressure, chẩn đoán và so với PostgreSQL

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

Ở 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à.

Ý 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 trong lab24/.

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ứ 3Không có createIndexCó createIndex
Write ticket out / available / totalTickets1 / 9 / 1011 / 0 / 11
Hàng chờ ticket (queueLength của pool ghi)021
Thao tác waitingForLock: true010
globalLock.currentQueue.writers031
30 lệnh ghi vào cùng collection11 đến 17 mskhoảng 3.020 ms (xong cùng lúc với A)
insertOne vào collection khác1 ms1.954, 1.989, 1.991 ms
findOne cùng collection1 ms2, 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ìnhThroughputLatency p50p95Hàng chờ ticket lớn nhấtTổng thời gian chờ ticketTổ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/s7.239 ms8.433 ms53301 đến 343 s89 đến 96 s
128 cố định7,79 lần quét/s7.067 ms8.159 ms00357 đến 388 s
8 cố định8,56 lần quét/s6.357 ms7.383 ms56299 đến 306 s59 đế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 ở clientMột lệnh p50Latency ứng dụng p50Ứng dụng p95Server: ticket out lớn nhấtServer: hàng chờ ticket
4106 ms2.755 ms5.093 ms40
64387 đến 419 ms1.030 đến 1.068 ms1.750 đến 1.882 ms1253

(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 đó:

slowmsDòng Slow queryGhi chú
1000client thấy p95 = 257 ms, vượt 100 ms
2069 (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 serverSố đo của labCá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 đầy500 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 đã đầynhư trên, kèm CPU của mongod bằng số CPU được cấp64 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 ticketgiả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 theoDDL chờ + 30 lệnh ghi: queueLength 21, lệnh ghi vào collection khác chờ 1,95 đến 1,99 stì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 poolserver rảnh: queueLength 0, currentOp thấy ít thao tác; chỉ ứng dụng thấy chậmLIMIT 4: server 106 ms, ứng dụng p50 2,8 schỉ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-yieldchỉ theo tài liệu, chưa đo ở đây; xem bài Cache, Eviction & Working Setxem bài đó
Write conflict trên hot documentmetrics.operation.writeConflicts tăng (và writeConflicts của từng thao tác trong currentOp); không có hàng chờ ticketLab 2d: 11.289 đến 13.920 conflict trong 6 s, hàng chờ ticket 0giả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ự.

  1. Có waitingForLock: true không? Nếu có, lock; tìm thao tác đang giữ (locks và secs_running) và DDL đang chờ.
  2. queues.execution.*.normalPriority.queueLength có 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).
  3. CPU của mongod có bằng số CPU được cấp không? Nếu có, tăng ticket không giúp.
  4. 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 available bằ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 maxTimeMS cho 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 queueLength kéo dài và mức tăng của totalTimeQueuedMicros, không cảnh báo trên available bằng 0 (từ 7.0). Cảnh báo waitingForLock riê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 định false) để 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 SELECT lấy ACCESS SHARE, chỉ xung đột với ACCESS EXCLUSIVE; mà ACCESS EXCLUSIVE do DROP TABLE, TRUNCATE, VACUUM FULL, REINDEX và nhiều dạng ALTER TABLE lấy, và "xung đột với mọi mode", nên nó chặn cả lệnh đọc. MongoDB cho find và aggregate là 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.
  • "available bằ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 createIndex hay dropIndexes trê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 maxTransactionLockRequestTimeoutMillis thay 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 sleep hay 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ì?

  1. Hàng chờ chuyển vào trong: throughput xấp xỉ, thời gian giữ ticket tăng gấp bốn

    Lab: tổng thời gian giữ ticket tăng từ 89 đến 96 s lên 357 đến 388 s khi nâng lên 128, còn throughput gần như không đổi và CPU vẫn quanh 200%. Xem mục "Lab 3b".

  2. Throughput tăng rõ, vì hàng chờ ticket biến mất và mọi query chạy cùng lúc

    Hàng chờ biến mất nhưng throughput gần như không đổi: 7,79 lần quét/s với 128 ticket so với 7,57 lần/s mặc định. Xem mục "Lab 3b".

  3. Không nâng mà hạ xuống 8 ticket sẽ làm throughput sụp đổ

    Lab: với 8 ticket, throughput 8,56 lần/s, nhỉnh hơn mặc định. Chênh lệch nhỏ so với dao động của host, nhưng không có sụp đổ. Xem mục "Lab 3b".

  4. Ticket không liên quan, vì waitingForLock bằng false nghĩa là server không có hàng chờ nào

    waitingForLock chỉ nói về lock. Hàng chờ ticket là queueLength, một hàng chờ khác; Lab 3b không có DDL hay lock nào phải chờ mà queueLength vẫn lên 53. Xem mục "Bảng chẩn đoán".

Ứ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?

  1. Slow query log bị tắt theo mặc định nên không có dòng nào

    Cùng cấu hình, với slowms: 20 log có 69 dòng, nên log đang bật. Xem mục "Lab 3d".

  2. Mỗi query chạy trên một connection riêng trong pool, nên server không gom chúng lại để ghi log

    Mọi connection đều ghi log khi vượt ngưỡng; lab dùng 64 connection. Xem mục "Lab 3d".

  3. Thuật toán động đã tăng ticket đủ cao để hàng chờ biến mất

    Lab dùng 8 ticket cố định, thuật toán động đã tắt; hàng chờ vẫn có. Xem mục "Lab 3d".

  4. Thao tác chỉ làm việc khoảng 30 ms; thời gian xếp hàng không tính vào workingMillis

    Manual (từ 8.0) nói log theo thời gian làm việc; lab thấy dòng mẫu durationMillis 268, workingMillis 34, totalTimeQueuedMicros 234.442. Xem mục "Lab 3d".

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?

  1. Mua thêm bếp: nhiều bếp hơn thì nhiều món được nấu cùng lúc

    Bếp đã bị giữ bởi người chưa nấu, thêm bếp chỉ thêm người đứng chờ hàng. Nó giống Lab 3b, nơi tăng ticket chỉ dời hàng chờ. Xem mục "Lab 3b".

  2. Tìm xem vì sao mỗi phiếu giữ bếp lửa lâu và chữa chỗ đó trước

    Thời gian chờ tỉ lệ với thời gian giữ, nên rút ngắn thời gian giữ là cách chữa gốc. Xem mục "Chỉnh gì, và đừng chỉnh gì".

  3. Đóng cửa không nhận khách mới cho tới khi hàng chờ hết

    Việc đó giống đặt maxTimeMS rất ngắn: hàng chờ hết nhưng khách bị từ chối, và không chữa nguyên nhân. Xem mục "Chỉnh gì, và đừng chỉnh gì".

  4. Cho khách lấy số và chờ ngoài cửa, bếp sẽ đỡ đông

    Dời hàng chờ ra ngoài chỉ giấu nó đi, giống Lab 3c: hàng chờ ở client làm server trông rảnh trong khi ứng dụng vẫn chậm. Xem mục "Lab 3c".

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