1 điểm bởi GN⁺ 1 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • Khóa độc quyền toàn cục của Postgres LISTEN/NOTIFY giới hạn thông lượng của cách triển khai đơn giản, nhưng nếu đệm thông báo và gửi theo lô, một máy chủ đơn lẻ có thể xử lý tối đa 60.000 lượt ghi stream mỗi giây
  • Giao dịch gọi NOTIFY giữ khóa toàn cục cho đến khi commit và fsync() hoàn tất để bảo đảm thứ tự commit của thông báo; điều này khiến commit bị tuần tự hóa và cũng không tận dụng được group commit
  • Cách triển khai ban đầu gọi NOTIFY bằng trigger cho mỗi lượt ghi vào bảng stream mang lại độ trễ thấp, nhưng bị nghẽn ở mức 2.900 lượt/giây dù chưa sử dụng hết CPU, bộ nhớ và IOPS
  • Nếu lấy bảng cơ sở dữ liệu chứ không phải thông báo làm nguồn sự thật, rồi định kỳ gửi các thông báo đã gom trong bộ nhớ trong một giao dịch duy nhất, có thể giảm đáng kể số lần lấy khóa
  • Khả năng mất thông báo trong bộ đệm khi tiến trình gặp lỗi được bù bằng polling tần suất thấp; ngay cả trong môi trường đọc đồng thời, cách này đạt độ trễ 15~100ms và thông lượng gấp 20 lần so với trước

Stream độ trễ thấp được triển khai bằng Postgres

  • Stream dựa trên Postgres lưu mỗi mảnh stream dưới dạng một hàng mới trong bảng streams; token phản hồi của LLM cũng có thể là một mảnh
  • Ở phía đọc, khó chờ hiệu quả chỉ bằng truy vấn đơn giản vì không biết khi nào mảnh tiếp theo sẽ đến
  • Polling định kỳ khiến độ trễ tăng trong các use case tương tác như chat trực tuyến nếu khoảng cách dài, còn nếu ngắn thì nhiều poller đồng thời sẽ làm quá tải cơ sở dữ liệu
  • Dùng LISTEN/NOTIFY cho phép tiến trình đọc chờ ở trạng thái bị chặn, rồi thức dậy ngay khi có thông báo rằng mảnh mới đã được ghi, nhờ đó tránh polling không cần thiết

Điểm nghẽn của NOTIFY gửi cho mỗi lượt ghi

  • Trong cách triển khai ban đầu, mỗi khi một mảnh mới được ghi vào bảng streams, trigger chạy một hàm để gửi một NOTIFY, còn tiến trình đọc chờ thông báo rồi đọc mảnh mới
  • Đã bảo đảm được tính đúng đắn và độ trễ thấp, nhưng ngay cả với cơ sở dữ liệu Postgres lớn cũng không thể duy trì quá 2.900 lượt ghi stream mỗi giây
  • Trong khi xảy ra nghẽn, mức dùng CPU, bộ nhớ và IOPS không tăng đáng kể; nguyên nhân là khóa toàn cục trên đường commit của NOTIFY

Khóa toàn cục bảo đảm thứ tự commit

  • Giao dịch gọi NOTIFY lấy khóa độc quyền toàn cục khi bắt đầu commit và không nhả cho đến khi đã commit hoàn toàn, nội dung được ghi xuống đĩa bằng fsync()
  • Postgres bảo đảm thông báo được chuyển đi theo đúng thứ tự commit của giao dịch, và lưu mọi thông báo gửi đi trong một hàng đợi nội bộ toàn cục phải khớp chính xác với thứ tự commit
  • Việc thêm thông báo vào hàng đợi cũng phải được xử lý theo kiểu giao dịch như một phần của commit, nhưng thời gian commit của mỗi giao dịch khác nhau nên không thể xác định thứ tự trước khi commit hoàn tất
  • Khóa toàn cục tuần tự hóa commit của các giao dịch có thông báo, xác định trước thứ tự và bảo đảm chúng cũng được thêm vào hàng đợi thông báo nội bộ theo cùng thứ tự

Cách tuần tự hóa commit giới hạn thông lượng

  • Vì mọi lượt ghi stream đều gọi NOTIFY thông qua trigger, mỗi giao dịch ghi giữ khóa toàn cục trong suốt quá trình commit và flush xuống đĩa
  • Khi các giao dịch commit lần lượt, không thể tận dụng group commit của Postgres, vốn xử lý nhiều giao dịch bằng một lần fsync()
  • Thông lượng không thể vượt quá tốc độ Postgres commit từng giao dịch riêng lẻ, và do các tác vụ chờ khóa nên CPU và đĩa cũng không được sử dụng hết
  • Bản vá sẽ có trong Postgres 19 không loại bỏ khóa toàn cục, nên không giải quyết được điểm nghẽn này
    • Thay vào đó, nó tối ưu cho trường hợp hạn chế có nhiều kênh thông báo và mỗi listener chỉ chờ một kênh cụ thể

Đệm thông báo và gửi theo lô

  • Trong nhiều use case LISTEN/NOTIFY, bao gồm stream, thông báo không phải là nguồn sự thật mà chỉ là tín hiệu yêu cầu kiểm tra bảng nơi dữ liệu thực được lưu
  • Với cấu trúc này, bản thân thông báo không cần có thứ tự toàn cục hoàn hảo hay độ bền hoàn toàn, nên có thể đệm trong bộ nhớ rồi định kỳ gửi trong một giao dịch theo lô duy nhất
  • Khóa toàn cục được lấy chỉ khi làm rỗng bộ đệm, chứ không phải cho từng lượt ghi stream riêng lẻ
  • Từng lượt ghi được tách khỏi việc gửi thông báo nền nên diễn ra nhanh, đồng thời có thể tận dụng các tối ưu của Postgres như group commit để tăng thông lượng

Polling tần suất thấp để bù cho thông báo bị mất

  • Nếu tiến trình dừng khi thông báo vẫn còn trong bộ nhớ, thông báo đó có thể không được chuyển đi
  • Tiến trình đọc vừa chờ thông báo, vừa định kỳ truy vấn cơ sở dữ liệu để kiểm tra xem có dữ liệu stream được ghi mà không có thông báo hay không
  • Polling này chỉ là phương tiện phụ để khôi phục các thông báo bị mất, nên có thể chạy với tần suất thấp và tác động đến hiệu năng cũng không lớn

Thông lượng và độ trễ

  • Cách triển khai tối ưu xử lý tối đa 60.000 lượt ghi stream mỗi giây trong môi trường có các tiến trình đọc đồng thời, đạt thông lượng cao gấp 20 lần so với cách triển khai ban đầu
  • Ngay cả khi tăng thông lượng, độ trễ vẫn duy trì trong khoảng 15~100ms
  • Ở thông lượng tối đa, CPU của Postgres được sử dụng hoàn toàn, cho thấy hệ thống đã đạt trạng thái bão hòa thực sự của chính cơ sở dữ liệu chứ không phải do tranh chấp khóa
  • Toàn bộ mã benchmark có tại dbos-postgres-benchmark

1 bình luận

 
Ý kiến Hacker News
  • Khả năng mở rộng là một phổ liên tục, không phải nhị phân. 60 nghìn sự kiện mỗi giây có thể nhiều hơn nhu cầu của hệ thống này 100 nghìn lần, nhưng cũng có thể ít hơn nhu cầu của hệ thống khác 100 nghìn lần. Một sai lầm phổ biến của lập trình viên mà tôi muốn chỉ ra còn hơn cả “tối ưu hóa quá sớm” là chọn công nghệ có đặc tính mở rộng không phù hợp
    Công nghệ quá nhỏ sẽ thất bại rõ ràng khi vượt giới hạn, nhưng công nghệ có khả năng mở rộng quá mức cũng mang theo gánh nặng vận hành và các ràng buộc. Đưa loại công nghệ như vậy vào một hệ thống nhỏ, nơi một mô hình phong phú hơn có thể giảm đáng kể công sức phát triển, cũng là một lựa chọn tệ
    Giới hạn của LISTEN/NOTIFY đủ thấp để cần phải cẩn trọng, nên tốt nhất là sau khi tính cả tải tối đa theo hướng bi quan thì vẫn nên chừa ít nhất 10 lần dư địa, nhưng với nhiều dự án thì như vậy đã đủ. Nhờ ưu điểm tích hợp với cơ sở dữ liệu, tính sẵn sàng và không cần vận hành dịch vụ riêng, đây không phải lựa chọn nên loại bỏ ngay từ đầu; ngay cả con số 2 nghìn sự kiện mỗi giây được nêu trước đó cũng là rất lớn đối với hệ thống xử lý một thông điệp trong khoảng một giây

    • Đây là phản biện nhỏ với một bài viết hay, nhưng gần như không có hệ thống nào xử lý 6 tỷ request mỗi giây, và nếu có thì nhiều khả năng họ sẽ dùng công cụ tự xây phù hợp với mục đích
    • Việc dự đoán dưới 60 nghìn sự kiện mỗi giây rồi đột ngột tăng từ 20 nghìn lên 200 nghìn vẫn là vấn đề tốt hơn so với xây hệ thống nhắm tới 1 triệu sự kiện mỗi giây nhưng tải thực tế chỉ là 20 nghìn. Thành công bất ngờ ở trường hợp đầu sẽ giúp chi trả cho biện pháp tạm thời và chi phí mở rộng, còn trường hợp sau sẽ bị trói vào cấu trúc chi phí cao và đầu tư sớm
      Tốt hơn là thiết kế với một mức dư địa hợp lý dựa trên quy mô dự kiến thực tế, và chỉ chọn cao hơn khi khả năng mở rộng bổ sung gần như miễn phí. Nếu có thể mua phần cứng lớn hơn với vài nghìn đô la, hoặc nếu các lựa chọn là tương đương ngoài yếu tố mở rộng, thì cứ chọn phương án lớn hơn
    • Trong trường hợp này, nó đạt tới giới hạn phần cứng tối đa, nên có thể xem là mở rộng được. Nút thắt không nằm ở cơ sở dữ liệu hay I/O mà ở phần cứng, và ở mức thông lượng tối đa, CPU của Postgres được dùng hết hoàn toàn, cho thấy bản thân cơ sở dữ liệu đã bão hòa chứ không phải bị tranh chấp
  • Tôi đã có thành công lớn khi kết hợp LISTEN/NOTIFY với broker subscription GraphQL bằng Rust. Có hàng chục nghìn subscription, nhưng tổng số kết nối LISTEN chỉ là 3~4, mỗi host một kết nối
    Mọi thay đổi được gửi tới từng host, và host quản lý subscription thực của người dùng rồi quyết định sẽ publish gì. Nếu thay hàng trăm host Ruby hoặc Node bằng vài host Rust thì có thể đơn giản hóa kiến trúc rất nhiều, và cả những cách từng bị cho là không mở rộng được cũng hoạt động khá tốt

    • Tôi tò mò không biết có thư viện GraphQL nào đáng khuyến nghị cho Rust không
  • Khi còn làm CTO, chúng tôi xử lý khoảng 100 nghìn sự kiện mỗi ngày trên tất cả dịch vụ, rồi tăng lên hàng triệu, cuối cùng là hàng chục triệu. Trong quá trình đó, một kỹ sư đã xây hàng đợi dựa trên ngữ nghĩa của LISTEN/NOTIFY để tận dụng tính nhất quán mạnh với mô hình dữ liệu. Nó không quá khó hiểu, và thời điểm đó việc loại bỏ thêm một tầng lưu trữ/truyền tải riêng cũng có vẻ hợp lý
    Nhưng khi mở rộng tính năng tự xây này, chúng tôi phải lách qua cách PostgreSQL hoạt động bên trong nên cực kỳ bất tiện, và đáng lẽ nên chuyển sang hệ thống khác sớm hơn. Khả năng mở rộng cũng không tốt, khiến trên RDS xuất hiện tranh chấp đĩa nghiêm trọng khó truy nguyên, còn VACUUM trên bảng đó thì là ác mộng. Vì một hàng đợi quen thuộc lại được triển khai theo cách lạ lẫm bằng tính năng nội bộ của PostgreSQL, các kỹ sư khác sợ đụng vào nó và ngại debug cũng như nhận ownership
    Bỏ qua các chi tiết như schema và index, bài học cốt lõi luôn là hãy bắt đầu với công nghệ đơn giản và dễ dự đoán. Nếu không thật sự cần tính nhất quán dữ liệu cực mạnh, thì dù hạ tầng có thêm một thành phần, vẫn tốt hơn khi dùng hàng đợi đơn giản theo hợp đồng API như SQS hoặc Redis queue rồi để phần còn lại thích nghi theo đó. Sẽ tốt hơn nếu kho dữ liệu cốt lõi duy nhất phải gánh ít trách nhiệm cơ học nhất có thể

    • Cuối cùng thì có thể tóm lại là “nếu dùng mà không hiểu cách nó hoạt động thì sẽ không mở rộng được”. LISTEN/NOTIFY cơ bản vốn không mở rộng được, nhưng bài gốc thực sự đã tìm ra cách để mở rộng nó, nên mỗi team không cần tự giải lại bài toán này
      Khi hệ sinh thái hệ phân tán phát triển, chúng ta hiểu được từng thành phần có thể làm gì. Bắt đầu với ít thành phần hơn rồi chỉ thêm khi thực sự cần sẽ tạo ra một hệ thống lành mạnh hơn
    • Thành phần mới hiếm khi lấn át mọi yếu tố khác, nhưng việc vận hành hàng đợi độc lập với các thành phần kiến trúc khác có lợi thế rất lớn. Hàng đợi vốn được tạo ra để lưu rồi chuyển tiếp, nên nếu tách vòng đời của nó ra thì trong lúc cập nhật, phân tích sự cố hoặc vá lỗi, các hệ thống khác cũng có thể được tách biệt với nhau
    • Điều này gần với vấn đề quản lý hơn, và tôi không đồng ý với kết luận rằng cần thêm thành phần mới vào hạ tầng. Thêm một node mạng mới chỉ vì lập trình viên không muốn đụng vào một phần của stack là không hợp lý; cứ giao cho họ xử lý phần đó là được
  • Tôi vẫn rất thích DBOS, thứ tận dụng tốt Postgres và giờ cả SQLite. Có thể đưa nó vào cả stack CRUD hiện có mà gần như không tốn công
    Một khi bắt đầu dùng workflow bền vững, bạn sẽ liên tục thấy thêm nơi để áp dụng. Gần đây tôi còn đang thử coi từng email là một workflow bền vững, nơi người dùng, đối phương, agent và các công cụ như GitHub hay Attio lần lượt tham gia vào luồng đó
    https://housecat.com/blog/gmail-durable-workflows-sandbox-vm

  • Những bài như thế này phần lớn là kết quả của việc mỗi người tự đánh giá độc lập vấn đề, cách hiểu và lời giải của mình. Chỉ vì ai đó kỳ vọng một mức hiệu năng nhất định từ cấu hình mặc định của công cụ mà gọi là thiếu chuyên môn thì cũng hơi khó nói, và ai cũng tiếp tục học qua thất bại
    Việc dùng máy chủ cơ sở dữ liệu 96 lõi·384GB RAM cho thử nghiệm (https://github.com/dbos-inc/dbos-postgres-benchmark/blob/mai...) là cực kỳ quan trọng và lẽ ra phải được nêu rõ. Cơ sở dữ liệu có thể mở rộng theo chiều dọc, nhưng điều đó cũng có giới hạn. Kết nối đến từ đâu cũng ảnh hưởng tới hiệu năng và độ trễ tổng thể
    60 nghìn sự kiện mỗi giây có thể trông là lớn, nhưng thứ thực sự làm sập hệ thống thực tế là các đợt tăng đột biến tức thời chứ không phải lưu lượng thông thường. Nếu không phải doanh nghiệp lớn thì bạn sẽ không bắt đầu với kiểu máy chủ khổng lồ như vậy. Nếu tính cả read replica và dự phòng đa vùng, chỉ riêng một cụm cơ sở dữ liệu production cũng đã tốn hơn 100 nghìn đô la

  • Có vẻ là bài liên quan: Postgres LISTEN/NOTIFY does not scale - https://news.ycombinator.com/item?id=44490510 - tháng 7 năm 2025, 321 bình luận

  • Có vẻ bài viết đã bỏ qua phần quan trọng nhất: cách gán offset hoặc số thứ tự để theo dõi người tiêu thụ đã đọc đến đâu và cho phép truy vấn các tin nhắn mới qua đường thay thế. Có nhiều cách, nhưng không dễ giải quyết mà không tăng độ phức tạp hoặc tranh chấp khóa, và nếu triển khai sai còn có thể tạo race condition với consumer. Thông thường tác giả sẽ khóa một hàng duy nhất trong bảng trạng thái hoặc tương tự để gán số tiếp theo cho chủ đề sự kiện
    Tôi tò mò đâu là cách tốt nhất. Một công cụ đọc change data capture (CDC) rồi ghi số sự kiện đã được gán vào bảng khác có thể ổn, nhưng độ trễ có thể tăng lên và bộ xử lý CDC đó cũng phải thực hiện NOTIFY
    Ở phía consumer, cũng có những trường hợp xử lý theo lô có thể tăng mạnh thông lượng. Khi đó có thể chạy lặp consumer mà không cần LISTEN/NOTIFY, mỗi lần xử lý toàn bộ các tin nhắn mới chưa được xử lý, rồi lưu số thứ tự cuối cùng giữa các lần lặp

  • Theo tôi nhớ, ở bản phát hành đầu tiên hỗ trợ LISTEN/NOTIFY, việc triển khai khóa chưa tốt nên có vấn đề về hiệu năng. Bài viết cũ bị chỉ trích ở đây cũng đã đính chính điều đó ngay sau đoạn đầu tiên
    Nếu ngày đính chính là 8 tháng 5, thì với bài viết ngày 24 tháng 7 cần thừa nhận rằng bài nổi tiếng nói tính năng này không mở rộng được có thể không hề được viết với ác ý, và cũng có thể không sai theo bối cảnh thời điểm đó

    • Nếu ý nói tới tối ưu hóa sẽ có trong Postgres 19 thì bài gốc cũng có đề cập. Bản vá đó (https://github.com/postgres/postgres/commit/282b1cde9dedf456...) không loại bỏ khóa toàn cục cũng không giải quyết nút thắt cổ chai đã được quan sát
      Thay vào đó, nó tối ưu cho trường hợp hạn chế hơn, khi có nhiều kênh thông báo và mỗi bên nhận chỉ chờ một kênh cụ thể
  • Lần cuối tôi kiểm tra, LISTEN/NOTIFY có giới hạn 8.000 byte cho dữ liệu thông báo, nên rõ ràng có một khía cạnh không thể mở rộng. Nếu dữ liệu không thể được lưu thành hàng và chỉ truyền ID thông báo, thì sẽ khó sử dụng
    Sự kiện trong web game là dữ liệu tạm thời mô tả thay đổi trạng thái, nên không có lý do để lưu vào cơ sở dữ liệu, và cũng có thể vượt quá 8.000 byte, vì vậy không phù hợp cho mục đích này

    • Nếu triển khai một hệ thống thông báo có thể mở rộng, tôi sẽ đặt giới hạn kích thước thông báo. Giữ kích thước tin nhắn ở mức O(1) để có thể tập trung vào việc mở rộng số lượng thông báo; các tin nhắn lớn tùy ý có thể làm hiệu năng chững lại và cũng cho thấy có thể đang dùng sai hệ thống thông báo
    • Tôi tự hỏi liệu có thể gửi một thông điệp tham chiếu tới trạng thái đã thay đổi, ngay cả khi nó không trỏ tới một hàng cụ thể hay không
  • Bài viết có đề cập tranh chấp khóa của hàng đợi toàn cục, nhưng dường như không nhắc đến một vấn đề khác của hàng đợi toàn cục kích thước cố định. Một bên nhận chậm trên một kênh có thể chặn ghi trên mọi kênh. Ít nhất vài năm trước kiểu sự cố đó là có thể xảy ra; giờ có thể đã thay đổi