Newsletter #139

Mời bạn thưởng thức Newsletter #139.

What Go Taught Us About Java Garbage Collection

So sánh ParparVM (VM của Codename One, dịch Java sang C và compile AOT) với Go, tác giả ban đầu nghĩ Go nhanh nhờ stack allocation, nhưng rồi nhận ra runtime của Go còn khác ở cách chọn thời điểm collect và chia sẻ công việc GC. Bước đầu tiên là hạ ngưỡng allocation tối thiểu 24 MB: throughput và p99 gần như không đổi, nhưng RSS giảm từ 98 MB xuống 38 MB. Nhóm thử cho ngưỡng bám theo live set nhưng thất bại vì các counter không đếm đủ object sống (partial page, page của mutator, large object), nên chỉ biến floor thành tùy chọn khi build. Với bài benchmark GcPause, pause tệ nhất từ 2,2–3,3 giây khi dùng một marker giảm còn 0,3–0,9 giây với bốn marker, còn Go khoảng 20 ms. Họ thêm mutator assistance: thread đang allocate khi chạy quá xa sẽ tự mark một batch object thay vì chờ.

Phần thứ hai là cache ảnh. Trước đây WeakReference của ParparVM giữ referent như field thường, nên thực chất là strong reference, còn iOS dùng một bảng strong reference và xóa sạch khi có memory warning. PR mới đưa referent vào Reference, translator phát sinh reference discovery thay vì trace như cạnh strong, và collector tự quyết định giữ hay xóa soft referent. Ca nguy hiểm là thread gọi get() trong lúc đang marking, có thể nhận về con trỏ đã bị giải phóng, nên các lần đọc được ghi log qua barrier snapshot-at-the-beginning; sau khi lọc các object đã mark, số lượt termination giảm từ 32 xuống 1. Mỗi lần get() cập nhật độ “mới”, giúp ảnh hay dùng sống lâu hơn: ở trần 160 MB, hit rate tăng từ 87,99% lên 97,44% mà footprint còn giảm từ 91 MB xuống 82,1 MB.

How We Built a Real-Time Stock Pricing System at Scale

Mofid, công ty môi giới chứng khoán lớn nhất Iran, cần hiển thị giá cổ phiếu và quỹ theo thời gian thực. Dữ liệu từ lõi sàn được đọc bằng C++ rồi đẩy vào Kafka dưới dạng Protobuf, khoảng 1.900 tin nhắn mỗi giây và có thể gấp ba vào ngày biến động. Để phân phối tiếp, nhóm chọn Redis Pub/Sub: giá là dữ liệu có thể thay thế, mất một bản cập nhật thì bản sau bù lại, nên ngữ nghĩa at-most-once là đủ. Redpanda Connect chuyển dữ liệu từ Kafka sang Redis mà không cần viết dịch vụ trung gian. Phía client, nhóm chọn SSE vì chỉ cần đẩy một chiều, chạy trên HTTP thông thường và trình duyệt hỗ trợ sẵn. Họ dùng gói eventsource để gửi header xác thực, gom cập nhật theo lô để giao diện không vẽ lại liên tục, đóng kết nối khi rời trang, và lấy snapshot giá qua API trước khi áp dụng luồng thay đổi.

Ở backend .NET, mỗi kênh Redis có một hosted service ghi vào System.Threading.Channels dạng bounded, sức chứa 100 nghìn tin và bỏ tin cũ nhất khi đầy. Một coordinator tạo kênh riêng cho từng người dùng và chỉ gửi dữ liệu họ cần theo trang đang xem. Nhóm triển khai dần qua feature flag Unleash, từ 30% lên 100% người dùng. Kết quả: sáu pod, mỗi pod khoảng 55 MB RAM và 20% CPU lúc cao điểm, phục vụ hàng chục nghìn kết nối đồng thời, hàng đợi đông nhất chỉ 15 tin. Bài học gồm tắt buffering ở proxy bằng X-Accel-Buffering: no, giải phóng kênh khi client ngắt kết nối để tránh rò rỉ bộ nhớ, và gửi heartbeat mỗi 20 giây để kết nối không bị đóng.

Saving another 100TB of RAM with math (and Rust)

Cloudflare giải phóng thêm hơn 100 TB RAM bằng cách thu nhỏ pingora-ketama, thư viện consistent hashing mà Pingora Backend Router dùng để định tuyến request theo URL. Vấn đề nằm ở các vòng băm: do quy định tuân thủ và tính năng cache, mỗi tổ hợp tính năng cần một vòng riêng, và có tiến trình tốn tới 6 GB bộ nhớ. Bước đầu là thu gọn từng điểm trên vòng. Struct cũ gồm hash: u32 và index: u32, tức 8 byte. Đổi index sang u16 không giúp gì vì quy tắc căn chỉnh của Rust vẫn đệm struct lên bội số của trường lớn nhất, nên nhóm chuyển sang mảng [u8; 6] kèm hàm đọc, giảm 25% bộ nhớ.

Bước thứ hai là dùng ít điểm hơn. Nhóm tính công thức chính xác cho độ lệch tải khi mỗi máy chủ có k điểm và thấy lợi ích giảm rất nhanh: với 100 máy chủ, 160 điểm mặc định của NGINX đã hạ độ lệch từ khoảng 99% xuống 8%, còn ở mức 100.000 điểm, 90.000 điểm cuối chỉ giảm thêm 0,7% sai số. Va chạm của băm 32 bit còn làm sai số tăng lại khi có quá nhiều điểm. Kết quả là giảm 90% số điểm mà sai số gần như không đổi. Vì đổi vòng sẽ làm mất phần lớn cache và dồn tải về origin, nhóm giữ cả hai vòng trong bộ nhớ, chọn vòng theo băm của từng request, mở rộng dần theo tỉ lệ traffic và theo nhóm trung tâm dữ liệu, luôn có đường quay lui. Bài học: ở quy mô lớn, vài byte nhân lên thành hàng trăm TB, và mặc định “hiển nhiên” cũng nên được kiểm tra lại bằng số liệu.

Postgres DELETE Can Actualy Scale

DELETE trong PostgreSQL thường bị xem là chậm, nên nhiều hệ thống hiệu năng cao chọn TRUNCATE hoặc xóa cả partition. Nhóm DBOS lại cần DELETE vì nó linh hoạt hơn: engine durable workflow của họ chạy hàng chục nghìn workflow và step mỗi giây, và phải xóa dữ liệu theo chính sách lưu giữ. Nguyên nhân khiến DELETE tốn kém nằm ở MVCC: dòng bị xóa không biến mất ngay mà chỉ được đánh dấu bằng ID của giao dịch xóa, để lại dead tuple chờ autovacuum dọn. Index không mang dấu xóa, nên mỗi lần quét index vẫn phải ghé bảng để biết dòng đã chết chưa.

Thiết kế ban đầu dùng khóa ngoại với ON DELETE CASCADE. Cách này giữ toàn vẹn dữ liệu nhưng các step của một workflow nằm rải rác theo thời gian, nên mỗi lần xóa lại chạm một trang khác, đến mức xóa workflow còn chậm hơn chạy chúng. Nhóm bỏ khóa ngoại, xóa bảng workflow trước, rồi xóa theo lô các bảng phụ theo đúng thứ tự tạo để tận dụng tính cục bộ của cache. Một anti-join NOT EXISTS đảm bảo chỉ xóa dữ liệu của workflow đã bị xóa, tránh đụng vào workflow chạy lâu. Vấn đề tiếp theo là truy vấn tìm mốc cắt trên index created_at có lúc mất khoảng 20 phút, vì khi autovacuum chậm, hàng triệu dead tuple dồn ở đầu index. Chạy VACUUM thủ công trước và sau mỗi đợt xóa đã khắc phục điều này. Kết hợp lại, tốc độ xóa nhanh hơn khoảng mười lần. Cái giá là ứng dụng phải tự giữ tính nhất quán giữa các bảng, nhưng đổi lại vẫn hỗ trợ được thời gian lưu giữ khác nhau cho từng loại workflow.

Where PostgreSQL stores row locks

PostgreSQL không có bảng trong bộ nhớ để giữ khóa dòng. Khóa được ghi thẳng vào header của tuple: trường t_xmax, vốn lưu giao dịch xóa, cũng lưu giao dịch đang khóa, còn các bit trong t_infomask cho biết chế độ khóa và t_xmax chỉ là khóa hay không. Vì vậy mọi lần khóa dòng đều là một lần ghi xuống trang dữ liệu. Khi commit, dấu khóa cũng không bị xóa: nó hết hiệu lực vì giao dịch đã kết thúc, rồi VACUUM hoặc người khóa tiếp theo ghi đè. Có bốn mức khóa. Kiểm tra khóa ngoại lấy FOR KEY SHARE, UPDATE không đổi cột khóa lấy FOR NO KEY UPDATE, còn DELETE và cập nhật cột khóa lấy FOR UPDATE, xung đột với mọi mức khác. Do đó dùng FOR UPDATE trên dòng cha sẽ chặn mọi lệnh chèn dòng con tham chiếu tới nó, và FOR NO KEY UPDATE thường là lựa chọn đúng hơn.

Phiên phải chờ sẽ chờ khóa transactionid của giao dịch đang giữ, và lấy thêm khóa tuple để xếp hàng. pg_locks chỉ thấy các mục chờ này chứ không thấy ai đang giữ dòng. Khi hai giao dịch cùng khóa một dòng, t_xmax lưu một MultiXactId, trỏ tới danh sách thành viên trong thư mục pg_multixact. Ví dụ trong bài: một giao dịch dài giữ khóa trên các dòng cha, trong khi 5.000 lệnh chèn tự commit tạo ra 5.000 multixact riêng. Mỗi thành viên tốn khoảng năm byte, giới hạn 2^32 thành viên tương đương khoảng 20 GB, và khi cạn sẽ gây lỗi multixact "members" limit exceeded. Cơ sở dữ liệu có thể chạm ngưỡng chống wraparound của multixact dù không có dead tuple. Nên theo dõi mxid_age(), relminmxid và next_multi_offset.

A 40ms Go GC pause caused by swap

Tác giả bật swap để hấp thụ những đợt tăng bộ nhớ đột ngột, và phát hiện bộ thu gom rác (GC) của Go có lúc dừng toàn bộ chương trình (stop-the-world) tới 40 ms, trong khi trung vị chỉ khoảng 51 µs, tức chậm hơn khoảng 800 lần. Hệ thống gồm một cgroup chứa hai tiến trình: một tiến trình Go đọc dữ liệu bằng io.ReadAll rồi giải mã bằng proto.Unmarshal, và một máy chủ HTTP gần như nhàn rỗi. Ban đầu tác giả cho rằng kernel thu hồi bộ nhớ theo từng cgroup nên hai tiến trình sẽ bị đẩy ra swap cùng lúc, khó xảy ra cảnh GC tranh chấp với swap. Giả định này hóa ra sai.

Dùng BPF để đếm page fault trong lúc chương trình bị dừng, tác giả thấy lần dừng tệ nhất kéo dài 39.902 µs, có 228 lỗi trang, và riêng các lỗi này đã chiếm 39.013 µs. Dịch địa chỉ bằng addr2line cho thấy chúng nằm trong runtime.(*spanSet).reset và runtime.nextMarkBitArenaEpoch, thuộc giai đoạn kết thúc sweep khi GC bắt đầu chu kỳ mới. Nguyên nhân là runtime Go giữ siêu dữ liệu quản lý GC bên ngoài heap, ở những vùng được tái sử dụng nhưng hiếm khi bị chạm tới. Kernel thu hồi trang theo tuổi nên đẩy chúng ra swap. Khi GC dừng mọi goroutine rồi đọc lại vùng này, mỗi lỗi trang phải chờ đọc đĩa, và vì không còn ai chạy nên độ trễ ảnh hưởng toàn cục. Việc cấp phát cũng chậm đi: dựng một thông điệp 511 KiB thường mất 3–5 ms, nhưng lên tới 105 ms trên NVMe và 903 ms trên ổ mạng. Bài học là swap không xấu, nhưng không hợp với GC trong tải kiểu này.

How to optimize when you can’t do anything!

Hettie Dombrovskaya kể lại một ca tối ưu truy vấn PostgreSQL mà mọi cách quen thuộc đều bị chặn. Bảng không phân vùng, nặng 750 GB với 16 tỉ dòng. Một truy vấn production đột ngột chậm đi, lúc vài giây, lúc vài phút. Truy vấn tìm các bản ghi có khoảng từ start_date đến end_date chứa một ngày cho trước cùng ba điều kiện bằng. Index GiST trên khoảng ngày là lựa chọn lý tưởng, nhưng xây trên 16 tỉ dòng sẽ mất hơn 24 giờ. Index tốt nhất hiện có là (a, start_date, end_date), nhưng a lại là cột kém chọn lọc nhất, còn với một ngày gần đây thì điều kiện trên start_date khớp gần như mọi dòng.

Tác giả thử thêm cận dưới cho start_date, nhưng khách hàng cần mọi bản ghi chứ không chỉ bản ghi gần đây. Lấy start_date nhỏ nhất theo cột a cũng không giúp vì giá trị đó có thể rất cũ. Partial index cho nhóm tổ hợp mà khách hàng liệt kê đưa thời gian xuống dưới 100 ms, nhưng chỉ cho nhóm đó. Chìa khóa nằm ở bảng tính của khách hàng: nó ghi ngày đầu tiên mỗi tổ hợp (a, b, c) xuất hiện. Tác giả nạp danh sách hơn 2.000 dòng này vào một bảng ánh xạ, rồi lấy ngày đó làm cận dưới chính xác cho start_date. Truy vấn chạy dưới 100 ms. Vì danh sách nằm ở cơ sở dữ liệu khác, bản cuối dùng foreign table chỉ đọc và vẫn nhanh. Bài học là khi không thể xây index mới, một bảng nhỏ đóng vai trò “index” có thể thay thế, và hiểu dữ liệu thật cùng mục đích của truy vấn là công cụ tối ưu mạnh nhất.

Four ways to do super fast packet processing in Go

Andree Toonk cho rằng xử lý gói tin trong Go là phần dễ, còn phần khó là đưa gói tin giữa card mạng và chương trình thật nhanh. Mỗi cơ chế có mô hình riêng, nên ông viết packetio, một thư viện Go gom bốn backend sau cùng một API. AF_PACKET, socket mà tcpdump dùng, dễ cài và chạy gần như mọi nơi nhưng gói tin đi qua phần lớn đường xử lý của kernel. AF_XDP chặn gói tin sớm trong driver và chuyển vào vùng nhớ dùng chung với ứng dụng, trong khi Linux vẫn quản lý card nên SSH hay ARP vẫn chạy. DPDK là framework userspace lâu đời nhưng khó học, còn mlx5 Direct Verbs cho phép ứng dụng nói chuyện trực tiếp với hàng đợi phần cứng của card NVIDIA ConnectX. Đổi backend chủ yếu chỉ là đổi lời gọi Open.

Bài đo dùng hai máy AMD EPYC với card ConnectX-6 Dx 100G và gói tin 64 byte, tức khoảng 148,8 triệu gói mỗi giây, CPU được đo trên toàn máy kể cả thời gian softirq của kernel. Ở chiều nhận, AF_PACKET chỉ đạt 2,3 triệu gói mỗi giây dù tốn 52,8 lõi, AF_XDP đạt 146,5 triệu với 11,4 lõi, Direct Verbs và DPDK đạt trọn 148,6 triệu với 10 và 8 lõi. Ở chiều gửi, DPDK chỉ cần 3 lõi, còn AF_XDP cần 20 lõi. Khi chuyển tiếp gói tin, Direct Verbs dẫn đầu với 147,4 triệu gói mỗi giây trên 12 lõi. Đáng chú ý, AF_XDP tốn nhiều CPU ở softirq, dễ bỏ sót nếu chỉ đo tiến trình Go. Tác giả khuyên dùng Direct Verbs với card ConnectX, AF_XDP cho phần cứng Linux thông thường, DPDK khi cần hệ sinh thái driver rộng, và AF_PACKET chỉ để quan sát gói tin.

How Uber Protects Against Retry Storms

Ở Uber, retry được áp dụng đồng loạt: dịch vụ chỉ kiểm soát được số lần thử lại chứ không biết khi nào nên thử, vì khó phân biệt lỗi do chính dịch vụ gây ra hay chỉ được chuyển tiếp từ bên dưới. Với lỗi thoáng qua thì không sao, nhưng khi một dịch vụ quá tải, retry lại dồn thêm tải lên nó và nhân lên qua từng tầng gọi. Trong chuỗi bảy dịch vụ, mỗi tầng thử lại một lần, tải ở tầng sâu nhất gấp tám lần ban đầu. Retry budget 10% giảm con số này xuống khoảng 1,33 lần, nhưng vẫn không thấy được hiệu ứng khuếch đại giữa các dịch vụ. Retry chỉ có ích khi lỗi độc lập và ngẫu nhiên.

Giải pháp của Uber là “quyền sở hữu lỗi”. Nếu lỗi của một dịch vụ bắt nguồn từ lời gọi xuống dưới bị hỏng thì đó chỉ là triệu chứng, còn dịch vụ tự gây lỗi mới là nơi sở hữu lỗi. Thành phần Service Dependency Analysis đối chiếu lỗi vào với lỗi ra để gắn header x-uber-error-claim. Chỉ cạnh gọi tới nơi sở hữu lỗi được thử lại, và khi chuyển lỗi lên trên, claim bị gỡ bỏ để các tầng trên không thử lại nữa. Cơ chế này nằm trong middleware retry dùng chung, nên các dịch vụ được bảo vệ mà không phải viết thêm mã. Trong sự cố ngày 18/11/2025, một dịch vụ lõi nằm sâu hơn năm tầng trả lỗi hàng loạt. Nếu chỉ có retry budget, lưu lượng tới nó sẽ tăng 46–135%, nhưng hệ thống đã chặn được 9,5 triệu request thừa. Bán kính tối đa của retry storm giảm từ 25 tầng xuống 3, trung bình từ 20 xuống 2.

The Real Cost of a Single Insert in Postgres

Bài viết đo xem một lệnh INSERT trong PostgreSQL 16 thực sự ghi bao nhiêu byte, so với dữ liệu gửi đi, gọi là hệ số khuếch đại ghi. Bảng thử nghiệm có ba index, mỗi dòng khoảng 40 byte. Riêng phần lưu trữ, mỗi tuple mang header 23 byte để phục vụ MVCC, được đệm lên 24 byte, cộng thêm 4 byte con trỏ dòng trong trang, nên một dòng chiếm 68 byte, gấp 1,7 lần dữ liệu thật. Mọi index đều được cập nhật đồng bộ trong cùng giao dịch: ba index nghĩa là ba lần duyệt B-tree và ba trang lá bị sửa. Khóa thời gian tăng dần dồn mọi lệnh chèn vào trang lá ngoài cùng bên phải, còn index có nhiều giá trị khác nhau rải lệnh chèn khắp cây, khiến trang cần dùng ít khi có sẵn trong cache. Mỗi lần chèn vào heap và vào từng index đều sinh một bản ghi WAL riêng, tức bốn bản ghi mỗi dòng.

Thí nghiệm chèn 100.000 dòng và đo bằng pg_current_wal_lsn() cùng pg_stat_wal. Ngay sau checkpoint, hệ số là 10,74 vì trang đầu tiên bị sửa phải ghi nguyên trang vào WAL. Khi không có checkpoint, hệ số là 8,73, còn khi bỏ hết index chỉ còn 2,44, nghĩa là khoảng 70% WAL đến từ việc duy trì index. Ở trạng thái ổn định, mỗi dòng 40 byte tốn khoảng 345 byte WAL, tức 17 MB/s log cho 50.000 lệnh chèn mỗi giây. Tác giả gợi ý theo thứ tự rủi ro tăng dần: gom dòng bằng COPY hoặc INSERT nhiều dòng, xóa index không dùng (xem idx_scan), bật wal_compression, rồi tăng max_wal_size và checkpoint_timeout. Cuối bài, Tiger Data giới thiệu engine lưu trữ dạng cột của họ.

Made by miti99 with ❤️
Built with Hugo
Theme Stack thiết kế bởi Jimmy