Concurrency (P2/3): Latch, ticket và lab tranh ticket

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

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

Ý 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, collectioncấu trúc dữ liệu trong RAM
Giữ bao lâucả thời gian một lệnh hoặc transactioncỡ micro giây
Có hàng chờ nhìn thấy đượccó (waitingForLock, globalLock.currentQueue)không có thao tác nào "đang chờ latch" trong currentOp
Tránh deadlockkhoá từ trên xuống: Global, Database, Collection (README lock manager); transaction chờ lock tối đa 5 msluô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ệcbtree page lock acquisitionsbtree page lock application thread wait time (µs, 3 lượt)schema lock acquisitions
Chỉ ghikhoảng 13.6001.659 / 34 / 5000
Ghi, cùng lúc 5 lần createIndex rồi dropIndex16.343 đến 17.1196.372 / 20.794 / 22.11432 / 38 / 32
Ghi, cùng lúc 5 lần fsync (ép checkpoint)khoảng 13.700150.099 / 128.222 / 97.2465 / 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ảnHành vi và cách đọcNguồn
6.0 trở về trướcMỗ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ảimanual 6.0, manual hiện hành
7.0Thuậ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àngmanual 7.0
8.0Số liệu chuyển sang serverStatus().queues.execution (và queues.ingress cho hàng chờ ở cửa vào)manual 8.0
8.3queues.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ố throughputProbingStallEscalationThresholdmanual "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ườngNghĩaĐọc thế nào
outsố ticket đang bị giữ≤ totalTickets
availablesố ticket còn lạibằng 0 là hết ticket, nhưng chưa chắc có vấn đề
totalTicketsdung lượng hiện tại của pooltừ 7.0 thay đổi theo thời gian
normalPriority.queueLengthsố thao tác đang xếp hàng chờ tickettín hiệu chính: lớn và kéo dài là quá tải
normalPriority.totalTimeQueuedMicrostổng thời gian chờ, cộng dồnlấy hiệu hai lần đo, chia cho số thao tác
normalPriority.canceledsố 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, timesDecreasedsố lần thuật toán đổi dung lượng poolthuậ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 clientLatency p50p95Tổng thời gianHàng chờ ticket lớn nhấtTicket tối đa thấy được
8510 ms521 ms521 ms010
321.021 ms1.529 ms1.529 ms2111
1283.527 ms5.613 ms6.042 ms11711 đến 12
50012.081 ms21.610 ms22.132 ms488 đến 48912 đế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ố ticketLatency p50p95Tổng thời gianHà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 ms9.049 ms9.132 ms189khoảng 9 s
8 cố định6.631 ms12.134 ms12.664 ms185 đến 19212,5 s
16 cố định3.555 ms6.122 ms6.587 ms1846,5 s
32 cố định2.067 ms3.225 ms3.578 ms1683,5 s
128 cố định1.377 ms1.726 ms1.758 ms29 đến 591 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ànhHết hạn (MaxTimeMSExpired)Tổng thời giannormalPriority.canceled
Không maxTimeMS100 / 10005.043 và 5.101 ms0
maxTimeMS: 150028 và 3172 và 691.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âyp50 / p95Write conflict phía serverWrite ticket out lớn nhấtHàng chờ ticket
Cùng một document22.558; 23.803; 22.5651 ms / 2 ms11.289; 13.013; 13.9201 đến 20
Mỗi client một document21.799; 22.705; 23.3381 ms / 2 đến 3 ms00 đến 20

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.execution theo 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?

  1. Quá tải: mọi ticket đều đã bị giữ, thao tác mới chắc chắn phải chờ

    Đó là cách đọc của 6.0 trở về trước. Từ 7.0 manual nói available bằng 0 lâu không chỉ ra quá tải; hàng chờ rỗng nghĩa là chưa ai phải chờ. Xem mục "Ticket theo từng phiên bản".

  2. Sự cố lock: ticket hết là hậu quả của waitingForLock

    Lock và ticket là hai hàng chờ riêng. Thao tác chờ ticket có waitingForLock: false và isHoldingTicket: false. Xem mục "Ticket là gì".

  3. Phải tăng storageEngineConcurrentReadTransactions lên 128 ngay

    Không có dấu hiệu chờ nào để chữa, và manual dặn việc chỉnh các tham số này cần liên hệ support. Xem mục "Ticket theo từng phiên bản".

  4. Chưa phải quá tải: chưa có thao tác nào phải xếp hàng chờ ticket

    Manual 7.0 trở đi: dùng số thao tác xếp hàng để nhận biết quá tải, available thấp không đủ kết luận. Trong lab 2a, chờ thật chỉ xuất hiện khi queueLength lớn hơn 0 (từ T = 32). Xem mục "Đọc hàng chờ ticket".

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?

  1. Vì mongod chia đều CPU cho 16 thao tác nên mỗi thao tác mất đúng 500 ms CPU

    Thao tác sleep không tốn CPU; số ticket mới là thứ giới hạn. Xem mục "Lab 2".

  2. Vì chỉ 16 thao tác chạy mỗi đợt 500 ms, nên 13 đợt cho khoảng 6,5 giây

    200 ÷ 16 làm tròn lên là 13 đợt, mỗi đợt 0,5 s. Với 8 ticket là 25 đợt, đo được 12,7 s; với 32 ticket là 7 đợt, đo được 3,6 s. Xem mục "2b. Cố định số ticket".

  3. Vì 16 là số ticket khởi động mặc định của thuật toán động

    Mặc định trên lab là 10 ticket mỗi loại, và thuật toán động chỉ đưa lên tới 12 đến 13. Xem mục "2a. Mặc định".

  4. Vì WriteConflict buộc mỗi thao tác chạy lại

    Đây là các lệnh đọc, không có xung đột ghi nào để thử lại; thời gian đo được là thời gian chờ ticket. Xem mục "Lab 2".

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?

  1. Cả 100 xong, vì thời gian chạy thật của mỗi query chỉ 500 ms, nhỏ hơn 1.500 ms

    maxTimeMS tính cả thời gian chờ ticket. Trong lab chỉ 28 và 31 trên 100 xong. Xem mục "2c. maxTimeMS và hàng chờ".

  2. Server tự tăng ticket lên 128 trong lúc chờ để không query nào bị hết hạn

    Lab: tổng ticket vẫn khoảng 10 đến 11 suốt lượt chạy. Xem mục "2a. Mặc định" và "2c. maxTimeMS và hàng chờ".

  3. Khoảng 30 query xong, phần còn lại hết hạn sau 1,5 giây

    Lab: 28 và 31 xong, 72 và 69 hết hạn ở 1.521 đến 1.610 ms, tổng thời gian 1,6 giây thay vì 5 giây; canceled tăng 58. Hàng chờ ticket cũng được dọn sớm. Xem mục "2c. maxTimeMS và hàng chờ".

  4. Các query hết hạn nhưng vẫn giữ ticket của chúng cho tới khi chạy xong

    Query hết hạn khi còn xếp hàng thì không bao giờ lấy được ticket; canceled tăng đúng cho trường hợp đó (58 trên 72 và 69). Xem mục "2c. maxTimeMS và hàng chờ".