SharedData của Vert.x có sáu phương thức trông na ná nhau: getLocalMap, getLocalAsyncMap, getAsyncMap, getLocalLock, getLock, getLocalCounter, getCounter. Chúng khác nhau ba nghìn lần về tốc độ, và hai trong số đó sẽ hỏng khi bạn triển khai thật.

Tôi đo tất cả, hai lần: một lần không cụm, một lần trong cụm Hazelcast hai node.

Bảng số

Không cụm Có cụm (2 node)
LocalMap.put 0,026 µs 0,033 µs
LocalMap.get 0,026 µs 0,024 µs
getLocalAsyncMap.put 5,95 µs 7,75 µs
getAsyncMap.put (tuần tự) 5,95 µs 49,53 µs
getAsyncMap.get (tuần tự) 5,99 µs 16,41 µs
getAsyncMap.put (64 lượt) 0,23 µs 3,20 µs
getLocalLock 6,81 µs 7,65 µs
getLock 6,34 µs HỎNG
getLocalCounter 5,96 µs 7,59 µs
getCounter 6,22 µs HỎNG

Chuyện đáng nói nhất: hai dòng "HỎNG"

getLock    -> 20000/20000 LOI — UnsupportedOperationException:
              CP subsystem is a licensed feature.
getCounter -> 20000/20000 LOI — (y het)

Phần trước đã gặp lỗi này, nhưng bảng trên mới cho thấy phần khó chịu thật sự: cả hai chạy hoàn hảo khi không có cụm.

Nghĩ về chuỗi công việc thường ngày. Bạn viết mã dùng getLock để bảo vệ một thao tác. Bạn chạy test — Vert.x không cụm, khoá hoạt động, test xanh. Bạn chạy thử trên máy mình — vẫn không cụm, vẫn chạy. Rồi bạn triển khai lên môi trường thật, nơi có cụm, và đúng lúc đó nó bắt đầu ném UnsupportedOperationException.

Đây là kiểu hỏng tệ nhất có thể thiết kế ra: nó chỉ xuất hiện ở nơi bạn ít thử nhất, và nó xuất hiện ở chính đoạn mã có nhiệm vụ bảo vệ tính đúng đắn. Một khoá không hoạt động không làm hỏng ngay — nó chỉ làm hỏng khi có hai tiến trình chạy chồng nhau, tức là ngẫu nhiên và hiếm.

Nếu bạn dùng getLock hay getCounter ở bất cứ đâu, hãy chạy thử một lần trong cụm thật trước khi tin vào chúng. Với Hazelcast bản mở thì câu trả lời là không dùng được, và các lựa chọn thay thế là khoá trong cơ sở dữ liệu, Redis, hoặc thiết kế lại để khỏi cần khoá.

LocalMap nhanh hơn AsyncMap 229 lần

26 nano giây so với 5,95 micro giây — cùng một tiến trình, cùng một dữ liệu trong bộ nhớ. Vì sao?

LocalMap là một ConcurrentHashMap được bọc mỏng: gọi là trả về ngay. AsyncMap trả về Future, nghĩa là mỗi thao tác phải đi qua ít nhất một lần chuyển ngữ cảnh của Vert.x, kể cả khi dữ liệu nằm ngay trong bộ nhớ cùng tiến trình.

Nhưng đó là con số tuần tự — tức là độ trễ, không phải năng lực. Cho 64 lượt cùng bay:

getAsyncMap.put, tuan tu : 5,95 µs
getAsyncMap.put, 64 luot : 0,23 µs      gap 26 lan

Nên cách đọc đúng là: AsyncMap không đắt hơn 229 lần về công sức, nó chỉ có độ trễ cao hơn 229 lần cho một thao tác đơn lẻ. Nếu bạn có nhiều việc chạy song song thì hệ thống vẫn theo kịp; nếu bạn xâu chuỗi tuần tự thì bạn trả đủ.

Ghi vào cụm đắt gấp ba lần đọc

getAsyncMap.put trong cum : 49,53 µs
getAsyncMap.get trong cum : 16,41 µs

Chênh ba lần, và lý do nằm ở việc Hazelcast giữ một bản sao dự phòng: mỗi lần ghi phải sang node giữ phân vùng đó node giữ bản dự phòng, rồi mới xác nhận. Đọc chỉ cần hỏi một nơi.

Đây là thứ đáng nhớ khi bạn định dùng AsyncMap làm cache dùng chung: một cache đọc nhiều ghi ít thì rất hợp, còn một bộ đếm ghi liên tục thì mỗi lần cập nhật tốn 49,5 µs — gấp năm lần một truy vấn tra khoá chính trên PostgreSQL cục bộ mà phần 35 đã đo.

Cũng chú ý dòng getLocalAsyncMap trong cụm: 7,75 µs, gần như bằng lúc không cụm. Đó là điểm mấu chốt — getLocalAsyncMap không bao giờ ra khỏi tiến trình, kể cả khi bạn đang chạy cụm. Nếu dữ liệu chỉ cần dùng chung giữa các Verticle trong một JVM, dùng bản Local là tiết kiệm được sáu lần.

LocalMap sao chép lúc get, không phải lúc put

Đây là chi tiết tôi tưởng mình đã biết cho tới khi đo.

LocalMap chỉ nhận vài kiểu. Thử năm loại:

String      -> nhan
Integer     -> nhan
JsonObject  -> nhan
lop implements Shareable -> nhan
POJO thuong -> TU CHOI: Invalid type for shareddata data structure

Từ chối POJO thường là hợp lý: nhiều Verticle trên nhiều event loop cùng đọc một đối tượng có thể sửa được là công thức của một cuộc đua dữ liệu. Vert.x chặn ngay ở put.

Nhưng cách nó bảo vệ thì bất đối xứng. Tôi kiểm tra bằng phép thử đồng nhất:

put roi get -> cung mot doi tuong? false
get hai lan -> cung mot doi tuong? false
sua BAN LAY RA        -> trong map: 1     (an toan)
sua BAN GOC sau put   -> trong map: 999   (KHONG an toan)

get trả về một bản sao mới mỗi lần, nên sửa thứ bạn lấy ra không ảnh hưởng gì tới map. Nhưng put giữ chính tham chiếu bạn đưa vào. Sửa đối tượng gốc sau khi đã put là sửa luôn nội dung trong map — và làm vậy từ một event loop khác thì không có gì đồng bộ hoá cả.

Đoạn mã dưới đây trông vô hại và là một cuộc đua dữ liệu thật:

JsonObject cauHinh = new JsonObject().put("nguong", 10);
map.put("cau-hinh", cauHinh);
// ... o cho khac, tren event loop khac:
cauHinh.put("nguong", 20);     // vua sua thu nam trong map, khong khoa gi ca

Cách viết an toàn là tự sao chép trước khi put, hoặc đơn giản hơn: đừng giữ tham chiếu tới thứ đã bỏ vào map.

map.put("cau-hinh", cauHinh.copy());

Chọn cái nào

Nhu cầu Dùng
Dùng chung trong một JVM, cần nhanh getLocalMap — 26 ns
Dùng chung trong một JVM, cần API bất đồng bộ getLocalAsyncMap
Dùng chung toàn cụm, đọc nhiều ghi ít getAsyncMap
Đếm hoặc khoá trong một JVM getLocalCounter, getLocalLock
Đếm hoặc khoá toàn cụm không phải Hazelcast bản mở

Và một luật bao trùm: Local không có nghĩa là "chỉ dùng khi chạy một mình". Nó có nghĩa là "phạm vi một tiến trình". Trong một cụm, phần lớn dữ liệu bạn muốn chia sẻ thật ra chỉ cần chia sẻ giữa các Verticle trong cùng tiến trình — và với những thứ đó, bản Local vừa nhanh hơn sáu lần vừa không phụ thuộc vào giấy phép của ai.

Thử ba mươi giây

Tìm những chỗ dùng khoá hoặc bộ đếm toàn cụm trong mã của bạn:

grep -rn "getLock\|getCounter" --include=*.java src/ | grep -v "getLocalLock\|getLocalCounter"

Mỗi dòng tìm được cần được chạy thử trong cụm thật đúng một lần. Nếu test của bạn chạy Vert.x không cụm — gần như chắc chắn là vậy — thì những dòng đó chưa bao giờ được kiểm chứng, và chúng sẽ hỏng lần đầu tiên vào đúng ngày bạn triển khai.

Phần sau bật metrics bằng Micrometer và chọn ra sáu chỉ số đáng đặt cảnh báo cho một ứng dụng event loop.